@brainervirus/workit-core 2.8.0 → 2.9.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/src/forge/pr-ops.ts +13 -1
- package/src/ledger.ts +43 -1
- package/src/stack.ts +1915 -0
package/src/stack.ts
ADDED
|
@@ -0,0 +1,1915 @@
|
|
|
1
|
+
// `workit stack` (D7, design §2.1 S12): plain base-branch chains on GitHub and
|
|
2
|
+
// GitLab, no Graphite and no `gh stack`/git-spice dependency.
|
|
3
|
+
//
|
|
4
|
+
// Truth is git ancestry plus the forge's PR bases. The stack file
|
|
5
|
+
// `<store>/stacks/<name>.json` (D13: under the git common dir, so it is shared
|
|
6
|
+
// by every worktree and survives worktree removal) is a reconstructible
|
|
7
|
+
// cache of the order and of what each branch was last built on:
|
|
8
|
+
// {v:1, name, trunk, forge, repo, branches:[{branch, parent, pr, lastHead,
|
|
9
|
+
// lastParentHead, patchId, merged}], updatedAt}
|
|
10
|
+
// `lastParentHead` is the parent tip a branch was last rebased onto, so a
|
|
11
|
+
// restack is `git rebase --onto <new parent tip> <lastParentHead> <branch>`.
|
|
12
|
+
//
|
|
13
|
+
// One writer per stack: every command that writes the file or touches the
|
|
14
|
+
// branches holds `<store>/stacks/<name>.json.lock` (a held lock answers
|
|
15
|
+
// `busy`; a lock left by a dead process is reclaimed).
|
|
16
|
+
//
|
|
17
|
+
// sync: after a parent merged (squash merges rewrite history, so a child must
|
|
18
|
+
// be rebased, design §0 #9) every remaining branch is restacked in order,
|
|
19
|
+
// pushed with a lease through the S11 push, and its PR retargeted. A rebase
|
|
20
|
+
// conflict stops with the rebase left in progress and the unblock command;
|
|
21
|
+
// unprocessed branches keep their recorded state. Each restack appends an
|
|
22
|
+
// observed `stack.restacked` row; when the change relative to its parent is
|
|
23
|
+
// identical (patch-id and exact diff hash), S13 carries the verdict through
|
|
24
|
+
// it. CI is never carried: it runs again on the new head.
|
|
25
|
+
//
|
|
26
|
+
// land: walks from the root and merges only the contiguous run of PRs that
|
|
27
|
+
// are open, based on trunk, READY (S10) and verdict-accepted (S13), one at a
|
|
28
|
+
// time through the S11 `pr merge` gates (merge grant, head-SHA guard). After
|
|
29
|
+
// each merge the rest is restacked and retargeted and the next PR's CI is
|
|
30
|
+
// awaited before it is judged. The first PR that does not qualify stops the
|
|
31
|
+
// run with the reason; it has already been moved onto trunk. Descendants are
|
|
32
|
+
// never armed for auto-merge.
|
|
33
|
+
//
|
|
34
|
+
// Optional adapters (`gh stack`, git-spice) can later implement `StackAdapter`
|
|
35
|
+
// and replace the built-in restack/retarget; nothing else depends on how the
|
|
36
|
+
// chain is rewritten.
|
|
37
|
+
import { spawnSync } from "node:child_process";
|
|
38
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
39
|
+
import fs from "node:fs";
|
|
40
|
+
import path from "node:path";
|
|
41
|
+
import { requireGrant, type GrantDecision } from "./autonomy";
|
|
42
|
+
import { classifyLockOwner, localLockHost, processStartOf } from "./core/store-lock";
|
|
43
|
+
import { mergePullRequest, type Sleep } from "./forge/pr-ops";
|
|
44
|
+
import {
|
|
45
|
+
buildStatusDoc,
|
|
46
|
+
pollDelay,
|
|
47
|
+
waitVerdict,
|
|
48
|
+
type ChecksState,
|
|
49
|
+
type NextAction,
|
|
50
|
+
type PrStatusDoc,
|
|
51
|
+
} from "./forge/report";
|
|
52
|
+
import type { ResolvedForge } from "./forge/resolve";
|
|
53
|
+
import type { ForgeResult, MergeMethod, PrState } from "./forge/types";
|
|
54
|
+
import { executePush, pushPreflight, recordedRemoteTip, type PushPlan } from "./git/ops";
|
|
55
|
+
import {
|
|
56
|
+
currentBranch,
|
|
57
|
+
fetchRefs,
|
|
58
|
+
GIT_TIMEOUTS,
|
|
59
|
+
hasCommit,
|
|
60
|
+
mergeBase,
|
|
61
|
+
patchId,
|
|
62
|
+
pushRemoteName,
|
|
63
|
+
remoteRefTip,
|
|
64
|
+
resolveRef,
|
|
65
|
+
} from "./git/rev";
|
|
66
|
+
import {
|
|
67
|
+
appendObserved,
|
|
68
|
+
checkVerdicts,
|
|
69
|
+
diffHash,
|
|
70
|
+
readLedger,
|
|
71
|
+
storeRoot,
|
|
72
|
+
type LedgerActor,
|
|
73
|
+
type ReadRow,
|
|
74
|
+
type VerdictBasis,
|
|
75
|
+
} from "./ledger";
|
|
76
|
+
|
|
77
|
+
export const STACK_VERSION = 1;
|
|
78
|
+
|
|
79
|
+
export type StackEntry = {
|
|
80
|
+
branch: string;
|
|
81
|
+
/** The branch this one is built on (the trunk for the root). */
|
|
82
|
+
parent: string;
|
|
83
|
+
pr: number | null;
|
|
84
|
+
/** The branch tip when the stack last processed it. */
|
|
85
|
+
lastHead: string | null;
|
|
86
|
+
/** The parent tip it was last rebased onto (the `--onto` upstream for the next restack). */
|
|
87
|
+
lastParentHead: string | null;
|
|
88
|
+
/** patch-id of lastParentHead...lastHead. */
|
|
89
|
+
patchId: string | null;
|
|
90
|
+
/** Set once its PR merged (or its tip reached the trunk); skipped afterwards. */
|
|
91
|
+
merged: { pr: number | null; mergeSha: string | null; at: string } | null;
|
|
92
|
+
/**
|
|
93
|
+
* A restack begun but not yet verified and pushed: the change as it was
|
|
94
|
+
* (`from..tip`) and the base it is moving onto. Survives a conflict or a
|
|
95
|
+
* content_changed stop, so the next sync compares against the original.
|
|
96
|
+
*/
|
|
97
|
+
pendingRestack?: { from: string; tip: string; onto: string } | null;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export type StackFile = {
|
|
101
|
+
v: number;
|
|
102
|
+
name: string;
|
|
103
|
+
trunk: string;
|
|
104
|
+
forge: "github" | "gitlab" | null;
|
|
105
|
+
repo: string | null;
|
|
106
|
+
branches: StackEntry[];
|
|
107
|
+
updatedAt: string;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export type StackError = {
|
|
111
|
+
ok: false;
|
|
112
|
+
code: "blocked" | "failed" | "unavailable" | "not_found" | "busy" | "invalid_input";
|
|
113
|
+
error: string;
|
|
114
|
+
unblock?: string;
|
|
115
|
+
data?: Record<string, unknown>;
|
|
116
|
+
};
|
|
117
|
+
export type StackResult<T> = { ok: true; data: T } | StackError;
|
|
118
|
+
|
|
119
|
+
const stackFail = (
|
|
120
|
+
code: StackError["code"],
|
|
121
|
+
error: string,
|
|
122
|
+
unblock?: string,
|
|
123
|
+
data?: Record<string, unknown>,
|
|
124
|
+
): StackError => ({
|
|
125
|
+
ok: false,
|
|
126
|
+
code,
|
|
127
|
+
error,
|
|
128
|
+
...(unblock ? { unblock } : {}),
|
|
129
|
+
...(data ? { data } : {}),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const fromForge = (result: Extract<ForgeResult<unknown>, { ok: false }>): StackError =>
|
|
133
|
+
stackFail(result.code, result.error, result.unblock);
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Test and adapter seams. `pushPlan` decides where a branch is pushed (the
|
|
137
|
+
* S11 preflight by default); `adapter` is where an optional `gh stack` /
|
|
138
|
+
* git-spice adapter would plug in later. Only the built-in one ships.
|
|
139
|
+
*/
|
|
140
|
+
export type StackAdapter = {
|
|
141
|
+
name: string;
|
|
142
|
+
/** Rebase `branch` from `from` onto `onto`; the built-in one runs git rebase --onto. */
|
|
143
|
+
restack(cwd: string, input: { branch: string; onto: string; from: string }): RestackOutcome;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export const stackDeps: {
|
|
147
|
+
pushPlan: (cwd: string, branch: string) => ForgeResult<PushPlan>;
|
|
148
|
+
adapter: StackAdapter;
|
|
149
|
+
/** Bound on one restack (hooks such as post-rewrite run inside it). */
|
|
150
|
+
rebaseTimeoutMs: number;
|
|
151
|
+
} = {
|
|
152
|
+
pushPlan: (cwd, branch) => pushPreflight(cwd, { branch }),
|
|
153
|
+
adapter: { name: "git", restack: (cwd, input) => gitRestack(cwd, input) },
|
|
154
|
+
rebaseTimeoutMs: Number(process.env.WORKIT_STACK_REBASE_TIMEOUT_MS) || 10 * 60_000,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// git plumbing
|
|
159
|
+
|
|
160
|
+
type GitRun = { ok: boolean; stdout: string; stderr: string; timedOut: boolean };
|
|
161
|
+
|
|
162
|
+
const gitRun = (cwd: string, args: string[], timeoutMs: number = GIT_TIMEOUTS.local): GitRun => {
|
|
163
|
+
const run = spawnSync("git", args, {
|
|
164
|
+
cwd,
|
|
165
|
+
encoding: "utf8",
|
|
166
|
+
timeout: timeoutMs,
|
|
167
|
+
killSignal: "SIGKILL",
|
|
168
|
+
windowsHide: true,
|
|
169
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
170
|
+
env: {
|
|
171
|
+
...process.env,
|
|
172
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
173
|
+
GIT_EDITOR: "true",
|
|
174
|
+
GIT_SEQUENCE_EDITOR: "true",
|
|
175
|
+
LC_ALL: "C",
|
|
176
|
+
LANGUAGE: "C",
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
const failure: NodeJS.ErrnoException | undefined = run.error;
|
|
180
|
+
return {
|
|
181
|
+
ok: run.status === 0,
|
|
182
|
+
stdout: run.stdout ?? "",
|
|
183
|
+
stderr: run.error ? String(run.error.message) : (run.stderr ?? ""),
|
|
184
|
+
timedOut: failure?.code === "ETIMEDOUT",
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const safeRef = (value: string): boolean => value.length > 0 && !value.startsWith("-");
|
|
189
|
+
|
|
190
|
+
const isAncestor = (cwd: string, ancestor: string, descendant: string): boolean =>
|
|
191
|
+
safeRef(ancestor) &&
|
|
192
|
+
safeRef(descendant) &&
|
|
193
|
+
gitRun(cwd, ["merge-base", "--is-ancestor", ancestor, descendant]).ok;
|
|
194
|
+
|
|
195
|
+
const branchTip = (cwd: string, branch: string): string | null =>
|
|
196
|
+
resolveRef(cwd, `refs/heads/${branch}`);
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The worktree that has `branch` checked out, if any, including one where a
|
|
200
|
+
* rebase of it is stopped (HEAD is detached then; the branch is named in
|
|
201
|
+
* rebase-merge/head-name).
|
|
202
|
+
*/
|
|
203
|
+
function worktreeOf(cwd: string, branch: string): string | null {
|
|
204
|
+
const list = gitRun(cwd, ["worktree", "list", "--porcelain"]);
|
|
205
|
+
if (!list.ok) return null;
|
|
206
|
+
let current: string | null = null;
|
|
207
|
+
for (const line of list.stdout.split("\n")) {
|
|
208
|
+
if (line.startsWith("worktree ")) current = line.slice("worktree ".length);
|
|
209
|
+
else if (line === `branch refs/heads/${branch}` && current) return current;
|
|
210
|
+
else if (line === "detached" && current && rebasingBranch(current) === `refs/heads/${branch}`)
|
|
211
|
+
return current;
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function rebasingBranch(dir: string): string | null {
|
|
217
|
+
for (const name of ["rebase-merge/head-name", "rebase-apply/head-name"]) {
|
|
218
|
+
const found = gitRun(dir, ["rev-parse", "--path-format=absolute", "--git-path", name]);
|
|
219
|
+
if (!found.ok) continue;
|
|
220
|
+
try {
|
|
221
|
+
return fs.readFileSync(found.stdout.trim(), "utf8").trim();
|
|
222
|
+
} catch {
|
|
223
|
+
// not this one
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** A rebase (or am) stopped in this worktree. */
|
|
230
|
+
function rebaseInProgress(dir: string): boolean {
|
|
231
|
+
for (const name of ["rebase-merge", "rebase-apply"]) {
|
|
232
|
+
const found = gitRun(dir, ["rev-parse", "--path-format=absolute", "--git-path", name]);
|
|
233
|
+
if (found.ok && fs.existsSync(found.stdout.trim())) return true;
|
|
234
|
+
}
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const trackedDirt = (dir: string): boolean => {
|
|
239
|
+
const status = gitRun(dir, ["status", "--porcelain", "--untracked-files=no"]);
|
|
240
|
+
return !status.ok || status.stdout.trim() !== "";
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
export type RestackOutcome =
|
|
244
|
+
| { ok: true; head: string }
|
|
245
|
+
| { ok: false; conflict: boolean; timedOut?: boolean; worktree: string; error: string };
|
|
246
|
+
|
|
247
|
+
/** Where sync checks out branches nobody has checked out (never the user's checkout). */
|
|
248
|
+
const scratchDir = (cwd: string): string | null => {
|
|
249
|
+
const dir = stacksDir(cwd);
|
|
250
|
+
return dir.ok ? path.join(dir.data, "worktrees") : null;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* `git rebase --onto <onto> <from>` with the branch checked out: in the
|
|
255
|
+
* worktree that already has it, else in a temporary worktree under the store
|
|
256
|
+
* root (`git worktree add`), so the user's own checkout never moves. Never
|
|
257
|
+
* autostashes and never updates other refs (`rebase.updateRefs` would move
|
|
258
|
+
* sibling stack branches). A temporary worktree is removed on success and
|
|
259
|
+
* kept on a conflict, so the rebase can be continued there.
|
|
260
|
+
*/
|
|
261
|
+
function gitRestack(
|
|
262
|
+
cwd: string,
|
|
263
|
+
input: { branch: string; onto: string; from: string },
|
|
264
|
+
): RestackOutcome {
|
|
265
|
+
const owner = worktreeOf(cwd, input.branch);
|
|
266
|
+
let dir = owner;
|
|
267
|
+
if (!dir) {
|
|
268
|
+
const scratch = scratchDir(cwd);
|
|
269
|
+
if (!scratch) return { ok: false, conflict: false, worktree: cwd, error: "no store root" };
|
|
270
|
+
fs.mkdirSync(scratch, { recursive: true });
|
|
271
|
+
dir = path.join(
|
|
272
|
+
scratch,
|
|
273
|
+
`${input.branch.replace(/[^A-Za-z0-9._-]+/gu, "_").slice(0, 40)}-${randomBytes(4).toString("hex")}`,
|
|
274
|
+
);
|
|
275
|
+
const added = gitRun(cwd, ["worktree", "add", "-q", dir, input.branch], GIT_TIMEOUTS.worktree);
|
|
276
|
+
if (!added.ok) return { ok: false, conflict: false, worktree: cwd, error: firstLine(added) };
|
|
277
|
+
}
|
|
278
|
+
const run = gitRun(
|
|
279
|
+
dir,
|
|
280
|
+
[
|
|
281
|
+
"-c",
|
|
282
|
+
"rebase.autoStash=false",
|
|
283
|
+
"-c",
|
|
284
|
+
"rebase.updateRefs=false",
|
|
285
|
+
"rebase",
|
|
286
|
+
"--no-autosquash",
|
|
287
|
+
"--onto",
|
|
288
|
+
input.onto,
|
|
289
|
+
input.from,
|
|
290
|
+
],
|
|
291
|
+
stackDeps.rebaseTimeoutMs,
|
|
292
|
+
);
|
|
293
|
+
if (!run.ok) {
|
|
294
|
+
// Killed at the bound: the rebase stopped mid-way, which is not a conflict.
|
|
295
|
+
if (run.timedOut)
|
|
296
|
+
return {
|
|
297
|
+
ok: false,
|
|
298
|
+
conflict: false,
|
|
299
|
+
timedOut: true,
|
|
300
|
+
worktree: dir,
|
|
301
|
+
error: `git rebase did not finish within ${stackDeps.rebaseTimeoutMs} ms`,
|
|
302
|
+
};
|
|
303
|
+
if (rebaseInProgress(dir))
|
|
304
|
+
return { ok: false, conflict: true, worktree: dir, error: firstLine(run) };
|
|
305
|
+
if (!owner) gitRun(cwd, ["worktree", "remove", "--force", dir]);
|
|
306
|
+
return { ok: false, conflict: false, worktree: dir, error: firstLine(run) };
|
|
307
|
+
}
|
|
308
|
+
if (!owner) gitRun(cwd, ["worktree", "remove", "--force", dir]);
|
|
309
|
+
const head = branchTip(cwd, input.branch);
|
|
310
|
+
return head
|
|
311
|
+
? { ok: true, head }
|
|
312
|
+
: { ok: false, conflict: false, worktree: dir, error: `${input.branch} vanished` };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Remove temporary sync worktrees that are clean and not mid-rebase. */
|
|
316
|
+
function pruneScratch(cwd: string): void {
|
|
317
|
+
const scratch = scratchDir(cwd);
|
|
318
|
+
if (!scratch || !fs.existsSync(scratch)) return;
|
|
319
|
+
for (const name of fs.readdirSync(scratch)) {
|
|
320
|
+
const dir = path.join(scratch, name);
|
|
321
|
+
if (!rebaseInProgress(dir) && !trackedDirt(dir))
|
|
322
|
+
gitRun(cwd, ["worktree", "remove", "--force", dir]);
|
|
323
|
+
}
|
|
324
|
+
gitRun(cwd, ["worktree", "prune"]);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const firstLine = (run: GitRun): string =>
|
|
328
|
+
(`${run.stderr}\n${run.stdout}`.split("\n").find((line) => line.trim()) ?? "git failed")
|
|
329
|
+
.trim()
|
|
330
|
+
.slice(0, 300);
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// store
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Portable file name for a stack: a readable slug plus a hash of the exact
|
|
337
|
+
* name, so names differing only in case or in characters Windows rejects
|
|
338
|
+
* never collide, and no file is ever a reserved device name (CON, NUL, …).
|
|
339
|
+
*/
|
|
340
|
+
export const stackFileName = (name: string): string =>
|
|
341
|
+
`${name
|
|
342
|
+
.replace(/[^A-Za-z0-9._-]+/gu, "_")
|
|
343
|
+
.replace(/^[._]+/u, "")
|
|
344
|
+
.slice(0, 48)
|
|
345
|
+
.toLowerCase()}-${createHash("sha256").update(name).digest("hex").slice(0, 12)}.json`;
|
|
346
|
+
|
|
347
|
+
const fileFor = stackFileName;
|
|
348
|
+
|
|
349
|
+
/** A stack name must be printable and short. */
|
|
350
|
+
const validName = (name: string): boolean => {
|
|
351
|
+
if (name.length === 0 || name.length > 200) return false;
|
|
352
|
+
for (let index = 0; index < name.length; index += 1) {
|
|
353
|
+
const code = name.charCodeAt(index);
|
|
354
|
+
if (code < 0x20 || code === 0x7f) return false;
|
|
355
|
+
}
|
|
356
|
+
return true;
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
export function stacksDir(cwd: string): StackResult<string> {
|
|
360
|
+
const root = storeRoot(cwd);
|
|
361
|
+
if (!root.ok) return stackFail(root.code, root.error, root.unblock);
|
|
362
|
+
return { ok: true, data: path.join(root.value.root, "stacks") };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
366
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
367
|
+
const str = (value: unknown): string | null => (typeof value === "string" && value ? value : null);
|
|
368
|
+
|
|
369
|
+
/** Tolerant read (D17): unknown keys are ignored, required keys are checked. */
|
|
370
|
+
function parseStack(raw: unknown): StackFile | null {
|
|
371
|
+
if (!isRecord(raw) || !Array.isArray(raw.branches)) return null;
|
|
372
|
+
const name = str(raw.name);
|
|
373
|
+
const trunk = str(raw.trunk);
|
|
374
|
+
if (!name || !trunk) return null;
|
|
375
|
+
const branches: StackEntry[] = [];
|
|
376
|
+
for (const item of raw.branches) {
|
|
377
|
+
if (!isRecord(item)) return null;
|
|
378
|
+
const branch = str(item.branch);
|
|
379
|
+
if (!branch) return null;
|
|
380
|
+
const merged = isRecord(item.merged)
|
|
381
|
+
? {
|
|
382
|
+
pr: typeof item.merged.pr === "number" ? item.merged.pr : null,
|
|
383
|
+
mergeSha: str(item.merged.mergeSha),
|
|
384
|
+
at: str(item.merged.at) ?? "",
|
|
385
|
+
}
|
|
386
|
+
: null;
|
|
387
|
+
branches.push({
|
|
388
|
+
branch,
|
|
389
|
+
parent: str(item.parent) ?? trunk,
|
|
390
|
+
pr: typeof item.pr === "number" ? item.pr : null,
|
|
391
|
+
lastHead: str(item.lastHead),
|
|
392
|
+
lastParentHead: str(item.lastParentHead),
|
|
393
|
+
patchId: str(item.patchId),
|
|
394
|
+
merged,
|
|
395
|
+
...(isRecord(item.pendingRestack) &&
|
|
396
|
+
str(item.pendingRestack.from) &&
|
|
397
|
+
str(item.pendingRestack.tip) &&
|
|
398
|
+
str(item.pendingRestack.onto)
|
|
399
|
+
? {
|
|
400
|
+
pendingRestack: {
|
|
401
|
+
from: str(item.pendingRestack.from) as string,
|
|
402
|
+
tip: str(item.pendingRestack.tip) as string,
|
|
403
|
+
onto: str(item.pendingRestack.onto) as string,
|
|
404
|
+
},
|
|
405
|
+
}
|
|
406
|
+
: {}),
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
v: typeof raw.v === "number" ? raw.v : STACK_VERSION,
|
|
411
|
+
name,
|
|
412
|
+
trunk,
|
|
413
|
+
forge: raw.forge === "github" || raw.forge === "gitlab" ? raw.forge : null,
|
|
414
|
+
repo: str(raw.repo),
|
|
415
|
+
branches,
|
|
416
|
+
updatedAt: str(raw.updatedAt) ?? "",
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export function readStack(cwd: string, name: string): StackResult<StackFile | null> {
|
|
421
|
+
const dir = stacksDir(cwd);
|
|
422
|
+
if (!dir.ok) return dir;
|
|
423
|
+
const file = path.join(dir.data, fileFor(name));
|
|
424
|
+
let text: string;
|
|
425
|
+
try {
|
|
426
|
+
text = fs.readFileSync(file, "utf8");
|
|
427
|
+
} catch (error) {
|
|
428
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { ok: true, data: null };
|
|
429
|
+
return stackFail("unavailable", `cannot read ${file}: ${(error as Error).message}`);
|
|
430
|
+
}
|
|
431
|
+
let parsed: StackFile | null = null;
|
|
432
|
+
try {
|
|
433
|
+
parsed = parseStack(JSON.parse(text));
|
|
434
|
+
} catch {
|
|
435
|
+
parsed = null;
|
|
436
|
+
}
|
|
437
|
+
return parsed
|
|
438
|
+
? { ok: true, data: parsed }
|
|
439
|
+
: stackFail(
|
|
440
|
+
"failed",
|
|
441
|
+
`stack file ${file} is not a valid stack`,
|
|
442
|
+
`workit stack plan --name ${name} <branch…> # rebuild it`,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Atomic write (temp file + rename) so a reader never sees half a stack. */
|
|
447
|
+
export function writeStack(
|
|
448
|
+
cwd: string,
|
|
449
|
+
stack: StackFile,
|
|
450
|
+
now: Date = new Date(),
|
|
451
|
+
): StackResult<void> {
|
|
452
|
+
const dir = stacksDir(cwd);
|
|
453
|
+
if (!dir.ok) return dir;
|
|
454
|
+
const file = path.join(dir.data, fileFor(stack.name));
|
|
455
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
456
|
+
try {
|
|
457
|
+
fs.mkdirSync(dir.data, { recursive: true });
|
|
458
|
+
stack.updatedAt = now.toISOString();
|
|
459
|
+
fs.writeFileSync(temp, `${JSON.stringify(stack, null, 2)}\n`);
|
|
460
|
+
fs.renameSync(temp, file);
|
|
461
|
+
return { ok: true, data: undefined };
|
|
462
|
+
} catch (error) {
|
|
463
|
+
fs.rmSync(temp, { force: true });
|
|
464
|
+
return stackFail("unavailable", `cannot write ${file}: ${(error as Error).message}`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Every stack in the store, sorted by name (unreadable files are skipped). */
|
|
469
|
+
export function listStacks(cwd: string): StackResult<StackFile[]> {
|
|
470
|
+
const dir = stacksDir(cwd);
|
|
471
|
+
if (!dir.ok) return dir;
|
|
472
|
+
let names: string[] = [];
|
|
473
|
+
try {
|
|
474
|
+
names = fs.readdirSync(dir.data).filter((name) => name.endsWith(".json"));
|
|
475
|
+
} catch {
|
|
476
|
+
return { ok: true, data: [] };
|
|
477
|
+
}
|
|
478
|
+
const out: StackFile[] = [];
|
|
479
|
+
for (const name of names) {
|
|
480
|
+
let parsed: StackFile | null = null;
|
|
481
|
+
try {
|
|
482
|
+
parsed = parseStack(JSON.parse(fs.readFileSync(path.join(dir.data, name), "utf8")));
|
|
483
|
+
} catch {
|
|
484
|
+
parsed = null;
|
|
485
|
+
}
|
|
486
|
+
// Only files at the name's own path count (a renamed copy is ignored).
|
|
487
|
+
if (parsed && fileFor(parsed.name) === name) out.push(parsed);
|
|
488
|
+
}
|
|
489
|
+
return { ok: true, data: out.toSorted((a, b) => a.name.localeCompare(b.name)) };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** The stack `name`, else the one holding the current branch, else the only one. */
|
|
493
|
+
export function selectStack(cwd: string, name: string | null): StackResult<StackFile> {
|
|
494
|
+
if (name !== null && !validName(name))
|
|
495
|
+
return stackFail("invalid_input", "a stack name must be 1-200 printable characters");
|
|
496
|
+
if (name) {
|
|
497
|
+
const read = readStack(cwd, name);
|
|
498
|
+
if (!read.ok) return read;
|
|
499
|
+
return read.data
|
|
500
|
+
? { ok: true, data: read.data }
|
|
501
|
+
: stackFail(
|
|
502
|
+
"not_found",
|
|
503
|
+
`no stack named ${name}`,
|
|
504
|
+
`workit stack plan --name ${name} <branch…>`,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
const all = listStacks(cwd);
|
|
508
|
+
if (!all.ok) return all;
|
|
509
|
+
const branch = currentBranch(cwd);
|
|
510
|
+
const holding = all.data.filter((stack) =>
|
|
511
|
+
stack.branches.some((entry) => entry.branch === branch),
|
|
512
|
+
);
|
|
513
|
+
if (holding.length === 1) return { ok: true, data: holding[0] };
|
|
514
|
+
if (holding.length === 0 && all.data.length === 1) return { ok: true, data: all.data[0] };
|
|
515
|
+
return stackFail(
|
|
516
|
+
"not_found",
|
|
517
|
+
holding.length > 1 || all.data.length > 1
|
|
518
|
+
? `several stacks match; pick one (${(holding.length > 1 ? holding : all.data).map((stack) => stack.name).join(", ")})`
|
|
519
|
+
: "no stack is planned here",
|
|
520
|
+
holding.length > 1 || all.data.length > 1
|
|
521
|
+
? "pass --name <stack>"
|
|
522
|
+
: "workit stack plan [<branch>…] # from the current branch chain by default",
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
// single writer
|
|
528
|
+
|
|
529
|
+
const LOCK_WAIT_MS = 2_000;
|
|
530
|
+
/** The holder touches its owner file this often while it works (land waits for CI). */
|
|
531
|
+
const HEARTBEAT_MS = 20_000;
|
|
532
|
+
/** A lock whose owner has not touched it for this long is stale, whoever holds it. */
|
|
533
|
+
const HEARTBEAT_STALE_MS = 2 * 60_000;
|
|
534
|
+
/** A lock directory without a readable owner is debris after this (a holder writes it at once). */
|
|
535
|
+
const OWNERLESS_STALE_MS = 30_000;
|
|
536
|
+
|
|
537
|
+
const OWNER = "owner";
|
|
538
|
+
|
|
539
|
+
type LockOwner = { pid: number; processStart: string | null; host: string; nonce: string };
|
|
540
|
+
|
|
541
|
+
const readOwner = (lock: string): { raw: string | null; age: number | null } => {
|
|
542
|
+
let raw: string | null = null;
|
|
543
|
+
try {
|
|
544
|
+
raw = fs.readFileSync(path.join(lock, OWNER), "utf8");
|
|
545
|
+
} catch {
|
|
546
|
+
raw = null;
|
|
547
|
+
}
|
|
548
|
+
let age: number | null = null;
|
|
549
|
+
for (const target of [path.join(lock, OWNER), lock])
|
|
550
|
+
try {
|
|
551
|
+
age = Date.now() - fs.statSync(target).mtimeMs;
|
|
552
|
+
break;
|
|
553
|
+
} catch {
|
|
554
|
+
// next
|
|
555
|
+
}
|
|
556
|
+
return { raw, age };
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Is the lock's holder gone? Same machine (host + pid namespace + boot, the
|
|
561
|
+
* S1 store-lock identity): the pid is dead or reused. Any holder whose
|
|
562
|
+
* heartbeat is older than HEARTBEAT_STALE_MS. A lock with no readable owner
|
|
563
|
+
* once it is OWNERLESS_STALE_MS old.
|
|
564
|
+
*/
|
|
565
|
+
export function stackLockStale(raw: string | null, age: number | null): boolean {
|
|
566
|
+
let payload: unknown = null;
|
|
567
|
+
try {
|
|
568
|
+
payload = raw ? JSON.parse(raw) : null;
|
|
569
|
+
} catch {
|
|
570
|
+
payload = null;
|
|
571
|
+
}
|
|
572
|
+
if (payload === null) return age !== null && age > OWNERLESS_STALE_MS;
|
|
573
|
+
const owner = classifyLockOwner(payload, age);
|
|
574
|
+
// A verifiably live holder on this machine is never stale, however old its
|
|
575
|
+
// heartbeat (a long synchronous rebase cannot heartbeat).
|
|
576
|
+
if (owner.state === "live") return false;
|
|
577
|
+
if (owner.state === "stale") return true;
|
|
578
|
+
// Another host or pid namespace: only its heartbeat can tell.
|
|
579
|
+
return age !== null && age > HEARTBEAT_STALE_MS;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Touch the held stack lock's owner file (called between git/forge steps). */
|
|
583
|
+
let activeBeat: (() => void) | null = null;
|
|
584
|
+
export const touchStackLock = (): void => activeBeat?.();
|
|
585
|
+
|
|
586
|
+
function takeLock(lock: string): LockOwner | null {
|
|
587
|
+
try {
|
|
588
|
+
fs.mkdirSync(lock);
|
|
589
|
+
} catch (error) {
|
|
590
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return null;
|
|
591
|
+
throw error;
|
|
592
|
+
}
|
|
593
|
+
const owner: LockOwner = {
|
|
594
|
+
pid: process.pid,
|
|
595
|
+
processStart: processStartOf(process.pid),
|
|
596
|
+
host: localLockHost(),
|
|
597
|
+
nonce: randomBytes(8).toString("hex"),
|
|
598
|
+
};
|
|
599
|
+
fs.writeFileSync(path.join(lock, OWNER), JSON.stringify(owner));
|
|
600
|
+
return owner;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Move a stale lock aside, only if it still holds what was judged stale. */
|
|
604
|
+
function reapLock(lock: string, observed: string | null): boolean {
|
|
605
|
+
const tomb = `${lock}.reap-${randomBytes(6).toString("hex")}`;
|
|
606
|
+
try {
|
|
607
|
+
fs.renameSync(lock, tomb);
|
|
608
|
+
} catch {
|
|
609
|
+
return false;
|
|
610
|
+
}
|
|
611
|
+
if (readOwner(tomb).raw === observed) {
|
|
612
|
+
fs.rmSync(tomb, { recursive: true, force: true });
|
|
613
|
+
return true;
|
|
614
|
+
}
|
|
615
|
+
try {
|
|
616
|
+
fs.renameSync(tomb, lock);
|
|
617
|
+
} catch {
|
|
618
|
+
fs.rmSync(tomb, { recursive: true, force: true });
|
|
619
|
+
}
|
|
620
|
+
return false;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function releaseLock(lock: string, owner: LockOwner): void {
|
|
624
|
+
const { raw } = readOwner(lock);
|
|
625
|
+
if (raw !== JSON.stringify(owner)) return;
|
|
626
|
+
const tomb = `${lock}.release-${randomBytes(6).toString("hex")}`;
|
|
627
|
+
try {
|
|
628
|
+
fs.renameSync(lock, tomb);
|
|
629
|
+
fs.rmSync(tomb, { recursive: true, force: true });
|
|
630
|
+
} catch {
|
|
631
|
+
// someone reaped it
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Hold the stack's lock for `fn`. A lock held by a live process answers
|
|
637
|
+
* `busy` after LOCK_WAIT_MS (retryable). The holder heartbeats while it works,
|
|
638
|
+
* so a lock left by a dead process, a crashed host or an aborted mkdir is
|
|
639
|
+
* reclaimed (see stackLockStale) without ever stealing a working holder's.
|
|
640
|
+
*/
|
|
641
|
+
export async function withStackLock<T>(
|
|
642
|
+
cwd: string,
|
|
643
|
+
name: string,
|
|
644
|
+
fn: () => Promise<StackResult<T>>,
|
|
645
|
+
waitMs: number = LOCK_WAIT_MS,
|
|
646
|
+
): Promise<StackResult<T>> {
|
|
647
|
+
const dir = stacksDir(cwd);
|
|
648
|
+
if (!dir.ok) return dir;
|
|
649
|
+
if (!validName(name))
|
|
650
|
+
return stackFail("invalid_input", "a stack name must be 1-200 printable characters");
|
|
651
|
+
const lock = path.join(dir.data, `${fileFor(name)}.lock`);
|
|
652
|
+
try {
|
|
653
|
+
fs.mkdirSync(dir.data, { recursive: true });
|
|
654
|
+
} catch (error) {
|
|
655
|
+
return stackFail("unavailable", `cannot create ${dir.data}: ${(error as Error).message}`);
|
|
656
|
+
}
|
|
657
|
+
const deadline = Date.now() + waitMs;
|
|
658
|
+
let owner: LockOwner | null = null;
|
|
659
|
+
for (;;) {
|
|
660
|
+
owner = takeLock(lock);
|
|
661
|
+
if (owner) break;
|
|
662
|
+
const seen = readOwner(lock);
|
|
663
|
+
if (stackLockStale(seen.raw, seen.age) && reapLock(lock, seen.raw)) continue;
|
|
664
|
+
if (Date.now() >= deadline) {
|
|
665
|
+
let holder: Partial<LockOwner> = {};
|
|
666
|
+
try {
|
|
667
|
+
holder = seen.raw ? (JSON.parse(seen.raw) as LockOwner) : {};
|
|
668
|
+
} catch {
|
|
669
|
+
holder = {};
|
|
670
|
+
}
|
|
671
|
+
return stackFail(
|
|
672
|
+
"busy",
|
|
673
|
+
`stack ${name} is busy: another workit stack command${holder.pid ? ` (pid ${holder.pid} on ${holder.host ?? "?"})` : ""} holds ${lock}`,
|
|
674
|
+
"retry when it finishes; a lock whose holder stopped heartbeating is reclaimed automatically",
|
|
675
|
+
{ lock, pid: holder.pid ?? null, host: holder.host ?? null },
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
679
|
+
}
|
|
680
|
+
const held = owner;
|
|
681
|
+
const touch = () => {
|
|
682
|
+
try {
|
|
683
|
+
const now = new Date();
|
|
684
|
+
fs.utimesSync(path.join(lock, OWNER), now, now);
|
|
685
|
+
} catch {
|
|
686
|
+
// lost the lock; the release below is a no-op then
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
const beat = setInterval(touch, HEARTBEAT_MS);
|
|
690
|
+
beat.unref?.();
|
|
691
|
+
const previous = activeBeat;
|
|
692
|
+
activeBeat = touch;
|
|
693
|
+
try {
|
|
694
|
+
return await fn();
|
|
695
|
+
} finally {
|
|
696
|
+
activeBeat = previous;
|
|
697
|
+
clearInterval(beat);
|
|
698
|
+
releaseLock(lock, held);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ---------------------------------------------------------------------------
|
|
703
|
+
// plan
|
|
704
|
+
|
|
705
|
+
export type PlanInput = {
|
|
706
|
+
name: string | null;
|
|
707
|
+
trunk: string;
|
|
708
|
+
/** Bottom (closest to trunk) first; empty: derive from the current branch. */
|
|
709
|
+
branches: string[];
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
export type PlanOutcome = {
|
|
713
|
+
stack: StackFile;
|
|
714
|
+
created: boolean;
|
|
715
|
+
/** Per branch: built on its parent's tip, and the PR base the forge reports. */
|
|
716
|
+
checks: Array<{
|
|
717
|
+
branch: string;
|
|
718
|
+
parent: string;
|
|
719
|
+
onParent: boolean;
|
|
720
|
+
pr: number | null;
|
|
721
|
+
prBase: string | null;
|
|
722
|
+
baseMatches: boolean | null;
|
|
723
|
+
}>;
|
|
724
|
+
notes: string[];
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
const remoteFor = (cwd: string, resolved: ResolvedForge | null, trunk: string): string =>
|
|
728
|
+
resolved?.baseRemote ?? pushRemoteName(cwd, trunk) ?? "origin";
|
|
729
|
+
|
|
730
|
+
/** The trunk tip: the remote-tracking ref when present, else the local branch. */
|
|
731
|
+
const trunkTip = (cwd: string, remote: string, trunk: string): string | null =>
|
|
732
|
+
resolveRef(cwd, `refs/remotes/${remote}/${trunk}`) ?? branchTip(cwd, trunk);
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* The current branch and the local branches it is stacked on: every local
|
|
736
|
+
* branch (not the trunk) whose tip is a proper ancestor of HEAD and not
|
|
737
|
+
* already on the trunk, ordered bottom-up.
|
|
738
|
+
*/
|
|
739
|
+
function deriveChain(cwd: string, trunk: string, base: string): StackResult<string[]> {
|
|
740
|
+
const branch = currentBranch(cwd);
|
|
741
|
+
if (!branch || branch === trunk)
|
|
742
|
+
return stackFail(
|
|
743
|
+
"invalid_input",
|
|
744
|
+
branch ? `you are on the trunk ${trunk}` : "HEAD is detached",
|
|
745
|
+
"switch to the top branch of the stack, or name the branches: workit stack plan <bottom> … <top>",
|
|
746
|
+
);
|
|
747
|
+
const refs = gitRun(cwd, [
|
|
748
|
+
"for-each-ref",
|
|
749
|
+
"--format=%(refname:short) %(objectname)",
|
|
750
|
+
"refs/heads",
|
|
751
|
+
]);
|
|
752
|
+
const head = branchTip(cwd, branch);
|
|
753
|
+
if (!refs.ok || !head) return stackFail("failed", "cannot list local branches");
|
|
754
|
+
const below: Array<{ branch: string; depth: number }> = [];
|
|
755
|
+
for (const line of refs.stdout.split("\n")) {
|
|
756
|
+
const [name, sha] = line.trim().split(" ");
|
|
757
|
+
if (!name || !sha || name === branch || name === trunk) continue;
|
|
758
|
+
if (sha === head || isAncestor(cwd, sha, base) || !isAncestor(cwd, sha, head)) continue;
|
|
759
|
+
const count = gitRun(cwd, ["rev-list", "--count", `${base}..${sha}`]);
|
|
760
|
+
below.push({ branch: name, depth: Number(count.stdout.trim()) || 0 });
|
|
761
|
+
}
|
|
762
|
+
// Branches sharing one tip are ambiguous; keep the order stable by name.
|
|
763
|
+
below.sort((a, b) => a.depth - b.depth || a.branch.localeCompare(b.branch));
|
|
764
|
+
return { ok: true, data: [...below.map((entry) => entry.branch), branch] };
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
export function planStack(
|
|
768
|
+
cwd: string,
|
|
769
|
+
resolved: ResolvedForge | null,
|
|
770
|
+
input: PlanInput,
|
|
771
|
+
existing: StackFile | null,
|
|
772
|
+
): StackResult<PlanOutcome> {
|
|
773
|
+
const remote = remoteFor(cwd, resolved, input.trunk);
|
|
774
|
+
const notes: string[] = [];
|
|
775
|
+
const trunkSha = trunkTip(cwd, remote, input.trunk);
|
|
776
|
+
if (!trunkSha)
|
|
777
|
+
return stackFail(
|
|
778
|
+
"not_found",
|
|
779
|
+
`trunk ${input.trunk} is not available locally`,
|
|
780
|
+
`git fetch ${remote} ${input.trunk} # or pass --trunk <branch>`,
|
|
781
|
+
);
|
|
782
|
+
let branches = input.branches;
|
|
783
|
+
if (branches.length === 0) {
|
|
784
|
+
if (existing) branches = existing.branches.map((entry) => entry.branch);
|
|
785
|
+
else {
|
|
786
|
+
const derived = deriveChain(cwd, input.trunk, trunkSha);
|
|
787
|
+
if (!derived.ok) return derived;
|
|
788
|
+
branches = derived.data;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
if (new Set(branches).size !== branches.length)
|
|
792
|
+
return stackFail("invalid_input", "a branch is listed twice");
|
|
793
|
+
if (branches.includes(input.trunk))
|
|
794
|
+
return stackFail("invalid_input", `the trunk ${input.trunk} cannot be in the stack`);
|
|
795
|
+
for (const branch of branches)
|
|
796
|
+
if (!safeRef(branch) || !branchTip(cwd, branch))
|
|
797
|
+
return stackFail(
|
|
798
|
+
"not_found",
|
|
799
|
+
`branch ${branch} does not exist locally`,
|
|
800
|
+
`git switch ${branch} # or git branch ${branch} ${remote}/${branch}`,
|
|
801
|
+
);
|
|
802
|
+
|
|
803
|
+
const previous = new Map((existing?.branches ?? []).map((entry) => [entry.branch, entry]));
|
|
804
|
+
const entries: StackEntry[] = [];
|
|
805
|
+
const checks: PlanOutcome["checks"] = [];
|
|
806
|
+
let parent = input.trunk;
|
|
807
|
+
let parentSha = trunkSha;
|
|
808
|
+
for (const branch of branches) {
|
|
809
|
+
const tip = branchTip(cwd, branch) as string;
|
|
810
|
+
const before = previous.get(branch);
|
|
811
|
+
// A recorded merge is re-checked, never trusted: the PR must read merged,
|
|
812
|
+
// or (no PR) the branch must have its own commits and be on the trunk.
|
|
813
|
+
if (before?.merged) {
|
|
814
|
+
let still = false;
|
|
815
|
+
if (before.pr && resolved) {
|
|
816
|
+
const status = resolved.forge.prStatus(before.pr);
|
|
817
|
+
still = status.ok && status.data.state === "merged";
|
|
818
|
+
} else
|
|
819
|
+
still = hasOwnCommits(cwd, tip, before.lastParentHead) && isAncestor(cwd, tip, trunkSha);
|
|
820
|
+
if (still) {
|
|
821
|
+
entries.push(before);
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
notes.push(`${branch} was recorded as merged but is not; it is back in the stack`);
|
|
825
|
+
}
|
|
826
|
+
const onParent = isAncestor(cwd, parentSha, tip);
|
|
827
|
+
let builtOn = parentSha;
|
|
828
|
+
if (!onParent) {
|
|
829
|
+
if (before?.lastParentHead && isAncestor(cwd, before.lastParentHead, tip))
|
|
830
|
+
// What it was last built on still is in its history: keep it, so sync
|
|
831
|
+
// replays only this branch's own commits.
|
|
832
|
+
builtOn = before.lastParentHead;
|
|
833
|
+
else {
|
|
834
|
+
const old =
|
|
835
|
+
oldParentVersion(cwd, parent, parentSha, tip) ?? oldParentLookalike(cwd, parentSha, tip);
|
|
836
|
+
if (old)
|
|
837
|
+
return stackFail(
|
|
838
|
+
"blocked",
|
|
839
|
+
`parent_rewritten: ${branch} is built on an older version of ${parent} (${old.slice(0, 12)}); replaying it from the merge base would bring the old ${parent} commits back`,
|
|
840
|
+
`git rebase --onto ${parent} ${old} ${branch} # moves only ${branch}'s own commits; then workit stack plan`,
|
|
841
|
+
{ branch, parent, oldParentTip: old },
|
|
842
|
+
);
|
|
843
|
+
builtOn = mergeBase(cwd, parentSha, tip) ?? parentSha;
|
|
844
|
+
notes.push(
|
|
845
|
+
`${branch} is not on ${parent} and has no recorded base; sync will replay every commit since their merge base ${builtOn.slice(0, 12)}`,
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
let pr = before?.pr ?? null;
|
|
850
|
+
let prBase: string | null = null;
|
|
851
|
+
if (resolved) {
|
|
852
|
+
const found = resolved.forge.findPr(branch, {
|
|
853
|
+
owner: resolved.headRepo.split("/")[0] ?? null,
|
|
854
|
+
projectId: resolved.headProjectId,
|
|
855
|
+
sha: tip,
|
|
856
|
+
});
|
|
857
|
+
if (found.ok && found.data && found.data.state === "open") {
|
|
858
|
+
pr = found.data.number;
|
|
859
|
+
const status = resolved.forge.prStatus(pr);
|
|
860
|
+
if (status.ok) prBase = status.data.base;
|
|
861
|
+
} else if (!found.ok) notes.push(`${branch}: PR lookup failed (${found.error})`);
|
|
862
|
+
}
|
|
863
|
+
entries.push({
|
|
864
|
+
...(before?.pendingRestack ? { pendingRestack: before.pendingRestack } : {}),
|
|
865
|
+
branch,
|
|
866
|
+
parent,
|
|
867
|
+
pr,
|
|
868
|
+
lastHead: tip,
|
|
869
|
+
lastParentHead: builtOn,
|
|
870
|
+
patchId: patchId(cwd, builtOn, tip),
|
|
871
|
+
merged: null,
|
|
872
|
+
});
|
|
873
|
+
checks.push({
|
|
874
|
+
branch,
|
|
875
|
+
parent,
|
|
876
|
+
onParent,
|
|
877
|
+
pr,
|
|
878
|
+
prBase,
|
|
879
|
+
baseMatches: prBase === null ? null : prBase === parent,
|
|
880
|
+
});
|
|
881
|
+
if (!onParent) notes.push(`${branch} is not on ${parent}; workit stack sync rebases it`);
|
|
882
|
+
if (prBase !== null && prBase !== parent)
|
|
883
|
+
notes.push(
|
|
884
|
+
`${branch}: PR base is ${prBase}, the stack says ${parent}; workit stack sync retargets it`,
|
|
885
|
+
);
|
|
886
|
+
parent = branch;
|
|
887
|
+
parentSha = tip;
|
|
888
|
+
}
|
|
889
|
+
if (!resolved) notes.push("forge not resolved: PR numbers were not looked up");
|
|
890
|
+
const name = input.name ?? existing?.name ?? branches[0];
|
|
891
|
+
return {
|
|
892
|
+
ok: true,
|
|
893
|
+
data: {
|
|
894
|
+
stack: {
|
|
895
|
+
v: STACK_VERSION,
|
|
896
|
+
name,
|
|
897
|
+
trunk: input.trunk,
|
|
898
|
+
forge: resolved?.forge.kind ?? existing?.forge ?? null,
|
|
899
|
+
repo: resolved?.forge.repo ?? existing?.repo ?? null,
|
|
900
|
+
branches: entries,
|
|
901
|
+
updatedAt: "",
|
|
902
|
+
},
|
|
903
|
+
created: existing === null,
|
|
904
|
+
checks,
|
|
905
|
+
notes,
|
|
906
|
+
},
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** `tip` has commits of its own beyond `base` (an empty branch is not "merged"). */
|
|
911
|
+
function hasOwnCommits(cwd: string, tip: string, base: string | null): boolean {
|
|
912
|
+
if (!base) return false;
|
|
913
|
+
return tip !== base && !isAncestor(cwd, tip, base);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* An earlier tip of `parent` (from its reflog) that `child` is built on but
|
|
918
|
+
* the current parent no longer contains: the parent was rewritten (amend,
|
|
919
|
+
* rebase) under the child.
|
|
920
|
+
*/
|
|
921
|
+
function oldParentVersion(
|
|
922
|
+
cwd: string,
|
|
923
|
+
parent: string,
|
|
924
|
+
parentSha: string,
|
|
925
|
+
child: string,
|
|
926
|
+
): string | null {
|
|
927
|
+
if (!safeRef(parent)) return null;
|
|
928
|
+
const log = gitRun(cwd, ["reflog", "show", "--format=%H", `refs/heads/${parent}`, "--"]);
|
|
929
|
+
if (!log.ok) return null;
|
|
930
|
+
for (const sha of log.stdout.split("\n").map((line) => line.trim())) {
|
|
931
|
+
if (!sha || sha === parentSha) continue;
|
|
932
|
+
if (isAncestor(cwd, sha, child) && !isAncestor(cwd, sha, parentSha)) return sha;
|
|
933
|
+
}
|
|
934
|
+
return null;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
type CommitKey = { sha: string; subject: string; patch: string | null };
|
|
938
|
+
|
|
939
|
+
/** Non-merge commits of `range`, oldest first, with their stable patch-ids. */
|
|
940
|
+
function commitKeys(cwd: string, range: string): CommitKey[] {
|
|
941
|
+
const log = gitRun(cwd, ["log", "--reverse", "--no-merges", "--format=%H%x00%s", range]);
|
|
942
|
+
if (!log.ok) return [];
|
|
943
|
+
const patches = new Map<string, string>();
|
|
944
|
+
const diff = gitRun(cwd, ["log", "--no-merges", "-p", "--format=commit %H", range]);
|
|
945
|
+
if (diff.ok && diff.stdout.trim()) {
|
|
946
|
+
const ids = spawnSync("git", ["patch-id", "--stable"], {
|
|
947
|
+
cwd,
|
|
948
|
+
input: diff.stdout,
|
|
949
|
+
encoding: "utf8",
|
|
950
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
951
|
+
});
|
|
952
|
+
for (const line of (ids.stdout ?? "").split("\n")) {
|
|
953
|
+
const [patch, sha] = line.trim().split(/\s+/u);
|
|
954
|
+
if (patch && sha) patches.set(sha, patch);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return log.stdout
|
|
958
|
+
.split("\n")
|
|
959
|
+
.filter(Boolean)
|
|
960
|
+
.map((line) => {
|
|
961
|
+
const [sha, subject = ""] = line.split("\0");
|
|
962
|
+
return { sha, subject, patch: patches.get(sha) ?? null };
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Without a reflog (expired, fresh clone): commits of `child` since its merge
|
|
968
|
+
* base with the parent that look like the parent's own (same patch-id or same
|
|
969
|
+
* subject as a commit the parent now has instead). The newest such commit is
|
|
970
|
+
* where the old parent version ended.
|
|
971
|
+
*/
|
|
972
|
+
function oldParentLookalike(cwd: string, parentSha: string, child: string): string | null {
|
|
973
|
+
const base = mergeBase(cwd, parentSha, child);
|
|
974
|
+
if (!base) return null;
|
|
975
|
+
const parentCommits = commitKeys(cwd, `${base}..${parentSha}`);
|
|
976
|
+
if (parentCommits.length === 0) return null;
|
|
977
|
+
const patches = new Set(parentCommits.map((commit) => commit.patch).filter(Boolean));
|
|
978
|
+
const subjects = new Set(parentCommits.map((commit) => commit.subject).filter(Boolean));
|
|
979
|
+
let last: string | null = null;
|
|
980
|
+
for (const commit of commitKeys(cwd, `${base}..${child}`))
|
|
981
|
+
if ((commit.patch && patches.has(commit.patch)) || subjects.has(commit.subject))
|
|
982
|
+
last = commit.sha;
|
|
983
|
+
return last;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// ---------------------------------------------------------------------------
|
|
987
|
+
// status
|
|
988
|
+
|
|
989
|
+
export type StackVerdict = "READY" | "WAITING" | "ADVANCE" | "COMPLETE";
|
|
990
|
+
|
|
991
|
+
export type StopReason =
|
|
992
|
+
| "no_pr"
|
|
993
|
+
| "pr_mismatch"
|
|
994
|
+
| "pr_closed"
|
|
995
|
+
| "base_not_trunk"
|
|
996
|
+
| "not_ready"
|
|
997
|
+
| "no_verdict"
|
|
998
|
+
| "needs_verdict"
|
|
999
|
+
| "grant_required"
|
|
1000
|
+
| "max_reached"
|
|
1001
|
+
| "merge_refused"
|
|
1002
|
+
| "head_unverified";
|
|
1003
|
+
|
|
1004
|
+
export type BranchStatus = {
|
|
1005
|
+
branch: string;
|
|
1006
|
+
parent: string;
|
|
1007
|
+
pr: number | null;
|
|
1008
|
+
merged: boolean;
|
|
1009
|
+
head: string | null;
|
|
1010
|
+
/** The parent's (or trunk's) tip is in this branch's history. */
|
|
1011
|
+
onParent: boolean | null;
|
|
1012
|
+
prState: PrState | null;
|
|
1013
|
+
prBase: string | null;
|
|
1014
|
+
baseMatches: boolean | null;
|
|
1015
|
+
next: NextAction | null;
|
|
1016
|
+
checks: ChecksState | null;
|
|
1017
|
+
blockers: string[];
|
|
1018
|
+
verdict: { accepted: boolean; basis: VerdictBasis; reasons: string[]; verdictId: string | null };
|
|
1019
|
+
};
|
|
1020
|
+
|
|
1021
|
+
export type Qualification =
|
|
1022
|
+
| { ok: true }
|
|
1023
|
+
| { ok: false; reason: StopReason; detail: string; unblock: string; ready: boolean };
|
|
1024
|
+
|
|
1025
|
+
export type StatusOutcome = {
|
|
1026
|
+
name: string;
|
|
1027
|
+
trunk: string;
|
|
1028
|
+
verdict: StackVerdict;
|
|
1029
|
+
reason: string | null;
|
|
1030
|
+
next: string | null;
|
|
1031
|
+
branches: BranchStatus[];
|
|
1032
|
+
};
|
|
1033
|
+
|
|
1034
|
+
type Ctx = {
|
|
1035
|
+
cwd: string;
|
|
1036
|
+
resolved: ResolvedForge;
|
|
1037
|
+
actor: LedgerActor;
|
|
1038
|
+
sleep: Sleep;
|
|
1039
|
+
};
|
|
1040
|
+
|
|
1041
|
+
const noun = (resolved: ResolvedForge, pr: number): string =>
|
|
1042
|
+
`${resolved.forge.kind === "github" ? "PR #" : "MR !"}${pr}`;
|
|
1043
|
+
|
|
1044
|
+
function statusDoc(ctx: Ctx, pr: number): ForgeResult<PrStatusDoc> {
|
|
1045
|
+
const status = ctx.resolved.forge.prStatus(pr);
|
|
1046
|
+
if (!status.ok) return status;
|
|
1047
|
+
return buildStatusDoc(ctx.cwd, ctx.resolved, status.data, { logLines: 0, behind: false });
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* The recorded PR number must still be this branch's PR from our head
|
|
1052
|
+
* repository: the forge reports this branch as its head, and looking the
|
|
1053
|
+
* branch up (owner/project matched, as `pr create` does) finds this number.
|
|
1054
|
+
*/
|
|
1055
|
+
function prBinding(ctx: Pick<Ctx, "resolved">, entry: StackEntry, doc: PrStatusDoc): string | null {
|
|
1056
|
+
const label = noun(ctx.resolved, entry.pr as number);
|
|
1057
|
+
if (doc.head.branch !== entry.branch)
|
|
1058
|
+
return `${label} is for ${doc.head.branch}, not ${entry.branch}`;
|
|
1059
|
+
const found = ctx.resolved.forge.findPr(entry.branch, {
|
|
1060
|
+
owner: ctx.resolved.headRepo.split("/")[0] ?? null,
|
|
1061
|
+
projectId: ctx.resolved.headProjectId,
|
|
1062
|
+
sha: null,
|
|
1063
|
+
});
|
|
1064
|
+
if (!found.ok) return `cannot confirm ${label} belongs to ${entry.branch}: ${found.error}`;
|
|
1065
|
+
if (found.data?.number !== entry.pr)
|
|
1066
|
+
return `${entry.branch} from ${ctx.resolved.headRepo} is ${found.data ? noun(ctx.resolved, found.data.number) : "without a PR"}, not ${label}`;
|
|
1067
|
+
return null;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/** The stack was planned against this forge repository. */
|
|
1071
|
+
export function repoMismatch(ctx: Pick<Ctx, "resolved">, stack: StackFile): StackError | null {
|
|
1072
|
+
const { kind, repo } = ctx.resolved.forge;
|
|
1073
|
+
if ((stack.repo && stack.repo !== repo) || (stack.forge && stack.forge !== kind))
|
|
1074
|
+
return stackFail(
|
|
1075
|
+
"blocked",
|
|
1076
|
+
`repo_mismatch: stack ${stack.name} was planned on ${stack.forge ?? "?"} ${stack.repo ?? "?"}, but this checkout resolves to ${kind} ${repo}`,
|
|
1077
|
+
`workit stack plan --name ${stack.name} <branch…> # re-plan it here, or run from the right checkout`,
|
|
1078
|
+
{ planned: { forge: stack.forge, repo: stack.repo }, resolved: { forge: kind, repo } },
|
|
1079
|
+
);
|
|
1080
|
+
return null;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
const verdictOf = (cwd: string, branch: string, rows: readonly ReadRow[]) => {
|
|
1084
|
+
const check = checkVerdicts(cwd, branch, rows);
|
|
1085
|
+
return {
|
|
1086
|
+
accepted: check.accepted.accepted,
|
|
1087
|
+
basis: check.current.basis,
|
|
1088
|
+
reasons: check.accepted.reasons as string[],
|
|
1089
|
+
verdictId:
|
|
1090
|
+
typeof check.accepted.verdict?.id === "string" && check.accepted.accepted
|
|
1091
|
+
? check.accepted.verdict.id
|
|
1092
|
+
: null,
|
|
1093
|
+
};
|
|
1094
|
+
};
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* Does the root-most open PR qualify to land? Order: a PR exists and is
|
|
1098
|
+
* open, it targets the trunk, the forge says READY, an accepted verdict
|
|
1099
|
+
* covers the head (unless the grant is `merge: true`), and the grant allows
|
|
1100
|
+
* merging. `ready` says everything but the grant holds ("verified, ready").
|
|
1101
|
+
*/
|
|
1102
|
+
export function qualify(
|
|
1103
|
+
ctx: Pick<Ctx, "cwd" | "resolved">,
|
|
1104
|
+
stack: StackFile,
|
|
1105
|
+
entry: StackEntry,
|
|
1106
|
+
doc: PrStatusDoc | null,
|
|
1107
|
+
verdict: BranchStatus["verdict"],
|
|
1108
|
+
grant: GrantDecision,
|
|
1109
|
+
/** Why the PR number no longer belongs to this branch (null: it does). */
|
|
1110
|
+
binding: string | null = null,
|
|
1111
|
+
): Qualification {
|
|
1112
|
+
const label = entry.pr ? noun(ctx.resolved, entry.pr) : entry.branch;
|
|
1113
|
+
const stop = (reason: StopReason, detail: string, unblock: string, ready = false) => ({
|
|
1114
|
+
ok: false as const,
|
|
1115
|
+
reason,
|
|
1116
|
+
detail,
|
|
1117
|
+
unblock,
|
|
1118
|
+
ready,
|
|
1119
|
+
});
|
|
1120
|
+
if (!entry.pr || !doc)
|
|
1121
|
+
return stop(
|
|
1122
|
+
"no_pr",
|
|
1123
|
+
`${entry.branch} has no open PR`,
|
|
1124
|
+
`git switch ${entry.branch} && workit pr create --fill --base ${entry.parent} # then workit stack plan`,
|
|
1125
|
+
);
|
|
1126
|
+
if (binding)
|
|
1127
|
+
return stop(
|
|
1128
|
+
"pr_mismatch",
|
|
1129
|
+
binding,
|
|
1130
|
+
`workit stack plan --name ${stack.name} # re-resolve the PRs`,
|
|
1131
|
+
);
|
|
1132
|
+
if (doc.state !== "open")
|
|
1133
|
+
return stop(
|
|
1134
|
+
"pr_closed",
|
|
1135
|
+
`${label} is ${doc.state}`,
|
|
1136
|
+
"reopen it, or drop the branch from the stack: workit stack plan <branches…>",
|
|
1137
|
+
);
|
|
1138
|
+
if (doc.base !== stack.trunk)
|
|
1139
|
+
return stop(
|
|
1140
|
+
"base_not_trunk",
|
|
1141
|
+
`${label} targets ${doc.base}, not ${stack.trunk}`,
|
|
1142
|
+
"workit stack sync",
|
|
1143
|
+
);
|
|
1144
|
+
if (doc.next !== "READY")
|
|
1145
|
+
return stop(
|
|
1146
|
+
"not_ready",
|
|
1147
|
+
`${label} is ${doc.next}${doc.blockers.length ? ` (${doc.blockers.join(", ")})` : ""}`,
|
|
1148
|
+
doc.next === "WAITING_CI"
|
|
1149
|
+
? `workit ci wait --pr ${entry.pr}`
|
|
1150
|
+
: `workit pr status --pr ${entry.pr}`,
|
|
1151
|
+
);
|
|
1152
|
+
const requireVerdict = grant.allowed ? grant.requireVerdict : true;
|
|
1153
|
+
if (requireVerdict && !verdict.accepted)
|
|
1154
|
+
return stop(
|
|
1155
|
+
verdict.reasons.includes("no_verdict") ? "no_verdict" : "needs_verdict",
|
|
1156
|
+
`${label} has no accepted independent verdict for ${doc.head.sha.slice(0, 12)} (${verdict.reasons.join(", ") || "none"})`,
|
|
1157
|
+
`an independent session verifies ${entry.branch} and runs: workit ledger verdict verified --how "<what was exercised>" --branch ${entry.branch}`,
|
|
1158
|
+
);
|
|
1159
|
+
if (!grant.allowed)
|
|
1160
|
+
return stop(
|
|
1161
|
+
"grant_required",
|
|
1162
|
+
`${label} is verified and ready, but ${grant.error}`,
|
|
1163
|
+
grant.unblock,
|
|
1164
|
+
true,
|
|
1165
|
+
);
|
|
1166
|
+
return { ok: true };
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
export function stackStatus(ctx: Ctx, stack: StackFile): StackResult<StatusOutcome> {
|
|
1170
|
+
const mismatch = repoMismatch(ctx, stack);
|
|
1171
|
+
if (mismatch) return mismatch;
|
|
1172
|
+
const ledger = readLedger(ctx.cwd);
|
|
1173
|
+
if (!ledger.ok) return stackFail(ledger.code, ledger.error, ledger.unblock);
|
|
1174
|
+
const remote = remoteFor(ctx.cwd, ctx.resolved, stack.trunk);
|
|
1175
|
+
const trunkSha = trunkTip(ctx.cwd, remote, stack.trunk);
|
|
1176
|
+
const grant = requireGrant(ctx.cwd, "merge");
|
|
1177
|
+
const branches: BranchStatus[] = [];
|
|
1178
|
+
let verdict: StackVerdict | null = null;
|
|
1179
|
+
let reason: string | null = null;
|
|
1180
|
+
let next: string | null = null;
|
|
1181
|
+
let parentName = stack.trunk;
|
|
1182
|
+
let parentSha = trunkSha;
|
|
1183
|
+
let mergedSeen = false;
|
|
1184
|
+
for (const entry of stack.branches) {
|
|
1185
|
+
let doc: PrStatusDoc | null = null;
|
|
1186
|
+
if (entry.pr) {
|
|
1187
|
+
const read = statusDoc(ctx, entry.pr);
|
|
1188
|
+
if (!read.ok) return fromForge(read);
|
|
1189
|
+
doc = read.data;
|
|
1190
|
+
}
|
|
1191
|
+
const merged = entry.merged !== null || doc?.state === "merged";
|
|
1192
|
+
const head = branchTip(ctx.cwd, entry.branch);
|
|
1193
|
+
const row: BranchStatus = {
|
|
1194
|
+
branch: entry.branch,
|
|
1195
|
+
parent: parentName,
|
|
1196
|
+
pr: entry.pr,
|
|
1197
|
+
merged,
|
|
1198
|
+
head,
|
|
1199
|
+
onParent: merged || !head || !parentSha ? null : isAncestor(ctx.cwd, parentSha, head),
|
|
1200
|
+
prState: doc?.state ?? null,
|
|
1201
|
+
prBase: doc?.base ?? null,
|
|
1202
|
+
baseMatches: doc && !merged ? doc.base === parentName : null,
|
|
1203
|
+
next: doc?.next ?? null,
|
|
1204
|
+
checks: doc?.checks.state ?? null,
|
|
1205
|
+
blockers: doc?.blockers ?? [],
|
|
1206
|
+
verdict: merged
|
|
1207
|
+
? { accepted: true, basis: "none", reasons: [], verdictId: null }
|
|
1208
|
+
: verdictOf(ctx.cwd, entry.branch, ledger.value.rows),
|
|
1209
|
+
};
|
|
1210
|
+
branches.push(row);
|
|
1211
|
+
if (merged) {
|
|
1212
|
+
mergedSeen = true;
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
if (verdict === null) {
|
|
1216
|
+
// The root-most open branch decides the stack's verdict.
|
|
1217
|
+
const stale = row.onParent === false || row.baseMatches === false;
|
|
1218
|
+
if (mergedSeen && stale) {
|
|
1219
|
+
verdict = "ADVANCE";
|
|
1220
|
+
reason = "a parent merged; the rest is not restacked or retargeted yet";
|
|
1221
|
+
next = "workit stack sync";
|
|
1222
|
+
} else {
|
|
1223
|
+
const q = qualify(
|
|
1224
|
+
ctx,
|
|
1225
|
+
stack,
|
|
1226
|
+
entry,
|
|
1227
|
+
doc,
|
|
1228
|
+
row.verdict,
|
|
1229
|
+
grant,
|
|
1230
|
+
doc ? prBinding(ctx, entry, doc) : null,
|
|
1231
|
+
);
|
|
1232
|
+
if (q.ok) {
|
|
1233
|
+
verdict = "READY";
|
|
1234
|
+
next = "workit stack land";
|
|
1235
|
+
} else {
|
|
1236
|
+
verdict = "WAITING";
|
|
1237
|
+
reason = `${q.reason}: ${q.detail}`;
|
|
1238
|
+
next = q.unblock;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
parentName = entry.branch;
|
|
1243
|
+
parentSha = head;
|
|
1244
|
+
}
|
|
1245
|
+
return {
|
|
1246
|
+
ok: true,
|
|
1247
|
+
data: {
|
|
1248
|
+
name: stack.name,
|
|
1249
|
+
trunk: stack.trunk,
|
|
1250
|
+
verdict: verdict ?? "COMPLETE",
|
|
1251
|
+
reason,
|
|
1252
|
+
next,
|
|
1253
|
+
branches,
|
|
1254
|
+
},
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// ---------------------------------------------------------------------------
|
|
1259
|
+
// sync
|
|
1260
|
+
|
|
1261
|
+
export type SyncOptions = {
|
|
1262
|
+
/** Push and retarget (false: only rebase locally). */
|
|
1263
|
+
publish: boolean;
|
|
1264
|
+
dryRun: boolean;
|
|
1265
|
+
/** Process only the first N unmerged branches (land moves just the new root). */
|
|
1266
|
+
limit?: number;
|
|
1267
|
+
/** Branches whose changed restack may be pushed anyway; never used by land. */
|
|
1268
|
+
force?: ReadonlySet<string>;
|
|
1269
|
+
};
|
|
1270
|
+
|
|
1271
|
+
export type SyncStep = {
|
|
1272
|
+
branch: string;
|
|
1273
|
+
pr: number | null;
|
|
1274
|
+
parent: string;
|
|
1275
|
+
/** null: already on its parent. */
|
|
1276
|
+
restack: { from: string; onto: string; head: string | null; carried: boolean | null } | null;
|
|
1277
|
+
push: { pushed: boolean; forced: boolean; sha: string } | null;
|
|
1278
|
+
retarget: { from: string; to: string } | null;
|
|
1279
|
+
};
|
|
1280
|
+
|
|
1281
|
+
export type SyncOutcome = {
|
|
1282
|
+
name: string;
|
|
1283
|
+
dryRun: boolean;
|
|
1284
|
+
merged: Array<{ branch: string; pr: number | null }>;
|
|
1285
|
+
steps: SyncStep[];
|
|
1286
|
+
};
|
|
1287
|
+
|
|
1288
|
+
type SyncState = {
|
|
1289
|
+
/** PR status docs read during this run, by PR number. */
|
|
1290
|
+
docs: Map<number, PrStatusDoc>;
|
|
1291
|
+
};
|
|
1292
|
+
|
|
1293
|
+
/** Mark branches whose PR merged (or whose tip is on the trunk) as merged. */
|
|
1294
|
+
function detectMerged(
|
|
1295
|
+
ctx: Ctx | null,
|
|
1296
|
+
cwd: string,
|
|
1297
|
+
stack: StackFile,
|
|
1298
|
+
trunkSha: string,
|
|
1299
|
+
state: SyncState,
|
|
1300
|
+
): StackResult<Array<{ branch: string; pr: number | null }>> {
|
|
1301
|
+
const newly: Array<{ branch: string; pr: number | null }> = [];
|
|
1302
|
+
for (const entry of stack.branches) {
|
|
1303
|
+
if (entry.merged) continue;
|
|
1304
|
+
let merged = false;
|
|
1305
|
+
if (entry.pr && ctx) {
|
|
1306
|
+
const read = statusDoc(ctx, entry.pr);
|
|
1307
|
+
if (!read.ok) return fromForge(read);
|
|
1308
|
+
if (read.data.head.branch !== entry.branch)
|
|
1309
|
+
return stackFail(
|
|
1310
|
+
"blocked",
|
|
1311
|
+
`pr_mismatch: ${noun(ctx.resolved, entry.pr)} is for ${read.data.head.branch}, not ${entry.branch}`,
|
|
1312
|
+
`workit stack plan --name ${stack.name} # re-resolve the PRs`,
|
|
1313
|
+
{ branch: entry.branch, pr: entry.pr },
|
|
1314
|
+
);
|
|
1315
|
+
state.docs.set(entry.pr, read.data);
|
|
1316
|
+
merged = read.data.state === "merged";
|
|
1317
|
+
} else {
|
|
1318
|
+
// Without a PR only ancestry can tell; a branch with no commits of its
|
|
1319
|
+
// own is empty, not merged.
|
|
1320
|
+
const tip = branchTip(cwd, entry.branch);
|
|
1321
|
+
merged =
|
|
1322
|
+
tip !== null &&
|
|
1323
|
+
hasOwnCommits(cwd, tip, entry.lastParentHead) &&
|
|
1324
|
+
isAncestor(cwd, tip, trunkSha);
|
|
1325
|
+
}
|
|
1326
|
+
if (merged) {
|
|
1327
|
+
entry.merged = { pr: entry.pr, mergeSha: null, at: new Date().toISOString() };
|
|
1328
|
+
newly.push({ branch: entry.branch, pr: entry.pr });
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
return { ok: true, data: newly };
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* Push `branch` through the S11 push: the grant, the protected-branch check
|
|
1336
|
+
* and the lease. A rewrite is forced only with a lease on the tip the stack
|
|
1337
|
+
* last saw (or workit's own recorded push), and only when that tip is in the
|
|
1338
|
+
* branch's history or reflog (S11 `--force-if-includes` semantics).
|
|
1339
|
+
*/
|
|
1340
|
+
function pushBranch(
|
|
1341
|
+
cwd: string,
|
|
1342
|
+
actor: LedgerActor,
|
|
1343
|
+
branch: string,
|
|
1344
|
+
priorTip: string | null,
|
|
1345
|
+
): StackResult<{ pushed: boolean; forced: boolean; sha: string }> {
|
|
1346
|
+
const plan = stackDeps.pushPlan(cwd, branch);
|
|
1347
|
+
if (!plan.ok) return fromForge(plan);
|
|
1348
|
+
const remote = remoteRefTip(cwd, plan.data.rawUrl, `refs/heads/${branch}`);
|
|
1349
|
+
if (!remote.ok) return stackFail(remote.code, remote.error);
|
|
1350
|
+
if (remote.sha === plan.data.sha)
|
|
1351
|
+
return { ok: true, data: { pushed: false, forced: false, sha: plan.data.sha } };
|
|
1352
|
+
const grant = requireGrant(cwd, "push", { forge: !plan.data.local });
|
|
1353
|
+
if (!grant.allowed)
|
|
1354
|
+
return stackFail("blocked", grant.error, grant.unblock, { reason: grant.reason });
|
|
1355
|
+
const force = remote.sha !== null && !isAncestor(cwd, remote.sha, plan.data.sha);
|
|
1356
|
+
const expect = !force
|
|
1357
|
+
? null
|
|
1358
|
+
: remote.sha === priorTip
|
|
1359
|
+
? priorTip
|
|
1360
|
+
: (recordedRemoteTip(cwd, plan.data) ?? null);
|
|
1361
|
+
const pushed = executePush(cwd, plan.data, { forceWithLease: force, expect, actor });
|
|
1362
|
+
if (!pushed.ok) return { ...fromForge(pushed), data: { branch } };
|
|
1363
|
+
return {
|
|
1364
|
+
ok: true,
|
|
1365
|
+
data: { pushed: pushed.data.pushed, forced: pushed.data.forced, sha: plan.data.sha },
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
/**
|
|
1370
|
+
* Bring every unmerged branch onto its effective parent (the nearest unmerged
|
|
1371
|
+
* branch below it, else the trunk), push it and retarget its PR, in order.
|
|
1372
|
+
* Writes the stack file after every branch, so a stop leaves the processed
|
|
1373
|
+
* branches recorded and the rest untouched.
|
|
1374
|
+
*/
|
|
1375
|
+
export function syncStack(
|
|
1376
|
+
cwd: string,
|
|
1377
|
+
ctx: Ctx | null,
|
|
1378
|
+
stack: StackFile,
|
|
1379
|
+
options: SyncOptions,
|
|
1380
|
+
actor: LedgerActor,
|
|
1381
|
+
): StackResult<SyncOutcome> {
|
|
1382
|
+
const remote = remoteFor(cwd, ctx?.resolved ?? null, stack.trunk);
|
|
1383
|
+
let observedTrunk: string | null = null;
|
|
1384
|
+
if (!options.dryRun && safeRef(stack.trunk)) {
|
|
1385
|
+
const fetched = fetchRefs(cwd, remote, [
|
|
1386
|
+
`+refs/heads/${stack.trunk}:refs/remotes/${remote}/${stack.trunk}`,
|
|
1387
|
+
]);
|
|
1388
|
+
if (!fetched.ok)
|
|
1389
|
+
return stackFail(fetched.code, fetched.error, `git fetch ${remote} ${stack.trunk}`);
|
|
1390
|
+
} else if (safeRef(stack.trunk)) {
|
|
1391
|
+
// Dry run: read the remote trunk without moving any ref (objects by id only).
|
|
1392
|
+
const remoteTrunk = remoteRefTip(cwd, remote, `refs/heads/${stack.trunk}`);
|
|
1393
|
+
if (remoteTrunk.ok && remoteTrunk.sha) {
|
|
1394
|
+
if (!hasCommit(cwd, remoteTrunk.sha)) fetchRefs(cwd, remote, [remoteTrunk.sha]);
|
|
1395
|
+
if (hasCommit(cwd, remoteTrunk.sha)) observedTrunk = remoteTrunk.sha;
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
const trunkSha = observedTrunk ?? trunkTip(cwd, remote, stack.trunk);
|
|
1399
|
+
if (!trunkSha)
|
|
1400
|
+
return stackFail(
|
|
1401
|
+
"not_found",
|
|
1402
|
+
`trunk ${stack.trunk} is not available`,
|
|
1403
|
+
`git fetch ${remote} ${stack.trunk}`,
|
|
1404
|
+
);
|
|
1405
|
+
if (ctx) {
|
|
1406
|
+
const mismatch = repoMismatch(ctx, stack);
|
|
1407
|
+
if (mismatch) return mismatch;
|
|
1408
|
+
}
|
|
1409
|
+
const state: SyncState = { docs: new Map() };
|
|
1410
|
+
const merged = detectMerged(ctx, cwd, stack, trunkSha, state);
|
|
1411
|
+
if (!merged.ok) return merged;
|
|
1412
|
+
|
|
1413
|
+
const outcome: SyncOutcome = {
|
|
1414
|
+
name: stack.name,
|
|
1415
|
+
dryRun: options.dryRun,
|
|
1416
|
+
merged: merged.data,
|
|
1417
|
+
steps: [],
|
|
1418
|
+
};
|
|
1419
|
+
pruneScratch(cwd);
|
|
1420
|
+
|
|
1421
|
+
let parent = { name: stack.trunk, tip: trunkSha as string | null };
|
|
1422
|
+
let processed = 0;
|
|
1423
|
+
for (const entry of stack.branches) {
|
|
1424
|
+
if (entry.merged) continue;
|
|
1425
|
+
if (options.limit !== undefined && processed >= options.limit) break;
|
|
1426
|
+
processed += 1;
|
|
1427
|
+
touchStackLock();
|
|
1428
|
+
const tip = branchTip(cwd, entry.branch);
|
|
1429
|
+
if (!tip)
|
|
1430
|
+
return stackFail(
|
|
1431
|
+
"not_found",
|
|
1432
|
+
`stack branch ${entry.branch} does not exist locally`,
|
|
1433
|
+
`git branch ${entry.branch} ${remote}/${entry.branch} # then workit stack sync`,
|
|
1434
|
+
{ progress: outcome },
|
|
1435
|
+
);
|
|
1436
|
+
const step: SyncStep = {
|
|
1437
|
+
branch: entry.branch,
|
|
1438
|
+
pr: entry.pr,
|
|
1439
|
+
parent: parent.name,
|
|
1440
|
+
restack: null,
|
|
1441
|
+
push: null,
|
|
1442
|
+
retarget: null,
|
|
1443
|
+
};
|
|
1444
|
+
let newTip: string | null = tip;
|
|
1445
|
+
const parentTip = parent.tip;
|
|
1446
|
+
if (parentTip === null) {
|
|
1447
|
+
// Dry run: the parent would be rewritten first, so this one moves too.
|
|
1448
|
+
step.restack = {
|
|
1449
|
+
from: entry.lastParentHead ?? "?",
|
|
1450
|
+
onto: `${parent.name} (restacked)`,
|
|
1451
|
+
head: null,
|
|
1452
|
+
carried: null,
|
|
1453
|
+
};
|
|
1454
|
+
newTip = null;
|
|
1455
|
+
} else if (!isAncestor(cwd, parentTip, tip)) {
|
|
1456
|
+
const pending = entry.pendingRestack ?? null;
|
|
1457
|
+
const from =
|
|
1458
|
+
pending && isAncestor(cwd, pending.onto, tip)
|
|
1459
|
+
? pending.onto
|
|
1460
|
+
: entry.lastParentHead && isAncestor(cwd, entry.lastParentHead, tip)
|
|
1461
|
+
? entry.lastParentHead
|
|
1462
|
+
: (mergeBase(cwd, entry.lastParentHead ?? parentTip, tip) ?? parentTip);
|
|
1463
|
+
if (options.dryRun) {
|
|
1464
|
+
step.restack = { from, onto: parentTip, head: null, carried: null };
|
|
1465
|
+
newTip = null;
|
|
1466
|
+
} else {
|
|
1467
|
+
touchStackLock();
|
|
1468
|
+
// A plain rebase linearizes merges and drops their resolutions.
|
|
1469
|
+
const merges = gitRun(cwd, ["rev-list", "--merges", `${from}..${tip}`]);
|
|
1470
|
+
if (!merges.ok || merges.stdout.trim())
|
|
1471
|
+
return stackFail(
|
|
1472
|
+
"blocked",
|
|
1473
|
+
`merge_commits: ${entry.branch} has merge commits since ${from.slice(0, 12)}; restacking would drop what they resolved`,
|
|
1474
|
+
`git rebase --rebase-merges --onto ${parentTip} ${from} ${entry.branch} # check the result, then: workit stack sync`,
|
|
1475
|
+
{
|
|
1476
|
+
branch: entry.branch,
|
|
1477
|
+
merges: merges.stdout.trim().split("\n").filter(Boolean),
|
|
1478
|
+
progress: outcome,
|
|
1479
|
+
},
|
|
1480
|
+
);
|
|
1481
|
+
const owner = worktreeOf(cwd, entry.branch);
|
|
1482
|
+
if (owner && rebaseInProgress(owner))
|
|
1483
|
+
return stackFail(
|
|
1484
|
+
"blocked",
|
|
1485
|
+
`a rebase is in progress in ${owner}`,
|
|
1486
|
+
`cd ${owner} && git rebase --continue # or --abort; then workit stack sync`,
|
|
1487
|
+
{ conflict: entry.branch, worktree: owner, progress: outcome },
|
|
1488
|
+
);
|
|
1489
|
+
if (owner && trackedDirt(owner))
|
|
1490
|
+
return stackFail(
|
|
1491
|
+
"blocked",
|
|
1492
|
+
`dirty_worktree: ${owner} has ${entry.branch} checked out with uncommitted changes; restacking needs it clean`,
|
|
1493
|
+
`commit or stash the changes in ${owner}, then workit stack sync`,
|
|
1494
|
+
{ branch: entry.branch, worktree: owner, progress: outcome },
|
|
1495
|
+
);
|
|
1496
|
+
// Record what is being moved before moving it: a conflict or a
|
|
1497
|
+
// timeout leaves this in the stack file for the next sync to check.
|
|
1498
|
+
entry.pendingRestack = pending
|
|
1499
|
+
? { ...pending, onto: parentTip }
|
|
1500
|
+
: { from, tip, onto: parentTip };
|
|
1501
|
+
const recorded = writeStack(cwd, stack);
|
|
1502
|
+
if (!recorded.ok) return recorded;
|
|
1503
|
+
const rebased = stackDeps.adapter.restack(cwd, {
|
|
1504
|
+
branch: entry.branch,
|
|
1505
|
+
onto: parentTip,
|
|
1506
|
+
from,
|
|
1507
|
+
});
|
|
1508
|
+
touchStackLock();
|
|
1509
|
+
if (!rebased.ok) {
|
|
1510
|
+
outcome.steps.push(step);
|
|
1511
|
+
if (rebased.timedOut)
|
|
1512
|
+
return stackFail(
|
|
1513
|
+
"failed",
|
|
1514
|
+
`timed_out: restacking ${entry.branch} onto ${parent.name}: ${rebased.error}`,
|
|
1515
|
+
`finish or abort the rebase in ${rebased.worktree} (git rebase --continue | --abort), then workit stack sync # slow hooks: raise WORKIT_STACK_REBASE_TIMEOUT_MS`,
|
|
1516
|
+
{
|
|
1517
|
+
reason: "timed_out",
|
|
1518
|
+
branch: entry.branch,
|
|
1519
|
+
worktree: rebased.worktree,
|
|
1520
|
+
progress: outcome,
|
|
1521
|
+
},
|
|
1522
|
+
);
|
|
1523
|
+
return rebased.conflict
|
|
1524
|
+
? stackFail(
|
|
1525
|
+
"blocked",
|
|
1526
|
+
`conflict restacking ${entry.branch} onto ${parent.name}: ${rebased.error}`,
|
|
1527
|
+
`resolve the conflicts in ${rebased.worktree}, git rebase --continue, then workit stack sync # or git rebase --abort`,
|
|
1528
|
+
{ conflict: entry.branch, worktree: rebased.worktree, progress: outcome },
|
|
1529
|
+
)
|
|
1530
|
+
: stackFail(
|
|
1531
|
+
"failed",
|
|
1532
|
+
`restacking ${entry.branch} failed: ${rebased.error}`,
|
|
1533
|
+
undefined,
|
|
1534
|
+
{
|
|
1535
|
+
branch: entry.branch,
|
|
1536
|
+
progress: outcome,
|
|
1537
|
+
},
|
|
1538
|
+
);
|
|
1539
|
+
}
|
|
1540
|
+
newTip = rebased.head;
|
|
1541
|
+
step.restack = { from, onto: parentTip, head: newTip, carried: null };
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
if (!options.dryRun && newTip) {
|
|
1545
|
+
// Content safety: what was moved must arrive as the same change. The
|
|
1546
|
+
// comparison is between the commits actually rebased (`from..tip`, as
|
|
1547
|
+
// recorded before the rebase, even across a conflict) and the result
|
|
1548
|
+
// on the new base: the exact diff, and no commit dropped as empty or
|
|
1549
|
+
// already applied. Otherwise nothing is pushed and the record stays.
|
|
1550
|
+
const check =
|
|
1551
|
+
entry.pendingRestack && parentTip && isAncestor(cwd, parentTip, newTip)
|
|
1552
|
+
? entry.pendingRestack
|
|
1553
|
+
: null;
|
|
1554
|
+
let forced = false;
|
|
1555
|
+
if (check && parentTip) {
|
|
1556
|
+
const before = {
|
|
1557
|
+
d: diffHash(cwd, check.from, check.tip),
|
|
1558
|
+
commits: commitCount(cwd, check.from, check.tip),
|
|
1559
|
+
};
|
|
1560
|
+
const after = {
|
|
1561
|
+
d: diffHash(cwd, parentTip, newTip),
|
|
1562
|
+
commits: commitCount(cwd, parentTip, newTip),
|
|
1563
|
+
};
|
|
1564
|
+
const sameContent = before.d !== null && before.d === after.d;
|
|
1565
|
+
const dropped = Math.max(0, before.commits - after.commits);
|
|
1566
|
+
if (!sameContent || dropped > 0) {
|
|
1567
|
+
if (!options.force?.has(entry.branch)) {
|
|
1568
|
+
outcome.steps.push(step);
|
|
1569
|
+
const written = writeStack(cwd, stack);
|
|
1570
|
+
if (!written.ok) return written;
|
|
1571
|
+
return stackFail(
|
|
1572
|
+
"blocked",
|
|
1573
|
+
`content_changed: ${entry.branch} on ${parent.name} is not the same change it was on ${check.from.slice(0, 12)}${dropped ? ` (${dropped} commit${dropped === 1 ? "" : "s"} dropped as empty or already applied)` : ""}; nothing was pushed`,
|
|
1574
|
+
`inspect: git range-diff ${check.from}..${check.tip} ${parentTip}..${newTip} # if intended: workit stack sync --force ${entry.branch}; to undo: git branch -f ${entry.branch} ${check.tip}`,
|
|
1575
|
+
{
|
|
1576
|
+
reason: "content_changed",
|
|
1577
|
+
branch: entry.branch,
|
|
1578
|
+
before: {
|
|
1579
|
+
base: check.from,
|
|
1580
|
+
head: check.tip,
|
|
1581
|
+
commits: before.commits,
|
|
1582
|
+
stat: shortStat(cwd, check.from, check.tip),
|
|
1583
|
+
},
|
|
1584
|
+
after: {
|
|
1585
|
+
base: parentTip,
|
|
1586
|
+
head: newTip,
|
|
1587
|
+
commits: after.commits,
|
|
1588
|
+
stat: shortStat(cwd, parentTip, newTip),
|
|
1589
|
+
},
|
|
1590
|
+
dropped,
|
|
1591
|
+
progress: outcome,
|
|
1592
|
+
},
|
|
1593
|
+
);
|
|
1594
|
+
}
|
|
1595
|
+
forced = true;
|
|
1596
|
+
}
|
|
1597
|
+
entry.pendingRestack = null;
|
|
1598
|
+
}
|
|
1599
|
+
// Verdict carry (S13): the last verified head vs the new one, each
|
|
1600
|
+
// against its own base. Equal only for the same patch and exact diff.
|
|
1601
|
+
const moved =
|
|
1602
|
+
entry.lastHead !== null &&
|
|
1603
|
+
entry.lastHead !== newTip &&
|
|
1604
|
+
entry.lastParentHead !== null &&
|
|
1605
|
+
parentTip !== null &&
|
|
1606
|
+
parentTip !== entry.lastParentHead;
|
|
1607
|
+
if (moved && parentTip) {
|
|
1608
|
+
const fromBase = entry.lastParentHead as string;
|
|
1609
|
+
const fromHead = entry.lastHead as string;
|
|
1610
|
+
const before = {
|
|
1611
|
+
p: patchId(cwd, fromBase, fromHead),
|
|
1612
|
+
d: diffHash(cwd, fromBase, fromHead),
|
|
1613
|
+
};
|
|
1614
|
+
const after = { p: patchId(cwd, parentTip, newTip), d: diffHash(cwd, parentTip, newTip) };
|
|
1615
|
+
const equal =
|
|
1616
|
+
before.d !== null && before.d === after.d && before.p !== null && before.p === after.p;
|
|
1617
|
+
appendObserved(cwd, {
|
|
1618
|
+
type: "stack.restacked",
|
|
1619
|
+
actor,
|
|
1620
|
+
stack: stack.name,
|
|
1621
|
+
branch: entry.branch,
|
|
1622
|
+
head: newTip,
|
|
1623
|
+
fromHead,
|
|
1624
|
+
fromBase,
|
|
1625
|
+
toBase: parentTip,
|
|
1626
|
+
patchId: after.p,
|
|
1627
|
+
diffHash: after.d,
|
|
1628
|
+
patchEqual: equal,
|
|
1629
|
+
forced,
|
|
1630
|
+
});
|
|
1631
|
+
if (step.restack) step.restack.carried = equal;
|
|
1632
|
+
else step.restack = { from: fromBase, onto: parentTip, head: newTip, carried: equal };
|
|
1633
|
+
}
|
|
1634
|
+
if (options.publish) {
|
|
1635
|
+
touchStackLock();
|
|
1636
|
+
const pushed = pushBranch(cwd, actor, entry.branch, entry.lastHead ?? tip);
|
|
1637
|
+
touchStackLock();
|
|
1638
|
+
if (!pushed.ok) {
|
|
1639
|
+
outcome.steps.push(step);
|
|
1640
|
+
return { ...pushed, data: { ...pushed.data, progress: outcome } };
|
|
1641
|
+
}
|
|
1642
|
+
step.push = pushed.data;
|
|
1643
|
+
}
|
|
1644
|
+
entry.lastHead = newTip;
|
|
1645
|
+
entry.lastParentHead = parentTip;
|
|
1646
|
+
entry.patchId = parentTip ? patchId(cwd, parentTip, newTip) : null;
|
|
1647
|
+
}
|
|
1648
|
+
if (options.publish && ctx && entry.pr) {
|
|
1649
|
+
const doc = state.docs.get(entry.pr);
|
|
1650
|
+
if (doc && doc.state === "open" && doc.base !== parent.name) {
|
|
1651
|
+
step.retarget = { from: doc.base, to: parent.name };
|
|
1652
|
+
if (!options.dryRun) {
|
|
1653
|
+
const grant = requireGrant(cwd, "pr");
|
|
1654
|
+
if (!grant.allowed)
|
|
1655
|
+
return stackFail("blocked", grant.error, grant.unblock, {
|
|
1656
|
+
reason: grant.reason,
|
|
1657
|
+
progress: outcome,
|
|
1658
|
+
});
|
|
1659
|
+
const updated = ctx.resolved.forge.updateBase(entry.pr, parent.name);
|
|
1660
|
+
if (!updated.ok)
|
|
1661
|
+
return { ...fromForge(updated), data: { branch: entry.branch, progress: outcome } };
|
|
1662
|
+
appendObserved(cwd, {
|
|
1663
|
+
type: "pr.retargeted",
|
|
1664
|
+
actor,
|
|
1665
|
+
stack: stack.name,
|
|
1666
|
+
branch: entry.branch,
|
|
1667
|
+
head: newTip,
|
|
1668
|
+
pr: entry.pr,
|
|
1669
|
+
base: parent.name,
|
|
1670
|
+
previousBase: doc.base,
|
|
1671
|
+
});
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
entry.parent = parent.name;
|
|
1676
|
+
outcome.steps.push(step);
|
|
1677
|
+
if (!options.dryRun) {
|
|
1678
|
+
const written = writeStack(cwd, stack);
|
|
1679
|
+
if (!written.ok) return written;
|
|
1680
|
+
}
|
|
1681
|
+
parent = { name: entry.branch, tip: newTip };
|
|
1682
|
+
}
|
|
1683
|
+
if (!options.dryRun) {
|
|
1684
|
+
const written = writeStack(cwd, stack);
|
|
1685
|
+
if (!written.ok) return written;
|
|
1686
|
+
}
|
|
1687
|
+
return { ok: true, data: outcome };
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
const commitCount = (cwd: string, base: string, head: string): number =>
|
|
1691
|
+
Number(gitRun(cwd, ["rev-list", "--count", "--no-merges", `${base}..${head}`]).stdout.trim()) ||
|
|
1692
|
+
0;
|
|
1693
|
+
|
|
1694
|
+
const shortStat = (cwd: string, base: string, head: string): string =>
|
|
1695
|
+
gitRun(cwd, ["diff", "--shortstat", `${base}...${head}`]).stdout.trim();
|
|
1696
|
+
|
|
1697
|
+
// ---------------------------------------------------------------------------
|
|
1698
|
+
// land
|
|
1699
|
+
|
|
1700
|
+
export type LandOptions = {
|
|
1701
|
+
dryRun: boolean;
|
|
1702
|
+
max: number | null;
|
|
1703
|
+
method: MergeMethod;
|
|
1704
|
+
/** How long to wait for CI on a PR that was just restacked. */
|
|
1705
|
+
timeoutMs: number;
|
|
1706
|
+
intervalMs: number;
|
|
1707
|
+
};
|
|
1708
|
+
|
|
1709
|
+
export type LandOutcome = {
|
|
1710
|
+
name: string;
|
|
1711
|
+
dryRun: boolean;
|
|
1712
|
+
landed: Array<{ pr: number; branch: string; mergeSha: string | null }>;
|
|
1713
|
+
/** Dry run: the PRs that qualify now, in order (later ones assume a clean restack). */
|
|
1714
|
+
wouldLand: Array<{ pr: number; branch: string }>;
|
|
1715
|
+
stoppedAt: {
|
|
1716
|
+
pr: number | null;
|
|
1717
|
+
branch: string;
|
|
1718
|
+
reason: StopReason;
|
|
1719
|
+
detail: string;
|
|
1720
|
+
unblock: string;
|
|
1721
|
+
/** Everything but the merge grant holds: "verified, ready". */
|
|
1722
|
+
ready: boolean;
|
|
1723
|
+
} | null;
|
|
1724
|
+
retargeted: number[];
|
|
1725
|
+
restacked: string[];
|
|
1726
|
+
complete: boolean;
|
|
1727
|
+
};
|
|
1728
|
+
|
|
1729
|
+
/** Poll the PR until its checks settle or the wait budget is spent (sleep-time based). */
|
|
1730
|
+
async function awaitChecks(
|
|
1731
|
+
ctx: Ctx,
|
|
1732
|
+
pr: number,
|
|
1733
|
+
head: string,
|
|
1734
|
+
options: LandOptions,
|
|
1735
|
+
): Promise<ForgeResult<PrStatusDoc>> {
|
|
1736
|
+
let waited = 0;
|
|
1737
|
+
for (let poll = 0; ; poll += 1) {
|
|
1738
|
+
touchStackLock();
|
|
1739
|
+
const doc = statusDoc(ctx, pr);
|
|
1740
|
+
if (!doc.ok) return doc;
|
|
1741
|
+
const verdict = waitVerdict(doc.data, { head, elapsedMs: waited });
|
|
1742
|
+
if (verdict.state !== "waiting" || waited >= options.timeoutMs) return doc;
|
|
1743
|
+
const delay = Math.min(
|
|
1744
|
+
pollDelay(options.intervalMs, poll),
|
|
1745
|
+
Math.max(1, options.timeoutMs - waited),
|
|
1746
|
+
);
|
|
1747
|
+
await ctx.sleep(delay);
|
|
1748
|
+
waited += delay;
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
export async function landStack(
|
|
1753
|
+
ctx: Ctx,
|
|
1754
|
+
stack: StackFile,
|
|
1755
|
+
options: LandOptions,
|
|
1756
|
+
): Promise<StackResult<LandOutcome>> {
|
|
1757
|
+
const outcome: LandOutcome = {
|
|
1758
|
+
name: stack.name,
|
|
1759
|
+
dryRun: options.dryRun,
|
|
1760
|
+
landed: [],
|
|
1761
|
+
wouldLand: [],
|
|
1762
|
+
stoppedAt: null,
|
|
1763
|
+
retargeted: [],
|
|
1764
|
+
restacked: [],
|
|
1765
|
+
complete: false,
|
|
1766
|
+
};
|
|
1767
|
+
const absorb = (sync: SyncOutcome) => {
|
|
1768
|
+
for (const step of sync.steps) {
|
|
1769
|
+
if (step.retarget && step.pr && !outcome.retargeted.includes(step.pr))
|
|
1770
|
+
outcome.retargeted.push(step.pr);
|
|
1771
|
+
if (step.restack?.head && !outcome.restacked.includes(step.branch))
|
|
1772
|
+
outcome.restacked.push(step.branch);
|
|
1773
|
+
}
|
|
1774
|
+
};
|
|
1775
|
+
const failWith = (error: StackError): StackError => ({
|
|
1776
|
+
...error,
|
|
1777
|
+
data: { ...error.data, land: outcome },
|
|
1778
|
+
});
|
|
1779
|
+
const mismatch = repoMismatch(ctx, stack);
|
|
1780
|
+
if (mismatch) return failWith(mismatch);
|
|
1781
|
+
|
|
1782
|
+
// Catch up first: a parent merged elsewhere leaves the rest to restack.
|
|
1783
|
+
if (!options.dryRun) {
|
|
1784
|
+
const synced = syncStack(
|
|
1785
|
+
ctx.cwd,
|
|
1786
|
+
ctx,
|
|
1787
|
+
stack,
|
|
1788
|
+
{ publish: true, dryRun: false, limit: 1 },
|
|
1789
|
+
ctx.actor,
|
|
1790
|
+
);
|
|
1791
|
+
if (!synced.ok) return failWith(synced);
|
|
1792
|
+
absorb(synced.data);
|
|
1793
|
+
}
|
|
1794
|
+
const ledgerRows = () => {
|
|
1795
|
+
const ledger = readLedger(ctx.cwd);
|
|
1796
|
+
return ledger.ok ? ledger.value.rows : [];
|
|
1797
|
+
};
|
|
1798
|
+
const simulated = new Set<string>();
|
|
1799
|
+
let justRestacked = new Set(outcome.restacked);
|
|
1800
|
+
for (;;) {
|
|
1801
|
+
touchStackLock();
|
|
1802
|
+
const entry = stack.branches.find(
|
|
1803
|
+
(candidate) => !candidate.merged && !simulated.has(candidate.branch),
|
|
1804
|
+
);
|
|
1805
|
+
if (!entry) {
|
|
1806
|
+
outcome.complete =
|
|
1807
|
+
!options.dryRun ||
|
|
1808
|
+
stack.branches.every((candidate) => candidate.merged || simulated.has(candidate.branch));
|
|
1809
|
+
break;
|
|
1810
|
+
}
|
|
1811
|
+
if (options.max !== null && outcome.landed.length + outcome.wouldLand.length >= options.max) {
|
|
1812
|
+
outcome.stoppedAt = {
|
|
1813
|
+
pr: entry.pr,
|
|
1814
|
+
branch: entry.branch,
|
|
1815
|
+
reason: "max_reached",
|
|
1816
|
+
detail: `--max ${options.max} reached`,
|
|
1817
|
+
unblock: "workit stack land",
|
|
1818
|
+
ready: false,
|
|
1819
|
+
};
|
|
1820
|
+
break;
|
|
1821
|
+
}
|
|
1822
|
+
let doc: PrStatusDoc | null = null;
|
|
1823
|
+
if (entry.pr) {
|
|
1824
|
+
const head = branchTip(ctx.cwd, entry.branch);
|
|
1825
|
+
const read =
|
|
1826
|
+
justRestacked.has(entry.branch) && head
|
|
1827
|
+
? await awaitChecks(ctx, entry.pr, head, options)
|
|
1828
|
+
: statusDoc(ctx, entry.pr);
|
|
1829
|
+
if (!read.ok) return failWith(fromForge(read));
|
|
1830
|
+
doc = read.data;
|
|
1831
|
+
if (options.dryRun && doc.state === "merged") {
|
|
1832
|
+
// Merged elsewhere; the real run would restack past it.
|
|
1833
|
+
simulated.add(entry.branch);
|
|
1834
|
+
continue;
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
const grant = requireGrant(ctx.cwd, "merge");
|
|
1838
|
+
const verdict = verdictOf(ctx.cwd, entry.branch, ledgerRows());
|
|
1839
|
+
// A dry run predicts the retarget the real run would do first.
|
|
1840
|
+
const effective =
|
|
1841
|
+
options.dryRun && simulated.size > 0 && doc ? { ...doc, base: stack.trunk } : doc;
|
|
1842
|
+
const q = qualify(
|
|
1843
|
+
ctx,
|
|
1844
|
+
stack,
|
|
1845
|
+
entry,
|
|
1846
|
+
effective,
|
|
1847
|
+
verdict,
|
|
1848
|
+
grant,
|
|
1849
|
+
doc ? prBinding(ctx, entry, doc) : null,
|
|
1850
|
+
);
|
|
1851
|
+
if (!q.ok) {
|
|
1852
|
+
outcome.stoppedAt = {
|
|
1853
|
+
pr: entry.pr,
|
|
1854
|
+
branch: entry.branch,
|
|
1855
|
+
reason: q.reason,
|
|
1856
|
+
detail: q.detail,
|
|
1857
|
+
unblock: q.unblock,
|
|
1858
|
+
ready: q.ready,
|
|
1859
|
+
};
|
|
1860
|
+
break;
|
|
1861
|
+
}
|
|
1862
|
+
const pr = entry.pr as number;
|
|
1863
|
+
if (options.dryRun) {
|
|
1864
|
+
outcome.wouldLand.push({ pr, branch: entry.branch });
|
|
1865
|
+
simulated.add(entry.branch);
|
|
1866
|
+
continue;
|
|
1867
|
+
}
|
|
1868
|
+
const merged = await mergePullRequest(
|
|
1869
|
+
ctx.cwd,
|
|
1870
|
+
ctx.resolved,
|
|
1871
|
+
{
|
|
1872
|
+
pr,
|
|
1873
|
+
method: options.method,
|
|
1874
|
+
deleteBranch: false,
|
|
1875
|
+
actor: ctx.actor,
|
|
1876
|
+
expect: { base: stack.trunk, branch: entry.branch },
|
|
1877
|
+
},
|
|
1878
|
+
ctx.sleep,
|
|
1879
|
+
);
|
|
1880
|
+
if (!merged.ok) {
|
|
1881
|
+
if (merged.code === "blocked" && "refusal" in merged && merged.refusal) {
|
|
1882
|
+
outcome.stoppedAt = {
|
|
1883
|
+
pr,
|
|
1884
|
+
branch: entry.branch,
|
|
1885
|
+
reason: "merge_refused",
|
|
1886
|
+
detail: merged.error,
|
|
1887
|
+
unblock: merged.unblock ?? `workit pr status --pr ${pr}`,
|
|
1888
|
+
ready: false,
|
|
1889
|
+
};
|
|
1890
|
+
break;
|
|
1891
|
+
}
|
|
1892
|
+
return failWith(stackFail(merged.code, merged.error, merged.unblock));
|
|
1893
|
+
}
|
|
1894
|
+
outcome.landed.push({ pr, branch: entry.branch, mergeSha: merged.data.mergeSha });
|
|
1895
|
+
entry.merged = { pr, mergeSha: merged.data.mergeSha, at: new Date().toISOString() };
|
|
1896
|
+
const written = writeStack(ctx.cwd, stack);
|
|
1897
|
+
if (!written.ok) return failWith(written);
|
|
1898
|
+
// Move the next PR onto the trunk: restack, lease push, retarget. The
|
|
1899
|
+
// ones above it wait (restacking them now would only spend CI that the
|
|
1900
|
+
// next merge restarts); `workit stack sync` moves them all.
|
|
1901
|
+
const before = new Set(outcome.restacked);
|
|
1902
|
+
const synced = syncStack(
|
|
1903
|
+
ctx.cwd,
|
|
1904
|
+
ctx,
|
|
1905
|
+
stack,
|
|
1906
|
+
{ publish: true, dryRun: false, limit: 1 },
|
|
1907
|
+
ctx.actor,
|
|
1908
|
+
);
|
|
1909
|
+
if (!synced.ok) return failWith(synced);
|
|
1910
|
+
absorb(synced.data);
|
|
1911
|
+
justRestacked = new Set(outcome.restacked.filter((branch) => !before.has(branch)));
|
|
1912
|
+
for (const step of synced.data.steps) if (step.restack?.head) justRestacked.add(step.branch);
|
|
1913
|
+
}
|
|
1914
|
+
return { ok: true, data: outcome };
|
|
1915
|
+
}
|