@agentproto/worktree 0.14.3 → 0.14.4

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/README.md CHANGED
@@ -234,7 +234,10 @@ in place rather than silently cleaned up.
234
234
  merged worktrees on a schedule, so a long-running workspace doesn't accumulate
235
235
  stale `_worktrees/*` trees and dead `wt/*` branches. It fires the `worktree_gc`
236
236
  tool (the daemon's MCP/HTTP surface over `planGc` / `applyGc` in `src/gc.ts`)
237
- with `apply: true`, `salvageDirty: false` on a daily cron (`0 4 * * *`, UTC).
237
+ with `apply: true`, `salvageDirty: false`, `wait: true` on a daily cron (`0 4 * * *`, UTC).
238
+ `wait: true` makes the cron run block for the real outcomes; without it a big
239
+ repo outlasts the tool's 25 s default `waitMs`, the run records a bare jobId
240
+ as success, and a failed apply never reaches `on_failure`.
238
241
 
239
242
  **It ships disabled (`enabled: false`)** — it registers but never fires until
240
243
  you turn it on. To activate it in a workspace:
package/dist/index.d.ts CHANGED
@@ -2141,6 +2141,17 @@ interface LadderContext {
2141
2141
  * plan's anchor detection asks the same question of every tip) — saves
2142
2142
  * the ladder's first `merge-base` per tip. */
2143
2143
  related?: ReadonlyMap<string, boolean>;
2144
+ /** Tip sha → `git merge-base baseSha tip`, filled by the same pass as
2145
+ * `related` — saves the ladder's second `merge-base` per related tip. */
2146
+ mergeBases?: ReadonlyMap<string, string>;
2147
+ /**
2148
+ * Memoized `git patch-id --stable` of non-merge commits (the key `git
2149
+ * cherry` matches on), batched and shared across tips: `git cherry` re-did
2150
+ * the patch-id of every base commit since the merge-base for EVERY tip.
2151
+ * `null` for a commit with no patch (empty diff) and `null` for the whole
2152
+ * call when git failed — both mean "ask `git cherry` itself".
2153
+ */
2154
+ patchIds?: (shas: readonly string[]) => Promise<Map<string, string | null> | null>;
2144
2155
  /**
2145
2156
  * Memoized set of every commit reachable from `cmp` (at most two distinct
2146
2157
  * values: `baseSha` and the anchor) — ONE `git rev-list <cmp>` replaces one
@@ -2149,7 +2160,7 @@ interface LadderContext {
2149
2160
  */
2150
2161
  ancestorsOf?: (cmp: string) => Promise<Set<string> | null>;
2151
2162
  }
2152
- declare function createLadderContext(repoRoot: string, baseRef: string, anchor: string | null, related?: ReadonlyMap<string, boolean>): Promise<LadderContext>;
2163
+ declare function createLadderContext(repoRoot: string, baseRef: string, anchor: string | null, related?: ReadonlyMap<string, boolean>, mergeBases?: ReadonlyMap<string, string>): Promise<LadderContext>;
2153
2164
  /**
2154
2165
  * Run the ladder for one tip against `ctx`'s base. Pure read. A tip with no
2155
2166
  * history in common with base is checked against the anchor when there is one
@@ -2173,7 +2184,9 @@ declare function classifyTipAgainstBase(repoRoot: string, baseRef: string, tip:
2173
2184
  declare function detectAnchor(repoRoot: string, baseSha: string, tips: readonly string[],
2174
2185
  /** Filled with every tip's answer (`true` = shares history with base) for
2175
2186
  * the ladder to reuse — see {@link LadderContext.related}. */
2176
- related?: Map<string, boolean>): Promise<string | null>;
2187
+ related?: Map<string, boolean>,
2188
+ /** Filled with `merge-base baseSha tip` for every related tip — see {@link LadderContext.mergeBases}. */
2189
+ mergeBases?: Map<string, string>): Promise<string | null>;
2177
2190
  declare const DEFAULT_BRANCH_GC_MIN_AGE_DAYS = 3;
