@agentproto/worktree 0.8.0 → 0.9.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,16 +1,17 @@
1
- export { connectDaemonAgentSessionHost, makeDaemonAgentSessionHost, worktreeAgentInputSchema, worktreeAgentWorkflow } from './chunk-AGIUUFLI.mjs';
1
+ export { connectDaemonAgentSessionHost, makeDaemonAgentSessionHost, worktreeAgentInputSchema, worktreeAgentWorkflow } from './chunk-6EV7WP2U.mjs';
2
2
  import './chunk-E3EM24BS.mjs';
3
- import { computeProvenance, execArgv, classify, computeWorktreeStatus, listGitWorktrees, execGit, worktreeProvider } from './chunk-AFS6W62Z.mjs';
4
- export { CONFIG_FILENAME, ConfigError, DEFAULT_PROXY_PORT, ENV_VARS, FileVerdictMemoStore, HookError, InMemoryVerdictMemoStore, ProxyTable, SESSIONS_BUCKETS_ROOT, SESSIONS_FILE_PATH, ServiceSupervisor, VERDICT_MEMO_PATH, WORKTREES_TURBO_CACHE_DIR_ENV, 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, resolveWorktreesTurboCacheDir, runSetup, runTeardown, serviceEnv, serviceEnvToken, serviceHostname, serviceUrl, sessionInWorktree, sharedProxyTable, slugify, stripPort, worktreeProvider, writeWorktreeMarker } from './chunk-AFS6W62Z.mjs';
3
+ import { computeProvenance, execArgv, classify, computeWorktreeStatus, listGitWorktrees, execGit, worktreeProvider } from './chunk-KRQFVJPK.mjs';
4
+ export { CONFIG_FILENAME, ConfigError, DEFAULT_PROXY_PORT, ENV_VARS, FileVerdictMemoStore, HookError, InMemoryVerdictMemoStore, ProxyTable, SESSIONS_BUCKETS_ROOT, SESSIONS_FILE_PATH, ServiceSupervisor, VERDICT_MEMO_PATH, WORKTREES_TURBO_CACHE_DIR_ENV, WorktreeNotRemovableError, allocatePort, classify, computeBaseDivergence, 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, resolveWorktreesTurboCacheDir, runSetup, runTeardown, serviceEnv, serviceEnvToken, serviceHostname, serviceUrl, sessionInWorktree, sharedProxyTable, slugify, stripPort, worktreeProvider, writeWorktreeMarker } from './chunk-KRQFVJPK.mjs';
5
5
  import { cleanupWorktreeTool } from './chunk-IKCBL2UN.mjs';
6
6
  export { cleanupWorktreeTool, listServicesTool, provisionWorktreeTool, runGateTool, runScriptTool, serviceStatusSchema, startServiceTool, stopServiceTool } from './chunk-IKCBL2UN.mjs';
7
- import { mkdir, copyFile, writeFile, open, readdir, realpath, rm, readFile, stat } from 'fs/promises';
7
+ import { mkdir, copyFile, readFile, writeFile, open, rename, readdir, realpath, rm, stat } from 'fs/promises';
8
8
  import { homedir } from 'os';
9
9
  import { resolve, join, dirname, basename, sep, relative, isAbsolute } from 'path';
10
10
  import http from 'http';
11
11
  import { connect } from 'net';
12
12
  import { z } from 'zod';
13
13
  import { runTool } from '@agentproto/driver';
14
+ import { spawn } from 'child_process';
14
15
 
