61a52f8d
Design patches as revision refs instead of branches
a73x 2026-08-09 16:12
Commit message
docs/superpowers/specs/2026-08-09-patch-revision-refs-design.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,204 @@ | |||
| 1 | # Patches Without Branches — Design | ||
| 2 | |||
| 3 | Date: 2026-08-09 | ||
| 4 | Status: Draft | ||
| 5 | |||
| 6 | ## Purpose | ||
| 7 | |||
| 8 | A patch currently *is* a branch: `PatchState.branch` names a `refs/heads/*` ref | ||
| 9 | (`state.rs:208`, `patch.rs:101`), and the patch's commits reach the remote only | ||
| 10 | because the contributor pushed that branch by hand. This design removes the | ||
| 11 | dependency. Each revision becomes an immutable ref inside the patch's own | ||
| 12 | namespace, so a patch and all of its revisions travel under the refspecs `sync` | ||
| 13 | already uses, and contributing requires no write access to `refs/heads/*`. | ||
| 14 | |||
| 15 | ## Design test | ||
| 16 | |||
| 17 | > If a feature only works when the server is `git-collab-server`, it is not a | ||
| 18 | > git-collab feature. | ||
| 19 | |||
| 20 | git-collab's claim is that collaboration lives in the repository and works | ||
| 21 | between any two clones. Anything that needs a server-side hook, a receive-pack | ||
| 22 | wrapper, or an intercepted push fails the test and is out of scope by | ||
| 23 | construction. This rules out Gerrit-style `refs/for/<branch>`: it requires a | ||
| 24 | server that rewrites a virtual ref into a patch, so it would work on this forge | ||
| 25 | and nowhere else. `patch create` and `patch revise` remain the entry points, and | ||
| 26 | they are client-side. | ||
| 27 | |||
| 28 | ## Ref layout | ||
| 29 | |||
| 30 | ``` | ||
| 31 | refs/collab/patches/<id>/events the event DAG (today's refs/collab/patches/<id>) | ||
| 32 | refs/collab/patches/<id>/r/<n> revision n's commit — immutable, write-once | ||
| 33 | ``` | ||
| 34 | |||
| 35 | The `events` rename is forced: git will not let `<id>` be both a ref and a | ||
| 36 | directory. | ||
| 37 | |||
| 38 | `sync` already pushes and fetches `refs/collab/patches/*` (`sync.rs:230-233`, | ||
| 39 | `sync.rs:344-347`), so revision refs are carried by the existing refspecs with no | ||
| 40 | change. A contributor's whole workflow becomes `git-collab sync`, where it is | ||
| 41 | currently `git push origin <branch>` followed by `git-collab sync`. | ||
| 42 | |||
| 43 | Revision refs are write-once. A revision is never rewritten; a changed patch adds | ||
| 44 | `r/<n+1>`. This makes the objects behind every revision permanently reachable — | ||
| 45 | today they survive only while the branch happens to still reach them, so an | ||
| 46 | author who rebases and force-pushes silently strips earlier revisions of their | ||
| 47 | blobs and leaves interdiff and revision-anchored inline comments | ||
| 48 | (`state.rs:133`, `state.rs:197`) pointing at objects `git gc` is entitled to | ||
| 49 | delete. | ||
| 50 | |||
| 51 | ## Identity | ||
| 52 | |||
| 53 | Patch identity is declared, not derived. The id lives in the event DAG and is | ||
| 54 | computed from nothing in the commits, so squashing, rebasing, reordering, or | ||
| 55 | replacing the work wholesale leaves it intact. | ||
| 56 | |||
| 57 | This is the role Gerrit fills with a `Change-Id` trailer and a client-side | ||
| 58 | `commit-msg` hook. git-collab needs neither: the event is already a side-band | ||
| 59 | that can say which patch a revision belongs to, so nothing has to be smuggled | ||
| 60 | through the commit message. | ||
| 61 | |||
| 62 | Duplicate detection changes accordingly. It currently scans open patches for one | ||
| 63 | whose `branch` field matches (`patch.rs:106-112`), which is why patches break | ||
| 64 | when worked on from ephemeral worktree branches — two worktrees on the same | ||
| 65 | branch name collide, and one worktree with a generated branch name never matches | ||
| 66 | its own patch. After this change a revision names its patch id explicitly and | ||
| 67 | branch names carry no meaning. | ||
| 68 | |||
| 69 | ## Base per revision | ||
| 70 | |||
| 71 | `base_ref` stays on the patch: the target branch is a property of the patch. | ||
| 72 | `base_commit` moves onto `Revision` as `base`, and `PatchState.base_commit` | ||
| 73 | (`state.rs:209-211`) is removed rather than kept as a second source of truth. | ||
| 74 | |||
| 75 | ``` | ||
| 76 | patch a1b2c3d4 base_ref = "main" | ||
| 77 | |||
| 78 | r1 commit A1 base B1 branched off main@B1 | ||
| 79 | r2 commit A2 base B1 no rebase — base unchanged | ||
| 80 | r3 commit A3 base B2 main moved; rebased onto B2 | ||
| 81 | ``` | ||
| 82 | |||
| 83 | `base` is `merge_base(<base_ref tip>, <revision commit>)`, computed when the | ||
| 84 | revision is recorded and stored. Merge-base rather than branch tip: if the base | ||
| 85 | branch advances and the author does not rebase, the merge-base is unchanged, so | ||
| 86 | `base` moves only when the author actually moves. "Did the author rebase between | ||
| 87 | these two revisions" is then exactly "did `base` change". | ||
| 88 | |||
| 89 | The computation already exists — `resolve_base_tree` (`patch.rs:454-466`) does it | ||
| 90 | at display time. The change is *when*. Recomputing at display time is what loses | ||
| 91 | the information: by the time an interdiff is requested, the base branch has moved | ||
| 92 | and there is no way to reconstruct where an earlier revision stood. | ||
| 93 | |||
| 94 | ## Interdiff | ||
| 95 | |||
| 96 | `interdiff` (`patch.rs:501-525`) is currently a flat tree-to-tree diff between | ||
| 97 | two revision trees with no base awareness. When a rebase separates the two | ||
| 98 | revisions, the result includes every upstream commit rebased over, burying the | ||
| 99 | author's actual response to review. Single-revision `diff` does not have this | ||
| 100 | problem — it three-dots through `resolve_base_tree` — so only the interdiff path | ||
| 101 | needs fixing. | ||
| 102 | |||
| 103 | With `base` on each revision, there are two cases. | ||
| 104 | |||
| 105 | **Bases equal.** Tree diff, as today. This is also the pure-squash case: squashing | ||
| 106 | does not change the resulting tree, so the interdiff is empty. That is the | ||
| 107 | correct answer, and a useful one — it tells the reviewer the author restructured | ||
| 108 | commits and changed no code. | ||
| 109 | |||
| 110 | **Bases differ.** A rebase happened. Cherry-pick the *older* revision onto the | ||
| 111 | *newer* revision's base, then tree-diff the result against the newer revision. | ||
| 112 | Upstream churn cancels and only the author's changes remain. `git2::merge_commits` | ||
| 113 | does this in memory; no working tree or checkout is involved. | ||
| 114 | |||
| 115 | The direction is load-bearing. The newer revision is the one under review and | ||
| 116 | must be shown exactly as its author recorded it; the older revision is the | ||
| 117 | reference point being brought forward. Replaying the newer one backwards would | ||
| 118 | show the reviewer a synthesised version of the revision they are evaluating. | ||
| 119 | |||
| 120 | A conflicting cherry-pick is reported as such. It means upstream and the patch | ||
| 121 | touched the same lines, and there is no honest "only the author's changes" view | ||
| 122 | to render. `git range-diff` is the fallback worth considering here, but not as | ||
| 123 | the primary path: it emits a format nothing downstream — web UI, TUI, or inline | ||
| 124 | comment anchoring — knows how to render. | ||
| 125 | |||
| 126 | ## What the branch was doing | ||
| 127 | |||
| 128 | Four things depend on `PatchState.branch` today and each needs a replacement. | ||
| 129 | |||
| 130 | | Today | After | | ||
| 131 | |---|---| | ||
| 132 | | `resolve_head` looks up `refs/heads/<branch>` (`state.rs:433-446`) | Latest `r/<n>` | | ||
| 133 | | Staleness, ahead/behind against base tip (`state.rs:448-456`) | Unchanged; anchored to latest `r/<n>` | | ||
| 134 | | Merged detection by reachability from base (`state.rs:460`) | Unchanged; anchored to latest `r/<n>` | | ||
| 135 | | `patch checkout` creates a local branch (`patch.rs:663-668`) | Unchanged; branches from `r/<n>` | | ||
| 136 | |||
| 137 | `resolve_head` already accepts an OID before falling back to a name lookup | ||
| 138 | (`state.rs:436`), so the OID path is partly built. | ||
| 139 | |||
| 140 | Revision recording is the one behaviour that is genuinely lost. Revisions are | ||
| 141 | currently inferred while applying events: an observed commit not already in the | ||
| 142 | revision list becomes a new revision (`state.rs:578-581`). With no branch to | ||
| 143 | observe, `patch revise` becomes the explicit act that records one. This is the | ||
| 144 | same operation Gerrit performs on a push to `refs/for`, moved to the client. | ||
| 145 | |||
| 146 | ## Ref lifecycle | ||
| 147 | |||
| 148 | `delete` removes a single reference (`patch.rs:677-681`) and `close` archives one | ||
| 149 | via `state::archive_patch_ref` (`patch.rs:694-698`). Both now operate on a | ||
| 150 | subtree: `<id>/events` plus every `<id>/r/<n>`. Archiving must move the whole | ||
| 151 | subtree under `refs/collab/archive/patches/<id>/` so that closed patches keep | ||
| 152 | their revisions and remain reviewable. | ||
| 153 | |||
| 154 | ## Migration | ||
| 155 | |||
| 156 | Pre-release; no compatibility guarantee. Patches in the old layout are migrated | ||
| 157 | on first use rather than supported indefinitely: | ||
| 158 | |||
| 159 | - `refs/collab/patches/<id>` becomes `<id>/events`. | ||
| 160 | - Each recorded revision with a non-empty `commit` gets an `r/<n>` ref, provided | ||
| 161 | the object is still present. Revisions whose objects were already lost to a | ||
| 162 | force-push cannot be recovered and keep today's `""`-means-unknown convention | ||
| 163 | (`state.rs:164-167`). | ||
| 164 | - Revisions with no stored `base` fall back to recomputing the merge-base against | ||
| 165 | the current base branch, which is exactly today's behaviour — old patches | ||
| 166 | degrade to what they already do rather than failing. | ||
| 167 | |||
| 168 | ## Testing | ||
| 169 | |||
| 170 | - A patch created and synced with no `git push` of any branch is fully visible to | ||
| 171 | a second clone, including its revision commits. | ||
| 172 | - Squashing a revision's commits without changing the tree produces an empty | ||
| 173 | interdiff. | ||
| 174 | - Rebasing onto a base branch that has advanced produces an interdiff containing | ||
| 175 | only the author's changes. | ||
| 176 | - A rebase that conflicts with upstream reports a conflict rather than emitting a | ||
| 177 | misleading diff. | ||
| 178 | - An inline comment anchored to revision 1 still resolves after the author | ||
| 179 | rebases and records revision 2. | ||
| 180 | - Two worktrees with generated branch names each revise their own patch without | ||
| 181 | collision. | ||
| 182 | - A closed patch retains all revision refs under the archive namespace. | ||
| 183 | - A patch migrated from the old layout diffs and interdiffs correctly. | ||
| 184 | |||
| 185 | ## Consequences for governance | ||
| 186 | |||
| 187 | `2026-08-09-repo-governance-design.md` assumes a server-side `pre-receive` hook | ||
| 188 | and `refs/for` conversion. Both fail the design test and are withdrawn; that | ||
| 189 | document needs revising against this one. | ||
| 190 | |||
| 191 | The relevant consequence here is that a contributor no longer needs write access | ||
| 192 | to `refs/heads/*` at all — only to `refs/collab/*`. This server's authorization | ||
| 193 | is per-repository and cannot express that distinction, so it does not by itself | ||
| 194 | constrain a contributor on this forge. On any host that does have per-ref control | ||
| 195 | — GitHub branch protection, Gitea, gitolite — the constraint applies for free, | ||
| 196 | because the access is never requested. The remaining question of how this forge | ||
| 197 | scopes agent credentials belongs to the governance document. | ||
| 198 | |||
| 199 | ## Out of scope | ||
| 200 | |||
| 201 | - `refs/for/<branch>` and any server-side push interception. | ||
| 202 | - Server-side enforcement of ref classes. | ||
| 203 | - Signature verification changes; revision refs are ordinary commits and the | ||
| 204 | event DAG remains the signed surface. | ||
docs/superpowers/specs/2026-08-09-repo-governance-design.md
| Old | New | ||
|---|---|---|---|
| @@ -1,7 +1,21 @@ | |||
| 1 | # Repository Governance — Design | 1 | # Repository Governance — Design |
| 2 | 2 | ||
| 3 | Date: 2026-08-09 | 3 | Date: 2026-08-09 |
| 4 | Status: Draft — open questions at the end must be settled before implementation | 4 | Status: Superseded in part — needs revising against |
| 5 | `2026-08-09-patch-revision-refs-design.md` | ||
| 6 | |||
| 7 | That document adopts the design test "if a feature only works when the server is | ||
| 8 | `git-collab-server`, it is not a git-collab feature." Three parts of this design | ||
| 9 | fail it and are withdrawn: the `pre-receive` hook (Enforcement · At receive), the | ||
| 10 | `refs/for` contribution class and its conversion (Q3), and in-band policy on | ||
| 11 | `refs/collab/policy`, which was only enforceable by the hook. Governance returns | ||
| 12 | to a settings repository and per-repository read/write authorization. Q2 is | ||
| 13 | answered in the affirmative by that return; Q1 is unaffected. | ||
| 14 | |||
| 15 | The ref-class table goes down with the hook as an enforcement mechanism, but | ||
| 16 | survives as a description of what a host with per-ref control can apply — this | ||
| 17 | forge cannot. The threat model, the failure modes and the repository lookup key | ||
| 18 | carry into the revision unchanged. | ||
| 5 | 19 | ||
| 6 | ## Purpose | 20 | ## Purpose |
| 7 | 21 | ||