@brainervirus/workit-core 7.4.0 → 7.5.1
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/package.json +1 -1
- package/skills/workit-bdd/SKILL.md +4 -3
- package/skills/workit-continue/SKILL.md +1 -1
- package/skills/workit-deslop/SKILL.md +3 -5
- package/skills/workit-fanout/SKILL.md +27 -27
- package/skills/workit-fanout/references/brief.md +7 -2
- package/skills/workit-implement/SKILL.md +10 -11
- package/skills/workit-retro/SKILL.md +1 -1
- package/skills/workit-review/SKILL.md +6 -3
- package/skills/workit-shape/SKILL.md +5 -5
- package/skills/workit-shape/references/knowledge.md +1 -1
- package/skills/workit-shape/references/mockups.md +1 -1
- package/skills/workit-shape/references/slicing.md +3 -1
- package/skills/workit-ship/SKILL.md +27 -27
- package/skills/workit-ship/references/pr-body.md +30 -0
- package/skills/workit-verify-app/SKILL.md +1 -2
- package/src/core/skill-manifests.ts +47 -1
- package/src/duration.ts +27 -0
- package/src/fanout-check.ts +178 -27
- package/src/fanout-landed.ts +313 -0
- package/src/fanout-status.ts +321 -0
- package/src/fanout-worktree.ts +520 -0
- package/src/fanout.ts +131 -17
package/src/fanout-check.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
+
}
|