@brainervirus/workit-core 7.3.0 → 7.5.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.
@@ -9,14 +9,22 @@
9
9
  // PRs that would conflict once the first lands are caught before either
10
10
  // does. A pairwise conflict is charged to the slice that lands later; the
11
11
  // earlier one stays landable.
12
+ // - case: a file or directory it adds that the trunk spells differently only
13
+ // in case (one path on macOS and Windows checkouts).
14
+ // A slice that already landed (fanout-landed.ts: its PR merged, squash
15
+ // merges included, or git shows its change on the trunk) is done: it is not
16
+ // checked, and slices that depend on it no longer wait for it.
12
17
  // The landing order puts dependencies first, then the plan's order. A slice
13
18
  // is ready when none of the above fired and every slice it depends on is
14
- // ready. Verdicts are reported (`workit ledger check`) but gated by
19
+ // ready or landed. Verdicts are reported (`workit ledger check`) but gated by
15
20
  // `workit pr merge`, not here. Refs are read as they are locally: fetch first.
21
+ import type { ResolvedForge } from "./forge/resolve";
16
22
  import { checkVerdicts, readLedger, type ReadRow } from "./ledger";
17
23
  import {
18
24
  FANOUT_VERSION,
19
25
  branchRef,
26
+ caseClash,
27
+ caseIndex,
20
28
  fanoutFail,
21
29
  gitRun,
22
30
  inScope,
@@ -27,8 +35,15 @@ import {
27
35
  type FanoutResult,
28
36
  type Slice,
29
37
  } from "./fanout";
38
+ import {
39
+ detectLanding,
40
+ forgeBreaker,
41
+ sliceStart,
42
+ type LandingContext,
43
+ type SliceLanding,
44
+ } from "./fanout-landed";
30
45
 
31
- export type SliceStatus = "ready" | "blocked";
46
+ export type SliceStatus = "ready" | "blocked" | "landed";
32
47
 
33
48
  export type SliceCheck = {
34
49
  id: string;
@@ -38,10 +53,14 @@ export type SliceCheck = {
38
53
  status: SliceStatus;
39
54
  changed: number;
40
55
  outOfScope: string[];
56
+ /** Paths it adds that the trunk spells differently only in case: `ours ~ trunk's`. */
57
+ caseCollisions: string[];
41
58
  trunkConflicts: string[];
42
59
  siblingConflicts: Array<{ with: string; paths: string[] }>;
43
60
  waitsFor: string[];
44
61
  verdict: { accepted: boolean; basis: string };
62
+ /** How it landed, when it did (then nothing else was checked). */
63
+ landed: { how: string; pr: number | null } | null;
45
64
  reasons: string[];
46
65
  };
47
66
 
@@ -68,6 +87,13 @@ export type CheckOutcome = {
68
87
  notes: string[];
69
88
  };
70
89
 
90
+ export type CheckOptions = {
91
+ only?: readonly string[];
92
+ trunkRef?: string | null;
93
+ /** Asks the forge whether a slice's PR merged; null checks with git alone. */
94
+ forge?: ResolvedForge | null;
95
+ };
96
+
71
97
  const SAFE = (value: string | null): value is string => Boolean(value) && !value?.startsWith("-");
72
98
 
73
99
  /** Paths both sides change incompatibly, [] when clean, or an error. */
@@ -97,7 +123,7 @@ const isAncestor = (cwd: string, a: string, b: string): boolean =>
97
123
  export function checkFanout(
98
124
  cwd: string,
99
125
  plan: FanoutFile,
100
- options: { only?: readonly string[]; trunkRef?: string | null } = {},
126
+ options: CheckOptions = {},
101
127
  ): FanoutResult<CheckOutcome> {
102
128
  const unknown = (options.only ?? []).filter((id) => !plan.slices.some((s) => s.id === id));
103
129
  if (unknown.length)
@@ -119,10 +145,22 @@ export function checkFanout(
119
145
  const ledger = readLedger(cwd);
120
146
  const rows: readonly ReadRow[] = ledger.ok ? ledger.value.rows : [];
121
147
 
148
+ const breaker = forgeBreaker(options.forge ?? null);
149
+ const landings = sliceLandings(cwd, plan, trunk, tips, rows, breaker.forge);
150
+ const trunkCase = caseIndex(
151
+ nulList(gitRun(cwd, ["ls-tree", "-r", "-z", "--name-only", trunk]).stdout),
152
+ );
122
153
  const checks = new Map<string, SliceCheck>();
123
154
  const changedBy = new Map<string, string[]>();
124
- for (const slice of plan.slices)
125
- checks.set(slice.id, inspect(cwd, plan, slice, trunk, tips, rows, changedBy));
155
+ for (const slice of plan.slices) {
156
+ const landing = landings.get(slice.id) as SliceLanding;
157
+ checks.set(
158
+ slice.id,
159
+ landing.landed
160
+ ? landedCheck(slice, tips.get(slice.id) ?? null, landing)
161
+ : inspect(cwd, plan, slice, { trunk, trunkCase, tips, landings, rows }, changedBy),
162
+ );
163
+ }
126
164
 
127
165
  // Pairwise siblings: skip a pair where one contains the other (a stacked child).
128
166
  const conflicts: SiblingConflict[] = [];
@@ -132,6 +170,7 @@ export function checkFanout(
132
170
  const second = order[j];
133
171
  const a = tips.get(first);
134
172
  const b = tips.get(second);
173
+ if (landings.get(first)?.landed || landings.get(second)?.landed) continue;
135
174
  if (!a || !b || isAncestor(cwd, a, b) || isAncestor(cwd, b, a)) continue;
136
175
  const check = checks.get(second) as SliceCheck;
137
176
  const theirs = new Map(
@@ -173,8 +212,9 @@ export function checkFanout(
173
212
 
174
213
  for (const id of order) {
175
214
  const check = checks.get(id) as SliceCheck;
215
+ if (check.status === "landed") continue;
176
216
  check.waitsFor = (byId.get(id)?.dependsOn ?? []).filter(
177
- (dep) => checks.get(dep)?.status !== "ready",
217
+ (dep) => checks.get(dep)?.status !== "ready" && checks.get(dep)?.status !== "landed",
178
218
  );
179
219
  if (check.waitsFor.length) check.reasons.push(`waits for ${check.waitsFor.join(", ")}`);
180
220
  check.status = check.reasons.length ? "blocked" : "ready";
@@ -184,7 +224,7 @@ export function checkFanout(
184
224
  const slices = order
185
225
  .filter((id) => !selected || selected.has(id))
186
226
  .map((id) => checks.get(id) as SliceCheck);
187
- const ready = slices.every((check) => check.status === "ready");
227
+ const ready = slices.every((check) => check.status !== "blocked");
188
228
  const blocked = slices.find((check) => check.status === "blocked");
189
229
  const notes =
190
230
  plan.v === FANOUT_VERSION
@@ -192,9 +232,21 @@ export function checkFanout(
192
232
  : [
193
233
  `plan file version ${plan.v} is not ${FANOUT_VERSION}; read leniently (re-plan to rewrite it)`,
194
234
  ];
235
+ const forgeErrors = [...landings.values()].flatMap((landing) =>
236
+ landing.forgeError ? [landing.forgeError] : [],
237
+ );
238
+ if (!options.forge)
239
+ notes.push("landed slices detected with git only (no forge): a squash merge may be missed");
240
+ else if (breaker.down())
241
+ notes.push(`forge went down (${breaker.down()}); git alone decided the remaining slices`);
242
+ else if (forgeErrors.length)
243
+ notes.push(`forge lookups failed (${forgeErrors[0]}); git alone decided those slices`);
244
+ const toLand = slices.filter((check) => check.status === "ready").map((check) => check.id);
195
245
  const next = blocked
196
246
  ? unblockFor(blocked, plan)
197
- : `land in this order: ${slices.map((check) => check.id).join(", ")} (workit pr merge each once verified; stacked slices with workit stack land)`;
247
+ : toLand.length
248
+ ? `land in this order: ${toLand.join(", ")} (workit pr merge each once verified; stacked slices with workit stack land)`
249
+ : "every slice has landed";
198
250
  return {
199
251
  ok: true,
200
252
  data: {
@@ -216,40 +268,132 @@ export function checkFanout(
216
268
  const resolves = (cwd: string, ref: string): boolean =>
217
269
  gitRun(cwd, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]).ok;
218
270
 
219
- function inspect(
271
+ /** Landed state per slice, dependencies first (a child's base is its parent's merged head). */
272
+ export function sliceLandings(
220
273
  cwd: string,
221
274
  plan: FanoutFile,
222
- slice: Slice,
223
275
  trunk: string,
224
276
  tips: ReadonlyMap<string, string | null>,
225
277
  rows: readonly ReadRow[],
278
+ forge: ResolvedForge | null,
279
+ ): Map<string, SliceLanding> {
280
+ const ctx: LandingContext = { cwd, trunk, forge };
281
+ const out = new Map<string, SliceLanding>();
282
+ const byId = new Map(plan.slices.map((slice) => [slice.id, slice]));
283
+ for (const id of landingOrder(plan.slices)) {
284
+ const slice = byId.get(id) as Slice;
285
+ const parent = plan.slices.find((other) => other.branch === slice.base);
286
+ const parentHead = parent
287
+ ? (tips.get(parent.id) ?? out.get(parent.id)?.landedHead ?? null)
288
+ : null;
289
+ out.set(
290
+ id,
291
+ detectLanding(ctx, slice, {
292
+ head: tips.get(id) ?? null,
293
+ createdFrom: sliceStart(cwd, rows, plan.name, slice),
294
+ parentHead,
295
+ fanout: plan.name,
296
+ since: plan.createdAt || null,
297
+ rows,
298
+ }),
299
+ );
300
+ }
301
+ return out;
302
+ }
303
+
304
+ const blank = (slice: Slice, head: string | null): SliceCheck => ({
305
+ id: slice.id,
306
+ branch: slice.branch,
307
+ head,
308
+ base: slice.base,
309
+ status: "blocked",
310
+ changed: 0,
311
+ outOfScope: [],
312
+ caseCollisions: [],
313
+ trunkConflicts: [],
314
+ siblingConflicts: [],
315
+ waitsFor: [],
316
+ verdict: { accepted: false, basis: "missing" },
317
+ landed: null,
318
+ reasons: [],
319
+ });
320
+
321
+ function landedCheck(slice: Slice, head: string | null, landing: SliceLanding): SliceCheck {
322
+ return {
323
+ ...blank(slice, head),
324
+ status: "landed",
325
+ landed: { how: landing.how ?? "pr_merged", pr: landing.pr?.number ?? null },
326
+ };
327
+ }
328
+
329
+ type InspectContext = {
330
+ trunk: string;
331
+ trunkCase: ReadonlyMap<string, ReadonlySet<string>>;
332
+ tips: ReadonlyMap<string, string | null>;
333
+ landings: ReadonlyMap<string, SliceLanding>;
334
+ rows: readonly ReadRow[];
335
+ };
336
+
337
+ /** Files at `rev`, or null when it cannot be listed. */
338
+ const treeFiles = (cwd: string, rev: string | null): Set<string> | null => {
339
+ if (!rev) return null;
340
+ const run = gitRun(cwd, ["ls-tree", "-r", "-z", "--name-only", rev]);
341
+ return run.ok ? new Set(nulList(run.stdout)) : null;
342
+ };
343
+
344
+ /**
345
+ * Paths the slice adds or changes that the trunk spells differently only in
346
+ * case, unless the slice removed the trunk's spelling (a case rename).
347
+ */
348
+ function trunkCaseCollisions(
349
+ cwd: string,
350
+ ctx: InspectContext,
351
+ head: string,
352
+ changed: readonly string[],
353
+ ): string[] {
354
+ const headFiles = treeFiles(cwd, head);
355
+ const forkFiles = treeFiles(
356
+ cwd,
357
+ gitRun(cwd, ["merge-base", head, ctx.trunk]).stdout.trim() || null,
358
+ );
359
+ if (!headFiles) return [];
360
+ const present = (files: ReadonlySet<string> | null, prefix: string): boolean =>
361
+ Boolean(
362
+ files && (files.has(prefix) || [...files].some((file) => file.startsWith(`${prefix}/`))),
363
+ );
364
+ const out = new Set<string>();
365
+ for (const file of changed) {
366
+ if (!headFiles.has(file)) continue;
367
+ const clash = caseClash(ctx.trunkCase, file);
368
+ if (!clash) continue;
369
+ // The slice deleted the trunk's spelling it had: a deliberate case rename.
370
+ if (present(forkFiles, clash.trunk) && !present(headFiles, clash.trunk)) continue;
371
+ out.add(`${clash.path} ~ ${clash.trunk}`);
372
+ }
373
+ return [...out].toSorted();
374
+ }
375
+
376
+ function inspect(
377
+ cwd: string,
378
+ plan: FanoutFile,
379
+ slice: Slice,
380
+ ctx: InspectContext,
226
381
  changedBy: Map<string, string[]>,
227
382
  ): SliceCheck {
383
+ const { trunk, tips, rows } = ctx;
228
384
  const head = tips.get(slice.id) ?? null;
229
- const check: SliceCheck = {
230
- id: slice.id,
231
- branch: slice.branch,
232
- head,
233
- base: slice.base,
234
- status: "blocked",
235
- changed: 0,
236
- outOfScope: [],
237
- trunkConflicts: [],
238
- siblingConflicts: [],
239
- waitsFor: [],
240
- verdict: { accepted: false, basis: "missing" },
241
- reasons: [],
242
- };
385
+ const check = blank(slice, head);
243
386
  if (!head) {
244
- check.reasons.push(`branch ${slice.branch} not found (not started, landed, or not fetched)`);
387
+ check.reasons.push(`branch ${slice.branch} not found (not started, or not fetched)`);
245
388
  return check;
246
389
  }
247
390
  const parent = plan.slices.find((other) => other.branch === slice.base);
391
+ // A landed parent whose branch is gone: diff against the head that merged.
248
392
  const baseRef =
249
393
  slice.base === plan.trunk
250
394
  ? trunk
251
395
  : parent
252
- ? (tips.get(parent.id) ?? null)
396
+ ? (tips.get(parent.id) ?? ctx.landings.get(parent.id)?.landedHead ?? null)
253
397
  : branchRef(cwd, slice.base);
254
398
  if (!SAFE(baseRef)) {
255
399
  check.reasons.push(`base ${slice.base} not found`);
@@ -267,6 +411,11 @@ function inspect(
267
411
  check.outOfScope = changed.filter((file) => !inScope(plan, slice, file)).toSorted();
268
412
  if (check.outOfScope.length)
269
413
  check.reasons.push(`edits outside its scope: ${check.outOfScope.join(", ")}`);
414
+ check.caseCollisions = trunkCaseCollisions(cwd, ctx, head, changed);
415
+ if (check.caseCollisions.length)
416
+ check.reasons.push(
417
+ `differs only in case from ${plan.trunk}: ${check.caseCollisions.join(", ")}`,
418
+ );
270
419
  const trunkResult = mergeConflicts(cwd, trunk, head);
271
420
  if (Array.isArray(trunkResult)) {
272
421
  check.trunkConflicts = trunkResult;
@@ -280,9 +429,11 @@ function inspect(
280
429
 
281
430
  function unblockFor(check: SliceCheck, plan: FanoutFile): string {
282
431
  if (!check.head)
283
- return `start or fetch ${check.branch}, then workit fanout check; if it landed, re-plan without slice ${check.id}`;
432
+ return `start or fetch ${check.branch}, then workit fanout check (a landed slice is detected through its merged PR when the forge is reachable)`;
284
433
  if (check.outOfScope.length)
285
434
  return `move ${check.outOfScope.join(", ")} off ${check.branch} (a follow-up slice), or widen slice ${check.id}'s scope with workit fanout plan, then workit fanout check`;
435
+ if (check.caseCollisions.length)
436
+ return `rename to ${plan.trunk}'s spelling on ${check.branch} (git mv), then workit fanout check`;
286
437
  if (check.trunkConflicts.length)
287
438
  return `rebase ${check.branch} onto ${plan.trunk} and resolve ${check.trunkConflicts.join(", ")}, then workit fanout check`;
288
439
  if (check.siblingConflicts.length) {
@@ -0,0 +1,313 @@
1
+ // Has a fanout slice landed? Shared by `fanout status` and `fanout check`, so
2
+ // a landed slice is done for both and its dependents stop waiting.
3
+ //
4
+ // With the forge, the slice branch's PR reads merged. A squash merge never
5
+ // makes the branch an ancestor of the trunk, so this is the only reliable
6
+ // signal; it holds while the branch has nothing newer than the merged head.
7
+ // Without the forge (offline, no gh/glab, no PR yet), git decides:
8
+ // - on_trunk: the branch tip is on the trunk and its reflog proves commits of
9
+ // its own (a commit, cherry-pick or rebase made on it, inside start..head);
10
+ // a branch only fast-forwarded to a newer trunk or parent has none;
11
+ // - squash_patch: the branch's change has the same patch-id as one trunk
12
+ // commit (a squash merge that applied without the trunk touching the same
13
+ // lines). Anything else reads as not landed.
14
+ // A merged PR whose branch is gone counts only when the ledger links that
15
+ // branch to this slice (a worktree row, or a row recorded on its merged
16
+ // head), so a reused branch name from older work is not taken for this slice.
17
+ // A landing whose changed paths read on the trunk exactly as before it (a
18
+ // revert) is not landed; the note says so.
19
+ // The forge is asked through a breaker: after the first unavailable answer
20
+ // (gh/glab missing, not logged in, timed out) it is not asked again in the
21
+ // same run, and git decides the rest.
22
+ import { spawnSync } from "node:child_process";
23
+ import { GIT_TIMEOUTS, hasCommit, mergeBase, patchId } from "./git/rev";
24
+ import type { ResolvedForge } from "./forge/resolve";
25
+ import type { ForgeResult, PrRef } from "./forge/types";
26
+ import { gitRun, type Slice } from "./fanout";
27
+
28
+ export type LandedHow = "pr_merged" | "on_trunk" | "squash_patch";
29
+
30
+ export type SliceLanding = {
31
+ landed: boolean;
32
+ how: LandedHow | null;
33
+ /** The slice's PR when the forge was asked and has one (open, closed or merged). */
34
+ pr: PrRef | null;
35
+ /** The merged head: a stacked child's diff base once the branch is gone. */
36
+ landedHead: string | null;
37
+ /** Why the forge could not answer for this slice; null when it did or was not asked. */
38
+ forgeError: string | null;
39
+ note: string | null;
40
+ };
41
+
42
+ export type LandingContext = {
43
+ cwd: string;
44
+ /** The trunk ref (e.g. refs/remotes/origin/main). */
45
+ trunk: string;
46
+ forge: ResolvedForge | null;
47
+ /** Trunk patch-ids per fork point, filled lazily. */
48
+ cache?: Map<string, Set<string>>;
49
+ };
50
+
51
+ export type ForgeBreaker = {
52
+ /** The forge whose calls stop after the first unavailable answer; null when there is none. */
53
+ forge: ResolvedForge | null;
54
+ /** Why it went down during this run, else null. */
55
+ down: () => string | null;
56
+ };
57
+
58
+ /**
59
+ * Wrap the forge so a missing, logged-out or hanging gh/glab costs one call
60
+ * per run, not one timeout per slice: after the first `unavailable` answer
61
+ * every later call fails at once with the same error.
62
+ */
63
+ export function forgeBreaker(resolved: ResolvedForge | null): ForgeBreaker {
64
+ if (!resolved) return { forge: null, down: () => null };
65
+ let tripped: string | null = null;
66
+ const guard =
67
+ <A extends unknown[], T>(call: (...args: A) => ForgeResult<T>) =>
68
+ (...args: A): ForgeResult<T> => {
69
+ if (tripped) return { ok: false, code: "unavailable", error: tripped };
70
+ const result = call(...args);
71
+ if (!result.ok && result.code === "unavailable") tripped = result.error;
72
+ return result;
73
+ };
74
+ const inner = resolved.forge;
75
+ const forge = Object.create(inner) as typeof inner;
76
+ forge.findPr = guard(inner.findPr.bind(inner));
77
+ forge.prStatus = guard(inner.prStatus.bind(inner));
78
+ return { forge: { ...resolved, forge }, down: () => tripped };
79
+ }
80
+
81
+ const isAncestor = (cwd: string, a: string, b: string): boolean =>
82
+ gitRun(cwd, ["merge-base", "--is-ancestor", a, b]).ok;
83
+
84
+ /** Where a local branch began (its oldest reflog entry), or null. */
85
+ export function branchCreatedAt(cwd: string, branch: string): string | null {
86
+ if (!branch || branch.startsWith("-")) return null;
87
+ const log = gitRun(cwd, ["log", "-g", "--format=%H", `refs/heads/${branch}`, "--"]);
88
+ if (!log.ok) return null;
89
+ return log.stdout.trim().split("\n").findLast(Boolean) ?? null;
90
+ }
91
+
92
+ /**
93
+ * Does the branch's reflog show a commit made on it inside `start..head`?
94
+ * A commit, cherry-pick or rebased pick counts; a fast-forward, reset or
95
+ * branch creation does not, and neither does a rebase that only moved it.
96
+ */
97
+ export function hasOwnCommits(
98
+ cwd: string,
99
+ branch: string,
100
+ start: string | null,
101
+ head: string,
102
+ ): boolean {
103
+ if (!branch || branch.startsWith("-")) return false;
104
+ const log = gitRun(cwd, ["log", "-g", "--format=%H%x00%gs", `refs/heads/${branch}`, "--"]);
105
+ if (!log.ok) return false;
106
+ for (const line of log.stdout.split("\n")) {
107
+ const [sha, subject = ""] = line.split("\0");
108
+ if (!sha) continue;
109
+ const made =
110
+ /^(?:commit(?: \((?:initial|amend)\))?|cherry-pick|rebase(?: -i)? \(pick\)):/u.test(subject);
111
+ const onto = /^rebase(?: -i)? \(finish\): \S+ onto ([0-9a-f]+)/u.exec(subject)?.[1];
112
+ if (!made && !(onto && !sha.startsWith(onto) && !onto.startsWith(sha))) continue;
113
+ if (sha !== head && !isAncestor(cwd, sha, head)) continue;
114
+ if (start && (sha === start || isAncestor(cwd, sha, start))) continue;
115
+ return true;
116
+ }
117
+ return false;
118
+ }
119
+
120
+ /**
121
+ * The landing was undone: every path the change touched reads on the trunk
122
+ * exactly as before it (`git revert`, or a manual undo).
123
+ */
124
+ function reverted(cwd: string, trunk: string, landedHead: string, start: string | null): boolean {
125
+ const fork = mergeBase(cwd, landedHead, trunk);
126
+ if (fork && fork !== landedHead) return unchangedSince(cwd, fork, landedHead, trunk);
127
+ // On the trunk already (merge or fast-forward): compare with where it began.
128
+ const before =
129
+ start && start !== landedHead && isAncestor(cwd, start, landedHead)
130
+ ? start
131
+ : gitRun(cwd, ["rev-parse", "--verify", "-q", `${landedHead}^1`]).stdout.trim();
132
+ return before ? unchangedSince(cwd, before, landedHead, trunk) : false;
133
+ }
134
+
135
+ function unchangedSince(cwd: string, before: string, after: string, trunk: string): boolean {
136
+ const paths = gitRun(cwd, ["diff", "--name-only", "--no-renames", "-z", before, after]);
137
+ const files = paths.ok ? paths.stdout.split("\0").filter(Boolean) : [];
138
+ if (files.length === 0) return false;
139
+ return gitRun(cwd, ["diff", "--quiet", before, trunk, "--", ...files]).ok;
140
+ }
141
+
142
+ const TRUNK_SCAN = 500;
143
+
144
+ /** Stable patch-ids of the trunk's own commits since `fork` (same pinned diff options as patchId). */
145
+ function trunkPatchIds(ctx: LandingContext, fork: string): Set<string> {
146
+ const cache = (ctx.cache ??= new Map());
147
+ const known = cache.get(fork);
148
+ if (known) return known;
149
+ const ids = new Set<string>();
150
+ const log = gitRun(ctx.cwd, [
151
+ "log",
152
+ "--no-merges",
153
+ `--max-count=${TRUNK_SCAN}`,
154
+ "-p",
155
+ "--format=commit %H",
156
+ "-U3",
157
+ "--diff-algorithm=myers",
158
+ "--indent-heuristic",
159
+ "--no-color",
160
+ "--no-ext-diff",
161
+ "--no-textconv",
162
+ "--no-renames",
163
+ "--full-index",
164
+ "--src-prefix=a/",
165
+ "--dst-prefix=b/",
166
+ `${fork}..${ctx.trunk}`,
167
+ "--",
168
+ ]);
169
+ if (log.ok && log.stdout.trim()) {
170
+ const run = spawnSync("git", ["patch-id", "--stable"], {
171
+ cwd: ctx.cwd,
172
+ input: log.stdout,
173
+ encoding: "utf8",
174
+ timeout: GIT_TIMEOUTS.local,
175
+ windowsHide: true,
176
+ maxBuffer: 64 * 1024 * 1024,
177
+ });
178
+ for (const line of (run.stdout ?? "").split("\n")) {
179
+ const id = line.trim().split(/\s+/u)[0];
180
+ if (id) ids.add(id);
181
+ }
182
+ }
183
+ cache.set(fork, ids);
184
+ return ids;
185
+ }
186
+
187
+ /** The ledger links `branch` to this slice: a worktree row, or a row recorded on `head`. */
188
+ function linked(
189
+ rows: readonly Record<string, unknown>[],
190
+ input: Pick<LandingInput, "fanout" | "since">,
191
+ slice: Pick<Slice, "id" | "branch">,
192
+ head: string | null,
193
+ ): boolean {
194
+ // Only rows from this run of the fanout: a plan of the same name made
195
+ // earlier must not lend its merged PRs to this one.
196
+ const since = input.since ? Date.parse(input.since) : Number.NaN;
197
+ return rows.some(
198
+ (row) =>
199
+ (Number.isNaN(since) || Date.parse(String(row.at)) >= since) &&
200
+ ((row.type === "fanout.worktree.created" &&
201
+ row.fanout === input.fanout &&
202
+ row.slice === slice.id) ||
203
+ (head !== null && row.branch === slice.branch && row.head === head)),
204
+ );
205
+ }
206
+
207
+ export type LandingInput = {
208
+ /** The branch tip; null when the branch is gone. */
209
+ head: string | null;
210
+ /** The commit it began at when known (reflog or the worktree ledger row). */
211
+ createdFrom: string | null;
212
+ /** Its dependency branch's tip (or merged head) for a stacked slice. */
213
+ parentHead: string | null;
214
+ fanout: string;
215
+ /** When this fanout plan was first made (FanoutFile.createdAt): older rows do not link. */
216
+ since: string | null;
217
+ rows: readonly Record<string, unknown>[];
218
+ };
219
+
220
+ /** Has `slice` landed on the trunk? */
221
+ export function detectLanding(
222
+ ctx: LandingContext,
223
+ slice: Pick<Slice, "id" | "branch">,
224
+ input: LandingInput,
225
+ ): SliceLanding {
226
+ const out: SliceLanding = {
227
+ landed: false,
228
+ how: null,
229
+ pr: null,
230
+ landedHead: null,
231
+ forgeError: null,
232
+ note: null,
233
+ };
234
+ const landed = (how: LandedHow, landedHead: string): SliceLanding => {
235
+ if (
236
+ !hasCommit(ctx.cwd, landedHead) ||
237
+ !reverted(ctx.cwd, ctx.trunk, landedHead, input.createdFrom)
238
+ )
239
+ return { ...out, landed: true, how, landedHead };
240
+ return {
241
+ ...out,
242
+ note: `landed (${how}${out.pr ? ` #${out.pr.number}` : ""}), then reverted on the trunk: its paths read as before it`,
243
+ };
244
+ };
245
+ const { head } = input;
246
+ if (ctx.forge) {
247
+ const found = ctx.forge.forge.findPr(slice.branch, {
248
+ owner: ctx.forge.headRepo.split("/")[0] ?? null,
249
+ projectId: ctx.forge.headProjectId,
250
+ sha: head,
251
+ });
252
+ if (!found.ok) out.forgeError = found.error;
253
+ else if (found.data) {
254
+ out.pr = found.data;
255
+ if (found.data.state === "merged") {
256
+ const merged = found.data.headSha;
257
+ const label = `#${found.data.number}`;
258
+ if (!head) {
259
+ if (!linked(input.rows, input, slice, merged)) {
260
+ out.pr = null;
261
+ out.note = `${slice.branch} is gone; merged ${label} is not linked to this slice in the ledger (an older branch of the same name?)`;
262
+ return out;
263
+ }
264
+ return merged ? landed("pr_merged", merged) : { ...out, landed: true, how: "pr_merged" };
265
+ }
266
+ if (!merged || head === merged || isAncestor(ctx.cwd, head, merged))
267
+ return landed("pr_merged", merged ?? head);
268
+ out.note = isAncestor(ctx.cwd, merged, head)
269
+ ? `${label} merged at ${merged.slice(0, 12)}; ${slice.branch} is ahead of it with newer commits`
270
+ : `${label} merged at ${merged.slice(0, 12)}; ${slice.branch} has diverged from it (rewritten after the merge)`;
271
+ return out;
272
+ }
273
+ }
274
+ }
275
+ if (!head) return out;
276
+ const fork = mergeBase(ctx.cwd, head, ctx.trunk);
277
+ if (fork === head) {
278
+ // On the trunk: landed only when commits were made on the branch itself.
279
+ return hasOwnCommits(ctx.cwd, slice.branch, input.createdFrom, head)
280
+ ? landed("on_trunk", head)
281
+ : out;
282
+ }
283
+ if (!fork) return out;
284
+ const trunkIds = trunkPatchIds(ctx, fork);
285
+ if (trunkIds.size === 0) return out;
286
+ const froms = [fork];
287
+ if (input.parentHead && input.parentHead !== head && isAncestor(ctx.cwd, input.parentHead, head))
288
+ froms.push(input.parentHead);
289
+ for (const from of froms) {
290
+ const id = patchId(ctx.cwd, from, head);
291
+ if (id && trunkIds.has(id)) return landed("squash_patch", head);
292
+ }
293
+ return out;
294
+ }
295
+
296
+ /** The commit a slice branch began at: its reflog, else `fanout worktree create`'s ledger row. */
297
+ export function sliceStart(
298
+ cwd: string,
299
+ rows: readonly Record<string, unknown>[],
300
+ fanout: string,
301
+ slice: Pick<Slice, "id" | "branch">,
302
+ ): string | null {
303
+ const reflog = branchCreatedAt(cwd, slice.branch);
304
+ if (reflog) return reflog;
305
+ const row = rows.findLast(
306
+ (candidate) =>
307
+ candidate.type === "fanout.worktree.created" &&
308
+ candidate.fanout === fanout &&
309
+ candidate.slice === slice.id &&
310
+ typeof candidate.baseSha === "string",
311
+ );
312
+ return (row?.baseSha as string | undefined) ?? null;
313
+ }