@webpieces/rules-config 0.4.497 → 0.4.499
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/branch-mutation-log.d.ts +13 -2
- package/src/branch-mutation-log.js +33 -1
- package/src/branch-mutation-log.js.map +1 -1
- package/src/checklist-config.js +1 -1
- package/src/checklist-config.js.map +1 -1
- package/src/checklist-instructions.d.ts +1 -1
- package/src/checklist-instructions.js +28 -13
- package/src/checklist-instructions.js.map +1 -1
- package/src/diff-scope.d.ts +10 -0
- package/src/diff-scope.js +14 -1
- package/src/diff-scope.js.map +1 -1
- package/src/index.d.ts +5 -3
- package/src/index.js +19 -3
- package/src/index.js.map +1 -1
- package/src/merged-branches.d.ts +24 -1
- package/src/merged-branches.js +36 -8
- package/src/merged-branches.js.map +1 -1
- package/src/pr-gate-config.d.ts +53 -0
- package/src/pr-gate-config.js +66 -4
- package/src/pr-gate-config.js.map +1 -1
- package/src/review-json.d.ts +35 -2
- package/src/review-json.js +73 -14
- package/src/review-json.js.map +1 -1
- package/src/reviewer-instructions.d.ts +86 -0
- package/src/reviewer-instructions.js +236 -0
- package/src/reviewer-instructions.js.map +1 -0
- package/src/subagent-provenance.d.ts +55 -1
- package/src/subagent-provenance.js +143 -5
- package/src/subagent-provenance.js.map +1 -1
- package/src/worktree-reaper.d.ts +116 -0
- package/src/worktree-reaper.js +305 -0
- package/src/worktree-reaper.js.map +1 -0
- package/templates/webpieces.git-workflow.md +13 -7
- package/templates/webpieces.review-checklists.md +19 -8
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WorktreeReaper = exports.WorktreeReapResult = exports.ReapedWorktree = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const child_process_1 = require("child_process");
|
|
6
|
+
const path = tslib_1.__importStar(require("path"));
|
|
7
|
+
const inversify_1 = require("inversify");
|
|
8
|
+
const branch_archiver_1 = require("./branch-archiver");
|
|
9
|
+
const branch_mutation_log_1 = require("./branch-mutation-log");
|
|
10
|
+
const merged_branches_1 = require("./merged-branches");
|
|
11
|
+
const worktrees_1 = require("./worktrees");
|
|
12
|
+
/**
|
|
13
|
+
* The EXECUTOR for the dead-WORKTREE verdicts that merged-branches.ts has been computing all along.
|
|
14
|
+
*
|
|
15
|
+
* WHY this exists: `DeletableWorktree` was designed for reaping — it carries `path` (what
|
|
16
|
+
* `git worktree remove` takes) AND `branch` (what `git branch -D` takes afterwards) precisely because
|
|
17
|
+
* a reap is always those two steps in that order. The reaping was then never wired up. The verdicts
|
|
18
|
+
* had exactly one consumer, branch-creation-guard, which used them only to BLOCK: at the worktree cap
|
|
19
|
+
* it printed the reap commands and refused to create the next worktree. Nothing ever ran them.
|
|
20
|
+
*
|
|
21
|
+
* That composes into a deadlock, not merely a missing feature. A merged worktree HOLDS its branch, so
|
|
22
|
+
* the branch lands in `keep` with "checked out in worktree '<path>' — remove that worktree before
|
|
23
|
+
* deleting the branch". Nothing removes the worktree. Both accumulate forever, and the guard's only
|
|
24
|
+
* remaining advice is to loosen its own cap — which is the failure the cap exists to prevent. Observed
|
|
25
|
+
* twice in one day: a `wp-cleanup` run that spared three worktree-held branches with that exact line,
|
|
26
|
+
* and another that spared seven.
|
|
27
|
+
*
|
|
28
|
+
* THE ORDER IS FIXED AND LOAD-BEARING: archive the branch as a tag → `git worktree remove <path>` →
|
|
29
|
+
* `git branch -D <branch>`.
|
|
30
|
+
* - Archive FIRST, and if it fails nothing is deleted. Same rule BranchArchiver already enforces for
|
|
31
|
+
* branches: a branch we could not tag is a branch whose only copy would be the reflog.
|
|
32
|
+
* - Remove the worktree BEFORE the branch, because git flatly refuses to delete a branch that is
|
|
33
|
+
* still checked out somewhere.
|
|
34
|
+
*
|
|
35
|
+
* AND NEVER `--force`. Git refuses to remove a worktree with uncommitted changes or untracked files,
|
|
36
|
+
* and that refusal is the entire safety property here: a worktree removal deletes real FILES, not just
|
|
37
|
+
* a ref, and an untracked file is by definition something no archive tag captured. A failed removal is
|
|
38
|
+
* reported and moved past, exactly as a failed branch delete already is.
|
|
39
|
+
*/
|
|
40
|
+
// Data-only (per CLAUDE.md, classes for data). One worktree and what happened to it.
|
|
41
|
+
class ReapedWorktree {
|
|
42
|
+
path;
|
|
43
|
+
// The branch the worktree held, '' when it was detached.
|
|
44
|
+
branch;
|
|
45
|
+
// The branch's tip BEFORE anything was destroyed. '' when it could not be resolved (or detached).
|
|
46
|
+
sha;
|
|
47
|
+
reason;
|
|
48
|
+
pr;
|
|
49
|
+
ok;
|
|
50
|
+
// git's own stderr when ok=false. Kept verbatim — a refused removal is a thing a human must read.
|
|
51
|
+
error;
|
|
52
|
+
// The `archive/<date>/<branch>` tag written before the removal, or '' (policy 'delete', or detached).
|
|
53
|
+
archiveTag = '';
|
|
54
|
+
/**
|
|
55
|
+
* Did the BRANCH delete also succeed? A worktree removal that succeeds while the branch delete
|
|
56
|
+
* fails is a real, reportable half-state — the directory is gone, the branch is still there — and
|
|
57
|
+
* collapsing it into `ok` would hide it. `ok` means the DIRECTORY is gone; this means the pair is.
|
|
58
|
+
*/
|
|
59
|
+
branchDeleted = false;
|
|
60
|
+
// eslint-disable-next-line @typescript-eslint/max-params
|
|
61
|
+
constructor(treePath, branch, sha, reason, pr, ok, error) {
|
|
62
|
+
this.path = treePath;
|
|
63
|
+
this.branch = branch;
|
|
64
|
+
this.sha = sha;
|
|
65
|
+
this.reason = reason;
|
|
66
|
+
this.pr = pr;
|
|
67
|
+
this.ok = ok;
|
|
68
|
+
this.error = error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
exports.ReapedWorktree = ReapedWorktree;
|
|
72
|
+
/**
|
|
73
|
+
* The outcome of one worktree reap. `spared` carries the worktrees we refused to touch, each with its
|
|
74
|
+
* reason, for the same reason ReapResult does: a cleanup silent about what it did NOT remove reads as
|
|
75
|
+
* "there was nothing else", when those are exactly the ones only a human can rule on.
|
|
76
|
+
*/
|
|
77
|
+
class WorktreeReapResult {
|
|
78
|
+
reaped;
|
|
79
|
+
failed;
|
|
80
|
+
spared;
|
|
81
|
+
constructor(reaped, failed, spared) {
|
|
82
|
+
this.reaped = reaped;
|
|
83
|
+
this.failed = failed;
|
|
84
|
+
this.spared = spared;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
exports.WorktreeReapResult = WorktreeReapResult;
|
|
88
|
+
// The two never-removable sets, kept apart so each refusal can name its own reason (data-only class
|
|
89
|
+
// per CLAUDE.md — `primary` is the clone that owns .git, `current` is the tree wp-cleanup runs in).
|
|
90
|
+
class ProtectedPaths {
|
|
91
|
+
primary;
|
|
92
|
+
current;
|
|
93
|
+
constructor(primary, current) {
|
|
94
|
+
this.primary = primary;
|
|
95
|
+
this.current = current;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
let WorktreeReaper = class WorktreeReaper {
|
|
99
|
+
worktrees;
|
|
100
|
+
mutationLog;
|
|
101
|
+
archiver;
|
|
102
|
+
// Defaulted like BranchReaper's collaborators, so the non-DI call sites can just
|
|
103
|
+
// `new WorktreeReaper()` while inversify still injects the singletons from a container.
|
|
104
|
+
constructor(worktrees = new worktrees_1.WorktreeService(), mutationLog = new branch_mutation_log_1.BranchMutationLog(), archiver = new branch_archiver_1.BranchArchiver()) {
|
|
105
|
+
this.worktrees = worktrees;
|
|
106
|
+
this.mutationLog = mutationLog;
|
|
107
|
+
this.archiver = archiver;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Reap every worktree in `targets`, skipping any the safety rails refuse.
|
|
111
|
+
*
|
|
112
|
+
* `cwd` is passed in rather than read from `process.cwd()` here so the "never remove the tree I am
|
|
113
|
+
* standing in" rule is testable and so a caller running from a subdirectory still gets the right
|
|
114
|
+
* answer — the containing WORKTREE is what matters, not the exact directory.
|
|
115
|
+
*
|
|
116
|
+
* `targets` is caller-chosen on purpose. wp-cleanup passes the provably-dead ones unattended, and
|
|
117
|
+
* passes human-approved probably-dead ones on a second call. The safety rails below apply to both:
|
|
118
|
+
* no answer at a prompt can authorise removing your own cwd or the primary clone.
|
|
119
|
+
*/
|
|
120
|
+
// eslint-disable-next-line @typescript-eslint/max-params
|
|
121
|
+
reapWorktrees(repoRoot, cwd, verb, targets, retention = branch_archiver_1.BRANCH_RETENTION_ARCHIVE_TAG) {
|
|
122
|
+
// 'keep' means "delete nothing, ever" — the reap degrades to a pure report, exactly as it does
|
|
123
|
+
// for branches, so a repo that opted out of destructive cleanup still SEES what would have gone.
|
|
124
|
+
if (retention === branch_archiver_1.BRANCH_RETENTION_KEEP)
|
|
125
|
+
return new WorktreeReapResult([], [], targets);
|
|
126
|
+
const protectedPaths = this.protectedPaths(repoRoot, cwd);
|
|
127
|
+
const reaped = [];
|
|
128
|
+
const failed = [];
|
|
129
|
+
const spared = [];
|
|
130
|
+
for (const target of targets) {
|
|
131
|
+
const refusal = this.refuseReason(target, protectedPaths);
|
|
132
|
+
if (refusal !== '') {
|
|
133
|
+
spared.push(new merged_branches_1.DeletableWorktree(target.path, target.branch, refusal, target.pr, false, target.classification));
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const outcome = this.removeOne(repoRoot, verb, target, retention);
|
|
137
|
+
if (outcome.ok)
|
|
138
|
+
reaped.push(outcome);
|
|
139
|
+
else
|
|
140
|
+
failed.push(outcome);
|
|
141
|
+
}
|
|
142
|
+
return new WorktreeReapResult(reaped, failed, spared);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The two directories that must never be removed, resolved to absolute paths so a relative
|
|
146
|
+
* `../foo` in a verdict can still be compared against them.
|
|
147
|
+
*
|
|
148
|
+
* - THE PRIMARY CLONE. It owns `.git`; `git worktree remove` cannot take it, and a caller who
|
|
149
|
+
* somehow got it into a target list has a bug we must not execute.
|
|
150
|
+
* - THE TREE WE ARE STANDING IN. Removing your own cwd mid-command deletes the files underneath
|
|
151
|
+
* the running process — including, when the tooling is invoked by an agent, the checkout the
|
|
152
|
+
* agent's next tool call will try to read. merged-branches.ts already declines to mark it
|
|
153
|
+
* deletable; this is the second, independent line, because the caller supplies the list.
|
|
154
|
+
*/
|
|
155
|
+
protectedPaths(repoRoot, cwd) {
|
|
156
|
+
const primary = new Set();
|
|
157
|
+
for (const tree of this.worktrees.listWorktrees(repoRoot)) {
|
|
158
|
+
if (tree.isMain)
|
|
159
|
+
primary.add(path.resolve(tree.path));
|
|
160
|
+
}
|
|
161
|
+
const here = new Set();
|
|
162
|
+
const current = this.currentTree(repoRoot, cwd);
|
|
163
|
+
if (current !== null)
|
|
164
|
+
here.add(path.resolve(current.path));
|
|
165
|
+
// Fail SAFE when git could not name the current worktree: protect the raw paths anyway. An
|
|
166
|
+
// over-protected path costs one worktree that survives to the next cleanup; an under-protected
|
|
167
|
+
// one costs the directory the command is running in.
|
|
168
|
+
here.add(path.resolve(repoRoot));
|
|
169
|
+
here.add(path.resolve(cwd));
|
|
170
|
+
return new ProtectedPaths(primary, here);
|
|
171
|
+
}
|
|
172
|
+
// The worktree record CONTAINING cwd — not merely the one whose path equals it, so `wp-cleanup`
|
|
173
|
+
// run from `packages/whatever` inside a worktree still protects that worktree.
|
|
174
|
+
currentTree(repoRoot, cwd) {
|
|
175
|
+
const here = path.resolve(cwd);
|
|
176
|
+
let best = null;
|
|
177
|
+
for (const tree of this.worktrees.listWorktrees(repoRoot)) {
|
|
178
|
+
const root = path.resolve(tree.path);
|
|
179
|
+
if (here !== root && !here.startsWith(root + path.sep))
|
|
180
|
+
continue;
|
|
181
|
+
// Longest match wins: worktrees can nest, and the innermost one is the one we are in.
|
|
182
|
+
if (best === null || root.length > path.resolve(best.path).length)
|
|
183
|
+
best = tree;
|
|
184
|
+
}
|
|
185
|
+
return best;
|
|
186
|
+
}
|
|
187
|
+
// '' when the target may be reaped; otherwise the human-readable reason it was spared instead. The
|
|
188
|
+
// two rails report SEPARATELY: "you are standing in it" and "that is the primary clone" are
|
|
189
|
+
// different mistakes, and a message covering both tells the reader neither.
|
|
190
|
+
refuseReason(target, protectedPaths) {
|
|
191
|
+
if (target.path === '')
|
|
192
|
+
return 'no path recorded for this worktree — nothing safe to remove';
|
|
193
|
+
const resolved = path.resolve(target.path);
|
|
194
|
+
if (protectedPaths.primary.has(resolved)) {
|
|
195
|
+
return 'refused — that is the primary clone, which owns .git and is not removable';
|
|
196
|
+
}
|
|
197
|
+
if (protectedPaths.current.has(resolved)) {
|
|
198
|
+
return 'refused — this command is running in that worktree; removing your own cwd is a self-destruct';
|
|
199
|
+
}
|
|
200
|
+
return '';
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* One worktree: ARCHIVE the branch, REMOVE the directory, then DELETE the branch — and stop at the
|
|
204
|
+
* first step that fails.
|
|
205
|
+
*
|
|
206
|
+
* The prunable case is genuinely different and is why the classification token rides along on the
|
|
207
|
+
* verdict: the directory is ALREADY gone, so `git worktree remove` fails on it and the reap is
|
|
208
|
+
* `git worktree prune`. There is also nothing to archive from a directory that no longer exists —
|
|
209
|
+
* the branch itself is still archived, since it may well still hold the only copy of some work.
|
|
210
|
+
*/
|
|
211
|
+
removeOne(repoRoot, verb, target, retention) {
|
|
212
|
+
// Tip first: after the branch is gone there is nothing left to resolve, and the audit line's
|
|
213
|
+
// whole job is to record what was destroyed while it still exists.
|
|
214
|
+
const sha = target.branch !== '' ? this.revParse(repoRoot, target.branch) : '';
|
|
215
|
+
const archive = this.archiveBranch(repoRoot, target, retention, sha);
|
|
216
|
+
if (!archive.ok)
|
|
217
|
+
return this.archiveFailed(repoRoot, verb, target, sha, archive);
|
|
218
|
+
const removed = target.classification === merged_branches_1.CLASSIFICATION_PRUNABLE
|
|
219
|
+
? this.capture(repoRoot, ['worktree', 'prune'])
|
|
220
|
+
: this.capture(repoRoot, ['worktree', 'remove', target.path]);
|
|
221
|
+
if (!removed.ok)
|
|
222
|
+
return this.removalFailed(repoRoot, verb, target, sha, archive, removed.err);
|
|
223
|
+
// Only NOW may the branch go: git refuses while a worktree still holds it.
|
|
224
|
+
const branchDeleted = target.branch === ''
|
|
225
|
+
|| this.capture(repoRoot, ['branch', '-D', target.branch]).ok;
|
|
226
|
+
const result = new ReapedWorktree(target.path, target.branch, sha, target.reason, target.pr, true, '');
|
|
227
|
+
result.archiveTag = archive.tag;
|
|
228
|
+
result.branchDeleted = branchDeleted;
|
|
229
|
+
this.log(repoRoot, verb, target, sha, archive.tag, branchDeleted
|
|
230
|
+
? `removed worktree and deleted branch (${target.reason})`
|
|
231
|
+
: `removed worktree; branch '${target.branch}' survived (git refused the delete)`);
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
// Archiving is skipped for a detached worktree (no branch to tag) and under retention 'delete'.
|
|
235
|
+
// Both report ok=true with an empty tag: there is nothing to archive, which is not a failure.
|
|
236
|
+
archiveBranch(repoRoot, target, retention, sha) {
|
|
237
|
+
if (target.branch === '' || retention !== branch_archiver_1.BRANCH_RETENTION_ARCHIVE_TAG) {
|
|
238
|
+
return new branch_archiver_1.ArchiveResult('', sha, true, '');
|
|
239
|
+
}
|
|
240
|
+
return this.archiver.archive(repoRoot, target.branch);
|
|
241
|
+
}
|
|
242
|
+
// Archive refused ⇒ NOTHING is removed. The directory survives to the next cleanup, which is the
|
|
243
|
+
// fail-safe direction: the alternative is deleting files whose branch has no permanent ref.
|
|
244
|
+
// eslint-disable-next-line @typescript-eslint/max-params
|
|
245
|
+
archiveFailed(repoRoot, verb, target, sha, archive) {
|
|
246
|
+
const error = `not removed — could not archive its branch first: ${archive.error}`;
|
|
247
|
+
this.log(repoRoot, verb, target, sha, '', `SKIPPED (${error})`);
|
|
248
|
+
return new ReapedWorktree(target.path, target.branch, sha, target.reason, target.pr, false, error);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* git refused to remove the directory — nearly always because it holds uncommitted changes or
|
|
252
|
+
* untracked files. Reported with git's own words and moved past. We do NOT retry with `--force`:
|
|
253
|
+
* an untracked file is work no archive tag captured, and `--force` is how a cleanup command turns
|
|
254
|
+
* into a data-loss command. The branch is left alone too, since it is still checked out here.
|
|
255
|
+
*/
|
|
256
|
+
// eslint-disable-next-line @typescript-eslint/max-params
|
|
257
|
+
removalFailed(repoRoot, verb, target, sha, archive, err) {
|
|
258
|
+
const error = `git refused to remove it: ${err} (not forced — untracked or modified files are `
|
|
259
|
+
+ 'work nothing has archived; remove them or the worktree by hand)';
|
|
260
|
+
this.log(repoRoot, verb, target, sha, archive.tag, `FAILED (${err})`);
|
|
261
|
+
const result = new ReapedWorktree(target.path, target.branch, sha, target.reason, target.pr, false, error);
|
|
262
|
+
result.archiveTag = archive.tag;
|
|
263
|
+
return result;
|
|
264
|
+
}
|
|
265
|
+
// eslint-disable-next-line @typescript-eslint/max-params
|
|
266
|
+
log(repoRoot, verb, target, sha, archiveTag, outcome) {
|
|
267
|
+
const event = new branch_mutation_log_1.BranchMutationEvent(verb, 'REAP_WORKTREE');
|
|
268
|
+
event.fromBranch = target.branch;
|
|
269
|
+
event.sha = sha;
|
|
270
|
+
event.archiveTag = archiveTag;
|
|
271
|
+
event.worktreePath = target.path;
|
|
272
|
+
event.outcome = outcome;
|
|
273
|
+
this.mutationLog.logBranchMutation(repoRoot, event);
|
|
274
|
+
}
|
|
275
|
+
/** The literal command that puts BOTH the directory and the branch back. */
|
|
276
|
+
restoreCommand(target) {
|
|
277
|
+
const ref = target.archiveTag !== '' ? target.archiveTag : target.sha;
|
|
278
|
+
if (ref === '')
|
|
279
|
+
return `git worktree add ${target.path} <ref>`;
|
|
280
|
+
if (target.branch === '')
|
|
281
|
+
return `git worktree add ${target.path} ${ref}`;
|
|
282
|
+
return `git worktree add -b ${target.branch} ${target.path} ${ref}`;
|
|
283
|
+
}
|
|
284
|
+
revParse(repoRoot, ref) {
|
|
285
|
+
const result = this.capture(repoRoot, ['rev-parse', ref]);
|
|
286
|
+
return result.ok ? result.out : '';
|
|
287
|
+
}
|
|
288
|
+
// Run a git command capturing trimmed stdout/stderr; ok=false on spawn failure or non-zero exit.
|
|
289
|
+
capture(repoRoot, args) {
|
|
290
|
+
const result = (0, child_process_1.spawnSync)('git', args, { cwd: repoRoot, encoding: 'utf8' });
|
|
291
|
+
const err = typeof result.stderr === 'string' ? result.stderr.trim() : '';
|
|
292
|
+
if (result.status !== 0 || typeof result.stdout !== 'string') {
|
|
293
|
+
return { ok: false, out: '', err: err !== '' ? err : 'git command failed' };
|
|
294
|
+
}
|
|
295
|
+
return { ok: true, out: result.stdout.trim(), err };
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
exports.WorktreeReaper = WorktreeReaper;
|
|
299
|
+
exports.WorktreeReaper = WorktreeReaper = tslib_1.__decorate([
|
|
300
|
+
(0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
|
|
301
|
+
tslib_1.__metadata("design:paramtypes", [worktrees_1.WorktreeService,
|
|
302
|
+
branch_mutation_log_1.BranchMutationLog,
|
|
303
|
+
branch_archiver_1.BranchArchiver])
|
|
304
|
+
], WorktreeReaper);
|
|
305
|
+
//# sourceMappingURL=worktree-reaper.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"worktree-reaper.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/worktree-reaper.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport * as path from 'path';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport {\n ArchiveResult,\n BranchArchiver,\n BRANCH_RETENTION_ARCHIVE_TAG,\n BRANCH_RETENTION_KEEP,\n} from './branch-archiver';\nimport { BranchMutationEvent, BranchMutationLog, MutationVerb } from './branch-mutation-log';\nimport { CLASSIFICATION_PRUNABLE, DeletableWorktree } from './merged-branches';\nimport { Worktree, WorktreeService } from './worktrees';\n\n/**\n * The EXECUTOR for the dead-WORKTREE verdicts that merged-branches.ts has been computing all along.\n *\n * WHY this exists: `DeletableWorktree` was designed for reaping — it carries `path` (what\n * `git worktree remove` takes) AND `branch` (what `git branch -D` takes afterwards) precisely because\n * a reap is always those two steps in that order. The reaping was then never wired up. The verdicts\n * had exactly one consumer, branch-creation-guard, which used them only to BLOCK: at the worktree cap\n * it printed the reap commands and refused to create the next worktree. Nothing ever ran them.\n *\n * That composes into a deadlock, not merely a missing feature. A merged worktree HOLDS its branch, so\n * the branch lands in `keep` with \"checked out in worktree '<path>' — remove that worktree before\n * deleting the branch\". Nothing removes the worktree. Both accumulate forever, and the guard's only\n * remaining advice is to loosen its own cap — which is the failure the cap exists to prevent. Observed\n * twice in one day: a `wp-cleanup` run that spared three worktree-held branches with that exact line,\n * and another that spared seven.\n *\n * THE ORDER IS FIXED AND LOAD-BEARING: archive the branch as a tag → `git worktree remove <path>` →\n * `git branch -D <branch>`.\n * - Archive FIRST, and if it fails nothing is deleted. Same rule BranchArchiver already enforces for\n * branches: a branch we could not tag is a branch whose only copy would be the reflog.\n * - Remove the worktree BEFORE the branch, because git flatly refuses to delete a branch that is\n * still checked out somewhere.\n *\n * AND NEVER `--force`. Git refuses to remove a worktree with uncommitted changes or untracked files,\n * and that refusal is the entire safety property here: a worktree removal deletes real FILES, not just\n * a ref, and an untracked file is by definition something no archive tag captured. A failed removal is\n * reported and moved past, exactly as a failed branch delete already is.\n */\n\n// Data-only (per CLAUDE.md, classes for data). One worktree and what happened to it.\nexport class ReapedWorktree {\n path: string;\n // The branch the worktree held, '' when it was detached.\n branch: string;\n // The branch's tip BEFORE anything was destroyed. '' when it could not be resolved (or detached).\n sha: string;\n reason: string;\n pr: number;\n ok: boolean;\n // git's own stderr when ok=false. Kept verbatim — a refused removal is a thing a human must read.\n error: string;\n // The `archive/<date>/<branch>` tag written before the removal, or '' (policy 'delete', or detached).\n archiveTag: string = '';\n /**\n * Did the BRANCH delete also succeed? A worktree removal that succeeds while the branch delete\n * fails is a real, reportable half-state — the directory is gone, the branch is still there — and\n * collapsing it into `ok` would hide it. `ok` means the DIRECTORY is gone; this means the pair is.\n */\n branchDeleted: boolean = false;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(treePath: string, branch: string, sha: string, reason: string, pr: number, ok: boolean, error: string) {\n this.path = treePath;\n this.branch = branch;\n this.sha = sha;\n this.reason = reason;\n this.pr = pr;\n this.ok = ok;\n this.error = error;\n }\n}\n\n/**\n * The outcome of one worktree reap. `spared` carries the worktrees we refused to touch, each with its\n * reason, for the same reason ReapResult does: a cleanup silent about what it did NOT remove reads as\n * \"there was nothing else\", when those are exactly the ones only a human can rule on.\n */\nexport class WorktreeReapResult {\n reaped: ReapedWorktree[];\n failed: ReapedWorktree[];\n spared: DeletableWorktree[];\n\n constructor(reaped: ReapedWorktree[], failed: ReapedWorktree[], spared: DeletableWorktree[]) {\n this.reaped = reaped;\n this.failed = failed;\n this.spared = spared;\n }\n}\n\n// The two never-removable sets, kept apart so each refusal can name its own reason (data-only class\n// per CLAUDE.md — `primary` is the clone that owns .git, `current` is the tree wp-cleanup runs in).\nclass ProtectedPaths {\n primary: Set<string>;\n current: Set<string>;\n\n constructor(primary: Set<string>, current: Set<string>) {\n this.primary = primary;\n this.current = current;\n }\n}\n\n// Result of a captured git invocation. `err` carries stderr so a refused removal can be reported.\ninterface CmdCapture {\n ok: boolean;\n out: string;\n err: string;\n}\n\n@injectable(bindingScopeValues.Singleton)\nexport class WorktreeReaper {\n // Defaulted like BranchReaper's collaborators, so the non-DI call sites can just\n // `new WorktreeReaper()` while inversify still injects the singletons from a container.\n constructor(\n private readonly worktrees: WorktreeService = new WorktreeService(),\n private readonly mutationLog: BranchMutationLog = new BranchMutationLog(),\n private readonly archiver: BranchArchiver = new BranchArchiver(),\n ) {}\n\n /**\n * Reap every worktree in `targets`, skipping any the safety rails refuse.\n *\n * `cwd` is passed in rather than read from `process.cwd()` here so the \"never remove the tree I am\n * standing in\" rule is testable and so a caller running from a subdirectory still gets the right\n * answer — the containing WORKTREE is what matters, not the exact directory.\n *\n * `targets` is caller-chosen on purpose. wp-cleanup passes the provably-dead ones unattended, and\n * passes human-approved probably-dead ones on a second call. The safety rails below apply to both:\n * no answer at a prompt can authorise removing your own cwd or the primary clone.\n */\n // eslint-disable-next-line @typescript-eslint/max-params\n reapWorktrees(\n repoRoot: string,\n cwd: string,\n verb: MutationVerb,\n targets: DeletableWorktree[],\n retention: string = BRANCH_RETENTION_ARCHIVE_TAG,\n ): WorktreeReapResult {\n // 'keep' means \"delete nothing, ever\" — the reap degrades to a pure report, exactly as it does\n // for branches, so a repo that opted out of destructive cleanup still SEES what would have gone.\n if (retention === BRANCH_RETENTION_KEEP) return new WorktreeReapResult([], [], targets);\n\n const protectedPaths = this.protectedPaths(repoRoot, cwd);\n const reaped: ReapedWorktree[] = [];\n const failed: ReapedWorktree[] = [];\n const spared: DeletableWorktree[] = [];\n\n for (const target of targets) {\n const refusal = this.refuseReason(target, protectedPaths);\n if (refusal !== '') {\n spared.push(new DeletableWorktree(\n target.path, target.branch, refusal, target.pr, false, target.classification));\n continue;\n }\n const outcome = this.removeOne(repoRoot, verb, target, retention);\n if (outcome.ok) reaped.push(outcome);\n else failed.push(outcome);\n }\n\n return new WorktreeReapResult(reaped, failed, spared);\n }\n\n /**\n * The two directories that must never be removed, resolved to absolute paths so a relative\n * `../foo` in a verdict can still be compared against them.\n *\n * - THE PRIMARY CLONE. It owns `.git`; `git worktree remove` cannot take it, and a caller who\n * somehow got it into a target list has a bug we must not execute.\n * - THE TREE WE ARE STANDING IN. Removing your own cwd mid-command deletes the files underneath\n * the running process — including, when the tooling is invoked by an agent, the checkout the\n * agent's next tool call will try to read. merged-branches.ts already declines to mark it\n * deletable; this is the second, independent line, because the caller supplies the list.\n */\n private protectedPaths(repoRoot: string, cwd: string): ProtectedPaths {\n const primary = new Set<string>();\n for (const tree of this.worktrees.listWorktrees(repoRoot)) {\n if (tree.isMain) primary.add(path.resolve(tree.path));\n }\n\n const here = new Set<string>();\n const current = this.currentTree(repoRoot, cwd);\n if (current !== null) here.add(path.resolve(current.path));\n // Fail SAFE when git could not name the current worktree: protect the raw paths anyway. An\n // over-protected path costs one worktree that survives to the next cleanup; an under-protected\n // one costs the directory the command is running in.\n here.add(path.resolve(repoRoot));\n here.add(path.resolve(cwd));\n return new ProtectedPaths(primary, here);\n }\n\n // The worktree record CONTAINING cwd — not merely the one whose path equals it, so `wp-cleanup`\n // run from `packages/whatever` inside a worktree still protects that worktree.\n private currentTree(repoRoot: string, cwd: string): Worktree | null {\n const here = path.resolve(cwd);\n let best: Worktree | null = null;\n for (const tree of this.worktrees.listWorktrees(repoRoot)) {\n const root = path.resolve(tree.path);\n if (here !== root && !here.startsWith(root + path.sep)) continue;\n // Longest match wins: worktrees can nest, and the innermost one is the one we are in.\n if (best === null || root.length > path.resolve(best.path).length) best = tree;\n }\n return best;\n }\n\n // '' when the target may be reaped; otherwise the human-readable reason it was spared instead. The\n // two rails report SEPARATELY: \"you are standing in it\" and \"that is the primary clone\" are\n // different mistakes, and a message covering both tells the reader neither.\n private refuseReason(target: DeletableWorktree, protectedPaths: ProtectedPaths): string {\n if (target.path === '') return 'no path recorded for this worktree — nothing safe to remove';\n const resolved = path.resolve(target.path);\n if (protectedPaths.primary.has(resolved)) {\n return 'refused — that is the primary clone, which owns .git and is not removable';\n }\n if (protectedPaths.current.has(resolved)) {\n return 'refused — this command is running in that worktree; removing your own cwd is a self-destruct';\n }\n return '';\n }\n\n /**\n * One worktree: ARCHIVE the branch, REMOVE the directory, then DELETE the branch — and stop at the\n * first step that fails.\n *\n * The prunable case is genuinely different and is why the classification token rides along on the\n * verdict: the directory is ALREADY gone, so `git worktree remove` fails on it and the reap is\n * `git worktree prune`. There is also nothing to archive from a directory that no longer exists —\n * the branch itself is still archived, since it may well still hold the only copy of some work.\n */\n private removeOne(\n repoRoot: string,\n verb: MutationVerb,\n target: DeletableWorktree,\n retention: string,\n ): ReapedWorktree {\n // Tip first: after the branch is gone there is nothing left to resolve, and the audit line's\n // whole job is to record what was destroyed while it still exists.\n const sha = target.branch !== '' ? this.revParse(repoRoot, target.branch) : '';\n\n const archive = this.archiveBranch(repoRoot, target, retention, sha);\n if (!archive.ok) return this.archiveFailed(repoRoot, verb, target, sha, archive);\n\n const removed = target.classification === CLASSIFICATION_PRUNABLE\n ? this.capture(repoRoot, ['worktree', 'prune'])\n : this.capture(repoRoot, ['worktree', 'remove', target.path]);\n if (!removed.ok) return this.removalFailed(repoRoot, verb, target, sha, archive, removed.err);\n\n // Only NOW may the branch go: git refuses while a worktree still holds it.\n const branchDeleted = target.branch === ''\n || this.capture(repoRoot, ['branch', '-D', target.branch]).ok;\n\n const result = new ReapedWorktree(\n target.path, target.branch, sha, target.reason, target.pr, true, '');\n result.archiveTag = archive.tag;\n result.branchDeleted = branchDeleted;\n\n this.log(repoRoot, verb, target, sha, archive.tag, branchDeleted\n ? `removed worktree and deleted branch (${target.reason})`\n : `removed worktree; branch '${target.branch}' survived (git refused the delete)`);\n return result;\n }\n\n // Archiving is skipped for a detached worktree (no branch to tag) and under retention 'delete'.\n // Both report ok=true with an empty tag: there is nothing to archive, which is not a failure.\n private archiveBranch(\n repoRoot: string, target: DeletableWorktree, retention: string, sha: string,\n ): ArchiveResult {\n if (target.branch === '' || retention !== BRANCH_RETENTION_ARCHIVE_TAG) {\n return new ArchiveResult('', sha, true, '');\n }\n return this.archiver.archive(repoRoot, target.branch);\n }\n\n // Archive refused ⇒ NOTHING is removed. The directory survives to the next cleanup, which is the\n // fail-safe direction: the alternative is deleting files whose branch has no permanent ref.\n // eslint-disable-next-line @typescript-eslint/max-params\n private archiveFailed(\n repoRoot: string, verb: MutationVerb, target: DeletableWorktree, sha: string, archive: ArchiveResult,\n ): ReapedWorktree {\n const error = `not removed — could not archive its branch first: ${archive.error}`;\n this.log(repoRoot, verb, target, sha, '', `SKIPPED (${error})`);\n return new ReapedWorktree(target.path, target.branch, sha, target.reason, target.pr, false, error);\n }\n\n /**\n * git refused to remove the directory — nearly always because it holds uncommitted changes or\n * untracked files. Reported with git's own words and moved past. We do NOT retry with `--force`:\n * an untracked file is work no archive tag captured, and `--force` is how a cleanup command turns\n * into a data-loss command. The branch is left alone too, since it is still checked out here.\n */\n // eslint-disable-next-line @typescript-eslint/max-params\n private removalFailed(\n repoRoot: string, verb: MutationVerb, target: DeletableWorktree, sha: string,\n archive: ArchiveResult, err: string,\n ): ReapedWorktree {\n const error = `git refused to remove it: ${err} (not forced — untracked or modified files are `\n + 'work nothing has archived; remove them or the worktree by hand)';\n this.log(repoRoot, verb, target, sha, archive.tag, `FAILED (${err})`);\n const result = new ReapedWorktree(\n target.path, target.branch, sha, target.reason, target.pr, false, error);\n result.archiveTag = archive.tag;\n return result;\n }\n\n // eslint-disable-next-line @typescript-eslint/max-params\n private log(\n repoRoot: string, verb: MutationVerb, target: DeletableWorktree,\n sha: string, archiveTag: string, outcome: string,\n ): void {\n const event = new BranchMutationEvent(verb, 'REAP_WORKTREE');\n event.fromBranch = target.branch;\n event.sha = sha;\n event.archiveTag = archiveTag;\n event.worktreePath = target.path;\n event.outcome = outcome;\n this.mutationLog.logBranchMutation(repoRoot, event);\n }\n\n /** The literal command that puts BOTH the directory and the branch back. */\n restoreCommand(target: ReapedWorktree): string {\n const ref = target.archiveTag !== '' ? target.archiveTag : target.sha;\n if (ref === '') return `git worktree add ${target.path} <ref>`;\n if (target.branch === '') return `git worktree add ${target.path} ${ref}`;\n return `git worktree add -b ${target.branch} ${target.path} ${ref}`;\n }\n\n private revParse(repoRoot: string, ref: string): string {\n const result = this.capture(repoRoot, ['rev-parse', ref]);\n return result.ok ? result.out : '';\n }\n\n // Run a git command capturing trimmed stdout/stderr; ok=false on spawn failure or non-zero exit.\n private capture(repoRoot: string, args: string[]): CmdCapture {\n const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' });\n const err = typeof result.stderr === 'string' ? result.stderr.trim() : '';\n if (result.status !== 0 || typeof result.stdout !== 'string') {\n return { ok: false, out: '', err: err !== '' ? err : 'git command failed' };\n }\n return { ok: true, out: result.stdout.trim(), err };\n }\n}\n"]}
|
|
@@ -105,13 +105,19 @@ flow are not interchangeable.
|
|
|
105
105
|
2. **`pnpm wp-finish-update`** — finalize a squash-update after you've resolved its conflicts (validates +
|
|
106
106
|
commits + swaps the branch). Only needed on the conflict path; a clean `wp-start-update` finalizes
|
|
107
107
|
itself. It does NOT build or post a PR.
|
|
108
|
-
3. **`pnpm wp-start-upsert-pr`** — the PR flow
|
|
109
|
-
run `wp-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
4. **`pnpm wp-
|
|
113
|
-
|
|
114
|
-
|
|
108
|
+
3. **`pnpm wp-start-upsert-pr`** — the PR flow, STAGE ①: update from main (same 3-point engine), then
|
|
109
|
+
tells you to run `wp-review-upsert-pr`. It runs NO build gate and does NOT push, and never creates the
|
|
110
|
+
PR itself. On a merge conflict here, resolve it and continue with `pnpm wp-review-upsert-pr` (not
|
|
111
|
+
`wp-finish-update`, and no longer `wp-finish-upsert-pr`).
|
|
112
|
+
4. **`pnpm wp-review-upsert-pr`** — STAGE ②, and the one that verifies before anyone reviews. It validates
|
|
113
|
+
and commits any in-progress 3-point merge, asserts a clean tree, runs the **build gate**, extracts this
|
|
114
|
+
branch's diff to `.webpieces/pr-review/<branch>/diff/`, writes a per-reviewer instructions file, and
|
|
115
|
+
prints exactly what to spawn plus the `review.json` schema. It CAN fail — and when it does, no reviewer
|
|
116
|
+
has been spawned yet, so a broken branch costs no review effort.
|
|
117
|
+
5. **`pnpm wp-finish-upsert-pr`** — STAGE ③: requires stage ②'s receipt, your `review.json` (its `title`
|
|
118
|
+
becomes the PR title) and every reviewer's verdict, then pushes and creates/updates the PR. It re-runs
|
|
119
|
+
the build gate ONLY if HEAD moved since stage ②, so the three stages still cost one build.
|
|
120
|
+
6. **`pnpm wp-cleanup`** — delete the local branches that are provably dead (merged PR, squash-merge
|
|
115
121
|
backup of a merged branch, or no commits of their own). Run it after `gh pr merge`, or any time the
|
|
116
122
|
branch cap blocks you. It takes no arguments and needs no judgement call from you: it recomputes the
|
|
117
123
|
verdicts itself, deletes one branch per command, spares anything a human should rule on, and logs
|
|
@@ -22,23 +22,34 @@ array of `{ subagent, doc?, patterns? }`, and that is the **only** accepted shap
|
|
|
22
22
|
## The one command you run
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
|
-
pnpm wp-
|
|
25
|
+
pnpm wp-review-upsert-pr
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
It validates
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
It validates and commits any in-progress 3-point merge, runs the build gate, then **extracts this branch's
|
|
29
|
+
diff to disk** and writes one instructions file per reviewer under
|
|
30
|
+
`.webpieces/pr-review/<branch>/instructions/`. It prints a copy-paste spawn block per reviewer whose prompt
|
|
31
|
+
is a POINTER to that file and nothing else.
|
|
32
|
+
|
|
33
|
+
That indirection is the design. Everything volatile — the diff, the matched files, the verdict schema, the
|
|
34
|
+
resolved context paths — is REGENERATED every run, so it cannot go stale. The registered
|
|
35
|
+
`.claude/agents/<subagent>.md` stays a thin stub that says "read the instructions file your caller names".
|
|
36
|
+
When the verdict format last changed, hand-written agent files kept documenting the removed `success`
|
|
37
|
+
field and a real PR had to carry a correction in its spawn prompt to work around it; nothing restated by
|
|
38
|
+
hand can drift out of date if nothing is restated by hand.
|
|
39
|
+
|
|
40
|
+
Unlike the report-only command it replaces, this one **can fail** — on an unresolved merge or a red build.
|
|
41
|
+
It fails before any reviewer is spawned, which is the point.
|
|
32
42
|
|
|
33
43
|
Checklists already reviewed on this branch are **not** re-listed — verdict files persist, so a second
|
|
34
|
-
|
|
44
|
+
start/review/finish cycle re-instructs nothing.
|
|
35
45
|
|
|
36
46
|
`wp-finish-upsert-pr` recomputes the same set and **refuses to open the PR** while any reviewer still owes
|
|
37
47
|
a passing verdict, naming only the ones still missing.
|
|
38
48
|
|
|
39
49
|
Note that **not every checklist is pattern-matched**: one with no `patterns` runs on EVERY PR, and the
|
|
40
|
-
whole diff is in its scope. `wp-
|
|
41
|
-
a "match"
|
|
50
|
+
whole diff is in its scope. `wp-review-upsert-pr` says `ALWAYS RUNS` for those rather than calling the
|
|
51
|
+
whole diff a "match" — and says so loudly, because a patternless checklist fires on docs-only PRs too, and
|
|
52
|
+
that is usually a missing `patterns` rather than an intent.
|
|
42
53
|
|
|
43
54
|
Where patterns DO apply, matching is deliberately **coarse** — the reviewer subagent makes the fine,
|
|
44
55
|
content-level judgment by reading the actual diff.
|