@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
package/src/worktrees.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WorktreeService = exports.Worktree = exports.WorktreeWorkInFlight = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const child_process_1 = require("child_process");
|
|
6
|
+
const fs = tslib_1.__importStar(require("fs"));
|
|
7
|
+
const path = tslib_1.__importStar(require("path"));
|
|
8
|
+
const inversify_1 = require("inversify");
|
|
9
|
+
const to_error_1 = require("@webpieces/tooling-common/to-error");
|
|
10
|
+
/**
|
|
11
|
+
* Reading the git worktree list.
|
|
12
|
+
*
|
|
13
|
+
* WHY this exists separately from merged-branches: a worktree is a SECOND budget. Every worktree holds
|
|
14
|
+
* a branch, so if worktree-held branches also counted against the local-branch cap, five worktrees
|
|
15
|
+
* would consume the entire branch budget and no branch could ever be created again. The two are
|
|
16
|
+
* therefore counted apart — held branches against the worktree cap, parked branches against the branch
|
|
17
|
+
* cap — and this service is what tells the two apart.
|
|
18
|
+
*
|
|
19
|
+
* Unlike the merged-PR lookup, everything here is LOCAL and cheap (one `git worktree list`), so it is
|
|
20
|
+
* safe to call on the guard's blocking path.
|
|
21
|
+
*/
|
|
22
|
+
// `git worktree list --porcelain` emits blank-line-separated records. The first record is always the
|
|
23
|
+
// main worktree. Keys are space-separated; `detached`, `bare` and `locked` may appear bare (no value).
|
|
24
|
+
const WORKTREE_KEY = 'worktree ';
|
|
25
|
+
const BRANCH_KEY = 'branch ';
|
|
26
|
+
const HEAD_KEY = 'HEAD ';
|
|
27
|
+
const LOCKED_KEY = 'locked ';
|
|
28
|
+
const REFS_HEADS = 'refs/heads/';
|
|
29
|
+
/**
|
|
30
|
+
* Whether a worktree is being worked in, and the reason in the words an operator gets shown.
|
|
31
|
+
*
|
|
32
|
+
* Data-only (per CLAUDE.md, classes for data). A bare boolean was the first cut and it collapsed
|
|
33
|
+
* "there are uncommitted files here" into "git would not answer me" — two different things to tell
|
|
34
|
+
* somebody who is deciding whether a directory is safe to remove.
|
|
35
|
+
*/
|
|
36
|
+
class WorktreeWorkInFlight {
|
|
37
|
+
held;
|
|
38
|
+
reason;
|
|
39
|
+
constructor(held, reason) {
|
|
40
|
+
this.held = held;
|
|
41
|
+
this.reason = reason;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
exports.WorktreeWorkInFlight = WorktreeWorkInFlight;
|
|
45
|
+
// Data-only (per CLAUDE.md, classes for data).
|
|
46
|
+
class Worktree {
|
|
47
|
+
path;
|
|
48
|
+
// Short branch name (refs/heads/ stripped). Empty when the worktree is detached or bare.
|
|
49
|
+
branch;
|
|
50
|
+
/**
|
|
51
|
+
* The commit this worktree's HEAD is at, verbatim from the `HEAD <sha>` line every porcelain record
|
|
52
|
+
* already carries. '' only for a bare worktree, which has no HEAD at all.
|
|
53
|
+
*
|
|
54
|
+
* Load-bearing, and the reason it stopped being dropped by the parser: a BRANCH NAME does not
|
|
55
|
+
* identify a tree. A second clone, or a stale worktree, can hold the same name at a different
|
|
56
|
+
* commit — which is the wrong-objects-under-the-right-name failure `wp-land-pr` already refused to
|
|
57
|
+
* make from the tree it happened to be standing in. Pairing the name with this sha turns that veto
|
|
58
|
+
* into a SELECTION: the tree to archive from and reap is the one whose HEAD is the exact commit
|
|
59
|
+
* GitHub squashed, whichever directory the operator is standing in. See LandedTreeResolver.
|
|
60
|
+
*/
|
|
61
|
+
head;
|
|
62
|
+
// The primary clone — the one that owns .git. Never counted against the cap, never removable.
|
|
63
|
+
isMain;
|
|
64
|
+
// git already knows this worktree's directory is gone; `git worktree prune` will clear it.
|
|
65
|
+
prunable;
|
|
66
|
+
// Somebody ran `git worktree lock`. WHO is a question only `lockReason` can answer.
|
|
67
|
+
locked;
|
|
68
|
+
/**
|
|
69
|
+
* The `--reason` text verbatim, '' when the lock carried none.
|
|
70
|
+
*
|
|
71
|
+
* Load-bearing, not decoration: the Claude Code agent harness locks every worktree it opens for a
|
|
72
|
+
* subagent and writes a machine-readable reason naming the agent and its pid. Without this field
|
|
73
|
+
* `locked` collapsed a live agent, a dead agent and a human into one verdict, and wp-cleanup
|
|
74
|
+
* reported all three as "locked by a human". See agent-worktree-lock.ts.
|
|
75
|
+
*/
|
|
76
|
+
lockReason;
|
|
77
|
+
// eslint-disable-next-line @typescript-eslint/max-params
|
|
78
|
+
constructor(path, branch,
|
|
79
|
+
// REQUIRED, with no default, for the same reason lockReason is: a defaulted '' would read as
|
|
80
|
+
// "this tree is not at any commit", and every sha comparison built on it would silently decline.
|
|
81
|
+
head, isMain, prunable, locked,
|
|
82
|
+
// REQUIRED, with no default. A 5-arg construction would silently mean "no reason recorded",
|
|
83
|
+
// which routes straight back to the bug this exists to fix — every agent lock unreadable and
|
|
84
|
+
// its worktree spared forever. A missing argument must be a compile error, not a quiet ''.
|
|
85
|
+
lockReason) {
|
|
86
|
+
this.path = path;
|
|
87
|
+
this.branch = branch;
|
|
88
|
+
this.head = head;
|
|
89
|
+
this.isMain = isMain;
|
|
90
|
+
this.prunable = prunable;
|
|
91
|
+
this.locked = locked;
|
|
92
|
+
this.lockReason = lockReason;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
exports.Worktree = Worktree;
|
|
96
|
+
let WorktreeService = class WorktreeService {
|
|
97
|
+
/**
|
|
98
|
+
* Every worktree, main one first. Fails SOFT to [] — a repo with no worktree support, or a git
|
|
99
|
+
* that errors, must read as "no worktrees" so the cap fails OPEN rather than blocking on data we
|
|
100
|
+
* do not have.
|
|
101
|
+
*/
|
|
102
|
+
listWorktrees(repoRoot) {
|
|
103
|
+
const result = this.capture(repoRoot, ['worktree', 'list', '--porcelain']);
|
|
104
|
+
if (!result.ok || result.out === '')
|
|
105
|
+
return [];
|
|
106
|
+
return this.parsePorcelain(result.out);
|
|
107
|
+
}
|
|
108
|
+
// The linked worktrees — everything except the primary clone. This is what the cap counts.
|
|
109
|
+
linkedWorktrees(repoRoot) {
|
|
110
|
+
return this.listWorktrees(repoRoot).filter((tree) => !tree.isMain);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Branch names checked out in ANY worktree, including the primary clone's own HEAD.
|
|
114
|
+
*
|
|
115
|
+
* Two callers, one reason: git flatly refuses to delete a branch that is checked out somewhere.
|
|
116
|
+
* A held branch must never be proposed for `git branch -D` (the delete would fail and take the
|
|
117
|
+
* whole reap command down with it), and it must not be counted as a parked branch either.
|
|
118
|
+
*/
|
|
119
|
+
heldBranches(repoRoot) {
|
|
120
|
+
const held = new Set();
|
|
121
|
+
for (const tree of this.listWorktrees(repoRoot)) {
|
|
122
|
+
if (tree.branch !== '')
|
|
123
|
+
held.add(tree.branch);
|
|
124
|
+
}
|
|
125
|
+
return held;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Am I standing in a LINKED worktree (as opposed to the primary clone)?
|
|
129
|
+
*
|
|
130
|
+
* This is the question every recovery message needs, because the two trees take different
|
|
131
|
+
* commands: `git checkout main` fatals inside a linked worktree ("main is already checked out
|
|
132
|
+
* at <primary>"), and a dead linked worktree is reaped with `git worktree remove`, not
|
|
133
|
+
* `git branch -d`. A guard that cannot tell them apart must print BOTH forms and let the AI
|
|
134
|
+
* guess — which is exactly how an AI ends up running the fatal one.
|
|
135
|
+
*
|
|
136
|
+
* The test is a single `statSync`, no process spawn: git gives a linked worktree a `.git` FILE
|
|
137
|
+
* (a `gitdir:` pointer) where the primary clone has a `.git` DIRECTORY. This runs on the read
|
|
138
|
+
* path, where reads vastly outnumber every other tool call, so the cost matters.
|
|
139
|
+
*
|
|
140
|
+
* It is a CHEAP FAST PATH over the authoritative answer, not a second authority. The authority is
|
|
141
|
+
* `DotWebpieces.gitDirs` — `gitDir !== commonDir`, git's own canonical test, which also handles the
|
|
142
|
+
* layouts a `.git` stat cannot see (`--separate-git-dir`, submodules). This is NOT calling it,
|
|
143
|
+
* deliberately: that costs a `rev-parse` spawn per read. `worktree-identity.spec.ts` asserts the two
|
|
144
|
+
* agree for the primary clone, an in-repo `.claude/worktrees/**` worktree and a sibling worktree
|
|
145
|
+
* outside the repo — so if git ever changes the `.git` layout this fails there, not in the field.
|
|
146
|
+
* Anything that needs to be RIGHT rather than cheap (tree identity, state paths) asks `gitDirs`.
|
|
147
|
+
*
|
|
148
|
+
* Returns FALSE on anything uncertain (no `.git` at all, unreadable, a submodule's `.git` file
|
|
149
|
+
* in a non-worktree checkout). False is the fail-open direction here: callers then print both
|
|
150
|
+
* forms rather than confidently printing the wrong one.
|
|
151
|
+
*
|
|
152
|
+
* This answers "am I in a linked worktree", which is a question about MESSAGE WORDING and nothing
|
|
153
|
+
* else. It is deliberately NOT a way to ask "which worktree am I in" so as to then act on that tree:
|
|
154
|
+
* a `currentWorktree(root)` used to exist for exactly that and it is deleted, because pnpm hoists a
|
|
155
|
+
* bin's cwd out of a nested `.claude/worktrees/**` tree and made the answer confidently wrong. Work
|
|
156
|
+
* that has to NAME a tree resolves it from a commit — see pr-gate's LandedTreeResolver.
|
|
157
|
+
*/
|
|
158
|
+
isLinkedWorktree(root) {
|
|
159
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
160
|
+
try {
|
|
161
|
+
return !fs.statSync(path.join(root, '.git')).isDirectory();
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
const error = (0, to_error_1.toError)(err);
|
|
165
|
+
void error;
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Is somebody working in this worktree — and if so, WHY do we say that?
|
|
171
|
+
*
|
|
172
|
+
* THE ONE CHECK THAT MAKES A ZERO-COMMIT WORKTREE UNREAPABLE. A ref with no commits of its own
|
|
173
|
+
* loses nothing when it is deleted — that is a property of the REF. A DIRECTORY is different:
|
|
174
|
+
* every worktree looks exactly like that husk from `git worktree add -b` until its first commit,
|
|
175
|
+
* which is precisely the window an agent is working in. `git status --porcelain` is what tells
|
|
176
|
+
* those two apart, and it is one cheap local spawn per candidate.
|
|
177
|
+
*
|
|
178
|
+
* It returns the REASON rather than a bare boolean because the two ways of being held are not the
|
|
179
|
+
* same sentence to a human: "it has uncommitted files" is a fact about the tree, and "git could
|
|
180
|
+
* not tell me" is a fact about our evidence. Both spare the worktree — unknown must fail SAFE,
|
|
181
|
+
* since the fail-safe direction for deleting a directory is to leave it — but reporting the
|
|
182
|
+
* second as the first tells an operator to go look for edits that are not there.
|
|
183
|
+
*/
|
|
184
|
+
workInFlight(worktreePath) {
|
|
185
|
+
const result = (0, child_process_1.spawnSync)('git', ['status', '--porcelain'], { cwd: worktreePath, encoding: 'utf8' });
|
|
186
|
+
if (result.status !== 0 || typeof result.stdout !== 'string') {
|
|
187
|
+
return new WorktreeWorkInFlight(true, '`git status` could not report on it, so it is not provably empty');
|
|
188
|
+
}
|
|
189
|
+
if (result.stdout.trim() !== '') {
|
|
190
|
+
return new WorktreeWorkInFlight(true, 'has uncommitted or untracked files');
|
|
191
|
+
}
|
|
192
|
+
return new WorktreeWorkInFlight(false, '');
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Parse the porcelain records. A record starts at a `worktree <path>` line and runs to the blank
|
|
196
|
+
* line; the FIRST record is the main worktree (git guarantees the ordering). A `prunable` worktree
|
|
197
|
+
* still appears in the list, which is exactly why it can be reaped.
|
|
198
|
+
*/
|
|
199
|
+
parsePorcelain(out) {
|
|
200
|
+
const trees = [];
|
|
201
|
+
let path = '';
|
|
202
|
+
let branch = '';
|
|
203
|
+
let head = '';
|
|
204
|
+
let prunable = false;
|
|
205
|
+
let locked = false;
|
|
206
|
+
let lockReason = '';
|
|
207
|
+
const flush = () => {
|
|
208
|
+
if (path === '')
|
|
209
|
+
return;
|
|
210
|
+
trees.push(new Worktree(path, branch, head, trees.length === 0, prunable, locked, lockReason));
|
|
211
|
+
path = '';
|
|
212
|
+
branch = '';
|
|
213
|
+
head = '';
|
|
214
|
+
prunable = false;
|
|
215
|
+
locked = false;
|
|
216
|
+
lockReason = '';
|
|
217
|
+
};
|
|
218
|
+
for (const raw of out.split('\n')) {
|
|
219
|
+
const line = raw.trim();
|
|
220
|
+
if (line === '') {
|
|
221
|
+
flush();
|
|
222
|
+
}
|
|
223
|
+
else if (line.startsWith(WORKTREE_KEY)) {
|
|
224
|
+
// A new record begins — flush the previous one in case the blank line was missing.
|
|
225
|
+
flush();
|
|
226
|
+
path = line.slice(WORKTREE_KEY.length).trim();
|
|
227
|
+
}
|
|
228
|
+
else if (line.startsWith(HEAD_KEY)) {
|
|
229
|
+
// Already on the wire — git emits it for every non-bare record, detached ones included.
|
|
230
|
+
head = line.slice(HEAD_KEY.length).trim();
|
|
231
|
+
}
|
|
232
|
+
else if (line.startsWith(BRANCH_KEY)) {
|
|
233
|
+
const ref = line.slice(BRANCH_KEY.length).trim();
|
|
234
|
+
branch = ref.startsWith(REFS_HEADS) ? ref.slice(REFS_HEADS.length) : ref;
|
|
235
|
+
}
|
|
236
|
+
else if (line === 'prunable' || line.startsWith('prunable ')) {
|
|
237
|
+
prunable = true;
|
|
238
|
+
}
|
|
239
|
+
else if (line === LOCKED_KEY.trim() || line.startsWith(LOCKED_KEY)) {
|
|
240
|
+
locked = true;
|
|
241
|
+
// `locked` alone is a lock with no --reason; `locked <text>` carries the reason verbatim.
|
|
242
|
+
lockReason = line.slice(LOCKED_KEY.length).trim();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
flush();
|
|
246
|
+
return trees;
|
|
247
|
+
}
|
|
248
|
+
// Run a git command capturing trimmed stdout; ok=false on spawn failure or non-zero exit.
|
|
249
|
+
capture(repoRoot, args) {
|
|
250
|
+
const result = (0, child_process_1.spawnSync)('git', args, { cwd: repoRoot, encoding: 'utf8' });
|
|
251
|
+
if (result.status !== 0 || typeof result.stdout !== 'string')
|
|
252
|
+
return { ok: false, out: '' };
|
|
253
|
+
return { ok: true, out: result.stdout.trim() };
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
exports.WorktreeService = WorktreeService;
|
|
257
|
+
exports.WorktreeService = WorktreeService = tslib_1.__decorate([
|
|
258
|
+
(0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton)
|
|
259
|
+
], WorktreeService);
|
|
260
|
+
//# sourceMappingURL=worktrees.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"worktrees.js","sourceRoot":"","sources":["../../../../../packages/tooling/repo-workflow-core/src/worktrees.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { toError } from '@webpieces/tooling-common/to-error';\n\n/**\n * Reading the git worktree list.\n *\n * WHY this exists separately from merged-branches: a worktree is a SECOND budget. Every worktree holds\n * a branch, so if worktree-held branches also counted against the local-branch cap, five worktrees\n * would consume the entire branch budget and no branch could ever be created again. The two are\n * therefore counted apart — held branches against the worktree cap, parked branches against the branch\n * cap — and this service is what tells the two apart.\n *\n * Unlike the merged-PR lookup, everything here is LOCAL and cheap (one `git worktree list`), so it is\n * safe to call on the guard's blocking path.\n */\n\n// `git worktree list --porcelain` emits blank-line-separated records. The first record is always the\n// main worktree. Keys are space-separated; `detached`, `bare` and `locked` may appear bare (no value).\nconst WORKTREE_KEY = 'worktree ';\nconst BRANCH_KEY = 'branch ';\nconst HEAD_KEY = 'HEAD ';\nconst LOCKED_KEY = 'locked ';\nconst REFS_HEADS = 'refs/heads/';\n\n/**\n * Whether a worktree is being worked in, and the reason in the words an operator gets shown.\n *\n * Data-only (per CLAUDE.md, classes for data). A bare boolean was the first cut and it collapsed\n * \"there are uncommitted files here\" into \"git would not answer me\" — two different things to tell\n * somebody who is deciding whether a directory is safe to remove.\n */\nexport class WorktreeWorkInFlight {\n held: boolean;\n reason: string;\n\n constructor(held: boolean, reason: string) {\n this.held = held;\n this.reason = reason;\n }\n}\n\n// Data-only (per CLAUDE.md, classes for data).\nexport class Worktree {\n path: string;\n // Short branch name (refs/heads/ stripped). Empty when the worktree is detached or bare.\n branch: string;\n /**\n * The commit this worktree's HEAD is at, verbatim from the `HEAD <sha>` line every porcelain record\n * already carries. '' only for a bare worktree, which has no HEAD at all.\n *\n * Load-bearing, and the reason it stopped being dropped by the parser: a BRANCH NAME does not\n * identify a tree. A second clone, or a stale worktree, can hold the same name at a different\n * commit — which is the wrong-objects-under-the-right-name failure `wp-land-pr` already refused to\n * make from the tree it happened to be standing in. Pairing the name with this sha turns that veto\n * into a SELECTION: the tree to archive from and reap is the one whose HEAD is the exact commit\n * GitHub squashed, whichever directory the operator is standing in. See LandedTreeResolver.\n */\n head: string;\n // The primary clone — the one that owns .git. Never counted against the cap, never removable.\n isMain: boolean;\n // git already knows this worktree's directory is gone; `git worktree prune` will clear it.\n prunable: boolean;\n // Somebody ran `git worktree lock`. WHO is a question only `lockReason` can answer.\n locked: boolean;\n /**\n * The `--reason` text verbatim, '' when the lock carried none.\n *\n * Load-bearing, not decoration: the Claude Code agent harness locks every worktree it opens for a\n * subagent and writes a machine-readable reason naming the agent and its pid. Without this field\n * `locked` collapsed a live agent, a dead agent and a human into one verdict, and wp-cleanup\n * reported all three as \"locked by a human\". See agent-worktree-lock.ts.\n */\n lockReason: string;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n path: string,\n branch: string,\n // REQUIRED, with no default, for the same reason lockReason is: a defaulted '' would read as\n // \"this tree is not at any commit\", and every sha comparison built on it would silently decline.\n head: string,\n isMain: boolean,\n prunable: boolean,\n locked: boolean,\n // REQUIRED, with no default. A 5-arg construction would silently mean \"no reason recorded\",\n // which routes straight back to the bug this exists to fix — every agent lock unreadable and\n // its worktree spared forever. A missing argument must be a compile error, not a quiet ''.\n lockReason: string,\n ) {\n this.path = path;\n this.branch = branch;\n this.head = head;\n this.isMain = isMain;\n this.prunable = prunable;\n this.locked = locked;\n this.lockReason = lockReason;\n }\n}\n\n// Result of a captured git invocation: ok=false on spawn failure or non-zero exit.\ninterface CmdCapture {\n ok: boolean;\n out: string;\n}\n\n@injectable(bindingScopeValues.Singleton)\nexport class WorktreeService {\n /**\n * Every worktree, main one first. Fails SOFT to [] — a repo with no worktree support, or a git\n * that errors, must read as \"no worktrees\" so the cap fails OPEN rather than blocking on data we\n * do not have.\n */\n listWorktrees(repoRoot: string): Worktree[] {\n const result = this.capture(repoRoot, ['worktree', 'list', '--porcelain']);\n if (!result.ok || result.out === '') return [];\n return this.parsePorcelain(result.out);\n }\n\n // The linked worktrees — everything except the primary clone. This is what the cap counts.\n linkedWorktrees(repoRoot: string): Worktree[] {\n return this.listWorktrees(repoRoot).filter((tree: Worktree): boolean => !tree.isMain);\n }\n\n /**\n * Branch names checked out in ANY worktree, including the primary clone's own HEAD.\n *\n * Two callers, one reason: git flatly refuses to delete a branch that is checked out somewhere.\n * A held branch must never be proposed for `git branch -D` (the delete would fail and take the\n * whole reap command down with it), and it must not be counted as a parked branch either.\n */\n heldBranches(repoRoot: string): Set<string> {\n const held = new Set<string>();\n for (const tree of this.listWorktrees(repoRoot)) {\n if (tree.branch !== '') held.add(tree.branch);\n }\n return held;\n }\n\n /**\n * Am I standing in a LINKED worktree (as opposed to the primary clone)?\n *\n * This is the question every recovery message needs, because the two trees take different\n * commands: `git checkout main` fatals inside a linked worktree (\"main is already checked out\n * at <primary>\"), and a dead linked worktree is reaped with `git worktree remove`, not\n * `git branch -d`. A guard that cannot tell them apart must print BOTH forms and let the AI\n * guess — which is exactly how an AI ends up running the fatal one.\n *\n * The test is a single `statSync`, no process spawn: git gives a linked worktree a `.git` FILE\n * (a `gitdir:` pointer) where the primary clone has a `.git` DIRECTORY. This runs on the read\n * path, where reads vastly outnumber every other tool call, so the cost matters.\n *\n * It is a CHEAP FAST PATH over the authoritative answer, not a second authority. The authority is\n * `DotWebpieces.gitDirs` — `gitDir !== commonDir`, git's own canonical test, which also handles the\n * layouts a `.git` stat cannot see (`--separate-git-dir`, submodules). This is NOT calling it,\n * deliberately: that costs a `rev-parse` spawn per read. `worktree-identity.spec.ts` asserts the two\n * agree for the primary clone, an in-repo `.claude/worktrees/**` worktree and a sibling worktree\n * outside the repo — so if git ever changes the `.git` layout this fails there, not in the field.\n * Anything that needs to be RIGHT rather than cheap (tree identity, state paths) asks `gitDirs`.\n *\n * Returns FALSE on anything uncertain (no `.git` at all, unreadable, a submodule's `.git` file\n * in a non-worktree checkout). False is the fail-open direction here: callers then print both\n * forms rather than confidently printing the wrong one.\n *\n * This answers \"am I in a linked worktree\", which is a question about MESSAGE WORDING and nothing\n * else. It is deliberately NOT a way to ask \"which worktree am I in\" so as to then act on that tree:\n * a `currentWorktree(root)` used to exist for exactly that and it is deleted, because pnpm hoists a\n * bin's cwd out of a nested `.claude/worktrees/**` tree and made the answer confidently wrong. Work\n * that has to NAME a tree resolves it from a commit — see pr-gate's LandedTreeResolver.\n */\n isLinkedWorktree(root: string): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return !fs.statSync(path.join(root, '.git')).isDirectory();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return false;\n }\n }\n\n /**\n * Is somebody working in this worktree — and if so, WHY do we say that?\n *\n * THE ONE CHECK THAT MAKES A ZERO-COMMIT WORKTREE UNREAPABLE. A ref with no commits of its own\n * loses nothing when it is deleted — that is a property of the REF. A DIRECTORY is different:\n * every worktree looks exactly like that husk from `git worktree add -b` until its first commit,\n * which is precisely the window an agent is working in. `git status --porcelain` is what tells\n * those two apart, and it is one cheap local spawn per candidate.\n *\n * It returns the REASON rather than a bare boolean because the two ways of being held are not the\n * same sentence to a human: \"it has uncommitted files\" is a fact about the tree, and \"git could\n * not tell me\" is a fact about our evidence. Both spare the worktree — unknown must fail SAFE,\n * since the fail-safe direction for deleting a directory is to leave it — but reporting the\n * second as the first tells an operator to go look for edits that are not there.\n */\n workInFlight(worktreePath: string): WorktreeWorkInFlight {\n const result = spawnSync('git', ['status', '--porcelain'], { cwd: worktreePath, encoding: 'utf8' });\n if (result.status !== 0 || typeof result.stdout !== 'string') {\n return new WorktreeWorkInFlight(true,\n '`git status` could not report on it, so it is not provably empty');\n }\n if (result.stdout.trim() !== '') {\n return new WorktreeWorkInFlight(true, 'has uncommitted or untracked files');\n }\n return new WorktreeWorkInFlight(false, '');\n }\n\n /**\n * Parse the porcelain records. A record starts at a `worktree <path>` line and runs to the blank\n * line; the FIRST record is the main worktree (git guarantees the ordering). A `prunable` worktree\n * still appears in the list, which is exactly why it can be reaped.\n */\n private parsePorcelain(out: string): Worktree[] {\n const trees: Worktree[] = [];\n let path = '';\n let branch = '';\n let head = '';\n let prunable = false;\n let locked = false;\n let lockReason = '';\n\n const flush = (): void => {\n if (path === '') return;\n trees.push(new Worktree(path, branch, head, trees.length === 0, prunable, locked, lockReason));\n path = '';\n branch = '';\n head = '';\n prunable = false;\n locked = false;\n lockReason = '';\n };\n\n for (const raw of out.split('\\n')) {\n const line = raw.trim();\n if (line === '') {\n flush();\n } else if (line.startsWith(WORKTREE_KEY)) {\n // A new record begins — flush the previous one in case the blank line was missing.\n flush();\n path = line.slice(WORKTREE_KEY.length).trim();\n } else if (line.startsWith(HEAD_KEY)) {\n // Already on the wire — git emits it for every non-bare record, detached ones included.\n head = line.slice(HEAD_KEY.length).trim();\n } else if (line.startsWith(BRANCH_KEY)) {\n const ref = line.slice(BRANCH_KEY.length).trim();\n branch = ref.startsWith(REFS_HEADS) ? ref.slice(REFS_HEADS.length) : ref;\n } else if (line === 'prunable' || line.startsWith('prunable ')) {\n prunable = true;\n } else if (line === LOCKED_KEY.trim() || line.startsWith(LOCKED_KEY)) {\n locked = true;\n // `locked` alone is a lock with no --reason; `locked <text>` carries the reason verbatim.\n lockReason = line.slice(LOCKED_KEY.length).trim();\n }\n }\n flush();\n\n return trees;\n }\n\n // Run a git command capturing trimmed stdout; 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 if (result.status !== 0 || typeof result.stdout !== 'string') return { ok: false, out: '' };\n return { ok: true, out: result.stdout.trim() };\n }\n}\n"]}
|