diff --git a/.claude/commands/tidy-history.md b/.claude/commands/tidy-history.md index bd242c10..81883d9b 100644 --- a/.claude/commands/tidy-history.md +++ b/.claude/commands/tidy-history.md @@ -7,9 +7,9 @@ Tidy the commit history of the current branch **before it reaches `main`**, per `CONTRIBUTING.md` ("Branches", "Commit messages") and `AGENTS.md` ("Tidying history before a pull request"). -This repository merges pull requests with a **merge commit**: every commit on -the branch lands in `main`'s history, so cleaning it up before merge is not -cosmetic. +This repository merges pull requests by **rebasing** them onto `main`: every +commit on the branch is replayed into `main`'s history, so cleaning it up before +merge is not cosmetic. Base to compare against: `origin/main`. If a base ref was passed (`$ARGUMENTS`), use it in place of `origin/main` everywhere below. diff --git a/AGENTS.md b/AGENTS.md index 7b8cbcc6..97cf433a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,9 +58,9 @@ request and let `@reefact` judge rather than guessing. ## Tidying history before a pull request (acting agent) This governs the agent that *prepares* a branch for review, not the reviewer. -This repository merges pull requests with a **merge commit**, so every commit a -branch carries lands in `main`'s history — a messy branch is not squashed away -on merge, it pollutes protected history for good. `CONTRIBUTING.md` already +This repository merges pull requests by **rebasing** them onto `main`, so every +commit a branch carries lands in `main`'s history — a messy branch is not +squashed away on merge, it pollutes protected history for good. `CONTRIBUTING.md` already fixes the endpoint (autosquash placeholders squashed before merge, a conforming header on every commit, one intention per commit); this section makes the agent *reach* it **on its own initiative**, the way it runs the ADR check without diff --git a/CLAUDE.md b/CLAUDE.md index aea078af..51951c64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,7 +136,7 @@ The essentials, inlined so they hold even if `AGENTS.md` is not read: * Write every commit message per [`CONTRIBUTING.md`](CONTRIBUTING.md): Conventional Commits, a closed type list, the scopes `core, analyzers, binder, cli, gendoc, testing`, an imperative header within 72 characters, and `Refs: #NN` in a footer when a GitHub issue exists (issue-closing keywords belong in the PR description, not the commit). * Write every pull request title per [`CONTRIBUTING.md`](CONTRIBUTING.md): name the whole change in English; a single-intention PR mirrors its commit header (`type(scope): description`), a multi-intention PR uses a short descriptive title, and issue references stay in the description, not the title. * Enable the local commit-message hook once per clone with `git config core.hooksPath .githooks`; the same check runs in CI on every pull request. -* Before opening a pull request — and after pushing more commits to an open one — read the branch against a fresh `origin/main` and, if the history is messy (pending `fixup!`/`squash!`, wip/typo/"address review" commits, headers the lint rejects, one change split across non-standalone commits or two folded into one), **propose** a cleanup and rewrite only after I approve — while the branch is yours alone, with `git push --force-with-lease`, leaving the diff against `origin/main` unchanged. This repository merges with a merge commit, so a messy branch reaches `main`. Full rule in [`AGENTS.md`](AGENTS.md) ("Tidying history before a pull request"); the `/tidy-history` command runs it. +* Before opening a pull request — and after pushing more commits to an open one — read the branch against a fresh `origin/main` and, if the history is messy (pending `fixup!`/`squash!`, wip/typo/"address review" commits, headers the lint rejects, one change split across non-standalone commits or two folded into one), **propose** a cleanup and rewrite only after I approve — while the branch is yours alone, with `git push --force-with-lease`, leaving the diff against `origin/main` unchanged. This repository merges by rebasing, so every commit of a messy branch reaches `main`. Full rule in [`AGENTS.md`](AGENTS.md) ("Tidying history before a pull request"); the `/tidy-history` command runs it. * In PR descriptions, do not invent testing results. Only check items that were actually run. ## Responding to pull request review feedback diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ae66f0ef..9ae2b9a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -172,9 +172,9 @@ enforces it. The branch name adds the one thing the commits omit — whose work is — so `claude/…` and `dependabot/…` are not exceptions but the rule itself, read the same on a human or a machine. -**A branch is disposable.** Its history is preserved by the merge commit that -lands it; the ref itself is cut fresh and deleted on merge. Nothing of value -lives only on a branch. +**A branch is disposable.** Its history is replayed onto `main` by the rebase +that lands it; the ref itself is cut fresh and deleted on merge. Nothing of +value lives only on a branch. **A merged branch is spent.** Reviving it stacks new work on settled history and forks from a `main` that has moved. The reviewer pays the cost, reading the @@ -553,10 +553,10 @@ own, and it is not the same object: the commit is the unit of the change, the draws between the commit and the issue. A pull request MAY therefore gather several commits, of several types. -Its title is read in three places: the list of open pull requests, the -`Merge pull request #NN` commit GitHub writes when the branch lands (this -repository merges with a merge commit), and the draft of the release notes. It -earns the same care as a commit header. Unlike a commit, it is **not** linted; +Its title is read in two places: the list of open pull requests and the draft of +the release notes — this repository rebases a pull request onto `main`, so no +`Merge pull request #NN` commit carries it. It earns the same care as a commit +header. Unlike a commit, it is **not** linted; it stands on the review, as the code does. ### The rule diff --git a/doc/handwritten/for-maintainers/adr/0070-land-pull-requests-by-rebasing-and-keep-main-linear.fr.md b/doc/handwritten/for-maintainers/adr/0070-land-pull-requests-by-rebasing-and-keep-main-linear.fr.md new file mode 100644 index 00000000..5442c614 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0070-land-pull-requests-by-rebasing-and-keep-main-linear.fr.md @@ -0,0 +1,174 @@ +# ADR-0070 | Intégrer les pull requests par rebase et garder l'historique de `main` linéaire + +🌍 🇫🇷 Français (ce fichier) · 🇬🇧 [English](0070-land-pull-requests-by-rebasing-and-keep-main-linear.md) + +**Statut :** Proposé +**Proposé :** 2026-08-10 +**Décideurs :** Reefact + +## Contexte + +`main` portait 1366 commits, dont 401 merges. Parmi ces merges, 314 sont les +commits `Merge pull request #NN` écrits par le bouton de merge de GitHub, et 87 +sont des commits `Merge branch 'main' into …` créés lorsqu'une branche +récupérait `main` en cours de revue. Les 965 commits restants — 71 % — sont ceux +qui ont changé quelque chose. + +Les commits de merge ne sont pas seulement du bruit. Ils coûtent trois choses +concrètes : + +* `git log main` entrelace les branches développées en parallèle : lire + l'historique revient à séparer ce qui s'est passé du moment où les branches se + sont rencontrées. +* `git bisect` et `git blame` traversent des arbres que personne n'a jamais + écrits : l'arbre d'un commit de merge est la réconciliation de deux branches + par une machine, pas un état qu'un contributeur a rédigé, relu ou exécuté. +* Les règles du dépôt sont justifiées par le commit de merge. `AGENTS.md`, + `CONTRIBUTING.md`, `CLAUDE.md` et la commande `/tidy-history` soutiennent tous + que l'historique d'une branche doit être propre *parce que le commit de merge + le conserve*. L'argument tient, mais il nomme le mauvais mécanisme. + +Ce dernier point est le plus important. Ce dépôt exige déjà que chaque commit se +tienne seul, lint chaque en-tête en CI et demande à un agent de nettoyer une +branche avant la revue. La stratégie de merge est la seule partie du processus +qui ne découle pas de cette doctrine : elle conserve la *forme* d'une branche — +quand elle a commencé, quand elle a été rafraîchie, quand elle a atterri — alors +que tout le reste porte sur le *contenu* de ses commits. + +### Pourquoi maintenant + +La bibliothèque n'a pas atteint la 1.0.0. Son unique tag est +`lib-v0.1.0-preview.1`, aucune release n'est publiée et aucune pull request n'est +ouverte. Rien, hors de ce dépôt, ne dépend d'un identifiant de commit : aucun +consommateur n'épingle un SHA, aucune entrée de changelog n'en cite un, et aucun +package publié ne porte de SourceLink qui en désigne un. + +C'est toute la fenêtre pendant laquelle réécrire `main` coûte peu, et elle se +referme à la 1.0.0. Dès qu'une version stable est livrée, son SourceLink, ses +notes de version et les références que les consommateurs épinglent font des +identifiants de `main` une partie de ce que le projet promet ; les réécrire +cesse alors d'être une tâche de dépôt pour devenir une rupture pour tout l'aval. +Le faire maintenant achète un historique contre lequel la ligne 1.0.0 pourra se +lire. Le faire plus tard reviendrait à ne pas le faire du tout. + +## Décision + +L'historique de `main` est **linéaire**. La décision a deux volets. + +**Les pull requests atterrissent par rebase.** Le *Rebase and merge* de GitHub +devient la seule stratégie activée sur ce dépôt ; les commits de merge et les +squash merges sont désactivés. Les commits d'une pull request sont rejoués sur +`main` tels qu'ils ont été écrits, et aucun commit n'est créé pour enregistrer +l'intégration. + +**L'historique existant est réécrit linéaire, une fois.** Les 401 commits de +merge sont supprimés et les 965 commits rédigés sont rejoués dans l'ordre du +premier parent, en préservant pour chacun son message, son auteur, son +committer et ses dates. + +La réécriture n'est pas un simple `git rebase` de tout l'historique. Elle +parcourt la chaîne de premier parent de `main` et, pour chaque merge de pull +request, rejoue les commits de cette pull request puis **épingle** l'arbre +obtenu à celui qu'avait réellement le commit de merge. Cet épinglage est ce qui +rend l'opération sûre : 15 des 401 merges portaient une résolution de conflit +faite à la main, qu'un rejeu naïf aurait perdue en silence. L'épinglage +rétablit le contenu historique exact à chaque point où `main` a avancé : toute +dérive reste contenue dans une seule pull request et ne peut jamais franchir une +frontière. + +La réécriture n'est acceptée que sur preuves, pas sur intention. Les +vérifications qui devaient passer, et qui sont passées : + +| Contrôle | Résultat | +| --- | --- | +| Arbre du sommet de `main`, avant et après | identique | +| Arbre à chacune des 362 étapes de premier parent | 362 / 362 identiques | +| Commits rédigés préservés (auteur, e-mail, date, sujet) | 965 / 965 | +| Corps des messages de commit, comparés en multiensemble | 0 perdu, 0 ajouté | +| Conflits de rejeu | 0 | +| Commits de merge restants | 0 | +| Build et suite de tests sur le sommet réécrit | 974 tests, 0 échec | + +La réécriture est une **exception unique, et ce record ne fait pas précédent pour +une seconde**. À partir d'ici, `main` ne fait que croître : son historique se +corrige par de nouveaux commits, jamais en réécrivant ceux déjà publiés. Une +réécriture ultérieure exigerait son propre ADR et devrait répondre à l'objection +à laquelle celle-ci n'échappe que par le calendrier — qu'à ce moment-là, les +identifiants appartiennent au contrat publié. + +## Conséquences + +### Tous les SHA de commit de `main` changent + +C'est le prix de la décision, et il se paie une fois. Quiconque détient un clone +doit le recloner ou le réinitialiser ; un clone qui ferait un `pull` regrefferait +sinon l'ancien historique. Aucune pull request ouverte n'est invalidée et aucune +release publiée n'est cassée : la seule release GitHub est un brouillon non +taggé. + +### L'historique publié reste atteignable + +Les anciens SHA ne disparaissent pas. GitHub conserve `refs/pull/NN/head` pour +chaque pull request ouverte, si bien que les commits référencés par chaque pull +request mergée restent résolvables et que les liens dans les issues et les +revues continuent de fonctionner. Une référence de sauvegarde du `main` +d'avant la réécriture est poussée au préalable, et c'est elle que servirait une +restauration. + +### Rafraîchir une branche en y mergeant `main` ne convient plus + +`CONTRIBUTING.md` (« Branches ») propose aujourd'hui deux façons de reporter les +avancées de `main` dans une branche ouverte : rebaser tant que la branche +n'appartient qu'à vous, merger `main` dedans dès que d'autres ont pu baser du +travail dessus. La seconde produit exactement les commits +`Merge branch 'main' into …` que cette décision supprime. La présente ADR ne +tranche pas : la règle de rafraîchissement protège le travail déjà récupéré par +un collaborateur, ce qui est une préoccupation distincte de la forme de +l'historique, et les branches de ce dépôt n'ont en pratique qu'un propriétaire. +Le point est nommé ici pour que la prochaine personne à le rencontrer sache +qu'il s'agit d'un cas connu et non d'un oubli. + +### Deux tags se déplacent, et 28 signatures sont perdues + +`lib-v0.1.0-preview.1` et `archive/justdummies-adr` sont repointés sur les +commits équivalents de l'historique réécrit, le second conservant son +annotation, son tagueur et sa date. 28 commits portaient une signature ; un +commit réécrit ne peut pas la conserver, ils deviennent non signés. Aucune +protection de branche n'exigeait de commits signés. + +### Les dates d'auteur ne sont plus monotones + +75 paires de commits consécutifs présentent une date d'auteur postérieure avant +une antérieure. C'est inhérent à un historique linéaire construit à partir de +travaux menés en parallèle, et c'est ce qu'une stratégie de rebase aurait produit +dès l'origine. + +## Alternatives considérées + +### Changer la stratégie pour la suite et conserver les merges existants + +L'option la moins coûteuse, rejetée pour la raison même qui motive la décision. +Un demi-historique n'est pas un historique lisible : `git log` entrelacerait +toujours et `git bisect` traverserait toujours des arbres que personne n'a +écrits, sur les 1366 commits qui représentent toute la vie du projet à ce jour. +Le coût de la réécriture est borné et payé une fois ; celui du bruit se paie à +chaque lecture. + +### Écraser chaque pull request en un commit unique + +Rejeté. Cela produit mécaniquement un historique linéaire — un commit par pull +request, aucun rejeu, aucun conflit — mais détruit la discipline par commit que +ce dépôt s'attache réellement à faire respecter. Les 965 commits rédigés, chacun +avec un en-tête linté et une intention unique, s'effondreraient en 314 dont les +messages seraient les titres des pull requests. La doctrine dit que le commit +est l'unité du changement ; l'écrasement fait de la pull request cette unité. + +### Réécrire avec un simple `git rebase --root` + +Rejeté comme non sûr, plutôt que comme faux. Cela rejouerait les mêmes commits, +mais sans aucune garantie sur le résultat : rien ne vérifie que l'arbre obtenu à +chaque intégration correspond à ce que `main` contenait réellement, si bien que +les 15 merges résolus à la main pourraient être perdus en silence et que la +divergence n'apparaîtrait que bien plus tard, sous la forme d'un bug sans cause +apparente. La méthode par épinglage et vérification coûte un script et fournit +une preuve. diff --git a/doc/handwritten/for-maintainers/adr/0070-land-pull-requests-by-rebasing-and-keep-main-linear.md b/doc/handwritten/for-maintainers/adr/0070-land-pull-requests-by-rebasing-and-keep-main-linear.md new file mode 100644 index 00000000..887aea7e --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0070-land-pull-requests-by-rebasing-and-keep-main-linear.md @@ -0,0 +1,157 @@ +# ADR-0070 | Land pull requests by rebasing, and keep `main`'s history linear + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0070-land-pull-requests-by-rebasing-and-keep-main-linear.fr.md) + +**Status:** Proposed +**Proposed:** 2026-08-10 +**Decision Makers:** Reefact + +## Context + +`main` carried 1366 commits, 401 of them merges. Of those merges, 314 are the +`Merge pull request #NN` commits GitHub's merge button writes, and 87 are +`Merge branch 'main' into …` commits created when a branch pulled `main` in +mid-review. The 965 remaining commits — 71% — are the ones that changed +something. + +The merge commits are not only noise. They cost three concrete things: + +* `git log main` interleaves branches that were developed in parallel, so + reading the history means separating what happened from when branches met. +* `git bisect` and `git blame` walk trees that no one ever wrote: a merge + commit's tree is a machine's reconciliation of two branches, not a state a + contributor authored, reviewed or ran. +* The repository's own rules are justified by the merge commit. `AGENTS.md`, + `CONTRIBUTING.md`, `CLAUDE.md` and the `/tidy-history` command all argue that a + branch's history must be clean *because the merge commit preserves it*. The + argument holds, but it names the wrong mechanism. + +That last point matters most. This repository already requires each commit to +stand alone, lints every header in CI and asks an agent to tidy a branch before +review. A merge-commit strategy is the one part of the workflow that does not +follow from that doctrine: it preserves the *shape* of a branch — when it +started, when it was refreshed, when it landed — when everything else in the +process is about the *content* of its commits. + +### Why now + +The library has not reached 1.0.0. Its only tag is `lib-v0.1.0-preview.1`, no +release has been published, and no pull request is open. Nothing outside this +repository depends on a commit identifier: no consumer pins a SHA, no changelog +entry cites one, and no published package carries SourceLink pointing at one. + +That is the entire window in which rewriting `main` is cheap, and it closes at +1.0.0. Once a stable version ships, its SourceLink, its release notes and the +references consumers pin make `main`'s identifiers part of what the project +promises; rewriting them then stops being a repository chore and becomes a +breaking change for everyone downstream. Doing it now buys a history the 1.0.0 +line can be read against. Doing it later would mean not doing it at all. + +## Decision + +`main`'s history is **linear**. It has two parts. + +**Pull requests land by rebase.** GitHub's *Rebase and merge* becomes the only +enabled strategy on this repository; merge commits and squash merges are +disabled. A pull request's commits are replayed onto `main` as they were +written, and no commit is created to record the landing. + +**The existing history is rewritten linear, once.** The 401 merge commits are +removed and the 965 authored commits are replayed in first-parent order, +preserving each one's message, author, committer and dates. + +The rewrite is not a plain `git rebase` of the whole history. It walks `main`'s +first-parent chain and, for each pull-request merge, replays that pull request's +commits and then **pins** the resulting tree to the tree the merge commit +actually had. That pinning is what makes the operation safe: 15 of the 401 +merges carried a hand-made conflict resolution, and a naive replay would +silently drop it. Pinning restores the exact historical content at every point +where `main` advanced, so any drift is contained inside a single pull request +and can never cross a boundary. + +The rewrite is accepted only against evidence, not against intent. The +verification that must pass, and did: + +| Check | Result | +| --- | --- | +| Tree of `main`'s tip, before and after | identical | +| Tree at each of the 362 first-parent steps | 362 / 362 identical | +| Authored commits preserved (author, e-mail, date, subject) | 965 / 965 | +| Commit message bodies, compared as a multiset | 0 lost, 0 added | +| Replay conflicts | 0 | +| Merge commits remaining | 0 | +| Build and test suite on the rewritten tip | 974 tests, 0 failures | + +The rewrite is a **one-time exception, and this record is not a precedent for a +second one**. From here `main` is append-only: its history is corrected by new +commits, never by rewriting published ones. A later rewrite would need its own +ADR, and would have to answer the objection this one escapes only by timing — +that by then the identifiers belong to the published contract. + +## Consequences + +### Every commit SHA on `main` changes + +This is the price of the decision, and it is paid once. Anyone holding a clone +must re-clone or hard-reset; a clone that pulls will otherwise graft the old +history back on. There are no open pull requests to invalidate and no published +release to break: the only GitHub release is an untagged draft. + +### The published history stays reachable + +Old SHAs do not disappear. GitHub keeps `refs/pull/NN/head` for every pull +request ever opened, so the commits every merged pull request references remain +resolvable, and links in issues and reviews keep working. A backup ref of the +pre-rewrite `main` is pushed before the rewrite and is what a recovery would use. + +### Refreshing a branch by merging `main` into it no longer fits + +`CONTRIBUTING.md` ("Branches") currently offers two ways to carry `main`'s +progress into an open branch: rebase while the branch is yours alone, merge +`main` in once others may have based work on it. The second produces exactly the +`Merge branch 'main' into …` commits this decision removes. This ADR does not +resolve that: the branch-refresh rule protects a collaborator's pulled work, +which is a different concern from history shape, and this repository's branches +are in practice single-owner. It is named here so the next person to hit it +knows it is a known edge and not an oversight. + +### Two tags move, and 28 signatures are lost + +`lib-v0.1.0-preview.1` and `archive/justdummies-adr` are re-pointed at the +equivalent commits of the rewritten history, the latter keeping its annotation, +tagger and date. 28 commits carried a signature; a rewritten commit cannot keep +one, and they become unsigned. No branch protection required signed commits. + +### Author dates are no longer monotonic + +75 consecutive pairs of commits have a later author date before an earlier one. +That is inherent to a linear history built from work done in parallel, and is +what a rebase strategy would have produced from the start. + +## Alternatives Considered + +### Switch the strategy going forward and keep the existing merge commits + +The cheapest option, and it was rejected on the reason the decision exists. +Half a history is not a readable history: `git log` would still interleave, and +`git bisect` would still walk trees nobody wrote, for the 1366 commits that +represent the whole life of the project so far. The cost of the rewrite is +bounded and paid once; the cost of the noise is paid at every read. + +### Squash each pull request into a single commit + +Rejected. It produces a linear history mechanically — one commit per pull +request, no replay, no conflict — but it destroys the per-commit discipline this +repository spends real effort enforcing. The 965 authored commits, each with a +linted header and a single intention, would collapse into 314 whose messages are +the pull-request titles. The doctrine says the commit is the unit of the change; +squashing makes the pull request that unit. + +### Rewrite with a plain `git rebase --root` + +Rejected as unsafe rather than wrong. It would replay the same commits, but it +offers no guarantee about the result: nothing checks that the tree at each +landing matches what `main` actually held, so the 15 hand-resolved merges could +be silently lost and the divergence would surface much later, as a bug with no +apparent cause. The pinning-and-verifying method costs a script and yields a +proof. diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index 54bd568f..1fcc8a39 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -237,3 +237,4 @@ Optional supporting material: | [ADR-0062](0062-derive-the-build-rule-set-from-the-quality-profile.md) | Derive the build's Sonar rule set from the quality profile: generated membership, hand-written exceptions, weekly drift check | Accepted | | [ADR-0067](0067-treat-the-cli-s-exit-codes-as-a-closed-published-contract.md) | Treat the CLI's exit codes as a closed, published contract | Accepted | | [ADR-0069](0069-consume-justdummies-from-its-own-repository.md) | Consume JustDummies from its own repository | Accepted | +| [ADR-0070](0070-land-pull-requests-by-rebasing-and-keep-main-linear.md) | Land pull requests by rebasing, and keep `main`'s history linear | Proposed |