@brainervirus/workit-core 7.4.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.
- package/package.json +1 -1
- package/skills/workit-fanout/SKILL.md +27 -27
- package/skills/workit-fanout/references/brief.md +5 -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/package.json
CHANGED
|
@@ -14,30 +14,30 @@ ceremony; do it yourself.
|
|
|
14
14
|
per slice an id, branch, TIER, the file-scope manifest (SCOPE globs),
|
|
15
15
|
`owns` for shared files (lockfile, registry, barrels), `dependsOn` only for
|
|
16
16
|
a real dependency (independent PRs off trunk are the default), and the
|
|
17
|
-
brief fields. `workit fanout plan <plan.json>` refuses an empty
|
|
18
|
-
(exit 2) and two slices that may
|
|
19
|
-
owner for a shared file, or a
|
|
20
|
-
re-run: refuse to spawn while
|
|
21
|
-
Its `waves` say which slices may
|
|
17
|
+
brief fields. `workit fanout plan <plan.json>` refuses an empty or
|
|
18
|
+
placeholder (`<goal>`, `TBD`) brief field (exit 2) and two slices that may
|
|
19
|
+
write one file (exit 3) with a fix: an owner for a shared file, or a
|
|
20
|
+
dependency that serializes them. Apply it and re-run: refuse to spawn while
|
|
21
|
+
a field is empty or the plan is refused. Its `waves` say which slices may
|
|
22
|
+
run together; keep 4-6 in flight.
|
|
22
23
|
2. **Brief each worker** from its slice with the fixed template: GOAL, SCOPE,
|
|
23
24
|
CONTEXT (pointers, not pasted text), ACCEPTANCE (Given/When/Then), VERIFY
|
|
24
|
-
(exact commands), TIER, TIMEBOX, FORBIDDEN, REPORT, STANDING.
|
|
25
|
-
every standing order and user directive so far, pasted
|
|
26
|
-
spawn and respawn
|
|
25
|
+
(exact commands), TIER, TIMEBOX, SCRATCH, FORBIDDEN, REPORT, STANDING.
|
|
26
|
+
STANDING is every standing order and user directive so far, pasted
|
|
27
|
+
verbatim into each spawn and respawn: directives decay across resumes.
|
|
27
28
|
3. **Spawn all workers in one message**, in the background, each in its own
|
|
28
|
-
worktree
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
4. **Judge liveness by side effects only:**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
twice, then re-slice or report the gap. Never chain resumes.
|
|
29
|
+
worktree: Claude Code's `implementer` agent (first command `workit git
|
|
30
|
+
branch <branch> --base <base>`); elsewhere `workit fanout worktree create
|
|
31
|
+
<slice>`, which prints the SCRATCH dir. Refill from `fanout status`.
|
|
32
|
+
4. **Judge liveness by side effects only:** `workit fanout status` (head age,
|
|
33
|
+
PR, CI, verdict, landed; STUCK past the TIMEBOX, default 30 min). Stop a
|
|
34
|
+
stuck worker and observe that it exited (a timeout is not proof). Removing
|
|
35
|
+
a worktree drops its uncommitted changes: `workit fanout worktree release
|
|
36
|
+
<slice>` records `git status` in the ledger first and refuses them without
|
|
37
|
+
`--force` (native worktrees: record `git -C <wt> status --short` first).
|
|
38
|
+
Respawn in `MODE: resume` (brief, directives, last report), same branch.
|
|
39
|
+
Never two live workers on one branch. Replace at most twice, then re-slice
|
|
40
|
+
or report the gap. Never chain resumes.
|
|
41
41
|
5. **Verify each slice independently.** A fresh agent that did not write it
|
|
42
42
|
(Claude Code: the `verifier` agent) runs VERIFY and verify-<app>, then
|
|
43
43
|
`workit ledger verdict <result> --branch <b> --how "<evidence>"` under the
|
|
@@ -47,12 +47,12 @@ ceremony; do it yourself.
|
|
|
47
47
|
6. **Fan in** with `workit fanout check`: out-of-scope files (any file outside
|
|
48
48
|
it stops the fan-in), and `git merge-tree` conflicts with trunk and between
|
|
49
49
|
siblings, charged to the slice that lands later. Fix what it names (an
|
|
50
|
-
out-of-scope edit becomes a follow-up slice) until it exits 0. A
|
|
51
|
-
|
|
52
|
-
`
|
|
53
|
-
|
|
54
|
-
`
|
|
55
|
-
rebase, retarget or merge. Then workit-ship.
|
|
50
|
+
out-of-scope edit becomes a follow-up slice) until it exits 0. A slice
|
|
51
|
+
whose PR merged reads as landed; its dependents stop waiting. Then
|
|
52
|
+
`workit ledger check --branch <b>` per slice, land in `fanout status` order
|
|
53
|
+
and release the worktrees you made. Stacks: `workit stack plan <bottom> …
|
|
54
|
+
<top>` once, then `stack sync` and `land`. Only you touch topology: workers
|
|
55
|
+
never rebase, retarget or merge. Then workit-ship.
|
|
56
56
|
|
|
57
57
|
## Example
|
|
58
58
|
|
|
@@ -14,6 +14,7 @@ ACCEPTANCE:
|
|
|
14
14
|
VERIFY: <exact commands, e.g. `workit check test`, the verify-<app> feature to drive>
|
|
15
15
|
TIER: <mundane | standard | hard: how much model the slice needs>
|
|
16
16
|
TIMEBOX: <wall clock or turn budget; past it without a new commit you will be replaced>
|
|
17
|
+
SCRATCH: <your own temp dir: the one `fanout worktree create` printed, else `mktemp -d`; never a shared path>
|
|
17
18
|
FORBIDDEN: <no edits outside SCOPE; no rebase, retarget, merge or force-push; no new dependencies; ...>
|
|
18
19
|
REPORT: branch, head SHA, files changed, each VERIFY command with its exit code,
|
|
19
20
|
each ACCEPTANCE line met / not met, rulings you made (`workit ledger ruling`),
|
|
@@ -37,6 +38,7 @@ ACCEPTANCE:
|
|
|
37
38
|
VERIFY: `workit check test`; verify-api feature "usage"
|
|
38
39
|
TIER: standard
|
|
39
40
|
TIMEBOX: 45 minutes
|
|
41
|
+
SCRATCH: ../app-wt/usage-endpoint/.workit-scratch
|
|
40
42
|
FORBIDDEN: no edits outside SCOPE; no schema migration; no rebase or force-push; no new packages
|
|
41
43
|
REPORT: as in the template
|
|
42
44
|
STANDING: conventional commits; no comments that restate code; ask nothing, record rulings instead
|
|
@@ -50,7 +52,9 @@ verify, forbidden must be filled; tier is mundane, standard or hard). `base`
|
|
|
50
52
|
defaults to the trunk, or to the branch of a single `dependsOn` slice (a
|
|
51
53
|
stack); `worktree` defaults to `../<repo>-wt/<id>`. `owns` claims a shared
|
|
52
54
|
file another slice's glob also matches. Globs that match no file yet are compared
|
|
53
|
-
through a sample path.
|
|
55
|
+
through a sample path. Use `/` as the separator; a backslash only escapes
|
|
56
|
+
literal brackets: `"app/\\[id\\]/page.tsx"`. A `timebox` such as `45 minutes`
|
|
57
|
+
is the slice's STUCK threshold in `workit fanout status`.
|
|
54
58
|
|
|
55
59
|
```json
|
|
56
60
|
{
|
package/src/duration.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// One duration parser for CLI flags (`--timeout 20m`, `--stuck-after 1h30m`)
|
|
2
|
+
// and plan fields (a slice TIMEBOX such as `45 minutes`).
|
|
3
|
+
|
|
4
|
+
const UNITS: ReadonlyArray<[RegExp, number]> = [
|
|
5
|
+
[/^(?:ms|milliseconds?)$/u, 1],
|
|
6
|
+
[/^(?:s|secs?|seconds?)$/u, 1000],
|
|
7
|
+
[/^(?:m|mins?|minutes?)$/u, 60_000],
|
|
8
|
+
[/^(?:h|hrs?|hours?)$/u, 3_600_000],
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `20m`, `30s`, `1500ms`, `1h`, `1h30m`, `45 minutes`, `2 hours`, or bare
|
|
13
|
+
* seconds, in ms; null when it does not read as a duration.
|
|
14
|
+
*/
|
|
15
|
+
export function parseDuration(value: string): number | null {
|
|
16
|
+
const text = value.trim().toLowerCase();
|
|
17
|
+
if (/^\d+(?:\.\d+)?$/u.test(text)) return Math.round(Number(text) * 1000);
|
|
18
|
+
const parts = [...text.matchAll(/(\d+(?:\.\d+)?)\s*([a-z]+)\s*/gu)];
|
|
19
|
+
if (parts.length === 0 || parts.map((part) => part[0]).join("") !== text) return null;
|
|
20
|
+
let total = 0;
|
|
21
|
+
for (const [, amount, unit] of parts) {
|
|
22
|
+
const factor = UNITS.find(([pattern]) => pattern.test(unit))?.[1];
|
|
23
|
+
if (factor === undefined) return null;
|
|
24
|
+
total += Number(amount) * factor;
|
|
25
|
+
}
|
|
26
|
+
return Math.round(total);
|
|
27
|
+
}
|
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) {
|