Skip to content

Commit c738201

Browse files
authored
Recognize exact squash trees in worktree merge proof (#188)
Accept complete Git tree equality with an identified reachable single-parent PR squash as merge proof for guarded worktree pruning. Retain repository, branch, head, base, ownership, dirty-state, activity and verified-archive requirements. Four regression cases cover exact-tree recovery and refusal for wrong head, wrong base and mismatched trees. Build, workspace type checks and the full unit suite pass; independent review found no actionable issues. Coven-Harness: Codex / gpt-6-astra
1 parent bf2d724 commit c738201

4 files changed

Lines changed: 45 additions & 3 deletions

File tree

‎docs/status.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,8 @@ or a live merged PR; the sweep and removal are covered by unit tests with a real
363363

364364
- **Worktree hygiene.** Local pruning requires an explicit release of an exact commit,
365365
fresh merged-work proof, no active or dirty state, and a verified recovery bundle.
366-
The shared command supports a daily macOS user job and keeps unknown legacy worktrees
366+
A squash-merged tip whose tree matches the landed commit counts as proof, so branches
367+
that merged `main` in before landing can be pruned. The shared command supports a daily macOS user job and keeps unknown legacy worktrees
367368
and remote branches. Desktop retirement respects project-script retention. Maintenance
368369
regression tests run in the standard test suite. [Procedure and recovery](worktree-hygiene.md)
369370
and [verification](reviews/2026-10-11-worktree-hygiene.md).

‎docs/worktree-hygiene.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@ Each run fetches the GitHub default branch without pruning refs and reads PRs th
6262
malformed GitHub responses preserve work. Any open PR for the branch blocks pruning.
6363
- The exact tip is an ancestor of the freshly fetched default branch, or a merged PR has
6464
that exact repository, branch, and tip, and its reachable single-parent squash commit
65-
has the identical complete binary diff. Ambiguous merge histories stay preserved.
65+
either has the identical tree (every path, mode, blob, and gitlink) or the identical
66+
complete binary diff. The tree match also covers branches that merged the default branch
67+
in before landing. Without either match, ambiguous merge histories stay preserved.
6668
- Archive refs and a self-contained bundle have been created, restored into an empty
6769
bare repository, checked for exact tip equality, and passed `git fsck --full`.
6870

‎scripts/lib/worktree-maintenance.mjs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,11 @@ export class WorktreeMaintenance {
248248
if (!this.ancestor(merge, info.defaultHead)) continue;
249249
const parents = this.text(['rev-list', '--parents', '-n', '1', merge]).split(' ').slice(1);
250250
if (parents.length !== 1) continue; // Only unambiguous single-parent squash commits.
251-
const bases = this.text(['merge-base', '--all', row.head, parents[0]]).split('\n');
251+
// An identical tree (every path, mode, blob and gitlink) means the tip is exactly the snapshot
252+
// that landed, so merge commits on the branch cannot hold anything the default branch lacks.
253+
const tree = this.text(['rev-parse', '--verify', `${row.head}^{tree}`]);
254+
if (fullSha(tree) && tree === this.text(['rev-parse', '--verify', `${merge}^{tree}`])) return { kind: 'squash-tree', pull: pr.number, mergeCommit: merge, tree, defaultHead: info.defaultHead };
255+
const bases =this.text(['merge-base', '--all', row.head, parents[0]]).split('\n');
252256
if (bases.length !== 1 || !bases[0]) continue;
253257
if (this.text(['rev-list', '--min-parents=2', `${bases[0]}..${row.head}`])) continue;
254258
const flags = ['diff', '--binary', '--full-index', '--no-ext-diff', '--no-textconv', '--no-renames', '--no-color', '--ignore-submodules=none', '--submodule=short', '--no-relative'];

‎scripts/test/worktree-maintenance.test.mjs‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,41 @@ for (const kind of ['wrong-head', 'wrong-base', 'patch-mismatch']) {
132132
});
133133
}
134134

135+
// A branch that merged main into itself before landing, as long-lived PR branches do.
136+
function mergedMainBranch(f) {
137+
const w = f.create();
138+
fs.writeFileSync(path.join(f.primary, 'other.txt'), 'main moved\n');
139+
git(f.primary, 'add', 'other.txt'); git(f.primary, 'commit', '-m', 'main moved'); git(f.primary, 'push', 'origin', 'main');
140+
git(w.location, 'merge', '--no-ff', 'main', '-m', 'merge main');
141+
w.head = git(w.location, 'rev-parse', 'HEAD');
142+
assert.ok(git(w.location, 'rev-list', '--min-parents=2', 'main..HEAD'), 'branch carries a merge commit');
143+
return w;
144+
}
145+
146+
test('squash proof accepts a branch with merge commits whose tip tree is the landed tree', async (t) => {
147+
const f = fixture(t); const w = mergedMainBranch(f); f.merge(w, true); await f.release(w);
148+
assert.equal(git(f.primary, 'rev-parse', `${w.head}^{tree}`), git(f.primary, 'rev-parse', 'main^{tree}'));
149+
const row = await f.row(w.name);
150+
assert.equal(row.eligible, true, row.reasons.join(','));
151+
assert.equal(row.proof.kind, 'squash-tree');
152+
const result = await f.core.prune({ apply: true });
153+
assert.equal(result.results[0].status, 'removed');
154+
assert.equal(fs.existsSync(w.location), false);
155+
assert.equal(result.run.entries[0].proof.kind, 'squash-tree');
156+
assert.equal(git(f.primary, 'rev-parse', result.run.entries[0].archiveRef), w.head);
157+
});
158+
159+
for (const kind of ['wrong-head', 'wrong-base', 'tree-mismatch']) {
160+
test(`rejects tree proof for a branch with merge commits with ${kind}`, async (t) => {
161+
const f = fixture(t); const w = mergedMainBranch(f); f.merge(w, true); await f.release(w);
162+
if (kind === 'wrong-head') f.pulls[0].head.sha = '1'.repeat(40);
163+
if (kind === 'wrong-base') f.pulls[0].base.ref = 'other';
164+
if (kind === 'tree-mismatch') { fs.appendFileSync(path.join(f.primary, 'code.txt'), 'only part landed\n'); git(f.primary, 'commit', '-am', 'different squash', '--amend'); git(f.primary, 'push', '--force', 'origin', 'main'); f.pulls[0].merge_commit_sha = git(f.primary, 'rev-parse', 'HEAD'); }
165+
assert.deepEqual((await f.row(w.name)).reasons, ['not-proven-merged']);
166+
await f.core.prune({ apply: true }); assert.equal(f.exists(w), true);
167+
});
168+
}
169+
135170
test('changed head after audit is preserved by apply', async (t) => {
136171
const f = fixture(t); const w = f.create(); f.merge(w); await f.release(w);
137172
assert.equal((await f.row()).eligible, true);

0 commit comments

Comments
 (0)