cyber-agentkit 0.0.0-stage → 1.0.0
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/.claude-plugin/plugin.json +19 -0
- package/.codex-plugin/plugin.json +19 -0
- package/.cursor-plugin/plugin.json +19 -0
- package/LICENSE +21 -0
- package/bin/cyber-agentkit.mjs +10 -0
- package/dist/cli.js +14307 -0
- package/dist/commands.d.ts +149 -0
- package/dist/commands.js +984 -0
- package/dist/harness-id-BgKL-x6C.d.ts +11 -0
- package/dist/index.d.ts +724 -0
- package/dist/index.js +652 -0
- package/dist/reference-where-BhymfNgA.js +10195 -0
- package/dist/resolve-reference-Cis-Ye6T.d.ts +232 -0
- package/dist/worktrees.d.ts +690 -0
- package/dist/worktrees.js +1456 -0
- package/package.json +89 -3
- package/plugin.json +27 -0
- package/readme.md +423 -0
- package/references/plugin-dependencies.md +31 -0
- package/skills/reference/README.md +63 -0
- package/skills/reference/SKILL.md +109 -0
- package/skills/reference/references/create.md +71 -0
- package/skills/reference/references/load.md +80 -0
- package/skills/reference/scripts/reference.mjs +199 -0
- package/README.md +0 -3
|
@@ -0,0 +1,1456 @@
|
|
|
1
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
2
|
+
import { copyFile, lstat, mkdir, readFile, readdir, readlink, realpath, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
//#region src/worktrees/dirty.ts
|
|
6
|
+
/**
|
|
7
|
+
* Whether a checkout has uncommitted changes, tracked or untracked, or `undefined` when git could not
|
|
8
|
+
* say. `undefined` is not `false`: a caller deciding whether a worktree is safe to reuse must not read
|
|
9
|
+
* an unanswered question as a clean tree.
|
|
10
|
+
*
|
|
11
|
+
* `--untracked-files=all` is explicit because a repo's `status.showUntrackedFiles=no` would otherwise
|
|
12
|
+
* hide untracked work.
|
|
13
|
+
*/
|
|
14
|
+
async function readDirty(worktreeRoot, options) {
|
|
15
|
+
const args = [
|
|
16
|
+
"-C",
|
|
17
|
+
worktreeRoot,
|
|
18
|
+
"status",
|
|
19
|
+
"--porcelain",
|
|
20
|
+
"--untracked-files=all"
|
|
21
|
+
];
|
|
22
|
+
if (options.ignore?.length) args.push("--", ".", ...options.ignore.map((path) => `:(exclude)${path}`));
|
|
23
|
+
const out = await options.exec("git", args);
|
|
24
|
+
return out === null ? void 0 : out.length > 0;
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
27
|
+
//#region src/worktrees/exec.ts
|
|
28
|
+
/** The default `Exec`: `execFile` with stderr captured, never inherited, because routine failures
|
|
29
|
+
* (no `origin/HEAD`, a branch with no upstream) would otherwise spam the caller's terminal. */
|
|
30
|
+
const nodeExec = (cmd, args) => new Promise((resolve) => {
|
|
31
|
+
execFile(cmd, [...args], {
|
|
32
|
+
encoding: "utf8",
|
|
33
|
+
maxBuffer: 67108864
|
|
34
|
+
}, (error, stdout) => {
|
|
35
|
+
resolve(error ? null : stdout.trim());
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region src/worktrees/landed.ts
|
|
40
|
+
/**
|
|
41
|
+
* The ref "landed" is measured against: `origin/HEAD` first, because landed means landed upstream,
|
|
42
|
+
* then the branch checked out in the primary checkout. Undefined when neither answers.
|
|
43
|
+
*/
|
|
44
|
+
async function defaultBranchRef(exec, primaryRoot, primaryBranch) {
|
|
45
|
+
return await exec("git", [
|
|
46
|
+
"-C",
|
|
47
|
+
primaryRoot,
|
|
48
|
+
"symbolic-ref",
|
|
49
|
+
"--short",
|
|
50
|
+
"refs/remotes/origin/HEAD"
|
|
51
|
+
]) || primaryBranch;
|
|
52
|
+
}
|
|
53
|
+
async function readLandedContext(exec, primaryRoot, target, forge) {
|
|
54
|
+
const [merged, goneUpstream] = await Promise.all([readMergedBranches(exec, primaryRoot, target), readGoneUpstreamBranches(exec, primaryRoot)]);
|
|
55
|
+
return {
|
|
56
|
+
exec,
|
|
57
|
+
primaryRoot,
|
|
58
|
+
target,
|
|
59
|
+
merged,
|
|
60
|
+
goneUpstream,
|
|
61
|
+
forge
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Runs the signals for one branch in cost order and stops at the first positive. Only `ancestor` may
|
|
66
|
+
* seed a `false`; no later signal can turn a `true` into a `false`.
|
|
67
|
+
*/
|
|
68
|
+
async function resolveLanded(branch, context) {
|
|
69
|
+
if (!branch) return {};
|
|
70
|
+
if (context.merged?.has(branch)) return {
|
|
71
|
+
merged: true,
|
|
72
|
+
signal: "ancestor"
|
|
73
|
+
};
|
|
74
|
+
const seed = context.merged ? { merged: false } : {};
|
|
75
|
+
if (context.goneUpstream?.has(branch)) return {
|
|
76
|
+
merged: true,
|
|
77
|
+
signal: "upstream-gone"
|
|
78
|
+
};
|
|
79
|
+
if (context.target && await isSquashApplied(context.exec, context.primaryRoot, context.target, branch)) return {
|
|
80
|
+
merged: true,
|
|
81
|
+
signal: "squash-patch"
|
|
82
|
+
};
|
|
83
|
+
if (await context.forge?.(branch) === true) return {
|
|
84
|
+
merged: true,
|
|
85
|
+
signal: "forge"
|
|
86
|
+
};
|
|
87
|
+
return seed;
|
|
88
|
+
}
|
|
89
|
+
async function readMergedBranches(exec, primaryRoot, target) {
|
|
90
|
+
if (!target) return void 0;
|
|
91
|
+
const out = await exec("git", [
|
|
92
|
+
"-C",
|
|
93
|
+
primaryRoot,
|
|
94
|
+
"branch",
|
|
95
|
+
"--format=%(refname:short)",
|
|
96
|
+
"--merged",
|
|
97
|
+
target
|
|
98
|
+
]);
|
|
99
|
+
if (out === null) return void 0;
|
|
100
|
+
return new Set(out.split("\n").map((line) => line.trim()).filter(Boolean));
|
|
101
|
+
}
|
|
102
|
+
async function readGoneUpstreamBranches(exec, primaryRoot) {
|
|
103
|
+
const out = await exec("git", [
|
|
104
|
+
"-C",
|
|
105
|
+
primaryRoot,
|
|
106
|
+
"for-each-ref",
|
|
107
|
+
"--format=%(refname:short) %(upstream:track)",
|
|
108
|
+
"refs/heads/"
|
|
109
|
+
]);
|
|
110
|
+
if (out === null) return void 0;
|
|
111
|
+
const gone = /* @__PURE__ */ new Set();
|
|
112
|
+
for (const line of out.split("\n")) {
|
|
113
|
+
const tab = line.indexOf(" ");
|
|
114
|
+
if (tab !== -1 && line.slice(tab + 1).trim() === "[gone]") gone.add(line.slice(0, tab));
|
|
115
|
+
}
|
|
116
|
+
return gone;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Whether the branch, collapsed to one synthetic commit (`commit-tree <branch>^{tree} -p <merge-base>`),
|
|
120
|
+
* is already applied on `target` by patch id (`git cherry`). Writes one unreferenced commit object that
|
|
121
|
+
* ordinary gc collects. The identity is inline because `commit-tree` refuses on a machine with no git
|
|
122
|
+
* identity configured, such as a CI runner.
|
|
123
|
+
*/
|
|
124
|
+
async function isSquashApplied(exec, primaryRoot, target, branch) {
|
|
125
|
+
const base = await exec("git", [
|
|
126
|
+
"-C",
|
|
127
|
+
primaryRoot,
|
|
128
|
+
"merge-base",
|
|
129
|
+
target,
|
|
130
|
+
branch
|
|
131
|
+
]);
|
|
132
|
+
if (!base) return false;
|
|
133
|
+
const synthetic = await exec("git", [
|
|
134
|
+
"-c",
|
|
135
|
+
"user.name=agent-harness",
|
|
136
|
+
"-c",
|
|
137
|
+
"user.email=probe@agent-harness.invalid",
|
|
138
|
+
"-C",
|
|
139
|
+
primaryRoot,
|
|
140
|
+
"commit-tree",
|
|
141
|
+
`${branch}^{tree}`,
|
|
142
|
+
"-p",
|
|
143
|
+
base,
|
|
144
|
+
"-m",
|
|
145
|
+
"agent-harness squash probe"
|
|
146
|
+
]);
|
|
147
|
+
if (!synthetic) return false;
|
|
148
|
+
return (await exec("git", [
|
|
149
|
+
"-C",
|
|
150
|
+
primaryRoot,
|
|
151
|
+
"cherry",
|
|
152
|
+
target,
|
|
153
|
+
synthetic
|
|
154
|
+
]))?.split("\n").some((line) => line.startsWith("-")) ?? false;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* A `ForgeMergedProbe` backed by the GitHub CLI: `gh pr list --head <branch> --state merged`. Every
|
|
158
|
+
* way it can fail (no `gh`, no auth, no network, a non-GitHub origin) resolves `undefined`.
|
|
159
|
+
*/
|
|
160
|
+
function ghForgeMergedProbe(exec, primaryRoot) {
|
|
161
|
+
return async (branch) => {
|
|
162
|
+
const url = await exec("git", [
|
|
163
|
+
"-C",
|
|
164
|
+
primaryRoot,
|
|
165
|
+
"remote",
|
|
166
|
+
"get-url",
|
|
167
|
+
"origin"
|
|
168
|
+
]);
|
|
169
|
+
const slug = url ? /[:/]([^/:]+\/[^/]+?)(?:\.git)?$/.exec(url.trim())?.[1] : void 0;
|
|
170
|
+
if (!slug) return void 0;
|
|
171
|
+
const out = await exec("gh", [
|
|
172
|
+
"pr",
|
|
173
|
+
"list",
|
|
174
|
+
"--repo",
|
|
175
|
+
slug,
|
|
176
|
+
"--head",
|
|
177
|
+
branch,
|
|
178
|
+
"--state",
|
|
179
|
+
"merged",
|
|
180
|
+
"--json",
|
|
181
|
+
"number",
|
|
182
|
+
"--limit",
|
|
183
|
+
"1"
|
|
184
|
+
]);
|
|
185
|
+
if (out === null) return void 0;
|
|
186
|
+
try {
|
|
187
|
+
const prs = JSON.parse(out);
|
|
188
|
+
return Array.isArray(prs) ? prs.length > 0 : void 0;
|
|
189
|
+
} catch {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
//#endregion
|
|
195
|
+
//#region src/worktrees/list-worktrees.ts
|
|
196
|
+
/**
|
|
197
|
+
* The primary checkout's root, from the primary checkout or any linked worktree:
|
|
198
|
+
* `--git-common-dir` always points at the primary's `.git`.
|
|
199
|
+
*/
|
|
200
|
+
async function primaryRoot(options = {}) {
|
|
201
|
+
const commonDir = await (options.exec ?? nodeExec)("git", [
|
|
202
|
+
"-C",
|
|
203
|
+
options.from ?? ".",
|
|
204
|
+
"rev-parse",
|
|
205
|
+
"--path-format=absolute",
|
|
206
|
+
"--git-common-dir"
|
|
207
|
+
]);
|
|
208
|
+
if (!commonDir) throw new Error(`not inside a git repository: ${resolve(options.from ?? ".")}`);
|
|
209
|
+
return dirname(commonDir);
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The single normalization for every path matched against another, so a symlinked repo or macOS's
|
|
213
|
+
* `/tmp` → `/private/tmp` still matches. Falls back to `resolve` for a path not on disk.
|
|
214
|
+
*/
|
|
215
|
+
async function normalizeWorktreePath(path) {
|
|
216
|
+
try {
|
|
217
|
+
return await realpath(path);
|
|
218
|
+
} catch {
|
|
219
|
+
return resolve(path);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** Every worktree of the repo, from `git worktree list --porcelain`, with landed and dirty facts. */
|
|
223
|
+
async function listWorktrees(options) {
|
|
224
|
+
const exec = options.exec ?? nodeExec;
|
|
225
|
+
const out = await exec("git", [
|
|
226
|
+
"-C",
|
|
227
|
+
options.primaryRoot,
|
|
228
|
+
"worktree",
|
|
229
|
+
"list",
|
|
230
|
+
"--porcelain"
|
|
231
|
+
]);
|
|
232
|
+
if (!out) return [];
|
|
233
|
+
const primary = await normalizeWorktreePath(options.primaryRoot);
|
|
234
|
+
const entries = await Promise.all(out.split("\n\n").map((record) => record.trim()).filter((record) => record.startsWith("worktree ")).map((record) => parseRecord(record, primary)));
|
|
235
|
+
const target = await defaultBranchRef(exec, options.primaryRoot, entries.find((entry) => !entry.linked)?.branch);
|
|
236
|
+
const context = await readLandedContext(exec, options.primaryRoot, target, options.forge);
|
|
237
|
+
for (const entry of entries) {
|
|
238
|
+
const landed = await resolveLanded(entry.branch, context);
|
|
239
|
+
if (landed.merged !== void 0) entry.merged = landed.merged;
|
|
240
|
+
if (landed.signal !== void 0) entry.mergedSignal = landed.signal;
|
|
241
|
+
if (!entry.prunable) {
|
|
242
|
+
const dirty = await readDirty(entry.root, {
|
|
243
|
+
exec,
|
|
244
|
+
ignore: options.ignore
|
|
245
|
+
});
|
|
246
|
+
if (dirty !== void 0) entry.dirty = dirty;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return entries;
|
|
250
|
+
}
|
|
251
|
+
async function parseRecord(record, primary) {
|
|
252
|
+
const lines = record.split("\n");
|
|
253
|
+
const root = await normalizeWorktreePath(unquote(lines[0].slice(9)));
|
|
254
|
+
const value = (key) => {
|
|
255
|
+
const line = lines.find((line) => line === key || line.startsWith(`${key} `));
|
|
256
|
+
return line === void 0 ? void 0 : unquote(line.slice(key.length + 1));
|
|
257
|
+
};
|
|
258
|
+
const entry = {
|
|
259
|
+
root,
|
|
260
|
+
detached: lines.includes("detached"),
|
|
261
|
+
linked: root !== primary,
|
|
262
|
+
prunable: value("prunable") !== void 0
|
|
263
|
+
};
|
|
264
|
+
const head = value("HEAD");
|
|
265
|
+
if (head) entry.head = head;
|
|
266
|
+
const branch = value("branch")?.replace(/^refs\/heads\//, "");
|
|
267
|
+
if (branch) entry.branch = branch;
|
|
268
|
+
const locked = value("locked");
|
|
269
|
+
if (locked !== void 0) entry.locked = locked;
|
|
270
|
+
return entry;
|
|
271
|
+
}
|
|
272
|
+
const escapes = {
|
|
273
|
+
a: "\x07",
|
|
274
|
+
b: "\b",
|
|
275
|
+
f: "\f",
|
|
276
|
+
n: "\n",
|
|
277
|
+
r: "\r",
|
|
278
|
+
t: " ",
|
|
279
|
+
v: "\v"
|
|
280
|
+
};
|
|
281
|
+
/**
|
|
282
|
+
* Undo git's C-style quoting, which porcelain applies to a path or lock reason holding a quote,
|
|
283
|
+
* backslash, or control character: `"a\"b\n"` → `a"b` plus a newline. Octal escapes are bytes, so a
|
|
284
|
+
* quoted UTF-8 name decodes back to its characters.
|
|
285
|
+
*/
|
|
286
|
+
function unquote(text) {
|
|
287
|
+
if (text.length < 2 || !text.startsWith("\"") || !text.endsWith("\"")) return text;
|
|
288
|
+
const bytes = [];
|
|
289
|
+
const body = text.slice(1, -1);
|
|
290
|
+
for (let i = 0; i < body.length; i++) {
|
|
291
|
+
const char = body[i];
|
|
292
|
+
if (char !== "\\") {
|
|
293
|
+
bytes.push(...Buffer.from(char));
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const next = body[++i] ?? "";
|
|
297
|
+
const octal = /^[0-7]{3}/.exec(body.slice(i));
|
|
298
|
+
if (octal) {
|
|
299
|
+
bytes.push(Number.parseInt(octal[0], 8));
|
|
300
|
+
i += 2;
|
|
301
|
+
} else bytes.push(...Buffer.from(escapes[next] ?? next));
|
|
302
|
+
}
|
|
303
|
+
return Buffer.from(bytes).toString("utf8");
|
|
304
|
+
}
|
|
305
|
+
//#endregion
|
|
306
|
+
//#region src/worktrees/naming.ts
|
|
307
|
+
/**
|
|
308
|
+
* Where the library puts a repo's worktrees: `<parent>/<repo>.worktrees`, a sibling of the primary
|
|
309
|
+
* checkout (E-MUX-2, E-LEG-1). Every worktree the library creates is a slot in this folder.
|
|
310
|
+
*/
|
|
311
|
+
function worktreesDir(primaryRoot) {
|
|
312
|
+
return join(dirname(primaryRoot), `${basename(primaryRoot)}.worktrees`);
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* The path of slot `n`: `<parent>/<repo>.worktrees/<repo>-<n>`. The library assigns `n` and the
|
|
316
|
+
* caller cannot, so the name stays meaningless and true for the worktree's whole life; what it is
|
|
317
|
+
* for lives in its branch and lease holder, which change on reuse (decision 2026-10-05, E-TH-7).
|
|
318
|
+
*/
|
|
319
|
+
function slotPath(primaryRoot, n) {
|
|
320
|
+
return join(worktreesDir(primaryRoot), `${basename(primaryRoot)}-${n}`);
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* The slot number of a worktree root, or `undefined` when the root is not one of the library's slots.
|
|
324
|
+
* Both paths are normalized (`normalizeWorktreePath`). `n` is a positive integer with no leading
|
|
325
|
+
* zero, so `repo-01` is not slot 1.
|
|
326
|
+
*/
|
|
327
|
+
function slotNumber(primaryRoot, worktreeRoot) {
|
|
328
|
+
if (dirname(worktreeRoot) !== worktreesDir(primaryRoot)) return void 0;
|
|
329
|
+
const prefix = `${basename(primaryRoot)}-`;
|
|
330
|
+
const name = basename(worktreeRoot);
|
|
331
|
+
if (!name.startsWith(prefix)) return void 0;
|
|
332
|
+
const digits = name.slice(prefix.length);
|
|
333
|
+
return /^[1-9]\d*$/.test(digits) ? Number(digits) : void 0;
|
|
334
|
+
}
|
|
335
|
+
//#endregion
|
|
336
|
+
//#region src/worktrees/owner.ts
|
|
337
|
+
/** The `library` value a lease reason carries. */
|
|
338
|
+
const LEASE_LIBRARY = "cyber-agentkit";
|
|
339
|
+
/** The lease a lock reason records, or `undefined` when the lock is not this library's. */
|
|
340
|
+
function parseLeaseReason(reason) {
|
|
341
|
+
if (!reason) return void 0;
|
|
342
|
+
let value;
|
|
343
|
+
try {
|
|
344
|
+
value = JSON.parse(reason);
|
|
345
|
+
} catch {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
349
|
+
const { library, leaseId, holder } = value;
|
|
350
|
+
if (library !== "cyber-agentkit" || typeof leaseId !== "string" || typeof holder !== "string") return void 0;
|
|
351
|
+
return {
|
|
352
|
+
library,
|
|
353
|
+
leaseId,
|
|
354
|
+
holder
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
/** Classify a worktree's owner from its lock reason and path. Pure: reads nothing from disk. */
|
|
358
|
+
function classifyOwner(entry, options) {
|
|
359
|
+
if (parseLeaseReason(entry.locked)) return {
|
|
360
|
+
owner: "self",
|
|
361
|
+
research: ["E-GIT-L1", "E-GIT-L2"]
|
|
362
|
+
};
|
|
363
|
+
if (entry.locked?.startsWith("claude session ")) return {
|
|
364
|
+
owner: "claude-code",
|
|
365
|
+
research: ["E-CC-W4", "E-PROC-CC5"]
|
|
366
|
+
};
|
|
367
|
+
if (!entry.linked) return {
|
|
368
|
+
owner: "user",
|
|
369
|
+
research: []
|
|
370
|
+
};
|
|
371
|
+
if (entry.locked === void 0 && slotNumber(options.primaryRoot, entry.root) !== void 0) return {
|
|
372
|
+
owner: "self",
|
|
373
|
+
research: []
|
|
374
|
+
};
|
|
375
|
+
if (isInside(entry.root, `${options.primaryRoot}${sep}.claude${sep}worktrees`)) return {
|
|
376
|
+
owner: "claude-code",
|
|
377
|
+
research: ["E-CC-W1"]
|
|
378
|
+
};
|
|
379
|
+
if (options.codexHome && isInside(entry.root, `${options.codexHome}${sep}worktrees`)) return {
|
|
380
|
+
owner: "codex",
|
|
381
|
+
research: ["E-CODEX-W1"]
|
|
382
|
+
};
|
|
383
|
+
return {
|
|
384
|
+
owner: "unknown",
|
|
385
|
+
research: []
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
function isInside(path, dir) {
|
|
389
|
+
return path.startsWith(`${dir}${sep}`);
|
|
390
|
+
}
|
|
391
|
+
//#endregion
|
|
392
|
+
//#region src/worktrees/lease.ts
|
|
393
|
+
/** The default `LeaseFs`, on `node:fs/promises`. */
|
|
394
|
+
const nodeLeaseFs = {
|
|
395
|
+
async createExclusive(path, text) {
|
|
396
|
+
try {
|
|
397
|
+
await writeFile(path, text, { flag: "wx" });
|
|
398
|
+
return true;
|
|
399
|
+
} catch (error) {
|
|
400
|
+
if (error.code === "EEXIST") return false;
|
|
401
|
+
throw error;
|
|
402
|
+
}
|
|
403
|
+
},
|
|
404
|
+
async readText(path) {
|
|
405
|
+
try {
|
|
406
|
+
return await readFile(path, "utf8");
|
|
407
|
+
} catch (error) {
|
|
408
|
+
if (error.code === "ENOENT") return void 0;
|
|
409
|
+
throw error;
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
async remove(path) {
|
|
413
|
+
try {
|
|
414
|
+
await unlink(path);
|
|
415
|
+
} catch (error) {
|
|
416
|
+
if (error.code !== "ENOENT") throw error;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
/**
|
|
421
|
+
* `$GIT_COMMON_DIR/worktrees/<id>/locked` for a linked worktree, or `undefined` when git cannot say.
|
|
422
|
+
* The `<id>` is git's admin folder name, which need not match the basename, so it is read from
|
|
423
|
+
* `--git-dir` rather than built. When the checkout is gone, `commonDir` finds the admin folder by the
|
|
424
|
+
* `gitdir` pointer it keeps, so a lease on a vanished worktree can still be cleared.
|
|
425
|
+
*/
|
|
426
|
+
async function leaseFile(worktreeRoot, exec = nodeExec, commonDir) {
|
|
427
|
+
const gitDir = await exec("git", [
|
|
428
|
+
"-C",
|
|
429
|
+
worktreeRoot,
|
|
430
|
+
"rev-parse",
|
|
431
|
+
"--path-format=absolute",
|
|
432
|
+
"--git-dir"
|
|
433
|
+
]);
|
|
434
|
+
if (gitDir) return join(gitDir, "locked");
|
|
435
|
+
const admin = commonDir && await adminDirOf(worktreeRoot, commonDir);
|
|
436
|
+
return admin ? join(admin, "locked") : void 0;
|
|
437
|
+
}
|
|
438
|
+
/** The `worktrees/<id>` folder whose `gitdir` file points at `<worktreeRoot>/.git`. */
|
|
439
|
+
async function adminDirOf(worktreeRoot, commonDir) {
|
|
440
|
+
const dir = join(commonDir, "worktrees");
|
|
441
|
+
const target = await dotGitOf(worktreeRoot);
|
|
442
|
+
let ids;
|
|
443
|
+
try {
|
|
444
|
+
ids = await readdir(dir);
|
|
445
|
+
} catch {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
for (const id of ids) {
|
|
449
|
+
let pointer;
|
|
450
|
+
try {
|
|
451
|
+
pointer = (await readFile(join(dir, id, "gitdir"), "utf8")).trim();
|
|
452
|
+
} catch {
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
if (await dotGitOf(dirname(pointer)) === target) return join(dir, id);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
/** `<root>/.git` with the root's existing ancestors resolved, so a vanished root still compares. */
|
|
459
|
+
async function dotGitOf(root) {
|
|
460
|
+
return join(await normalizeWorktreePath(dirname(root)), basename(root), ".git");
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Claim a worktree: exclusive-create its `locked` file with the lease as a one-line JSON reason, which
|
|
464
|
+
* git then honours as a lock (E-GIT-L1, E-GIT-L2, E-GIT-L3), and read it back. `git worktree lock`
|
|
465
|
+
* checks then truncates, so it can overwrite a lease created inside its window (E-GIT-L5); the read
|
|
466
|
+
* back catches that. `undefined` when the worktree is already locked or the claim was lost.
|
|
467
|
+
*/
|
|
468
|
+
async function claimLease(worktreeRoot, holder, options = {}) {
|
|
469
|
+
const exec = options.exec ?? nodeExec;
|
|
470
|
+
const fs = options.fs ?? nodeLeaseFs;
|
|
471
|
+
const path = await leaseFile(worktreeRoot, exec);
|
|
472
|
+
if (!path) return void 0;
|
|
473
|
+
const commonDir = await exec("git", [
|
|
474
|
+
"-C",
|
|
475
|
+
worktreeRoot,
|
|
476
|
+
"rev-parse",
|
|
477
|
+
"--path-format=absolute",
|
|
478
|
+
"--git-common-dir"
|
|
479
|
+
]);
|
|
480
|
+
const lease = {
|
|
481
|
+
worktree: worktreeRoot,
|
|
482
|
+
leaseId: options.leaseId ?? randomUUID(),
|
|
483
|
+
holder,
|
|
484
|
+
commonDir: commonDir ?? void 0
|
|
485
|
+
};
|
|
486
|
+
const reason = {
|
|
487
|
+
library: LEASE_LIBRARY,
|
|
488
|
+
leaseId: lease.leaseId,
|
|
489
|
+
holder
|
|
490
|
+
};
|
|
491
|
+
if (!await fs.createExclusive(path, JSON.stringify(reason))) return void 0;
|
|
492
|
+
return await holdsLease(lease, {
|
|
493
|
+
exec,
|
|
494
|
+
fs
|
|
495
|
+
}) ? lease : void 0;
|
|
496
|
+
}
|
|
497
|
+
/** Whether the worktree's `locked` file still records this lease. Checked again before a destructive
|
|
498
|
+
* step, since a manual `git worktree unlock` or a racing `lock` can take it away (E-GIT-L4, E-GIT-L5). */
|
|
499
|
+
async function holdsLease(lease, options = {}) {
|
|
500
|
+
const path = await leaseFile(lease.worktree, options.exec ?? nodeExec, lease.commonDir);
|
|
501
|
+
if (!path) return false;
|
|
502
|
+
return parseLeaseReason(await (options.fs ?? nodeLeaseFs).readText(path))?.leaseId === lease.leaseId;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Give a lease back by deleting the `locked` file, only when it still records this `leaseId`. It does
|
|
506
|
+
* not recycle: an occupant such as a judge may still be working there, so the next `acquire` recycles
|
|
507
|
+
* after probing.
|
|
508
|
+
*/
|
|
509
|
+
async function release(lease, options = {}) {
|
|
510
|
+
const exec = options.exec ?? nodeExec;
|
|
511
|
+
const fs = options.fs ?? nodeLeaseFs;
|
|
512
|
+
const path = await leaseFile(lease.worktree, exec, lease.commonDir);
|
|
513
|
+
if (!path || parseLeaseReason(await fs.readText(path))?.leaseId !== lease.leaseId) return {
|
|
514
|
+
released: false,
|
|
515
|
+
reason: "lost"
|
|
516
|
+
};
|
|
517
|
+
await fs.remove(path);
|
|
518
|
+
return { released: true };
|
|
519
|
+
}
|
|
520
|
+
//#endregion
|
|
521
|
+
//#region src/worktrees/process-source.ts
|
|
522
|
+
/**
|
|
523
|
+
* Reads processes from a Linux `/proc` (or a mounted copy at `procRoot`). On any other platform the
|
|
524
|
+
* default root returns `null`: macOS would need `proc_pidinfo` (E-PROC-OS2), and Windows has no
|
|
525
|
+
* documented way to read another process's cwd (E-PROC-OS3).
|
|
526
|
+
*/
|
|
527
|
+
function procfsProcessSource(procRoot = "/proc") {
|
|
528
|
+
return async () => {
|
|
529
|
+
if (procRoot === "/proc" && process.platform !== "linux") return null;
|
|
530
|
+
let names;
|
|
531
|
+
try {
|
|
532
|
+
names = await readdir(procRoot);
|
|
533
|
+
} catch {
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
return (await Promise.all(names.filter((name) => /^\d+$/.test(name)).map((name) => readProcess(join(procRoot, name), Number(name))))).filter((info) => info !== void 0);
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
async function readProcess(dir, pid) {
|
|
540
|
+
const [stat, cmdline, exe, cwd, environ] = await Promise.all([
|
|
541
|
+
readText(join(dir, "stat")),
|
|
542
|
+
readText(join(dir, "cmdline")),
|
|
543
|
+
readLink(join(dir, "exe")),
|
|
544
|
+
readLink(join(dir, "cwd")),
|
|
545
|
+
readText(join(dir, "environ"))
|
|
546
|
+
]);
|
|
547
|
+
if (stat === void 0) return void 0;
|
|
548
|
+
const info = {
|
|
549
|
+
pid,
|
|
550
|
+
argv: cmdline ? splitNul(cmdline) : []
|
|
551
|
+
};
|
|
552
|
+
const ppid = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
|
|
553
|
+
if (Number.isInteger(ppid)) info.ppid = ppid;
|
|
554
|
+
if (exe !== void 0) info.exe = exe;
|
|
555
|
+
if (cwd !== void 0) info.cwd = cwd;
|
|
556
|
+
if (environ !== void 0) {
|
|
557
|
+
const env = {};
|
|
558
|
+
for (const entry of splitNul(environ)) {
|
|
559
|
+
const eq = entry.indexOf("=");
|
|
560
|
+
if (eq > 0) env[entry.slice(0, eq)] = entry.slice(eq + 1);
|
|
561
|
+
}
|
|
562
|
+
info.env = env;
|
|
563
|
+
}
|
|
564
|
+
return info;
|
|
565
|
+
}
|
|
566
|
+
const splitNul = (text) => text.split("\0").filter((part) => part.length > 0);
|
|
567
|
+
async function readText(path) {
|
|
568
|
+
try {
|
|
569
|
+
return await readFile(path, "utf8");
|
|
570
|
+
} catch {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
/** The kernel appends ` (deleted)` to a link whose target is gone, as after an in-place
|
|
575
|
+
* auto-update of the Copilot CLI (E-PROC-COP1). */
|
|
576
|
+
async function readLink(path) {
|
|
577
|
+
try {
|
|
578
|
+
return (await readlink(path)).replace(/ \(deleted\)$/, "");
|
|
579
|
+
} catch {
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
//#endregion
|
|
584
|
+
//#region src/worktrees/session-signatures.ts
|
|
585
|
+
const base = (path) => path?.slice(path.lastIndexOf("/") + 1);
|
|
586
|
+
const exeIs = (info, name) => base(info.exe) === name;
|
|
587
|
+
const exeMatches = (info, pattern) => info.exe !== void 0 && pattern.test(info.exe);
|
|
588
|
+
const isNode = (info) => exeIs(info, "node") || base(info.argv[0]) === "node";
|
|
589
|
+
const argMatches = (info, pattern) => info.argv.some((arg) => pattern.test(arg));
|
|
590
|
+
const sessionSignatures = [
|
|
591
|
+
{
|
|
592
|
+
harness: "claude-code",
|
|
593
|
+
research: [
|
|
594
|
+
"E-PROC-CC1",
|
|
595
|
+
"E-PROC-CC2",
|
|
596
|
+
"E-PROC-CC3"
|
|
597
|
+
],
|
|
598
|
+
isSession: (info) => exeMatches(info, /\/claude\/versions\/[^/]+$/),
|
|
599
|
+
link: {
|
|
600
|
+
pid: "CLAUDE_PID",
|
|
601
|
+
id: "CLAUDE_CODE_SESSION_ID"
|
|
602
|
+
}
|
|
603
|
+
},
|
|
604
|
+
{
|
|
605
|
+
harness: "codex",
|
|
606
|
+
research: ["E-PROC-CX1", "E-PROC-CX2"],
|
|
607
|
+
isSession: (info) => exeIs(info, "codex") && !isCodexHelper(info),
|
|
608
|
+
helper: (info) => isCodexHelper(info) ? "session" : void 0,
|
|
609
|
+
link: { id: "CODEX_SESSION_ID" }
|
|
610
|
+
},
|
|
611
|
+
{
|
|
612
|
+
harness: "copilot-cli",
|
|
613
|
+
research: ["E-PROC-COP1", "E-PROC-COP2"],
|
|
614
|
+
isSession: (info) => exeIs(info, "copilot"),
|
|
615
|
+
link: { id: "COPILOT_AGENT_SESSION_ID" }
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
harness: "cursor",
|
|
619
|
+
research: [
|
|
620
|
+
"E-PROC-CUR1",
|
|
621
|
+
"E-PROC-CUR3",
|
|
622
|
+
"E-PROC-CUR4"
|
|
623
|
+
],
|
|
624
|
+
isSession: (info) => isCursorAgent(info) && info.env?.AGENT_CLI_SOCKET_PATH === void 0,
|
|
625
|
+
helper: (info) => isCursorAgent(info) && info.env?.AGENT_CLI_SOCKET_PATH !== void 0 ? "session" : void 0,
|
|
626
|
+
link: { id: "CURSOR_CONVERSATION_ID" }
|
|
627
|
+
},
|
|
628
|
+
{
|
|
629
|
+
harness: "opencode",
|
|
630
|
+
research: ["E-PROC-OC1"],
|
|
631
|
+
isSession: (info) => exeIs(info, "opencode"),
|
|
632
|
+
link: { pid: "OPENCODE_PID" }
|
|
633
|
+
},
|
|
634
|
+
{
|
|
635
|
+
harness: "kilo",
|
|
636
|
+
research: ["E-PROC-KILO1"],
|
|
637
|
+
isSession: (info) => exeMatches(info, /\/cli-linux-x64\/bin\/kilo$/) || isNode(info) && argMatches(info, /\/@kilocode\/cli\/bin\/kilo$/),
|
|
638
|
+
link: {
|
|
639
|
+
pid: "KILO_PID",
|
|
640
|
+
id: "KILO_RUN_ID"
|
|
641
|
+
}
|
|
642
|
+
},
|
|
643
|
+
{
|
|
644
|
+
harness: "qwen-code",
|
|
645
|
+
research: ["E-PROC-QWEN1"],
|
|
646
|
+
isSession: (info) => isNode(info) && argMatches(info, /\/qwen-code\/cli-entry\.js$/),
|
|
647
|
+
link: { id: "QWEN_CODE_SESSION_ID" }
|
|
648
|
+
},
|
|
649
|
+
{
|
|
650
|
+
harness: "crush",
|
|
651
|
+
research: ["E-PROC-CRUSH1"],
|
|
652
|
+
isSession: (info) => exeMatches(info, /\/crush\/bin\/crush$/) || isNode(info) && argMatches(info, /\/@charmland\/crush(\/|$)/)
|
|
653
|
+
},
|
|
654
|
+
{
|
|
655
|
+
harness: "gemini-cli",
|
|
656
|
+
research: ["E-PROC-GEM1"],
|
|
657
|
+
isSession: (info) => isNode(info) && argMatches(info, /\/gemini-cli\/bundle\/gemini\.js$/)
|
|
658
|
+
},
|
|
659
|
+
{
|
|
660
|
+
harness: "goose",
|
|
661
|
+
research: ["E-PROC-GOOSE1"],
|
|
662
|
+
isSession: (info) => exeIs(info, "goose") && (info.argv[1] === "run" || info.argv[1] === "session"),
|
|
663
|
+
link: { id: "AGENT_SESSION_ID" }
|
|
664
|
+
},
|
|
665
|
+
{
|
|
666
|
+
harness: "openhands",
|
|
667
|
+
research: ["E-PROC-OH1"],
|
|
668
|
+
isSession: (info) => /^python[\d.]*$/.test(base(info.exe) ?? "") && argMatches(info, /\/openhands$/),
|
|
669
|
+
helper: (info) => exeIs(info, "tmux") && (info.argv.includes("-Lopenhands") || /-L openhands( |$)/.test(info.argv.join(" "))) ? "shared" : void 0
|
|
670
|
+
},
|
|
671
|
+
{
|
|
672
|
+
harness: "cline",
|
|
673
|
+
research: ["E-PROC-CLINE1"],
|
|
674
|
+
isSession: (info) => isClineProcess(info) && !info.argv.includes("--cline-hub-daemon"),
|
|
675
|
+
helper: (info) => isClineProcess(info) && info.argv.includes("--cline-hub-daemon") ? "shared" : void 0
|
|
676
|
+
},
|
|
677
|
+
{
|
|
678
|
+
harness: "augment",
|
|
679
|
+
research: ["E-PROC-AUG1"],
|
|
680
|
+
isSession: (info) => isNode(info) && argMatches(info, /\/@augmentcode\/auggie\/augment\.mjs$/)
|
|
681
|
+
},
|
|
682
|
+
{
|
|
683
|
+
harness: "antigravity-cli",
|
|
684
|
+
research: ["E-PROC-AGY1"],
|
|
685
|
+
isSession: (info) => exeIs(info, "agy"),
|
|
686
|
+
link: { id: "ANTIGRAVITY_CONVERSATION_ID" }
|
|
687
|
+
}
|
|
688
|
+
];
|
|
689
|
+
function isCodexHelper(info) {
|
|
690
|
+
return base(info.exe) === "codex-code-mode-host" || info.argv.some((arg) => base(arg) === "codex-code-mode-host");
|
|
691
|
+
}
|
|
692
|
+
function isCursorAgent(info) {
|
|
693
|
+
return isNode(info) && argMatches(info, /(^|\/)cursor-agent$/);
|
|
694
|
+
}
|
|
695
|
+
function isClineProcess(info) {
|
|
696
|
+
return exeMatches(info, /\/cli-linux-x64\/bin\/cline$/) || isNode(info) && argMatches(info, /\/cline\/bin\/cline$/);
|
|
697
|
+
}
|
|
698
|
+
//#endregion
|
|
699
|
+
//#region src/worktrees/occupancy.ts
|
|
700
|
+
/**
|
|
701
|
+
* Snapshots processes once and classifies each against the session signature table. Read-only: the
|
|
702
|
+
* probe never signals or kills a process. A harness missing from the table is invisible to it.
|
|
703
|
+
*/
|
|
704
|
+
async function probeProcesses(options = {}) {
|
|
705
|
+
const processes = await (options.source ?? procfsProcessSource())();
|
|
706
|
+
if (processes === null) return {
|
|
707
|
+
verified: false,
|
|
708
|
+
occupancy: (_root, occupancyOptions) => ({
|
|
709
|
+
busy: occupancyOptions?.strict === true,
|
|
710
|
+
verified: false,
|
|
711
|
+
occupants: [],
|
|
712
|
+
lingering: [],
|
|
713
|
+
unlinked: []
|
|
714
|
+
})
|
|
715
|
+
};
|
|
716
|
+
return classify(processes);
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* The live agent sessions inside a worktree, or `undefined` when the probe could not read this
|
|
720
|
+
* platform's processes.
|
|
721
|
+
*/
|
|
722
|
+
async function occupants(worktreeRoot, options = {}) {
|
|
723
|
+
const probe = await probeProcesses(options);
|
|
724
|
+
if (!probe.verified) return void 0;
|
|
725
|
+
const nested = options.nested && await Promise.all(options.nested.map(normalizeWorktreePath));
|
|
726
|
+
return probe.occupancy(await normalizeWorktreePath(worktreeRoot), { nested }).occupants;
|
|
727
|
+
}
|
|
728
|
+
function classify(processes) {
|
|
729
|
+
const byPid = new Map(processes.map((info) => [info.pid, info]));
|
|
730
|
+
const sessionOf = /* @__PURE__ */ new Map();
|
|
731
|
+
const helperOf = /* @__PURE__ */ new Map();
|
|
732
|
+
for (const info of processes) for (const signature of sessionSignatures) {
|
|
733
|
+
const scope = signature.helper?.(info);
|
|
734
|
+
if (scope) {
|
|
735
|
+
helperOf.set(info.pid, {
|
|
736
|
+
signature,
|
|
737
|
+
scope
|
|
738
|
+
});
|
|
739
|
+
break;
|
|
740
|
+
}
|
|
741
|
+
if (signature.isSession(info)) {
|
|
742
|
+
sessionOf.set(info.pid, signature);
|
|
743
|
+
break;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
const parentOf = (info) => info.ppid === void 0 || info.ppid === info.pid ? void 0 : byPid.get(info.ppid);
|
|
747
|
+
const groupOf = /* @__PURE__ */ new Map();
|
|
748
|
+
const groupRoot = (info) => {
|
|
749
|
+
const parent = parentOf(info);
|
|
750
|
+
return parent && sessionOf.get(parent.pid) === sessionOf.get(info.pid) ? groupRoot(parent) : info;
|
|
751
|
+
};
|
|
752
|
+
for (const [pid, signature] of sessionOf) {
|
|
753
|
+
const root = groupRoot(byPid.get(pid));
|
|
754
|
+
let group = groupOf.get(root.pid);
|
|
755
|
+
if (!group) {
|
|
756
|
+
group = {
|
|
757
|
+
signature,
|
|
758
|
+
members: [],
|
|
759
|
+
ids: /* @__PURE__ */ new Set()
|
|
760
|
+
};
|
|
761
|
+
groupOf.set(root.pid, group);
|
|
762
|
+
}
|
|
763
|
+
group.members.push(byPid.get(pid));
|
|
764
|
+
groupOf.set(pid, group);
|
|
765
|
+
}
|
|
766
|
+
const groups = [...new Set(groupOf.values())];
|
|
767
|
+
const nearestSession = (info) => {
|
|
768
|
+
const seen = /* @__PURE__ */ new Set();
|
|
769
|
+
for (let parent = parentOf(info); parent && !seen.has(parent.pid); parent = parentOf(parent)) {
|
|
770
|
+
seen.add(parent.pid);
|
|
771
|
+
const group = groupOf.get(parent.pid);
|
|
772
|
+
if (group) return group;
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
for (const info of processes) {
|
|
776
|
+
if (sessionOf.has(info.pid)) continue;
|
|
777
|
+
const group = nearestSession(info);
|
|
778
|
+
const idVar = group?.signature.link?.id;
|
|
779
|
+
const id = idVar && info.env?.[idVar];
|
|
780
|
+
if (group && id) group.ids.add(id);
|
|
781
|
+
}
|
|
782
|
+
const liveGroups = (harness) => groups.filter((group) => group.signature.harness === harness);
|
|
783
|
+
const resolveLink = (signature, env) => {
|
|
784
|
+
const live = liveGroups(signature.harness);
|
|
785
|
+
const pidVar = signature.link?.pid;
|
|
786
|
+
if (pidVar && env[pidVar]) {
|
|
787
|
+
const pid = Number(env[pidVar]);
|
|
788
|
+
return live.some((group) => group.members.some((member) => member.pid === pid)) ? "live" : "dead";
|
|
789
|
+
}
|
|
790
|
+
const idVar = signature.link?.id;
|
|
791
|
+
const id = idVar && env[idVar];
|
|
792
|
+
if (!id) return void 0;
|
|
793
|
+
if (live.some((group) => group.ids.has(id))) return "live";
|
|
794
|
+
return live.every((group) => group.ids.size > 0) ? "dead" : "unknown";
|
|
795
|
+
};
|
|
796
|
+
const leftover = (info) => {
|
|
797
|
+
const helper = helperOf.get(info.pid);
|
|
798
|
+
if (helper?.scope === "shared") {
|
|
799
|
+
if (liveGroups(helper.signature.harness).length > 0) return void 0;
|
|
800
|
+
return {
|
|
801
|
+
pid: info.pid,
|
|
802
|
+
argv: info.argv,
|
|
803
|
+
harness: helper.signature.harness,
|
|
804
|
+
reason: "harness-idle"
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
if (nearestSession(info)) return void 0;
|
|
808
|
+
if (helper) return {
|
|
809
|
+
pid: info.pid,
|
|
810
|
+
argv: info.argv,
|
|
811
|
+
harness: helper.signature.harness,
|
|
812
|
+
reason: "helper-orphaned"
|
|
813
|
+
};
|
|
814
|
+
if (!info.env) return {
|
|
815
|
+
pid: info.pid,
|
|
816
|
+
argv: info.argv,
|
|
817
|
+
reason: "env-unreadable"
|
|
818
|
+
};
|
|
819
|
+
const links = sessionSignatures.map((signature) => ({
|
|
820
|
+
signature,
|
|
821
|
+
link: resolveLink(signature, info.env)
|
|
822
|
+
})).filter((entry) => entry.link !== void 0);
|
|
823
|
+
if (links.some((entry) => entry.link === "live")) return void 0;
|
|
824
|
+
if (links.length === 0) return {
|
|
825
|
+
pid: info.pid,
|
|
826
|
+
argv: info.argv,
|
|
827
|
+
reason: "no-link"
|
|
828
|
+
};
|
|
829
|
+
const reason = links.every((entry) => entry.link === "dead") ? "session-gone" : "unresolved-link";
|
|
830
|
+
return {
|
|
831
|
+
pid: info.pid,
|
|
832
|
+
argv: info.argv,
|
|
833
|
+
harness: links[0].signature.harness,
|
|
834
|
+
reason
|
|
835
|
+
};
|
|
836
|
+
};
|
|
837
|
+
return {
|
|
838
|
+
verified: true,
|
|
839
|
+
occupancy(worktreeRoot, options = {}) {
|
|
840
|
+
const within = (cwd, root) => cwd !== void 0 && (cwd === root || cwd.startsWith(root.endsWith(sep) ? root : root + sep));
|
|
841
|
+
const inside = (cwd) => within(cwd, worktreeRoot) && !(options.nested ?? []).some((root) => within(cwd, root));
|
|
842
|
+
const found = [];
|
|
843
|
+
for (const group of groups) {
|
|
844
|
+
if (!group.members.some((member) => inside(member.cwd))) continue;
|
|
845
|
+
const session = {
|
|
846
|
+
harness: group.signature.harness,
|
|
847
|
+
pid: (group.members.find((member) => groupRoot(member) === member) ?? group.members[0]).pid,
|
|
848
|
+
pids: group.members.map((member) => member.pid).sort((a, b) => a - b),
|
|
849
|
+
research: group.signature.research
|
|
850
|
+
};
|
|
851
|
+
if (group.ids.size === 1) session.sessionId = [...group.ids][0];
|
|
852
|
+
found.push(session);
|
|
853
|
+
}
|
|
854
|
+
const lingering = [];
|
|
855
|
+
const unlinked = [];
|
|
856
|
+
for (const info of processes) {
|
|
857
|
+
if (sessionOf.has(info.pid) || !inside(info.cwd)) continue;
|
|
858
|
+
const entry = leftover(info);
|
|
859
|
+
if (!entry) continue;
|
|
860
|
+
const report = {
|
|
861
|
+
...entry,
|
|
862
|
+
cwd: info.cwd
|
|
863
|
+
};
|
|
864
|
+
if (info.ppid !== void 0) report.ppid = info.ppid;
|
|
865
|
+
(entry.reason === "session-gone" || entry.reason === "helper-orphaned" || entry.reason === "harness-idle" ? lingering : unlinked).push(report);
|
|
866
|
+
}
|
|
867
|
+
return {
|
|
868
|
+
busy: found.length > 0,
|
|
869
|
+
verified: true,
|
|
870
|
+
occupants: found,
|
|
871
|
+
lingering,
|
|
872
|
+
unlinked
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
//#endregion
|
|
878
|
+
//#region src/worktrees/acquire.ts
|
|
879
|
+
/** Every worktree of the repo with the reason it cannot be reused, or none when it can. */
|
|
880
|
+
async function explain(options = {}) {
|
|
881
|
+
return (await survey(options)).verdicts;
|
|
882
|
+
}
|
|
883
|
+
async function survey(options) {
|
|
884
|
+
const exec = options.exec ?? nodeExec;
|
|
885
|
+
const primary = await normalizeWorktreePath(options.primaryRoot ?? await primaryRoot({
|
|
886
|
+
from: options.from,
|
|
887
|
+
exec
|
|
888
|
+
}));
|
|
889
|
+
const entries = await listWorktrees({
|
|
890
|
+
primaryRoot: primary,
|
|
891
|
+
exec,
|
|
892
|
+
ignore: options.ignore,
|
|
893
|
+
forge: options.forge
|
|
894
|
+
});
|
|
895
|
+
const base = options.base ?? await defaultBranchRef(exec, primary, entries.find((entry) => !entry.linked)?.branch);
|
|
896
|
+
const codexHome = options.codexHome && await normalizeWorktreePath(options.codexHome);
|
|
897
|
+
let probe = options.probe;
|
|
898
|
+
const verdicts = [];
|
|
899
|
+
for (const entry of entries) {
|
|
900
|
+
const owner = classifyOwner(entry, {
|
|
901
|
+
primaryRoot: primary,
|
|
902
|
+
codexHome
|
|
903
|
+
});
|
|
904
|
+
const verdict = {
|
|
905
|
+
worktree: entry,
|
|
906
|
+
owner,
|
|
907
|
+
verified: true
|
|
908
|
+
};
|
|
909
|
+
verdicts.push(verdict);
|
|
910
|
+
if (!entry.linked) {
|
|
911
|
+
verdict.skip = "primary";
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
if (owner.owner !== "self") {
|
|
915
|
+
verdict.skip = "foreign";
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
const lease = parseLeaseReason(entry.locked);
|
|
919
|
+
if (lease) {
|
|
920
|
+
verdict.skip = "leased";
|
|
921
|
+
verdict.holder = lease.holder;
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
if (entry.prunable) {
|
|
925
|
+
verdict.skip = "prunable";
|
|
926
|
+
continue;
|
|
927
|
+
}
|
|
928
|
+
probe ??= await probeProcesses();
|
|
929
|
+
const nested = entries.map((other) => other.root).filter((root) => root !== entry.root && root.startsWith(entry.root + sep));
|
|
930
|
+
const occupancy = probe.occupancy(entry.root, {
|
|
931
|
+
strict: options.strict,
|
|
932
|
+
nested
|
|
933
|
+
});
|
|
934
|
+
verdict.occupancy = occupancy;
|
|
935
|
+
verdict.verified = occupancy.verified;
|
|
936
|
+
if (occupancy.busy) {
|
|
937
|
+
verdict.skip = occupancy.verified ? "busy" : "unverified";
|
|
938
|
+
continue;
|
|
939
|
+
}
|
|
940
|
+
if (entry.dirty !== false) {
|
|
941
|
+
verdict.skip = "dirty";
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
944
|
+
if (!await hasLanded(exec, primary, entry, base)) {
|
|
945
|
+
verdict.skip = "unmerged";
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
if (options.available && !await options.available(entry)) verdict.skip = "rejected";
|
|
949
|
+
}
|
|
950
|
+
return {
|
|
951
|
+
primary,
|
|
952
|
+
exec,
|
|
953
|
+
base,
|
|
954
|
+
entries,
|
|
955
|
+
verdicts
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
/** The branch has landed on the default branch, or HEAD (detached after a recycle) is in `base`. */
|
|
959
|
+
async function hasLanded(exec, primary, entry, base) {
|
|
960
|
+
if (entry.merged === true) return true;
|
|
961
|
+
if (!entry.head || !base) return false;
|
|
962
|
+
return await exec("git", [
|
|
963
|
+
"-C",
|
|
964
|
+
primary,
|
|
965
|
+
"merge-base",
|
|
966
|
+
"--is-ancestor",
|
|
967
|
+
entry.head,
|
|
968
|
+
base
|
|
969
|
+
]) !== null;
|
|
970
|
+
}
|
|
971
|
+
/** The default `WorktreeCreator`: `git worktree add`. */
|
|
972
|
+
function gitWorktreeCreator(exec = nodeExec) {
|
|
973
|
+
return async ({ primaryRoot, path, base, branch }) => {
|
|
974
|
+
const args = [
|
|
975
|
+
"-C",
|
|
976
|
+
primaryRoot,
|
|
977
|
+
"worktree",
|
|
978
|
+
"add",
|
|
979
|
+
"--quiet"
|
|
980
|
+
];
|
|
981
|
+
if (!branch) args.push("--detach", path, base);
|
|
982
|
+
else if (await branchExists(exec, primaryRoot, branch)) args.push(path, branch);
|
|
983
|
+
else args.push("-b", branch, path, base);
|
|
984
|
+
if (await exec("git", args) === null) throw new Error(`git worktree add failed: ${path}`);
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
var AcquireError = class extends Error {
|
|
988
|
+
code;
|
|
989
|
+
verdicts;
|
|
990
|
+
constructor(code, message, verdicts) {
|
|
991
|
+
super(message);
|
|
992
|
+
this.code = code;
|
|
993
|
+
this.verdicts = verdicts;
|
|
994
|
+
this.name = "AcquireError";
|
|
995
|
+
}
|
|
996
|
+
};
|
|
997
|
+
const ATTEMPTS = 3;
|
|
998
|
+
/**
|
|
999
|
+
* Lease a worktree: recycle an idle one of ours when there is one, otherwise create one at the next
|
|
1000
|
+
* free slot if under `max`. Never fetches and never kills a process.
|
|
1001
|
+
*/
|
|
1002
|
+
async function acquire(options) {
|
|
1003
|
+
let verdicts = [];
|
|
1004
|
+
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
1005
|
+
const found = await survey(options);
|
|
1006
|
+
verdicts = found.verdicts;
|
|
1007
|
+
const { primary, exec, entries } = found;
|
|
1008
|
+
if (!found.base) throw new AcquireError("no-base", "no base ref: pass `base` or set origin/HEAD", verdicts);
|
|
1009
|
+
const base = await exec("git", [
|
|
1010
|
+
"-C",
|
|
1011
|
+
primary,
|
|
1012
|
+
"rev-parse",
|
|
1013
|
+
"--verify",
|
|
1014
|
+
"--quiet",
|
|
1015
|
+
`${found.base}^{commit}`
|
|
1016
|
+
]);
|
|
1017
|
+
if (!base) throw new AcquireError("no-base", `base does not resolve to a commit: ${found.base}`, verdicts);
|
|
1018
|
+
const idle = verdicts.filter((verdict) => verdict.skip === void 0).sort((a, b) => slotOf(primary, a) - slotOf(primary, b));
|
|
1019
|
+
for (const verdict of idle) {
|
|
1020
|
+
const result = await recycle(verdict, base, options, exec, primary);
|
|
1021
|
+
if (result) return result;
|
|
1022
|
+
}
|
|
1023
|
+
const slots = entries.filter((entry) => entry.linked && slotNumber(primary, entry.root) !== void 0);
|
|
1024
|
+
if (options.max !== void 0 && slots.length >= options.max) throw new AcquireError("pool-full", `all ${options.max} worktrees are in use`, verdicts);
|
|
1025
|
+
const lease = await createSlot(primary, base, options, exec, slots);
|
|
1026
|
+
if (lease) return {
|
|
1027
|
+
...lease,
|
|
1028
|
+
reused: false,
|
|
1029
|
+
branch: options.branch,
|
|
1030
|
+
verified: true,
|
|
1031
|
+
lingering: []
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
throw new AcquireError("contended", "another caller took every worktree this one tried", verdicts);
|
|
1035
|
+
}
|
|
1036
|
+
function slotOf(primary, verdict) {
|
|
1037
|
+
return slotNumber(primary, verdict.worktree.root) ?? Number.POSITIVE_INFINITY;
|
|
1038
|
+
}
|
|
1039
|
+
async function recycle(verdict, base, options, exec, primary) {
|
|
1040
|
+
const entry = verdict.worktree;
|
|
1041
|
+
const store = {
|
|
1042
|
+
exec,
|
|
1043
|
+
fs: options.fs
|
|
1044
|
+
};
|
|
1045
|
+
const lease = await claimLease(entry.root, options.holder, {
|
|
1046
|
+
...store,
|
|
1047
|
+
leaseId: options.newLeaseId?.()
|
|
1048
|
+
});
|
|
1049
|
+
if (!lease) return void 0;
|
|
1050
|
+
const head = await exec("git", [
|
|
1051
|
+
"-C",
|
|
1052
|
+
entry.root,
|
|
1053
|
+
"rev-parse",
|
|
1054
|
+
"HEAD"
|
|
1055
|
+
]);
|
|
1056
|
+
const dirty = await readDirty(entry.root, {
|
|
1057
|
+
exec,
|
|
1058
|
+
ignore: options.ignore
|
|
1059
|
+
});
|
|
1060
|
+
if (head !== entry.head || dirty !== false || !await holdsLease(lease, store)) {
|
|
1061
|
+
await release(lease, store);
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
const git = (...args) => exec("git", [
|
|
1065
|
+
"-C",
|
|
1066
|
+
entry.root,
|
|
1067
|
+
...args
|
|
1068
|
+
]);
|
|
1069
|
+
const fail = async (message) => {
|
|
1070
|
+
await release(lease, store);
|
|
1071
|
+
throw new Error(`${message}: ${entry.root}`);
|
|
1072
|
+
};
|
|
1073
|
+
if (await git("read-tree", "--reset", "-u", base) === null || await git("clean", "-fd") === null) return fail("recycle failed");
|
|
1074
|
+
if ((options.branch ? await branchExists(exec, primary, options.branch) ? await git("switch", "--quiet", "--discard-changes", options.branch) : await git("switch", "--quiet", "--discard-changes", "-c", options.branch, base) : await git("switch", "--quiet", "--discard-changes", "--detach", base)) === null) return fail(`recycle could not switch to ${options.branch ?? base}`);
|
|
1075
|
+
const result = {
|
|
1076
|
+
...lease,
|
|
1077
|
+
reused: true,
|
|
1078
|
+
branch: options.branch,
|
|
1079
|
+
verified: verdict.verified,
|
|
1080
|
+
lingering: verdict.occupancy?.lingering ?? []
|
|
1081
|
+
};
|
|
1082
|
+
const previous = entry.branch;
|
|
1083
|
+
if (previous) {
|
|
1084
|
+
result.previousBranch = previous;
|
|
1085
|
+
if (entry.merged === true && previous !== options.branch) result.previousBranchDeleted = await exec("git", [
|
|
1086
|
+
"-C",
|
|
1087
|
+
primary,
|
|
1088
|
+
"branch",
|
|
1089
|
+
"-D",
|
|
1090
|
+
previous
|
|
1091
|
+
]) !== null;
|
|
1092
|
+
}
|
|
1093
|
+
return result;
|
|
1094
|
+
}
|
|
1095
|
+
async function createSlot(primary, base, options, exec, slots) {
|
|
1096
|
+
const used = new Set(slots.map((entry) => slotNumber(primary, entry.root)));
|
|
1097
|
+
let n = 1;
|
|
1098
|
+
while (used.has(n) || await exists(slotPath(primary, n))) n++;
|
|
1099
|
+
const path = slotPath(primary, n);
|
|
1100
|
+
const create = options.create ?? gitWorktreeCreator(exec);
|
|
1101
|
+
const request = {
|
|
1102
|
+
primaryRoot: primary,
|
|
1103
|
+
path,
|
|
1104
|
+
base,
|
|
1105
|
+
branch: options.branch
|
|
1106
|
+
};
|
|
1107
|
+
try {
|
|
1108
|
+
await create(request);
|
|
1109
|
+
} catch (error) {
|
|
1110
|
+
if (await exists(path)) return void 0;
|
|
1111
|
+
throw error;
|
|
1112
|
+
}
|
|
1113
|
+
let lease;
|
|
1114
|
+
try {
|
|
1115
|
+
lease = await claimLease(await normalizeWorktreePath(path), options.holder, {
|
|
1116
|
+
exec,
|
|
1117
|
+
fs: options.fs,
|
|
1118
|
+
leaseId: options.newLeaseId?.()
|
|
1119
|
+
});
|
|
1120
|
+
} catch (error) {
|
|
1121
|
+
await undo(options.undoCreate, request);
|
|
1122
|
+
throw error;
|
|
1123
|
+
}
|
|
1124
|
+
if (!lease) await undo(options.undoCreate, request);
|
|
1125
|
+
return lease;
|
|
1126
|
+
}
|
|
1127
|
+
async function undo(undoCreate, request) {
|
|
1128
|
+
try {
|
|
1129
|
+
await undoCreate?.(request);
|
|
1130
|
+
} catch {}
|
|
1131
|
+
}
|
|
1132
|
+
async function branchExists(exec, primary, branch) {
|
|
1133
|
+
return await exec("git", [
|
|
1134
|
+
"-C",
|
|
1135
|
+
primary,
|
|
1136
|
+
"rev-parse",
|
|
1137
|
+
"--verify",
|
|
1138
|
+
"--quiet",
|
|
1139
|
+
`refs/heads/${branch}`
|
|
1140
|
+
]) !== null;
|
|
1141
|
+
}
|
|
1142
|
+
async function exists(path) {
|
|
1143
|
+
try {
|
|
1144
|
+
await lstat(path);
|
|
1145
|
+
return true;
|
|
1146
|
+
} catch {
|
|
1147
|
+
return false;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
//#endregion
|
|
1151
|
+
//#region src/worktrees/prune-worktrees.ts
|
|
1152
|
+
/**
|
|
1153
|
+
* Removes the worktrees this library owns that nothing needs any more: unleased, idle, clean, and
|
|
1154
|
+
* landed. A dry run unless `apply` is set. Never touches the primary checkout, a foreign worktree, a
|
|
1155
|
+
* leased one, or a busy one, and never kills a process: lingering services are reported so the caller
|
|
1156
|
+
* can reclaim them (E-TH-5 is the counterexample).
|
|
1157
|
+
*
|
|
1158
|
+
* An unleased worktree carries no record of its owner, since the lease is the only one this library
|
|
1159
|
+
* writes; `classifyOwner` recognises it as this library's by its slot path (`slotPath`).
|
|
1160
|
+
*/
|
|
1161
|
+
async function pruneWorktrees(options) {
|
|
1162
|
+
const exec = options.exec ?? nodeExec;
|
|
1163
|
+
const primary = await normalizeWorktreePath(options.primaryRoot);
|
|
1164
|
+
const entries = await listWorktrees({
|
|
1165
|
+
primaryRoot: options.primaryRoot,
|
|
1166
|
+
exec,
|
|
1167
|
+
ignore: options.ignore,
|
|
1168
|
+
forge: options.forge
|
|
1169
|
+
});
|
|
1170
|
+
const wanted = options.roots && new Set(await Promise.all(options.roots.map(normalizeWorktreePath)));
|
|
1171
|
+
const probe = await probeProcesses({ source: options.source });
|
|
1172
|
+
const strict = options.strict ?? true;
|
|
1173
|
+
const apply = options.apply === true;
|
|
1174
|
+
const worktrees = [];
|
|
1175
|
+
for (const entry of entries) {
|
|
1176
|
+
if (wanted && !wanted.has(entry.root)) continue;
|
|
1177
|
+
const owner = classifyOwner(entry, {
|
|
1178
|
+
primaryRoot: primary,
|
|
1179
|
+
codexHome: options.codexHome
|
|
1180
|
+
}).owner;
|
|
1181
|
+
const nested = entries.map((other) => other.root).filter((root) => root.startsWith(`${entry.root}${sep}`));
|
|
1182
|
+
const occupancy = probe.occupancy(entry.root, {
|
|
1183
|
+
strict,
|
|
1184
|
+
nested
|
|
1185
|
+
});
|
|
1186
|
+
const outcome = {
|
|
1187
|
+
root: entry.root,
|
|
1188
|
+
owner,
|
|
1189
|
+
status: "skipped",
|
|
1190
|
+
occupants: occupancy.occupants,
|
|
1191
|
+
lingering: occupancy.lingering,
|
|
1192
|
+
unlinked: occupancy.unlinked
|
|
1193
|
+
};
|
|
1194
|
+
if (entry.branch) outcome.branch = entry.branch;
|
|
1195
|
+
if (!occupancy.verified && !strict) outcome.unverified = true;
|
|
1196
|
+
const reason = skipReason(entry, owner, occupancy);
|
|
1197
|
+
if (reason) outcome.reason = reason;
|
|
1198
|
+
else outcome.status = apply ? await removeEntry(exec, primary, entry, options.ignore) : "candidate";
|
|
1199
|
+
worktrees.push(outcome);
|
|
1200
|
+
}
|
|
1201
|
+
if (!apply) return {
|
|
1202
|
+
applied: false,
|
|
1203
|
+
worktrees
|
|
1204
|
+
};
|
|
1205
|
+
return {
|
|
1206
|
+
applied: true,
|
|
1207
|
+
worktrees,
|
|
1208
|
+
pruned: await exec("git", [
|
|
1209
|
+
"-C",
|
|
1210
|
+
primary,
|
|
1211
|
+
"worktree",
|
|
1212
|
+
"prune"
|
|
1213
|
+
]) !== null
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
/**
|
|
1217
|
+
* The first check a worktree fails, or `undefined` when it may go. `force` is `removeWorktree`'s
|
|
1218
|
+
* explicit override. `true` waives every check except `primary`, `leased`, and `busy` or `unverified`;
|
|
1219
|
+
* `'in-use'` waives those two as well. Nothing waives `primary` and `leased`.
|
|
1220
|
+
*/
|
|
1221
|
+
function skipReason(entry, owner, occupancy, force = false) {
|
|
1222
|
+
if (!entry.linked) return "primary";
|
|
1223
|
+
if (parseLeaseReason(entry.locked)) return "leased";
|
|
1224
|
+
if (force) {
|
|
1225
|
+
if (force !== "in-use" && occupancy.busy) return occupancy.verified ? "busy" : "unverified";
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
if (owner !== "self") return "foreign";
|
|
1229
|
+
if (entry.prunable) return "prunable";
|
|
1230
|
+
if (occupancy.busy) return occupancy.verified ? "busy" : "unverified";
|
|
1231
|
+
if (entry.dirty === void 0) return "dirty-unknown";
|
|
1232
|
+
if (entry.dirty) return "dirty";
|
|
1233
|
+
if (entry.merged === void 0) return "merge-unknown";
|
|
1234
|
+
if (!entry.merged) return "unmerged";
|
|
1235
|
+
}
|
|
1236
|
+
async function removeEntry(exec, primary, entry, ignore, forced = false) {
|
|
1237
|
+
if (forced) return await exec("git", [
|
|
1238
|
+
"-C",
|
|
1239
|
+
primary,
|
|
1240
|
+
"worktree",
|
|
1241
|
+
"remove",
|
|
1242
|
+
"--force",
|
|
1243
|
+
entry.root
|
|
1244
|
+
]) === null ? "failed" : "removed";
|
|
1245
|
+
if (await readDirty(entry.root, {
|
|
1246
|
+
exec,
|
|
1247
|
+
ignore
|
|
1248
|
+
}) !== false) return "failed";
|
|
1249
|
+
return await exec("git", [
|
|
1250
|
+
"-C",
|
|
1251
|
+
primary,
|
|
1252
|
+
"worktree",
|
|
1253
|
+
"remove",
|
|
1254
|
+
...ignore?.length ? ["--force"] : [],
|
|
1255
|
+
entry.root
|
|
1256
|
+
]) === null ? "failed" : "removed";
|
|
1257
|
+
}
|
|
1258
|
+
//#endregion
|
|
1259
|
+
//#region src/worktrees/remove-worktree.ts
|
|
1260
|
+
/**
|
|
1261
|
+
* Removes one worktree the caller names, the single-target counterpart of `pruneWorktrees`, and
|
|
1262
|
+
* reports the same `PruneOutcome` with a skip `reason`. The caller naming it is what a prune lacks, so
|
|
1263
|
+
* the landed check can be waived with `force`, and the idle check with `force: 'in-use'`; ownership cannot:
|
|
1264
|
+
*
|
|
1265
|
+
* | worktree | without `force` | `force: true` | `force: 'in-use'` |
|
|
1266
|
+
* | --- | --- | --- | --- |
|
|
1267
|
+
* | the primary checkout | `primary` | `primary` | `primary` |
|
|
1268
|
+
* | held by this library's lease | `leased`; `release` it first | `leased` | `leased`; the lease is never broken |
|
|
1269
|
+
* | foreign (another tool, person, or lock) | `foreign` | removed, unless git refuses a locked one: `failed` | same |
|
|
1270
|
+
* | a live agent session inside | `busy` (or `unverified`) | `busy` (or `unverified`) | removed; the sessions are reported, never killed |
|
|
1271
|
+
* | checkout already gone | `prunable` | `git worktree remove --force` is tried | same |
|
|
1272
|
+
* | dirty or unmerged | the matching reason | removed | removed |
|
|
1273
|
+
* | a path that is no worktree of this repository | `unlisted` | `unlisted` | `unlisted` |
|
|
1274
|
+
*
|
|
1275
|
+
* `--force` is passed once and `-f -f` never: one refuses a locked worktree, so a lease taken after the
|
|
1276
|
+
* listing holds (E-GIT-L3, E-GIT-L4). Never kills a process; lingering services are reported so the
|
|
1277
|
+
* caller can reclaim them. Always applies: there is no dry run, since the caller named the target.
|
|
1278
|
+
*/
|
|
1279
|
+
async function removeWorktree(options) {
|
|
1280
|
+
const exec = options.exec ?? nodeExec;
|
|
1281
|
+
const primary = await normalizeWorktreePath(options.primaryRoot);
|
|
1282
|
+
const root = await normalizeWorktreePath(options.root);
|
|
1283
|
+
const entries = await listWorktrees({
|
|
1284
|
+
primaryRoot: options.primaryRoot,
|
|
1285
|
+
exec,
|
|
1286
|
+
ignore: options.ignore,
|
|
1287
|
+
forge: options.forge
|
|
1288
|
+
});
|
|
1289
|
+
const entry = entries.find((candidate) => candidate.root === root);
|
|
1290
|
+
if (!entry) return {
|
|
1291
|
+
root,
|
|
1292
|
+
owner: "unknown",
|
|
1293
|
+
status: "skipped",
|
|
1294
|
+
reason: "unlisted",
|
|
1295
|
+
occupants: [],
|
|
1296
|
+
lingering: [],
|
|
1297
|
+
unlinked: []
|
|
1298
|
+
};
|
|
1299
|
+
const force = options.force ?? false;
|
|
1300
|
+
const strict = options.strict ?? true;
|
|
1301
|
+
const owner = classifyOwner(entry, {
|
|
1302
|
+
primaryRoot: primary,
|
|
1303
|
+
codexHome: options.codexHome
|
|
1304
|
+
}).owner;
|
|
1305
|
+
const probe = await probeProcesses({ source: options.source });
|
|
1306
|
+
const nested = entries.map((other) => other.root).filter((path) => path.startsWith(`${entry.root}${sep}`));
|
|
1307
|
+
const occupancy = probe.occupancy(entry.root, {
|
|
1308
|
+
strict,
|
|
1309
|
+
nested
|
|
1310
|
+
});
|
|
1311
|
+
const outcome = {
|
|
1312
|
+
root: entry.root,
|
|
1313
|
+
owner,
|
|
1314
|
+
status: "skipped",
|
|
1315
|
+
occupants: occupancy.occupants,
|
|
1316
|
+
lingering: occupancy.lingering,
|
|
1317
|
+
unlinked: occupancy.unlinked
|
|
1318
|
+
};
|
|
1319
|
+
if (entry.branch) outcome.branch = entry.branch;
|
|
1320
|
+
if (!occupancy.verified && !strict && force !== "in-use") outcome.unverified = true;
|
|
1321
|
+
const reason = skipReason(entry, owner, occupancy, force);
|
|
1322
|
+
if (reason) outcome.reason = reason;
|
|
1323
|
+
else outcome.status = await removeEntry(exec, primary, entry, options.ignore, force !== false);
|
|
1324
|
+
return outcome;
|
|
1325
|
+
}
|
|
1326
|
+
//#endregion
|
|
1327
|
+
//#region src/worktrees/seed-worktree.ts
|
|
1328
|
+
/** The default `SeedFs`, on `node:fs/promises`. */
|
|
1329
|
+
const nodeSeedFs = {
|
|
1330
|
+
async readText(path) {
|
|
1331
|
+
try {
|
|
1332
|
+
return await readFile(path, "utf8");
|
|
1333
|
+
} catch (error) {
|
|
1334
|
+
if (error.code === "ENOENT") return void 0;
|
|
1335
|
+
throw error;
|
|
1336
|
+
}
|
|
1337
|
+
},
|
|
1338
|
+
async exists(path) {
|
|
1339
|
+
try {
|
|
1340
|
+
await lstat(path);
|
|
1341
|
+
return true;
|
|
1342
|
+
} catch {
|
|
1343
|
+
return false;
|
|
1344
|
+
}
|
|
1345
|
+
},
|
|
1346
|
+
async mkdirp(path) {
|
|
1347
|
+
await mkdir(path, { recursive: true });
|
|
1348
|
+
},
|
|
1349
|
+
async copyFile(from, to) {
|
|
1350
|
+
await copyFile(from, to);
|
|
1351
|
+
}
|
|
1352
|
+
};
|
|
1353
|
+
/** The include file's name and place: the project root, `.gitignore` syntax (research: 'E-CC-W2'). */
|
|
1354
|
+
const WORKTREE_INCLUDE = ".worktreeinclude";
|
|
1355
|
+
/** Pathspecs per `git ls-files` call, to stay under the platform's argument-length limit. */
|
|
1356
|
+
const CHUNK = 500;
|
|
1357
|
+
/**
|
|
1358
|
+
* Copies the gitignored files a `.worktreeinclude` names from a source checkout (normally the primary)
|
|
1359
|
+
* into a new or recycled worktree, and reports what it copied and what it skipped.
|
|
1360
|
+
*
|
|
1361
|
+
* Claude Code's semantics (research: 'E-CC-W2'): the file sits at the project root and uses
|
|
1362
|
+
* `.gitignore` syntax, and only a file that matches a pattern *and* is gitignored is copied, so
|
|
1363
|
+
* tracked files are never duplicated. Git does the matching here, so negation, anchoring, and
|
|
1364
|
+
* directory patterns behave exactly as they do in `.gitignore`. Inside a wholly ignored directory, a
|
|
1365
|
+
* pattern that starts with `**` and a slash matches every file below it here, while Claude Code reaches
|
|
1366
|
+
* in only when the directory's path holds the pattern's first name (research: 'E-CC-W6'), so this can
|
|
1367
|
+
* copy a superset of what Claude Code would.
|
|
1368
|
+
*
|
|
1369
|
+
* The include file is read from the source's working tree, as Claude Code does, not from its
|
|
1370
|
+
* committed HEAD as Treehouse does (research: 'E-TH-4'), so an uncommitted edit takes effect.
|
|
1371
|
+
*/
|
|
1372
|
+
async function seedWorktree(sourceRoot, worktreeRoot, options) {
|
|
1373
|
+
const fs = options.fs ?? nodeSeedFs;
|
|
1374
|
+
const inventory = {
|
|
1375
|
+
include: "absent",
|
|
1376
|
+
copied: [],
|
|
1377
|
+
skipped: []
|
|
1378
|
+
};
|
|
1379
|
+
const text = await fs.readText(join(sourceRoot, WORKTREE_INCLUDE));
|
|
1380
|
+
if (text === void 0) return inventory;
|
|
1381
|
+
const patterns = parsePatterns(text);
|
|
1382
|
+
if (patterns.length === 0) return {
|
|
1383
|
+
...inventory,
|
|
1384
|
+
include: "present"
|
|
1385
|
+
};
|
|
1386
|
+
const candidates = await lsFiles(options.exec, sourceRoot, patterns.flatMap((pattern) => ["-x", pattern]));
|
|
1387
|
+
if (candidates === void 0) return {
|
|
1388
|
+
...inventory,
|
|
1389
|
+
include: "unknown"
|
|
1390
|
+
};
|
|
1391
|
+
const ignored = /* @__PURE__ */ new Set();
|
|
1392
|
+
for (let i = 0; i < candidates.length; i += CHUNK) {
|
|
1393
|
+
const chunk = candidates.slice(i, i + CHUNK).map((path) => `:(literal)${path}`);
|
|
1394
|
+
const found = await lsFiles(options.exec, sourceRoot, [
|
|
1395
|
+
"--exclude-standard",
|
|
1396
|
+
"--",
|
|
1397
|
+
...chunk
|
|
1398
|
+
]);
|
|
1399
|
+
if (found === void 0) return {
|
|
1400
|
+
...inventory,
|
|
1401
|
+
include: "unknown"
|
|
1402
|
+
};
|
|
1403
|
+
for (const path of found) ignored.add(path);
|
|
1404
|
+
}
|
|
1405
|
+
inventory.include = "present";
|
|
1406
|
+
for (const path of candidates) {
|
|
1407
|
+
if (!ignored.has(path)) {
|
|
1408
|
+
inventory.skipped.push({
|
|
1409
|
+
path,
|
|
1410
|
+
reason: "not-ignored"
|
|
1411
|
+
});
|
|
1412
|
+
continue;
|
|
1413
|
+
}
|
|
1414
|
+
const to = join(worktreeRoot, path);
|
|
1415
|
+
if (!options.overwrite && await fs.exists(to)) {
|
|
1416
|
+
inventory.skipped.push({
|
|
1417
|
+
path,
|
|
1418
|
+
reason: "exists"
|
|
1419
|
+
});
|
|
1420
|
+
continue;
|
|
1421
|
+
}
|
|
1422
|
+
try {
|
|
1423
|
+
await fs.mkdirp(dirname(to));
|
|
1424
|
+
await fs.copyFile(join(sourceRoot, path), to);
|
|
1425
|
+
inventory.copied.push(path);
|
|
1426
|
+
} catch (error) {
|
|
1427
|
+
inventory.skipped.push({
|
|
1428
|
+
path,
|
|
1429
|
+
reason: "failed",
|
|
1430
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
return inventory;
|
|
1435
|
+
}
|
|
1436
|
+
/** Lines of a `.gitignore`-syntax file that carry a pattern; git itself ignores blanks and comments. */
|
|
1437
|
+
function parsePatterns(text) {
|
|
1438
|
+
return text.split(/\r?\n/).filter((line) => line.trim() !== "" && !line.startsWith("#"));
|
|
1439
|
+
}
|
|
1440
|
+
/** `git ls-files --others --ignored` with the given exclude source, NUL-separated so no path is
|
|
1441
|
+
* C-quoted; `undefined` when git failed. */
|
|
1442
|
+
async function lsFiles(exec, root, args) {
|
|
1443
|
+
const out = await exec("git", [
|
|
1444
|
+
"-C",
|
|
1445
|
+
root,
|
|
1446
|
+
"ls-files",
|
|
1447
|
+
"-z",
|
|
1448
|
+
"--others",
|
|
1449
|
+
"--ignored",
|
|
1450
|
+
...args
|
|
1451
|
+
]);
|
|
1452
|
+
if (out === null) return void 0;
|
|
1453
|
+
return out.split("\0").filter((path) => path !== "");
|
|
1454
|
+
}
|
|
1455
|
+
//#endregion
|
|
1456
|
+
export { AcquireError, LEASE_LIBRARY, WORKTREE_INCLUDE, acquire, claimLease, classifyOwner, explain, ghForgeMergedProbe, gitWorktreeCreator, holdsLease, leaseFile, listWorktrees, nodeExec, nodeLeaseFs, nodeSeedFs, normalizeWorktreePath, occupants, parseLeaseReason, primaryRoot, probeProcesses, procfsProcessSource, pruneWorktrees, readDirty, release, removeWorktree, seedWorktree, slotNumber, slotPath, worktreesDir };
|