a73x

61a52f8d

Design patches as revision refs instead of branches

a73x   2026-08-09 16:12

Commit message
Design patches as revision refs instead of branches

Each revision becomes an immutable ref in the patch's own namespace, so
patches travel under the refspecs sync already uses and contributing needs
no write access to refs/heads/*. Records the base per revision so interdiff
survives a rebase.

Marks the server-side parts of the governance design superseded: the
pre-receive hook, refs/for conversion and in-band policy all require a
git-collab-specific server, which the design test rules out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

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