@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
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// Has a fanout slice landed? Shared by `fanout status` and `fanout check`, so
|
|
2
|
+
// a landed slice is done for both and its dependents stop waiting.
|
|
3
|
+
//
|
|
4
|
+
// With the forge, the slice branch's PR reads merged. A squash merge never
|
|
5
|
+
// makes the branch an ancestor of the trunk, so this is the only reliable
|
|
6
|
+
// signal; it holds while the branch has nothing newer than the merged head.
|
|
7
|
+
// Without the forge (offline, no gh/glab, no PR yet), git decides:
|
|
8
|
+
// - on_trunk: the branch tip is on the trunk and its reflog proves commits of
|
|
9
|
+
// its own (a commit, cherry-pick or rebase made on it, inside start..head);
|
|
10
|
+
// a branch only fast-forwarded to a newer trunk or parent has none;
|
|
11
|
+
// - squash_patch: the branch's change has the same patch-id as one trunk
|
|
12
|
+
// commit (a squash merge that applied without the trunk touching the same
|
|
13
|
+
// lines). Anything else reads as not landed.
|
|
14
|
+
// A merged PR whose branch is gone counts only when the ledger links that
|
|
15
|
+
// branch to this slice (a worktree row, or a row recorded on its merged
|
|
16
|
+
// head), so a reused branch name from older work is not taken for this slice.
|
|
17
|
+
// A landing whose changed paths read on the trunk exactly as before it (a
|
|
18
|
+
// revert) is not landed; the note says so.
|
|
19
|
+
// The forge is asked through a breaker: after the first unavailable answer
|
|
20
|
+
// (gh/glab missing, not logged in, timed out) it is not asked again in the
|
|
21
|
+
// same run, and git decides the rest.
|
|
22
|
+
import { spawnSync } from "node:child_process";
|
|
23
|
+
import { GIT_TIMEOUTS, hasCommit, mergeBase, patchId } from "./git/rev";
|
|
24
|
+
import type { ResolvedForge } from "./forge/resolve";
|
|
25
|
+
import type { ForgeResult, PrRef } from "./forge/types";
|
|
26
|
+
import { gitRun, type Slice } from "./fanout";
|
|
27
|
+
|
|
28
|
+
export type LandedHow = "pr_merged" | "on_trunk" | "squash_patch";
|
|
29
|
+
|
|
30
|
+
export type SliceLanding = {
|
|
31
|
+
landed: boolean;
|
|
32
|
+
how: LandedHow | null;
|
|
33
|
+
/** The slice's PR when the forge was asked and has one (open, closed or merged). */
|
|
34
|
+
pr: PrRef | null;
|
|
35
|
+
/** The merged head: a stacked child's diff base once the branch is gone. */
|
|
36
|
+
landedHead: string | null;
|
|
37
|
+
/** Why the forge could not answer for this slice; null when it did or was not asked. */
|
|
38
|
+
forgeError: string | null;
|
|
39
|
+
note: string | null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type LandingContext = {
|
|
43
|
+
cwd: string;
|
|
44
|
+
/** The trunk ref (e.g. refs/remotes/origin/main). */
|
|
45
|
+
trunk: string;
|
|
46
|
+
forge: ResolvedForge | null;
|
|
47
|
+
/** Trunk patch-ids per fork point, filled lazily. */
|
|
48
|
+
cache?: Map<string, Set<string>>;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type ForgeBreaker = {
|
|
52
|
+
/** The forge whose calls stop after the first unavailable answer; null when there is none. */
|
|
53
|
+
forge: ResolvedForge | null;
|
|
54
|
+
/** Why it went down during this run, else null. */
|
|
55
|
+
down: () => string | null;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Wrap the forge so a missing, logged-out or hanging gh/glab costs one call
|
|
60
|
+
* per run, not one timeout per slice: after the first `unavailable` answer
|
|
61
|
+
* every later call fails at once with the same error.
|
|
62
|
+
*/
|
|
63
|
+
export function forgeBreaker(resolved: ResolvedForge | null): ForgeBreaker {
|
|
64
|
+
if (!resolved) return { forge: null, down: () => null };
|
|
65
|
+
let tripped: string | null = null;
|
|
66
|
+
const guard =
|
|
67
|
+
<A extends unknown[], T>(call: (...args: A) => ForgeResult<T>) =>
|
|
68
|
+
(...args: A): ForgeResult<T> => {
|
|
69
|
+
if (tripped) return { ok: false, code: "unavailable", error: tripped };
|
|
70
|
+
const result = call(...args);
|
|
71
|
+
if (!result.ok && result.code === "unavailable") tripped = result.error;
|
|
72
|
+
return result;
|
|
73
|
+
};
|
|
74
|
+
const inner = resolved.forge;
|
|
75
|
+
const forge = Object.create(inner) as typeof inner;
|
|
76
|
+
forge.findPr = guard(inner.findPr.bind(inner));
|
|
77
|
+
forge.prStatus = guard(inner.prStatus.bind(inner));
|
|
78
|
+
return { forge: { ...resolved, forge }, down: () => tripped };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const isAncestor = (cwd: string, a: string, b: string): boolean =>
|
|
82
|
+
gitRun(cwd, ["merge-base", "--is-ancestor", a, b]).ok;
|
|
83
|
+
|
|
84
|
+
/** Where a local branch began (its oldest reflog entry), or null. */
|
|
85
|
+
export function branchCreatedAt(cwd: string, branch: string): string | null {
|
|
86
|
+
if (!branch || branch.startsWith("-")) return null;
|
|
87
|
+
const log = gitRun(cwd, ["log", "-g", "--format=%H", `refs/heads/${branch}`, "--"]);
|
|
88
|
+
if (!log.ok) return null;
|
|
89
|
+
return log.stdout.trim().split("\n").findLast(Boolean) ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Does the branch's reflog show a commit made on it inside `start..head`?
|
|
94
|
+
* A commit, cherry-pick or rebased pick counts; a fast-forward, reset or
|
|
95
|
+
* branch creation does not, and neither does a rebase that only moved it.
|
|
96
|
+
*/
|
|
97
|
+
export function hasOwnCommits(
|
|
98
|
+
cwd: string,
|
|
99
|
+
branch: string,
|
|
100
|
+
start: string | null,
|
|
101
|
+
head: string,
|
|
102
|
+
): boolean {
|
|
103
|
+
if (!branch || branch.startsWith("-")) return false;
|
|
104
|
+
const log = gitRun(cwd, ["log", "-g", "--format=%H%x00%gs", `refs/heads/${branch}`, "--"]);
|
|
105
|
+
if (!log.ok) return false;
|
|
106
|
+
for (const line of log.stdout.split("\n")) {
|
|
107
|
+
const [sha, subject = ""] = line.split("\0");
|
|
108
|
+
if (!sha) continue;
|
|
109
|
+
const made =
|
|
110
|
+
/^(?:commit(?: \((?:initial|amend)\))?|cherry-pick|rebase(?: -i)? \(pick\)):/u.test(subject);
|
|
111
|
+
const onto = /^rebase(?: -i)? \(finish\): \S+ onto ([0-9a-f]+)/u.exec(subject)?.[1];
|
|
112
|
+
if (!made && !(onto && !sha.startsWith(onto) && !onto.startsWith(sha))) continue;
|
|
113
|
+
if (sha !== head && !isAncestor(cwd, sha, head)) continue;
|
|
114
|
+
if (start && (sha === start || isAncestor(cwd, sha, start))) continue;
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The landing was undone: every path the change touched reads on the trunk
|
|
122
|
+
* exactly as before it (`git revert`, or a manual undo).
|
|
123
|
+
*/
|
|
124
|
+
function reverted(cwd: string, trunk: string, landedHead: string, start: string | null): boolean {
|
|
125
|
+
const fork = mergeBase(cwd, landedHead, trunk);
|
|
126
|
+
if (fork && fork !== landedHead) return unchangedSince(cwd, fork, landedHead, trunk);
|
|
127
|
+
// On the trunk already (merge or fast-forward): compare with where it began.
|
|
128
|
+
const before =
|
|
129
|
+
start && start !== landedHead && isAncestor(cwd, start, landedHead)
|
|
130
|
+
? start
|
|
131
|
+
: gitRun(cwd, ["rev-parse", "--verify", "-q", `${landedHead}^1`]).stdout.trim();
|
|
132
|
+
return before ? unchangedSince(cwd, before, landedHead, trunk) : false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function unchangedSince(cwd: string, before: string, after: string, trunk: string): boolean {
|
|
136
|
+
const paths = gitRun(cwd, ["diff", "--name-only", "--no-renames", "-z", before, after]);
|
|
137
|
+
const files = paths.ok ? paths.stdout.split("\0").filter(Boolean) : [];
|
|
138
|
+
if (files.length === 0) return false;
|
|
139
|
+
return gitRun(cwd, ["diff", "--quiet", before, trunk, "--", ...files]).ok;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const TRUNK_SCAN = 500;
|
|
143
|
+
|
|
144
|
+
/** Stable patch-ids of the trunk's own commits since `fork` (same pinned diff options as patchId). */
|
|
145
|
+
function trunkPatchIds(ctx: LandingContext, fork: string): Set<string> {
|
|
146
|
+
const cache = (ctx.cache ??= new Map());
|
|
147
|
+
const known = cache.get(fork);
|
|
148
|
+
if (known) return known;
|
|
149
|
+
const ids = new Set<string>();
|
|
150
|
+
const log = gitRun(ctx.cwd, [
|
|
151
|
+
"log",
|
|
152
|
+
"--no-merges",
|
|
153
|
+
`--max-count=${TRUNK_SCAN}`,
|
|
154
|
+
"-p",
|
|
155
|
+
"--format=commit %H",
|
|
156
|
+
"-U3",
|
|
157
|
+
"--diff-algorithm=myers",
|
|
158
|
+
"--indent-heuristic",
|
|
159
|
+
"--no-color",
|
|
160
|
+
"--no-ext-diff",
|
|
161
|
+
"--no-textconv",
|
|
162
|
+
"--no-renames",
|
|
163
|
+
"--full-index",
|
|
164
|
+
"--src-prefix=a/",
|
|
165
|
+
"--dst-prefix=b/",
|
|
166
|
+
`${fork}..${ctx.trunk}`,
|
|
167
|
+
"--",
|
|
168
|
+
]);
|
|
169
|
+
if (log.ok && log.stdout.trim()) {
|
|
170
|
+
const run = spawnSync("git", ["patch-id", "--stable"], {
|
|
171
|
+
cwd: ctx.cwd,
|
|
172
|
+
input: log.stdout,
|
|
173
|
+
encoding: "utf8",
|
|
174
|
+
timeout: GIT_TIMEOUTS.local,
|
|
175
|
+
windowsHide: true,
|
|
176
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
177
|
+
});
|
|
178
|
+
for (const line of (run.stdout ?? "").split("\n")) {
|
|
179
|
+
const id = line.trim().split(/\s+/u)[0];
|
|
180
|
+
if (id) ids.add(id);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
cache.set(fork, ids);
|
|
184
|
+
return ids;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The ledger links `branch` to this slice: a worktree row, or a row recorded on `head`. */
|
|
188
|
+
function linked(
|
|
189
|
+
rows: readonly Record<string, unknown>[],
|
|
190
|
+
input: Pick<LandingInput, "fanout" | "since">,
|
|
191
|
+
slice: Pick<Slice, "id" | "branch">,
|
|
192
|
+
head: string | null,
|
|
193
|
+
): boolean {
|
|
194
|
+
// Only rows from this run of the fanout: a plan of the same name made
|
|
195
|
+
// earlier must not lend its merged PRs to this one.
|
|
196
|
+
const since = input.since ? Date.parse(input.since) : Number.NaN;
|
|
197
|
+
return rows.some(
|
|
198
|
+
(row) =>
|
|
199
|
+
(Number.isNaN(since) || Date.parse(String(row.at)) >= since) &&
|
|
200
|
+
((row.type === "fanout.worktree.created" &&
|
|
201
|
+
row.fanout === input.fanout &&
|
|
202
|
+
row.slice === slice.id) ||
|
|
203
|
+
(head !== null && row.branch === slice.branch && row.head === head)),
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export type LandingInput = {
|
|
208
|
+
/** The branch tip; null when the branch is gone. */
|
|
209
|
+
head: string | null;
|
|
210
|
+
/** The commit it began at when known (reflog or the worktree ledger row). */
|
|
211
|
+
createdFrom: string | null;
|
|
212
|
+
/** Its dependency branch's tip (or merged head) for a stacked slice. */
|
|
213
|
+
parentHead: string | null;
|
|
214
|
+
fanout: string;
|
|
215
|
+
/** When this fanout plan was first made (FanoutFile.createdAt): older rows do not link. */
|
|
216
|
+
since: string | null;
|
|
217
|
+
rows: readonly Record<string, unknown>[];
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/** Has `slice` landed on the trunk? */
|
|
221
|
+
export function detectLanding(
|
|
222
|
+
ctx: LandingContext,
|
|
223
|
+
slice: Pick<Slice, "id" | "branch">,
|
|
224
|
+
input: LandingInput,
|
|
225
|
+
): SliceLanding {
|
|
226
|
+
const out: SliceLanding = {
|
|
227
|
+
landed: false,
|
|
228
|
+
how: null,
|
|
229
|
+
pr: null,
|
|
230
|
+
landedHead: null,
|
|
231
|
+
forgeError: null,
|
|
232
|
+
note: null,
|
|
233
|
+
};
|
|
234
|
+
const landed = (how: LandedHow, landedHead: string): SliceLanding => {
|
|
235
|
+
if (
|
|
236
|
+
!hasCommit(ctx.cwd, landedHead) ||
|
|
237
|
+
!reverted(ctx.cwd, ctx.trunk, landedHead, input.createdFrom)
|
|
238
|
+
)
|
|
239
|
+
return { ...out, landed: true, how, landedHead };
|
|
240
|
+
return {
|
|
241
|
+
...out,
|
|
242
|
+
note: `landed (${how}${out.pr ? ` #${out.pr.number}` : ""}), then reverted on the trunk: its paths read as before it`,
|
|
243
|
+
};
|
|
244
|
+
};
|
|
245
|
+
const { head } = input;
|
|
246
|
+
if (ctx.forge) {
|
|
247
|
+
const found = ctx.forge.forge.findPr(slice.branch, {
|
|
248
|
+
owner: ctx.forge.headRepo.split("/")[0] ?? null,
|
|
249
|
+
projectId: ctx.forge.headProjectId,
|
|
250
|
+
sha: head,
|
|
251
|
+
});
|
|
252
|
+
if (!found.ok) out.forgeError = found.error;
|
|
253
|
+
else if (found.data) {
|
|
254
|
+
out.pr = found.data;
|
|
255
|
+
if (found.data.state === "merged") {
|
|
256
|
+
const merged = found.data.headSha;
|
|
257
|
+
const label = `#${found.data.number}`;
|
|
258
|
+
if (!head) {
|
|
259
|
+
if (!linked(input.rows, input, slice, merged)) {
|
|
260
|
+
out.pr = null;
|
|
261
|
+
out.note = `${slice.branch} is gone; merged ${label} is not linked to this slice in the ledger (an older branch of the same name?)`;
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
return merged ? landed("pr_merged", merged) : { ...out, landed: true, how: "pr_merged" };
|
|
265
|
+
}
|
|
266
|
+
if (!merged || head === merged || isAncestor(ctx.cwd, head, merged))
|
|
267
|
+
return landed("pr_merged", merged ?? head);
|
|
268
|
+
out.note = isAncestor(ctx.cwd, merged, head)
|
|
269
|
+
? `${label} merged at ${merged.slice(0, 12)}; ${slice.branch} is ahead of it with newer commits`
|
|
270
|
+
: `${label} merged at ${merged.slice(0, 12)}; ${slice.branch} has diverged from it (rewritten after the merge)`;
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (!head) return out;
|
|
276
|
+
const fork = mergeBase(ctx.cwd, head, ctx.trunk);
|
|
277
|
+
if (fork === head) {
|
|
278
|
+
// On the trunk: landed only when commits were made on the branch itself.
|
|
279
|
+
return hasOwnCommits(ctx.cwd, slice.branch, input.createdFrom, head)
|
|
280
|
+
? landed("on_trunk", head)
|
|
281
|
+
: out;
|
|
282
|
+
}
|
|
283
|
+
if (!fork) return out;
|
|
284
|
+
const trunkIds = trunkPatchIds(ctx, fork);
|
|
285
|
+
if (trunkIds.size === 0) return out;
|
|
286
|
+
const froms = [fork];
|
|
287
|
+
if (input.parentHead && input.parentHead !== head && isAncestor(ctx.cwd, input.parentHead, head))
|
|
288
|
+
froms.push(input.parentHead);
|
|
289
|
+
for (const from of froms) {
|
|
290
|
+
const id = patchId(ctx.cwd, from, head);
|
|
291
|
+
if (id && trunkIds.has(id)) return landed("squash_patch", head);
|
|
292
|
+
}
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The commit a slice branch began at: its reflog, else `fanout worktree create`'s ledger row. */
|
|
297
|
+
export function sliceStart(
|
|
298
|
+
cwd: string,
|
|
299
|
+
rows: readonly Record<string, unknown>[],
|
|
300
|
+
fanout: string,
|
|
301
|
+
slice: Pick<Slice, "id" | "branch">,
|
|
302
|
+
): string | null {
|
|
303
|
+
const reflog = branchCreatedAt(cwd, slice.branch);
|
|
304
|
+
if (reflog) return reflog;
|
|
305
|
+
const row = rows.findLast(
|
|
306
|
+
(candidate) =>
|
|
307
|
+
candidate.type === "fanout.worktree.created" &&
|
|
308
|
+
candidate.fanout === fanout &&
|
|
309
|
+
candidate.slice === slice.id &&
|
|
310
|
+
typeof candidate.baseSha === "string",
|
|
311
|
+
);
|
|
312
|
+
return (row?.baseSha as string | undefined) ?? null;
|
|
313
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// `workit fanout status` (G3): one dashboard row per slice, derived from side
|
|
2
|
+
// effects only (git, the forge, the ledger), never from what a worker said.
|
|
3
|
+
//
|
|
4
|
+
// Per slice: whether its branch exists and its head, the age of its last
|
|
5
|
+
// commit, its PR and CI state (when the forge answers), the ledger verdict on
|
|
6
|
+
// that head, and whether it landed (fanout-landed.ts, squash merges
|
|
7
|
+
// included). A slice is STUCK when it started, has not landed, has no
|
|
8
|
+
// accepted verdict, and nothing moved for longer than the threshold: no new
|
|
9
|
+
// commit or branch update, no ledger row for its branch or slice. The
|
|
10
|
+
// threshold is --stuck-after, else the slice's TIMEBOX when it reads as a
|
|
11
|
+
// duration, else 30 minutes.
|
|
12
|
+
//
|
|
13
|
+
// Landable: not landed, an accepted verdict on the current head and, when the
|
|
14
|
+
// forge answered, an open PR whose checks pass. The suggested landing order
|
|
15
|
+
// walks the plan's order (dependencies first), skips landed slices and keeps
|
|
16
|
+
// a slice only once each dependency has landed or comes earlier in the order.
|
|
17
|
+
// Without the forge (offline, no gh/glab) the rest still works and says so.
|
|
18
|
+
import { parseDuration } from "./duration";
|
|
19
|
+
import { checksState, gatingChecks, missingRequired, type ChecksState } from "./forge/report";
|
|
20
|
+
import type { ResolvedForge } from "./forge/resolve";
|
|
21
|
+
import { checkVerdicts, readLedger, type ReadRow, type VerdictBasis } from "./ledger";
|
|
22
|
+
import { sliceLandings } from "./fanout-check";
|
|
23
|
+
import { forgeBreaker } from "./fanout-landed";
|
|
24
|
+
import {
|
|
25
|
+
branchRef,
|
|
26
|
+
fanoutFail,
|
|
27
|
+
gitRun,
|
|
28
|
+
landingOrder,
|
|
29
|
+
trunkRef,
|
|
30
|
+
type FanoutFile,
|
|
31
|
+
type FanoutResult,
|
|
32
|
+
type Slice,
|
|
33
|
+
} from "./fanout";
|
|
34
|
+
import type { LandedHow } from "./fanout-landed";
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_STUCK_MS = 30 * 60_000;
|
|
37
|
+
|
|
38
|
+
export type SliceState = "landed" | "ready" | "stuck" | "active" | "waiting" | "not_started";
|
|
39
|
+
|
|
40
|
+
export type SliceStatusRow = {
|
|
41
|
+
id: string;
|
|
42
|
+
branch: string;
|
|
43
|
+
base: string;
|
|
44
|
+
worktree: string;
|
|
45
|
+
dependsOn: string[];
|
|
46
|
+
branchExists: boolean;
|
|
47
|
+
head: string | null;
|
|
48
|
+
lastCommitAt: string | null;
|
|
49
|
+
/** Newest side effect: commit, branch update (reflog) or ledger row. */
|
|
50
|
+
lastActivityAt: string | null;
|
|
51
|
+
idleMinutes: number | null;
|
|
52
|
+
stuckAfterMinutes: number;
|
|
53
|
+
pr: { number: number; url: string; state: string } | null;
|
|
54
|
+
/** passing | failing | pending | none, or unknown without the forge or a PR. */
|
|
55
|
+
ci: ChecksState | "unknown";
|
|
56
|
+
verdict: { accepted: boolean; basis: VerdictBasis; review: string };
|
|
57
|
+
landed: { how: LandedHow; pr: number | null } | null;
|
|
58
|
+
stuck: boolean;
|
|
59
|
+
/** Dependencies that have not landed. */
|
|
60
|
+
waitsFor: string[];
|
|
61
|
+
state: SliceState;
|
|
62
|
+
/** Why it is not landable yet. */
|
|
63
|
+
reasons: string[];
|
|
64
|
+
notes: string[];
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type StatusOutcome = {
|
|
68
|
+
name: string;
|
|
69
|
+
trunk: string;
|
|
70
|
+
trunkRef: string | null;
|
|
71
|
+
forge: { available: boolean; error: string | null };
|
|
72
|
+
slices: SliceStatusRow[];
|
|
73
|
+
/** Landable slices in the order to land them. */
|
|
74
|
+
landingOrder: string[];
|
|
75
|
+
/** Not started; every dependency landed (or, stacked, its parent has an accepted verdict). */
|
|
76
|
+
spawnable: string[];
|
|
77
|
+
stuck: string[];
|
|
78
|
+
next: string;
|
|
79
|
+
notes: string[];
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type StatusOptions = {
|
|
83
|
+
forge: ResolvedForge | null;
|
|
84
|
+
/** Why the forge is not available (shown in notes). */
|
|
85
|
+
forgeError?: string | null;
|
|
86
|
+
stuckAfterMs?: number | null;
|
|
87
|
+
now?: Date;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** A TIMEBOX such as `45 minutes`, `2h` or `1h30m`, in ms; null without a unit or otherwise. */
|
|
91
|
+
export function timeboxMs(text: string | null): number | null {
|
|
92
|
+
if (!text || /^\s*\d+(?:\.\d+)?\s*$/u.test(text)) return null;
|
|
93
|
+
const ms = parseDuration(text);
|
|
94
|
+
return ms && ms > 0 ? ms : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const isoOf = (seconds: string): string | null => {
|
|
98
|
+
const value = Number(seconds.trim());
|
|
99
|
+
return Number.isFinite(value) && value > 0 ? new Date(value * 1000).toISOString() : null;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const newest = (...values: Array<string | null | undefined>): string | null =>
|
|
103
|
+
values
|
|
104
|
+
.filter((value): value is string => Boolean(value) && !Number.isNaN(Date.parse(value ?? "")))
|
|
105
|
+
.reduce<string | null>(
|
|
106
|
+
(best, value) => (best === null || Date.parse(value) > Date.parse(best) ? value : best),
|
|
107
|
+
null,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
function activity(cwd: string, plan: FanoutFile, slice: Slice, rows: readonly ReadRow[]) {
|
|
111
|
+
const head = branchRef(cwd, slice.branch);
|
|
112
|
+
const commit = head ? isoOf(gitRun(cwd, ["log", "-1", "--format=%ct", head, "--"]).stdout) : null;
|
|
113
|
+
// `refs/heads/<b>@{<unix time>}`: when the local branch last moved.
|
|
114
|
+
const moved = /@\{(\d+)\}/u.exec(
|
|
115
|
+
gitRun(cwd, [
|
|
116
|
+
"log",
|
|
117
|
+
"-g",
|
|
118
|
+
"-1",
|
|
119
|
+
"--date=unix",
|
|
120
|
+
"--format=%gd",
|
|
121
|
+
`refs/heads/${slice.branch}`,
|
|
122
|
+
"--",
|
|
123
|
+
]).stdout,
|
|
124
|
+
);
|
|
125
|
+
const reflog = moved ? isoOf(moved[1]) : null;
|
|
126
|
+
const ledger = rows
|
|
127
|
+
.filter(
|
|
128
|
+
(row) => row.branch === slice.branch || (row.fanout === plan.name && row.slice === slice.id),
|
|
129
|
+
)
|
|
130
|
+
.map((row) => row.at);
|
|
131
|
+
return {
|
|
132
|
+
head,
|
|
133
|
+
commit,
|
|
134
|
+
last: newest(commit, reflog, ...ledger),
|
|
135
|
+
worktreeCreated: rows.some(
|
|
136
|
+
(row) =>
|
|
137
|
+
row.type === "fanout.worktree.created" &&
|
|
138
|
+
row.fanout === plan.name &&
|
|
139
|
+
row.slice === slice.id,
|
|
140
|
+
),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function fanoutStatus(
|
|
145
|
+
cwd: string,
|
|
146
|
+
plan: FanoutFile,
|
|
147
|
+
options: StatusOptions,
|
|
148
|
+
): FanoutResult<StatusOutcome> {
|
|
149
|
+
const now = options.now ?? new Date();
|
|
150
|
+
const breaker = forgeBreaker(options.forge);
|
|
151
|
+
const forge = breaker.forge;
|
|
152
|
+
const trunk = trunkRef(cwd, plan.trunk);
|
|
153
|
+
if (!trunk)
|
|
154
|
+
return fanoutFail(
|
|
155
|
+
"unavailable",
|
|
156
|
+
`trunk ${plan.trunk} does not resolve here`,
|
|
157
|
+
`git fetch origin ${plan.trunk}`,
|
|
158
|
+
);
|
|
159
|
+
const ledger = readLedger(cwd);
|
|
160
|
+
const rows: readonly ReadRow[] = ledger.ok ? ledger.value.rows : [];
|
|
161
|
+
const notes: string[] = [];
|
|
162
|
+
if (!ledger.ok) notes.push(`ledger unreadable (${ledger.error}); verdicts read as missing`);
|
|
163
|
+
if (!forge)
|
|
164
|
+
notes.push(
|
|
165
|
+
`forge unavailable${options.forgeError ? ` (${options.forgeError})` : ""}: PR and CI not shown; landed detection uses git only, so a squash merge may be missed`,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const tips = new Map(plan.slices.map((slice) => [slice.id, branchRef(cwd, slice.branch)]));
|
|
169
|
+
const landings = sliceLandings(cwd, plan, trunk, tips, rows, forge);
|
|
170
|
+
const byId = new Map(plan.slices.map((slice) => [slice.id, slice]));
|
|
171
|
+
const order = landingOrder(plan.slices);
|
|
172
|
+
const out = new Map<string, SliceStatusRow>();
|
|
173
|
+
|
|
174
|
+
for (const id of order) {
|
|
175
|
+
const slice = byId.get(id) as Slice;
|
|
176
|
+
const landing = landings.get(id);
|
|
177
|
+
const seen = activity(cwd, plan, slice, rows);
|
|
178
|
+
const threshold = options.stuckAfterMs ?? timeboxMs(slice.timebox) ?? DEFAULT_STUCK_MS;
|
|
179
|
+
const idle = seen.last ? Math.max(0, now.getTime() - Date.parse(seen.last)) : null;
|
|
180
|
+
const verdict = seen.head ? checkVerdicts(cwd, slice.branch, rows) : null;
|
|
181
|
+
const row: SliceStatusRow = {
|
|
182
|
+
id,
|
|
183
|
+
branch: slice.branch,
|
|
184
|
+
base: slice.base,
|
|
185
|
+
worktree: slice.worktree,
|
|
186
|
+
dependsOn: slice.dependsOn,
|
|
187
|
+
branchExists: seen.head !== null,
|
|
188
|
+
head: seen.head,
|
|
189
|
+
lastCommitAt: seen.commit,
|
|
190
|
+
lastActivityAt: seen.last,
|
|
191
|
+
idleMinutes: idle === null ? null : Math.floor(idle / 60_000),
|
|
192
|
+
stuckAfterMinutes: Math.round(threshold / 60_000),
|
|
193
|
+
pr: landing?.pr
|
|
194
|
+
? { number: landing.pr.number, url: landing.pr.url, state: landing.pr.state }
|
|
195
|
+
: null,
|
|
196
|
+
ci: "unknown",
|
|
197
|
+
verdict: {
|
|
198
|
+
accepted: verdict?.accepted.accepted ?? false,
|
|
199
|
+
basis: verdict?.current.basis ?? "none",
|
|
200
|
+
review: verdict?.review ?? "unreviewed",
|
|
201
|
+
},
|
|
202
|
+
landed:
|
|
203
|
+
landing?.landed && landing.how
|
|
204
|
+
? { how: landing.how, pr: landing.pr?.number ?? null }
|
|
205
|
+
: null,
|
|
206
|
+
stuck: false,
|
|
207
|
+
waitsFor: [],
|
|
208
|
+
state: "not_started",
|
|
209
|
+
reasons: [],
|
|
210
|
+
notes: [
|
|
211
|
+
...(landing?.note ? [landing.note] : []),
|
|
212
|
+
...(landing?.forgeError ? [`forge: ${landing.forgeError}`] : []),
|
|
213
|
+
],
|
|
214
|
+
};
|
|
215
|
+
const answered = forge !== null && !landing?.forgeError;
|
|
216
|
+
if (answered && landing?.pr && landing.pr.state === "open" && !row.landed) {
|
|
217
|
+
const status = forge.forge.prStatus(landing.pr.number);
|
|
218
|
+
if (status.ok)
|
|
219
|
+
row.ci = checksState(gatingChecks(status.data).checks, missingRequired(status.data));
|
|
220
|
+
else row.notes.push(`forge: ${status.error}`);
|
|
221
|
+
}
|
|
222
|
+
row.waitsFor = slice.dependsOn.filter((dep) => !out.get(dep)?.landed);
|
|
223
|
+
out.set(id, row);
|
|
224
|
+
if (row.landed) {
|
|
225
|
+
row.state = "landed";
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const started = row.branchExists || seen.worktreeCreated;
|
|
229
|
+
if (!row.branchExists) row.reasons.push("no branch yet");
|
|
230
|
+
if (!row.verdict.accepted)
|
|
231
|
+
row.reasons.push(
|
|
232
|
+
`verdict ${row.verdict.basis === "none" ? "missing" : row.verdict.basis} on the current head`,
|
|
233
|
+
);
|
|
234
|
+
if (answered && row.branchExists) {
|
|
235
|
+
if (!row.pr) row.reasons.push("no PR");
|
|
236
|
+
else if (row.pr.state !== "open") row.reasons.push(`PR ${row.pr.state}`);
|
|
237
|
+
else if (row.ci === "failing" || row.ci === "pending") row.reasons.push(`CI ${row.ci}`);
|
|
238
|
+
}
|
|
239
|
+
row.stuck = started && !row.verdict.accepted && idle !== null && idle > threshold;
|
|
240
|
+
if (row.stuck) row.reasons.push(`no activity for ${row.idleMinutes} min`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Landing order: landable slices whose dependencies landed or land earlier.
|
|
244
|
+
const suggested: string[] = [];
|
|
245
|
+
for (const id of order) {
|
|
246
|
+
const row = out.get(id) as SliceStatusRow;
|
|
247
|
+
if (row.landed || row.reasons.length) continue;
|
|
248
|
+
if (row.waitsFor.every((dep) => suggested.includes(dep))) suggested.push(id);
|
|
249
|
+
}
|
|
250
|
+
const spawnable: string[] = [];
|
|
251
|
+
for (const id of order) {
|
|
252
|
+
const row = out.get(id) as SliceStatusRow;
|
|
253
|
+
if (row.landed) continue;
|
|
254
|
+
const parent = plan.slices.find((other) => other.branch === row.base);
|
|
255
|
+
if (suggested.includes(id)) row.state = "ready";
|
|
256
|
+
else if (row.stuck) row.state = "stuck";
|
|
257
|
+
// Work on a branch is in progress even while its dependency has not landed.
|
|
258
|
+
else if (row.branchExists) row.state = "active";
|
|
259
|
+
else if (
|
|
260
|
+
row.waitsFor.length === 0 ||
|
|
261
|
+
(parent &&
|
|
262
|
+
row.waitsFor.length === 1 &&
|
|
263
|
+
row.waitsFor[0] === parent.id &&
|
|
264
|
+
out.get(parent.id)?.verdict.accepted)
|
|
265
|
+
) {
|
|
266
|
+
const created = rows.some(
|
|
267
|
+
(candidate) =>
|
|
268
|
+
candidate.type === "fanout.worktree.created" &&
|
|
269
|
+
candidate.fanout === plan.name &&
|
|
270
|
+
candidate.slice === id,
|
|
271
|
+
);
|
|
272
|
+
row.state = created ? "active" : "not_started";
|
|
273
|
+
if (!created) spawnable.push(id);
|
|
274
|
+
} else row.state = "waiting";
|
|
275
|
+
if (row.waitsFor.length && !suggested.includes(id))
|
|
276
|
+
row.reasons.push(`waits for ${row.waitsFor.join(", ")}`);
|
|
277
|
+
}
|
|
278
|
+
const stuck = order.filter((id) => out.get(id)?.stuck);
|
|
279
|
+
const down = breaker.down();
|
|
280
|
+
if (down)
|
|
281
|
+
notes.push(
|
|
282
|
+
`forge went down during this run (${down}): later slices show no PR or CI, and git alone decided whether they landed`,
|
|
283
|
+
);
|
|
284
|
+
const online = forge !== null && !down;
|
|
285
|
+
// Verified, but the forge has no PR for them yet.
|
|
286
|
+
const noPr = order.filter((id) => {
|
|
287
|
+
const row = out.get(id) as SliceStatusRow;
|
|
288
|
+
return !row.landed && row.reasons.length === 1 && row.reasons[0] === "no PR";
|
|
289
|
+
});
|
|
290
|
+
const next = stuck.length
|
|
291
|
+
? `replace ${stuck.join(", ")}: stop the worker, then workit fanout worktree release ${stuck[0]} (records git status first) and respawn it in MODE: resume`
|
|
292
|
+
: suggested.length
|
|
293
|
+
? online
|
|
294
|
+
? `land in this order: ${suggested.join(", ")} (workit fanout check first; workit pr merge each)`
|
|
295
|
+
: `land in this order: ${suggested.join(", ")} once each has a PR (offline: PRs unchecked; open a missing one with workit git push && workit pr create --fill, then workit fanout status online)`
|
|
296
|
+
: noPr.length
|
|
297
|
+
? `open PRs for ${noPr.join(", ")}: workit git push && workit pr create --fill on each branch`
|
|
298
|
+
: spawnable.length
|
|
299
|
+
? `spawn ${spawnable.join(", ")} (workit fanout worktree create <slice> on hosts without native worktrees)`
|
|
300
|
+
: order.every((id) => out.get(id)?.landed)
|
|
301
|
+
? "every slice has landed"
|
|
302
|
+
: "wait for workers, then workit fanout status again";
|
|
303
|
+
return {
|
|
304
|
+
ok: true,
|
|
305
|
+
data: {
|
|
306
|
+
name: plan.name,
|
|
307
|
+
trunk: plan.trunk,
|
|
308
|
+
trunkRef: trunk,
|
|
309
|
+
forge: {
|
|
310
|
+
available: online,
|
|
311
|
+
error: online ? null : (down ?? options.forgeError ?? null),
|
|
312
|
+
},
|
|
313
|
+
slices: order.map((id) => out.get(id) as SliceStatusRow),
|
|
314
|
+
landingOrder: suggested,
|
|
315
|
+
spawnable,
|
|
316
|
+
stuck,
|
|
317
|
+
next,
|
|
318
|
+
notes,
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|