@brainervirus/workit-core 7.1.0 → 7.3.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.
@@ -0,0 +1,294 @@
1
+ // `workit fanout check` (G2): the fan-in gate over a recorded fanout plan.
2
+ //
3
+ // For each slice branch it reads what git says, never what a worker reported:
4
+ // - scope: `git diff --name-only <base>...<branch>` against the slice's
5
+ // manifest (its scope and owns, minus files another slice owns); any other
6
+ // file is an out-of-scope edit;
7
+ // - trunk: `git merge-tree --write-tree` of the branch with the trunk;
8
+ // - siblings: the same merge between every pair of slice branches, so two
9
+ // PRs that would conflict once the first lands are caught before either
10
+ // does. A pairwise conflict is charged to the slice that lands later; the
11
+ // earlier one stays landable.
12
+ // The landing order puts dependencies first, then the plan's order. A slice
13
+ // 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
15
+ // `workit pr merge`, not here. Refs are read as they are locally: fetch first.
16
+ import { checkVerdicts, readLedger, type ReadRow } from "./ledger";
17
+ import {
18
+ FANOUT_VERSION,
19
+ branchRef,
20
+ fanoutFail,
21
+ gitRun,
22
+ inScope,
23
+ landingOrder,
24
+ nulList,
25
+ trunkRef,
26
+ type FanoutFile,
27
+ type FanoutResult,
28
+ type Slice,
29
+ } from "./fanout";
30
+
31
+ export type SliceStatus = "ready" | "blocked";
32
+
33
+ export type SliceCheck = {
34
+ id: string;
35
+ branch: string;
36
+ head: string | null;
37
+ base: string;
38
+ status: SliceStatus;
39
+ changed: number;
40
+ outOfScope: string[];
41
+ trunkConflicts: string[];
42
+ siblingConflicts: Array<{ with: string; paths: string[] }>;
43
+ waitsFor: string[];
44
+ verdict: { accepted: boolean; basis: string };
45
+ reasons: string[];
46
+ };
47
+
48
+ export type SiblingConflict = {
49
+ /** Lands first in the landing order. */
50
+ first: string;
51
+ /** Lands second; it carries the conflict. */
52
+ second: string;
53
+ paths: string[];
54
+ /** The two branches name one file with different case (one file on macOS and Windows). */
55
+ caseOnly?: true;
56
+ resolution: string;
57
+ };
58
+
59
+ export type CheckOutcome = {
60
+ name: string;
61
+ trunk: string;
62
+ trunkRef: string;
63
+ ready: boolean;
64
+ slices: SliceCheck[];
65
+ conflicts: SiblingConflict[];
66
+ landingOrder: string[];
67
+ next: string;
68
+ notes: string[];
69
+ };
70
+
71
+ const SAFE = (value: string | null): value is string => Boolean(value) && !value?.startsWith("-");
72
+
73
+ /** Paths both sides change incompatibly, [] when clean, or an error. */
74
+ function mergeConflicts(cwd: string, a: string, b: string): string[] | { error: string } {
75
+ const run = gitRun(cwd, [
76
+ "merge-tree",
77
+ "--write-tree",
78
+ "--name-only",
79
+ "--no-messages",
80
+ "-z",
81
+ a,
82
+ b,
83
+ ]);
84
+ if (run.status === 0) return [];
85
+ if (run.status === 1) return [...new Set(nulList(run.stdout).slice(1))];
86
+ const first = run.stderr.trim().split("\n")[0] || `git merge-tree exited ${run.status}`;
87
+ return {
88
+ error: /write-tree|usage: git merge-tree/u.test(run.stderr)
89
+ ? `${first} (needs git >= 2.38 for merge-tree --write-tree)`
90
+ : first,
91
+ };
92
+ }
93
+
94
+ const isAncestor = (cwd: string, a: string, b: string): boolean =>
95
+ gitRun(cwd, ["merge-base", "--is-ancestor", a, b]).ok;
96
+
97
+ export function checkFanout(
98
+ cwd: string,
99
+ plan: FanoutFile,
100
+ options: { only?: readonly string[]; trunkRef?: string | null } = {},
101
+ ): FanoutResult<CheckOutcome> {
102
+ const unknown = (options.only ?? []).filter((id) => !plan.slices.some((s) => s.id === id));
103
+ if (unknown.length)
104
+ return fanoutFail(
105
+ "invalid_input",
106
+ `${unknown.join(", ")}: not a slice of fanout ${plan.name}`,
107
+ `slices: ${plan.slices.map((slice) => slice.id).join(", ")}`,
108
+ );
109
+ const trunk = options.trunkRef ?? trunkRef(cwd, plan.trunk);
110
+ if (!SAFE(trunk) || !resolves(cwd, trunk))
111
+ return fanoutFail(
112
+ "unavailable",
113
+ `trunk ${options.trunkRef ?? plan.trunk} does not resolve here`,
114
+ `git fetch, or pass --base <ref>`,
115
+ );
116
+ const order = landingOrder(plan.slices);
117
+ const byId = new Map(plan.slices.map((slice) => [slice.id, slice]));
118
+ const tips = new Map(plan.slices.map((slice) => [slice.id, branchRef(cwd, slice.branch)]));
119
+ const ledger = readLedger(cwd);
120
+ const rows: readonly ReadRow[] = ledger.ok ? ledger.value.rows : [];
121
+
122
+ const checks = new Map<string, SliceCheck>();
123
+ 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));
126
+
127
+ // Pairwise siblings: skip a pair where one contains the other (a stacked child).
128
+ const conflicts: SiblingConflict[] = [];
129
+ for (let i = 0; i < order.length; i += 1)
130
+ for (let j = i + 1; j < order.length; j += 1) {
131
+ const first = order[i];
132
+ const second = order[j];
133
+ const a = tips.get(first);
134
+ const b = tips.get(second);
135
+ if (!a || !b || isAncestor(cwd, a, b) || isAncestor(cwd, b, a)) continue;
136
+ const check = checks.get(second) as SliceCheck;
137
+ const theirs = new Map(
138
+ (changedBy.get(first) ?? []).map((file) => [file.toLowerCase(), file]),
139
+ );
140
+ const cased = (changedBy.get(second) ?? []).flatMap((file) => {
141
+ const other = theirs.get(file.toLowerCase());
142
+ return other !== undefined && other !== file ? [`${other} ~ ${file}`] : [];
143
+ });
144
+ if (cased.length) {
145
+ conflicts.push({
146
+ first,
147
+ second,
148
+ paths: cased,
149
+ caseOnly: true,
150
+ resolution: `use ${first}'s spelling on ${second} (git mv), then workit fanout check`,
151
+ });
152
+ check.siblingConflicts.push({ with: first, paths: cased });
153
+ check.reasons.push(`differs only in case from ${first}: ${cased.join(", ")}`);
154
+ }
155
+ const result = mergeConflicts(cwd, a, b);
156
+ if (!Array.isArray(result)) {
157
+ check.reasons.push(`cannot merge with ${first}: ${result.error}`);
158
+ continue;
159
+ }
160
+ if (result.length === 0) continue;
161
+ const related = byId.get(second)?.dependsOn.includes(first);
162
+ conflicts.push({
163
+ first,
164
+ second,
165
+ paths: result,
166
+ resolution: related
167
+ ? `rebase ${second} onto ${byId.get(first)?.branch} (its dependency), then workit fanout check`
168
+ : `land ${first} first, then rebase ${second} onto ${plan.trunk} and run workit fanout check; or make ${second} depend on ${first}`,
169
+ });
170
+ check.siblingConflicts.push({ with: first, paths: result });
171
+ check.reasons.push(`conflicts with ${first} on ${result.join(", ")}`);
172
+ }
173
+
174
+ for (const id of order) {
175
+ const check = checks.get(id) as SliceCheck;
176
+ check.waitsFor = (byId.get(id)?.dependsOn ?? []).filter(
177
+ (dep) => checks.get(dep)?.status !== "ready",
178
+ );
179
+ if (check.waitsFor.length) check.reasons.push(`waits for ${check.waitsFor.join(", ")}`);
180
+ check.status = check.reasons.length ? "blocked" : "ready";
181
+ }
182
+
183
+ const selected = options.only?.length ? new Set(options.only) : null;
184
+ const slices = order
185
+ .filter((id) => !selected || selected.has(id))
186
+ .map((id) => checks.get(id) as SliceCheck);
187
+ const ready = slices.every((check) => check.status === "ready");
188
+ const blocked = slices.find((check) => check.status === "blocked");
189
+ const notes =
190
+ plan.v === FANOUT_VERSION
191
+ ? []
192
+ : [
193
+ `plan file version ${plan.v} is not ${FANOUT_VERSION}; read leniently (re-plan to rewrite it)`,
194
+ ];
195
+ const next = blocked
196
+ ? 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)`;
198
+ return {
199
+ ok: true,
200
+ data: {
201
+ name: plan.name,
202
+ trunk: plan.trunk,
203
+ trunkRef: trunk,
204
+ ready,
205
+ slices,
206
+ conflicts: conflicts.filter(
207
+ (conflict) => !selected || selected.has(conflict.first) || selected.has(conflict.second),
208
+ ),
209
+ landingOrder: order,
210
+ notes,
211
+ next,
212
+ },
213
+ };
214
+ }
215
+
216
+ const resolves = (cwd: string, ref: string): boolean =>
217
+ gitRun(cwd, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]).ok;
218
+
219
+ function inspect(
220
+ cwd: string,
221
+ plan: FanoutFile,
222
+ slice: Slice,
223
+ trunk: string,
224
+ tips: ReadonlyMap<string, string | null>,
225
+ rows: readonly ReadRow[],
226
+ changedBy: Map<string, string[]>,
227
+ ): SliceCheck {
228
+ 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
+ };
243
+ if (!head) {
244
+ check.reasons.push(`branch ${slice.branch} not found (not started, landed, or not fetched)`);
245
+ return check;
246
+ }
247
+ const parent = plan.slices.find((other) => other.branch === slice.base);
248
+ const baseRef =
249
+ slice.base === plan.trunk
250
+ ? trunk
251
+ : parent
252
+ ? (tips.get(parent.id) ?? null)
253
+ : branchRef(cwd, slice.base);
254
+ if (!SAFE(baseRef)) {
255
+ check.reasons.push(`base ${slice.base} not found`);
256
+ return check;
257
+ }
258
+ const diff = gitRun(cwd, ["diff", "--name-only", "--no-renames", "-z", `${baseRef}...${head}`]);
259
+ if (!diff.ok) {
260
+ check.reasons.push(`cannot diff ${slice.base}...${slice.branch}: ${diff.stderr.trim()}`);
261
+ return check;
262
+ }
263
+ const changed = nulList(diff.stdout);
264
+ changedBy.set(slice.id, changed);
265
+ check.changed = changed.length;
266
+ if (changed.length === 0) check.reasons.push(`no changes beyond ${slice.base}`);
267
+ check.outOfScope = changed.filter((file) => !inScope(plan, slice, file)).toSorted();
268
+ if (check.outOfScope.length)
269
+ check.reasons.push(`edits outside its scope: ${check.outOfScope.join(", ")}`);
270
+ const trunkResult = mergeConflicts(cwd, trunk, head);
271
+ if (Array.isArray(trunkResult)) {
272
+ check.trunkConflicts = trunkResult;
273
+ if (trunkResult.length)
274
+ check.reasons.push(`conflicts with ${plan.trunk} on ${trunkResult.join(", ")}`);
275
+ } else check.reasons.push(`cannot merge with ${plan.trunk}: ${trunkResult.error}`);
276
+ const verdict = checkVerdicts(cwd, slice.branch, rows);
277
+ check.verdict = { accepted: verdict.accepted.accepted, basis: verdict.current.basis };
278
+ return check;
279
+ }
280
+
281
+ function unblockFor(check: SliceCheck, plan: FanoutFile): string {
282
+ if (!check.head)
283
+ return `start or fetch ${check.branch}, then workit fanout check; if it landed, re-plan without slice ${check.id}`;
284
+ if (check.outOfScope.length)
285
+ 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`;
286
+ if (check.trunkConflicts.length)
287
+ return `rebase ${check.branch} onto ${plan.trunk} and resolve ${check.trunkConflicts.join(", ")}, then workit fanout check`;
288
+ if (check.siblingConflicts.length) {
289
+ const first = check.siblingConflicts[0].with;
290
+ return `land ${first} first, then rebase ${check.branch} onto ${plan.trunk} and run workit fanout check`;
291
+ }
292
+ if (check.waitsFor.length) return `unblock ${check.waitsFor.join(", ")} first`;
293
+ return check.reasons[0] ?? "workit fanout check";
294
+ }