@brainervirus/workit-core 7.2.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.
- package/package.json +1 -1
- package/skills/workit-fanout/SKILL.md +29 -20
- package/skills/workit-fanout/references/brief.md +45 -0
- package/src/fanout-check.ts +294 -0
- package/src/fanout.ts +916 -0
package/package.json
CHANGED
|
@@ -10,41 +10,49 @@ verifiable alone. Code-coupled work stays with one owner, who fans out after
|
|
|
10
10
|
the blocking part lands. A worker whose whole job is re-running one command is
|
|
11
11
|
ceremony; do it yourself.
|
|
12
12
|
|
|
13
|
-
1. **
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
13
|
+
1. **Plan the slices** (workit-shape) in a plan file (`references/brief.md`):
|
|
14
|
+
per slice an id, branch, TIER, the file-scope manifest (SCOPE globs),
|
|
15
|
+
`owns` for shared files (lockfile, registry, barrels), `dependsOn` only for
|
|
16
|
+
a real dependency (independent PRs off trunk are the default), and the
|
|
17
|
+
brief fields. `workit fanout plan <plan.json>` refuses an empty brief field
|
|
18
|
+
(exit 2) and two slices that may write one file (exit 3) with a fix: an
|
|
19
|
+
owner for a shared file, or a dependency that serializes them. Apply it and
|
|
20
|
+
re-run: refuse to spawn while a field is empty or the plan is refused.
|
|
21
|
+
Its `waves` say which slices may run together; keep 4-6 in flight.
|
|
22
|
+
2. **Brief each worker** from its slice with the fixed template: GOAL, SCOPE,
|
|
23
|
+
CONTEXT (pointers, not pasted text), ACCEPTANCE (Given/When/Then), VERIFY
|
|
24
|
+
(exact commands), TIER, TIMEBOX, FORBIDDEN, REPORT, STANDING. STANDING is
|
|
25
|
+
every standing order and user directive so far, pasted verbatim into each
|
|
26
|
+
spawn and respawn, because directives decay across resumes.
|
|
27
|
+
3. **Spawn all workers in one message**, in the background, each in its own
|
|
24
28
|
worktree (Claude Code: the `implementer` agent; elsewhere
|
|
25
29
|
`git worktree add --detach ../<repo>-wt/<slug> origin/<base>`). The first
|
|
26
30
|
command a worker runs is `workit git branch <branch> --base <base>`.
|
|
27
|
-
|
|
31
|
+
4. **Judge liveness by side effects only:** new commits and pushes
|
|
28
32
|
(`git log <branch>`), PR and check changes (`workit pr status --branch <b>`).
|
|
29
33
|
No progress past the timebox means stuck. Stop the old worker and observe
|
|
30
34
|
that it exited (a timeout is not proof). `git worktree remove --force`
|
|
31
35
|
drops its uncommitted changes, so first record `git -C <wt> status --short`
|
|
32
|
-
in the ledger or your report; only then remove the worktree. Respawn with
|
|
33
|
-
`MODE: resume` (
|
|
36
|
+
in the ledger or your report; only then remove the worktree. Respawn with
|
|
37
|
+
the brief in `MODE: resume` (original, later directives, its last report):
|
|
34
38
|
the new worker runs `git switch <branch>` in its fresh worktree instead of
|
|
35
39
|
`workit git branch`. Never two live workers on one branch. Replace at most
|
|
36
40
|
twice, then re-slice or report the gap. Never chain resumes.
|
|
37
|
-
|
|
41
|
+
5. **Verify each slice independently.** A fresh agent that did not write it
|
|
38
42
|
(Claude Code: the `verifier` agent) runs VERIFY and verify-<app>, then
|
|
39
43
|
`workit ledger verdict <result> --branch <b> --how "<evidence>"` under the
|
|
40
44
|
session you started it with (`WORKIT_SESSION_ID=<lead>-v<n>`, set by you,
|
|
41
45
|
never chosen by the author; Claude Code: the hook names one).
|
|
42
46
|
A worker's report is a pointer, never evidence.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
6. **Fan in** with `workit fanout check`: out-of-scope files (any file outside
|
|
48
|
+
it stops the fan-in), and `git merge-tree` conflicts with trunk and between
|
|
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 landed
|
|
51
|
+
slice reads as not found: re-plan without it and drop it from dependents'
|
|
52
|
+
`dependsOn`. Then `workit ledger check --branch <b>` per slice; land in its
|
|
53
|
+
order. Stacked slices: `workit stack plan <bottom> … <top>` once, then
|
|
54
|
+
`workit stack sync` and `land`. Only you touch topology: workers never
|
|
55
|
+
rebase, retarget or merge. Then workit-ship.
|
|
48
56
|
|
|
49
57
|
## Example
|
|
50
58
|
|
|
@@ -58,5 +66,6 @@ counts; SCOPE: `src/routes/usage.ts`, `test/usage.test.ts`; VERIFY:
|
|
|
58
66
|
## Check
|
|
59
67
|
|
|
60
68
|
```sh
|
|
69
|
+
workit fanout check # exit 0: in scope, no conflicts, landing order printed
|
|
61
70
|
workit ledger check --branch <b> # per slice: accepted (current, passing, independent)
|
|
62
71
|
```
|
|
@@ -12,6 +12,7 @@ CONTEXT: <pointers: spec section, ledger decisions, the neighbour file to imitat
|
|
|
12
12
|
ACCEPTANCE:
|
|
13
13
|
- Given <state>, When <action>, Then <observable result>
|
|
14
14
|
VERIFY: <exact commands, e.g. `workit check test`, the verify-<app> feature to drive>
|
|
15
|
+
TIER: <mundane | standard | hard: how much model the slice needs>
|
|
15
16
|
TIMEBOX: <wall clock or turn budget; past it without a new commit you will be replaced>
|
|
16
17
|
FORBIDDEN: <no edits outside SCOPE; no rebase, retarget, merge or force-push; no new dependencies; ...>
|
|
17
18
|
REPORT: branch, head SHA, files changed, each VERIFY command with its exit code,
|
|
@@ -34,12 +35,56 @@ ACCEPTANCE:
|
|
|
34
35
|
- Given 3 runs today and 1 yesterday, When GET /v1/usage, Then the last two entries are {"runs":1} and {"runs":3}
|
|
35
36
|
- Given no auth header, When GET /v1/usage, Then the status is 401
|
|
36
37
|
VERIFY: `workit check test`; verify-api feature "usage"
|
|
38
|
+
TIER: standard
|
|
37
39
|
TIMEBOX: 45 minutes
|
|
38
40
|
FORBIDDEN: no edits outside SCOPE; no schema migration; no rebase or force-push; no new packages
|
|
39
41
|
REPORT: as in the template
|
|
40
42
|
STANDING: conventional commits; no comments that restate code; ask nothing, record rulings instead
|
|
41
43
|
```
|
|
42
44
|
|
|
45
|
+
## Plan file
|
|
46
|
+
|
|
47
|
+
`workit fanout plan <plan.json>` records the slices before any spawn. Each
|
|
48
|
+
slice carries the brief fields the plan checks (goal, scope, acceptance,
|
|
49
|
+
verify, forbidden must be filled; tier is mundane, standard or hard). `base`
|
|
50
|
+
defaults to the trunk, or to the branch of a single `dependsOn` slice (a
|
|
51
|
+
stack); `worktree` defaults to `../<repo>-wt/<id>`. `owns` claims a shared
|
|
52
|
+
file another slice's glob also matches. Globs that match no file yet are compared
|
|
53
|
+
through a sample path. Escape literal brackets: `"app/\\[id\\]/page.tsx"`.
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"name": "usage",
|
|
58
|
+
"trunk": "main",
|
|
59
|
+
"slices": [
|
|
60
|
+
{
|
|
61
|
+
"id": "usage-endpoint",
|
|
62
|
+
"branch": "feature/usage-endpoint",
|
|
63
|
+
"tier": "standard",
|
|
64
|
+
"scope": ["src/routes/usage.ts", "src/queries/usage.ts", "test/usage.test.ts"],
|
|
65
|
+
"owns": ["package.json"],
|
|
66
|
+
"goal": "GET /v1/usage returns the run count per UTC day for the last 7 days",
|
|
67
|
+
"acceptance": ["Given no auth header, When GET /v1/usage, Then the status is 401"],
|
|
68
|
+
"verify": ["workit check test"],
|
|
69
|
+
"forbidden": ["no edits outside SCOPE", "no rebase or force-push"],
|
|
70
|
+
"context": "docs/usage/spec.md Behavior",
|
|
71
|
+
"timebox": "45 minutes"
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"id": "usage-docs",
|
|
75
|
+
"branch": "docs/usage",
|
|
76
|
+
"tier": "mundane",
|
|
77
|
+
"dependsOn": ["usage-endpoint"],
|
|
78
|
+
"scope": ["docs/usage/**"],
|
|
79
|
+
"goal": "The API guide documents GET /v1/usage",
|
|
80
|
+
"acceptance": ["Given the guide, When a reader looks up usage, Then the response shape is shown"],
|
|
81
|
+
"verify": ["workit check docs"],
|
|
82
|
+
"forbidden": ["no edits outside SCOPE"]
|
|
83
|
+
}
|
|
84
|
+
]
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
43
88
|
## Worker rules (paste into the brief when the host has no implementer agent)
|
|
44
89
|
|
|
45
90
|
1. First command. `MODE: new`: `workit git branch <branch> --base <base>` (the
|
|
@@ -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
|
+
}
|
package/src/fanout.ts
ADDED
|
@@ -0,0 +1,916 @@
|
|
|
1
|
+
// `workit fanout plan` (G1): the slice registry for parallel work.
|
|
2
|
+
//
|
|
3
|
+
// A plan names the slices one lead fans out: each has an id, a branch, a base,
|
|
4
|
+
// a worktree, a model TIER, its dependencies and a brief (GOAL, SCOPE as a
|
|
5
|
+
// file-scope manifest, ACCEPTANCE, VERIFY, FORBIDDEN). The file lives at
|
|
6
|
+
// `<store>/fanouts/<name>.json` (D13: under the git common dir, shared by
|
|
7
|
+
// every worktree, surviving worktree removal), next to the stacks, and each
|
|
8
|
+
// plan appends an observed `fanout.planned` ledger row.
|
|
9
|
+
//
|
|
10
|
+
// Planning refuses (`invalid_input`) a brief with an empty or placeholder
|
|
11
|
+
// field, unknown or cyclic dependencies and malformed scopes, and refuses
|
|
12
|
+
// (`blocked`) two slices that may write the same file unless that is
|
|
13
|
+
// resolved: exactly one slice lists the file under `owns`, or the slices are
|
|
14
|
+
// serialized by a dependency. Overlap is evaluated on the trunk's tracked
|
|
15
|
+
// files plus one sample path per glob (`src/new/**` -> `src/new/<any>`), so
|
|
16
|
+
// directories nobody has created yet are compared too, and paths differing
|
|
17
|
+
// only in case count as one file. Each unresolved overlap carries one
|
|
18
|
+
// deterministic suggestion: an owner for known shared files (lockfiles,
|
|
19
|
+
// manifests, barrels, CI config), else a dependency that serializes them.
|
|
20
|
+
//
|
|
21
|
+
// The CLI never spawns, waits or wakes anything: it only records and checks.
|
|
22
|
+
import { spawnSync } from "node:child_process";
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { GIT_TIMEOUTS, gitCommonDir, pushRemoteName, resolveRef } from "./git/rev";
|
|
26
|
+
import { appendObserved, defaultBase, storeRoot, type LedgerActor } from "./ledger";
|
|
27
|
+
import { stackFileName } from "./stack";
|
|
28
|
+
|
|
29
|
+
export const FANOUT_VERSION = 1;
|
|
30
|
+
export const TIERS = ["mundane", "standard", "hard"] as const;
|
|
31
|
+
export type Tier = (typeof TIERS)[number];
|
|
32
|
+
|
|
33
|
+
export type Slice = {
|
|
34
|
+
id: string;
|
|
35
|
+
branch: string;
|
|
36
|
+
/** The branch it is cut from and diffed against: the trunk, or its single dependency's branch. */
|
|
37
|
+
base: string;
|
|
38
|
+
worktree: string;
|
|
39
|
+
tier: Tier;
|
|
40
|
+
dependsOn: string[];
|
|
41
|
+
/** The file-scope manifest: globs (`*`, `**`, `?`, `{a,b}`) relative to the repo root. */
|
|
42
|
+
scope: string[];
|
|
43
|
+
/** Shared files this slice alone may write, even where another scope matches them. */
|
|
44
|
+
owns: string[];
|
|
45
|
+
goal: string;
|
|
46
|
+
acceptance: string[];
|
|
47
|
+
verify: string[];
|
|
48
|
+
forbidden: string[];
|
|
49
|
+
context: string | null;
|
|
50
|
+
timebox: string | null;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type FanoutFile = {
|
|
54
|
+
v: number;
|
|
55
|
+
name: string;
|
|
56
|
+
trunk: string;
|
|
57
|
+
/** Extra shared-file globs on top of DEFAULT_SHARED. */
|
|
58
|
+
shared: string[];
|
|
59
|
+
slices: Slice[];
|
|
60
|
+
createdAt: string;
|
|
61
|
+
updatedAt: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type FanoutError = {
|
|
65
|
+
ok: false;
|
|
66
|
+
code: "blocked" | "busy" | "failed" | "unavailable" | "not_found" | "invalid_input";
|
|
67
|
+
error: string;
|
|
68
|
+
unblock?: string;
|
|
69
|
+
data?: Record<string, unknown>;
|
|
70
|
+
};
|
|
71
|
+
export type FanoutResult<T> = { ok: true; data: T } | FanoutError;
|
|
72
|
+
|
|
73
|
+
export const fanoutFail = (
|
|
74
|
+
code: FanoutError["code"],
|
|
75
|
+
error: string,
|
|
76
|
+
unblock?: string,
|
|
77
|
+
data?: Record<string, unknown>,
|
|
78
|
+
): FanoutError => ({
|
|
79
|
+
ok: false,
|
|
80
|
+
code,
|
|
81
|
+
error,
|
|
82
|
+
...(unblock ? { unblock } : {}),
|
|
83
|
+
...(data ? { data } : {}),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/** Files that every slice tends to touch; overlap on them is resolved by an owner. */
|
|
87
|
+
export const DEFAULT_SHARED: readonly string[] = [
|
|
88
|
+
"**/package.json",
|
|
89
|
+
"**/{package-lock.json,npm-shrinkwrap.json,bun.lock,bun.lockb,pnpm-lock.yaml,pnpm-workspace.yaml,yarn.lock}",
|
|
90
|
+
"**/{Cargo.lock,go.mod,go.sum,poetry.lock,uv.lock,Gemfile.lock,composer.lock}",
|
|
91
|
+
"**/index.{ts,tsx,js,jsx,mjs,cjs}",
|
|
92
|
+
"**/tsconfig.json",
|
|
93
|
+
".github/**",
|
|
94
|
+
".gitlab-ci.yml",
|
|
95
|
+
"CHANGELOG.md",
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// scope globs
|
|
100
|
+
|
|
101
|
+
const escapeRegExp = (text: string): string => text.replace(/[.+^$()|[\]\\{}*?]/gu, "\\$&");
|
|
102
|
+
/** The pattern with `\x` escapes resolved: what a wildcard-free pattern names. */
|
|
103
|
+
const unescape = (pattern: string): string => pattern.replace(/\\(.)/gu, "$1");
|
|
104
|
+
/** Has an unescaped `*`, `?` or `{`. */
|
|
105
|
+
const hasWildcard = (pattern: string): boolean => /[*?{]/u.test(pattern.replace(/\\./gu, ""));
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* One concrete path the pattern matches, standing in for files nobody has
|
|
109
|
+
* created yet: `src/new/**` -> `src/new/<any>`, `src/*.ts` -> `src/<any>.ts`.
|
|
110
|
+
*/
|
|
111
|
+
export function scopeSamples(pattern: string): string[] {
|
|
112
|
+
// A plain path covers what is below it too, so `src/new` and `src/new/**`
|
|
113
|
+
// overlap through `src/new/<any>` without a second sample.
|
|
114
|
+
if (!hasWildcard(pattern)) return [unescape(pattern)];
|
|
115
|
+
let out = "";
|
|
116
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
117
|
+
const char = pattern[index];
|
|
118
|
+
if (char === "\\" && index + 1 < pattern.length) {
|
|
119
|
+
out += pattern[index + 1];
|
|
120
|
+
index += 1;
|
|
121
|
+
} else if (char === "*" && pattern[index + 1] === "*") {
|
|
122
|
+
const atSegmentStart = index === 0 || pattern[index - 1] === "/";
|
|
123
|
+
if (atSegmentStart && pattern[index + 2] === "/") index += 2;
|
|
124
|
+
else {
|
|
125
|
+
out += "<any>";
|
|
126
|
+
index += 1;
|
|
127
|
+
}
|
|
128
|
+
} else if (char === "*") out += "<any>";
|
|
129
|
+
else if (char === "?") out += "x";
|
|
130
|
+
else if (char === "{" && pattern.indexOf("}", index) > index) {
|
|
131
|
+
const close = pattern.indexOf("}", index);
|
|
132
|
+
out += pattern.slice(index + 1, close).split(",")[0];
|
|
133
|
+
index = close;
|
|
134
|
+
} else out += char;
|
|
135
|
+
}
|
|
136
|
+
return [out];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function globSource(pattern: string): string {
|
|
140
|
+
let out = "";
|
|
141
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
142
|
+
const char = pattern[index];
|
|
143
|
+
if (char === "\\" && index + 1 < pattern.length) {
|
|
144
|
+
out += escapeRegExp(pattern[index + 1]);
|
|
145
|
+
index += 1;
|
|
146
|
+
} else if (char === "*" && pattern[index + 1] === "*") {
|
|
147
|
+
const atSegmentStart = index === 0 || pattern[index - 1] === "/";
|
|
148
|
+
if (atSegmentStart && pattern[index + 2] === "/") {
|
|
149
|
+
out += "(?:[^/]*/)*";
|
|
150
|
+
index += 2;
|
|
151
|
+
} else {
|
|
152
|
+
out += ".*";
|
|
153
|
+
index += 1;
|
|
154
|
+
}
|
|
155
|
+
} else if (char === "*") out += "[^/]*";
|
|
156
|
+
else if (char === "?") out += "[^/]";
|
|
157
|
+
else if (char === "{" && pattern.indexOf("}", index) > index) {
|
|
158
|
+
// An unclosed brace is a literal `{` (stored plans are not trusted).
|
|
159
|
+
const close = pattern.indexOf("}", index);
|
|
160
|
+
const alternatives = pattern
|
|
161
|
+
.slice(index + 1, close)
|
|
162
|
+
.split(",")
|
|
163
|
+
.map(globSource);
|
|
164
|
+
out += `(?:${alternatives.join("|")})`;
|
|
165
|
+
index = close;
|
|
166
|
+
} else out += escapeRegExp(char);
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const compiled = new Map<string, RegExp>();
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Does `file` (repo-relative, `/`-separated) fall under `pattern`? Dotfiles
|
|
175
|
+
* match like any other name, the same on every runtime. A pattern without
|
|
176
|
+
* wildcards also covers everything below it, so `src/api` is a directory
|
|
177
|
+
* scope as well as a file.
|
|
178
|
+
*/
|
|
179
|
+
export function matchesScope(pattern: string, file: string): boolean {
|
|
180
|
+
let regex = compiled.get(pattern);
|
|
181
|
+
if (!regex) {
|
|
182
|
+
regex = new RegExp(`^${globSource(pattern)}$`, "u");
|
|
183
|
+
compiled.set(pattern, regex);
|
|
184
|
+
}
|
|
185
|
+
return regex.test(file) || (!hasWildcard(pattern) && file.startsWith(`${unescape(pattern)}/`));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const matchesAny = (patterns: readonly string[], file: string): boolean =>
|
|
189
|
+
patterns.some((pattern) => matchesScope(pattern, file));
|
|
190
|
+
|
|
191
|
+
/** A scope entry in canonical form, or why it is refused. */
|
|
192
|
+
function normalizePattern(raw: string): { pattern: string } | { error: string } {
|
|
193
|
+
// A backslash escapes a glob character (`app/\\[id\\]`); any other one is a
|
|
194
|
+
// Windows separator.
|
|
195
|
+
let pattern = raw.trim().replace(/\\(?![[\]{}*?])/gu, "/");
|
|
196
|
+
while (pattern.startsWith("./")) pattern = pattern.slice(2);
|
|
197
|
+
if (pattern === "." || pattern === "") pattern = raw.trim() ? "**" : "";
|
|
198
|
+
if (pattern.endsWith("/")) pattern = `${pattern}**`;
|
|
199
|
+
if (!pattern) return { error: "is empty" };
|
|
200
|
+
if (pattern.startsWith("/") || /^[A-Za-z]:/u.test(pattern))
|
|
201
|
+
return { error: "must be relative to the repository root" };
|
|
202
|
+
if (pattern.split("/").includes("..")) return { error: "must not contain .." };
|
|
203
|
+
const bare = pattern.replace(/\\./gu, "");
|
|
204
|
+
if (/[[\]]/u.test(bare))
|
|
205
|
+
return {
|
|
206
|
+
error:
|
|
207
|
+
'has an unescaped [ or ]: write \\[ and \\] for literal brackets (app/\\[id\\]/page.tsx; in JSON "app/\\\\[id\\\\]/page.tsx")',
|
|
208
|
+
};
|
|
209
|
+
if (bare.startsWith("!")) return { error: "uses ! (negation is not supported)" };
|
|
210
|
+
let depth = 0;
|
|
211
|
+
for (const char of bare) {
|
|
212
|
+
if (char === "{") depth += 1;
|
|
213
|
+
if (char === "}") depth -= 1;
|
|
214
|
+
if (depth < 0 || depth > 1) return { error: "has unbalanced or nested braces" };
|
|
215
|
+
}
|
|
216
|
+
if (depth !== 0) return { error: "has unbalanced or nested braces" };
|
|
217
|
+
return { pattern };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The slice may write `file`: its scope or owns matches, and no other slice owns it. */
|
|
221
|
+
export function inScope(plan: FanoutFile, slice: Slice, file: string): boolean {
|
|
222
|
+
if (matchesAny(slice.owns, file)) return true;
|
|
223
|
+
if (!matchesAny(slice.scope, file)) return false;
|
|
224
|
+
return !plan.slices.some((other) => other.id !== slice.id && matchesAny(other.owns, file));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// parse (input and stored file; D17: unknown keys are ignored)
|
|
229
|
+
|
|
230
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
231
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
232
|
+
|
|
233
|
+
const PLACEHOLDER = /^(?:<[^>]*>|tbd|todo|fixme|\?+|\.\.\.|…)$/iu;
|
|
234
|
+
const filled = (text: string): boolean => text.trim() !== "" && !PLACEHOLDER.test(text.trim());
|
|
235
|
+
|
|
236
|
+
/** A string or list of strings; null when absent or of another type. */
|
|
237
|
+
const textList = (value: unknown): string[] | null => {
|
|
238
|
+
if (typeof value === "string") return [value];
|
|
239
|
+
if (Array.isArray(value) && value.every((item) => typeof item === "string")) return value;
|
|
240
|
+
return null;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
|
|
244
|
+
|
|
245
|
+
export type PlanDefaults = {
|
|
246
|
+
trunk: string;
|
|
247
|
+
/** Where default worktrees go: `<repo>-wt/` next to the main checkout. */
|
|
248
|
+
worktreeRoot: string;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
type Parsed = { ok: true; plan: FanoutFile } | { ok: false; problems: string[] };
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Validate a plan document. Every problem is reported at once, so one edit
|
|
255
|
+
* fixes them all; the brief fields GOAL, SCOPE, ACCEPTANCE, VERIFY and
|
|
256
|
+
* FORBIDDEN must be non-empty and not a template placeholder.
|
|
257
|
+
*/
|
|
258
|
+
export function parsePlan(raw: unknown, defaults: PlanDefaults, name?: string | null): Parsed {
|
|
259
|
+
const problems: string[] = [];
|
|
260
|
+
if (!isRecord(raw)) return { ok: false, problems: ["the plan must be a JSON object"] };
|
|
261
|
+
const planName = name ?? (typeof raw.name === "string" ? raw.name.trim() : "");
|
|
262
|
+
if (!planName || planName.length > 200 || /\p{Cc}/u.test(planName))
|
|
263
|
+
problems.push('name: pass --name or set a 1-200 character "name"');
|
|
264
|
+
const trunk =
|
|
265
|
+
typeof raw.trunk === "string" && raw.trunk.trim() ? raw.trunk.trim() : defaults.trunk;
|
|
266
|
+
const shared: string[] = [];
|
|
267
|
+
for (const entry of textList(raw.shared ?? []) ?? ["<not a list>"]) {
|
|
268
|
+
const normalized = normalizePattern(entry);
|
|
269
|
+
if ("error" in normalized) problems.push(`shared "${entry}" ${normalized.error}`);
|
|
270
|
+
else shared.push(normalized.pattern);
|
|
271
|
+
}
|
|
272
|
+
if (!Array.isArray(raw.slices) || raw.slices.length === 0)
|
|
273
|
+
return { ok: false, problems: [...problems, "slices: list at least one slice"] };
|
|
274
|
+
|
|
275
|
+
const slices: Slice[] = [];
|
|
276
|
+
const seenIds = new Set<string>();
|
|
277
|
+
const seenBranches = new Map<string, string>();
|
|
278
|
+
raw.slices.forEach((item, index) => {
|
|
279
|
+
const where =
|
|
280
|
+
isRecord(item) && typeof item.id === "string" ? `slice ${item.id}` : `slice #${index + 1}`;
|
|
281
|
+
if (!isRecord(item)) {
|
|
282
|
+
problems.push(`${where}: must be an object`);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const id = typeof item.id === "string" ? item.id : "";
|
|
286
|
+
if (!ID.test(id)) problems.push(`${where}: id must match ${ID.source}`);
|
|
287
|
+
else if (seenIds.has(id)) problems.push(`${where}: duplicate id`);
|
|
288
|
+
seenIds.add(id);
|
|
289
|
+
const branch = typeof item.branch === "string" ? item.branch.trim() : "";
|
|
290
|
+
if (!branch) problems.push(`${where}: branch is empty`);
|
|
291
|
+
else if (seenBranches.has(branch))
|
|
292
|
+
problems.push(`${where}: branch ${branch} is also slice ${seenBranches.get(branch)}'s`);
|
|
293
|
+
else seenBranches.set(branch, id);
|
|
294
|
+
if (branch === trunk) problems.push(`${where}: branch must not be the trunk ${trunk}`);
|
|
295
|
+
|
|
296
|
+
const goal = typeof item.goal === "string" ? item.goal.trim() : "";
|
|
297
|
+
if (!filled(goal)) problems.push(`${where}: goal is empty`);
|
|
298
|
+
const brief = (field: string): string[] => {
|
|
299
|
+
const list = textList(item[field]);
|
|
300
|
+
const kept = (list ?? []).map((entry) => entry.trim()).filter(filled);
|
|
301
|
+
if (kept.length === 0) problems.push(`${where}: ${field} is empty`);
|
|
302
|
+
return kept;
|
|
303
|
+
};
|
|
304
|
+
const acceptance = brief("acceptance");
|
|
305
|
+
const verify = brief("verify");
|
|
306
|
+
const forbidden = brief("forbidden");
|
|
307
|
+
const scope: string[] = [];
|
|
308
|
+
const owns: string[] = [];
|
|
309
|
+
for (const [field, out] of [
|
|
310
|
+
["scope", scope],
|
|
311
|
+
["owns", owns],
|
|
312
|
+
] as const) {
|
|
313
|
+
const list = textList(item[field] ?? []);
|
|
314
|
+
if (list === null) {
|
|
315
|
+
problems.push(`${where}: ${field} must be a list of globs`);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
for (const entry of list.filter(filled)) {
|
|
319
|
+
const normalized = normalizePattern(entry);
|
|
320
|
+
if ("error" in normalized)
|
|
321
|
+
problems.push(`${where}: ${field} "${entry}" ${normalized.error}`);
|
|
322
|
+
else out.push(normalized.pattern);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (scope.length === 0 && textList(item.scope ?? []) !== null)
|
|
326
|
+
problems.push(`${where}: scope is empty`);
|
|
327
|
+
const tier = item.tier;
|
|
328
|
+
if (typeof tier !== "string" || !(TIERS as readonly string[]).includes(tier))
|
|
329
|
+
problems.push(`${where}: tier must be one of ${TIERS.join(", ")}`);
|
|
330
|
+
const dependsOn = textList(item.dependsOn ?? []);
|
|
331
|
+
if (dependsOn === null) problems.push(`${where}: dependsOn must be a list of slice ids`);
|
|
332
|
+
const optional = (field: string): string | null =>
|
|
333
|
+
typeof item[field] === "string" && filled(item[field]) ? item[field].trim() : null;
|
|
334
|
+
slices.push({
|
|
335
|
+
id,
|
|
336
|
+
branch,
|
|
337
|
+
base: optional("base") ?? "",
|
|
338
|
+
worktree: optional("worktree") ?? path.join(defaults.worktreeRoot, id),
|
|
339
|
+
tier: tier as Tier,
|
|
340
|
+
dependsOn: [...new Set(dependsOn ?? [])],
|
|
341
|
+
scope,
|
|
342
|
+
owns,
|
|
343
|
+
goal,
|
|
344
|
+
acceptance,
|
|
345
|
+
verify,
|
|
346
|
+
forbidden,
|
|
347
|
+
context: optional("context"),
|
|
348
|
+
timebox: optional("timebox"),
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const byId = new Map(slices.map((slice) => [slice.id, slice]));
|
|
353
|
+
const byBranch = new Map(slices.map((slice) => [slice.branch, slice]));
|
|
354
|
+
for (const slice of slices) {
|
|
355
|
+
for (const dep of slice.dependsOn) {
|
|
356
|
+
if (dep === slice.id) problems.push(`slice ${slice.id}: depends on itself`);
|
|
357
|
+
else if (!byId.has(dep)) problems.push(`slice ${slice.id}: depends on unknown slice ${dep}`);
|
|
358
|
+
}
|
|
359
|
+
const explicitBase = slice.base;
|
|
360
|
+
if (explicitBase) {
|
|
361
|
+
const parent = byBranch.get(explicitBase);
|
|
362
|
+
if (parent && !slice.dependsOn.includes(parent.id))
|
|
363
|
+
problems.push(
|
|
364
|
+
`slice ${slice.id}: base ${explicitBase} is slice ${parent.id}'s branch; add "${parent.id}" to its dependsOn`,
|
|
365
|
+
);
|
|
366
|
+
} else {
|
|
367
|
+
const single = slice.dependsOn.length === 1 ? byId.get(slice.dependsOn[0]) : undefined;
|
|
368
|
+
slice.base = single ? single.branch : trunk;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const cycle = findCycle(slices);
|
|
372
|
+
if (cycle) problems.push(`dependency cycle: ${cycle.join(" -> ")}`);
|
|
373
|
+
if (problems.length) return { ok: false, problems };
|
|
374
|
+
return {
|
|
375
|
+
ok: true,
|
|
376
|
+
plan: {
|
|
377
|
+
v: FANOUT_VERSION,
|
|
378
|
+
name: planName,
|
|
379
|
+
trunk,
|
|
380
|
+
shared,
|
|
381
|
+
slices,
|
|
382
|
+
createdAt: "",
|
|
383
|
+
updatedAt: "",
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function findCycle(slices: readonly Slice[]): string[] | null {
|
|
389
|
+
const byId = new Map(slices.map((slice) => [slice.id, slice]));
|
|
390
|
+
const state = new Map<string, "open" | "done">();
|
|
391
|
+
const trail: string[] = [];
|
|
392
|
+
const visit = (id: string): string[] | null => {
|
|
393
|
+
if (state.get(id) === "done") return null;
|
|
394
|
+
if (state.get(id) === "open") return [...trail.slice(trail.indexOf(id)), id];
|
|
395
|
+
state.set(id, "open");
|
|
396
|
+
trail.push(id);
|
|
397
|
+
for (const dep of byId.get(id)?.dependsOn ?? []) {
|
|
398
|
+
if (!byId.has(dep) || dep === id) continue;
|
|
399
|
+
const found = visit(dep);
|
|
400
|
+
if (found) return found;
|
|
401
|
+
}
|
|
402
|
+
trail.pop();
|
|
403
|
+
state.set(id, "done");
|
|
404
|
+
return null;
|
|
405
|
+
};
|
|
406
|
+
for (const slice of slices) {
|
|
407
|
+
const found = visit(slice.id);
|
|
408
|
+
if (found) return found;
|
|
409
|
+
}
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
// ordering
|
|
415
|
+
|
|
416
|
+
/** Dependencies first; among the ready ones, the order the plan lists them. */
|
|
417
|
+
export function landingOrder(slices: readonly Slice[]): string[] {
|
|
418
|
+
const order: string[] = [];
|
|
419
|
+
const placed = new Set<string>();
|
|
420
|
+
const known = new Set(slices.map((slice) => slice.id));
|
|
421
|
+
while (order.length < slices.length) {
|
|
422
|
+
const next = slices.find(
|
|
423
|
+
(slice) =>
|
|
424
|
+
!placed.has(slice.id) && slice.dependsOn.every((dep) => placed.has(dep) || !known.has(dep)),
|
|
425
|
+
);
|
|
426
|
+
if (!next) break;
|
|
427
|
+
order.push(next.id);
|
|
428
|
+
placed.add(next.id);
|
|
429
|
+
}
|
|
430
|
+
return order;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Slices that can run at the same time: wave n waits only for waves before it. */
|
|
434
|
+
export function waves(slices: readonly Slice[]): string[][] {
|
|
435
|
+
const level = new Map<string, number>();
|
|
436
|
+
const byId = new Map(slices.map((slice) => [slice.id, slice]));
|
|
437
|
+
for (const id of landingOrder(slices)) {
|
|
438
|
+
const deps = byId.get(id)?.dependsOn ?? [];
|
|
439
|
+
level.set(id, deps.length ? Math.max(...deps.map((dep) => level.get(dep) ?? 0)) + 1 : 0);
|
|
440
|
+
}
|
|
441
|
+
const out: string[][] = [];
|
|
442
|
+
for (const slice of slices) (out[level.get(slice.id) ?? 0] ??= []).push(slice.id);
|
|
443
|
+
return out.filter(Boolean);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Every slice `id` waits for, directly or through another dependency. */
|
|
447
|
+
function ancestors(slices: readonly Slice[]): Map<string, Set<string>> {
|
|
448
|
+
const byId = new Map(slices.map((slice) => [slice.id, slice]));
|
|
449
|
+
const out = new Map<string, Set<string>>();
|
|
450
|
+
for (const id of landingOrder(slices)) {
|
|
451
|
+
const set = new Set<string>();
|
|
452
|
+
for (const dep of byId.get(id)?.dependsOn ?? []) {
|
|
453
|
+
set.add(dep);
|
|
454
|
+
for (const deeper of out.get(dep) ?? []) set.add(deeper);
|
|
455
|
+
}
|
|
456
|
+
out.set(id, set);
|
|
457
|
+
}
|
|
458
|
+
return out;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
// overlap
|
|
463
|
+
|
|
464
|
+
export type Overlap = {
|
|
465
|
+
path: string;
|
|
466
|
+
slices: string[];
|
|
467
|
+
resolution: "owner" | "serialized";
|
|
468
|
+
owner?: string;
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
type OwnerSuggestion = { kind: "owner"; slice: string; paths: string[]; text: string };
|
|
472
|
+
type Suggestion =
|
|
473
|
+
| OwnerSuggestion
|
|
474
|
+
| { kind: "serialize"; edges: Array<{ slice: string; dependsOn: string }>; text: string };
|
|
475
|
+
|
|
476
|
+
export type OverlapConflict = {
|
|
477
|
+
slices: string[];
|
|
478
|
+
paths: string[];
|
|
479
|
+
/** Every path is a known shared file (lockfile, manifest, barrel, CI config). */
|
|
480
|
+
shared: boolean;
|
|
481
|
+
reason: "overlap" | "several_owners";
|
|
482
|
+
suggestion: Suggestion;
|
|
483
|
+
alternative?: Suggestion;
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
const quoteList = (items: readonly string[]): string => items.map((item) => `"${item}"`).join(", ");
|
|
487
|
+
|
|
488
|
+
const ownerSuggestion = (slice: string, paths: string[]): OwnerSuggestion => ({
|
|
489
|
+
kind: "owner",
|
|
490
|
+
slice,
|
|
491
|
+
paths,
|
|
492
|
+
text: `add ${quoteList(paths)} to "owns" of slice ${slice}`,
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
const serializeSuggestion = (ids: readonly string[]): Suggestion => {
|
|
496
|
+
const edges = ids.slice(1).map((slice, index) => ({ slice, dependsOn: ids[index] }));
|
|
497
|
+
return {
|
|
498
|
+
kind: "serialize",
|
|
499
|
+
edges,
|
|
500
|
+
text: edges
|
|
501
|
+
.map((edge) => `add "${edge.dependsOn}" to "dependsOn" of slice ${edge.slice}`)
|
|
502
|
+
.join("; "),
|
|
503
|
+
};
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Which files more than one slice may write, given the candidate paths.
|
|
508
|
+
* Resolved overlaps (one owner, or every pair serialized by a dependency)
|
|
509
|
+
* are listed; the rest are conflicts grouped by the slices involved.
|
|
510
|
+
*/
|
|
511
|
+
export function analyzeOverlap(
|
|
512
|
+
plan: FanoutFile,
|
|
513
|
+
candidates: Iterable<string>,
|
|
514
|
+
): { overlaps: Overlap[]; conflicts: OverlapConflict[] } {
|
|
515
|
+
const shared = [...DEFAULT_SHARED, ...plan.shared];
|
|
516
|
+
const before = ancestors(plan.slices);
|
|
517
|
+
const related = (a: string, b: string): boolean =>
|
|
518
|
+
Boolean(before.get(a)?.has(b) || before.get(b)?.has(a));
|
|
519
|
+
const overlaps: Overlap[] = [];
|
|
520
|
+
const groups = new Map<string, OverlapConflict & { members: Slice[] }>();
|
|
521
|
+
// Paths that differ only in case are one file on case-insensitive checkouts.
|
|
522
|
+
const byCase = new Map<string, string[]>();
|
|
523
|
+
for (const file of [...new Set(candidates)].toSorted())
|
|
524
|
+
(
|
|
525
|
+
byCase.get(file.toLowerCase()) ?? byCase.set(file.toLowerCase(), []).get(file.toLowerCase())
|
|
526
|
+
)?.push(file);
|
|
527
|
+
for (const spellings of byCase.values()) {
|
|
528
|
+
const file = spellings.join(" ~ ");
|
|
529
|
+
const any = (patterns: readonly string[]) =>
|
|
530
|
+
spellings.some((spelling) => matchesAny(patterns, spelling));
|
|
531
|
+
const matching = plan.slices.filter((slice) => any(slice.scope) || any(slice.owns));
|
|
532
|
+
if (matching.length < 2) continue;
|
|
533
|
+
const ids = matching.map((slice) => slice.id);
|
|
534
|
+
const owners = matching.filter((slice) => any(slice.owns));
|
|
535
|
+
if (owners.length === 1) {
|
|
536
|
+
overlaps.push({ path: file, slices: ids, resolution: "owner", owner: owners[0].id });
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
if (
|
|
540
|
+
owners.length === 0 &&
|
|
541
|
+
matching.every((a, i) => matching.slice(i + 1).every((b) => related(a.id, b.id)))
|
|
542
|
+
) {
|
|
543
|
+
overlaps.push({ path: file, slices: ids, resolution: "serialized" });
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
const members = owners.length > 1 ? owners : matching;
|
|
547
|
+
const reason = owners.length > 1 ? "several_owners" : "overlap";
|
|
548
|
+
const isShared = any(shared);
|
|
549
|
+
const key = `${reason}|${isShared}|${members.map((slice) => slice.id).join(",")}`;
|
|
550
|
+
const group = groups.get(key);
|
|
551
|
+
if (group) group.paths.push(file);
|
|
552
|
+
else
|
|
553
|
+
groups.set(key, {
|
|
554
|
+
slices: members.map((slice) => slice.id),
|
|
555
|
+
paths: [file],
|
|
556
|
+
shared: isShared,
|
|
557
|
+
reason,
|
|
558
|
+
members,
|
|
559
|
+
suggestion: ownerSuggestion(members[0].id, []),
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
const conflicts = [...groups.values()].map(({ members, ...group }) => {
|
|
563
|
+
// The owner: the first slice (plan order) that names one of the paths
|
|
564
|
+
// literally, else the first slice. Serializing keeps plan order.
|
|
565
|
+
const literal = members.find((slice) =>
|
|
566
|
+
slice.scope.some((pattern) => group.paths.includes(pattern)),
|
|
567
|
+
);
|
|
568
|
+
const owner = ownerSuggestion((literal ?? members[0]).id, group.paths);
|
|
569
|
+
if (group.reason === "several_owners")
|
|
570
|
+
return {
|
|
571
|
+
...group,
|
|
572
|
+
suggestion: {
|
|
573
|
+
...owner,
|
|
574
|
+
text: `keep ${quoteList(group.paths)} in "owns" of slice ${owner.slice} only`,
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
const serialize = serializeSuggestion(group.slices);
|
|
578
|
+
return group.shared
|
|
579
|
+
? { ...group, suggestion: owner, alternative: serialize }
|
|
580
|
+
: { ...group, suggestion: serialize, alternative: owner };
|
|
581
|
+
});
|
|
582
|
+
return { overlaps, conflicts };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ---------------------------------------------------------------------------
|
|
586
|
+
// git
|
|
587
|
+
|
|
588
|
+
type GitRun = { ok: boolean; status: number | null; stdout: string; stderr: string };
|
|
589
|
+
|
|
590
|
+
export const gitRun = (cwd: string, args: string[]): GitRun => {
|
|
591
|
+
const run = spawnSync("git", args, {
|
|
592
|
+
cwd,
|
|
593
|
+
encoding: "utf8",
|
|
594
|
+
timeout: GIT_TIMEOUTS.local,
|
|
595
|
+
killSignal: "SIGKILL",
|
|
596
|
+
windowsHide: true,
|
|
597
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
598
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", LC_ALL: "C", LANGUAGE: "C" },
|
|
599
|
+
});
|
|
600
|
+
return {
|
|
601
|
+
ok: run.status === 0,
|
|
602
|
+
status: run.status,
|
|
603
|
+
stdout: run.stdout ?? "",
|
|
604
|
+
stderr: run.error ? String(run.error.message) : (run.stderr ?? ""),
|
|
605
|
+
};
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
export const nulList = (text: string): string[] => text.split("\0").filter(Boolean);
|
|
609
|
+
|
|
610
|
+
/** The commit a branch name points at: the local branch, else its remote-tracking ref. */
|
|
611
|
+
export function branchRef(cwd: string, branch: string): string | null {
|
|
612
|
+
if (!branch || branch.startsWith("-")) return null;
|
|
613
|
+
const local = resolveRef(cwd, `refs/heads/${branch}`);
|
|
614
|
+
if (local) return local;
|
|
615
|
+
const remote = pushRemoteName(cwd) ?? "origin";
|
|
616
|
+
return resolveRef(cwd, `refs/remotes/${remote}/${branch}`);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** The trunk as the forge sees it (remote-tracking ref) when known, else the local branch. */
|
|
620
|
+
export function trunkRef(cwd: string, trunk: string): string | null {
|
|
621
|
+
if (!trunk || trunk.startsWith("-")) return null;
|
|
622
|
+
const remote = pushRemoteName(cwd) ?? "origin";
|
|
623
|
+
for (const ref of [`refs/remotes/${remote}/${trunk}`, `refs/heads/${trunk}`, trunk])
|
|
624
|
+
if (resolveRef(cwd, ref)) return ref;
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** The trunk name when the plan does not give one: origin's default branch, else main. */
|
|
629
|
+
export function defaultTrunk(cwd: string): string {
|
|
630
|
+
const base = defaultBase(cwd);
|
|
631
|
+
return base ? base.replace(/^origin\//u, "") : "main";
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** `<repo>-wt/` beside the main checkout. */
|
|
635
|
+
export function worktreeRoot(cwd: string): string {
|
|
636
|
+
const common = gitCommonDir(cwd);
|
|
637
|
+
const main = common && path.basename(common) === ".git" ? path.dirname(common) : cwd;
|
|
638
|
+
return path.join(path.dirname(main), `${path.basename(main)}-wt`);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// ---------------------------------------------------------------------------
|
|
642
|
+
// store
|
|
643
|
+
|
|
644
|
+
export function fanoutsDir(cwd: string): FanoutResult<string> {
|
|
645
|
+
const root = storeRoot(cwd);
|
|
646
|
+
if (!root.ok) return fanoutFail(root.code, root.error, root.unblock);
|
|
647
|
+
return { ok: true, data: path.join(root.value.root, "fanouts") };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Tolerant read (D17) that still re-validates what the checks rely on: every
|
|
652
|
+
* glob normalizes, every dependency is a known slice, no cycle. A file that
|
|
653
|
+
* fails is "not a valid plan", never half-trusted.
|
|
654
|
+
*/
|
|
655
|
+
function parseStored(raw: unknown): FanoutFile | null {
|
|
656
|
+
if (!isRecord(raw) || typeof raw.name !== "string" || typeof raw.trunk !== "string") return null;
|
|
657
|
+
if (!Array.isArray(raw.slices)) return null;
|
|
658
|
+
const list = (value: unknown): string[] =>
|
|
659
|
+
Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
|
660
|
+
const text = (value: unknown): string => (typeof value === "string" ? value : "");
|
|
661
|
+
let globsValid = true;
|
|
662
|
+
const globs = (value: unknown): string[] =>
|
|
663
|
+
list(value).map((entry) => {
|
|
664
|
+
const normalized = normalizePattern(entry);
|
|
665
|
+
if ("error" in normalized) {
|
|
666
|
+
globsValid = false;
|
|
667
|
+
return entry;
|
|
668
|
+
}
|
|
669
|
+
return normalized.pattern;
|
|
670
|
+
});
|
|
671
|
+
const slices: Slice[] = [];
|
|
672
|
+
for (const item of raw.slices) {
|
|
673
|
+
if (!isRecord(item) || typeof item.id !== "string" || typeof item.branch !== "string")
|
|
674
|
+
return null;
|
|
675
|
+
slices.push({
|
|
676
|
+
id: item.id,
|
|
677
|
+
branch: item.branch,
|
|
678
|
+
base: text(item.base) || raw.trunk,
|
|
679
|
+
worktree: text(item.worktree),
|
|
680
|
+
tier: (TIERS as readonly unknown[]).includes(item.tier) ? (item.tier as Tier) : "standard",
|
|
681
|
+
dependsOn: list(item.dependsOn),
|
|
682
|
+
scope: globs(item.scope),
|
|
683
|
+
owns: globs(item.owns),
|
|
684
|
+
goal: text(item.goal),
|
|
685
|
+
acceptance: list(item.acceptance),
|
|
686
|
+
verify: list(item.verify),
|
|
687
|
+
forbidden: list(item.forbidden),
|
|
688
|
+
context: typeof item.context === "string" ? item.context : null,
|
|
689
|
+
timebox: typeof item.timebox === "string" ? item.timebox : null,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
const shared = globs(raw.shared);
|
|
693
|
+
const ids = new Set(slices.map((slice) => slice.id));
|
|
694
|
+
if (
|
|
695
|
+
!globsValid ||
|
|
696
|
+
ids.size !== slices.length ||
|
|
697
|
+
slices.some((slice) => slice.dependsOn.some((dep) => dep === slice.id || !ids.has(dep))) ||
|
|
698
|
+
findCycle(slices)
|
|
699
|
+
)
|
|
700
|
+
return null;
|
|
701
|
+
return {
|
|
702
|
+
v: typeof raw.v === "number" ? raw.v : FANOUT_VERSION,
|
|
703
|
+
name: raw.name,
|
|
704
|
+
trunk: raw.trunk,
|
|
705
|
+
shared,
|
|
706
|
+
slices,
|
|
707
|
+
createdAt: text(raw.createdAt),
|
|
708
|
+
updatedAt: text(raw.updatedAt),
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
export function readFanout(cwd: string, name: string): FanoutResult<FanoutFile | null> {
|
|
713
|
+
const dir = fanoutsDir(cwd);
|
|
714
|
+
if (!dir.ok) return dir;
|
|
715
|
+
const file = path.join(dir.data, stackFileName(name));
|
|
716
|
+
let parsed: FanoutFile | null = null;
|
|
717
|
+
try {
|
|
718
|
+
parsed = parseStored(JSON.parse(fs.readFileSync(file, "utf8")));
|
|
719
|
+
} catch (error) {
|
|
720
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { ok: true, data: null };
|
|
721
|
+
parsed = null;
|
|
722
|
+
}
|
|
723
|
+
return parsed
|
|
724
|
+
? { ok: true, data: parsed }
|
|
725
|
+
: fanoutFail(
|
|
726
|
+
"failed",
|
|
727
|
+
`fanout file ${file} is not a valid plan`,
|
|
728
|
+
`workit fanout plan <plan.json> --name ${name} # rewrite it`,
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function listFanouts(cwd: string): FanoutResult<FanoutFile[]> {
|
|
733
|
+
const dir = fanoutsDir(cwd);
|
|
734
|
+
if (!dir.ok) return dir;
|
|
735
|
+
let names: string[] = [];
|
|
736
|
+
try {
|
|
737
|
+
names = fs.readdirSync(dir.data).filter((entry) => entry.endsWith(".json"));
|
|
738
|
+
} catch {
|
|
739
|
+
return { ok: true, data: [] };
|
|
740
|
+
}
|
|
741
|
+
const out: FanoutFile[] = [];
|
|
742
|
+
for (const entry of names) {
|
|
743
|
+
let parsed: FanoutFile | null = null;
|
|
744
|
+
try {
|
|
745
|
+
parsed = parseStored(JSON.parse(fs.readFileSync(path.join(dir.data, entry), "utf8")));
|
|
746
|
+
} catch {
|
|
747
|
+
parsed = null;
|
|
748
|
+
}
|
|
749
|
+
if (parsed && stackFileName(parsed.name) === entry) out.push(parsed);
|
|
750
|
+
}
|
|
751
|
+
return { ok: true, data: out.toSorted((a, b) => a.name.localeCompare(b.name)) };
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** The plan `name`, else the one with the current branch as a slice, else the only one. */
|
|
755
|
+
export function selectFanout(
|
|
756
|
+
cwd: string,
|
|
757
|
+
name: string | null,
|
|
758
|
+
current: string | null,
|
|
759
|
+
): FanoutResult<FanoutFile> {
|
|
760
|
+
if (name) {
|
|
761
|
+
const read = readFanout(cwd, name);
|
|
762
|
+
if (!read.ok) return read;
|
|
763
|
+
return read.data
|
|
764
|
+
? { ok: true, data: read.data }
|
|
765
|
+
: fanoutFail("not_found", `no fanout plan named ${name}`, "workit fanout plan <plan.json>");
|
|
766
|
+
}
|
|
767
|
+
const all = listFanouts(cwd);
|
|
768
|
+
if (!all.ok) return all;
|
|
769
|
+
const holding = all.data.filter((plan) => plan.slices.some((slice) => slice.branch === current));
|
|
770
|
+
if (holding.length === 1) return { ok: true, data: holding[0] };
|
|
771
|
+
if (all.data.length === 1) return { ok: true, data: all.data[0] };
|
|
772
|
+
return all.data.length === 0
|
|
773
|
+
? fanoutFail("not_found", "no fanout plan is recorded here", "workit fanout plan <plan.json>")
|
|
774
|
+
: fanoutFail(
|
|
775
|
+
"invalid_input",
|
|
776
|
+
`several fanout plans match; pick one (${all.data.map((plan) => plan.name).join(", ")})`,
|
|
777
|
+
"pass --name <plan>",
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function writeFanout(cwd: string, plan: FanoutFile): FanoutResult<string> {
|
|
782
|
+
const dir = fanoutsDir(cwd);
|
|
783
|
+
if (!dir.ok) return dir;
|
|
784
|
+
const file = path.join(dir.data, stackFileName(plan.name));
|
|
785
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
786
|
+
try {
|
|
787
|
+
fs.mkdirSync(dir.data, { recursive: true });
|
|
788
|
+
fs.writeFileSync(temp, `${JSON.stringify(plan, null, 2)}\n`);
|
|
789
|
+
fs.renameSync(temp, file);
|
|
790
|
+
return { ok: true, data: file };
|
|
791
|
+
} catch (error) {
|
|
792
|
+
fs.rmSync(temp, { force: true });
|
|
793
|
+
return fanoutFail("unavailable", `cannot write ${file}: ${(error as Error).message}`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// ---------------------------------------------------------------------------
|
|
798
|
+
// plan
|
|
799
|
+
|
|
800
|
+
export type PlanOutcome = {
|
|
801
|
+
name: string;
|
|
802
|
+
trunk: string;
|
|
803
|
+
file: string;
|
|
804
|
+
created: boolean;
|
|
805
|
+
slices: Array<
|
|
806
|
+
Pick<Slice, "id" | "branch" | "base" | "worktree" | "tier" | "dependsOn" | "scope" | "owns">
|
|
807
|
+
>;
|
|
808
|
+
overlaps: Overlap[];
|
|
809
|
+
waves: string[][];
|
|
810
|
+
landingOrder: string[];
|
|
811
|
+
notes: string[];
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
/** Above this many slices in one wave, the lead queues the rest (4-6 in flight). */
|
|
815
|
+
export const IN_FLIGHT_CAP = 6;
|
|
816
|
+
|
|
817
|
+
export function planFanout(
|
|
818
|
+
cwd: string,
|
|
819
|
+
input: { raw: unknown; name: string | null; trunk: string | null; actor: LedgerActor },
|
|
820
|
+
now: Date = new Date(),
|
|
821
|
+
): FanoutResult<PlanOutcome> {
|
|
822
|
+
const dir = fanoutsDir(cwd);
|
|
823
|
+
if (!dir.ok) return dir;
|
|
824
|
+
const parsed = parsePlan(
|
|
825
|
+
input.trunk && isRecord(input.raw) ? { ...input.raw, trunk: input.trunk } : input.raw,
|
|
826
|
+
{ trunk: input.trunk ?? defaultTrunk(cwd), worktreeRoot: worktreeRoot(cwd) },
|
|
827
|
+
input.name,
|
|
828
|
+
);
|
|
829
|
+
if (!parsed.ok)
|
|
830
|
+
return fanoutFail(
|
|
831
|
+
"invalid_input",
|
|
832
|
+
`the plan is incomplete: ${parsed.problems[0]}${parsed.problems.length > 1 ? ` (+${parsed.problems.length - 1} more)` : ""}`,
|
|
833
|
+
"fill every listed field, then run workit fanout plan again",
|
|
834
|
+
{ problems: parsed.problems },
|
|
835
|
+
);
|
|
836
|
+
const plan = parsed.plan;
|
|
837
|
+
const badBranches = plan.slices
|
|
838
|
+
.filter((slice) => !gitRun(cwd, ["check-ref-format", "--branch", slice.branch]).ok)
|
|
839
|
+
.map((slice) => `slice ${slice.id}: ${slice.branch} is not a valid branch name`);
|
|
840
|
+
if (badBranches.length)
|
|
841
|
+
return fanoutFail("invalid_input", badBranches[0], "rename the branch in the plan", {
|
|
842
|
+
problems: badBranches,
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
const ref = trunkRef(cwd, plan.trunk);
|
|
846
|
+
const tracked = ref
|
|
847
|
+
? nulList(gitRun(cwd, ["ls-tree", "-r", "-z", "--name-only", ref]).stdout)
|
|
848
|
+
: [];
|
|
849
|
+
const samples = plan.slices.flatMap((slice) =>
|
|
850
|
+
[...slice.scope, ...slice.owns].flatMap(scopeSamples),
|
|
851
|
+
);
|
|
852
|
+
const { overlaps, conflicts } = analyzeOverlap(plan, [...tracked, ...samples]);
|
|
853
|
+
if (conflicts.length)
|
|
854
|
+
return fanoutFail(
|
|
855
|
+
"blocked",
|
|
856
|
+
`${conflicts.length} file-scope overlap${conflicts.length === 1 ? "" : "s"} between slices: ${conflicts[0].slices.join(" and ")} on ${conflicts[0].paths[0]}${conflicts[0].paths.length > 1 ? ` (+${conflicts[0].paths.length - 1})` : ""}`,
|
|
857
|
+
conflicts[0].suggestion.text,
|
|
858
|
+
{ name: plan.name, conflicts, overlaps },
|
|
859
|
+
);
|
|
860
|
+
|
|
861
|
+
const existing = readFanout(cwd, plan.name);
|
|
862
|
+
const previous = existing.ok ? existing.data : null;
|
|
863
|
+
plan.createdAt = previous?.createdAt || now.toISOString();
|
|
864
|
+
plan.updatedAt = now.toISOString();
|
|
865
|
+
const written = writeFanout(cwd, plan);
|
|
866
|
+
if (!written.ok) return written;
|
|
867
|
+
appendObserved(cwd, {
|
|
868
|
+
type: "fanout.planned",
|
|
869
|
+
actor: input.actor,
|
|
870
|
+
fanout: plan.name,
|
|
871
|
+
trunk: plan.trunk,
|
|
872
|
+
slices: plan.slices.length,
|
|
873
|
+
ids: plan.slices.slice(0, 20).map((slice) => slice.id),
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
const grouped = waves(plan.slices);
|
|
877
|
+
const notes: string[] = [];
|
|
878
|
+
if (!ref)
|
|
879
|
+
notes.push(
|
|
880
|
+
`trunk ${plan.trunk} does not resolve here; only sample paths of each glob were compared`,
|
|
881
|
+
);
|
|
882
|
+
grouped.forEach((wave, index) => {
|
|
883
|
+
if (wave.length > IN_FLIGHT_CAP)
|
|
884
|
+
notes.push(
|
|
885
|
+
`wave ${index + 1} has ${wave.length} slices; keep 4-${IN_FLIGHT_CAP} in flight and queue the rest`,
|
|
886
|
+
);
|
|
887
|
+
});
|
|
888
|
+
for (const slice of plan.slices)
|
|
889
|
+
if (slice.dependsOn.length > 1 && slice.base === plan.trunk)
|
|
890
|
+
notes.push(
|
|
891
|
+
`slice ${slice.id} depends on ${slice.dependsOn.join(", ")}: start it from ${plan.trunk} after they land`,
|
|
892
|
+
);
|
|
893
|
+
return {
|
|
894
|
+
ok: true,
|
|
895
|
+
data: {
|
|
896
|
+
name: plan.name,
|
|
897
|
+
trunk: plan.trunk,
|
|
898
|
+
file: written.data,
|
|
899
|
+
created: previous === null,
|
|
900
|
+
slices: plan.slices.map(({ id, branch, base, worktree, tier, dependsOn, scope, owns }) => ({
|
|
901
|
+
id,
|
|
902
|
+
branch,
|
|
903
|
+
base,
|
|
904
|
+
worktree,
|
|
905
|
+
tier,
|
|
906
|
+
dependsOn,
|
|
907
|
+
scope,
|
|
908
|
+
owns,
|
|
909
|
+
})),
|
|
910
|
+
overlaps,
|
|
911
|
+
waves: grouped,
|
|
912
|
+
landingOrder: landingOrder(plan.slices),
|
|
913
|
+
notes,
|
|
914
|
+
},
|
|
915
|
+
};
|
|
916
|
+
}
|