jguillaumesio
devtoolsai

Résumés de diff de branche git propulsés par IA

Comment générer un résumé intelligent des diffs de branche, mettant en avant les fichiers supprimés et renommés pour que les agents IA aient le contexte pour relire, refactoriser ou documenter.

Diff git brut contre résumé de branche adapté à l'IA

Vous demandez à un agent IA de relire votre branche. Il lit le diff. Il rate le fait que vous avez supprimé trois fichiers, refactorisé un module central et changé le schéma de la base. Il vous donne un retour sur les 50 lignes de changements visibles et ignore les mouvements structurels.

Le problème : un diff git brut est un format de contexte épouvantable pour les agents IA. Il montre les changements de lignes mais enterre les informations structurelles importantes.

Ce dont les agents IA ont besoin d’un diff de branche

Quand une IA relit une branche, elle a besoin de savoir :

  1. Quels fichiers ont été supprimés et quel était leur rôle
  2. Quels fichiers ont été renommés la réorganisation structurelle compte
  3. Quels modules ont changé pas seulement le nombre de lignes
  4. Quelle était l’intention messages de commit et description de la PR
  5. Quel est le rayon d’impact quels autres fichiers dépendent de ceux qui ont changé ?

Un git diff main...feature brut ne vous donne aucun de ces contextes. Ce ne sont que des lignes.

Construire un résumeur de diff intelligent

Voici un script qui génère un résumé de branche adapté à l’IA :

#!/usr/bin/env python3
"""branch-summary.py — Génère un résumé d'un diff de branche adapté à l'IA."""

import subprocess
import json
import sys
from dataclasses import dataclass, field
from typing import Optional


@dataclass
class FileChange:
    path: str
    status: str          # added, modified, deleted, renamed
    additions: int = 0
    deletions: int = 0
    old_path: Optional[str] = None  # pour les renommages
    summary: str = ""


@dataclass
class BranchSummary:
    branch: str
    base: str
    commits: list[str] = field(default_factory=list)
    files: list[FileChange] = field(default_factory=list)
    total_additions: int = 0
    total_deletions: int = 0
    deleted_files: list[FileChange] = field(default_factory=list)
    renamed_files: list[FileChange] = field(default_factory=list)
    new_files: list[FileChange] = field(default_factory=list)
    modified_files: list[FileChange] = field(default_factory=list)


def run_git(*args) -> str:
    result = subprocess.run(
        ["git", *args],
        capture_output=True, text=True, check=True
    )
    return result.stdout.strip()


def get_summary(branch: str, base: str = "main") -> BranchSummary:
    summary = BranchSummary(branch=branch, base=base)

    # Récupérer les commits de cette branche (absents de base)
    log = run_git("log", f"{base}..{branch}", "--oneline", "--reverse")
    summary.commits = [line.strip() for line in log.split("\n") if line.strip()]

    # Récupérer les stats de fichiers
    diff_stat = run_git("diff", f"{base}...{branch}", "--numstat")
    for line in diff_stat.split("\n"):
        if not line.strip():
            continue
        parts = line.split("\t")
        if len(parts) >= 3:
            additions = int(parts[0]) if parts[0] != "-" else 0
            deletions = int(parts[1]) if parts[1] != "-" else 0
            path = parts[2]

            # Déterminer le statut
            status_check = run_git("diff", f"{base}...{branch", "--name-status", "--", path)
            status = "modified"
            old_path = None

            if status_check.startswith("A"):
                status = "added"
            elif status_check.startswith("D"):
                status = "deleted"
            elif status_check.startswith("R"):
                status = "renamed"
                old_path = status_check.split("\t")[1] if "\t" in status_check else None

            fc = FileChange(
                path=path, status=status,
                additions=additions, deletions=deletions,
                old_path=old_path,
            )
            summary.files.append(fc)
            summary.total_additions += additions
            summary.total_deletions += deletions

            if status == "deleted":
                summary.deleted_files.append(fc)
            elif status == "renamed":
                summary.renamed_files.append(fc)
            elif status == "added":
                summary.new_files.append(fc)
            else:
                summary.modified_files.append(fc)

    return summary


def format_for_ai(summary: BranchSummary) -> str:
    """Formate le résumé en un bloc de contexte adapté à un prompt."""
    lines = [
        f"## Branch: {summary.branch} (vs {summary.base})",
        f"",
        f"**Stats:** +{summary.total_additions}/-{summary.total_deletions} lines across {len(summary.files)} files",
        f"**Commits:** {len(summary.commits)}",
        f"",
    ]

    # Commits
    lines.append("### Commits")
    for commit in summary.commits:
        lines.append(f"- {commit}")
    lines.append("")

    # Fichiers supprimés (contexte critique !)
    if summary.deleted_files:
        lines.append("### ⚠️ Deleted Files")
        lines.append("These files were removed. Consider what depended on them:")
        for f in summary.deleted_files:
            lines.append(f"- `{f.path}` ({f.deletions} lines removed)")
        lines.append("")

    # Fichiers renommés
    if summary.renamed_files:
        lines.append("### 🔄 Renamed Files")
        for f in summary.renamed_files:
            lines.append(f"- `{f.old_path}` → `{f.path}`")
        lines.append("")

    # Nouveaux fichiers
    if summary.new_files:
        lines.append("### ✨ New Files")
        for f in summary.new_files:
            lines.append(f"- `{f.path}` (+{f.additions} lines)")
        lines.append("")

    # Fichiers modifiés groupés par répertoire
    if summary.modified_files:
        lines.append("### 📝 Modified Files")
        by_dir: dict[str, list[FileChange]] = {}
        for f in summary.modified_files:
            dir_path = "/".join(f.path.split("/")[:-1]) or "(root)"
            by_dir.setdefault(dir_path, []).append(f)

        for dir_path, files in sorted(by_dir.items()):
            lines.append(f"**{dir_path}/**")
            for f in sorted(files, key=lambda x: -(x.additions + x.deletions)):
                lines.append(f"  - `{f.path.split('/')[-1]}` (+{f.additions}/-{f.deletions})")
        lines.append("")

    return "\n".join(lines)


