@webpieces/repo-workflow-core 0.0.1

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.
Files changed (46) hide show
  1. package/package.json +31 -0
  2. package/src/agent-worktree-lock.d.ts +21 -0
  3. package/src/agent-worktree-lock.js +77 -0
  4. package/src/agent-worktree-lock.js.map +1 -0
  5. package/src/branch-archiver.d.ts +70 -0
  6. package/src/branch-archiver.js +157 -0
  7. package/src/branch-archiver.js.map +1 -0
  8. package/src/branch-identity.d.ts +6 -0
  9. package/src/branch-identity.js +59 -0
  10. package/src/branch-identity.js.map +1 -0
  11. package/src/branch-mutation-log.d.ts +77 -0
  12. package/src/branch-mutation-log.js +221 -0
  13. package/src/branch-mutation-log.js.map +1 -0
  14. package/src/branch-reaper.d.ts +122 -0
  15. package/src/branch-reaper.js +261 -0
  16. package/src/branch-reaper.js.map +1 -0
  17. package/src/harness-agent-activity.d.ts +154 -0
  18. package/src/harness-agent-activity.js +346 -0
  19. package/src/harness-agent-activity.js.map +1 -0
  20. package/src/index.d.ts +14 -0
  21. package/src/index.js +18 -0
  22. package/src/index.js.map +1 -0
  23. package/src/main-sync-file.d.ts +121 -0
  24. package/src/main-sync-file.js +281 -0
  25. package/src/main-sync-file.js.map +1 -0
  26. package/src/main-sync-status.d.ts +197 -0
  27. package/src/main-sync-status.js +555 -0
  28. package/src/main-sync-status.js.map +1 -0
  29. package/src/merged-branch-verdicts.d.ts +189 -0
  30. package/src/merged-branch-verdicts.js +251 -0
  31. package/src/merged-branch-verdicts.js.map +1 -0
  32. package/src/merged-branches.d.ts +140 -0
  33. package/src/merged-branches.js +521 -0
  34. package/src/merged-branches.js.map +1 -0
  35. package/src/review-identity-stamp.d.ts +82 -0
  36. package/src/review-identity-stamp.js +172 -0
  37. package/src/review-identity-stamp.js.map +1 -0
  38. package/src/worktree-lock-verdicts.d.ts +149 -0
  39. package/src/worktree-lock-verdicts.js +225 -0
  40. package/src/worktree-lock-verdicts.js.map +1 -0
  41. package/src/worktree-reaper.d.ts +116 -0
  42. package/src/worktree-reaper.js +318 -0
  43. package/src/worktree-reaper.js.map +1 -0
  44. package/src/worktrees.d.ts +113 -0
  45. package/src/worktrees.js +260 -0
  46. package/src/worktrees.js.map +1 -0