2178
2191
  interface PlanBranchGcInput {
2179
2192
  repoRoot: string;
package/dist/index.mjs CHANGED
@@ -503,7 +503,7 @@ async function indexTree(repoRoot, sha) {
503
503
  const names = new Set([...byPath.keys()].map(basenameOf));
504
504
  return { byPath, blobs: new Set(byPath.values()), names };
505
505
  }
506
- async function createLadderContext(repoRoot, baseRef, anchor, related) {
506
+ async function createLadderContext(repoRoot, baseRef, anchor, related, mergeBases) {
507
507
  const baseSha = (await gitOk(repoRoot, ["rev-parse", "--verify", `${baseRef}^{commit}`])).trim();
508
508
  const baseTree = (await gitOk(repoRoot, ["rev-parse", `${baseSha}^{tree}`])).trim();
509
509
  let idx = null;
@@ -522,6 +522,33 @@ async function createLadderContext(repoRoot, baseRef, anchor, related) {
522
522
  }
523
523
  return p;
524
524
  };
525
+ const patchIdCache = /* @__PURE__ */ new Map();
526
+ const patchIds = async (shas) => {
527
+ const fresh = [...new Set(shas)].filter((sha) => !patchIdCache.has(sha));
528
+ if (fresh.length) {
529
+ const settle = /* @__PURE__ */ new Map();
530
+ for (const sha of fresh) {
531
+ const p = new Promise((resolve4, reject) => settle.set(sha, { resolve: resolve4, reject }));
532
+ p.catch(() => patchIdCache.delete(sha));
533
+ patchIdCache.set(sha, p);
534
+ }
535
+ await pool(chunk(fresh, PATCH_ID_BATCH), 2, async (batch) => {
536
+ try {
537
+ const got = await patchIdsOf(repoRoot, batch);
538
+ for (const sha of batch) settle.get(sha)?.resolve(got.get(sha) ?? null);
539
+ } catch (err) {
540
+ for (const sha of batch) settle.get(sha)?.reject(err);
541
+ }
542
+ });
543
+ }
544
+ try {
545
+ const out = /* @__PURE__ */ new Map();
546
+ for (const sha of shas) out.set(sha, await patchIdCache.get(sha) ?? null);
547
+ return out;
548
+ } catch {
549
+ return null;
550
+ }
551
+ };
525
552
  return {
526
553
  repoRoot,
527
554
  baseSha,
@@ -529,11 +556,96 @@ async function createLadderContext(repoRoot, baseRef, anchor, related) {
529
556
  anchor,
530
557
  index: () => idx ??= indexTree(repoRoot, baseSha),
531
558
  ancestorsOf,
532
- ...related ? { related } : {}
559
+ patchIds,
560
+ ...related ? { related } : {},
561
+ ...mergeBases ? { mergeBases } : {}
533
562
  };
534
563
  }
535
- async function sharesHistory(repoRoot, baseSha, tip) {
536
- return (await git(repoRoot, ["merge-base", baseSha, tip])).exitCode === 0;
564
+ var PATCH_ID_BATCH = 500;
565
+ function patchIdsOf(repoRoot, shas) {
566
+ return new Promise((resolvePromise, reject) => {
567
+ const log = spawn(
568
+ "git",
569
+ [
570
+ "-C",
571
+ repoRoot,
572
+ "-c",
573
+ "diff.noprefix=false",
574
+ "-c",
575
+ "diff.mnemonicPrefix=false",
576
+ "log",
577
+ "--no-walk=unsorted",
578
+ "--stdin",
579
+ "-p",
580
+ "--no-color",
581
+ "--no-renames",
582
+ "--no-textconv",
583
+ "--no-ext-diff"
584
+ ],
585
+ { cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"] }
586
+ );
587
+ const pid = spawn("git", ["-C", repoRoot, "patch-id", "--stable"], { cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"] });
588
+ let out = "";
589
+ let logCode = null;
590
+ let pidCode = null;
591
+ let failed = false;
592
+ const fail = (err) => {
593
+ if (failed) return;
594
+ failed = true;
595
+ log.kill();
596
+ pid.kill();
597
+ reject(err instanceof Error ? err : new Error(String(err)));
598
+ };
599
+ const done = () => {
600
+ if (failed || logCode === null || pidCode === null) return;
601
+ if (logCode !== 0 || pidCode !== 0) return fail(new Error(`patch-id batch failed (log exit ${logCode}, patch-id exit ${pidCode})`));
602
+ const ids = /* @__PURE__ */ new Map();
603
+ for (const line of out.split("\n")) {
604
+ const [id, commit] = line.split(" ");
605
+ if (id && commit) ids.set(commit, id);
606
+ }
607
+ resolvePromise(ids);
608
+ };
609
+ log.stdout.pipe(pid.stdin);
610
+ log.stdout.on("error", fail);
611
+ pid.stdin.on("error", () => {
612
+ });
613
+ pid.stdout.on("data", (d) => out += d.toString("utf8"));
614
+ log.stderr.resume();
615
+ pid.stderr.resume();
616
+ log.on("error", fail);
617
+ pid.on("error", fail);
618
+ log.on("close", (code) => {
619
+ logCode = code ?? -1;
620
+ done();
621
+ });
622
+ pid.on("close", (code) => {
623
+ pidCode = code ?? -1;
624
+ done();
625
+ });
626
+ log.stdin.on("error", () => {
627
+ });
628
+ log.stdin.end(shas.join("\n") + "\n");
629
+ });
630
+ }
631
+ async function allPatchEquivalent(ctx, cmp, tip) {
632
+ if (!ctx.patchIds) return null;
633
+ const lines = async (range) => (await gitOk(ctx.repoRoot, ["rev-list", "--no-merges", range])).split("\n").filter(Boolean);
634
+ const [tipOnly, upstreamOnly] = await Promise.all([lines(`${cmp}..${tip}`), lines(`${tip}..${cmp}`)]);
635
+ const ids = await ctx.patchIds([...tipOnly, ...upstreamOnly]);
636
+ if (!ids) return null;
637
+ const upstream = /* @__PURE__ */ new Set();
638
+ for (const sha of upstreamOnly) {
639
+ const id = ids.get(sha);
640
+ if (id) upstream.add(id);
641
+ }
642
+ let unknown = false;
643
+ for (const sha of tipOnly) {
644
+ const id = ids.get(sha);
645
+ if (!id) unknown = true;
646
+ else if (!upstream.has(id)) return false;
647
+ }
648
+ return unknown ? null : true;
537
649
  }
538
650
  var NULL_SHA_RE = /^0+$/;
539
651
  async function changedBlobs(repoRoot, mergeBase, tip) {
@@ -592,7 +704,13 @@ async function coverage(ctx, tip, mergeBase) {
592
704
  }
593
705
  async function classifyTip(ctx, tip) {
594
706
  const { repoRoot, baseSha, baseTree, anchor } = ctx;
595
- const related = ctx.related?.get(tip) ?? await sharesHistory(repoRoot, baseSha, tip);
707
+ let relatedMergeBase = ctx.mergeBases?.get(tip);
708
+ let related = ctx.related?.get(tip);
709
+ if (related === void 0) {
710
+ const res = await git(repoRoot, ["merge-base", baseSha, tip]);
711
+ related = res.exitCode === 0;
712
+ if (related) relatedMergeBase = res.stdout.trim();
713
+ }
596
714
  if (!related && !anchor) return { status: "unmerged", history: "unrelated", conflicts: true, ahead: null, behind: null };
597
715
  const history = related ? "current" : "pre-rewrite";
598
716
  const cmp = related ? baseSha : anchor;
@@ -604,13 +722,13 @@ async function classifyTip(ctx, tip) {
604
722
  const counts = (await gitOk(repoRoot, ["rev-list", "--left-right", "--count", `${cmp}...${tip}`])).trim().split(/\s+/);
605
723
  const behind = Number(counts[0]);
606
724
  const ahead = Number(counts[1]);
607
- const mergeBase = (await git(repoRoot, ["merge-base", cmp, tip])).stdout.trim();
725
+ const mergeBase = (related ? relatedMergeBase : void 0) ?? (await git(repoRoot, ["merge-base", cmp, tip])).stdout.trim();
608
726
  const mtArgs = related ? [baseSha, tip] : [`--merge-base=${mergeBase}`, baseSha, tip];
609
727
  const mt = await git(repoRoot, ["merge-tree", "--write-tree", "--no-messages", ...mtArgs]);
610
728
  const mergedTree = (mt.stdout.split("\n")[0] ?? "").trim();
611
729
  const conflicts = mt.exitCode !== 0;
612
730
  if (!conflicts && mergedTree === baseTree) return { status: "squash-merged", history, ahead, behind, conflicts };
613
- if (conflicts) {
731
+ if (conflicts && await allPatchEquivalent(ctx, cmp, tip) !== false) {
614
732
  const cherry = await gitOk(repoRoot, ["cherry", cmp, tip]);
615
733
  if (!cherry.split("\n").some((l) => l.startsWith("+"))) return { status: "patch-merged", history, ahead, behind, conflicts };
616
734
  }
@@ -653,14 +771,18 @@ async function classifyTipAgainstBase(repoRoot, baseRef, tip) {
653
771
  }
654
772
  return classifyTip(await ctx, tip);
655
773
  }
656
- async function detectAnchor(repoRoot, baseSha, tips, related) {
774
+ async function detectAnchor(repoRoot, baseSha, tips, related, mergeBases) {
657
775
  const roots = (await gitOk(repoRoot, ["rev-list", "--max-parents=0", baseSha])).trim().split("\n").filter(Boolean);
658
776
  if (roots.length !== 1) return null;
659
777
  const root = roots[0];
660
778
  const unique = [...new Set(tips)];
661
- const shares = await pool(unique, 8, (sha) => sharesHistory(repoRoot, baseSha, sha));
662
- const unrelated = unique.filter((_, i) => !shares[i]);
663
- if (related) unique.forEach((sha, i) => related.set(sha, shares[i]));
779
+ const bases = await pool(unique, 8, async (sha) => {
780
+ const res = await git(repoRoot, ["merge-base", baseSha, sha]);
781
+ return res.exitCode === 0 ? res.stdout.trim() : null;
782
+ });
783
+ const unrelated = unique.filter((_, i) => bases[i] === null);
784
+ if (related) unique.forEach((sha, i) => related.set(sha, bases[i] !== null));
785
+ if (mergeBases) unique.forEach((sha, i) => bases[i] !== null && mergeBases.set(sha, bases[i]));
664
786
  if (!unrelated.length) return null;
665
787
  const rootDate = (await gitOk(repoRoot, ["log", "-1", "--format=%cI", root])).trim();
666
788
  const since = new Date(new Date(rootDate).getTime() - 3 * 864e5).toISOString();
@@ -835,10 +957,11 @@ async function snapshot(input) {
835
957
  const baseSha = (await gitOk(input.repoRoot, ["rev-parse", "--verify", `${input.base}^{commit}`])).trim();
836
958
  let anchor;
837
959
  const related = /* @__PURE__ */ new Map();
838
- if (input.anchor === "auto") anchor = await detectAnchor(input.repoRoot, baseSha, refs.map((r) => r.sha), related);
960
+ const mergeBases = /* @__PURE__ */ new Map();
961
+ if (input.anchor === "auto") anchor = await detectAnchor(input.repoRoot, baseSha, refs.map((r) => r.sha), related, mergeBases);
839
962
  else if (input.anchor) anchor = (await gitOk(input.repoRoot, ["rev-parse", "--verify", `${input.anchor}^{commit}`])).trim();
840
963
  else anchor = null;
841
- const ctx = await createLadderContext(input.repoRoot, baseSha, anchor, related);
964
+ const ctx = await createLadderContext(input.repoRoot, baseSha, anchor, related, mergeBases);
842
965
  const remoteRefs = refs.filter((r) => r.kind === "remote");
843
966
  const MAX_REACHABLE = 5e5;
844
967
  const revListSet = async (args) => {