# Git Workflow Rules

## Branch Naming

```
type/name-short-descriptif
```
Examples: `feature/notebook-transformers`, `fix/ml-example-bug`, `docs/improve-readme`

## Commit Messages

```
Type: description courte de la modification
```
Examples: `Add: notebook sur les Transformers`, `Fix: correction d'erreurs dans l'exemple ML.NET`

### Safe reference syntax (prevent premature issue auto-close)

GitHub auto-closes issues on `Refs #N`, `Fixes #N`, `Closes #N`. Use safe syntax:

| Intent | Correct | Wrong |
|--------|---------|-------|
| Link without closing | `See #N` or `Part of #N` | ~~`Refs #N`~~ |
| Close when ALL criteria met | `Closes #N` (verify acceptance first) | ~~`Fixes #N`~~ for partial |
| Partial delivery | `See #N` (partial: X/Y criteria) | ~~`Refs #N`~~ |

**Incident**: 3 issues (#1943, #2048, #2158) auto-closed by GitHub on partial PRs using `Refs #N`. See #2211.

## Safety Rules

### Force push — interdit sur `main`, autorisé sur une branche de PR à lane unique

**Décision user 2026-08-08** : périmètre, pas interdit global — les deux cas n'ont pas la même conséquence (sur `main` un force-push écrase du contenu partagé, déjà consommé par ~95 forks étudiants ; sur une branche de feature à lane unique il ne réécrit que le travail non mergé de cette lane). Verbatim + incident 2026-03-13 : [git-workflow-detail.md](../../docs/reference/git-workflow-detail.md).

| Cible | Règle | Ce qui la porte |
|---|---|---|
| **`main`** | **INTERDIT**, sans exception d'urgence | `allow_force_pushes: false` — GitHub **refuse** le push. C'est le serveur qui tranche, pas une consigne |
| **Branche de PR (`feature/*`, `fix/*`, `docs/*`) à lane unique** | **AUTORISÉ**, `--force-with-lease` préféré | aucune protection plateforme : c'est la discipline de lane qui répond |
| **Branche manipulée par plusieurs agents de front** | **INTERDIT** | un `[CLAIMED]` d'une autre lane sur l'issue vaut « plusieurs agents » → [lane-claim-protocol.md](lane-claim-protocol.md) |

- **L'alternative merge d'abord, quand elle existe** : `git merge origin/main`, `gh pr update-branch`, cherry-pick, revert, nouveaux commits. Le force-push est le dernier recours, jamais le réflexe de rebase par défaut.
- **`gh pr update-branch` ne remet PAS le plancher DWELL à zéro quand la fusion est sans conflit** (#16149, durci par la CR du 2026-09-16 ; corrige ce que #15859 avait laissé croire) : le plancher de merge (120 min, `scripts/ci/merge_dwell.py`) se mesure par `last_authoritative_committed_at`, qui remonte la chaîne first-parent **au-delà** des fusions de rafraîchissement de base **prouvées content-free** — trois conditions conjointes : deux parents, le second **ancêtre de la base**, et l'arbre du merge **identique à l'auto-merge** des parents (`git merge-tree --write-tree`). C'est exactement la forme d'un `update-branch` sans conflit. **Rafraîchir une branche propre pour récupérer un fix de `main` est donc gratuit.** Fail-closed partout ailleurs : une résolution manuelle de conflit (arbre ≠ auto-merge), un rebase, ou une preuve d'équivalence indisponible **re-arment** le plancher — c'est du contenu d'auteur, et il se mesure. Un commit de code ordinaire le re-arme évidemment aussi. Ce rouge n'est **pas un défaut de la PR** : le gate le nomme `DWELL -- ... leve au premier balayage suivant <ts>` et le balayage (`pr-gate-stale-sweep.yml`) lève seul — **sa cadence réelle n'est pas horaire** malgré son cron `7 * * * *` : le workflow mesure lui-même **178-341 min** entre deux services (5 tirs en 24 h, #15197), donc l'échéance annoncée est une borne, pas une promesse. Sur une PR dont le **seul** rouge est `DWELL`, `update-branch` n'est pas nuisible, il est **inutile** (rafale CI pour rien sur un pool de runners partagé) ; le geste **gratuit** est de rejouer la jambe (`gh run rerun <run_id> --job <job_id>`) : aucun commit, donc aucun ré-armement.
- **Ce que chaque geste fait au plancher** :
  - `gh pr update-branch` **sans conflit** : **inchangé** — fusion content-free, sautée.
  - `gh pr update-branch` **avec résolution de conflit** : **ré-armé** — l'arbre diffère de l'auto-merge.
  - rebase : **ré-armé** — fail-closed.
  - commit de contenu ordinaire : **ré-armé** — la tête change de date.
- **`update-branch` tue AUSSI le dossier de prévalidation, et c'est la moitié qu'on oublie** : il change la tête, donc le contrat exact-head (`[ADJOINT PREFLIGHT]`, `surfaces-sha256`, comptes `diff-files`/`additions`) est **périmé à la seconde**, et `check_adjoint_prevalidation.py` rend `head is stale` / `discussion surfaces changed`. La boucle se referme sur elle-même : `main` rouge → la lane doit `update-branch` (bon geste) → **le dossier périt** → l'adjoint ne peut plus attester `checks: latest-wins-green` sans mentir, donc il retient le dossier (à juste titre) → `exit 1` → pas de merge → la PR vieillit → il faut re-`update-branch`. **Correction du 2026-09-20 (#16962)** : cette boucle avait d'abord été décrite avec un second effet — « et le DWELL se ré-arme ». C'est **faux** pour un `update-branch` sans conflit (`last_authoritative_committed_at` saute les fusions prouvées content-free), et l'erreur rendait la boucle plus serrée qu'elle ne l'est. Le seul effet réel est la **péremption du dossier**, et il suffit à produire la boucle. Mesure du 2026-09-19 : **17 candidates sur 17** refusées par le gate pour ce seul motif, aucune pour un défaut de PR.

  **L'ordre qui sort de la boucle — le dossier vient APRÈS la stabilisation de la branche, jamais avant :**

  1. la lane `update-branch` si elle doit récupérer `main` ;
  2. on laisse les checks se ré-agréger à la nouvelle tête, puis **on rejoue** le job si besoin — personne ne re-pousse. Le plancher DWELL, lui, n'a **pas** été ré-armé par un `update-branch` sans conflit : il continue de se mesurer depuis le dernier commit qui modifie le côté PR — la date de committer que remonte `last_authoritative_committed_at`, pas celle de la fusion de rafraîchissement. Il ne se ré-arme que si le rafraîchissement a exigé une **résolution manuelle de conflit** (arbre ≠ auto-merge des parents), cas où l'attente de 120 min redevient réelle ;
  3. **alors** l'adjoint écrit le dossier, à la tête exacte ;
  4. le coordinateur merge **immédiatement**, et **la branche est gelée entre 3 et 4**.

  Le gel est la pièce qui manquait : un dossier a besoin d'une **branche silencieuse**, sinon le travail de prévalidation est détruit par le travail de réparation, indéfiniment.

  La forme **opérationnelle** du contrat exact-head vit dans [`coordinate/SKILL.md`](../skills/coordinate/SKILL.md) (« un changement de head ou de surface le perime ») et n'est **pas** reformulée ici — deux surfaces qui redécrivent la même règle finissent par diverger (#16962). Verbatim de l'organe et réconciliation de l'issue fondatrice : [prevalidation-dossier-order-detail.md](../../docs/reference/prevalidation-dossier-order-detail.md).
- **`--force-with-lease` plutôt que `--force`** : il échoue si le remote a bougé depuis ta dernière lecture — précisément le cas « une autre lane a poussé sans que je le sache ». C'est le garde-fou qui rend le périmètre ci-dessus sûr.
- **Jamais de `reset --hard`** sur `main` ni sur une branche partagée.
- **Un secret déjà commité ne se répare PAS par réécriture d'historique** : branche propre + cherry-pick, et **rotation de la clé** (cf [secrets-hygiene.md](secrets-hygiene.md) règle 5).

**`allow_force_pushes` n'est PAS lisible sans droit admin** : `gh api .../branches/main/protection` renvoie **404** sous `myia-ai-01` (#9991). Un 404 y est **une question, pas une absence mesurée** — l'interpréter comme « pas de protection » conclut l'inverse de la vérité. Ce qu'une lane peut vérifier sans admin, c'est le comportement : un `push --force` sur `main` est rejeté par le serveur.

---

### Other Safety Rules

- If secrets are accidentally committed, create a new clean branch with cherry-pick rather than rewriting history
- Always commit incrementally to avoid needing force pushes
- Prefer adding specific files by name over `git add -A` or `git add .`

## Notebook-Specific

- When committing notebook files, always verify outputs are intentionally included
- Commit enrichment changes separately from execution output changes
- Use descriptive commit messages mentioning which notebooks were modified and why

## Orphan-branch scan (L576 ★★)

**S'applique quand** un worker voit une branche distante `jsboige/*` et **envisage de la self-pick**. Les ancres `pulls` **peuvent mentir** : REST `commits/<oid>/pulls` renvoie un faux négatif pour une branche pourtant attachée à une PR OPEN, et une branche squash-mergée n'est jamais ancêtre de `main`. Conclure « orpheline » sur une seule ancre = auto-pick d'un travail en cours, ou re-livraison d'un travail déjà sur `main`.

**Quatre ancres, toutes à passer** — matrice de décision complète, faux positifs mesurés et incident fondateur (c.576, branches attachées à #7086-#7091) : [orphan-branch-scan-l576.md](../../docs/reference/orphan-branch-scan-l576.md).

```bash
git merge-base --is-ancestor <sha> origin/main          # 1. intégrée upstream ? (muette si squash)
gh api repos/jsboige/CoursIA/commits/<sha>/pulls        # 2. REST (faux négatif possible)
gh pr list --state all --search "head:<branch>"         # 3. autoritatif sur les PRs
git log origin/main --oneline --grep "<sujet>"          # 4. identité de CONTENU (squash → sujet préservé)
```

**Anti-pattern** : ne JAMAIS conclure « orpheline » sur `git fetch` + REST seul. Coût de l'investigation : ~10 s. Coût de son omission : un cycle dupliqué, ou l'écrasement d'un travail en cours.

## Worktree cleanup en fin de cycle (#14195)

**Problème** : chaque review de PR notebook / build Lean crée un worktree ; sans organe de retrait, ces worktrees s'accumulent (mesure 2026-09-02 : 265 sur po-2026 en 36 h, 103 sur ai-01 ; précédent #8924). Le mécanisme est nommé depuis le 2026-08-05 mais l'organe n'avait jamais été construit.

**Solution** : `scripts/ci/prune_merged_worktrees.py`, dry-run par défaut, `--apply` explicite. À exécuter en fin de cycle worker (ou via cron) :

```bash
python scripts/ci/prune_merged_worktrees.py             # dry-run : liste ce qui serait retiré
python scripts/ci/prune_merged_worktrees.py --apply     # applique
python scripts/ci/prune_merged_worktrees.py --json      # sortie structuree pour sweep dashboard
```

**Cablage quotidien (#14473)** : `scripts/ci/install_prune_task.py` installe la tache planifiee locale (schtasks DAILY 03:17, journal `%LOCALAPPDATA%\CoursIA\prune_task\logs\`) qui execute la purge en `--apply` — l'appel manuel fin de cycle reste le filet. La prose d'une regle ne s'execute pas seule (regles injectees au demarrage, perdues en crash) : une fois par machine,

```bash
python scripts/ci/install_prune_task.py --install   # garde : REFUSE tant que le fix #14476 n'est pas merge
python scripts/ci/install_prune_task.py --status    # etat de la tache
python scripts/ci/install_prune_task.py --install --dry-run  # afficher argv schtasks sans installer
```

**Critères de retrait (cf issue #14195 acceptance)** :
- **REMOVE** : PR MERGED ou CLOSED, branche sans unpushed, pas d'édition source untracked.
- **REFUSE** : branche `main`/`master` (jamais le worktree de travail) · PR OPEN (l'itération continue) · commits non poussés vs upstream spécifique (`@{u}` non-`main`) · édition source untracked non tolérée (`.py`, `.ipynb`, `.lean`, `.md`, etc.).
- **SKIP_CURRENT** : le worktree depuis lequel le script est lancé.
- **Artefacts untracked tolérés** (`slides/images/`, `**/scripts/results/`, `.claude/agent-memory/*`, `*_output.ipynb`, `node_modules/`, `.cache/`, `.pytest_cache/`, `__pycache__/`, `_measurements/`, `.mypy_cache/`, `.ruff_cache/`, `dist/`, `build/`, `.eggs/`, `.tox/`) — d'après le dernier commentaire de #8924.

**Ancre PR autoritative** : `gh pr list --state all --search "head:<branch>"` (pas `--is-ancestor` seul, ni `commits/<oid>/pulls` REST — cf §Orphan-branch scan ci-dessus pour les faux négatifs mesurés).

**Refus ≠ échec + observabilité (roo-extensions #3895, 27/09)** : mesuré sur 3 machines, 86-100 % des worktrees vus sont REFUSE (worktrees de cycle nés HEAD-détachés ou sales par construction — po-2023 a saturé ses disques dessus). Un REFUS est une **décision** de l'organe : exit 0, le rapport porte le décompte par classe (`refusals:` en texte, `refusal_reasons` en JSON) ; rc≠0 = panne gh/git ou échec d'application, uniquement. La tâche planifiée relaie `--warn-threshold 20` : au-delà, une ligne `[WARN][prune-task]` vit dans le journal (`%LOCALAPPDATA%\CoursIA\prune_task\logs\`). Attribution lane : un spawn peut poser `.lane-owner` (une ligne : `machine:workspace`) à la racine du worktree — l'organe l'affiche (`lane=...`) et l'agrège (`lane_refusals`) ; le fichier est toléré au nettoyage et n'influence AUCUNE décision.

**Mesure 2026-09-03 (po-2027, cycle #14195)** :

| | Avant | Après `--apply` |
|---|---:|---:|
| Worktrees enregistrés | 5 | 4 |
| … REMOVE | 1 (test-prune-merged, PR #14403 MERGED) | — |
| … REFUSE | 4 (main + 3 PR OPEN) | 4 |

Le worktree de test créé sur `fix/14142-qc40-followup-state` (PR #14403 mergée par squash 2026-09-02) a été retiré — c'est le **contrôle positif** manquant à tout prédicat d'ascendance : un squash-merge efface l'ascendance, mais `gh pr list --state all --search` retrouve la PR par le nom de branche.

## PR Body Generation

**L677-L4 ★★** — le **body de PR se génère HORS worktree** : scratchpad `<scratchpad-dir>/c<NNN>_pr_body.md` + `gh pr create --body-file <scratchpad-path>`. Jamais un `PR_BODY.md` / `BODY.md` **dans** le worktree, qu'un `git add .` stagerait et qu'un rebase ou amend ramènerait dans un commit de code. Vérifier `git status` avant tout `git add .` : pas de `*.md` orphelin de body dans les fichiers tracés.

Autres leçons ancrées (L721 stale-tracker, L740 cron 7 j, L898 collision cross-lane) : [proactive-coordination.md](proactive-coordination.md) §Leçons ancrées.