@@ -0,0 +1,318 @@
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_branch_verdicts_1 = require("./merged-branch-verdicts");
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_branch_verdicts_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
+ // A worktree left LOCKED by an agent that has since died: git refuses `worktree remove` while
219
+ // the lock stands, so clearing it is part of the removal rather than a separate decision. The
220
+ // decision was already made upstream — merged-branches.ts sets this ONLY for a lock whose pid
221
+ // is provably gone, and a lock held by a live agent or by anything unrecognised never gets a
222
+ // verdict that reaches here. A failed unlock is not reported on its own: the removal that
223
+ // follows fails with git's own words, which is the message a human can actually act on.
224
+ const unlockedOk = target.unlockBeforeRemove && target.classification !== merged_branch_verdicts_1.CLASSIFICATION_PRUNABLE
225
+ && this.capture(repoRoot, ['worktree', 'unlock', target.path]).ok;
226
+ const removed = target.classification === merged_branch_verdicts_1.CLASSIFICATION_PRUNABLE
227
+ ? this.capture(repoRoot, ['worktree', 'prune'])
228
+ : this.capture(repoRoot, ['worktree', 'remove', target.path]);
229
+ if (!removed.ok)
230
+ return this.removalFailed(repoRoot, verb, target, sha, archive, removed.err);
231
+ // Only NOW may the branch go: git refuses while a worktree still holds it.
232
+ const branchDeleted = target.branch === ''
233
+ || this.capture(repoRoot, ['branch', '-D', target.branch]).ok;
234
+ const result = new ReapedWorktree(target.path, target.branch, sha, target.reason, target.pr, true, '');
235
+ result.archiveTag = archive.tag;
236
+ result.branchDeleted = branchDeleted;
237
+ // The audit line records the unlock too: overriding a `git worktree lock` is the one step here
238
+ // a reader could be surprised by, so it must be in the log next to the SHA and the recover=.
239
+ // Reads the RESULT rather than asserting the attempt: a log line claiming an unlock that git
240
+ // refused would be the same species of untrue message this whole change is removing.
241
+ const unlocked = unlockedOk ? 'unlocked stale agent lock, ' : '';
242
+ this.log(repoRoot, verb, target, sha, archive.tag, branchDeleted
243
+ ? `${unlocked}removed worktree and deleted branch (${target.reason})`
244
+ : `${unlocked}removed worktree; branch '${target.branch}' survived (git refused the delete)`);
245
+ return result;
246
+ }
247
+ // Archiving is skipped for a detached worktree (no branch to tag) and under retention 'delete'.
248
+ // Both report ok=true with an empty tag: there is nothing to archive, which is not a failure.
249
+ archiveBranch(repoRoot, target, retention, sha) {
250
+ if (target.branch === '' || retention !== branch_archiver_1.BRANCH_RETENTION_ARCHIVE_TAG) {
251
+ return new branch_archiver_1.ArchiveResult('', sha, true, '');
252
+ }
253
+ return this.archiver.archive(repoRoot, target.branch);
254
+ }
255
+ // Archive refused ⇒ NOTHING is removed. The directory survives to the next cleanup, which is the
256
+ // fail-safe direction: the alternative is deleting files whose branch has no permanent ref.
257
+ // eslint-disable-next-line @typescript-eslint/max-params
258
+ archiveFailed(repoRoot, verb, target, sha, archive) {
259
+ const error = `not removed — could not archive its branch first: ${archive.error}`;
260
+ this.log(repoRoot, verb, target, sha, '', `SKIPPED (${error})`);
261
+ return new ReapedWorktree(target.path, target.branch, sha, target.reason, target.pr, false, error);
262
+ }
263
+ /**
264
+ * git refused to remove the directory — nearly always because it holds uncommitted changes or
265
+ * untracked files. Reported with git's own words and moved past. We do NOT retry with `--force`:
266
+ * an untracked file is work no archive tag captured, and `--force` is how a cleanup command turns
267
+ * into a data-loss command. The branch is left alone too, since it is still checked out here.
268
+ */
269
+ // eslint-disable-next-line @typescript-eslint/max-params
270
+ removalFailed(repoRoot, verb, target, sha, archive, err) {
271
+ const error = `git refused to remove it: ${err} (not forced — untracked or modified files are `
272
+ + 'work nothing has archived; remove them or the worktree by hand)';
273
+ this.log(repoRoot, verb, target, sha, archive.tag, `FAILED (${err})`);
274
+ const result = new ReapedWorktree(target.path, target.branch, sha, target.reason, target.pr, false, error);
275
+ result.archiveTag = archive.tag;
276
+ return result;
277
+ }
278
+ // eslint-disable-next-line @typescript-eslint/max-params
279
+ log(repoRoot, verb, target, sha, archiveTag, outcome) {
280
+ const event = new branch_mutation_log_1.BranchMutationEvent(verb, 'REAP_WORKTREE');
281
+ event.fromBranch = target.branch;
282
+ event.sha = sha;
283
+ event.archiveTag = archiveTag;
284
+ event.worktreePath = target.path;
285
+ event.outcome = outcome;
286
+ this.mutationLog.logBranchMutation(repoRoot, event);
287
+ }
288
+ /** The literal command that puts BOTH the directory and the branch back. */
289
+ restoreCommand(target) {
290
+ const ref = target.archiveTag !== '' ? target.archiveTag : target.sha;
291
+ if (ref === '')
292
+ return `git worktree add ${target.path} <ref>`;
293
+ if (target.branch === '')
294
+ return `git worktree add ${target.path} ${ref}`;
295
+ return `git worktree add -b ${target.branch} ${target.path} ${ref}`;
296
+ }
297
+ revParse(repoRoot, ref) {
298
+ const result = this.capture(repoRoot, ['rev-parse', ref]);
299
+ return result.ok ? result.out : '';
300
+ }
301
+ // Run a git command capturing trimmed stdout/stderr; ok=false on spawn failure or non-zero exit.
302
+ capture(repoRoot, args) {
303
+ const result = (0, child_process_1.spawnSync)('git', args, { cwd: repoRoot, encoding: 'utf8' });
304
+ const err = typeof result.stderr === 'string' ? result.stderr.trim() : '';
305
+ if (result.status !== 0 || typeof result.stdout !== 'string') {
306
+ return { ok: false, out: '', err: err !== '' ? err : 'git command failed' };
307
+ }
308
+ return { ok: true, out: result.stdout.trim(), err };
309
+ }
310
+ };
311
+ exports.WorktreeReaper = WorktreeReaper;
312
+ exports.WorktreeReaper = WorktreeReaper = tslib_1.__decorate([
313
+ (0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
314
+ tslib_1.__metadata("design:paramtypes", [worktrees_1.WorktreeService,
315
+ branch_mutation_log_1.BranchMutationLog,
316
+ branch_archiver_1.BranchArchiver])
317
+ ], WorktreeReaper);
318
+ //# sourceMappingURL=worktree-reaper.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worktree-reaper.js","sourceRoot":"","sources":["../../../../../packages/tooling/repo-workflow-core/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-branch-verdicts';\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 // A worktree left LOCKED by an agent that has since died: git refuses `worktree remove` while\n // the lock stands, so clearing it is part of the removal rather than a separate decision. The\n // decision was already made upstream — merged-branches.ts sets this ONLY for a lock whose pid\n // is provably gone, and a lock held by a live agent or by anything unrecognised never gets a\n // verdict that reaches here. A failed unlock is not reported on its own: the removal that\n // follows fails with git's own words, which is the message a human can actually act on.\n const unlockedOk = target.unlockBeforeRemove && target.classification !== CLASSIFICATION_PRUNABLE\n && this.capture(repoRoot, ['worktree', 'unlock', target.path]).ok;\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 // The audit line records the unlock too: overriding a `git worktree lock` is the one step here\n // a reader could be surprised by, so it must be in the log next to the SHA and the recover=.\n // Reads the RESULT rather than asserting the attempt: a log line claiming an unlock that git\n // refused would be the same species of untrue message this whole change is removing.\n const unlocked = unlockedOk ? 'unlocked stale agent lock, ' : '';\n this.log(repoRoot, verb, target, sha, archive.tag, branchDeleted\n ? `${unlocked}removed worktree and deleted branch (${target.reason})`\n : `${unlocked}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"]}
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Whether a worktree is being worked in, and the reason in the words an operator gets shown.
3
+ *
4
+ * Data-only (per CLAUDE.md, classes for data). A bare boolean was the first cut and it collapsed
5
+ * "there are uncommitted files here" into "git would not answer me" — two different things to tell
6
+ * somebody who is deciding whether a directory is safe to remove.
7
+ */
8
+ export declare class WorktreeWorkInFlight {
9
+ held: boolean;
10
+ reason: string;
11
+ constructor(held: boolean, reason: string);
12
+ }
13
+ export declare class Worktree {
14
+ path: string;
15
+ branch: string;
16
+ /**
17
+ * The commit this worktree's HEAD is at, verbatim from the `HEAD <sha>` line every porcelain record
18
+ * already carries. '' only for a bare worktree, which has no HEAD at all.
19
+ *
20
+ * Load-bearing, and the reason it stopped being dropped by the parser: a BRANCH NAME does not
21
+ * identify a tree. A second clone, or a stale worktree, can hold the same name at a different
22
+ * commit — which is the wrong-objects-under-the-right-name failure `wp-land-pr` already refused to
23
+ * make from the tree it happened to be standing in. Pairing the name with this sha turns that veto
24
+ * into a SELECTION: the tree to archive from and reap is the one whose HEAD is the exact commit
25
+ * GitHub squashed, whichever directory the operator is standing in. See LandedTreeResolver.
26
+ */
27
+ head: string;
28
+ isMain: boolean;
29
+ prunable: boolean;
30
+ locked: boolean;
31
+ /**
32
+ * The `--reason` text verbatim, '' when the lock carried none.
33
+ *
34
+ * Load-bearing, not decoration: the Claude Code agent harness locks every worktree it opens for a
35
+ * subagent and writes a machine-readable reason naming the agent and its pid. Without this field
36
+ * `locked` collapsed a live agent, a dead agent and a human into one verdict, and wp-cleanup
37
+ * reported all three as "locked by a human". See agent-worktree-lock.ts.
38
+ */
39
+ lockReason: string;
40
+ constructor(path: string, branch: string, head: string, isMain: boolean, prunable: boolean, locked: boolean, lockReason: string);
41
+ }
42
+ export declare class WorktreeService {
43
+ /**
44
+ * Every worktree, main one first. Fails SOFT to [] — a repo with no worktree support, or a git
45
+ * that errors, must read as "no worktrees" so the cap fails OPEN rather than blocking on data we
46
+ * do not have.
47
+ */
48
+ listWorktrees(repoRoot: string): Worktree[];
49
+ linkedWorktrees(repoRoot: string): Worktree[];
50
+ /**
51
+ * Branch names checked out in ANY worktree, including the primary clone's own HEAD.
52
+ *
53
+ * Two callers, one reason: git flatly refuses to delete a branch that is checked out somewhere.
54
+ * A held branch must never be proposed for `git branch -D` (the delete would fail and take the
55
+ * whole reap command down with it), and it must not be counted as a parked branch either.
56
+ */
57
+ heldBranches(repoRoot: string): Set<string>;
58
+ /**
59
+ * Am I standing in a LINKED worktree (as opposed to the primary clone)?
60
+ *
61
+ * This is the question every recovery message needs, because the two trees take different
62
+ * commands: `git checkout main` fatals inside a linked worktree ("main is already checked out
63
+ * at <primary>"), and a dead linked worktree is reaped with `git worktree remove`, not
64
+ * `git branch -d`. A guard that cannot tell them apart must print BOTH forms and let the AI
65
+ * guess — which is exactly how an AI ends up running the fatal one.
66
+ *
67
+ * The test is a single `statSync`, no process spawn: git gives a linked worktree a `.git` FILE
68
+ * (a `gitdir:` pointer) where the primary clone has a `.git` DIRECTORY. This runs on the read
69
+ * path, where reads vastly outnumber every other tool call, so the cost matters.
70
+ *
71
+ * It is a CHEAP FAST PATH over the authoritative answer, not a second authority. The authority is
72
+ * `DotWebpieces.gitDirs` — `gitDir !== commonDir`, git's own canonical test, which also handles the
73
+ * layouts a `.git` stat cannot see (`--separate-git-dir`, submodules). This is NOT calling it,
74
+ * deliberately: that costs a `rev-parse` spawn per read. `worktree-identity.spec.ts` asserts the two
75
+ * agree for the primary clone, an in-repo `.claude/worktrees/**` worktree and a sibling worktree
76
+ * outside the repo — so if git ever changes the `.git` layout this fails there, not in the field.
77
+ * Anything that needs to be RIGHT rather than cheap (tree identity, state paths) asks `gitDirs`.
78
+ *
79
+ * Returns FALSE on anything uncertain (no `.git` at all, unreadable, a submodule's `.git` file
80
+ * in a non-worktree checkout). False is the fail-open direction here: callers then print both
81
+ * forms rather than confidently printing the wrong one.
82
+ *
83
+ * This answers "am I in a linked worktree", which is a question about MESSAGE WORDING and nothing
84
+ * else. It is deliberately NOT a way to ask "which worktree am I in" so as to then act on that tree:
85
+ * a `currentWorktree(root)` used to exist for exactly that and it is deleted, because pnpm hoists a
86
+ * bin's cwd out of a nested `.claude/worktrees/**` tree and made the answer confidently wrong. Work
87
+ * that has to NAME a tree resolves it from a commit — see pr-gate's LandedTreeResolver.
88
+ */
89
+ isLinkedWorktree(root: string): boolean;
90
+ /**
91
+ * Is somebody working in this worktree — and if so, WHY do we say that?
92
+ *
93
+ * THE ONE CHECK THAT MAKES A ZERO-COMMIT WORKTREE UNREAPABLE. A ref with no commits of its own
94
+ * loses nothing when it is deleted — that is a property of the REF. A DIRECTORY is different:
95
+ * every worktree looks exactly like that husk from `git worktree add -b` until its first commit,
96
+ * which is precisely the window an agent is working in. `git status --porcelain` is what tells
97
+ * those two apart, and it is one cheap local spawn per candidate.
98
+ *
99
+ * It returns the REASON rather than a bare boolean because the two ways of being held are not the
100
+ * same sentence to a human: "it has uncommitted files" is a fact about the tree, and "git could
101
+ * not tell me" is a fact about our evidence. Both spare the worktree — unknown must fail SAFE,
102
+ * since the fail-safe direction for deleting a directory is to leave it — but reporting the
103
+ * second as the first tells an operator to go look for edits that are not there.
104
+ */
105
+ workInFlight(worktreePath: string): WorktreeWorkInFlight;
106
+ /**
107
+ * Parse the porcelain records. A record starts at a `worktree <path>` line and runs to the blank
108
+ * line; the FIRST record is the main worktree (git guarantees the ordering). A `prunable` worktree
109
+ * still appears in the list, which is exactly why it can be reaped.
110
+ */
111
+ private parsePorcelain;
112
+ private capture;
113
+ }