@agentproto/worktree 0.2.0 → 0.3.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/dist/index.mjs CHANGED
@@ -1,6 +1,597 @@
1
- export { connectDaemonAgentSessionHost, makeDaemonAgentSessionHost, worktreeAgentInputSchema, worktreeAgentWorkflow } from './chunk-P7FOHOQ3.mjs';
2
- import './chunk-PSFICTF7.mjs';
3
- export { execArgv, execGit, execShell, expandGlob, globToRegExp, worktreeProvider } from './chunk-BBAH3VLQ.mjs';
4
- export { cleanupWorktreeTool, provisionWorktreeTool, runGateTool } from './chunk-FRVPZTQN.mjs';
1
+ export { connectDaemonAgentSessionHost, makeDaemonAgentSessionHost, worktreeAgentInputSchema, worktreeAgentWorkflow } from './chunk-CBEWP5OT.mjs';
2
+ import './chunk-E3EM24BS.mjs';
3
+ import { computeProvenance, execArgv, classify, computeWorktreeStatus, listGitWorktrees, worktreeProvider } from './chunk-SEVCUXPY.mjs';
4
+ export { CONFIG_FILENAME, ConfigError, DEFAULT_PROXY_PORT, ENV_VARS, FileVerdictMemoStore, HookError, InMemoryVerdictMemoStore, ProxyTable, SESSIONS_FILE_PATH, ServiceSupervisor, VERDICT_MEMO_PATH, WorktreeNotRemovableError, allocatePort, classify, computeLiveness, computeProvenance, computeTreeState, computeWorktreeStatus, detectDefaultBranch, disposeSupervisor, ephemeralPort, execArgv, execGit, execShell, expandGlob, getScript, getSupervisor, globToRegExp, hookEnv, isPortFree, listGitWorktrees, listScripts, listServices, listWorktreeStatuses, loadConfigFromBase, normalizeHook, parseConfig, peerEnv, peerEnvKey, readSessionsRegistry, readWorktreeMarker, reconcileIntegration, repoLabel, resolveSupervisor, runSetup, runTeardown, serviceEnv, serviceEnvToken, serviceHostname, serviceUrl, sessionInWorktree, sharedProxyTable, slugify, stripPort, worktreeProvider, writeWorktreeMarker } from './chunk-SEVCUXPY.mjs';
5
+ import { cleanupWorktreeTool } from './chunk-CM3XGWTY.mjs';
6
+ export { cleanupWorktreeTool, listServicesTool, provisionWorktreeTool, runGateTool, runScriptTool, serviceStatusSchema, startServiceTool, stopServiceTool } from './chunk-CM3XGWTY.mjs';
7
+ import { mkdir, copyFile, writeFile, open } from 'fs/promises';
8
+ import { homedir } from 'os';
9
+ import { resolve, join, dirname, basename } from 'path';
10
+ import http from 'http';
11
+ import { connect } from 'net';
12
+ import { z } from 'zod';
13
+ import { runTool } from '@agentproto/driver';
14
+
15
+ /**
16
+ * @agentproto/worktree v0.1.0
17
+ * git-worktree provision / gate / cleanup TOOL contracts + builtin PROVIDER.
18
+ */
19
+ var SALVAGE_ROOT = () => resolve(homedir(), ".agentproto", "worktree-salvage");
20
+ async function listUntrackedFiles(worktreePath) {
21
+ const res = await execArgv("git", ["-C", worktreePath, "ls-files", "--others", "--exclude-standard"], worktreePath);
22
+ if (res.exitCode !== 0) {
23
+ throw new Error(`git ls-files --others failed in ${worktreePath} (exit ${res.exitCode}): ${res.stderr.trim()}`);
24
+ }
25
+ return res.stdout.split("\n").filter(Boolean);
26
+ }
27
+ async function diffHead(worktreePath) {
28
+ const res = await execArgv("git", ["-C", worktreePath, "diff", "HEAD"], worktreePath);
29
+ if (res.exitCode !== 0) {
30
+ throw new Error(`git diff HEAD failed in ${worktreePath} (exit ${res.exitCode}): ${res.stderr.trim()}`);
31
+ }
32
+ return res.stdout;
33
+ }
34
+ function sanitizeSegment(value) {
35
+ return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+/, "") || "worktree";
36
+ }
37
+ async function fsyncFile(path) {
38
+ const handle = await open(path, "r+");
39
+ try {
40
+ await handle.sync();
41
+ } finally {
42
+ await handle.close();
43
+ }
44
+ }
45
+ async function writeFileSynced(path, content) {
46
+ await mkdir(dirname(path), { recursive: true });
47
+ await writeFile(path, content, "utf8");
48
+ await fsyncFile(path);
49
+ }
50
+ async function salvageWorktree(input) {
51
+ const now = input.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
52
+ const createdAt = now();
53
+ const tipShort = input.tipSha.slice(0, 7);
54
+ const dateStamp = createdAt.slice(0, 10);
55
+ const dirName = `${sanitizeSegment(input.slug)}-${tipShort}-${dateStamp}`;
56
+ const dir = resolve(input.salvageRoot ?? SALVAGE_ROOT(), sanitizeSegment(input.repoName), dirName);
57
+ await mkdir(dir, { recursive: true });
58
+ const [patch, untrackedFiles, provenance] = await Promise.all([
59
+ diffHead(input.worktreePath),
60
+ listUntrackedFiles(input.worktreePath),
61
+ computeProvenance(input.worktreePath, { sessionsPath: input.sessionsPath })
62
+ ]);
63
+ await writeFileSynced(join(dir, "changes.patch"), patch);
64
+ for (const relPath of untrackedFiles) {
65
+ const dest = join(dir, "untracked", relPath);
66
+ await mkdir(dirname(dest), { recursive: true });
67
+ await copyFile(join(input.worktreePath, relPath), dest);
68
+ await fsyncFile(dest);
69
+ }
70
+ const manifest = {
71
+ repo: input.repoName,
72
+ branch: input.branch,
73
+ tipSha: input.tipSha,
74
+ createdAt,
75
+ untrackedFiles,
76
+ hasPatch: patch.length > 0,
77
+ provenance
78
+ };
79
+ await writeFileSynced(join(dir, "MANIFEST.json"), JSON.stringify(manifest, null, 2) + "\n");
80
+ return { dir, manifest };
81
+ }
82
+ var TARGET_HOST = "127.0.0.1";
83
+ function notFound(table, host) {
84
+ const known = table.entries().map(([h]) => h);
85
+ return `worktree-proxy: no service for host "${host ?? "(none)"}"
86
+ ` + (known.length ? `known hosts:
87
+ ${known.map((h) => ` ${h}`).join("\n")}
88
+ ` : "no services registered\n");
89
+ }
90
+ function createProxyServer(table) {
91
+ const server = http.createServer((req, res) => {
92
+ const targetPort = table.lookup(req.headers.host);
93
+ if (targetPort === void 0) {
94
+ res.writeHead(502, { "content-type": "text/plain" });
95
+ res.end(notFound(table, req.headers.host));
96
+ return;
97
+ }
98
+ const proxyReq = http.request(
99
+ {
100
+ host: TARGET_HOST,
101
+ port: targetPort,
102
+ method: req.method,
103
+ path: req.url,
104
+ headers: req.headers
105
+ },
106
+ (proxyRes) => {
107
+ res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
108
+ proxyRes.pipe(res);
109
+ }
110
+ );
111
+ proxyReq.on("error", (err) => {
112
+ if (!res.headersSent) res.writeHead(502, { "content-type": "text/plain" });
113
+ res.end(`worktree-proxy: upstream error: ${err.message}
114
+ `);
115
+ });
116
+ req.pipe(proxyReq);
117
+ });
118
+ server.on("upgrade", (req, clientSocket, head) => {
119
+ const targetPort = table.lookup(req.headers.host);
120
+ if (targetPort === void 0) {
121
+ clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
122
+ return;
123
+ }
124
+ const upstream = connect(targetPort, TARGET_HOST, () => {
125
+ const headerLines = [`${req.method} ${req.url} HTTP/1.1`];
126
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
127
+ headerLines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`);
128
+ }
129
+ upstream.write(headerLines.join("\r\n") + "\r\n\r\n");
130
+ if (head && head.length) upstream.write(head);
131
+ upstream.pipe(clientSocket);
132
+ clientSocket.pipe(upstream);
133
+ });
134
+ const teardown = () => {
135
+ clientSocket.destroy();
136
+ upstream.destroy();
137
+ };
138
+ upstream.on("error", teardown);
139
+ clientSocket.on("error", teardown);
140
+ upstream.on("close", () => clientSocket.destroy());
141
+ clientSocket.on("close", () => upstream.destroy());
142
+ });
143
+ return server;
144
+ }
145
+ function startProxy(table, port = 0) {
146
+ const server = createProxyServer(table);
147
+ const sockets = /* @__PURE__ */ new Set();
148
+ server.on("connection", (socket) => {
149
+ sockets.add(socket);
150
+ socket.once("close", () => sockets.delete(socket));
151
+ });
152
+ return new Promise((resolve2, reject) => {
153
+ server.once("error", reject);
154
+ server.listen(port, () => {
155
+ const address = server.address();
156
+ const boundPort = address && typeof address === "object" ? address.port : port;
157
+ resolve2({
158
+ port: boundPort,
159
+ server,
160
+ close: () => new Promise((res) => {
161
+ for (const socket of sockets) socket.destroy();
162
+ sockets.clear();
163
+ server.close(() => res());
164
+ })
165
+ });
166
+ });
167
+ });
168
+ }
169
+ var ForgeUnavailableError = class extends Error {
170
+ constructor(message, options) {
171
+ super(message, options);
172
+ this.name = "ForgeUnavailableError";
173
+ }
174
+ };
175
+ var UnreachableForgeClient = class {
176
+ constructor(reason) {
177
+ this.reason = reason;
178
+ }
179
+ reason;
180
+ async pullRequestsForBranch() {
181
+ throw new ForgeUnavailableError(this.reason);
182
+ }
183
+ async pullRequestsForCommit() {
184
+ throw new ForgeUnavailableError(this.reason);
185
+ }
186
+ async ensurePullHeadFetched() {
187
+ throw new ForgeUnavailableError(this.reason);
188
+ }
189
+ };
190
+ var ghPrListItemSchema = z.object({
191
+ number: z.number(),
192
+ state: z.string(),
193
+ headRefName: z.string(),
194
+ headRefOid: z.string(),
195
+ mergedAt: z.string().nullable()
196
+ });
197
+ var ghPrListSchema = z.array(ghPrListItemSchema);
198
+ function normalizeGhPrListItem(item) {
199
+ const state = item.state.toUpperCase();
200
+ return {
201
+ number: item.number,
202
+ state: state === "OPEN" ? "open" : "closed",
203
+ merged: state === "MERGED" || item.mergedAt !== null,
204
+ mergedAt: item.mergedAt,
205
+ headRefName: item.headRefName,
206
+ headRefOid: item.headRefOid
207
+ };
208
+ }
209
+ var ghApiCommitPullSchema = z.object({
210
+ number: z.number(),
211
+ state: z.string(),
212
+ merged_at: z.string().nullable(),
213
+ head: z.object({ ref: z.string(), sha: z.string() })
214
+ });
215
+ var ghApiCommitPullListSchema = z.array(ghApiCommitPullSchema);
216
+ function normalizeGhApiCommitPull(item) {
217
+ return {
218
+ number: item.number,
219
+ state: item.state === "open" ? "open" : "closed",
220
+ merged: item.merged_at !== null,
221
+ mergedAt: item.merged_at,
222
+ headRefName: item.head.ref,
223
+ headRefOid: item.head.sha
224
+ };
225
+ }
226
+ function isCommitNotFoundError(output) {
227
+ return /\(HTTP (404|422)\)/.test(output);
228
+ }
229
+ var GhCliForgeClient = class {
230
+ constructor(repoRoot) {
231
+ this.repoRoot = repoRoot;
232
+ }
233
+ repoRoot;
234
+ async runRaw(args) {
235
+ try {
236
+ return await execArgv("gh", args, this.repoRoot);
237
+ } catch (err) {
238
+ throw new ForgeUnavailableError(
239
+ `gh ${args.join(" ")} could not be run: ${err instanceof Error ? err.message : String(err)}`,
240
+ { cause: err }
241
+ );
242
+ }
243
+ }
244
+ async run(args) {
245
+ const result = await this.runRaw(args);
246
+ if (result.exitCode !== 0) {
247
+ throw new ForgeUnavailableError(
248
+ `gh ${args.join(" ")} failed (exit ${result.exitCode}): ${result.stderr.trim() || result.stdout.trim()}`
249
+ );
250
+ }
251
+ return result.stdout;
252
+ }
253
+ parseOutput(stdout, schema, args) {
254
+ let parsed;
255
+ try {
256
+ parsed = JSON.parse(stdout);
257
+ } catch (err) {
258
+ throw new ForgeUnavailableError(
259
+ `gh ${args.join(" ")} produced non-JSON output: ${err instanceof Error ? err.message : String(err)}`
260
+ );
261
+ }
262
+ const result = schema.safeParse(parsed);
263
+ if (!result.success) {
264
+ throw new ForgeUnavailableError(`gh ${args.join(" ")} output did not match the expected shape`);
265
+ }
266
+ return result.data;
267
+ }
268
+ async pullRequestsForBranch(branch) {
269
+ const args = [
270
+ "pr",
271
+ "list",
272
+ "--head",
273
+ branch,
274
+ "--state",
275
+ "all",
276
+ "--json",
277
+ "number,state,headRefName,headRefOid,mergedAt"
278
+ ];
279
+ const stdout = await this.run(args);
280
+ return this.parseOutput(stdout, ghPrListSchema, args).map(normalizeGhPrListItem);
281
+ }
282
+ async pullRequestsForCommit(sha) {
283
+ const args = ["api", `repos/{owner}/{repo}/commits/${sha}/pulls`];
284
+ const result = await this.runRaw(args);
285
+ if (result.exitCode !== 0) {
286
+ if (isCommitNotFoundError(result.stderr) || isCommitNotFoundError(result.stdout)) return [];
287
+ throw new ForgeUnavailableError(
288
+ `gh ${args.join(" ")} failed (exit ${result.exitCode}): ${result.stderr.trim() || result.stdout.trim()}`
289
+ );
290
+ }
291
+ return this.parseOutput(result.stdout, ghApiCommitPullListSchema, args).map(normalizeGhApiCommitPull);
292
+ }
293
+ async ensurePullHeadFetched(prNumber, oid) {
294
+ const check = await execArgv("git", ["-C", this.repoRoot, "cat-file", "-e", `${oid}^{commit}`], this.repoRoot);
295
+ if (check.exitCode === 0) return;
296
+ const fetch2 = await execArgv(
297
+ "git",
298
+ ["-C", this.repoRoot, "fetch", "origin", `refs/pull/${prNumber}/head`],
299
+ this.repoRoot
300
+ );
301
+ if (fetch2.exitCode !== 0) {
302
+ throw new ForgeUnavailableError(
303
+ `git fetch origin refs/pull/${prNumber}/head failed: ${fetch2.stderr.trim() || fetch2.stdout.trim()}`
304
+ );
305
+ }
306
+ }
307
+ };
308
+ var restPullRequestSchema = z.object({
309
+ number: z.number(),
310
+ state: z.string(),
311
+ merged_at: z.string().nullable(),
312
+ head: z.object({ ref: z.string(), sha: z.string() })
313
+ });
314
+ var restPullRequestListSchema = z.array(restPullRequestSchema);
315
+ function normalizeRestPr(item) {
316
+ return {
317
+ number: item.number,
318
+ state: item.state === "open" ? "open" : "closed",
319
+ merged: item.merged_at !== null,
320
+ mergedAt: item.merged_at,
321
+ headRefName: item.head.ref,
322
+ headRefOid: item.head.sha
323
+ };
324
+ }
325
+ function parseGithubOwnerRepo(remoteUrl) {
326
+ const ssh = remoteUrl.match(/^git@github\.com:([^/]+)\/(.+?)(\.git)?$/);
327
+ if (ssh?.[1] && ssh[2]) return { owner: ssh[1], repo: ssh[2] };
328
+ const https = remoteUrl.match(/^https:\/\/github\.com\/([^/]+)\/(.+?)(\.git)?\/?$/);
329
+ if (https?.[1] && https[2]) return { owner: https[1], repo: https[2] };
330
+ return null;
331
+ }
332
+ var RestForgeClient = class {
333
+ constructor(owner, repo, repoRoot, token, fetchFn = fetch) {
334
+ this.owner = owner;
335
+ this.repo = repo;
336
+ this.repoRoot = repoRoot;
337
+ this.token = token;
338
+ this.fetchFn = fetchFn;
339
+ }
340
+ owner;
341
+ repo;
342
+ repoRoot;
343
+ token;
344
+ fetchFn;
345
+ headers() {
346
+ return {
347
+ Authorization: `Bearer ${this.token}`,
348
+ Accept: "application/vnd.github+json",
349
+ "X-GitHub-Api-Version": "2022-11-28",
350
+ "User-Agent": "agentproto-worktree-status"
351
+ };
352
+ }
353
+ async get(path) {
354
+ try {
355
+ return await this.fetchFn(`https://api.github.com${path}`, { headers: this.headers() });
356
+ } catch (err) {
357
+ throw new ForgeUnavailableError(
358
+ `GitHub REST request to ${path} failed: ${err instanceof Error ? err.message : String(err)}`,
359
+ { cause: err }
360
+ );
361
+ }
362
+ }
363
+ async pullRequestList(res) {
364
+ const body = await res.json();
365
+ const result = restPullRequestListSchema.safeParse(body);
366
+ if (!result.success) {
367
+ throw new ForgeUnavailableError(`GitHub REST response did not match the expected PR list shape`);
368
+ }
369
+ return result.data.map(normalizeRestPr);
370
+ }
371
+ async pullRequestsForBranch(branch) {
372
+ const res = await this.get(
373
+ `/repos/${this.owner}/${this.repo}/pulls?head=${this.owner}:${encodeURIComponent(branch)}&state=all&per_page=100`
374
+ );
375
+ if (!res.ok) {
376
+ throw new ForgeUnavailableError(`GitHub REST pulls?head= failed: ${res.status} ${res.statusText}`);
377
+ }
378
+ return this.pullRequestList(res);
379
+ }
380
+ async pullRequestsForCommit(sha) {
381
+ const res = await this.get(`/repos/${this.owner}/${this.repo}/commits/${sha}/pulls?per_page=100`);
382
+ if (res.status === 404 || res.status === 422) return [];
383
+ if (!res.ok) {
384
+ throw new ForgeUnavailableError(`GitHub REST commits/:sha/pulls failed: ${res.status} ${res.statusText}`);
385
+ }
386
+ return this.pullRequestList(res);
387
+ }
388
+ async ensurePullHeadFetched(prNumber, oid) {
389
+ const check = await execArgv("git", ["-C", this.repoRoot, "cat-file", "-e", `${oid}^{commit}`], this.repoRoot);
390
+ if (check.exitCode === 0) return;
391
+ const fetch2 = await execArgv(
392
+ "git",
393
+ ["-C", this.repoRoot, "fetch", "origin", `refs/pull/${prNumber}/head`],
394
+ this.repoRoot
395
+ );
396
+ if (fetch2.exitCode !== 0) {
397
+ throw new ForgeUnavailableError(
398
+ `git fetch origin refs/pull/${prNumber}/head failed: ${fetch2.stderr.trim() || fetch2.stdout.trim()}`
399
+ );
400
+ }
401
+ }
402
+ };
403
+ async function ghIsUsable(repoRoot) {
404
+ try {
405
+ const res = await execArgv("gh", ["auth", "status"], repoRoot);
406
+ return res.exitCode === 0;
407
+ } catch {
408
+ return false;
409
+ }
410
+ }
411
+ async function createForgeClient(repoRoot) {
412
+ if (await ghIsUsable(repoRoot)) return new GhCliForgeClient(repoRoot);
413
+ const token = process.env.GITHUB_TOKEN;
414
+ if (token) {
415
+ const remote = await execArgv("git", ["-C", repoRoot, "remote", "get-url", "origin"], repoRoot);
416
+ const parsed = remote.exitCode === 0 ? parseGithubOwnerRepo(remote.stdout.trim()) : null;
417
+ if (parsed) return new RestForgeClient(parsed.owner, parsed.repo, repoRoot, token);
418
+ }
419
+ return new UnreachableForgeClient(
420
+ "no usable forge client: `gh` is not installed/authenticated and GITHUB_TOKEN is not set (or origin isn't a GitHub remote)"
421
+ );
422
+ }
423
+ var candidates = [worktreeProvider];
424
+ function classifyForGc(tree, integration, liveness, options = {}) {
425
+ if (options.includeDetached && integration.state === "detached") {
426
+ const idleOrUnreachable = liveness.state === "idle" || liveness.state === "daemon-unreachable";
427
+ if (tree.state === "clean" && idleOrUnreachable) return "reclaim";
428
+ }
429
+ return classify(tree, integration, liveness, options.nowMs).class;
430
+ }
431
+ async function linkedWorktreesOf(repoRoot) {
432
+ const worktrees = await listGitWorktrees(repoRoot);
433
+ return worktrees.filter((w) => w.path !== repoRoot);
434
+ }
435
+ function toPlanEntry(worktree, status, includeDetached, nowMs) {
436
+ return {
437
+ path: worktree.path,
438
+ branch: worktree.branch,
439
+ head: worktree.head,
440
+ tree: status.tree,
441
+ integration: status.integration,
442
+ liveness: status.liveness,
443
+ class: classifyForGc(status.tree, status.integration, status.liveness, { includeDetached, nowMs })
444
+ };
445
+ }
446
+ async function planGc(input) {
447
+ const includeDetached = Boolean(input.includeDetached);
448
+ const nowMs = input.nowMs ?? Date.now();
449
+ const worktrees = await linkedWorktreesOf(input.repoRoot);
450
+ const entries = [];
451
+ for (const worktree of worktrees) {
452
+ const status = await computeWorktreeStatus({
453
+ repoRoot: input.repoRoot,
454
+ repoName: input.repoName,
455
+ worktree,
456
+ forge: input.forge,
457
+ memo: input.memo,
458
+ defaultBranchRef: input.defaultBranchRef,
459
+ sessionsPath: input.sessionsPath,
460
+ now: input.now
461
+ });
462
+ entries.push(toPlanEntry(worktree, status, includeDetached, nowMs));
463
+ }
464
+ return entries;
465
+ }
466
+ async function findWorktree(repoRoot, path) {
467
+ const worktrees = await linkedWorktreesOf(repoRoot);
468
+ return worktrees.find((w) => w.path === path) ?? null;
469
+ }
470
+ async function reclaimOne(options, worktree) {
471
+ try {
472
+ await runTool({
473
+ tool: cleanupWorktreeTool,
474
+ candidates,
475
+ input: {
476
+ repoRoot: options.repoRoot,
477
+ cwd: worktree.path,
478
+ ...worktree.branch ? { branch: worktree.branch } : {},
479
+ // Branch deletion only ever runs for a `reclaim`-class entry, which by
480
+ // construction requires `integration ∈ {merged(*), fresh}` and
481
+ // `tree = clean` (PLAN.md's invariant: branch -D only when there's
482
+ // nothing uncommitted to lose, never a hold class).
483
+ deleteBranch: true
484
+ // No discardUntracked/discardModified, ever, on this path (PLAN.md
485
+ // §5.2 layer 3): `reclaim` means `tree = clean`, so plain `git
486
+ // worktree remove` should succeed on its own; if the tree turned
487
+ // dirty in the instant between the re-check above and this call, git
488
+ // itself refuses and that refusal surfaces below as `failed` — never
489
+ // a force flag "just in case."
490
+ }
491
+ });
492
+ } catch (err) {
493
+ return {
494
+ path: worktree.path,
495
+ branch: worktree.branch,
496
+ result: "failed",
497
+ message: err instanceof Error ? err.message : String(err)
498
+ };
499
+ }
500
+ return { path: worktree.path, branch: worktree.branch, result: "reclaimed" };
501
+ }
502
+ async function salvageOne(options, worktree) {
503
+ let salvageDir;
504
+ try {
505
+ const result = await salvageWorktree({
506
+ repoName: options.repoName,
507
+ worktreePath: worktree.path,
508
+ branch: worktree.branch,
509
+ tipSha: worktree.head,
510
+ slug: worktree.branch ?? basename(worktree.path),
511
+ sessionsPath: options.sessionsPath,
512
+ now: options.now,
513
+ salvageRoot: options.salvageRoot
514
+ });
515
+ salvageDir = result.dir;
516
+ } catch (err) {
517
+ return {
518
+ path: worktree.path,
519
+ branch: worktree.branch,
520
+ result: "failed",
521
+ message: `salvage failed, nothing removed: ${err instanceof Error ? err.message : String(err)}`
522
+ };
523
+ }
524
+ try {
525
+ await runTool({
526
+ tool: cleanupWorktreeTool,
527
+ candidates,
528
+ input: {
529
+ repoRoot: options.repoRoot,
530
+ cwd: worktree.path,
531
+ ...worktree.branch ? { branch: worktree.branch } : {},
532
+ deleteBranch: true,
533
+ // Both discard flags: the snapshot above is already durable (fsynced)
534
+ // before this call, so nothing on disk is lost by discarding it here
535
+ // (PLAN.md §5.2 layer 4 — salvage before discard).
536
+ discardUntracked: true,
537
+ discardModified: true
538
+ }
539
+ });
540
+ } catch (err) {
541
+ return {
542
+ path: worktree.path,
543
+ branch: worktree.branch,
544
+ result: "failed",
545
+ message: `salvaged to ${salvageDir}, but removal failed: ${err instanceof Error ? err.message : String(err)}`
546
+ };
547
+ }
548
+ return { path: worktree.path, branch: worktree.branch, result: "salvaged", salvageDir };
549
+ }
550
+ async function applyOne(entry, options) {
551
+ if (entry.class === "hold") {
552
+ return { path: entry.path, branch: entry.branch, result: "held" };
553
+ }
554
+ if (entry.class === "salvage" && !options.salvageDirty) {
555
+ return { path: entry.path, branch: entry.branch, result: "skipped-dirty" };
556
+ }
557
+ const fresh = await findWorktree(options.repoRoot, entry.path);
558
+ if (!fresh) {
559
+ return { path: entry.path, branch: entry.branch, result: "aborted-vanished" };
560
+ }
561
+ const status = await computeWorktreeStatus({
562
+ repoRoot: options.repoRoot,
563
+ repoName: options.repoName,
564
+ worktree: fresh,
565
+ forge: options.forge,
566
+ memo: options.memo,
567
+ defaultBranchRef: options.defaultBranchRef,
568
+ sessionsPath: options.sessionsPath,
569
+ now: options.now
570
+ });
571
+ const freshClass = classifyForGc(status.tree, status.integration, status.liveness, {
572
+ includeDetached: Boolean(options.includeDetached),
573
+ nowMs: options.nowMs
574
+ });
575
+ if (freshClass !== entry.class) {
576
+ return {
577
+ path: entry.path,
578
+ branch: fresh.branch,
579
+ result: "aborted-reclassified",
580
+ from: entry.class,
581
+ to: freshClass
582
+ };
583
+ }
584
+ if (freshClass === "reclaim") return reclaimOne(options, fresh);
585
+ return salvageOne(options, fresh);
586
+ }
587
+ async function applyGc(plan, options) {
588
+ const outcomes = [];
589
+ for (const entry of plan) {
590
+ outcomes.push(await applyOne(entry, options));
591
+ }
592
+ return outcomes;
593
+ }
594
+
595
+ export { ForgeUnavailableError, GhCliForgeClient, RestForgeClient, SALVAGE_ROOT, UnreachableForgeClient, applyGc, classifyForGc, createForgeClient, createProxyServer, parseGithubOwnerRepo, planGc, salvageWorktree, startProxy };
5
596
  //# sourceMappingURL=index.mjs.map
6
597
  //# sourceMappingURL=index.mjs.map