@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.
- package/package.json +31 -0
- package/src/agent-worktree-lock.d.ts +21 -0
- package/src/agent-worktree-lock.js +77 -0
- package/src/agent-worktree-lock.js.map +1 -0
- package/src/branch-archiver.d.ts +70 -0
- package/src/branch-archiver.js +157 -0
- package/src/branch-archiver.js.map +1 -0
- package/src/branch-identity.d.ts +6 -0
- package/src/branch-identity.js +59 -0
- package/src/branch-identity.js.map +1 -0
- package/src/branch-mutation-log.d.ts +77 -0
- package/src/branch-mutation-log.js +221 -0
- package/src/branch-mutation-log.js.map +1 -0
- package/src/branch-reaper.d.ts +122 -0
- package/src/branch-reaper.js +261 -0
- package/src/branch-reaper.js.map +1 -0
- package/src/harness-agent-activity.d.ts +154 -0
- package/src/harness-agent-activity.js +346 -0
- package/src/harness-agent-activity.js.map +1 -0
- package/src/index.d.ts +14 -0
- package/src/index.js +18 -0
- package/src/index.js.map +1 -0
- package/src/main-sync-file.d.ts +121 -0
- package/src/main-sync-file.js +281 -0
- package/src/main-sync-file.js.map +1 -0
- package/src/main-sync-status.d.ts +197 -0
- package/src/main-sync-status.js +555 -0
- package/src/main-sync-status.js.map +1 -0
- package/src/merged-branch-verdicts.d.ts +189 -0
- package/src/merged-branch-verdicts.js +251 -0
- package/src/merged-branch-verdicts.js.map +1 -0
- package/src/merged-branches.d.ts +140 -0
- package/src/merged-branches.js +521 -0
- package/src/merged-branches.js.map +1 -0
- package/src/review-identity-stamp.d.ts +82 -0
- package/src/review-identity-stamp.js +172 -0
- package/src/review-identity-stamp.js.map +1 -0
- package/src/worktree-lock-verdicts.d.ts +149 -0
- package/src/worktree-lock-verdicts.js +225 -0
- package/src/worktree-lock-verdicts.js.map +1 -0
- package/src/worktree-reaper.d.ts +116 -0
- package/src/worktree-reaper.js +318 -0
- package/src/worktree-reaper.js.map +1 -0
- package/src/worktrees.d.ts +113 -0
- package/src/worktrees.js +260 -0
- 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
|
+
}
|