@titan-design/worktree 0.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/LICENSE +21 -0
- package/README.md +13 -0
- package/dist/index.d.ts +359 -0
- package/dist/index.js +877 -0
- package/dist/index.js.map +1 -0
- package/package.json +38 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,877 @@
|
|
|
1
|
+
// src/allocator.ts
|
|
2
|
+
import { existsSync as existsSync5, rmSync as rmSync2 } from "fs";
|
|
3
|
+
import path5 from "path";
|
|
4
|
+
|
|
5
|
+
// src/add.ts
|
|
6
|
+
import { spawn } from "child_process";
|
|
7
|
+
import { existsSync, rmSync } from "fs";
|
|
8
|
+
|
|
9
|
+
// src/git.ts
|
|
10
|
+
import { execFile } from "child_process";
|
|
11
|
+
import path from "path";
|
|
12
|
+
import { promisify } from "util";
|
|
13
|
+
var execFileAsync = promisify(execFile);
|
|
14
|
+
var DEFAULT_STRIPPED_ENV_PREFIXES = ["AGENT_CHAT_"];
|
|
15
|
+
function gitChildEnv(env = process.env, stripPrefixes = DEFAULT_STRIPPED_ENV_PREFIXES) {
|
|
16
|
+
const out = { ...env, GIT_TERMINAL_PROMPT: "0" };
|
|
17
|
+
for (const key of Object.keys(out)) {
|
|
18
|
+
if (stripPrefixes.some((prefix) => key.startsWith(prefix))) delete out[key];
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
async function gitText(args, cwd, timeout) {
|
|
23
|
+
const { stdout } = await execFileAsync("git", [...args], {
|
|
24
|
+
cwd,
|
|
25
|
+
encoding: "utf8",
|
|
26
|
+
env: gitChildEnv(),
|
|
27
|
+
...timeout === void 0 ? {} : { timeout }
|
|
28
|
+
});
|
|
29
|
+
return stdout.trim();
|
|
30
|
+
}
|
|
31
|
+
async function gitOrNull(args, cwd, timeout) {
|
|
32
|
+
try {
|
|
33
|
+
return await gitText(args, cwd, timeout);
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
var runGit = (args, cwd) => gitOrNull(args, cwd);
|
|
39
|
+
async function findGitRoot(cwd, git = runGit) {
|
|
40
|
+
const commonDir = await git(["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd);
|
|
41
|
+
return commonDir === null ? null : path.dirname(commonDir);
|
|
42
|
+
}
|
|
43
|
+
async function observedPresence(cwd, git = runGit) {
|
|
44
|
+
const [branch, toplevel, commonDir, gitDir] = await Promise.all([
|
|
45
|
+
git(["rev-parse", "--abbrev-ref", "HEAD"], cwd),
|
|
46
|
+
git(["rev-parse", "--show-toplevel"], cwd),
|
|
47
|
+
git(["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd),
|
|
48
|
+
git(["rev-parse", "--path-format=absolute", "--git-dir"], cwd)
|
|
49
|
+
]);
|
|
50
|
+
if (toplevel === null && commonDir === null) return void 0;
|
|
51
|
+
const observed = {
|
|
52
|
+
// 'HEAD' means detached, which is not a branch name.
|
|
53
|
+
...branch !== null && branch !== "" && branch !== "HEAD" ? { gitBranch: branch } : {},
|
|
54
|
+
...toplevel === null || toplevel === "" ? {} : { worktreePath: toplevel },
|
|
55
|
+
...commonDir === null ? {} : { repoPath: path.dirname(path.resolve(commonDir)) },
|
|
56
|
+
...commonDir === null || gitDir === null ? {} : { isLinkedWorktree: path.resolve(commonDir) !== path.resolve(gitDir) }
|
|
57
|
+
};
|
|
58
|
+
return Object.keys(observed).length === 0 ? void 0 : observed;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/process-group.ts
|
|
62
|
+
var groupAlive = (pid) => {
|
|
63
|
+
try {
|
|
64
|
+
process.kill(-pid, 0);
|
|
65
|
+
return true;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
var signalGroup = (pid, signal) => {
|
|
71
|
+
try {
|
|
72
|
+
process.kill(-pid, signal);
|
|
73
|
+
} catch {
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// src/add.ts
|
|
78
|
+
var WORKTREE_ADD_TIMEOUT_MS = 3e5;
|
|
79
|
+
var CLEANUP_TIMEOUT_MS = 3e4;
|
|
80
|
+
var TERM_GRACE_MS = 1e3;
|
|
81
|
+
var addQueues = /* @__PURE__ */ new Map();
|
|
82
|
+
function addWorktree(gitRoot, args, run = gitText) {
|
|
83
|
+
const adding = (addQueues.get(gitRoot) ?? Promise.resolve()).then(() => run(args, gitRoot));
|
|
84
|
+
const tail = adding.catch(() => void 0);
|
|
85
|
+
addQueues.set(gitRoot, tail);
|
|
86
|
+
void tail.then(() => addQueues.get(gitRoot) === tail && addQueues.delete(gitRoot));
|
|
87
|
+
return adding;
|
|
88
|
+
}
|
|
89
|
+
var addTarget = (args) => args[2] === "-b" ? args[4] : args[2];
|
|
90
|
+
var AddTimedOut = class extends Error {
|
|
91
|
+
};
|
|
92
|
+
async function reapGroup(pid) {
|
|
93
|
+
signalGroup(pid, "SIGKILL");
|
|
94
|
+
for (let i = 0; i < 100 && groupAlive(pid); i++) await new Promise((r) => setTimeout(r, 20));
|
|
95
|
+
}
|
|
96
|
+
function settleAdd(code, out, err) {
|
|
97
|
+
if (code === 0) return out.trim();
|
|
98
|
+
return Object.assign(new Error(err.trim() || `git exited with code ${code}`), { code, stderr: err });
|
|
99
|
+
}
|
|
100
|
+
function gitInGroup(args, cwd, timeoutMs) {
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
const child = spawn("git", [...args], { cwd, env: gitChildEnv(), detached: true });
|
|
103
|
+
const pid = child.pid;
|
|
104
|
+
let out = "";
|
|
105
|
+
let err = "";
|
|
106
|
+
let timedOut = false;
|
|
107
|
+
child.stdout.on("data", (chunk) => out += chunk);
|
|
108
|
+
child.stderr.on("data", (chunk) => err += chunk);
|
|
109
|
+
const term = setTimeout(() => {
|
|
110
|
+
timedOut = true;
|
|
111
|
+
if (pid !== void 0) signalGroup(pid, "SIGTERM");
|
|
112
|
+
}, timeoutMs);
|
|
113
|
+
const kill = setTimeout(() => pid !== void 0 && signalGroup(pid, "SIGKILL"), timeoutMs + TERM_GRACE_MS);
|
|
114
|
+
child.on("error", reject);
|
|
115
|
+
child.on("close", (code) => {
|
|
116
|
+
clearTimeout(term);
|
|
117
|
+
clearTimeout(kill);
|
|
118
|
+
void (pid === void 0 ? Promise.resolve() : reapGroup(pid)).then(() => {
|
|
119
|
+
const settled = timedOut ? new AddTimedOut() : settleAdd(code, out, err);
|
|
120
|
+
if (typeof settled === "string") resolve(settled);
|
|
121
|
+
else reject(settled);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
async function discardHalfCreated(gitRoot, args, created) {
|
|
127
|
+
const target = addTarget(args);
|
|
128
|
+
if (created && target !== void 0) {
|
|
129
|
+
await gitOrNull(["worktree", "remove", "--force", target], gitRoot, CLEANUP_TIMEOUT_MS);
|
|
130
|
+
rmSync(target, { recursive: true, force: true });
|
|
131
|
+
}
|
|
132
|
+
await gitOrNull(["worktree", "prune"], gitRoot, CLEANUP_TIMEOUT_MS);
|
|
133
|
+
}
|
|
134
|
+
var latestAdd = /* @__PURE__ */ new Map();
|
|
135
|
+
function boundedAdd(run, timeoutMs) {
|
|
136
|
+
return async (args, cwd) => {
|
|
137
|
+
const target = addTarget(args);
|
|
138
|
+
const created = target !== void 0 && !existsSync(target);
|
|
139
|
+
const mine = {};
|
|
140
|
+
if (target !== void 0) latestAdd.set(target, mine);
|
|
141
|
+
const timedOut = new Error(`git worktree add in ${cwd} timed out after ${timeoutMs}ms and was killed`);
|
|
142
|
+
try {
|
|
143
|
+
return await (run === void 0 ? gitInGroup(args, cwd, timeoutMs) : raceTimer(run(args, cwd), timeoutMs, () => cleanLate(cwd, args, created, mine)));
|
|
144
|
+
} catch (err) {
|
|
145
|
+
if (!(err instanceof AddTimedOut)) throw err;
|
|
146
|
+
await discardHalfCreated(cwd, args, created);
|
|
147
|
+
throw timedOut;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
async function cleanLate(cwd, args, created, mine) {
|
|
152
|
+
const target = addTarget(args);
|
|
153
|
+
if (target === void 0 || latestAdd.get(target) !== mine) return;
|
|
154
|
+
await discardHalfCreated(cwd, args, created);
|
|
155
|
+
}
|
|
156
|
+
async function raceTimer(add, timeoutMs, onLate) {
|
|
157
|
+
let timer;
|
|
158
|
+
const expired = new Promise((_, reject) => {
|
|
159
|
+
timer = setTimeout(() => reject(new AddTimedOut()), timeoutMs);
|
|
160
|
+
});
|
|
161
|
+
try {
|
|
162
|
+
return await Promise.race([add, expired]);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
if (err instanceof AddTimedOut) void add.then(onLate, () => void 0);
|
|
165
|
+
throw err;
|
|
166
|
+
} finally {
|
|
167
|
+
clearTimeout(timer);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/branch-base.ts
|
|
172
|
+
async function defaultBranchCandidates(gitRoot) {
|
|
173
|
+
const head = await gitOrNull(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], gitRoot);
|
|
174
|
+
return head?.startsWith("origin/") ? [head.slice("origin/".length)] : ["main", "master"];
|
|
175
|
+
}
|
|
176
|
+
async function fetchTip(gitRoot, branch, timeoutMs) {
|
|
177
|
+
const tracking = `refs/remotes/origin/${branch}`;
|
|
178
|
+
const fetched = await gitOrNull(
|
|
179
|
+
["fetch", "--quiet", "--no-tags", "origin", `+refs/heads/${branch}:${tracking}`],
|
|
180
|
+
gitRoot,
|
|
181
|
+
timeoutMs
|
|
182
|
+
);
|
|
183
|
+
return fetched === null ? null : gitOrNull(["rev-parse", "--verify", `${tracking}^{commit}`], gitRoot);
|
|
184
|
+
}
|
|
185
|
+
async function fetchDefaultTip(gitRoot, timeoutMs) {
|
|
186
|
+
if (await gitOrNull(["remote", "get-url", "origin"], gitRoot) === null)
|
|
187
|
+
return "the repository has no origin remote";
|
|
188
|
+
const deadline = Date.now() + timeoutMs;
|
|
189
|
+
const candidates = await defaultBranchCandidates(gitRoot);
|
|
190
|
+
for (const branch of candidates) {
|
|
191
|
+
const remaining = deadline - Date.now();
|
|
192
|
+
if (remaining <= 0) break;
|
|
193
|
+
const sha = await fetchTip(gitRoot, branch, remaining);
|
|
194
|
+
if (sha !== null) return { sha, ref: `origin/${branch}` };
|
|
195
|
+
}
|
|
196
|
+
return `fetching ${candidates.map((b) => `origin/${b}`).join(" or ")} failed or timed out`;
|
|
197
|
+
}
|
|
198
|
+
var inflightBases = /* @__PURE__ */ new Map();
|
|
199
|
+
function resolveBranchBase(gitRoot, timeoutMs) {
|
|
200
|
+
const pending = inflightBases.get(gitRoot);
|
|
201
|
+
if (pending !== void 0) return pending;
|
|
202
|
+
const resolving = resolveBranchBaseNow(gitRoot, timeoutMs).finally(() => inflightBases.delete(gitRoot));
|
|
203
|
+
inflightBases.set(gitRoot, resolving);
|
|
204
|
+
return resolving;
|
|
205
|
+
}
|
|
206
|
+
async function resolveBranchBaseNow(gitRoot, timeoutMs) {
|
|
207
|
+
const reason = await fetchDefaultTip(gitRoot, timeoutMs);
|
|
208
|
+
if (typeof reason !== "string") return reason;
|
|
209
|
+
const sha = await gitOrNull(["rev-parse", "HEAD"], gitRoot) ?? "HEAD";
|
|
210
|
+
return {
|
|
211
|
+
sha,
|
|
212
|
+
ref: "HEAD",
|
|
213
|
+
warning: `worktree branched from the local HEAD at ${sha} because ${reason}; it may lag origin or carry unpushed commits`
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// src/errors.ts
|
|
218
|
+
var WorktreeBudgetExhaustedError = class extends Error {
|
|
219
|
+
constructor(allocated, budget) {
|
|
220
|
+
super(`Worktree budget exhausted: ${allocated}/${budget} allocated`);
|
|
221
|
+
this.allocated = allocated;
|
|
222
|
+
this.budget = budget;
|
|
223
|
+
this.name = "WorktreeBudgetExhaustedError";
|
|
224
|
+
}
|
|
225
|
+
allocated;
|
|
226
|
+
budget;
|
|
227
|
+
};
|
|
228
|
+
var WorktreeInUseError = class extends Error {
|
|
229
|
+
constructor(branch, worktreePath) {
|
|
230
|
+
super(
|
|
231
|
+
`${worktreePath} is still on disk and holds branch ${branch}. Another agent of this name may be live. Release it, spawn under a different name, or force the allocation to discard it.`
|
|
232
|
+
);
|
|
233
|
+
this.branch = branch;
|
|
234
|
+
this.worktreePath = worktreePath;
|
|
235
|
+
this.name = "WorktreeInUseError";
|
|
236
|
+
}
|
|
237
|
+
branch;
|
|
238
|
+
worktreePath;
|
|
239
|
+
};
|
|
240
|
+
var OriginUnreachableError = class extends Error {
|
|
241
|
+
constructor(branch, detail) {
|
|
242
|
+
super(
|
|
243
|
+
`cannot reach origin to look for branch ${branch} (${detail}); it may still hold the agent's work, so the worktree was not re-created. Fix the remote and resume again.`
|
|
244
|
+
);
|
|
245
|
+
this.branch = branch;
|
|
246
|
+
this.name = "OriginUnreachableError";
|
|
247
|
+
}
|
|
248
|
+
branch;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// src/layout.ts
|
|
252
|
+
import { cpSync, existsSync as existsSync2 } from "fs";
|
|
253
|
+
import path2 from "path";
|
|
254
|
+
async function allocatedPaths(gitRoot, basePath) {
|
|
255
|
+
const out = await gitOrNull(["worktree", "list", "--porcelain"], gitRoot);
|
|
256
|
+
if (out === null) return [];
|
|
257
|
+
const base = path2.resolve(gitRoot, basePath) + path2.sep;
|
|
258
|
+
return out.split("\n").filter((line) => line.startsWith("worktree ")).map((line) => path2.resolve(line.slice("worktree ".length).trim())).filter((dir) => dir.startsWith(base));
|
|
259
|
+
}
|
|
260
|
+
async function pruneStaleWorktrees(gitRoot) {
|
|
261
|
+
await gitOrNull(["worktree", "prune"], gitRoot);
|
|
262
|
+
}
|
|
263
|
+
async function checkoutOf(gitRoot, branch) {
|
|
264
|
+
const out = await gitOrNull(["worktree", "list", "--porcelain"], gitRoot) ?? "";
|
|
265
|
+
const holder = out.split("\n\n").find((block) => block.includes(`branch refs/heads/${branch}`));
|
|
266
|
+
const line = holder?.split("\n").find((l) => l.startsWith("worktree "));
|
|
267
|
+
return line ? path2.resolve(line.slice("worktree ".length).trim()) : null;
|
|
268
|
+
}
|
|
269
|
+
function copyClaudeDir(gitRoot, worktreePath) {
|
|
270
|
+
const source = path2.resolve(gitRoot, ".claude");
|
|
271
|
+
const target = path2.resolve(worktreePath, ".claude");
|
|
272
|
+
if (existsSync2(source) && !existsSync2(target)) cpSync(source, target, { recursive: true });
|
|
273
|
+
}
|
|
274
|
+
async function removeWorktree(gitRoot, worktreePath, branch) {
|
|
275
|
+
if (await gitOrNull(["worktree", "remove", worktreePath], gitRoot) === null) {
|
|
276
|
+
await gitOrNull(["worktree", "remove", "--force", worktreePath], gitRoot);
|
|
277
|
+
}
|
|
278
|
+
await gitOrNull(["branch", "-D", branch], gitRoot);
|
|
279
|
+
await pruneStaleWorktrees(gitRoot);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// src/options.ts
|
|
283
|
+
var DEFAULT_WORKTREE_BUDGET = 3;
|
|
284
|
+
var DEFAULT_BASE_PATH = ".worktrees";
|
|
285
|
+
var BRANCH_PREFIX = "agent-chat/";
|
|
286
|
+
var DEFAULT_FETCH_TIMEOUT_MS = 15e3;
|
|
287
|
+
var RECLAIM_GRACE_MS = 12e4;
|
|
288
|
+
var basePathOf = (opts) => opts.basePath ?? DEFAULT_BASE_PATH;
|
|
289
|
+
var branchPrefixOf = (opts) => opts.branchPrefix ?? BRANCH_PREFIX;
|
|
290
|
+
function budgetOf(opts) {
|
|
291
|
+
const { budget } = opts;
|
|
292
|
+
return typeof budget === "function" ? budget() : budget ?? DEFAULT_WORKTREE_BUDGET;
|
|
293
|
+
}
|
|
294
|
+
var slug = (name) => name.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^[.-]+/, "") || "agent";
|
|
295
|
+
var branchFor = (name, opts = {}) => `${branchPrefixOf(opts)}${slug(name)}`;
|
|
296
|
+
|
|
297
|
+
// src/reattach.ts
|
|
298
|
+
import { existsSync as existsSync3 } from "fs";
|
|
299
|
+
import path4 from "path";
|
|
300
|
+
|
|
301
|
+
// src/setup.ts
|
|
302
|
+
import { execFile as execFile2, spawn as spawn2 } from "child_process";
|
|
303
|
+
import { writeFileSync } from "fs";
|
|
304
|
+
import path3 from "path";
|
|
305
|
+
import { promisify as promisify2 } from "util";
|
|
306
|
+
var execFileAsync2 = promisify2(execFile2);
|
|
307
|
+
var SETUP_FILE = ".agent-chat/worktree.json";
|
|
308
|
+
var SETUP_LOG = "agent-chat-setup.log";
|
|
309
|
+
var DEFAULT_SETUP_TIMEOUT_MS = 3e5;
|
|
310
|
+
var MAX_SETUP_TIMEOUT_MS = 18e5;
|
|
311
|
+
var TERM_GRACE_MS2 = 2e3;
|
|
312
|
+
var OUTPUT_TAIL_CHARS = 4e3;
|
|
313
|
+
var SETUP_ENV_KEYS = /* @__PURE__ */ new Set([
|
|
314
|
+
"PATH",
|
|
315
|
+
"HOME",
|
|
316
|
+
"USER",
|
|
317
|
+
"LOGNAME",
|
|
318
|
+
"SHELL",
|
|
319
|
+
"TMPDIR",
|
|
320
|
+
"LANG",
|
|
321
|
+
"PNPM_HOME",
|
|
322
|
+
"NODE_EXTRA_CA_CERTS",
|
|
323
|
+
"XDG_CACHE_HOME",
|
|
324
|
+
"XDG_CONFIG_HOME",
|
|
325
|
+
"XDG_DATA_HOME",
|
|
326
|
+
"HTTP_PROXY",
|
|
327
|
+
"HTTPS_PROXY",
|
|
328
|
+
"NO_PROXY",
|
|
329
|
+
"http_proxy",
|
|
330
|
+
"https_proxy",
|
|
331
|
+
"no_proxy"
|
|
332
|
+
]);
|
|
333
|
+
var SETUP_ENV_PREFIXES = ["LC_", "npm_config_", "NPM_CONFIG_", "COREPACK_"];
|
|
334
|
+
function setupEnv(env = process.env) {
|
|
335
|
+
const out = { GIT_TERMINAL_PROMPT: "0" };
|
|
336
|
+
for (const [key, value] of Object.entries(env)) {
|
|
337
|
+
if (SETUP_ENV_KEYS.has(key) || SETUP_ENV_PREFIXES.some((prefix) => key.startsWith(prefix))) out[key] = value;
|
|
338
|
+
}
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
var isCommand = (value) => Array.isArray(value) && value.length > 0 && value.every((part) => typeof part === "string" && part !== "");
|
|
342
|
+
var isTimeout = (value) => typeof value === "number" && Number.isInteger(value) && value > 0 && value <= MAX_SETUP_TIMEOUT_MS;
|
|
343
|
+
function parseSetupStep(text) {
|
|
344
|
+
if (text.trim() === "") return `${SETUP_FILE} is empty`;
|
|
345
|
+
let parsed;
|
|
346
|
+
try {
|
|
347
|
+
parsed = JSON.parse(text);
|
|
348
|
+
} catch (err) {
|
|
349
|
+
return `${SETUP_FILE} is not valid JSON (${err.message})`;
|
|
350
|
+
}
|
|
351
|
+
const setup = parsed?.setup;
|
|
352
|
+
if (setup === void 0) return null;
|
|
353
|
+
if (typeof setup !== "object" || setup === null) return `${SETUP_FILE} setup must be an object`;
|
|
354
|
+
const { command, timeoutMs } = setup;
|
|
355
|
+
if (!isCommand(command)) return `${SETUP_FILE} setup.command must be a non-empty array of strings`;
|
|
356
|
+
if (timeoutMs !== void 0 && !isTimeout(timeoutMs))
|
|
357
|
+
return `${SETUP_FILE} setup.timeoutMs must be a positive integer of at most ${MAX_SETUP_TIMEOUT_MS}`;
|
|
358
|
+
return { command, timeoutMs: timeoutMs ?? DEFAULT_SETUP_TIMEOUT_MS };
|
|
359
|
+
}
|
|
360
|
+
async function gitOutput(args, cwd) {
|
|
361
|
+
try {
|
|
362
|
+
const { stdout } = await execFileAsync2("git", [...args], { cwd, encoding: "utf8", env: gitChildEnv() });
|
|
363
|
+
return stdout;
|
|
364
|
+
} catch {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
var declarationAt = (gitRoot, sha) => gitOutput(["cat-file", "blob", `${sha}:${SETUP_FILE}`], gitRoot);
|
|
369
|
+
var runSetupCommand = (command, cwd, timeoutMs) => new Promise((resolve) => {
|
|
370
|
+
const [file, ...args] = command;
|
|
371
|
+
const child = spawn2(file ?? "", args, {
|
|
372
|
+
cwd,
|
|
373
|
+
env: setupEnv(),
|
|
374
|
+
detached: true,
|
|
375
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
376
|
+
});
|
|
377
|
+
let output = "";
|
|
378
|
+
let timedOut = false;
|
|
379
|
+
const collect = (chunk) => {
|
|
380
|
+
output = (output + chunk.toString()).slice(-OUTPUT_TAIL_CHARS);
|
|
381
|
+
};
|
|
382
|
+
child.stdout.on("data", collect);
|
|
383
|
+
child.stderr.on("data", collect);
|
|
384
|
+
const term = setTimeout(() => {
|
|
385
|
+
timedOut = true;
|
|
386
|
+
if (child.pid !== void 0) signalGroup(child.pid, "SIGTERM");
|
|
387
|
+
}, timeoutMs);
|
|
388
|
+
const kill = setTimeout(
|
|
389
|
+
() => child.pid !== void 0 && signalGroup(child.pid, "SIGKILL"),
|
|
390
|
+
timeoutMs + TERM_GRACE_MS2
|
|
391
|
+
);
|
|
392
|
+
const settle = (exitCode, extra = "") => {
|
|
393
|
+
clearTimeout(term);
|
|
394
|
+
clearTimeout(kill);
|
|
395
|
+
resolve({ exitCode, timedOut, output: output + extra });
|
|
396
|
+
};
|
|
397
|
+
child.on("error", (err) => settle(null, err.message));
|
|
398
|
+
child.on("close", (code) => settle(code));
|
|
399
|
+
});
|
|
400
|
+
async function writeSetupLog(worktree, output) {
|
|
401
|
+
const gitDir = await gitOutput(["rev-parse", "--absolute-git-dir"], worktree);
|
|
402
|
+
if (gitDir === null) return null;
|
|
403
|
+
const file = path3.join(gitDir.trim(), SETUP_LOG);
|
|
404
|
+
try {
|
|
405
|
+
writeFileSync(file, output, { mode: 384 });
|
|
406
|
+
return file;
|
|
407
|
+
} catch {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
function describeFailure(step, result, log) {
|
|
412
|
+
const name = step.command.join(" ");
|
|
413
|
+
const how = result.timedOut ? `timed out after ${step.timeoutMs}ms and was killed` : `exited with ${result.exitCode === null ? "no exit code" : `code ${result.exitCode}`}`;
|
|
414
|
+
const where = log === null ? "" : `; its output is in ${log}`;
|
|
415
|
+
return `worktree setup step \`${name}\` ${how}${where}; the worktree may be missing its dependencies`;
|
|
416
|
+
}
|
|
417
|
+
async function runWorktreeSetup(target, run = runSetupCommand) {
|
|
418
|
+
const declared = await declarationAt(target.gitRoot, target.baseSha);
|
|
419
|
+
if (declared === null) return [];
|
|
420
|
+
if (!target.fetched)
|
|
421
|
+
return [`worktree setup skipped: ${SETUP_FILE} is trusted only from origin's fetched default branch`];
|
|
422
|
+
const step = parseSetupStep(declared);
|
|
423
|
+
if (step === null) return [];
|
|
424
|
+
if (typeof step === "string") return [`worktree setup skipped: ${step}`];
|
|
425
|
+
const result = await run(step.command, target.worktree, step.timeoutMs).catch(
|
|
426
|
+
(err) => ({
|
|
427
|
+
exitCode: null,
|
|
428
|
+
timedOut: false,
|
|
429
|
+
output: String(err)
|
|
430
|
+
})
|
|
431
|
+
);
|
|
432
|
+
if (result.exitCode === 0 && !result.timedOut) return [];
|
|
433
|
+
return [describeFailure(step, result, await writeSetupLog(target.worktree, result.output))];
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// src/reattach.ts
|
|
437
|
+
var setupTarget = (gitRoot, worktree, base) => ({
|
|
438
|
+
gitRoot,
|
|
439
|
+
worktree,
|
|
440
|
+
baseSha: base.sha,
|
|
441
|
+
fetched: base.warning === void 0
|
|
442
|
+
});
|
|
443
|
+
async function originHasBranch(gitRoot, branch, timeoutMs) {
|
|
444
|
+
try {
|
|
445
|
+
await gitText(["ls-remote", "--exit-code", "--heads", "origin", `refs/heads/${branch}`], gitRoot, timeoutMs);
|
|
446
|
+
return true;
|
|
447
|
+
} catch (err) {
|
|
448
|
+
if (err.code === 2) return false;
|
|
449
|
+
throw new OriginUnreachableError(branch, firstLine(err));
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
var firstLine = (err) => String(err.stderr || err.message).trim().split("\n")[0] ?? "unknown error";
|
|
453
|
+
async function branchSource(gitRoot, branch, timeoutMs) {
|
|
454
|
+
if (await gitOrNull(["rev-parse", "--verify", branch], gitRoot) !== null) return "local";
|
|
455
|
+
if (await gitOrNull(["remote", "get-url", "origin"], gitRoot) === null) return "fresh";
|
|
456
|
+
if (!await originHasBranch(gitRoot, branch, timeoutMs)) return "fresh";
|
|
457
|
+
if (await fetchTip(gitRoot, branch, timeoutMs) === null)
|
|
458
|
+
throw new OriginUnreachableError(branch, "origin lists it but fetching it failed");
|
|
459
|
+
return "origin";
|
|
460
|
+
}
|
|
461
|
+
function addArgs(record, source, base) {
|
|
462
|
+
const { worktree, branch } = record;
|
|
463
|
+
if (source === "local") return ["worktree", "add", worktree, branch];
|
|
464
|
+
return ["worktree", "add", "-b", branch, worktree, source === "origin" ? `origin/${branch}` : base];
|
|
465
|
+
}
|
|
466
|
+
async function addForSource(record, source, base, run) {
|
|
467
|
+
try {
|
|
468
|
+
await addWorktree(record.gitRoot, addArgs(record, source, base), run);
|
|
469
|
+
} catch (err) {
|
|
470
|
+
const holder = await checkoutOf(record.gitRoot, record.branch);
|
|
471
|
+
if (holder !== null || existsSync3(record.worktree))
|
|
472
|
+
throw new WorktreeInUseError(record.branch, holder ?? record.worktree);
|
|
473
|
+
throw err;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
function reattachWarnings(record, source, base) {
|
|
477
|
+
const inherited = base.warning === void 0 ? [] : [base.warning];
|
|
478
|
+
if (source !== "fresh") return inherited;
|
|
479
|
+
const fresh = `branch ${record.branch} no longer exists locally or on origin (a squash merge deletes it), so ${record.worktree} was re-created on a fresh ${record.branch} from ${base.ref} ${base.sha}; commits the conversation mentions may be merged or gone`;
|
|
480
|
+
return [fresh, ...inherited];
|
|
481
|
+
}
|
|
482
|
+
function checkRecordPlacement(record, basePath) {
|
|
483
|
+
const base = path4.resolve(record.gitRoot, basePath) + path4.sep;
|
|
484
|
+
if (!path4.resolve(record.worktree).startsWith(base))
|
|
485
|
+
throw new Error(`recorded worktree ${record.worktree} is not under ${base}; not re-created`);
|
|
486
|
+
}
|
|
487
|
+
async function checkReattachable(record, opts) {
|
|
488
|
+
const { gitRoot, worktree, branch } = record;
|
|
489
|
+
checkRecordPlacement(record, basePathOf(opts));
|
|
490
|
+
if (existsSync3(worktree)) throw new WorktreeInUseError(branch, worktree);
|
|
491
|
+
await pruneStaleWorktrees(gitRoot);
|
|
492
|
+
const allocated = await allocatedPaths(gitRoot, basePathOf(opts));
|
|
493
|
+
const budget = budgetOf(opts);
|
|
494
|
+
if (allocated.length >= budget) throw new WorktreeBudgetExhaustedError(allocated.length, budget);
|
|
495
|
+
const holder = await checkoutOf(gitRoot, branch);
|
|
496
|
+
if (holder !== null) throw new WorktreeInUseError(branch, holder);
|
|
497
|
+
}
|
|
498
|
+
async function reattachWorktree(record, opts = {}, requireBranch = false) {
|
|
499
|
+
const { gitRoot, worktree, branch } = record;
|
|
500
|
+
await checkReattachable(record, opts);
|
|
501
|
+
const timeoutMs = opts.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
502
|
+
const base = await resolveBranchBase(gitRoot, timeoutMs);
|
|
503
|
+
const source = await branchSource(gitRoot, branch, timeoutMs);
|
|
504
|
+
if (source === "fresh" && requireBranch)
|
|
505
|
+
throw new Error(
|
|
506
|
+
`branch ${branch} no longer exists locally or on origin, so the adopted worktree ${worktree} was not re-created on a fresh one`
|
|
507
|
+
);
|
|
508
|
+
await addForSource(
|
|
509
|
+
record,
|
|
510
|
+
source,
|
|
511
|
+
base.sha,
|
|
512
|
+
boundedAdd(opts.runWorktreeAdd, opts.addTimeoutMs ?? WORKTREE_ADD_TIMEOUT_MS)
|
|
513
|
+
);
|
|
514
|
+
copyClaudeDir(gitRoot, worktree);
|
|
515
|
+
const warnings = [
|
|
516
|
+
...reattachWarnings(record, source, base),
|
|
517
|
+
...await runWorktreeSetup(setupTarget(gitRoot, worktree, base), opts.runSetup)
|
|
518
|
+
];
|
|
519
|
+
return {
|
|
520
|
+
cwd: worktree,
|
|
521
|
+
note: `Your worktree at ${worktree} had been removed and was re-created on branch ${branch}. Commit your work there; nothing outside it is yours to change.`,
|
|
522
|
+
ref: { branch, worktree, gitRoot, base: base.sha, base_ref: base.ref, reattached: source },
|
|
523
|
+
...warnings.length === 0 ? {} : { warnings }
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
async function readoptWorktree(record, opts) {
|
|
527
|
+
const allocation = await reattachWorktree(record, opts, true);
|
|
528
|
+
return {
|
|
529
|
+
...allocation,
|
|
530
|
+
note: `The worktree assigned to this task at ${record.worktree} had been removed and was re-created on branch ${record.branch}. You may be sharing it with other agents, so stay inside the paths you were given.`,
|
|
531
|
+
ref: { ...allocation.ref, assigned: "true" }
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
function recreateWorktree(record, opts = {}) {
|
|
535
|
+
return record.adopted === true ? readoptWorktree(record, opts) : reattachWorktree(record, opts);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// src/release-safety.ts
|
|
539
|
+
import { execFile as execFile3 } from "child_process";
|
|
540
|
+
import { existsSync as existsSync4 } from "fs";
|
|
541
|
+
import { promisify as promisify3 } from "util";
|
|
542
|
+
var execFileAsync3 = promisify3(execFile3);
|
|
543
|
+
async function landingTargets(gitRoot) {
|
|
544
|
+
const head = await gitOrNull(["symbolic-ref", "--quiet", "--short", "HEAD"], gitRoot);
|
|
545
|
+
const remoteHead = await gitOrNull(["rev-parse", "--verify", "--quiet", "origin/HEAD"], gitRoot);
|
|
546
|
+
return [head ?? "HEAD", ...remoteHead === null ? [] : ["origin/HEAD"]];
|
|
547
|
+
}
|
|
548
|
+
async function hasLanded(gitRoot, branch, target) {
|
|
549
|
+
if (await gitOrNull(["diff", "--quiet", target, branch], gitRoot) !== null) return true;
|
|
550
|
+
const base = await gitOrNull(["merge-base", target, branch], gitRoot);
|
|
551
|
+
if (base === null) return false;
|
|
552
|
+
const files = await gitOrNull(["diff", "--no-renames", "--name-only", base, branch], gitRoot);
|
|
553
|
+
if (files === null) return false;
|
|
554
|
+
const touched = files.split("\n").filter(Boolean);
|
|
555
|
+
if (touched.length === 0) return true;
|
|
556
|
+
return await gitOrNull(["diff", "--quiet", target, branch, "--", ...touched], gitRoot) !== null;
|
|
557
|
+
}
|
|
558
|
+
async function landedOn(gitRoot, branch, targets) {
|
|
559
|
+
for (const target of targets) {
|
|
560
|
+
if (await hasLanded(gitRoot, branch, target)) return target;
|
|
561
|
+
}
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
var GH_TIMEOUT_MS = 3e3;
|
|
565
|
+
async function ghPrState(gitRoot, branch) {
|
|
566
|
+
try {
|
|
567
|
+
const { stdout } = await execFileAsync3("gh", ["pr", "view", branch, "--json", "state", "--jq", ".state"], {
|
|
568
|
+
cwd: gitRoot,
|
|
569
|
+
encoding: "utf8",
|
|
570
|
+
timeout: GH_TIMEOUT_MS
|
|
571
|
+
});
|
|
572
|
+
return stdout.trim() || null;
|
|
573
|
+
} catch {
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
function dirtyCheck(dirty) {
|
|
578
|
+
return { name: "dirty", ok: !dirty, detail: dirty ? "uncommitted changes in the worktree" : "worktree clean" };
|
|
579
|
+
}
|
|
580
|
+
function pushedCheck(ahead, compareTo) {
|
|
581
|
+
if (ahead === null) return { name: "pushed", ok: false, detail: `could not count commits against ${compareTo}` };
|
|
582
|
+
return { name: "pushed", ok: ahead === 0, detail: `${ahead} commit(s) not on ${compareTo}` };
|
|
583
|
+
}
|
|
584
|
+
function landedCheck(target, targets) {
|
|
585
|
+
return target === null ? { name: "landed", ok: false, detail: `work not landed on ${targets.join(" or ")}` } : { name: "landed", ok: true, detail: `work landed on ${target}` };
|
|
586
|
+
}
|
|
587
|
+
async function commitsAhead(gitRoot, branch, baseRef) {
|
|
588
|
+
const remote = await gitOrNull(["rev-parse", "--verify", `origin/${branch}`], gitRoot);
|
|
589
|
+
const compareTo = remote === null ? baseRef : `origin/${branch}`;
|
|
590
|
+
const count = await gitOrNull(["rev-list", "--count", `${compareTo}..${branch}`], gitRoot);
|
|
591
|
+
return [count === null ? null : Number.parseInt(count, 10), compareTo];
|
|
592
|
+
}
|
|
593
|
+
async function inspectForRelease(gitRoot, worktreePath, branch, baseRef) {
|
|
594
|
+
const status = existsSync4(worktreePath) ? await gitOrNull(["status", "--porcelain"], worktreePath) : null;
|
|
595
|
+
const dirty = (status ?? "").length > 0;
|
|
596
|
+
const checked = [dirtyCheck(dirty)];
|
|
597
|
+
if (await gitOrNull(["rev-parse", "--verify", branch], gitRoot) === null)
|
|
598
|
+
return { dirty, unmerged: false, landed: false, ahead: 0, checked };
|
|
599
|
+
const [ahead, compareTo] = await commitsAhead(gitRoot, branch, baseRef);
|
|
600
|
+
checked.push(pushedCheck(ahead, compareTo));
|
|
601
|
+
if (ahead === 0) return { dirty, unmerged: false, landed: false, ahead, checked };
|
|
602
|
+
const targets = await landingTargets(gitRoot);
|
|
603
|
+
const target = await landedOn(gitRoot, branch, targets);
|
|
604
|
+
checked.push(landedCheck(target, targets));
|
|
605
|
+
if (target === null) {
|
|
606
|
+
const state = await ghPrState(gitRoot, branch);
|
|
607
|
+
if (state !== null) checked.push({ name: "gh", ok: true, detail: `gh reports the PR as ${state}` });
|
|
608
|
+
}
|
|
609
|
+
return { dirty, unmerged: target === null, landed: target !== null, ahead, checked };
|
|
610
|
+
}
|
|
611
|
+
function describeRefusal(safety) {
|
|
612
|
+
const failed = safety.checked.filter((c) => !c.ok && !(c.name === "pushed" && safety.landed));
|
|
613
|
+
const advisory = safety.checked.filter((c) => c.name === "gh").map((c) => c.detail);
|
|
614
|
+
return [...failed.map((c) => c.detail), ...advisory].join("; ");
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// src/allocator.ts
|
|
618
|
+
async function resetTo(gitRoot, branch, worktreePath, opts) {
|
|
619
|
+
await gitOrNull(["worktree", "remove", "--force", worktreePath], gitRoot);
|
|
620
|
+
rmSync2(worktreePath, { recursive: true, force: true });
|
|
621
|
+
await gitOrNull(["branch", "-D", branch], gitRoot);
|
|
622
|
+
await addWorktree(gitRoot, ["worktree", "add", "-b", branch, worktreePath, opts.base], opts.run);
|
|
623
|
+
}
|
|
624
|
+
async function attachWorktree(gitRoot, branch, worktreePath, opts) {
|
|
625
|
+
if (await gitOrNull(["rev-parse", "--verify", branch], gitRoot) === null) {
|
|
626
|
+
await addWorktree(gitRoot, ["worktree", "add", "-b", branch, worktreePath, opts.base], opts.run);
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
if (opts.force) {
|
|
630
|
+
await resetTo(gitRoot, branch, worktreePath, opts);
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
633
|
+
const holder = await checkoutOf(gitRoot, branch);
|
|
634
|
+
if (holder !== null || existsSync5(worktreePath)) throw new WorktreeInUseError(branch, holder ?? worktreePath);
|
|
635
|
+
const safety = await inspectForRelease(gitRoot, worktreePath, branch, opts.base);
|
|
636
|
+
if (safety.unmerged) await addWorktree(gitRoot, ["worktree", "add", worktreePath, branch], opts.run);
|
|
637
|
+
else await resetTo(gitRoot, branch, worktreePath, opts);
|
|
638
|
+
return safety.unmerged;
|
|
639
|
+
}
|
|
640
|
+
async function refuseRelease(exitedAt, ref) {
|
|
641
|
+
if (exitedAt !== void 0 && Date.now() - exitedAt < RECLAIM_GRACE_MS)
|
|
642
|
+
return `agent exited less than ${RECLAIM_GRACE_MS / 1e3}s ago; inside the reclaim grace window`;
|
|
643
|
+
const safety = await inspectForRelease(ref.gitRoot, ref.worktree, ref.branch, ref.base);
|
|
644
|
+
return safety.dirty || safety.unmerged ? describeRefusal(safety) : null;
|
|
645
|
+
}
|
|
646
|
+
function recreateAssigned(assigned, record, opts) {
|
|
647
|
+
if (record === void 0 || path5.resolve(record.worktree) !== assigned)
|
|
648
|
+
throw new Error(
|
|
649
|
+
`assigned worktree ${assigned} does not exist, and there is no record of allocating it to re-create it from. Not spawned`
|
|
650
|
+
);
|
|
651
|
+
return readoptWorktree(record, opts);
|
|
652
|
+
}
|
|
653
|
+
async function adoptAssigned(req, gitRoot, opts) {
|
|
654
|
+
const assigned = path5.resolve(req.assignedWorktree);
|
|
655
|
+
if (!existsSync5(assigned)) return recreateAssigned(assigned, req.assignedRecord, opts);
|
|
656
|
+
const branch = await gitOrNull(["rev-parse", "--abbrev-ref", "HEAD"], assigned) ?? "HEAD";
|
|
657
|
+
return {
|
|
658
|
+
cwd: assigned,
|
|
659
|
+
note: `You are in a worktree assigned to this task at ${assigned}, on branch ${branch}. You may be sharing it with other agents, so stay inside the paths you were given.`,
|
|
660
|
+
ref: { branch, worktree: assigned, gitRoot, assigned: "true" }
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
async function check(req, opts) {
|
|
664
|
+
const gitRoot = await findGitRoot(req.baseCwd);
|
|
665
|
+
if (gitRoot === null)
|
|
666
|
+
return { refusals: [`${req.baseCwd} is not a git repository; worktree isolation needs one`], warnings: [] };
|
|
667
|
+
await pruneStaleWorktrees(gitRoot);
|
|
668
|
+
const allocated = await allocatedPaths(gitRoot, basePathOf(opts));
|
|
669
|
+
const budget = budgetOf(opts);
|
|
670
|
+
const refusals = allocated.length >= budget ? [`worktree budget exhausted: ${allocated.length}/${budget} allocated under ${basePathOf(opts)}`] : [];
|
|
671
|
+
const branch = branchFor(req.agentName, opts);
|
|
672
|
+
const exists = await gitOrNull(["rev-parse", "--verify", branch], gitRoot) !== null;
|
|
673
|
+
const warnings = exists ? [`branch ${branch} already exists; it will be adopted if it holds commits, reset if it does not`] : [];
|
|
674
|
+
return { refusals, warnings };
|
|
675
|
+
}
|
|
676
|
+
async function allocate(req, opts) {
|
|
677
|
+
const gitRoot = await findGitRoot(req.baseCwd);
|
|
678
|
+
if (gitRoot === null) throw new Error(`${req.baseCwd} is not a git repository`);
|
|
679
|
+
if (req.assignedWorktree !== void 0) return adoptAssigned(req, gitRoot, opts);
|
|
680
|
+
await pruneStaleWorktrees(gitRoot);
|
|
681
|
+
const allocated = await allocatedPaths(gitRoot, basePathOf(opts));
|
|
682
|
+
const budget = budgetOf(opts);
|
|
683
|
+
if (allocated.length >= budget) throw new WorktreeBudgetExhaustedError(allocated.length, budget);
|
|
684
|
+
const branch = branchFor(req.agentName, opts);
|
|
685
|
+
const worktreePath = path5.resolve(gitRoot, basePathOf(opts), slug(req.agentName));
|
|
686
|
+
const base = await resolveBranchBase(gitRoot, opts.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS);
|
|
687
|
+
const reused = await attachWorktree(gitRoot, branch, worktreePath, {
|
|
688
|
+
base: base.sha,
|
|
689
|
+
force: req.forceReset === true,
|
|
690
|
+
run: boundedAdd(opts.runWorktreeAdd, opts.addTimeoutMs ?? WORKTREE_ADD_TIMEOUT_MS)
|
|
691
|
+
});
|
|
692
|
+
copyClaudeDir(gitRoot, worktreePath);
|
|
693
|
+
const warnings = [
|
|
694
|
+
...base.warning === void 0 ? [] : [base.warning],
|
|
695
|
+
...await runWorktreeSetup(setupTarget(gitRoot, worktreePath, base), opts.runSetup)
|
|
696
|
+
];
|
|
697
|
+
const carried = reused ? " It already carries commits from an earlier run under this name." : "";
|
|
698
|
+
return {
|
|
699
|
+
cwd: worktreePath,
|
|
700
|
+
note: `You are on branch ${branch} in an isolated worktree at ${worktreePath}.${carried} Commit your work there; nothing outside it is yours to change.`,
|
|
701
|
+
ref: {
|
|
702
|
+
branch,
|
|
703
|
+
worktree: worktreePath,
|
|
704
|
+
gitRoot,
|
|
705
|
+
base: base.sha,
|
|
706
|
+
base_ref: base.ref,
|
|
707
|
+
...reused ? { reused: "true" } : {}
|
|
708
|
+
},
|
|
709
|
+
...warnings.length === 0 ? {} : { warnings }
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
async function release(req, alloc, opts = {}) {
|
|
713
|
+
const ref = alloc.ref;
|
|
714
|
+
if (!ref?.branch || !ref.worktree || !ref.gitRoot)
|
|
715
|
+
return { released: false, refusal: "allocation carries no worktree reference" };
|
|
716
|
+
const { branch, worktree, gitRoot } = ref;
|
|
717
|
+
if (ref.assigned === "true") return { released: true };
|
|
718
|
+
if (!opts.force) {
|
|
719
|
+
const refusal = await refuseRelease(req.exitedAt, { gitRoot, worktree, branch, base: ref.base ?? "HEAD" });
|
|
720
|
+
if (refusal !== null) return { released: false, refusal };
|
|
721
|
+
}
|
|
722
|
+
await removeWorktree(gitRoot, worktree, branch);
|
|
723
|
+
return { released: true };
|
|
724
|
+
}
|
|
725
|
+
function createWorktreeAllocator(opts = {}) {
|
|
726
|
+
return {
|
|
727
|
+
check: (req) => check(req, opts),
|
|
728
|
+
allocate: (req) => allocate(req, opts),
|
|
729
|
+
release
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// src/park.ts
|
|
734
|
+
async function unpushed(target) {
|
|
735
|
+
const { worktree, branch } = target;
|
|
736
|
+
if (await gitOrNull(["remote", "get-url", "origin"], worktree) === null) return void 0;
|
|
737
|
+
if (await gitOrNull(["rev-parse", "--verify", "--quiet", `origin/${branch}`], worktree) === null)
|
|
738
|
+
return `${branch} is not on origin; push it first`;
|
|
739
|
+
const ahead = await gitOrNull(["rev-list", `origin/${branch}..HEAD`], worktree);
|
|
740
|
+
if (ahead === null) return `could not compare ${worktree} with origin/${branch}`;
|
|
741
|
+
const count = ahead.split("\n").filter(Boolean).length;
|
|
742
|
+
return count === 0 ? void 0 : `${count} commit(s) in ${worktree} are not on origin/${branch}; push first`;
|
|
743
|
+
}
|
|
744
|
+
async function offBranch(target) {
|
|
745
|
+
const head = await gitOrNull(["symbolic-ref", "--quiet", "--short", "HEAD"], target.worktree);
|
|
746
|
+
if (head === target.branch) return void 0;
|
|
747
|
+
const where = head === null ? "detached" : `on ${head}`;
|
|
748
|
+
return `HEAD in ${target.worktree} is ${where}, not on ${target.branch}; check out ${target.branch} first`;
|
|
749
|
+
}
|
|
750
|
+
var REGENERABLE_DIRS = ["node_modules", "dist", "coverage", ".turbo"];
|
|
751
|
+
var isRegenerable = (entry) => {
|
|
752
|
+
const segments = entry.split("/").filter(Boolean);
|
|
753
|
+
return segments[0] === ".claude" || segments.some((segment) => REGENERABLE_DIRS.includes(segment));
|
|
754
|
+
};
|
|
755
|
+
async function unsaved(worktree) {
|
|
756
|
+
const status = await gitOrNull(["status", "--porcelain", "-uall"], worktree);
|
|
757
|
+
if (status === null) return `could not read git status in ${worktree}`;
|
|
758
|
+
if (status !== "") return `uncommitted or untracked changes in ${worktree}`;
|
|
759
|
+
const ignored = await gitOrNull(["ls-files", "--others", "--ignored", "--exclude-standard", "--directory"], worktree);
|
|
760
|
+
if (ignored === null) return `could not list ignored files in ${worktree}`;
|
|
761
|
+
const kept = ignored.split("\n").filter((entry) => entry !== "" && !isRegenerable(entry));
|
|
762
|
+
if (kept.length === 0) return void 0;
|
|
763
|
+
return `ignored files in ${worktree} would be deleted (${kept.slice(0, 3).join(", ")}); park removes only ignored ${REGENERABLE_DIRS.join(", ")} and a top-level .claude`;
|
|
764
|
+
}
|
|
765
|
+
async function parkWorktree(target, recheck = () => void 0) {
|
|
766
|
+
const refusal = await offBranch(target) ?? await unsaved(target.worktree) ?? await unpushed(target);
|
|
767
|
+
if (refusal) return { ok: false, reason: refusal };
|
|
768
|
+
const head = await gitText(["rev-parse", "HEAD"], target.worktree);
|
|
769
|
+
const late = recheck();
|
|
770
|
+
if (late) return { ok: false, reason: late };
|
|
771
|
+
try {
|
|
772
|
+
await gitText(["worktree", "remove", target.worktree], target.gitRoot);
|
|
773
|
+
} catch (err) {
|
|
774
|
+
const detail = String(err.stderr || err.message).trim();
|
|
775
|
+
return { ok: false, reason: `git would not remove ${target.worktree}: ${detail.split("\n")[0]}` };
|
|
776
|
+
}
|
|
777
|
+
return { ok: true, head };
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// src/sweep.ts
|
|
781
|
+
import { execFileSync } from "child_process";
|
|
782
|
+
import path6 from "path";
|
|
783
|
+
var porcelain = async (gitRoot) => execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: gitRoot, encoding: "utf8", env: gitChildEnv() });
|
|
784
|
+
var parseBlock = (block) => ({
|
|
785
|
+
worktree: block.match(/^worktree (.+)$/m)?.[1]?.trim(),
|
|
786
|
+
branch: block.match(/^branch refs\/heads\/(.+)$/m)?.[1]?.trim(),
|
|
787
|
+
locked: /^locked/m.test(block)
|
|
788
|
+
});
|
|
789
|
+
async function agentWorktreesIn(gitRoot, list = porcelain, branchPrefix = branchPrefixOf({})) {
|
|
790
|
+
const out = await list(gitRoot).catch(() => "");
|
|
791
|
+
return out.split("\n\n").map(parseBlock).filter((e) => e.worktree !== void 0 && e.branch !== void 0).filter((e) => !e.locked && e.branch.startsWith(branchPrefix)).map(({ worktree, branch }) => ({ worktree: path6.resolve(worktree), branch }));
|
|
792
|
+
}
|
|
793
|
+
var isLive = (state) => state === "live" || state === "spawning";
|
|
794
|
+
function classifyByOwner(owner, now) {
|
|
795
|
+
if (owner === void 0) return void 0;
|
|
796
|
+
if (isLive(owner.state)) return { status: "held", detail: `${owner.name} is ${owner.state}` };
|
|
797
|
+
const since = now - (owner.exitedAt ?? owner.lastEventAt);
|
|
798
|
+
if (since >= RECLAIM_GRACE_MS) return void 0;
|
|
799
|
+
return {
|
|
800
|
+
status: "in-grace",
|
|
801
|
+
detail: `${owner.name} stopped ${Math.round(since / 1e3)}s ago; reclaimable after ${RECLAIM_GRACE_MS / 1e3}s`
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
function idleDetail(owner) {
|
|
805
|
+
return owner === void 0 ? "no agent in the log claims this branch, and it holds nothing" : `${owner.name} is ${owner.state}, and it holds nothing`;
|
|
806
|
+
}
|
|
807
|
+
async function classify(found, owner, base, now) {
|
|
808
|
+
const agent = owner && {
|
|
809
|
+
agentId: owner.agentId,
|
|
810
|
+
name: owner.name,
|
|
811
|
+
state: owner.state,
|
|
812
|
+
lastEventAt: owner.lastEventAt
|
|
813
|
+
};
|
|
814
|
+
const withAgent = { ...found, ...agent ? { agent } : {} };
|
|
815
|
+
const byOwner = classifyByOwner(owner, now);
|
|
816
|
+
if (byOwner) return { ...withAgent, ...byOwner };
|
|
817
|
+
const safety = await inspectForRelease(found.gitRoot, found.worktree, found.branch, base);
|
|
818
|
+
if (!safety.dirty && !safety.unmerged) return { ...withAgent, status: "reclaimable", detail: idleDetail(owner) };
|
|
819
|
+
const detail = [safety.dirty && "uncommitted changes", safety.unmerged && "commits that exist nowhere else"].filter(Boolean).join(" and ");
|
|
820
|
+
return { ...withAgent, status: "holds-work", detail };
|
|
821
|
+
}
|
|
822
|
+
async function sweepWorktrees(options = {}) {
|
|
823
|
+
const now = (options.now ?? Date.now)();
|
|
824
|
+
const held = new Map((options.held ?? []).map((h) => [h.branch, h]));
|
|
825
|
+
const roots = /* @__PURE__ */ new Set([...options.roots ?? [], ...[...held.values()].map((h) => h.gitRoot)]);
|
|
826
|
+
const prefix = options.branchPrefix ?? branchPrefixOf({});
|
|
827
|
+
const swept = [];
|
|
828
|
+
for (const gitRoot of roots) {
|
|
829
|
+
for (const { worktree, branch } of await agentWorktreesIn(gitRoot, options.list ?? porcelain, prefix)) {
|
|
830
|
+
const found = { gitRoot, worktree, branch };
|
|
831
|
+
swept.push(await classify(found, options.ownerOf?.(found), held.get(branch)?.base ?? "HEAD", now));
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
return swept;
|
|
835
|
+
}
|
|
836
|
+
async function reclaimWorktree(entry, options = {}) {
|
|
837
|
+
if (entry.status !== "reclaimable" && options.force !== true)
|
|
838
|
+
return { ok: false, reason: `${entry.branch} is ${entry.status}: ${entry.detail}` };
|
|
839
|
+
await removeWorktree(entry.gitRoot, entry.worktree, entry.branch);
|
|
840
|
+
return { ok: true };
|
|
841
|
+
}
|
|
842
|
+
export {
|
|
843
|
+
BRANCH_PREFIX,
|
|
844
|
+
DEFAULT_BASE_PATH,
|
|
845
|
+
DEFAULT_FETCH_TIMEOUT_MS,
|
|
846
|
+
DEFAULT_SETUP_TIMEOUT_MS,
|
|
847
|
+
DEFAULT_STRIPPED_ENV_PREFIXES,
|
|
848
|
+
DEFAULT_WORKTREE_BUDGET,
|
|
849
|
+
OriginUnreachableError,
|
|
850
|
+
RECLAIM_GRACE_MS,
|
|
851
|
+
SETUP_FILE,
|
|
852
|
+
SETUP_LOG,
|
|
853
|
+
WORKTREE_ADD_TIMEOUT_MS,
|
|
854
|
+
WorktreeBudgetExhaustedError,
|
|
855
|
+
WorktreeInUseError,
|
|
856
|
+
agentWorktreesIn,
|
|
857
|
+
createWorktreeAllocator,
|
|
858
|
+
describeRefusal,
|
|
859
|
+
findGitRoot,
|
|
860
|
+
gitChildEnv,
|
|
861
|
+
inspectForRelease,
|
|
862
|
+
isLive,
|
|
863
|
+
observedPresence,
|
|
864
|
+
parkWorktree,
|
|
865
|
+
parseSetupStep,
|
|
866
|
+
pruneStaleWorktrees,
|
|
867
|
+
reattachWorktree,
|
|
868
|
+
reclaimWorktree,
|
|
869
|
+
recreateWorktree,
|
|
870
|
+
resolveBranchBase,
|
|
871
|
+
runGit,
|
|
872
|
+
runSetupCommand,
|
|
873
|
+
runWorktreeSetup,
|
|
874
|
+
setupEnv,
|
|
875
|
+
sweepWorktrees
|
|
876
|
+
};
|
|
877
|
+
//# sourceMappingURL=index.js.map
|