if __name__ == "__main__":
    branch = sys.argv[1] if len(sys.argv) > 1 else "HEAD"
    base = sys.argv[2] if len(sys.argv) > 2 else "main"

    summary = get_summary(branch, base)
    print(format_for_ai(summary))

Utilisation

# Résumer la branche courante contre main
python branch-summary.py HEAD main

# Résumer une branche précise
python branch-summary.py feature/user-auth develop

# L'envoyer directement à une IA
python branch-summary.py HEAD main | \
  llm "Review this branch for potential issues and suggest improvements"

Exemple de sortie

## Branch: feature/user-auth (vs main)

**Stats:** +347/-182 lines across 12 files
**Commits:** 5

### Commits
- a1b2c3d Add password reset flow
- d4e5f6a Refactor auth middleware
- g7h8i9j Add user session model
- j0k1l2m Remove legacy cookie auth
- m3n4o5p Update API routes for new auth

### ⚠️ Deleted Files
These files were removed. Consider what depended on them:
- `src/middleware/cookie-auth.ts` (89 lines removed)
- `src/utils/token-legacy.ts` (34 lines removed)

### 🔄 Renamed Files
- `src/auth/handler.ts``src/auth/oauth-handler.ts`

### ✨ New Files
- `src/auth/password-reset.ts` (+124 lines)
- `src/models/session.ts` (+67 lines)

### 📝 Modified Files
**src/auth/**
  - `middleware.ts` (+45/-23)
  - `oauth-handler.ts` (+30/-56)
**src/routes/**
  - `api.ts` (+28/-12)
**src/models/**
  - `user.ts` (+15/-8)

Pourquoi ça compte pour le contexte de l’IA

Comparez ce que voit l’IA :

Diff brut (ce que la plupart des gens collent) :

-const cookieAuth = require('./cookie-auth');
+const oauthAuth = require('./oauth-auth');
@@ -45,12 +45,8 @@
-  legacyTokenCheck(req);
+  sessionCheck(req);

Résumé structuré (ce dont l’IA a vraiment besoin) :

  • “Cette branche remplace l’authentification par cookie par OAuth + sessions”
  • “Deux fichiers liés à l’authentification ont été entièrement supprimés”
  • “Le handler d’authentification a été renommé pour refléter son nouveau but”
  • “La réinitialisation de mot de passe est une nouvelle fonctionnalité (+124 lignes)”

Le résumé structuré permet à l’IA de raisonner sur ce qui a changé et pourquoi, pas juste sur quelles lignes ont bougé.

Intégration avec les agents IA

Ajoutez ceci à l’étape de pré-lecture de votre agent :

# Dans la préparation du contexte de votre agent
def prepare_branch_context(branch: str, base: str = "main") -> str:
    """Génère le contexte pour une relecture IA d'une branche."""
    result = subprocess.run(
        ["python", "scripts/branch-summary.py", branch, base],
        capture_output=True, text=True
    )
    return result.stdout

# Prompt de l'agent
context = prepare_branch_context("feature/user-auth")
response = ai.complete(f"""
{context}

Based on this branch summary:
1. What are the potential risks?
2. What should be tested?
3. Are there any files that need updating but weren't changed?
""")

Où ça s’inscrit

Ce script est un palliatif, pas une plateforme. Il marche bien pour les dépôts solo et les petites équipes où vous pouvez le glisser dans la CI ou un hook de pré-relecture. Une fois que vous faites tourner des agents sur une plus grande base de code sur plusieurs sessions, vous rencontrez un autre problème : les agents n’ont pas seulement besoin du diff de cette branche, ils doivent aussi se souvenir des décisions des sessions précédentes. C’est un problème distinct, la structure bat le texte brut dans les deux cas, mais le correctif ressemble plus à une base de connaissances persistante qu’à un script ponctuel.

Si vous adaptez ça pour votre propre dépôt, les parties les plus susceptibles d’avoir besoin de réglages sont les heuristiques de classification de fichiers dans get_summary et le seuil de détection des fichiers renommés, les deux dépendent de la façon dont votre équipe structure ses commits et de l’agressivité avec laquelle votre linter reformate les fichiers.