15
16
  /**
16
17
  * @agentproto/worktree v0.1.0
@@ -149,12 +150,12 @@ function startProxy(table, port = 0) {
149
150
  sockets.add(socket);
150
151
  socket.once("close", () => sockets.delete(socket));
151
152
  });
152
- return new Promise((resolve3, reject) => {
153
+ return new Promise((resolve4, reject) => {
153
154
  server.once("error", reject);
154
155
  server.listen(port, () => {
155
156
  const address = server.address();
156
157
  const boundPort = address && typeof address === "object" ? address.port : port;
157
- resolve3({
158
+ resolve4({
158
159
  port: boundPort,
159
160
  server,
160
161
  close: () => new Promise((res) => {
@@ -186,6 +187,12 @@ var UnreachableForgeClient = class {
186
187
  async ensurePullHeadFetched() {
187
188
  throw new ForgeUnavailableError(this.reason);
188
189
  }
190
+ async listOpenPullRequests() {
191
+ throw new ForgeUnavailableError(this.reason);
192
+ }
193
+ async listMergedPullRequests() {
194
+ throw new ForgeUnavailableError(this.reason);
195
+ }
189
196
  };
190
197
  var ghPrListItemSchema = z.object({
191
198
  number: z.number(),
@@ -290,6 +297,16 @@ var GhCliForgeClient = class {
290
297
  }
291
298
  return this.parseOutput(result.stdout, ghApiCommitPullListSchema, args).map(normalizeGhApiCommitPull);
292
299
  }
300
+ async listOpenPullRequests() {
301
+ const args = ["pr", "list", "--state", "open", "--limit", "1000", "--json", "number,state,headRefName,headRefOid,mergedAt"];
302
+ const stdout = await this.run(args);
303
+ return this.parseOutput(stdout, ghPrListSchema, args).map(normalizeGhPrListItem);
304
+ }
305
+ async listMergedPullRequests() {
306
+ const args = ["pr", "list", "--state", "merged", "--limit", "1000", "--json", "number,state,headRefName,headRefOid,mergedAt"];
307
+ const stdout = await this.run(args);
308
+ return this.parseOutput(stdout, ghPrListSchema, args).map(normalizeGhPrListItem);
309
+ }
293
310
  async ensurePullHeadFetched(prNumber, oid) {
294
311
  const check = await execArgv("git", ["-C", this.repoRoot, "cat-file", "-e", `${oid}^{commit}`], this.repoRoot);
295
312
  if (check.exitCode === 0) return;
@@ -323,7 +340,7 @@ function normalizeRestPr(item) {
323
340
  };
324
341
  }
325
342
  function parseGithubOwnerRepo(remoteUrl) {
326
- const ssh = remoteUrl.match(/^git@github\.com:([^/]+)\/(.+?)(\.git)?$/);
343
+ const ssh = remoteUrl.match(/^git@github\.com(?:-[\w.-]+)?:([^/]+)\/(.+?)(\.git)?$/);
327
344
  if (ssh?.[1] && ssh[2]) return { owner: ssh[1], repo: ssh[2] };
328
345
  const https = remoteUrl.match(/^https:\/\/github\.com\/([^/]+)\/(.+?)(\.git)?\/?$/);
329
346
  if (https?.[1] && https[2]) return { owner: https[1], repo: https[2] };
@@ -385,6 +402,32 @@ var RestForgeClient = class {
385
402
  }
386
403
  return this.pullRequestList(res);
387
404
  }
405
+ async listOpenPullRequests() {
406
+ const all = [];
407
+ for (let page = 1; page <= 10; page++) {
408
+ const res = await this.get(`/repos/${this.owner}/${this.repo}/pulls?state=open&per_page=100&page=${page}`);
409
+ if (!res.ok) {
410
+ throw new ForgeUnavailableError(`GitHub REST pulls?state=open failed: ${res.status} ${res.statusText}`);
411
+ }
412
+ const batch = await this.pullRequestList(res);
413
+ all.push(...batch);
414
+ if (batch.length < 100) break;
415
+ }
416
+ return all;
417
+ }
418
+ async listMergedPullRequests() {
419
+ const all = [];
420
+ for (let page = 1; page <= 10; page++) {
421
+ const res = await this.get(`/repos/${this.owner}/${this.repo}/pulls?state=closed&per_page=100&page=${page}`);
422
+ if (!res.ok) {
423
+ throw new ForgeUnavailableError(`GitHub REST pulls?state=closed failed: ${res.status} ${res.statusText}`);
424
+ }
425
+ const batch = await this.pullRequestList(res);
426
+ all.push(...batch);
427
+ if (batch.length < 100) break;
428
+ }
429
+ return all.filter((pr) => pr.merged);
430
+ }
388
431
  async ensurePullHeadFetched(prNumber, oid) {
389
432
  const check = await execArgv("git", ["-C", this.repoRoot, "cat-file", "-e", `${oid}^{commit}`], this.repoRoot);
390
433
  if (check.exitCode === 0) return;
@@ -420,6 +463,698 @@ async function createForgeClient(repoRoot) {
420
463
  "no usable forge client: `gh` is not installed/authenticated and GITHUB_TOKEN is not set (or origin isn't a GitHub remote)"
421
464
  );
422
465
  }
466
+ var BRANCH_GC_SCOPES = ["local", "remote", "orphan"];
467
+ function git(repoRoot, args, env) {
468
+ return execArgv("git", ["-C", repoRoot, ...args], repoRoot, {});
469
+ }
470
+ async function gitOk(repoRoot, args) {
471
+ const res = await git(repoRoot, args);
472
+ if (res.exitCode !== 0) {
473
+ throw new Error(`git ${args.join(" ")} failed (exit ${res.exitCode}): ${res.stderr.trim() || res.stdout.trim()}`);
474
+ }
475
+ return res.stdout;
476
+ }
477
+ async function pool(items, n, fn) {
478
+ const results = new Array(items.length);
479
+ let next = 0;
480
+ const worker = async () => {
481
+ while (next < items.length) {
482
+ const i = next++;
483
+ results[i] = await fn(items[i]);
484
+ }
485
+ };
486
+ await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker));
487
+ return results;
488
+ }
489
+ function chunk(items, size) {
490
+ const out = [];
491
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
492
+ return out;
493
+ }
494
+ var basenameOf = (p) => p.slice(p.lastIndexOf("/") + 1);
495
+ async function indexTree(repoRoot, sha) {
496
+ const byPath = /* @__PURE__ */ new Map();
497
+ for (const rec of (await gitOk(repoRoot, ["ls-tree", "-r", "-z", sha])).split("\0")) {
498
+ const tab = rec.indexOf(" ");
499
+ if (tab < 0) continue;
500
+ const blob = rec.slice(0, tab).split(" ")[2];
501
+ if (blob) byPath.set(rec.slice(tab + 1), blob);
502
+ }
503
+ const names = new Set([...byPath.keys()].map(basenameOf));
504
+ return { byPath, blobs: new Set(byPath.values()), names };
505
+ }
506
+ async function createLadderContext(repoRoot, baseRef, anchor, related) {
507
+ const baseSha = (await gitOk(repoRoot, ["rev-parse", "--verify", `${baseRef}^{commit}`])).trim();
508
+ const baseTree = (await gitOk(repoRoot, ["rev-parse", `${baseSha}^{tree}`])).trim();
509
+ let idx = null;
510
+ return {
511
+ repoRoot,
512
+ baseSha,
513
+ baseTree,
514
+ anchor,
515
+ index: () => idx ??= indexTree(repoRoot, baseSha),
516
+ ...related ? { related } : {}
517
+ };
518
+ }
519
+ async function sharesHistory(repoRoot, baseSha, tip) {
520
+ return (await git(repoRoot, ["merge-base", baseSha, tip])).exitCode === 0;
521
+ }
522
+ var NULL_SHA_RE = /^0+$/;
523
+ async function changedBlobs(repoRoot, mergeBase, tip) {
524
+ const out = await gitOk(repoRoot, ["diff", "--raw", "-z", "--no-abbrev", "--no-renames", mergeBase, tip]);
525
+ const parts = out.split("\0");
526
+ const changed = /* @__PURE__ */ new Map();
527
+ for (let i = 0; i + 1 < parts.length; i += 2) {
528
+ const meta = parts[i] ?? "";
529
+ const path = parts[i + 1] ?? "";
530
+ if (!meta.startsWith(":") || !path) continue;
531
+ const dst = meta.slice(1).split(" ")[3] ?? "";
532
+ changed.set(path, NULL_SHA_RE.test(dst) ? null : dst);
533
+ }
534
+ return changed;
535
+ }
536
+ function gitWithInput(repoRoot, args, input) {
537
+ return new Promise((resolvePromise, reject) => {
538
+ const child = spawn("git", ["-C", repoRoot, ...args], { cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"] });
539
+ let stdout = "";
540
+ let stderr = "";
541
+ child.stdout.on("data", (d) => stdout += d.toString("utf8"));
542
+ child.stderr.on("data", (d) => stderr += d.toString("utf8"));
543
+ child.on("error", reject);
544
+ child.on("close", (code) => resolvePromise({ exitCode: code ?? -1, stdout, stderr }));
545
+ child.stdin.end(input);
546
+ });
547
+ }
548
+ async function ignoredPaths(repoRoot, paths) {
549
+ const res = await gitWithInput(repoRoot, ["check-ignore", "--no-index", "--stdin", "-z"], paths.join("\0") + "\0");
550
+ if (res.exitCode !== 0 && res.exitCode !== 1) return /* @__PURE__ */ new Set();
551
+ return new Set(res.stdout.split("\0").filter(Boolean));
552
+ }
553
+ async function coverage(ctx, tip, mergeBase) {
554
+ const idx = await ctx.index();
555
+ const changed = await changedBlobs(ctx.repoRoot, mergeBase, tip);
556
+ const c = { residual: 0, covered: 0, ignored: 0, evolved: 0, unique: 0, deletes: 0, evolvedFiles: [], uniqueFiles: [], deletedFiles: [] };
557
+ const pending = [];
558
+ for (const [p, tipBlob] of changed) {
559
+ const baseBlob = idx.byPath.get(p) ?? null;
560
+ if (tipBlob === baseBlob) continue;
561
+ c.residual++;
562
+ if (tipBlob === null) c.deletedFiles.push(p);
563
+ else if (idx.blobs.has(tipBlob)) c.covered++;
564
+ else pending.push(p);
565
+ }
566
+ const ignored = pending.length ? await ignoredPaths(ctx.repoRoot, pending) : /* @__PURE__ */ new Set();
567
+ for (const p of pending) {
568
+ if (ignored.has(p)) c.ignored++;
569
+ else if (idx.names.has(basenameOf(p))) c.evolvedFiles.push(p);
570
+ else c.uniqueFiles.push(p);
571
+ }
572
+ c.evolved = c.evolvedFiles.length;
573
+ c.unique = c.uniqueFiles.length;
574
+ c.deletes = c.deletedFiles.length;
575
+ return c;
576
+ }
577
+ async function classifyTip(ctx, tip) {
578
+ const { repoRoot, baseSha, baseTree, anchor } = ctx;
579
+ const related = ctx.related?.get(tip) ?? await sharesHistory(repoRoot, baseSha, tip);
580
+ if (!related && !anchor) return { status: "unmerged", history: "unrelated", conflicts: true, ahead: null, behind: null };
581
+ const history = related ? "current" : "pre-rewrite";
582
+ const cmp = related ? baseSha : anchor;
583
+ if ((await git(repoRoot, ["merge-base", "--is-ancestor", tip, cmp])).exitCode === 0) {
584
+ return { status: "merged", history, ahead: 0, behind: null };
585
+ }
586
+ const counts = (await gitOk(repoRoot, ["rev-list", "--left-right", "--count", `${cmp}...${tip}`])).trim().split(/\s+/);
587
+ const behind = Number(counts[0]);
588
+ const ahead = Number(counts[1]);
589
+ const mergeBase = (await git(repoRoot, ["merge-base", cmp, tip])).stdout.trim();
590
+ const mtArgs = related ? [baseSha, tip] : [`--merge-base=${mergeBase}`, baseSha, tip];
591
+ const mt = await git(repoRoot, ["merge-tree", "--write-tree", "--no-messages", ...mtArgs]);
592
+ const mergedTree = (mt.stdout.split("\n")[0] ?? "").trim();
593
+ const conflicts = mt.exitCode !== 0;
594
+ if (!conflicts && mergedTree === baseTree) return { status: "squash-merged", history, ahead, behind, conflicts };
595
+ if (conflicts) {
596
+ const cherry = await gitOk(repoRoot, ["cherry", cmp, tip]);
597
+ if (!cherry.split("\n").some((l) => l.startsWith("+"))) return { status: "patch-merged", history, ahead, behind, conflicts };
598
+ }
599
+ const cov = await coverage(ctx, tip, mergeBase);
600
+ const summary = {
601
+ residual: cov.residual,
602
+ covered: cov.covered,
603
+ ignored: cov.ignored,
604
+ evolved: cov.evolved,
605
+ unique: cov.unique,
606
+ deletes: cov.deletes
607
+ };
608
+ if (cov.evolved + cov.unique + cov.deletes === 0) {
609
+ return { status: "content-merged", history, ahead, behind, conflicts, coverage: summary };
610
+ }
611
+ const residualFiles = [...cov.uniqueFiles, ...cov.evolvedFiles, ...cov.deletedFiles];
612
+ return {
613
+ status: "unmerged",
614
+ history,
615
+ ahead,
616
+ behind,
617
+ conflicts,
618
+ compareBase: cmp,
619
+ mergeBase,
620
+ mergedTree: conflicts ? null : mergedTree,
621
+ coverage: summary,
622
+ residualFiles: residualFiles.slice(0, 200),
623
+ residualFileCount: residualFiles.length
624
+ };
625
+ }
626
+ var contextCache = /* @__PURE__ */ new Map();
627
+ async function classifyTipAgainstBase(repoRoot, baseRef, tip) {
628
+ const baseSha = (await gitOk(repoRoot, ["rev-parse", "--verify", `${baseRef}^{commit}`])).trim();
629
+ const key = `${repoRoot}\0${baseSha}`;
630
+ let ctx = contextCache.get(key);
631
+ if (!ctx) {
632
+ ctx = createLadderContext(repoRoot, baseSha, null);
633
+ contextCache.set(key, ctx);
634
+ ctx.catch(() => contextCache.delete(key));
635
+ }
636
+ return classifyTip(await ctx, tip);
637
+ }
638
+ async function detectAnchor(repoRoot, baseSha, tips, related) {
639
+ const roots = (await gitOk(repoRoot, ["rev-list", "--max-parents=0", baseSha])).trim().split("\n").filter(Boolean);
640
+ if (roots.length !== 1) return null;
641
+ const root = roots[0];
642
+ const unique = [...new Set(tips)];
643
+ const shares = await pool(unique, 8, (sha) => sharesHistory(repoRoot, baseSha, sha));
644
+ const unrelated = unique.filter((_, i) => !shares[i]);
645
+ if (related) unique.forEach((sha, i) => related.set(sha, shares[i]));
646
+ if (!unrelated.length) return null;
647
+ const rootDate = (await gitOk(repoRoot, ["log", "-1", "--format=%cI", root])).trim();
648
+ const since = new Date(new Date(rootDate).getTime() - 3 * 864e5).toISOString();
649
+ const cands = (await gitOk(repoRoot, ["log", "--format=%H", `--since=${since}`, `--until=${rootDate}`, ...unrelated])).trim().split("\n").filter(Boolean).slice(0, 60);
650
+ let best = null;
651
+ for (const c of cands) {
652
+ const n = (await gitOk(repoRoot, ["diff", "--numstat", c, root])).trim().split("\n").filter(Boolean).length;
653
+ if (!best || n < best.n) best = { sha: c, n };
654
+ }
655
+ return best && best.n < 200 ? best.sha : null;
656
+ }
657
+ var PROTECTED_NAMES = /* @__PURE__ */ new Set(["main", "master", "develop", "HEAD", "gh-pages"]);
658
+ async function configuredRemotes(repoRoot) {
659
+ return new Set((await gitOk(repoRoot, ["remote"])).trim().split("\n").filter(Boolean));
660
+ }
661
+ function baseRemoteOf(base, remotes) {
662
+ const slash = base.indexOf("/");
663
+ if (slash > 0 && remotes.has(base.slice(0, slash))) return base.slice(0, slash);
664
+ return remotes.has("origin") ? "origin" : null;
665
+ }
666
+ async function listBranchRefs(repoRoot, remote, remotes) {
667
+ const fmt = "%(refname)%09%(objectname)%09%(committerdate:iso-strict)%09%(authorname)%09%(subject)";
668
+ const out = await gitOk(repoRoot, ["for-each-ref", `--format=${fmt}`, "refs/heads", "refs/remotes"]);
669
+ const refs = [];
670
+ let otherRemoteRefs = 0;
671
+ for (const line of out.split("\n")) {
672
+ if (!line) continue;
673
+ const [ref = "", sha = "", date = "", author = "", ...subj] = line.split(" ");
674
+ const rest = { ref, sha, date, author, subject: subj.join(" ") };
675
+ let entry;
676
+ if (ref.startsWith("refs/heads/")) {
677
+ entry = { kind: "local", name: ref.slice("refs/heads/".length), ...rest };
678
+ } else {
679
+ const ns = ref.split("/")[2] ?? "";
680
+ if (remote && ns === remote) {
681
+ entry = { kind: "remote", name: ref.slice(`refs/remotes/${remote}/`.length), remote, ...rest };
682
+ } else if (remotes.has(ns)) {
683
+ otherRemoteRefs++;
684
+ continue;
685
+ } else {
686
+ entry = { kind: "orphan", name: ref.slice("refs/remotes/".length), ...rest };
687
+ }
688
+ }
689
+ if (entry.name === "HEAD" || entry.name.endsWith("/HEAD")) continue;
690
+ refs.push(entry);
691
+ }
692
+ return { refs, otherRemoteRefs };
693
+ }
694
+ async function worktreeBranches(repoRoot) {
695
+ const map = /* @__PURE__ */ new Map();
696
+ for (const w of await listGitWorktrees(repoRoot)) if (w.branch) map.set(w.branch, w.path);
697
+ return map;
698
+ }
699
+ async function openPrHeads(forge) {
700
+ if (!forge) return { available: false, reason: "no forge client", heads: /* @__PURE__ */ new Map() };
701
+ if (!forge.listOpenPullRequests) {
702
+ return { available: false, reason: "forge client cannot list open PRs", heads: /* @__PURE__ */ new Map() };
703
+ }
704
+ try {
705
+ const prs = await forge.listOpenPullRequests();
706
+ const heads = /* @__PURE__ */ new Map();
707
+ for (const pr of prs) if (pr.state === "open") heads.set(pr.headRefName, pr.number);
708
+ return { available: true, heads };
709
+ } catch (err) {
710
+ const reason = (err instanceof Error ? err.message : String(err)).split("\n")[0] ?? "forge error";
711
+ return { available: false, reason, heads: /* @__PURE__ */ new Map() };
712
+ }
713
+ }
714
+ async function mergedPrHeads(forge) {
715
+ if (!forge?.listMergedPullRequests) return /* @__PURE__ */ new Map();
716
+ try {
717
+ const heads = /* @__PURE__ */ new Map();
718
+ for (const pr of await forge.listMergedPullRequests()) heads.set(pr.headRefOid, pr.number);
719
+ return heads;
720
+ } catch {
721
+ return /* @__PURE__ */ new Map();
722
+ }
723
+ }
724
+ function holdFor(b, env) {
725
+ if (b.kind === "orphan") return null;
726
+ if (PROTECTED_NAMES.has(b.name) || b.name === env.baseName) return { holdReason: "protected" };
727
+ const wt = env.worktrees.get(b.name);
728
+ if (wt) return { holdReason: "worktree", holdDetail: wt };
729
+ const pr = env.prs.heads.get(b.name);
730
+ if (pr !== void 0) return { holdReason: "open-pr", holdDetail: `PR #${pr}` };
731
+ if (!env.prs.available) return { holdReason: "pr-check-unavailable", holdDetail: env.prs.reason };
732
+ return null;
733
+ }
734
+ async function pushStateOf(b, env) {
735
+ const { repoRoot } = env.ctx;
736
+ if (b.kind === "local") {
737
+ if (env.remoteShas.has(b.sha)) return "same-tip-on-remote";
738
+ if (env.remote) {
739
+ const contained = await git(repoRoot, ["for-each-ref", "--contains", b.sha, "--format=x", `refs/remotes/${env.remote}`]);
740
+ if (contained.stdout.trim()) return "contained-in-remote";
741
+ }
742
+ return env.remoteNames.has(b.name) ? "diverged-from-remote" : "local-only";
743
+ }
744
+ if (b.kind === "orphan") {
745
+ const scopes = ["refs/heads", ...env.remote ? [`refs/remotes/${env.remote}`] : []];
746
+ const contained = await git(repoRoot, ["for-each-ref", "--contains", b.sha, "--format=x", ...scopes]);
747
+ return contained.stdout.trim() ? "contained-elsewhere" : "only-copy";
748
+ }
749
+ return void 0;
750
+ }
751
+ async function classifyRef(b, env) {
752
+ let pending = env.tips.get(b.sha);
753
+ if (!pending) {
754
+ pending = classifyTip(env.ctx, b.sha);
755
+ env.tips.set(b.sha, pending);
756
+ }
757
+ const tip = await pending;
758
+ const ageDays = Math.floor((env.nowMs - new Date(b.date).getTime()) / 864e5);
759
+ const entry = { ...b, ...tip, ageDays, class: "hold" };
760
+ if (tip.status === "unmerged" && env.mergedPrShas.has(b.sha)) {
761
+ const pr = env.mergedPrShas.get(b.sha);
762
+ entry.status = "pr-merged";
763
+ entry.mergedPr = pr;
764
+ }
765
+ if (entry.status === "unmerged") {
766
+ const pushed = await pushStateOf(b, env);
767
+ if (pushed) entry.pushed = pushed;
768
+ const stored = env.verdicts ? await env.verdicts.get(env.repoName, b.sha) : null;
769
+ if (stored) {
770
+ entry.verdict = { triage: stored.triage.verdict, agree: stored.gate ? stored.gate.agree : null, reviewer: stored.reviewer };
771
+ }
772
+ }
773
+ const hold = holdFor(b, env);
774
+ if (hold) {
775
+ entry.holdReason = hold.holdReason;
776
+ if (hold.holdDetail) entry.holdDetail = hold.holdDetail;
777
+ return entry;
778
+ }
779
+ if (entry.status !== "unmerged") {
780
+ entry.class = "reclaim";
781
+ entry.reclaimReason = entry.status;
782
+ return entry;
783
+ }
784
+ if (ageDays < env.minAgeDays) {
785
+ entry.holdReason = "young";
786
+ entry.holdDetail = `${ageDays}d < ${env.minAgeDays}d`;
787
+ return entry;
788
+ }
789
+ if (env.includeReviewed && entry.verdict?.agree === true) {
790
+ entry.class = "reclaim";
791
+ entry.reclaimReason = "reviewed";
792
+ return entry;
793
+ }
794
+ entry.class = "review";
795
+ return entry;
796
+ }
797
+ async function snapshot(input) {
798
+ const remotes = await configuredRemotes(input.repoRoot);
799
+ const remote = baseRemoteOf(input.base, remotes);
800
+ const [{ refs, otherRemoteRefs }, worktrees, prs, mergedPrs] = await Promise.all([
801
+ listBranchRefs(input.repoRoot, remote, remotes),
802
+ worktreeBranches(input.repoRoot),
803
+ openPrHeads(input.forge),
804
+ mergedPrHeads(input.forge)
805
+ ]);
806
+ const baseSha = (await gitOk(input.repoRoot, ["rev-parse", "--verify", `${input.base}^{commit}`])).trim();
807
+ let anchor;
808
+ const related = /* @__PURE__ */ new Map();
809
+ if (input.anchor === "auto") anchor = await detectAnchor(input.repoRoot, baseSha, refs.map((r) => r.sha), related);
810
+ else if (input.anchor) anchor = (await gitOk(input.repoRoot, ["rev-parse", "--verify", `${input.anchor}^{commit}`])).trim();
811
+ else anchor = null;
812
+ const ctx = await createLadderContext(input.repoRoot, baseSha, anchor, related);
813
+ const remoteRefs = refs.filter((r) => r.kind === "remote");
814
+ return {
815
+ refs,
816
+ otherRemoteRefs,
817
+ env: {
818
+ ctx,
819
+ tips: /* @__PURE__ */ new Map(),
820
+ remote,
821
+ baseName: remote && input.base.startsWith(`${remote}/`) ? input.base.slice(remote.length + 1) : input.base,
822
+ worktrees,
823
+ prs,
824
+ mergedPrShas: mergedPrs,
825
+ remoteShas: new Set(remoteRefs.map((r) => r.sha)),
826
+ remoteNames: new Set(remoteRefs.map((r) => r.name)),
827
+ minAgeDays: input.minAgeDays,
828
+ includeReviewed: input.includeReviewed,
829
+ ...input.verdicts ? { verdicts: input.verdicts } : {},
830
+ repoName: input.repoName,
831
+ nowMs: input.nowMs
832
+ }
833
+ };
834
+ }
835
+ var DEFAULT_BRANCH_GC_MIN_AGE_DAYS = 3;
836
+ function normalizeScopes(scopes) {
837
+ if (!scopes || scopes.length === 0) return [...BRANCH_GC_SCOPES];
838
+ for (const s of scopes) {
839
+ if (!BRANCH_GC_SCOPES.includes(s)) {
840
+ throw new Error(`branch gc: unknown scope "${s}" (expected ${BRANCH_GC_SCOPES.join(", ")})`);
841
+ }
842
+ }
843
+ return BRANCH_GC_SCOPES.filter((s) => scopes.includes(s));
844
+ }
845
+ async function planBranchGc(input) {
846
+ const base = input.base ?? "origin/main";
847
+ const scopes = normalizeScopes(input.scopes);
848
+ const minAgeDays = input.minAgeDays ?? DEFAULT_BRANCH_GC_MIN_AGE_DAYS;
849
+ const includeReviewed = Boolean(input.includeReviewed);
850
+ const nowMs = input.nowMs ?? Date.now();
851
+ const snap = await snapshot({
852
+ repoRoot: input.repoRoot,
853
+ repoName: input.repoName,
854
+ base,
855
+ anchor: input.anchor ?? "auto",
856
+ ...input.forge ? { forge: input.forge } : {},
857
+ ...input.verdicts ? { verdicts: input.verdicts } : {},
858
+ minAgeDays,
859
+ includeReviewed,
860
+ nowMs
861
+ });
862
+ const inScope = snap.refs.filter((r) => scopes.includes(r.kind));
863
+ const entries = await pool(inScope, input.concurrency ?? 8, (r) => classifyRef(r, snap.env));
864
+ return {
865
+ repoRoot: input.repoRoot,
866
+ repoName: input.repoName,
867
+ base,
868
+ baseSha: snap.env.ctx.baseSha,
869
+ baseTree: snap.env.ctx.baseTree,
870
+ remote: snap.env.remote,
871
+ anchor: snap.env.ctx.anchor,
872
+ prCheck: snap.env.prs.available ? { available: true } : { available: false, ...snap.env.prs.reason ? { reason: snap.env.prs.reason } : {} },
873
+ scopes,
874
+ minAgeDays,
875
+ includeReviewed,
876
+ generatedAt: new Date(nowMs).toISOString(),
877
+ otherRemoteRefs: snap.otherRemoteRefs,
878
+ entries
879
+ };
880
+ }
881
+ function summarizeBranchGcPlan(plan) {
882
+ const byClass = {};
883
+ const byStatus = {};
884
+ for (const k of BRANCH_GC_SCOPES) {
885
+ byClass[k] = { reclaim: 0, review: 0, hold: 0 };
886
+ byStatus[k] = {};
887
+ }
888
+ for (const e of plan.entries) {
889
+ byClass[e.kind][e.class]++;
890
+ const isProtected = e.holdReason === "protected" || e.holdReason === "worktree" || e.holdReason === "open-pr";
891
+ const key = isProtected ? "protected" : e.status;
892
+ byStatus[e.kind][key] = (byStatus[e.kind][key] ?? 0) + 1;
893
+ }
894
+ return { byClass, byStatus };
895
+ }
896
+ function branchReviewQueue(plan, options = {}) {
897
+ const bySha = /* @__PURE__ */ new Map();
898
+ for (const e of plan.entries) {
899
+ if (e.class !== "review") continue;
900
+ if (e.verdict && !options.all) continue;
901
+ const seen = bySha.get(e.sha);
902
+ if (seen) {
903
+ seen.refs.push(e.ref);
904
+ continue;
905
+ }
906
+ bySha.set(e.sha, {
907
+ name: e.name,
908
+ kind: e.kind,
909
+ sha: e.sha,
910
+ ageDays: e.ageDays,
911
+ author: e.author,
912
+ subject: e.subject,
913
+ history: e.history,
914
+ base: plan.baseSha,
915
+ ...e.compareBase ? { compareBase: e.compareBase } : {},
916
+ ...e.mergeBase ? { mergeBase: e.mergeBase } : {},
917
+ mergedTree: e.mergedTree ?? null,
918
+ ...e.conflicts !== void 0 ? { conflicts: e.conflicts } : {},
919
+ ahead: e.ahead,
920
+ behind: e.behind,
921
+ ...e.pushed ? { pushed: e.pushed } : {},
922
+ ...e.coverage ? { coverage: e.coverage } : {},
923
+ ...e.residualFiles ? { residualFiles: e.residualFiles } : {},
924
+ ...e.residualFileCount !== void 0 ? { residualFileCount: e.residualFileCount } : {},
925
+ refs: [e.ref],
926
+ ...e.verdict ? { verdict: e.verdict } : {}
927
+ });
928
+ }
929
+ return { base: plan.base, baseSha: plan.baseSha, anchor: plan.anchor, candidates: [...bySha.values()] };
930
+ }
931
+ var BRANCH_GC_STATE_DIR = () => resolve(homedir(), ".agentproto", "branch-gc");
932
+ var shellQuote = (s) => /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
933
+ function restoreEntry(repoRoot, e) {
934
+ const argv = e.kind === "local" ? ["branch", e.name, e.sha] : e.kind === "remote" ? ["push", e.remote, `${e.sha}:refs/heads/${e.name}`] : ["update-ref", e.ref, e.sha];
935
+ return {
936
+ kind: e.kind,
937
+ name: e.name,
938
+ ref: e.ref,
939
+ sha: e.sha,
940
+ ...e.remote ? { remote: e.remote } : {},
941
+ argv,
942
+ command: ["git", "-C", repoRoot, ...argv].map(shellQuote).join(" ")
943
+ };
944
+ }
945
+ async function writeJsonAtomic(path, value) {
946
+ await mkdir(dirname(path), { recursive: true });
947
+ const tmp = `${path}.${process.pid}.tmp`;
948
+ await writeFile(tmp, JSON.stringify(value, null, 2) + "\n", "utf8");
949
+ await rename(tmp, path);
950
+ }
951
+ async function applyBranchGc(plan, options) {
952
+ if (!options.scopes || options.scopes.length === 0) {
953
+ throw new Error("branch gc apply: `scopes` is required (any of local, remote, orphan) \u2014 apply never defaults to every kind");
954
+ }
955
+ const scopes = normalizeScopes(options.scopes);
956
+ const { repoRoot } = plan;
957
+ const snap = await snapshot({
958
+ repoRoot,
959
+ repoName: plan.repoName,
960
+ base: plan.base,
961
+ anchor: plan.anchor,
962
+ ...options.forge ? { forge: options.forge } : {},
963
+ ...options.verdicts ? { verdicts: options.verdicts } : {},
964
+ minAgeDays: plan.minAgeDays,
965
+ includeReviewed: plan.includeReviewed,
966
+ nowMs: options.nowMs ?? Date.now()
967
+ });
968
+ const current = new Map(snap.refs.map((r) => [r.ref, r]));
969
+ const outcomes = [];
970
+ const confirmed = [];
971
+ for (const e of plan.entries) {
972
+ if (!scopes.includes(e.kind)) continue;
973
+ const id = { kind: e.kind, name: e.name, sha: e.sha };
974
+ if (e.class === "hold") {
975
+ outcomes.push({ ...id, result: "held", ...e.holdReason ? { holdReason: e.holdReason } : {} });
976
+ continue;
977
+ }
978
+ if (e.class === "review") {
979
+ outcomes.push({ ...id, result: "skipped-review" });
980
+ continue;
981
+ }
982
+ const fresh = current.get(e.ref);
983
+ if (!fresh) {
984
+ outcomes.push({ ...id, result: "aborted-vanished" });
985
+ continue;
986
+ }
987
+ if (fresh.sha !== e.sha) {
988
+ outcomes.push({ ...id, result: "aborted-moved", currentSha: fresh.sha });
989
+ continue;
990
+ }
991
+ const now = await classifyRef(fresh, snap.env);
992
+ if (now.class !== "reclaim") {
993
+ outcomes.push({
994
+ ...id,
995
+ result: "aborted-reclassified",
996
+ from: e.class,
997
+ to: now.class,
998
+ ...now.holdReason ? { holdReason: now.holdReason } : {}
999
+ });
1000
+ continue;
1001
+ }
1002
+ confirmed.push(now);
1003
+ }
1004
+ if (confirmed.length === 0) return { outcomes, restoreLog: null };
1005
+ const createdAt = (/* @__PURE__ */ new Date()).toISOString();
1006
+ const log = {
1007
+ schema: "branch-gc-restore/v1",
1008
+ repoRoot,
1009
+ base: plan.base,
1010
+ baseSha: snap.env.ctx.baseSha,
1011
+ createdAt,
1012
+ entries: confirmed.map((e) => restoreEntry(repoRoot, e))
1013
+ };
1014
+ const logPath = join(options.stateDir ?? BRANCH_GC_STATE_DIR(), plan.repoName, `restore-${createdAt.replace(/[:.]/g, "-")}.json`);
1015
+ await writeJsonAtomic(logPath, log);
1016
+ const results = /* @__PURE__ */ new Map();
1017
+ const fail = (res) => ({ ok: false, message: (res.stderr || res.stdout).trim() || `exit ${res.exitCode}` });
1018
+ for (const e of confirmed.filter((c) => c.kind === "local")) {
1019
+ const res = await git(repoRoot, ["branch", "-D", e.name]);
1020
+ results.set(e.ref, res.exitCode === 0 ? { ok: true } : fail(res));
1021
+ }
1022
+ const remoteEntries = confirmed.filter((c) => c.kind === "remote");
1023
+ const byRemote = /* @__PURE__ */ new Map();
1024
+ for (const e of remoteEntries) byRemote.set(e.remote, [...byRemote.get(e.remote) ?? [], e]);
1025
+ for (const [remote, list] of byRemote) {
1026
+ const failedBatches = [];
1027
+ for (const batch of chunk(list, options.remoteBatchSize ?? 50)) {
1028
+ const res = await git(repoRoot, ["push", remote, "--delete", ...batch.map((e) => e.name)]);
1029
+ if (res.exitCode === 0) for (const e of batch) results.set(e.ref, { ok: true });
1030
+ else failedBatches.push({ batch, res });
1031
+ }
1032
+ await git(repoRoot, ["fetch", "--prune", "--quiet", remote]);
1033
+ let retried = false;
1034
+ for (const { batch } of failedBatches) {
1035
+ for (const e of batch) {
1036
+ if ((await git(repoRoot, ["rev-parse", "--verify", "--quiet", e.ref])).exitCode !== 0) {
1037
+ results.set(e.ref, { ok: true });
1038
+ continue;
1039
+ }
1040
+ const one = await git(repoRoot, ["push", remote, "--delete", e.name]);
1041
+ results.set(e.ref, one.exitCode === 0 ? { ok: true } : fail(one));
1042
+ retried = true;
1043
+ }
1044
+ }
1045
+ if (retried) await git(repoRoot, ["fetch", "--prune", "--quiet", remote]);
1046
+ }
1047
+ for (const e of confirmed.filter((c) => c.kind === "orphan")) {
1048
+ const res = await git(repoRoot, ["update-ref", "-d", e.ref, e.sha]);
1049
+ results.set(e.ref, res.exitCode === 0 ? { ok: true } : fail(res));
1050
+ }
1051
+ for (const entry of log.entries) entry.deleted = results.get(entry.ref)?.ok === true;
1052
+ await writeJsonAtomic(logPath, log);
1053
+ for (const e of confirmed) {
1054
+ const r = results.get(e.ref);
1055
+ const id = { kind: e.kind, name: e.name, sha: e.sha };
1056
+ if (r?.ok) outcomes.push({ ...id, result: "deleted", reclaimReason: e.reclaimReason });
1057
+ else outcomes.push({ ...id, result: "failed", message: r && !r.ok ? r.message : "not attempted" });
1058
+ }
1059
+ return { outcomes, restoreLog: logPath };
1060
+ }
1061
+ async function readBranchGcRestoreLog(path) {
1062
+ return JSON.parse(await readFile(path, "utf8"));
1063
+ }
1064
+ var BRANCH_TRIAGE_VERDICTS = ["obsolete", "superseded", "salvage", "in-progress", "unclear"];
1065
+ var branchVerdictSchema = z.object({
1066
+ name: z.string().min(1),
1067
+ sha: z.string().regex(/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/, "sha must be a full 40- or 64-char lowercase hex object id"),
1068
+ triage: z.object({
1069
+ verdict: z.enum(BRANCH_TRIAGE_VERDICTS),
1070
+ confidence: z.number().min(0).max(1),
1071
+ reason: z.string().min(1),
1072
+ salvage: z.string().optional()
1073
+ }),
1074
+ gate: z.object({
1075
+ agree: z.boolean(),
1076
+ verdict: z.enum(BRANCH_TRIAGE_VERDICTS),
1077
+ reason: z.string().min(1),
1078
+ evidence: z.array(z.string().min(1))
1079
+ }).optional(),
1080
+ reviewer: z.string().min(1)
1081
+ }).superRefine((v, ctx) => {
1082
+ if (v.gate?.agree && v.gate.evidence.length === 0) {
1083
+ ctx.addIssue({ code: "custom", path: ["gate", "evidence"], message: "gate.agree=true requires at least one evidence entry" });
1084
+ }
1085
+ });
1086
+ var verdictKey = (repo, sha) => `${repo}\0${sha}`;
1087
+ var BRANCH_VERDICTS_PATH = () => resolve(homedir(), ".agentproto", "branch-gc-verdicts.json");
1088
+ var verdictRecordSchema = z.intersection(branchVerdictSchema, z.object({ repo: z.string(), recordedAt: z.string() }));
1089
+ var verdictFileSchema = z.object({ entries: z.array(z.unknown()) });
1090
+ var FileBranchVerdictStore = class {
1091
+ constructor(path = BRANCH_VERDICTS_PATH()) {
1092
+ this.path = path;
1093
+ }
1094
+ path;
1095
+ cache = null;
1096
+ async load() {
1097
+ if (this.cache) return this.cache;
1098
+ const map = /* @__PURE__ */ new Map();
1099
+ this.cache = map;
1100
+ let parsed;
1101
+ try {
1102
+ parsed = JSON.parse(await readFile(this.path, "utf8"));
1103
+ } catch {
1104
+ return map;
1105
+ }
1106
+ const file = verdictFileSchema.safeParse(parsed);
1107
+ if (!file.success) return map;
1108
+ for (const raw of file.data.entries) {
1109
+ const rec = verdictRecordSchema.safeParse(raw);
1110
+ if (rec.success) map.set(verdictKey(rec.data.repo, rec.data.sha), rec.data);
1111
+ }
1112
+ return map;
1113
+ }
1114
+ async get(repo, sha) {
1115
+ return (await this.load()).get(verdictKey(repo, sha)) ?? null;
1116
+ }
1117
+ async set(record) {
1118
+ const map = await this.load();
1119
+ map.set(verdictKey(record.repo, record.sha), record);
1120
+ await writeJsonAtomic(this.path, { entries: [...map.values()] });
1121
+ }
1122
+ };
1123
+ var InMemoryBranchVerdictStore = class {
1124
+ map = /* @__PURE__ */ new Map();
1125
+ async get(repo, sha) {
1126
+ return this.map.get(verdictKey(repo, sha)) ?? null;
1127
+ }
1128
+ async set(record) {
1129
+ this.map.set(verdictKey(record.repo, record.sha), record);
1130
+ }
1131
+ };
1132
+ var BranchVerdictError = class extends Error {
1133
+ constructor(message) {
1134
+ super(message);
1135
+ this.name = "BranchVerdictError";
1136
+ }
1137
+ };
1138
+ async function recordBranchVerdict(input) {
1139
+ const parsed = branchVerdictSchema.safeParse(input.verdict);
1140
+ if (!parsed.success) {
1141
+ const issues = parsed.error.issues.map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`).join("; ");
1142
+ throw new BranchVerdictError(`invalid branch verdict: ${issues}`);
1143
+ }
1144
+ const exists = await git(input.repoRoot, ["cat-file", "-e", `${parsed.data.sha}^{commit}`]);
1145
+ if (exists.exitCode !== 0) {
1146
+ throw new BranchVerdictError(`invalid branch verdict: ${parsed.data.sha} is not a commit in ${input.repoRoot}`);
1147
+ }
1148
+ const record = {
1149
+ ...parsed.data,
1150
+ repo: input.repoName,
1151
+ recordedAt: (input.now ?? (() => (/* @__PURE__ */ new Date()).toISOString()))()
1152
+ };
1153
+ await input.store.set(record);
1154
+ return record;
1155
+ }
1156
+
1157
+ // src/gc.ts
423
1158
  var candidates = [worktreeProvider];
424
1159
  function classifyForGc(tree, integration, liveness, options = {}) {
425
1160
  if (options.includeDetached && integration.state === "detached") {
@@ -428,6 +1163,7 @@ function classifyForGc(tree, integration, liveness, options = {}) {
428
1163
  }
429
1164
  return classify(tree, integration, liveness, options.nowMs).class;
430
1165
  }
1166
+ var DEFAULT_GC_NOISE_PATHS = [".opencode/package-lock.json"];
431
1167
  function isProtectedPath(worktreePath, protectedPaths) {
432
1168
  if (!protectedPaths || protectedPaths.length === 0) return false;
433
1169
  return protectedPaths.some((p) => p === worktreePath || p.startsWith(worktreePath + sep));
@@ -456,11 +1192,35 @@ async function isMechanicalDepBumpRange(repoRoot, baseRef, tipSha) {
456
1192
  async function resolveGcClass(tree, integration, liveness, options) {
457
1193
  const baseClass = classifyForGc(tree, integration, liveness, options);
458
1194
  if (baseClass !== "hold") return { class: baseClass };
459
- if (tree.state !== "clean" || integration.state !== "unpushed") return { class: "hold" };
1195
+ if (tree.state !== "clean") return { class: "hold" };
460
1196
  const baseRef = options.defaultBranchRef ?? "origin/main";
461
- const isDepBump = await isMechanicalDepBumpRange(options.repoRoot, baseRef, options.tipSha);
462
- if (!isDepBump) return { class: "hold" };
463
- return { class: "reclaim", reclaimReason: "dep-bump" };
1197
+ if (integration.state === "unpushed" && await isMechanicalDepBumpRange(options.repoRoot, baseRef, options.tipSha)) {
1198
+ return { class: "reclaim", reclaimReason: "dep-bump" };
1199
+ }
1200
+ if (options.inBaseCheck !== false) {
1201
+ const inBase = await provenInBase(integration, liveness, options.repoRoot, baseRef, options.tipSha);
1202
+ if (inBase) return { class: "reclaim", reclaimReason: inBase };
1203
+ }
1204
+ return { class: "hold" };
1205
+ }
1206
+ var IN_BASE_PROMOTABLE = /* @__PURE__ */ new Set([
1207
+ "partial",
1208
+ "diverged",
1209
+ "pushed-no-pr",
1210
+ "unpushed",
1211
+ "local-only",
1212
+ "gone-unexplained"
1213
+ ]);
1214
+ async function provenInBase(integration, liveness, repoRoot, baseRef, tipSha) {
1215
+ if (!IN_BASE_PROMOTABLE.has(integration.state)) return null;
1216
+ if (liveness.state !== "idle" && liveness.state !== "daemon-unreachable") return null;
1217
+ try {
1218
+ const tip = await classifyTipAgainstBase(repoRoot, baseRef, tipSha);
1219
+ if (tip.status === "squash-merged" || tip.status === "patch-merged" || tip.status === "content-merged") return tip.status;
1220
+ return null;
1221
+ } catch {
1222
+ return null;
1223
+ }
464
1224
  }
465
1225
  async function linkedWorktreesOf(repoRoot) {
466
1226
  const worktrees = await listGitWorktrees(repoRoot);
@@ -645,7 +1405,8 @@ async function planGc(input) {
645
1405
  memo: input.memo,
646
1406
  defaultBranchRef: input.defaultBranchRef,
647
1407
  sessionsPath: input.sessionsPath,
648
- now: input.now
1408
+ now: input.now,
1409
+ noisePaths: input.noisePaths ?? DEFAULT_GC_NOISE_PATHS
649
1410
  });
650
1411
  entries.push(
651
1412
  await toPlanEntry(
@@ -669,8 +1430,19 @@ async function findWorktree(repoRoot, path) {
669
1430
  const worktrees = await linkedWorktreesOf(repoRoot);
670
1431
  return worktrees.find((w) => w.path === path) ?? null;
671
1432
  }
672
- async function reclaimOne(options, worktree, reclaimReason) {
1433
+ async function discardNoise(repoRoot, worktreePath, noise) {
1434
+ for (const path of noise) {
1435
+ const tracked = await execArgv("git", ["-C", worktreePath, "ls-files", "--error-unmatch", "--", path], repoRoot);
1436
+ if (tracked.exitCode === 0) {
1437
+ await execGit(repoRoot, ["-C", worktreePath, "checkout", "HEAD", "--", path]);
1438
+ } else {
1439
+ await rm(join(worktreePath, path), { force: true });
1440
+ }
1441
+ }
1442
+ }
1443
+ async function reclaimOne(options, worktree, reclaimReason, tree) {
673
1444
  try {
1445
+ if (tree?.state === "clean" && tree.noise?.length) await discardNoise(options.repoRoot, worktree.path, tree.noise);
674
1446
  await runTool({
675
1447
  tool: cleanupWorktreeTool,
676
1448
  candidates,
@@ -793,7 +1565,8 @@ async function applyOne(entry, options) {
793
1565
  memo: options.memo,
794
1566
  defaultBranchRef: options.defaultBranchRef,
795
1567
  sessionsPath: options.sessionsPath,
796
- now: options.now
1568
+ now: options.now,
1569
+ noisePaths: options.noisePaths ?? DEFAULT_GC_NOISE_PATHS
797
1570
  });
798
1571
  const resolved = await resolveGcClass(status.tree, status.integration, status.liveness, {
799
1572
  repoRoot: options.repoRoot,
@@ -811,7 +1584,7 @@ async function applyOne(entry, options) {
811
1584
  to: resolved.class
812
1585
  };
813
1586
  }
814
- if (resolved.class === "reclaim") return reclaimOne(options, fresh, resolved.reclaimReason);
1587
+ if (resolved.class === "reclaim") return reclaimOne(options, fresh, resolved.reclaimReason, status.tree);
815
1588
  return salvageOne(options, fresh);
816
1589
  }
817
1590
  async function applyGc(plan, options) {
@@ -835,7 +1608,8 @@ async function reclaimOneWorktree(worktreePath, options) {
835
1608
  memo: options.memo,
836
1609
  defaultBranchRef: options.defaultBranchRef,
837
1610
  sessionsPath: options.sessionsPath,
838
- now: options.now
1611
+ now: options.now,
1612
+ noisePaths: options.noisePaths ?? DEFAULT_GC_NOISE_PATHS
839
1613
  });
840
1614
  const entry = await toPlanEntry(
841
1615
  options.repoRoot,
@@ -849,6 +1623,6 @@ async function reclaimOneWorktree(worktreePath, options) {
849
1623
  return applyOne(entry, options);
850
1624
  }
851
1625
 
852
- export { ForgeUnavailableError, GhCliForgeClient, RestForgeClient, SALVAGE_ROOT, UnreachableForgeClient, applyGc, classifyForGc, createForgeClient, createProxyServer, isMechanicalDepBumpRange, parseGithubOwnerRepo, planGc, reclaimOneWorktree, resolveGcClass, salvageWorktree, startProxy };
1626
+ export { BRANCH_GC_SCOPES, BRANCH_GC_STATE_DIR, BRANCH_TRIAGE_VERDICTS, BRANCH_VERDICTS_PATH, BranchVerdictError, DEFAULT_BRANCH_GC_MIN_AGE_DAYS, DEFAULT_GC_NOISE_PATHS, FileBranchVerdictStore, ForgeUnavailableError, GhCliForgeClient, InMemoryBranchVerdictStore, RestForgeClient, SALVAGE_ROOT, UnreachableForgeClient, applyBranchGc, applyGc, branchReviewQueue, branchVerdictSchema, classifyForGc, classifyTip, classifyTipAgainstBase, createForgeClient, createLadderContext, createProxyServer, detectAnchor, isMechanicalDepBumpRange, parseGithubOwnerRepo, planBranchGc, planGc, readBranchGcRestoreLog, reclaimOneWorktree, recordBranchVerdict, resolveGcClass, salvageWorktree, startProxy, summarizeBranchGcPlan };
853
1627
  //# sourceMappingURL=index.mjs.map
854
1628
  //# sourceMappingURL=index.mjs.map