@workit-dev/core 0.0.1 → 1.0.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.
Files changed (234) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -43
  3. package/package.json +56 -6
  4. package/schemas/config.json +74 -0
  5. package/schemas/vcs.json +79 -0
  6. package/schemas/workspaces.json +1029 -0
  7. package/schemas/youtrack.json +84 -0
  8. package/scripts/analyze-release-scope.ts +215 -0
  9. package/scripts/bundled-deps.json +131 -0
  10. package/scripts/fixtures/sample-plan.md +15 -0
  11. package/scripts/fixtures/sample-sdd/progress.md +2 -0
  12. package/scripts/install-codex-plugin.sh +28 -0
  13. package/scripts/install-cursor-plugin.sh +146 -0
  14. package/scripts/install-opencode-plugin.sh +79 -0
  15. package/scripts/install-pi-package.sh +56 -0
  16. package/scripts/lib/config-dir.sh +28 -0
  17. package/scripts/publish-changed-packages.ts +395 -0
  18. package/scripts/rewrite-workspace-deps.ts +53 -0
  19. package/scripts/run-cursor-mcp.sh +6 -0
  20. package/scripts/sync-release-manifests.ts +82 -0
  21. package/scripts/sync-runtime.sh +142 -0
  22. package/scripts/validate-cursor-marketplace.ts +171 -0
  23. package/scripts/verify-bundle-sources.ts +134 -0
  24. package/scripts/write-schemas.ts +16 -0
  25. package/skills/workit-architecture/SKILL.md +72 -0
  26. package/skills/workit-architecture/agents/openai.yaml +3 -0
  27. package/skills/workit-architecture/references/instruction-files.md +52 -0
  28. package/skills/workit-architecture/references/vocabulary.md +53 -0
  29. package/skills/workit-bdd/SKILL.md +53 -0
  30. package/skills/workit-continue/SKILL.md +54 -0
  31. package/skills/workit-debug/SKILL.md +45 -0
  32. package/skills/workit-deslop/SKILL.md +58 -0
  33. package/skills/workit-fanout/SKILL.md +71 -0
  34. package/skills/workit-fanout/references/brief.md +141 -0
  35. package/skills/workit-fanout/references/orchestration.md +169 -0
  36. package/skills/workit-implement/SKILL.md +71 -0
  37. package/skills/workit-retro/SKILL.md +72 -0
  38. package/skills/workit-retro/agents/openai.yaml +3 -0
  39. package/skills/workit-retro/references/sources.md +35 -0
  40. package/skills/workit-retro/references/steering.md +39 -0
  41. package/skills/workit-review/SKILL.md +66 -0
  42. package/skills/workit-review/references/impact.md +24 -0
  43. package/skills/workit-shape/SKILL.md +71 -0
  44. package/skills/workit-shape/references/diagrams.md +17 -0
  45. package/skills/workit-shape/references/knowledge.md +61 -0
  46. package/skills/workit-shape/references/mockups.md +15 -0
  47. package/skills/workit-shape/references/slicing.md +44 -0
  48. package/skills/workit-ship/SKILL.md +71 -0
  49. package/skills/workit-ship/references/changelog.md +44 -0
  50. package/skills/workit-ship/references/pr-body.md +32 -0
  51. package/skills/workit-ship/references/release.md +39 -0
  52. package/skills/workit-test-audit/SKILL.md +54 -0
  53. package/skills/workit-verify-app/SKILL.md +68 -0
  54. package/skills/workit-verify-app/references/template.md +51 -0
  55. package/src/autonomy.ts +910 -0
  56. package/src/check-config.ts +613 -0
  57. package/src/checks.ts +534 -0
  58. package/src/ci-waiter.ts +159 -0
  59. package/src/core/boundary.ts +55 -0
  60. package/src/core/branch-policy.ts +112 -0
  61. package/src/core/branch.ts +1192 -0
  62. package/src/core/changelog-gate.ts +349 -0
  63. package/src/core/changelog-merge.ts +214 -0
  64. package/src/core/changelog.ts +392 -0
  65. package/src/core/cleanup.ts +638 -0
  66. package/src/core/cli-locate.ts +173 -0
  67. package/src/core/commit-flavors.ts +65 -0
  68. package/src/core/config-schemas.ts +653 -0
  69. package/src/core/config.ts +351 -0
  70. package/src/core/context-read.ts +144 -0
  71. package/src/core/docs-gate.ts +69 -0
  72. package/src/core/finding-evidence.ts +89 -0
  73. package/src/core/finding-queue.ts +36 -0
  74. package/src/core/git.ts +73 -0
  75. package/src/core/gitignore.ts +52 -0
  76. package/src/core/heavy-eta.ts +170 -0
  77. package/src/core/heavy-lease.ts +290 -0
  78. package/src/core/heavy-rules.ts +373 -0
  79. package/src/core/heavy-scope.ts +131 -0
  80. package/src/core/heavy-words.ts +83 -0
  81. package/src/core/heavy.ts +535 -0
  82. package/src/core/hygiene.ts +149 -0
  83. package/src/core/init.ts +218 -0
  84. package/src/core/live-session.ts +333 -0
  85. package/src/core/logger.ts +310 -0
  86. package/src/core/methods.ts +185 -0
  87. package/src/core/model-fallback.ts +208 -0
  88. package/src/core/node-resolve.mjs +249 -0
  89. package/src/core/operation-input.ts +632 -0
  90. package/src/core/package-root.ts +28 -0
  91. package/src/core/policy/attributes.ts +97 -0
  92. package/src/core/policy/bookkeeping.ts +119 -0
  93. package/src/core/policy/carry.ts +122 -0
  94. package/src/core/policy/check-evidence.ts +115 -0
  95. package/src/core/policy/current-judgment.ts +20 -0
  96. package/src/core/policy/derive.ts +387 -0
  97. package/src/core/policy/judgment.ts +246 -0
  98. package/src/core/policy/lane.ts +562 -0
  99. package/src/core/policy/pr-lane.ts +32 -0
  100. package/src/core/policy/substance.ts +365 -0
  101. package/src/core/policy-resolver.ts +94 -0
  102. package/src/core/pr-create.ts +448 -0
  103. package/src/core/pr-template.ts +162 -0
  104. package/src/core/release-notes.ts +197 -0
  105. package/src/core/release-tracks.ts +779 -0
  106. package/src/core/remotes.ts +436 -0
  107. package/src/core/repo-context.ts +542 -0
  108. package/src/core/repo-paths.ts +29 -0
  109. package/src/core/resources.ts +1299 -0
  110. package/src/core/role-models.ts +603 -0
  111. package/src/core/route-intent.ts +305 -0
  112. package/src/core/rules.ts +65 -0
  113. package/src/core/runtime-identity.ts +63 -0
  114. package/src/core/safe-write.ts +87 -0
  115. package/src/core/scope-glob.ts +56 -0
  116. package/src/core/scripts.ts +6 -0
  117. package/src/core/skill-manifests.ts +173 -0
  118. package/src/core/store-lock.ts +374 -0
  119. package/src/core/support-matrix.ts +24 -0
  120. package/src/core/task-context.ts +247 -0
  121. package/src/core/task-contract.ts +1279 -0
  122. package/src/core/task-delivery.ts +109 -0
  123. package/src/core/task-engine.ts +2248 -0
  124. package/src/core/task-evaluation.ts +1147 -0
  125. package/src/core/task-store.ts +2649 -0
  126. package/src/core/task-view.ts +180 -0
  127. package/src/core/templates.ts +22 -0
  128. package/src/core/tracker-issues.ts +174 -0
  129. package/src/core/vcs-config.ts +540 -0
  130. package/src/core/workers.ts +62 -0
  131. package/src/core/workit-paths.mjs +242 -0
  132. package/src/core/workspace-branch-policy.ts +123 -0
  133. package/src/core/workspaces.ts +1745 -0
  134. package/src/core/youtrack-backend.ts +288 -0
  135. package/src/core/youtrack-tools.ts +260 -0
  136. package/src/core/youtrack-writes.ts +73 -0
  137. package/src/core/youtrack-ytrack.ts +217 -0
  138. package/src/core/youtrack.ts +454 -0
  139. package/src/core/ytrack-skill.ts +297 -0
  140. package/src/core.ts +171 -0
  141. package/src/deslop/lex.ts +271 -0
  142. package/src/deslop/scan.ts +232 -0
  143. package/src/duration.ts +27 -0
  144. package/src/fanout-brief.ts +486 -0
  145. package/src/fanout-check.ts +455 -0
  146. package/src/fanout-landed.ts +370 -0
  147. package/src/fanout-stack.ts +108 -0
  148. package/src/fanout-status.ts +362 -0
  149. package/src/fanout-worktree.ts +893 -0
  150. package/src/fanout.ts +1473 -0
  151. package/src/forge/exec.ts +385 -0
  152. package/src/forge/github.ts +831 -0
  153. package/src/forge/gitlab.ts +906 -0
  154. package/src/forge/issue-forge.ts +209 -0
  155. package/src/forge/issue-link.ts +468 -0
  156. package/src/forge/issue-ops.ts +314 -0
  157. package/src/forge/merge-gate-base-red.ts +72 -0
  158. package/src/forge/pr-body.ts +100 -0
  159. package/src/forge/pr-edit.ts +292 -0
  160. package/src/forge/pr-ops.ts +1109 -0
  161. package/src/forge/pr-status.ts +10 -0
  162. package/src/forge/redact.ts +195 -0
  163. package/src/forge/report.ts +994 -0
  164. package/src/forge/reruns.ts +132 -0
  165. package/src/forge/resolve.ts +509 -0
  166. package/src/forge/types.ts +242 -0
  167. package/src/forge/verify.ts +398 -0
  168. package/src/git/default-base.ts +130 -0
  169. package/src/git/env-redirect.ts +28 -0
  170. package/src/git/facts.ts +122 -0
  171. package/src/git/ops.ts +1823 -0
  172. package/src/git/refs-read.ts +332 -0
  173. package/src/git/release.ts +1020 -0
  174. package/src/git/rev.ts +1175 -0
  175. package/src/git/session-hook.ts +597 -0
  176. package/src/git/stage.ts +63 -0
  177. package/src/hooks/agent-model.ts +149 -0
  178. package/src/hooks/budget-signal.ts +284 -0
  179. package/src/hooks/context.ts +461 -0
  180. package/src/hooks/cwd-normalize.ts +79 -0
  181. package/src/hooks/deadline.ts +228 -0
  182. package/src/hooks/delivery-verbs.ts +182 -0
  183. package/src/hooks/descriptor.ts +124 -0
  184. package/src/hooks/handle.ts +433 -0
  185. package/src/hooks/heavy-gate.ts +99 -0
  186. package/src/hooks/hook-runs.ts +130 -0
  187. package/src/hooks/hosts/claude-code.ts +380 -0
  188. package/src/hooks/hosts/codex.ts +372 -0
  189. package/src/hooks/hosts/cursor.ts +419 -0
  190. package/src/hooks/hosts/fields.ts +98 -0
  191. package/src/hooks/hosts/opencode.ts +79 -0
  192. package/src/hooks/hosts/pi.ts +78 -0
  193. package/src/hooks/index.ts +60 -0
  194. package/src/hooks/model-limit.ts +378 -0
  195. package/src/hooks/model-switch.ts +35 -0
  196. package/src/hooks/policy.ts +29 -0
  197. package/src/hooks/process.ts +48 -0
  198. package/src/hooks/protocol.ts +135 -0
  199. package/src/hooks/raw-git.ts +2289 -0
  200. package/src/hooks/repeat-failure.ts +115 -0
  201. package/src/hooks/run.ts +54 -0
  202. package/src/hooks/session-marker.ts +151 -0
  203. package/src/hooks/shell-words.ts +532 -0
  204. package/src/hooks/skill-nudge.ts +295 -0
  205. package/src/hooks/spawn-gate.ts +72 -0
  206. package/src/hooks/standing-signal.ts +99 -0
  207. package/src/hooks/standing.ts +225 -0
  208. package/src/hooks/steering.ts +74 -0
  209. package/src/hooks/stop.ts +408 -0
  210. package/src/hooks/worktree-confine.ts +415 -0
  211. package/src/hooks/write-gate.ts +1174 -0
  212. package/src/host-session.ts +459 -0
  213. package/src/knowledge.ts +368 -0
  214. package/src/ledger.ts +2913 -0
  215. package/src/stack.ts +2570 -0
  216. package/src/store/event-log.ts +217 -0
  217. package/src/store/notes.ts +29 -0
  218. package/src/store/patch.ts +135 -0
  219. package/src/store/paths.ts +368 -0
  220. package/src/store/reduce.ts +203 -0
  221. package/src/test-audit-mocks.ts +309 -0
  222. package/src/verdict-cover.ts +151 -0
  223. package/src/worktree-deps.ts +142 -0
  224. package/templates/headers.md +3 -0
  225. package/templates/hygiene/.editorconfig +8 -0
  226. package/templates/hygiene/.gitattributes +3 -0
  227. package/templates/hygiene/CHANGELOG.md +14 -0
  228. package/templates/hygiene/CONTRIBUTING.md +3 -0
  229. package/templates/hygiene/LICENSE +21 -0
  230. package/templates/hygiene/README.md +3 -0
  231. package/templates/issue-update.md +5 -0
  232. package/templates/plan-template.md +26 -0
  233. package/templates/spec-template.md +64 -0
  234. package/templates/workit-contract.md +13 -0
package/src/stack.ts ADDED
@@ -0,0 +1,2570 @@
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: first a pre-flight (#739) judges every PR the run would land (open,
27
+ // based on trunk or its parent, READY (S10), verdict-accepted (S13), the
28
+ // merge grant, its author, the `--allow-failing` waiver on its head); any
29
+ // refusal stops the land with nothing merged and names every refused PR. Then
30
+ // it merges from the root, one at a time through the S11 `pr merge` gates
31
+ // (merge grant, head-SHA guard). After each merge the rest is restacked and
32
+ // retargeted and the next PR's CI is awaited before it is judged again; what
33
+ // only shows then (a head that moved, a forge race) stops the run there, and
34
+ // the outcome lists what landed. Descendants are never armed for auto-merge.
35
+ //
36
+ // Optional adapters (`gh stack`, git-spice) can later implement `StackAdapter`
37
+ // and replace the built-in restack/retarget; nothing else depends on how the
38
+ // chain is rewritten.
39
+ import { spawnSync } from "node:child_process";
40
+ import { createHash, randomBytes } from "node:crypto";
41
+ import fs from "node:fs";
42
+ import path from "node:path";
43
+ import { requireGrant, type GrantDecision } from "./autonomy";
44
+ import { pushTarget } from "./core/remotes";
45
+ import { isProtectedTarget } from "./core/branch";
46
+ import { classifyLockOwner, localLockHost, processStartOf } from "./core/store-lock";
47
+ import { baseTarget, forgeLogin, mergePullRequest, otherAuthor, type Sleep } from "./forge/pr-ops";
48
+ import {
49
+ buildStatusDoc,
50
+ mergeGateForge,
51
+ pollDelay,
52
+ waitVerdict,
53
+ type CheckWaiver,
54
+ type WaivedCheck,
55
+ type ChecksState,
56
+ type NextAction,
57
+ type PrStatusDoc,
58
+ } from "./forge/report";
59
+ import type { ResolvedForge } from "./forge/resolve";
60
+ import type { ForgeResult, MergeMethod, PrState } from "./forge/types";
61
+ import { executePush, pushPreflight, recordedRemoteTip, type PushPlan } from "./git/ops";
62
+ import {
63
+ currentBranch,
64
+ fetchRefs,
65
+ GIT_TIMEOUTS,
66
+ hasCommit,
67
+ mergeBase,
68
+ patchId,
69
+ pushRemoteName,
70
+ remoteRefTip,
71
+ resolveRef,
72
+ sameOwnChange,
73
+ } from "./git/rev";
74
+ import {
75
+ appendObserved,
76
+ diffHash,
77
+ rangeKeys,
78
+ readLedger,
79
+ storeRoot,
80
+ type LedgerActor,
81
+ type ReadRow,
82
+ type VerdictBasis,
83
+ } from "./ledger";
84
+ import type { LaneNeed, ReviewLane } from "./core/policy/lane";
85
+ import { prLaneGate } from "./core/policy/pr-lane";
86
+ import { checkVerdictsCovered, type StackCover } from "./verdict-cover";
87
+
88
+ export const STACK_VERSION = 1;
89
+
90
+ export type StackEntry = {
91
+ branch: string;
92
+ /** The branch this one is built on (the trunk for the root). */
93
+ parent: string;
94
+ pr: number | null;
95
+ /** The branch tip when the stack last processed it. */
96
+ lastHead: string | null;
97
+ /** The parent tip it was last rebased onto (the `--onto` upstream for the next restack). */
98
+ lastParentHead: string | null;
99
+ /** patch-id of lastParentHead...lastHead. */
100
+ patchId: string | null;
101
+ /** Set once its PR merged (or its tip reached the trunk); skipped afterwards. */
102
+ merged: { pr: number | null; mergeSha: string | null; at: string } | null;
103
+ /**
104
+ * A restack begun but not yet verified and pushed: the change as it was
105
+ * (`from..tip`) and the base it is moving onto. Survives a conflict or a
106
+ * content_changed stop, so the next sync compares against the original.
107
+ */
108
+ pendingRestack?: { from: string; tip: string; onto: string } | null;
109
+ };
110
+
111
+ export type StackFile = {
112
+ v: number;
113
+ name: string;
114
+ trunk: string;
115
+ forge: "github" | "gitlab" | null;
116
+ repo: string | null;
117
+ branches: StackEntry[];
118
+ updatedAt: string;
119
+ };
120
+
121
+ export type StackError = {
122
+ ok: false;
123
+ code: "blocked" | "failed" | "unavailable" | "not_found" | "busy" | "invalid_input";
124
+ error: string;
125
+ unblock?: string;
126
+ data?: Record<string, unknown>;
127
+ };
128
+ export type StackResult<T> = { ok: true; data: T } | StackError;
129
+
130
+ const stackFail = (
131
+ code: StackError["code"],
132
+ error: string,
133
+ unblock?: string,
134
+ data?: Record<string, unknown>,
135
+ ): StackError => ({
136
+ ok: false,
137
+ code,
138
+ error,
139
+ ...(unblock ? { unblock } : {}),
140
+ ...(data ? { data } : {}),
141
+ });
142
+
143
+ const fromForge = (result: Extract<ForgeResult<unknown>, { ok: false }>): StackError =>
144
+ stackFail(result.code, result.error, result.unblock);
145
+
146
+ /**
147
+ * Test and adapter seams. `pushPlan` decides where a branch is pushed (the
148
+ * S11 preflight by default); `adapter` is where an optional `gh stack` /
149
+ * git-spice adapter would plug in later. Only the built-in one ships.
150
+ */
151
+ export type StackAdapter = {
152
+ name: string;
153
+ /** Rebase `branch` from `from` onto `onto`; the built-in one runs git rebase --onto. */
154
+ restack(cwd: string, input: { branch: string; onto: string; from: string }): RestackOutcome;
155
+ };
156
+
157
+ export const stackDeps: {
158
+ pushPlan: (cwd: string, branch: string) => ForgeResult<PushPlan>;
159
+ adapter: StackAdapter;
160
+ /** Bound on one restack (hooks such as post-rewrite run inside it). */
161
+ rebaseTimeoutMs: number;
162
+ } = {
163
+ pushPlan: (cwd, branch) => pushPreflight(cwd, { branch }),
164
+ adapter: { name: "git", restack: (cwd, input) => gitRestack(cwd, input) },
165
+ rebaseTimeoutMs: Number(process.env.WORKIT_STACK_REBASE_TIMEOUT_MS) || 10 * 60_000,
166
+ };
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // git plumbing
170
+
171
+ type GitRun = {
172
+ ok: boolean;
173
+ stdout: string;
174
+ stderr: string;
175
+ timedOut: boolean;
176
+ };
177
+
178
+ const gitRun = (cwd: string, args: string[], timeoutMs: number = GIT_TIMEOUTS.local): GitRun => {
179
+ const run = spawnSync("git", args, {
180
+ cwd,
181
+ encoding: "utf8",
182
+ timeout: timeoutMs,
183
+ killSignal: "SIGKILL",
184
+ windowsHide: true,
185
+ maxBuffer: 64 * 1024 * 1024,
186
+ env: {
187
+ ...process.env,
188
+ GIT_TERMINAL_PROMPT: "0",
189
+ GIT_EDITOR: "true",
190
+ GIT_SEQUENCE_EDITOR: "true",
191
+ LC_ALL: "C",
192
+ LANGUAGE: "C",
193
+ },
194
+ });
195
+ const failure: NodeJS.ErrnoException | undefined = run.error;
196
+ return {
197
+ ok: run.status === 0,
198
+ stdout: run.stdout ?? "",
199
+ stderr: run.error ? String(run.error.message) : (run.stderr ?? ""),
200
+ timedOut: failure?.code === "ETIMEDOUT",
201
+ };
202
+ };
203
+
204
+ const safeRef = (value: string): boolean => value.length > 0 && !value.startsWith("-");
205
+
206
+ const isAncestor = (cwd: string, ancestor: string, descendant: string): boolean =>
207
+ safeRef(ancestor) &&
208
+ safeRef(descendant) &&
209
+ gitRun(cwd, ["merge-base", "--is-ancestor", ancestor, descendant]).ok;
210
+
211
+ const branchTip = (cwd: string, branch: string): string | null =>
212
+ resolveRef(cwd, `refs/heads/${branch}`);
213
+
214
+ /**
215
+ * The worktree that has `branch` checked out, if any, including one where a
216
+ * rebase of it is stopped (HEAD is detached then; the branch is named in
217
+ * rebase-merge/head-name).
218
+ */
219
+ function worktreeOf(cwd: string, branch: string): string | null {
220
+ const list = gitRun(cwd, ["worktree", "list", "--porcelain"]);
221
+ if (!list.ok) return null;
222
+ let current: string | null = null;
223
+ for (const line of list.stdout.split("\n")) {
224
+ if (line.startsWith("worktree ")) current = line.slice("worktree ".length);
225
+ else if (line === `branch refs/heads/${branch}` && current) return current;
226
+ else if (line === "detached" && current && rebasingBranch(current) === `refs/heads/${branch}`)
227
+ return current;
228
+ }
229
+ return null;
230
+ }
231
+
232
+ function rebasingBranch(dir: string): string | null {
233
+ for (const name of ["rebase-merge/head-name", "rebase-apply/head-name"]) {
234
+ const found = gitRun(dir, ["rev-parse", "--path-format=absolute", "--git-path", name]);
235
+ if (!found.ok) continue;
236
+ try {
237
+ return fs.readFileSync(found.stdout.trim(), "utf8").trim();
238
+ } catch {
239
+ // not this one
240
+ }
241
+ }
242
+ return null;
243
+ }
244
+
245
+ /** A rebase (or am) stopped in this worktree. */
246
+ function rebaseInProgress(dir: string): boolean {
247
+ for (const name of ["rebase-merge", "rebase-apply"]) {
248
+ const found = gitRun(dir, ["rev-parse", "--path-format=absolute", "--git-path", name]);
249
+ if (found.ok && fs.existsSync(found.stdout.trim())) return true;
250
+ }
251
+ return false;
252
+ }
253
+
254
+ const trackedDirt = (dir: string): boolean => {
255
+ const status = gitRun(dir, ["status", "--porcelain", "--untracked-files=no"]);
256
+ return !status.ok || status.stdout.trim() !== "";
257
+ };
258
+
259
+ export type RestackOutcome =
260
+ | { ok: true; head: string }
261
+ | {
262
+ ok: false;
263
+ conflict: boolean;
264
+ timedOut?: boolean;
265
+ worktree: string;
266
+ error: string;
267
+ };
268
+
269
+ /** Where sync checks out branches nobody has checked out (never the user's checkout). */
270
+ const scratchDir = (cwd: string): string | null => {
271
+ const dir = stacksDir(cwd);
272
+ return dir.ok ? path.join(dir.data, "worktrees") : null;
273
+ };
274
+
275
+ /**
276
+ * `git rebase --onto <onto> <from>` with the branch checked out: in the
277
+ * worktree that already has it, else in a temporary worktree under the store
278
+ * root (`git worktree add`), so the user's own checkout never moves. Never
279
+ * autostashes and never updates other refs (`rebase.updateRefs` would move
280
+ * sibling stack branches). A temporary worktree is removed on success and
281
+ * kept on a conflict, so the rebase can be continued there.
282
+ */
283
+ function gitRestack(
284
+ cwd: string,
285
+ input: { branch: string; onto: string; from: string },
286
+ ): RestackOutcome {
287
+ const owner = worktreeOf(cwd, input.branch);
288
+ let dir = owner;
289
+ if (!dir) {
290
+ const scratch = scratchDir(cwd);
291
+ if (!scratch)
292
+ return {
293
+ ok: false,
294
+ conflict: false,
295
+ worktree: cwd,
296
+ error: "no store root",
297
+ };
298
+ fs.mkdirSync(scratch, { recursive: true });
299
+ dir = path.join(
300
+ scratch,
301
+ `${input.branch.replace(/[^A-Za-z0-9._-]+/gu, "_").slice(0, 40)}-${randomBytes(4).toString("hex")}`,
302
+ );
303
+ const added = gitRun(cwd, ["worktree", "add", "-q", dir, input.branch], GIT_TIMEOUTS.worktree);
304
+ if (!added.ok)
305
+ return {
306
+ ok: false,
307
+ conflict: false,
308
+ worktree: cwd,
309
+ error: firstLine(added),
310
+ };
311
+ }
312
+ const run = gitRun(
313
+ dir,
314
+ [
315
+ "-c",
316
+ "rebase.autoStash=false",
317
+ "-c",
318
+ "rebase.updateRefs=false",
319
+ "rebase",
320
+ "--no-autosquash",
321
+ "--onto",
322
+ input.onto,
323
+ input.from,
324
+ ],
325
+ stackDeps.rebaseTimeoutMs,
326
+ );
327
+ if (!run.ok) {
328
+ // Killed at the bound: the rebase stopped mid-way, which is not a conflict.
329
+ if (run.timedOut)
330
+ return {
331
+ ok: false,
332
+ conflict: false,
333
+ timedOut: true,
334
+ worktree: dir,
335
+ error: `git rebase did not finish within ${stackDeps.rebaseTimeoutMs} ms`,
336
+ };
337
+ if (rebaseInProgress(dir))
338
+ return {
339
+ ok: false,
340
+ conflict: true,
341
+ worktree: dir,
342
+ error: firstLine(run),
343
+ };
344
+ if (!owner) gitRun(cwd, ["worktree", "remove", "--force", dir]);
345
+ return { ok: false, conflict: false, worktree: dir, error: firstLine(run) };
346
+ }
347
+ if (!owner) gitRun(cwd, ["worktree", "remove", "--force", dir]);
348
+ const head = branchTip(cwd, input.branch);
349
+ return head
350
+ ? { ok: true, head }
351
+ : {
352
+ ok: false,
353
+ conflict: false,
354
+ worktree: dir,
355
+ error: `${input.branch} vanished`,
356
+ };
357
+ }
358
+
359
+ /** Remove temporary sync worktrees that are clean and not mid-rebase. */
360
+ function pruneScratch(cwd: string): void {
361
+ const scratch = scratchDir(cwd);
362
+ if (!scratch || !fs.existsSync(scratch)) return;
363
+ for (const name of fs.readdirSync(scratch)) {
364
+ const dir = path.join(scratch, name);
365
+ if (!rebaseInProgress(dir) && !trackedDirt(dir))
366
+ gitRun(cwd, ["worktree", "remove", "--force", dir]);
367
+ }
368
+ gitRun(cwd, ["worktree", "prune"]);
369
+ }
370
+
371
+ const firstLine = (run: GitRun): string =>
372
+ (`${run.stderr}\n${run.stdout}`.split("\n").find((line) => line.trim()) ?? "git failed")
373
+ .trim()
374
+ .slice(0, 300);
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // store
378
+
379
+ /**
380
+ * Portable file name for a stack: a readable slug plus a hash of the exact
381
+ * name, so names differing only in case or in characters Windows rejects
382
+ * never collide, and no file is ever a reserved device name (CON, NUL, …).
383
+ */
384
+ export const stackFileName = (name: string): string =>
385
+ `${name
386
+ .replace(/[^A-Za-z0-9._-]+/gu, "_")
387
+ .replace(/^[._]+/u, "")
388
+ .slice(0, 48)
389
+ .toLowerCase()}-${createHash("sha256").update(name).digest("hex").slice(0, 12)}.json`;
390
+
391
+ const fileFor = stackFileName;
392
+
393
+ /** A stack name must be printable and short. */
394
+ const validName = (name: string): boolean => {
395
+ if (name.length === 0 || name.length > 200) return false;
396
+ for (let index = 0; index < name.length; index += 1) {
397
+ const code = name.charCodeAt(index);
398
+ if (code < 0x20 || code === 0x7f) return false;
399
+ }
400
+ return true;
401
+ };
402
+
403
+ export function stacksDir(cwd: string): StackResult<string> {
404
+ const root = storeRoot(cwd);
405
+ if (!root.ok) return stackFail(root.code, root.error, root.unblock);
406
+ return { ok: true, data: path.join(root.value.root, "stacks") };
407
+ }
408
+
409
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
410
+ typeof value === "object" && value !== null && !Array.isArray(value);
411
+ const str = (value: unknown): string | null => (typeof value === "string" && value ? value : null);
412
+
413
+ /** Tolerant read (D17): unknown keys are ignored, required keys are checked. */
414
+ function parseStack(raw: unknown): StackFile | null {
415
+ if (!isRecord(raw) || !Array.isArray(raw.branches)) return null;
416
+ const name = str(raw.name);
417
+ const trunk = str(raw.trunk);
418
+ if (!name || !trunk) return null;
419
+ const branches: StackEntry[] = [];
420
+ for (const item of raw.branches) {
421
+ if (!isRecord(item)) return null;
422
+ const branch = str(item.branch);
423
+ if (!branch) return null;
424
+ const merged = isRecord(item.merged)
425
+ ? {
426
+ pr: typeof item.merged.pr === "number" ? item.merged.pr : null,
427
+ mergeSha: str(item.merged.mergeSha),
428
+ at: str(item.merged.at) ?? "",
429
+ }
430
+ : null;
431
+ branches.push({
432
+ branch,
433
+ parent: str(item.parent) ?? trunk,
434
+ pr: typeof item.pr === "number" ? item.pr : null,
435
+ lastHead: str(item.lastHead),
436
+ lastParentHead: str(item.lastParentHead),
437
+ patchId: str(item.patchId),
438
+ merged,
439
+ ...(isRecord(item.pendingRestack) &&
440
+ str(item.pendingRestack.from) &&
441
+ str(item.pendingRestack.tip) &&
442
+ str(item.pendingRestack.onto)
443
+ ? {
444
+ pendingRestack: {
445
+ from: str(item.pendingRestack.from) as string,
446
+ tip: str(item.pendingRestack.tip) as string,
447
+ onto: str(item.pendingRestack.onto) as string,
448
+ },
449
+ }
450
+ : {}),
451
+ });
452
+ }
453
+ return {
454
+ v: typeof raw.v === "number" ? raw.v : STACK_VERSION,
455
+ name,
456
+ trunk,
457
+ forge: raw.forge === "github" || raw.forge === "gitlab" ? raw.forge : null,
458
+ repo: str(raw.repo),
459
+ branches,
460
+ updatedAt: str(raw.updatedAt) ?? "",
461
+ };
462
+ }
463
+
464
+ export function readStack(cwd: string, name: string): StackResult<StackFile | null> {
465
+ const dir = stacksDir(cwd);
466
+ if (!dir.ok) return dir;
467
+ const file = path.join(dir.data, fileFor(name));
468
+ let text: string;
469
+ try {
470
+ text = fs.readFileSync(file, "utf8");
471
+ } catch (error) {
472
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { ok: true, data: null };
473
+ return stackFail("unavailable", `cannot read ${file}: ${(error as Error).message}`);
474
+ }
475
+ let parsed: StackFile | null = null;
476
+ try {
477
+ parsed = parseStack(JSON.parse(text));
478
+ } catch {
479
+ parsed = null;
480
+ }
481
+ return parsed
482
+ ? { ok: true, data: parsed }
483
+ : stackFail(
484
+ "failed",
485
+ `stack file ${file} is not a valid stack`,
486
+ `workit stack plan --name ${name} <branch…> # rebuild it`,
487
+ );
488
+ }
489
+
490
+ /** Atomic write (temp file + rename) so a reader never sees half a stack. */
491
+ export function writeStack(
492
+ cwd: string,
493
+ stack: StackFile,
494
+ now: Date = new Date(),
495
+ ): StackResult<void> {
496
+ const dir = stacksDir(cwd);
497
+ if (!dir.ok) return dir;
498
+ const file = path.join(dir.data, fileFor(stack.name));
499
+ const temp = `${file}.${process.pid}.tmp`;
500
+ try {
501
+ fs.mkdirSync(dir.data, { recursive: true });
502
+ stack.updatedAt = now.toISOString();
503
+ fs.writeFileSync(temp, `${JSON.stringify(stack, null, 2)}\n`);
504
+ fs.renameSync(temp, file);
505
+ return { ok: true, data: undefined };
506
+ } catch (error) {
507
+ fs.rmSync(temp, { force: true });
508
+ return stackFail("unavailable", `cannot write ${file}: ${(error as Error).message}`);
509
+ }
510
+ }
511
+
512
+ /** Every stack in the store, sorted by name (unreadable files are skipped). */
513
+ export function listStacks(cwd: string): StackResult<StackFile[]> {
514
+ const dir = stacksDir(cwd);
515
+ if (!dir.ok) return dir;
516
+ let names: string[] = [];
517
+ try {
518
+ names = fs.readdirSync(dir.data).filter((name) => name.endsWith(".json"));
519
+ } catch {
520
+ return { ok: true, data: [] };
521
+ }
522
+ const out: StackFile[] = [];
523
+ for (const name of names) {
524
+ let parsed: StackFile | null = null;
525
+ try {
526
+ parsed = parseStack(JSON.parse(fs.readFileSync(path.join(dir.data, name), "utf8")));
527
+ } catch {
528
+ parsed = null;
529
+ }
530
+ // Only files at the name's own path count (a renamed copy is ignored).
531
+ if (parsed && fileFor(parsed.name) === name) out.push(parsed);
532
+ }
533
+ return {
534
+ ok: true,
535
+ data: out.toSorted((a, b) => a.name.localeCompare(b.name)),
536
+ };
537
+ }
538
+
539
+ /** The stack `name`, else the one holding the current branch, else the only one. */
540
+ export function selectStack(cwd: string, name: string | null): StackResult<StackFile> {
541
+ if (name !== null && !validName(name))
542
+ return stackFail("invalid_input", "a stack name must be 1-200 printable characters");
543
+ if (name) {
544
+ const read = readStack(cwd, name);
545
+ if (!read.ok) return read;
546
+ return read.data
547
+ ? { ok: true, data: read.data }
548
+ : stackFail(
549
+ "not_found",
550
+ `no stack named ${name}`,
551
+ `workit stack plan --name ${name} <branch…>`,
552
+ );
553
+ }
554
+ const all = listStacks(cwd);
555
+ if (!all.ok) return all;
556
+ const branch = currentBranch(cwd);
557
+ const holding = all.data.filter((stack) =>
558
+ stack.branches.some((entry) => entry.branch === branch),
559
+ );
560
+ if (holding.length === 1) return { ok: true, data: holding[0] };
561
+ if (holding.length === 0 && all.data.length === 1) return { ok: true, data: all.data[0] };
562
+ return stackFail(
563
+ "not_found",
564
+ holding.length > 1 || all.data.length > 1
565
+ ? `several stacks match; pick one (${(holding.length > 1 ? holding : all.data).map((stack) => stack.name).join(", ")})`
566
+ : "no stack is planned here",
567
+ holding.length > 1 || all.data.length > 1
568
+ ? "pass --name <stack>"
569
+ : "workit stack plan [<branch>…] # from the current branch chain by default",
570
+ );
571
+ }
572
+
573
+ // ---------------------------------------------------------------------------
574
+ // single writer
575
+
576
+ const LOCK_WAIT_MS = 2_000;
577
+ /** The holder touches its owner file this often while it works (land waits for CI). */
578
+ const HEARTBEAT_MS = 20_000;
579
+ /** A lock whose owner has not touched it for this long is stale, whoever holds it. */
580
+ const HEARTBEAT_STALE_MS = 2 * 60_000;
581
+ /** A lock directory without a readable owner is debris after this (a holder writes it at once). */
582
+ const OWNERLESS_STALE_MS = 30_000;
583
+
584
+ const OWNER = "owner";
585
+
586
+ type LockOwner = {
587
+ pid: number;
588
+ processStart: string | null;
589
+ host: string;
590
+ nonce: string;
591
+ };
592
+
593
+ const readOwner = (lock: string): { raw: string | null; age: number | null } => {
594
+ let raw: string | null = null;
595
+ try {
596
+ raw = fs.readFileSync(path.join(lock, OWNER), "utf8");
597
+ } catch {
598
+ raw = null;
599
+ }
600
+ let age: number | null = null;
601
+ for (const target of [path.join(lock, OWNER), lock])
602
+ try {
603
+ age = Date.now() - fs.statSync(target).mtimeMs;
604
+ break;
605
+ } catch {
606
+ // next
607
+ }
608
+ return { raw, age };
609
+ };
610
+
611
+ /**
612
+ * Is the lock's holder gone? Same machine (host + pid namespace + boot, the
613
+ * S1 store-lock identity): the pid is dead or reused. Any holder whose
614
+ * heartbeat is older than HEARTBEAT_STALE_MS. A lock with no readable owner
615
+ * once it is OWNERLESS_STALE_MS old.
616
+ */
617
+ export function stackLockStale(raw: string | null, age: number | null): boolean {
618
+ let payload: unknown = null;
619
+ try {
620
+ payload = raw ? JSON.parse(raw) : null;
621
+ } catch {
622
+ payload = null;
623
+ }
624
+ if (payload === null) return age !== null && age > OWNERLESS_STALE_MS;
625
+ const owner = classifyLockOwner(payload, age);
626
+ // A verifiably live holder on this machine is never stale, however old its
627
+ // heartbeat (a long synchronous rebase cannot heartbeat).
628
+ if (owner.state === "live") return false;
629
+ if (owner.state === "stale") return true;
630
+ // Another host or pid namespace: only its heartbeat can tell.
631
+ return age !== null && age > HEARTBEAT_STALE_MS;
632
+ }
633
+
634
+ /** Touch the held stack lock's owner file (called between git/forge steps). */
635
+ let activeBeat: (() => void) | null = null;
636
+ export const touchStackLock = (): void => activeBeat?.();
637
+
638
+ function takeLock(lock: string): LockOwner | null {
639
+ try {
640
+ fs.mkdirSync(lock);
641
+ } catch (error) {
642
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return null;
643
+ throw error;
644
+ }
645
+ const owner: LockOwner = {
646
+ pid: process.pid,
647
+ processStart: processStartOf(process.pid),
648
+ host: localLockHost(),
649
+ nonce: randomBytes(8).toString("hex"),
650
+ };
651
+ fs.writeFileSync(path.join(lock, OWNER), JSON.stringify(owner));
652
+ return owner;
653
+ }
654
+
655
+ /** Move a stale lock aside, only if it still holds what was judged stale. */
656
+ function reapLock(lock: string, observed: string | null): boolean {
657
+ const tomb = `${lock}.reap-${randomBytes(6).toString("hex")}`;
658
+ try {
659
+ fs.renameSync(lock, tomb);
660
+ } catch {
661
+ return false;
662
+ }
663
+ if (readOwner(tomb).raw === observed) {
664
+ fs.rmSync(tomb, { recursive: true, force: true });
665
+ return true;
666
+ }
667
+ try {
668
+ fs.renameSync(tomb, lock);
669
+ } catch {
670
+ fs.rmSync(tomb, { recursive: true, force: true });
671
+ }
672
+ return false;
673
+ }
674
+
675
+ function releaseLock(lock: string, owner: LockOwner): void {
676
+ const { raw } = readOwner(lock);
677
+ if (raw !== JSON.stringify(owner)) return;
678
+ const tomb = `${lock}.release-${randomBytes(6).toString("hex")}`;
679
+ try {
680
+ fs.renameSync(lock, tomb);
681
+ fs.rmSync(tomb, { recursive: true, force: true });
682
+ } catch {
683
+ // someone reaped it
684
+ }
685
+ }
686
+
687
+ /**
688
+ * Hold the stack's lock for `fn`. A lock held by a live process answers
689
+ * `busy` after LOCK_WAIT_MS (retryable). The holder heartbeats while it works,
690
+ * so a lock left by a dead process, a crashed host or an aborted mkdir is
691
+ * reclaimed (see stackLockStale) without ever stealing a working holder's.
692
+ */
693
+ export async function withStackLock<T>(
694
+ cwd: string,
695
+ name: string,
696
+ fn: () => Promise<StackResult<T>>,
697
+ waitMs: number = LOCK_WAIT_MS,
698
+ ): Promise<StackResult<T>> {
699
+ const dir = stacksDir(cwd);
700
+ if (!dir.ok) return dir;
701
+ if (!validName(name))
702
+ return stackFail("invalid_input", "a stack name must be 1-200 printable characters");
703
+ const lock = path.join(dir.data, `${fileFor(name)}.lock`);
704
+ try {
705
+ fs.mkdirSync(dir.data, { recursive: true });
706
+ } catch (error) {
707
+ return stackFail("unavailable", `cannot create ${dir.data}: ${(error as Error).message}`);
708
+ }
709
+ const deadline = Date.now() + waitMs;
710
+ let owner: LockOwner | null = null;
711
+ for (;;) {
712
+ owner = takeLock(lock);
713
+ if (owner) break;
714
+ const seen = readOwner(lock);
715
+ if (stackLockStale(seen.raw, seen.age) && reapLock(lock, seen.raw)) continue;
716
+ if (Date.now() >= deadline) {
717
+ let holder: Partial<LockOwner> = {};
718
+ try {
719
+ holder = seen.raw ? (JSON.parse(seen.raw) as LockOwner) : {};
720
+ } catch {
721
+ holder = {};
722
+ }
723
+ return stackFail(
724
+ "busy",
725
+ `stack ${name} is busy: another workit stack command${holder.pid ? ` (pid ${holder.pid} on ${holder.host ?? "?"})` : ""} holds ${lock}`,
726
+ "retry when it finishes; a lock whose holder stopped heartbeating is reclaimed automatically",
727
+ { lock, pid: holder.pid ?? null, host: holder.host ?? null },
728
+ );
729
+ }
730
+ await new Promise((resolve) => setTimeout(resolve, 25));
731
+ }
732
+ const held = owner;
733
+ const touch = () => {
734
+ try {
735
+ const now = new Date();
736
+ fs.utimesSync(path.join(lock, OWNER), now, now);
737
+ } catch {
738
+ // lost the lock; the release below is a no-op then
739
+ }
740
+ };
741
+ const beat = setInterval(touch, HEARTBEAT_MS);
742
+ beat.unref?.();
743
+ const previous = activeBeat;
744
+ activeBeat = touch;
745
+ try {
746
+ return await fn();
747
+ } finally {
748
+ activeBeat = previous;
749
+ clearInterval(beat);
750
+ releaseLock(lock, held);
751
+ }
752
+ }
753
+
754
+ /**
755
+ * Hold the stack's lock for a synchronous `fn` without waiting: a live holder
756
+ * answers `busy` at once; a stale lock is reclaimed first.
757
+ */
758
+ export function withStackLockSync<T>(
759
+ cwd: string,
760
+ name: string,
761
+ fn: () => StackResult<T>,
762
+ ): StackResult<T> {
763
+ const dir = stacksDir(cwd);
764
+ if (!dir.ok) return dir;
765
+ if (!validName(name))
766
+ return stackFail("invalid_input", "a stack name must be 1-200 printable characters");
767
+ const lock = path.join(dir.data, `${fileFor(name)}.lock`);
768
+ try {
769
+ fs.mkdirSync(dir.data, { recursive: true });
770
+ } catch (error) {
771
+ return stackFail("unavailable", `cannot create ${dir.data}: ${(error as Error).message}`);
772
+ }
773
+ let owner = takeLock(lock);
774
+ if (!owner) {
775
+ const seen = readOwner(lock);
776
+ if (stackLockStale(seen.raw, seen.age) && reapLock(lock, seen.raw)) owner = takeLock(lock);
777
+ }
778
+ if (!owner)
779
+ return stackFail(
780
+ "busy",
781
+ `stack ${name} is busy: another workit stack command holds ${lock}`,
782
+ "retry when it finishes",
783
+ { lock },
784
+ );
785
+ try {
786
+ return fn();
787
+ } finally {
788
+ releaseLock(lock, owner);
789
+ }
790
+ }
791
+
792
+ // ---------------------------------------------------------------------------
793
+ // plan
794
+
795
+ export type PlanInput = {
796
+ name: string | null;
797
+ trunk: string;
798
+ /** Bottom (closest to trunk) first; empty: derive from the current branch. */
799
+ branches: string[];
800
+ };
801
+
802
+ export type PlanOutcome = {
803
+ stack: StackFile;
804
+ created: boolean;
805
+ /** Per branch: built on its parent's tip, and the PR base the forge reports. */
806
+ checks: Array<{
807
+ branch: string;
808
+ parent: string;
809
+ onParent: boolean;
810
+ pr: number | null;
811
+ prBase: string | null;
812
+ baseMatches: boolean | null;
813
+ }>;
814
+ notes: string[];
815
+ };
816
+
817
+ const remoteFor = (cwd: string, resolved: ResolvedForge | null, trunk: string): string =>
818
+ resolved?.baseRemote ?? pushRemoteName(cwd, trunk) ?? "origin";
819
+
820
+ /** The trunk tip: the remote-tracking ref when present, else the local branch. */
821
+ const trunkTip = (cwd: string, remote: string, trunk: string): string | null =>
822
+ resolveRef(cwd, `refs/remotes/${remote}/${trunk}`) ?? branchTip(cwd, trunk);
823
+
824
+ /**
825
+ * The current branch and the local branches it is stacked on: every local
826
+ * branch (not the trunk) whose tip is a proper ancestor of HEAD and not
827
+ * already on the trunk, ordered bottom-up.
828
+ */
829
+ function deriveChain(cwd: string, trunk: string, base: string): StackResult<string[]> {
830
+ const branch = currentBranch(cwd);
831
+ if (!branch || branch === trunk)
832
+ return stackFail(
833
+ "invalid_input",
834
+ branch ? `you are on the trunk ${trunk}` : "HEAD is detached",
835
+ "switch to the top branch of the stack, or name the branches: workit stack plan <bottom> … <top>",
836
+ );
837
+ const refs = gitRun(cwd, [
838
+ "for-each-ref",
839
+ "--format=%(refname:short) %(objectname)",
840
+ "refs/heads",
841
+ ]);
842
+ const head = branchTip(cwd, branch);
843
+ if (!refs.ok || !head) return stackFail("failed", "cannot list local branches");
844
+ const below: Array<{ branch: string; depth: number }> = [];
845
+ for (const line of refs.stdout.split("\n")) {
846
+ const [name, sha] = line.trim().split(" ");
847
+ if (!name || !sha || name === branch || name === trunk) continue;
848
+ if (sha === head || isAncestor(cwd, sha, base) || !isAncestor(cwd, sha, head)) continue;
849
+ const count = gitRun(cwd, ["rev-list", "--count", `${base}..${sha}`]);
850
+ below.push({ branch: name, depth: Number(count.stdout.trim()) || 0 });
851
+ }
852
+ // Branches sharing one tip are ambiguous; keep the order stable by name.
853
+ below.sort((a, b) => a.depth - b.depth || a.branch.localeCompare(b.branch));
854
+ return { ok: true, data: [...below.map((entry) => entry.branch), branch] };
855
+ }
856
+
857
+ export function planStack(
858
+ cwd: string,
859
+ resolved: ResolvedForge | null,
860
+ input: PlanInput,
861
+ existing: StackFile | null,
862
+ ): StackResult<PlanOutcome> {
863
+ const remote = remoteFor(cwd, resolved, input.trunk);
864
+ const notes: string[] = [];
865
+ const trunkSha = trunkTip(cwd, remote, input.trunk);
866
+ if (!trunkSha)
867
+ return stackFail(
868
+ "not_found",
869
+ `trunk ${input.trunk} is not available locally`,
870
+ `git fetch ${remote} ${input.trunk} # or pass --trunk <branch>`,
871
+ );
872
+ let branches = input.branches;
873
+ if (branches.length === 0) {
874
+ if (existing) branches = existing.branches.map((entry) => entry.branch);
875
+ else {
876
+ const derived = deriveChain(cwd, input.trunk, trunkSha);
877
+ if (!derived.ok) return derived;
878
+ branches = derived.data;
879
+ }
880
+ }
881
+ if (new Set(branches).size !== branches.length)
882
+ return stackFail("invalid_input", "a branch is listed twice");
883
+ if (branches.includes(input.trunk))
884
+ return stackFail("invalid_input", `the trunk ${input.trunk} cannot be in the stack`);
885
+ for (const branch of branches)
886
+ if (!safeRef(branch) || !branchTip(cwd, branch))
887
+ return stackFail(
888
+ "not_found",
889
+ `branch ${branch} does not exist locally`,
890
+ `git switch ${branch} # or git branch ${branch} ${remote}/${branch}`,
891
+ );
892
+
893
+ const previous = new Map((existing?.branches ?? []).map((entry) => [entry.branch, entry]));
894
+ const entries: StackEntry[] = [];
895
+ const checks: PlanOutcome["checks"] = [];
896
+ let parent = input.trunk;
897
+ let parentSha = trunkSha;
898
+ for (const branch of branches) {
899
+ const tip = branchTip(cwd, branch) as string;
900
+ const before = previous.get(branch);
901
+ // A recorded merge is re-checked, never trusted: the PR must read merged,
902
+ // or (no PR) the branch must have its own commits and be on the trunk.
903
+ if (before?.merged) {
904
+ let still = false;
905
+ if (before.pr && resolved) {
906
+ const status = resolved.forge.prStatus(before.pr);
907
+ still = status.ok && status.data.state === "merged";
908
+ } else
909
+ still =
910
+ hasOwnCommits(cwd, tip, before.lastParentHead) &&
911
+ (isAncestor(cwd, tip, trunkSha) ||
912
+ squashedOnTrunk(cwd, before.lastParentHead as string, tip, trunkSha));
913
+ if (still) {
914
+ entries.push(before);
915
+ continue;
916
+ }
917
+ notes.push(`${branch} was recorded as merged but is not; it is back in the stack`);
918
+ }
919
+ const onParent = isAncestor(cwd, parentSha, tip);
920
+ let builtOn = parentSha;
921
+ if (!onParent) {
922
+ if (before?.lastParentHead && isAncestor(cwd, before.lastParentHead, tip))
923
+ // What it was last built on still is in its history: keep it, so sync
924
+ // replays only this branch's own commits.
925
+ builtOn = before.lastParentHead;
926
+ else {
927
+ const old =
928
+ oldParentVersion(cwd, parent, parentSha, tip) ?? oldParentLookalike(cwd, parentSha, tip);
929
+ if (old)
930
+ return stackFail(
931
+ "blocked",
932
+ `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`,
933
+ `git rebase --onto ${parent} ${old} ${branch} # moves only ${branch}'s own commits; then workit stack plan`,
934
+ { branch, parent, oldParentTip: old },
935
+ );
936
+ builtOn = mergeBase(cwd, parentSha, tip) ?? parentSha;
937
+ notes.push(
938
+ `${branch} is not on ${parent} and has no recorded base; sync will replay every commit since their merge base ${builtOn.slice(0, 12)}`,
939
+ );
940
+ }
941
+ }
942
+ let pr = before?.pr ?? null;
943
+ let prBase: string | null = null;
944
+ if (resolved) {
945
+ const found = resolved.forge.findPr(branch, {
946
+ owner: resolved.headRepo.split("/")[0] ?? null,
947
+ projectId: resolved.headProjectId,
948
+ sha: tip,
949
+ });
950
+ if (found.ok && found.data && found.data.state === "open") {
951
+ pr = found.data.number;
952
+ const status = resolved.forge.prStatus(pr);
953
+ if (status.ok) prBase = status.data.base;
954
+ } else if (!found.ok) notes.push(`${branch}: PR lookup failed (${found.error})`);
955
+ }
956
+ entries.push({
957
+ ...(before?.pendingRestack ? { pendingRestack: before.pendingRestack } : {}),
958
+ branch,
959
+ parent,
960
+ pr,
961
+ lastHead: tip,
962
+ lastParentHead: builtOn,
963
+ patchId: patchId(cwd, builtOn, tip),
964
+ merged: null,
965
+ });
966
+ checks.push({
967
+ branch,
968
+ parent,
969
+ onParent,
970
+ pr,
971
+ prBase,
972
+ baseMatches: prBase === null ? null : prBase === parent,
973
+ });
974
+ if (!onParent) notes.push(`${branch} is not on ${parent}; workit stack sync rebases it`);
975
+ if (prBase !== null && prBase !== parent)
976
+ notes.push(
977
+ `${branch}: PR base is ${prBase}, the stack says ${parent}; workit stack sync retargets it`,
978
+ );
979
+ parent = branch;
980
+ parentSha = tip;
981
+ }
982
+ if (!resolved) notes.push("forge not resolved: PR numbers were not looked up");
983
+ const name = input.name ?? existing?.name ?? branches[0];
984
+ return {
985
+ ok: true,
986
+ data: {
987
+ stack: {
988
+ v: STACK_VERSION,
989
+ name,
990
+ trunk: input.trunk,
991
+ forge: resolved?.forge.kind ?? existing?.forge ?? null,
992
+ repo: resolved?.forge.repo ?? existing?.repo ?? null,
993
+ branches: entries,
994
+ updatedAt: "",
995
+ },
996
+ created: existing === null,
997
+ checks,
998
+ notes,
999
+ },
1000
+ };
1001
+ }
1002
+
1003
+ /**
1004
+ * Record (or refresh) stack `name` as `branches`, bottom first, without the
1005
+ * forge: `fanout worktree create` calls it when a slice starts on its parent
1006
+ * slice's branch, so `stack sync` can later replay only the child's commits.
1007
+ */
1008
+ export function recordStack(
1009
+ cwd: string,
1010
+ input: { name: string; trunk: string; branches: string[] },
1011
+ ): StackResult<{ stack: StackFile; created: boolean }> {
1012
+ return withStackLockSync(cwd, input.name, () => {
1013
+ const existing = readStack(cwd, input.name);
1014
+ if (!existing.ok) return existing;
1015
+ const planned = planStack(
1016
+ cwd,
1017
+ null,
1018
+ { name: input.name, trunk: input.trunk, branches: input.branches },
1019
+ existing.data,
1020
+ );
1021
+ if (!planned.ok) return planned;
1022
+ const written = writeStack(cwd, planned.data.stack);
1023
+ if (!written.ok) return written;
1024
+ return { ok: true, data: { stack: planned.data.stack, created: planned.data.created } };
1025
+ });
1026
+ }
1027
+
1028
+ /** `tip` has commits of its own beyond `base` (an empty branch is not "merged"). */
1029
+ function hasOwnCommits(cwd: string, tip: string, base: string | null): boolean {
1030
+ if (!base) return false;
1031
+ return tip !== base && !isAncestor(cwd, tip, base);
1032
+ }
1033
+
1034
+ /**
1035
+ * An earlier tip of `parent` (from its reflog) that `child` is built on but
1036
+ * the current parent no longer contains: the parent was rewritten (amend,
1037
+ * rebase) under the child.
1038
+ */
1039
+ function oldParentVersion(
1040
+ cwd: string,
1041
+ parent: string,
1042
+ parentSha: string,
1043
+ child: string,
1044
+ ): string | null {
1045
+ if (!safeRef(parent)) return null;
1046
+ const log = gitRun(cwd, ["reflog", "show", "--format=%H", `refs/heads/${parent}`, "--"]);
1047
+ if (!log.ok) return null;
1048
+ for (const sha of log.stdout.split("\n").map((line) => line.trim())) {
1049
+ if (!sha || sha === parentSha) continue;
1050
+ if (isAncestor(cwd, sha, child) && !isAncestor(cwd, sha, parentSha)) return sha;
1051
+ }
1052
+ return null;
1053
+ }
1054
+
1055
+ type CommitKey = { sha: string; subject: string; patch: string | null };
1056
+
1057
+ /** Non-merge commits of `range`, oldest first, with their stable patch-ids. */
1058
+ function commitKeys(cwd: string, range: string): CommitKey[] {
1059
+ const log = gitRun(cwd, ["log", "--reverse", "--no-merges", "--format=%H%x00%s", range]);
1060
+ if (!log.ok) return [];
1061
+ const patches = new Map<string, string>();
1062
+ const diff = gitRun(cwd, ["log", "--no-merges", "-p", "--format=commit %H", range]);
1063
+ if (diff.ok && diff.stdout.trim()) {
1064
+ const ids = spawnSync("git", ["patch-id", "--stable"], {
1065
+ cwd,
1066
+ input: diff.stdout,
1067
+ encoding: "utf8",
1068
+ maxBuffer: 64 * 1024 * 1024,
1069
+ });
1070
+ for (const line of (ids.stdout ?? "").split("\n")) {
1071
+ const [patch, sha] = line.trim().split(/\s+/u);
1072
+ if (patch && sha) patches.set(sha, patch);
1073
+ }
1074
+ }
1075
+ return log.stdout
1076
+ .split("\n")
1077
+ .filter(Boolean)
1078
+ .map((line) => {
1079
+ const [sha, subject = ""] = line.split("\0");
1080
+ return { sha, subject, patch: patches.get(sha) ?? null };
1081
+ });
1082
+ }
1083
+
1084
+ /**
1085
+ * Without a reflog (expired, fresh clone): commits of `child` since its merge
1086
+ * base with the parent that look like the parent's own (same patch-id or same
1087
+ * subject as a commit the parent now has instead). The newest such commit is
1088
+ * where the old parent version ended.
1089
+ */
1090
+ function oldParentLookalike(cwd: string, parentSha: string, child: string): string | null {
1091
+ const base = mergeBase(cwd, parentSha, child);
1092
+ if (!base) return null;
1093
+ const parentCommits = commitKeys(cwd, `${base}..${parentSha}`);
1094
+ if (parentCommits.length === 0) return null;
1095
+ const patches = new Set(parentCommits.map((commit) => commit.patch).filter(Boolean));
1096
+ const subjects = new Set(parentCommits.map((commit) => commit.subject).filter(Boolean));
1097
+ let last: string | null = null;
1098
+ for (const commit of commitKeys(cwd, `${base}..${child}`))
1099
+ if ((commit.patch && patches.has(commit.patch)) || subjects.has(commit.subject))
1100
+ last = commit.sha;
1101
+ return last;
1102
+ }
1103
+
1104
+ // ---------------------------------------------------------------------------
1105
+ // status
1106
+
1107
+ export type StackVerdict = "READY" | "WAITING" | "ADVANCE" | "COMPLETE";
1108
+
1109
+ export type StopReason =
1110
+ | "no_pr"
1111
+ | "pr_mismatch"
1112
+ | "pr_closed"
1113
+ | "base_not_trunk"
1114
+ | "not_ready"
1115
+ | "no_verdict"
1116
+ | "needs_verdict"
1117
+ | "failed_verdict"
1118
+ | "grant_required"
1119
+ | "max_reached"
1120
+ | "merge_refused"
1121
+ | "waiver_refused"
1122
+ | "head_unverified";
1123
+
1124
+ export type BranchStatus = {
1125
+ branch: string;
1126
+ parent: string;
1127
+ pr: number | null;
1128
+ merged: boolean;
1129
+ head: string | null;
1130
+ /** The parent's (or trunk's) tip is in this branch's history. */
1131
+ onParent: boolean | null;
1132
+ prState: PrState | null;
1133
+ prBase: string | null;
1134
+ baseMatches: boolean | null;
1135
+ next: NextAction | null;
1136
+ checks: ChecksState | null;
1137
+ blockers: string[];
1138
+ /**
1139
+ * `accepted`: an accepted independent verdict; `satisfied`: what the land
1140
+ * gate reads, the verdict the PR's review lane asks for (an accepted
1141
+ * independent one outside `verification: proportional`).
1142
+ */
1143
+ verdict: {
1144
+ accepted: boolean;
1145
+ satisfied: boolean;
1146
+ basis: VerdictBasis;
1147
+ reasons: string[];
1148
+ verdictId: string | null;
1149
+ lane: ReviewLane | null;
1150
+ need: LaneNeed;
1151
+ /** The verdict higher in the stack that covers this branch, when it has none of its own. */
1152
+ cover: StackCover | null;
1153
+ };
1154
+ };
1155
+
1156
+ export type Qualification =
1157
+ | { ok: true }
1158
+ | {
1159
+ ok: false;
1160
+ reason: StopReason;
1161
+ detail: string;
1162
+ unblock: string;
1163
+ ready: boolean;
1164
+ };
1165
+
1166
+ export type StatusOutcome = {
1167
+ name: string;
1168
+ trunk: string;
1169
+ verdict: StackVerdict;
1170
+ reason: string | null;
1171
+ next: string | null;
1172
+ branches: BranchStatus[];
1173
+ };
1174
+
1175
+ type Ctx = {
1176
+ cwd: string;
1177
+ resolved: ResolvedForge;
1178
+ actor: LedgerActor;
1179
+ sleep: Sleep;
1180
+ };
1181
+
1182
+ const noun = (resolved: ResolvedForge, pr: number): string =>
1183
+ `${resolved.forge.kind === "github" ? "PR #" : "MR !"}${pr}`;
1184
+
1185
+ function statusDoc(ctx: Ctx, pr: number): ForgeResult<PrStatusDoc> {
1186
+ const status = ctx.resolved.forge.prStatus(pr);
1187
+ if (!status.ok) return status;
1188
+ return buildStatusDoc(ctx.cwd, ctx.resolved, status.data, {
1189
+ logLines: 0,
1190
+ behind: false,
1191
+ });
1192
+ }
1193
+
1194
+ /**
1195
+ * The recorded PR number must still be this branch's PR from our head
1196
+ * repository: the forge reports this branch as its head, and looking the
1197
+ * branch up (owner/project matched, as `pr create` does) finds this number.
1198
+ */
1199
+ function prBinding(ctx: Pick<Ctx, "resolved">, entry: StackEntry, doc: PrStatusDoc): string | null {
1200
+ const label = noun(ctx.resolved, entry.pr as number);
1201
+ if (doc.head.branch !== entry.branch)
1202
+ return `${label} is for ${doc.head.branch}, not ${entry.branch}`;
1203
+ const found = ctx.resolved.forge.findPr(entry.branch, {
1204
+ owner: ctx.resolved.headRepo.split("/")[0] ?? null,
1205
+ projectId: ctx.resolved.headProjectId,
1206
+ sha: null,
1207
+ });
1208
+ if (!found.ok) return `cannot confirm ${label} belongs to ${entry.branch}: ${found.error}`;
1209
+ if (found.data?.number !== entry.pr)
1210
+ return `${entry.branch} from ${ctx.resolved.headRepo} is ${found.data ? noun(ctx.resolved, found.data.number) : "without a PR"}, not ${label}`;
1211
+ return null;
1212
+ }
1213
+
1214
+ /** The stack was planned against this forge repository. */
1215
+ export function repoMismatch(ctx: Pick<Ctx, "resolved">, stack: StackFile): StackError | null {
1216
+ const { kind, repo } = ctx.resolved.forge;
1217
+ if ((stack.repo && stack.repo !== repo) || (stack.forge && stack.forge !== kind))
1218
+ return stackFail(
1219
+ "blocked",
1220
+ `repo_mismatch: stack ${stack.name} was planned on ${stack.forge ?? "?"} ${stack.repo ?? "?"}, but this checkout resolves to ${kind} ${repo}`,
1221
+ `workit stack plan --name ${stack.name} <branch…> # re-plan it here, or run from the right checkout`,
1222
+ {
1223
+ planned: { forge: stack.forge, repo: stack.repo },
1224
+ resolved: { forge: kind, repo },
1225
+ },
1226
+ );
1227
+ return null;
1228
+ }
1229
+
1230
+ const verdictOf = (
1231
+ cwd: string,
1232
+ branch: string,
1233
+ rows: readonly ReadRow[],
1234
+ doc: PrStatusDoc | null,
1235
+ ): BranchStatus["verdict"] => {
1236
+ // A lower branch may be covered by the verdict at the top of the stack (#334).
1237
+ const check = checkVerdictsCovered(cwd, branch, rows);
1238
+ // Without a PR there is no base to diff against: the gate stays independent.
1239
+ const gate = doc ? prLaneGate(cwd, check, { branch, base: doc.base, head: doc.head.sha }) : null;
1240
+ return {
1241
+ accepted: check.accepted.accepted,
1242
+ satisfied: gate ? gate.satisfied : check.accepted.accepted,
1243
+ basis: check.current.basis,
1244
+ reasons: check.accepted.reasons,
1245
+ verdictId: gate
1246
+ ? gate.verdictId
1247
+ : typeof check.accepted.verdict?.id === "string" && check.accepted.accepted
1248
+ ? check.accepted.verdict.id
1249
+ : null,
1250
+ lane: gate?.lane.lane ?? null,
1251
+ need: gate?.need ?? "non-author",
1252
+ cover: check.cover,
1253
+ };
1254
+ };
1255
+
1256
+ /**
1257
+ * Does the root-most open PR qualify to land? Order: a PR exists and is
1258
+ * open, it targets the trunk, the forge says READY, an accepted verdict
1259
+ * covers the head (unless `bypass`: `--unverified` under `merge: true`), and
1260
+ * the grant allows merging. `ready` says everything but the grant holds
1261
+ * ("verified, ready").
1262
+ */
1263
+ export function qualify(
1264
+ ctx: Pick<Ctx, "cwd" | "resolved">,
1265
+ stack: StackFile,
1266
+ entry: StackEntry,
1267
+ doc: PrStatusDoc | null,
1268
+ verdict: BranchStatus["verdict"],
1269
+ grant: GrantDecision,
1270
+ /** Why the PR number no longer belongs to this branch (null: it does). */
1271
+ binding: string | null = null,
1272
+ /** `stack land --unverified`: skip the verdict where the grant allows it. */
1273
+ bypass = false,
1274
+ ): Qualification {
1275
+ const label = entry.pr ? noun(ctx.resolved, entry.pr) : entry.branch;
1276
+ const stop = (reason: StopReason, detail: string, unblock: string, ready = false) => ({
1277
+ ok: false as const,
1278
+ reason,
1279
+ detail,
1280
+ unblock,
1281
+ ready,
1282
+ });
1283
+ if (!entry.pr || !doc)
1284
+ return stop(
1285
+ "no_pr",
1286
+ `${entry.branch} has no open PR`,
1287
+ `git switch ${entry.branch} && workit pr create --fill --base ${entry.parent} # then workit stack plan`,
1288
+ );
1289
+ if (binding)
1290
+ return stop(
1291
+ "pr_mismatch",
1292
+ binding,
1293
+ `workit stack plan --name ${stack.name} # re-resolve the PRs`,
1294
+ );
1295
+ if (doc.state !== "open")
1296
+ return stop(
1297
+ "pr_closed",
1298
+ `${label} is ${doc.state}`,
1299
+ "reopen it, or drop the branch from the stack: workit stack plan <branches…>",
1300
+ );
1301
+ if (doc.base !== stack.trunk)
1302
+ return stop(
1303
+ "base_not_trunk",
1304
+ `${label} targets ${doc.base}, not ${stack.trunk}`,
1305
+ "workit stack sync",
1306
+ );
1307
+ if (doc.next !== "READY")
1308
+ return stop(
1309
+ "not_ready",
1310
+ `${label} is ${doc.next}${doc.blockers.length ? ` (${doc.blockers.join(", ")})` : ""}`,
1311
+ doc.next === "WAITING_CI"
1312
+ ? `workit ci wait --pr ${entry.pr}`
1313
+ : doc.blockers.includes("mergeability_unknown")
1314
+ ? `the forge is still computing mergeability; wait a minute, then workit stack land again (workit pr status --pr ${entry.pr} shows when it settles)`
1315
+ : `workit pr status --pr ${entry.pr}`,
1316
+ );
1317
+ const allowUnverified = grant.allowed && grant.allowUnverified;
1318
+ // The bypass covers a missing verdict, never a rejection.
1319
+ if (bypass && allowUnverified && verdict.reasons.includes("failing_verdict"))
1320
+ return stop(
1321
+ "failed_verdict",
1322
+ `${label} has a current independent failed verdict for ${doc.head.sha.slice(0, 12)}; --unverified never lands over a rejection`,
1323
+ "a new independent verdict on the head supersedes it",
1324
+ );
1325
+ if (!(bypass && allowUnverified) && !verdict.satisfied) {
1326
+ const bypassHint = allowUnverified
1327
+ ? `; or, only if the user asks to land unverified: workit stack land --unverified --reason "<why>"`
1328
+ : "";
1329
+ return stop(
1330
+ verdict.reasons.includes("no_verdict") ? "no_verdict" : "needs_verdict",
1331
+ verdict.need === "self"
1332
+ ? `${label} has no verdict for ${doc.head.sha.slice(0, 12)} (lane self: the author's own --self verdict is enough; ${verdict.reasons.join(", ") || "none"})`
1333
+ : `${label} has no accepted independent verdict for ${doc.head.sha.slice(0, 12)}${verdict.lane && verdict.lane !== "none" ? ` (lane ${verdict.lane})` : ""} (${verdict.reasons.join(", ") || "none"})`,
1334
+ verdict.need === "self"
1335
+ ? `after an observed passing \`workit check test\`, the author runs: workit ledger verdict tests-verified --self --how "<what you ran>" --branch ${entry.branch}${bypassHint}`
1336
+ : `an independent session verifies ${entry.branch} and runs: workit ledger verdict verified --how "<what was exercised>" --branch ${entry.branch}${bypassHint}`,
1337
+ );
1338
+ }
1339
+ if (!grant.allowed)
1340
+ return stop(
1341
+ "grant_required",
1342
+ `${label} is verified and ready, but ${grant.error}`,
1343
+ grant.unblock,
1344
+ true,
1345
+ );
1346
+ return { ok: true };
1347
+ }
1348
+
1349
+ export function stackStatus(ctx: Ctx, stack: StackFile): StackResult<StatusOutcome> {
1350
+ const mismatch = repoMismatch(ctx, stack);
1351
+ if (mismatch) return mismatch;
1352
+ const ledger = readLedger(ctx.cwd, { integrity: true });
1353
+ if (!ledger.ok) return stackFail(ledger.code, ledger.error, ledger.unblock);
1354
+ const remote = remoteFor(ctx.cwd, ctx.resolved, stack.trunk);
1355
+ const trunkSha = trunkTip(ctx.cwd, remote, stack.trunk);
1356
+ const grant = requireGrant(ctx.cwd, "merge", { target: baseTarget(ctx.resolved) });
1357
+ const branches: BranchStatus[] = [];
1358
+ let verdict: StackVerdict | null = null;
1359
+ let reason: string | null = null;
1360
+ let next: string | null = null;
1361
+ let parentName = stack.trunk;
1362
+ let parentSha = trunkSha;
1363
+ let mergedSeen = false;
1364
+ for (const entry of stack.branches) {
1365
+ let doc: PrStatusDoc | null = null;
1366
+ if (entry.pr) {
1367
+ const read = statusDoc(ctx, entry.pr);
1368
+ if (!read.ok) return fromForge(read);
1369
+ doc = read.data;
1370
+ }
1371
+ const merged = entry.merged !== null || doc?.state === "merged";
1372
+ const head = branchTip(ctx.cwd, entry.branch);
1373
+ const row: BranchStatus = {
1374
+ branch: entry.branch,
1375
+ parent: parentName,
1376
+ pr: entry.pr,
1377
+ merged,
1378
+ head,
1379
+ onParent: merged || !head || !parentSha ? null : isAncestor(ctx.cwd, parentSha, head),
1380
+ prState: doc?.state ?? null,
1381
+ prBase: doc?.base ?? null,
1382
+ baseMatches: doc && !merged ? doc.base === parentName : null,
1383
+ next: doc?.next ?? null,
1384
+ checks: doc?.checks.state ?? null,
1385
+ blockers: doc?.blockers ?? [],
1386
+ verdict: merged
1387
+ ? {
1388
+ accepted: true,
1389
+ satisfied: true,
1390
+ basis: "none",
1391
+ reasons: [],
1392
+ verdictId: null,
1393
+ lane: null,
1394
+ need: "non-author",
1395
+ cover: null,
1396
+ }
1397
+ : verdictOf(ctx.cwd, entry.branch, ledger.value.rows, doc),
1398
+ };
1399
+ branches.push(row);
1400
+ if (merged) {
1401
+ mergedSeen = true;
1402
+ continue;
1403
+ }
1404
+ if (verdict === null) {
1405
+ // The root-most open branch decides the stack's verdict.
1406
+ const stale = row.onParent === false || row.baseMatches === false;
1407
+ if (mergedSeen && stale) {
1408
+ verdict = "ADVANCE";
1409
+ reason = "a parent merged; the rest is not restacked or retargeted yet";
1410
+ next = "workit stack sync";
1411
+ } else {
1412
+ const q = qualify(
1413
+ ctx,
1414
+ stack,
1415
+ entry,
1416
+ doc,
1417
+ row.verdict,
1418
+ grant,
1419
+ doc ? prBinding(ctx, entry, doc) : null,
1420
+ );
1421
+ if (q.ok) {
1422
+ verdict = "READY";
1423
+ next = "workit stack land";
1424
+ } else {
1425
+ verdict = "WAITING";
1426
+ reason = `${q.reason}: ${q.detail}`;
1427
+ next = q.unblock;
1428
+ }
1429
+ }
1430
+ }
1431
+ parentName = entry.branch;
1432
+ parentSha = head;
1433
+ }
1434
+ return {
1435
+ ok: true,
1436
+ data: {
1437
+ name: stack.name,
1438
+ trunk: stack.trunk,
1439
+ verdict: verdict ?? "COMPLETE",
1440
+ reason,
1441
+ next,
1442
+ branches,
1443
+ },
1444
+ };
1445
+ }
1446
+
1447
+ // ---------------------------------------------------------------------------
1448
+ // sync
1449
+
1450
+ export type SyncOptions = {
1451
+ /** Push and retarget (false: only rebase locally). */
1452
+ publish: boolean;
1453
+ dryRun: boolean;
1454
+ /** Process only the first N unmerged branches (land moves just the new root). */
1455
+ limit?: number;
1456
+ /** Branches whose changed restack may be pushed anyway; never used by land. */
1457
+ force?: ReadonlySet<string>;
1458
+ };
1459
+
1460
+ export type SyncStep = {
1461
+ branch: string;
1462
+ pr: number | null;
1463
+ parent: string;
1464
+ /** null: already on its parent. */
1465
+ restack: {
1466
+ from: string;
1467
+ onto: string;
1468
+ head: string | null;
1469
+ carried: boolean | null;
1470
+ } | null;
1471
+ push: { pushed: boolean; forced: boolean; sha: string } | null;
1472
+ retarget: { from: string; to: string } | null;
1473
+ };
1474
+
1475
+ export type SyncOutcome = {
1476
+ name: string;
1477
+ dryRun: boolean;
1478
+ merged: Array<{ branch: string; pr: number | null }>;
1479
+ steps: SyncStep[];
1480
+ };
1481
+
1482
+ type SyncState = {
1483
+ /** PR status docs read during this run, by PR number. */
1484
+ docs: Map<number, PrStatusDoc>;
1485
+ };
1486
+
1487
+ /** Mark branches whose PR merged (or whose tip is on the trunk) as merged. */
1488
+ function detectMerged(
1489
+ ctx: Ctx | null,
1490
+ cwd: string,
1491
+ stack: StackFile,
1492
+ trunkSha: string,
1493
+ state: SyncState,
1494
+ ): StackResult<Array<{ branch: string; pr: number | null }>> {
1495
+ const newly: Array<{ branch: string; pr: number | null }> = [];
1496
+ for (const entry of stack.branches) {
1497
+ if (entry.merged) continue;
1498
+ let merged = false;
1499
+ if (entry.pr && ctx) {
1500
+ const read = statusDoc(ctx, entry.pr);
1501
+ if (!read.ok) return fromForge(read);
1502
+ if (read.data.head.branch !== entry.branch)
1503
+ return stackFail(
1504
+ "blocked",
1505
+ `pr_mismatch: ${noun(ctx.resolved, entry.pr)} is for ${read.data.head.branch}, not ${entry.branch}`,
1506
+ `workit stack plan --name ${stack.name} # re-resolve the PRs`,
1507
+ { branch: entry.branch, pr: entry.pr },
1508
+ );
1509
+ state.docs.set(entry.pr, read.data);
1510
+ merged = read.data.state === "merged";
1511
+ } else {
1512
+ // Without a PR only git can tell: the tip is on the trunk, or its own
1513
+ // change is one trunk commit (a squash merge). A branch with no commits
1514
+ // of its own is empty, not merged.
1515
+ const tip = branchTip(cwd, entry.branch);
1516
+ merged =
1517
+ tip !== null &&
1518
+ hasOwnCommits(cwd, tip, entry.lastParentHead) &&
1519
+ (isAncestor(cwd, tip, trunkSha) ||
1520
+ squashedOnTrunk(cwd, entry.lastParentHead as string, tip, trunkSha));
1521
+ }
1522
+ if (merged) {
1523
+ entry.merged = {
1524
+ pr: entry.pr,
1525
+ mergeSha: null,
1526
+ at: new Date().toISOString(),
1527
+ };
1528
+ newly.push({ branch: entry.branch, pr: entry.pr });
1529
+ }
1530
+ }
1531
+ return { ok: true, data: newly };
1532
+ }
1533
+
1534
+ /** Trunk commits scanned for a squash merge at most. */
1535
+ const SQUASH_SCAN = 500;
1536
+
1537
+ /**
1538
+ * Whether `base..tip` landed on the trunk as one squash commit: its patch-id
1539
+ * equals one trunk commit's since they forked (the trunk not touching the
1540
+ * same lines in between). Squash merges never make the tip an ancestor.
1541
+ */
1542
+ function squashedOnTrunk(cwd: string, base: string, tip: string, trunkSha: string): boolean {
1543
+ const own = patchId(cwd, base, tip);
1544
+ const fork = mergeBase(cwd, tip, trunkSha);
1545
+ if (!own || !fork || fork === trunkSha) return false;
1546
+ const log = gitRun(cwd, [
1547
+ "log",
1548
+ "--no-merges",
1549
+ `--max-count=${SQUASH_SCAN}`,
1550
+ "-p",
1551
+ "--format=commit %H",
1552
+ "-U3",
1553
+ "--diff-algorithm=myers",
1554
+ "--indent-heuristic",
1555
+ "--no-color",
1556
+ "--no-ext-diff",
1557
+ "--no-textconv",
1558
+ "--no-renames",
1559
+ "--full-index",
1560
+ "--src-prefix=a/",
1561
+ "--dst-prefix=b/",
1562
+ `${fork}..${trunkSha}`,
1563
+ "--",
1564
+ ]);
1565
+ if (!log.ok || !log.stdout.trim()) return false;
1566
+ const ids = spawnSync("git", ["patch-id", "--stable"], {
1567
+ cwd,
1568
+ input: log.stdout,
1569
+ encoding: "utf8",
1570
+ timeout: GIT_TIMEOUTS.local,
1571
+ windowsHide: true,
1572
+ });
1573
+ if (ids.status !== 0) return false;
1574
+ return ids.stdout.split("\n").some((line) => line.split(" ")[0] === own);
1575
+ }
1576
+
1577
+ /**
1578
+ * Push `branch` through the S11 push: the grant, the protected-branch check
1579
+ * and the lease. A rewrite is forced only with a lease on the tip the stack
1580
+ * last saw (or workit's own recorded push), and only when that tip is in the
1581
+ * branch's history or reflog (S11 `--force-if-includes` semantics).
1582
+ */
1583
+ function pushBranch(
1584
+ cwd: string,
1585
+ actor: LedgerActor,
1586
+ branch: string,
1587
+ priorTip: string | null,
1588
+ ): StackResult<{ pushed: boolean; forced: boolean; sha: string }> {
1589
+ const plan = stackDeps.pushPlan(cwd, branch);
1590
+ if (!plan.ok) return fromForge(plan);
1591
+ const remote = remoteRefTip(cwd, plan.data.rawUrl, `refs/heads/${branch}`);
1592
+ if (!remote.ok) return stackFail(remote.code, remote.error);
1593
+ if (remote.sha === plan.data.sha)
1594
+ return {
1595
+ ok: true,
1596
+ data: { pushed: false, forced: false, sha: plan.data.sha },
1597
+ };
1598
+ const grant = requireGrant(cwd, "push", {
1599
+ forge: !plan.data.local,
1600
+ target: () => pushTarget(cwd, plan.data.remote),
1601
+ });
1602
+ if (!grant.allowed)
1603
+ return stackFail("blocked", grant.error, grant.unblock, {
1604
+ reason: grant.reason,
1605
+ });
1606
+ const force = remote.sha !== null && !isAncestor(cwd, remote.sha, plan.data.sha);
1607
+ const expect = !force
1608
+ ? null
1609
+ : remote.sha === priorTip
1610
+ ? priorTip
1611
+ : (recordedRemoteTip(cwd, plan.data) ?? null);
1612
+ const pushed = executePush(cwd, plan.data, {
1613
+ forceWithLease: force,
1614
+ expect,
1615
+ actor,
1616
+ });
1617
+ if (!pushed.ok) return { ...fromForge(pushed), data: { branch } };
1618
+ return {
1619
+ ok: true,
1620
+ data: {
1621
+ pushed: pushed.data.pushed,
1622
+ forced: pushed.data.forced,
1623
+ sha: plan.data.sha,
1624
+ },
1625
+ };
1626
+ }
1627
+
1628
+ /**
1629
+ * Bring every unmerged branch onto its effective parent (the nearest unmerged
1630
+ * branch below it, else the trunk), push it and retarget its PR, in order.
1631
+ * Writes the stack file after every branch, so a stop leaves the processed
1632
+ * branches recorded and the rest untouched.
1633
+ */
1634
+ export function syncStack(
1635
+ cwd: string,
1636
+ ctx: Ctx | null,
1637
+ stack: StackFile,
1638
+ options: SyncOptions,
1639
+ actor: LedgerActor,
1640
+ ): StackResult<SyncOutcome> {
1641
+ const remote = remoteFor(cwd, ctx?.resolved ?? null, stack.trunk);
1642
+ let observedTrunk: string | null = null;
1643
+ if (!options.dryRun && safeRef(stack.trunk)) {
1644
+ const fetched = fetchRefs(cwd, remote, [
1645
+ `+refs/heads/${stack.trunk}:refs/remotes/${remote}/${stack.trunk}`,
1646
+ ]);
1647
+ if (!fetched.ok)
1648
+ return stackFail(fetched.code, fetched.error, `git fetch ${remote} ${stack.trunk}`);
1649
+ } else if (safeRef(stack.trunk)) {
1650
+ // Dry run: read the remote trunk without moving any ref (objects by id only).
1651
+ const remoteTrunk = remoteRefTip(cwd, remote, `refs/heads/${stack.trunk}`);
1652
+ if (remoteTrunk.ok && remoteTrunk.sha) {
1653
+ if (!hasCommit(cwd, remoteTrunk.sha)) fetchRefs(cwd, remote, [remoteTrunk.sha]);
1654
+ if (hasCommit(cwd, remoteTrunk.sha)) observedTrunk = remoteTrunk.sha;
1655
+ }
1656
+ }
1657
+ const trunkSha = observedTrunk ?? trunkTip(cwd, remote, stack.trunk);
1658
+ if (!trunkSha)
1659
+ return stackFail(
1660
+ "not_found",
1661
+ `trunk ${stack.trunk} is not available`,
1662
+ `git fetch ${remote} ${stack.trunk}`,
1663
+ );
1664
+ if (ctx) {
1665
+ const mismatch = repoMismatch(ctx, stack);
1666
+ if (mismatch) return mismatch;
1667
+ }
1668
+ const state: SyncState = { docs: new Map() };
1669
+ const merged = detectMerged(ctx, cwd, stack, trunkSha, state);
1670
+ if (!merged.ok) return merged;
1671
+
1672
+ const outcome: SyncOutcome = {
1673
+ name: stack.name,
1674
+ dryRun: options.dryRun,
1675
+ merged: merged.data,
1676
+ steps: [],
1677
+ };
1678
+ pruneScratch(cwd);
1679
+
1680
+ let parent = { name: stack.trunk, tip: trunkSha as string | null };
1681
+ let processed = 0;
1682
+ for (const entry of stack.branches) {
1683
+ if (entry.merged) continue;
1684
+ if (options.limit !== undefined && processed >= options.limit) break;
1685
+ processed += 1;
1686
+ touchStackLock();
1687
+ const tip = branchTip(cwd, entry.branch);
1688
+ if (!tip)
1689
+ return stackFail(
1690
+ "not_found",
1691
+ `stack branch ${entry.branch} does not exist locally`,
1692
+ `git branch ${entry.branch} ${remote}/${entry.branch} # then workit stack sync`,
1693
+ { progress: outcome },
1694
+ );
1695
+ const step: SyncStep = {
1696
+ branch: entry.branch,
1697
+ pr: entry.pr,
1698
+ parent: parent.name,
1699
+ restack: null,
1700
+ push: null,
1701
+ retarget: null,
1702
+ };
1703
+ let newTip: string | null = tip;
1704
+ const parentTip = parent.tip;
1705
+ if (parentTip === null) {
1706
+ // Dry run: the parent would be rewritten first, so this one moves too.
1707
+ step.restack = {
1708
+ from: entry.lastParentHead ?? "?",
1709
+ onto: `${parent.name} (restacked)`,
1710
+ head: null,
1711
+ carried: null,
1712
+ };
1713
+ newTip = null;
1714
+ } else if (!isAncestor(cwd, parentTip, tip)) {
1715
+ const pending = entry.pendingRestack ?? null;
1716
+ const from =
1717
+ pending && isAncestor(cwd, pending.onto, tip)
1718
+ ? pending.onto
1719
+ : entry.lastParentHead && isAncestor(cwd, entry.lastParentHead, tip)
1720
+ ? entry.lastParentHead
1721
+ : (mergeBase(cwd, entry.lastParentHead ?? parentTip, tip) ?? parentTip);
1722
+ if (options.dryRun) {
1723
+ step.restack = { from, onto: parentTip, head: null, carried: null };
1724
+ newTip = null;
1725
+ } else {
1726
+ touchStackLock();
1727
+ // A plain rebase linearizes merges and drops their resolutions.
1728
+ const merges = gitRun(cwd, ["rev-list", "--merges", `${from}..${tip}`]);
1729
+ if (!merges.ok || merges.stdout.trim())
1730
+ return stackFail(
1731
+ "blocked",
1732
+ `merge_commits: ${entry.branch} has merge commits since ${from.slice(0, 12)}; restacking would drop what they resolved`,
1733
+ `git rebase --rebase-merges --onto ${parentTip} ${from} ${entry.branch} # check the result, then: workit stack sync`,
1734
+ {
1735
+ branch: entry.branch,
1736
+ merges: merges.stdout.trim().split("\n").filter(Boolean),
1737
+ progress: outcome,
1738
+ },
1739
+ );
1740
+ const owner = worktreeOf(cwd, entry.branch);
1741
+ if (owner && rebaseInProgress(owner))
1742
+ return stackFail(
1743
+ "blocked",
1744
+ `a rebase is in progress in ${owner}`,
1745
+ `git -C ${owner} rebase --continue # or --abort; then workit stack --cwd ${owner} sync`,
1746
+ { conflict: entry.branch, worktree: owner, progress: outcome },
1747
+ );
1748
+ if (owner && trackedDirt(owner))
1749
+ return stackFail(
1750
+ "blocked",
1751
+ `dirty_worktree: ${owner} has ${entry.branch} checked out with uncommitted changes; restacking needs it clean`,
1752
+ `commit or stash the changes in ${owner}, then workit stack sync`,
1753
+ { branch: entry.branch, worktree: owner, progress: outcome },
1754
+ );
1755
+ // Record what is being moved before moving it: a conflict or a
1756
+ // timeout leaves this in the stack file for the next sync to check.
1757
+ entry.pendingRestack = pending
1758
+ ? { ...pending, onto: parentTip }
1759
+ : { from, tip, onto: parentTip };
1760
+ const recorded = writeStack(cwd, stack);
1761
+ if (!recorded.ok) return recorded;
1762
+ const rebased = stackDeps.adapter.restack(cwd, {
1763
+ branch: entry.branch,
1764
+ onto: parentTip,
1765
+ from,
1766
+ });
1767
+ touchStackLock();
1768
+ if (!rebased.ok) {
1769
+ outcome.steps.push(step);
1770
+ if (rebased.timedOut)
1771
+ return stackFail(
1772
+ "failed",
1773
+ `timed_out: restacking ${entry.branch} onto ${parent.name}: ${rebased.error}`,
1774
+ `finish or abort the rebase in ${rebased.worktree} (git rebase --continue | --abort), then workit stack sync # slow hooks: raise WORKIT_STACK_REBASE_TIMEOUT_MS`,
1775
+ {
1776
+ reason: "timed_out",
1777
+ branch: entry.branch,
1778
+ worktree: rebased.worktree,
1779
+ progress: outcome,
1780
+ },
1781
+ );
1782
+ return rebased.conflict
1783
+ ? stackFail(
1784
+ "blocked",
1785
+ `conflict restacking ${entry.branch} onto ${parent.name}: ${rebased.error}`,
1786
+ `resolve the conflicts in ${rebased.worktree}, git rebase --continue, then workit stack sync # or git rebase --abort`,
1787
+ {
1788
+ conflict: entry.branch,
1789
+ worktree: rebased.worktree,
1790
+ progress: outcome,
1791
+ },
1792
+ )
1793
+ : stackFail(
1794
+ "failed",
1795
+ `restacking ${entry.branch} failed: ${rebased.error}`,
1796
+ undefined,
1797
+ {
1798
+ branch: entry.branch,
1799
+ progress: outcome,
1800
+ },
1801
+ );
1802
+ }
1803
+ newTip = rebased.head;
1804
+ step.restack = { from, onto: parentTip, head: newTip, carried: null };
1805
+ }
1806
+ }
1807
+ if (!options.dryRun && newTip) {
1808
+ // Content safety: what was moved must arrive as the same change. The
1809
+ // comparison is between the commits actually rebased (`from..tip`, as
1810
+ // recorded before the rebase, even across a conflict) and the result
1811
+ // on the new base: the exact diff, and no commit dropped as empty or
1812
+ // already applied. Otherwise nothing is pushed and the record stays.
1813
+ const check =
1814
+ entry.pendingRestack && parentTip && isAncestor(cwd, parentTip, newTip)
1815
+ ? entry.pendingRestack
1816
+ : null;
1817
+ let forced = false;
1818
+ if (check && parentTip) {
1819
+ const before = {
1820
+ d: diffHash(cwd, check.from, check.tip),
1821
+ commits: commitCount(cwd, check.from, check.tip),
1822
+ };
1823
+ const after = {
1824
+ d: diffHash(cwd, parentTip, newTip),
1825
+ commits: commitCount(cwd, parentTip, newTip),
1826
+ };
1827
+ const sameContent = before.d !== null && before.d === after.d;
1828
+ const dropped = Math.max(0, before.commits - after.commits);
1829
+ if (!sameContent || dropped > 0) {
1830
+ if (!options.force?.has(entry.branch)) {
1831
+ outcome.steps.push(step);
1832
+ const written = writeStack(cwd, stack);
1833
+ if (!written.ok) return written;
1834
+ return stackFail(
1835
+ "blocked",
1836
+ `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`,
1837
+ `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}`,
1838
+ {
1839
+ reason: "content_changed",
1840
+ branch: entry.branch,
1841
+ before: {
1842
+ base: check.from,
1843
+ head: check.tip,
1844
+ commits: before.commits,
1845
+ stat: shortStat(cwd, check.from, check.tip),
1846
+ },
1847
+ after: {
1848
+ base: parentTip,
1849
+ head: newTip,
1850
+ commits: after.commits,
1851
+ stat: shortStat(cwd, parentTip, newTip),
1852
+ },
1853
+ dropped,
1854
+ progress: outcome,
1855
+ },
1856
+ );
1857
+ }
1858
+ forced = true;
1859
+ }
1860
+ entry.pendingRestack = null;
1861
+ }
1862
+ // Verdict carry (S13): the last verified head vs the new one, each
1863
+ // against its own base. Equal only for the same patch and exact diff.
1864
+ const moved =
1865
+ entry.lastHead !== null &&
1866
+ entry.lastHead !== newTip &&
1867
+ entry.lastParentHead !== null &&
1868
+ parentTip !== null &&
1869
+ parentTip !== entry.lastParentHead;
1870
+ if (moved && parentTip) {
1871
+ const fromBase = entry.lastParentHead as string;
1872
+ const fromHead = entry.lastHead as string;
1873
+ const keysBefore = rangeKeys(cwd, fromBase, fromHead);
1874
+ const keysAfter = rangeKeys(cwd, parentTip, newTip);
1875
+ const before = { p: keysBefore.patchId, d: keysBefore.diffHash };
1876
+ const after = { p: keysAfter.patchId, d: keysAfter.diffHash };
1877
+ const equal =
1878
+ before.d !== null &&
1879
+ before.d === after.d &&
1880
+ before.p !== null &&
1881
+ before.p === after.p &&
1882
+ sameOwnChange(cwd, {
1883
+ from: fromHead,
1884
+ to: newTip,
1885
+ fromBase,
1886
+ toBase: parentTip,
1887
+ });
1888
+ appendObserved(cwd, {
1889
+ type: "stack.restacked",
1890
+ actor,
1891
+ stack: stack.name,
1892
+ branch: entry.branch,
1893
+ head: newTip,
1894
+ fromHead,
1895
+ fromBase,
1896
+ toBase: parentTip,
1897
+ patchId: after.p,
1898
+ diffHash: after.d,
1899
+ patchEqual: equal,
1900
+ forced,
1901
+ });
1902
+ if (step.restack) step.restack.carried = equal;
1903
+ else
1904
+ step.restack = {
1905
+ from: fromBase,
1906
+ onto: parentTip,
1907
+ head: newTip,
1908
+ carried: equal,
1909
+ };
1910
+ }
1911
+ if (options.publish) {
1912
+ touchStackLock();
1913
+ const pushed = pushBranch(cwd, actor, entry.branch, entry.lastHead ?? tip);
1914
+ touchStackLock();
1915
+ if (!pushed.ok) {
1916
+ outcome.steps.push(step);
1917
+ return { ...pushed, data: { ...pushed.data, progress: outcome } };
1918
+ }
1919
+ step.push = pushed.data;
1920
+ }
1921
+ entry.lastHead = newTip;
1922
+ entry.lastParentHead = parentTip;
1923
+ entry.patchId = parentTip ? patchId(cwd, parentTip, newTip) : null;
1924
+ }
1925
+ if (options.publish && ctx && entry.pr) {
1926
+ const doc = state.docs.get(entry.pr);
1927
+ if (doc && doc.state === "open" && doc.base !== parent.name) {
1928
+ step.retarget = { from: doc.base, to: parent.name };
1929
+ if (!options.dryRun) {
1930
+ const grant = requireGrant(cwd, "pr", { target: baseTarget(ctx.resolved) });
1931
+ if (!grant.allowed)
1932
+ return stackFail("blocked", grant.error, grant.unblock, {
1933
+ reason: grant.reason,
1934
+ progress: outcome,
1935
+ });
1936
+ const updated = ctx.resolved.forge.updateBase(entry.pr, parent.name);
1937
+ if (!updated.ok)
1938
+ return {
1939
+ ...fromForge(updated),
1940
+ data: { branch: entry.branch, progress: outcome },
1941
+ };
1942
+ appendObserved(cwd, {
1943
+ type: "pr.retargeted",
1944
+ actor,
1945
+ stack: stack.name,
1946
+ branch: entry.branch,
1947
+ head: newTip,
1948
+ pr: entry.pr,
1949
+ base: parent.name,
1950
+ previousBase: doc.base,
1951
+ });
1952
+ }
1953
+ }
1954
+ }
1955
+ entry.parent = parent.name;
1956
+ outcome.steps.push(step);
1957
+ if (!options.dryRun) {
1958
+ const written = writeStack(cwd, stack);
1959
+ if (!written.ok) return written;
1960
+ }
1961
+ parent = { name: entry.branch, tip: newTip };
1962
+ }
1963
+ if (!options.dryRun) {
1964
+ const written = writeStack(cwd, stack);
1965
+ if (!written.ok) return written;
1966
+ }
1967
+ return { ok: true, data: outcome };
1968
+ }
1969
+
1970
+ export type PruneOutcome = {
1971
+ /** Merged stack branches deleted locally (or that would be, on a dry run), with the tip to restore. */
1972
+ deleted: Array<{ branch: string; sha: string }>;
1973
+ /** Merged stack branches left in place, with why. */
1974
+ kept: Array<{ branch: string; reason: string }>;
1975
+ };
1976
+
1977
+ /**
1978
+ * Delete the local branches of merged stack entries (#506). Only branches
1979
+ * this stack recorded, never the trunk or a protected name, never one a
1980
+ * worktree has checked out, only while the local tip is still the head the
1981
+ * stack last recorded, and only when no commit can be lost: the tip is on
1982
+ * the trunk, or is the head the PR merged at, or is contained in the
1983
+ * branch's current remote tip. Each deletion names its sha (restore with
1984
+ * `git branch <name> <sha>`) and is recorded in the ledger. Remote branches
1985
+ * are left to the forge's delete-on-merge.
1986
+ */
1987
+ export function pruneMergedBranches(
1988
+ cwd: string,
1989
+ ctx: Ctx | null,
1990
+ stack: StackFile,
1991
+ options: { dryRun: boolean; actor: LedgerActor },
1992
+ ): PruneOutcome {
1993
+ const outcome: PruneOutcome = { deleted: [], kept: [] };
1994
+ const remote = remoteFor(cwd, ctx?.resolved ?? null, stack.trunk);
1995
+ const trunkSha = trunkTip(cwd, remote, stack.trunk);
1996
+ const keep = (branch: string, reason: string) => outcome.kept.push({ branch, reason });
1997
+ for (const entry of stack.branches) {
1998
+ if (!entry.merged || !safeRef(entry.branch) || entry.branch === stack.trunk) continue;
1999
+ const tip = branchTip(cwd, entry.branch);
2000
+ if (!tip) continue;
2001
+ if (isProtectedTarget(cwd, entry.branch)) {
2002
+ keep(entry.branch, "a protected branch under the workspace branch policy; never deleted");
2003
+ continue;
2004
+ }
2005
+ if (entry.lastHead === null || tip !== entry.lastHead) {
2006
+ keep(
2007
+ entry.branch,
2008
+ `its tip ${tip.slice(0, 12)} is not recorded by the stack (last ${entry.lastHead?.slice(0, 12) ?? "none"}); commits made after it merged stay`,
2009
+ );
2010
+ continue;
2011
+ }
2012
+ const owner = worktreeOf(cwd, entry.branch);
2013
+ if (owner) {
2014
+ keep(
2015
+ entry.branch,
2016
+ `checked out in ${owner}; switch it away, then git branch -D ${entry.branch}`,
2017
+ );
2018
+ continue;
2019
+ }
2020
+ const safe = safeToDelete(cwd, ctx, remote, trunkSha, entry, tip);
2021
+ if (safe === null) {
2022
+ keep(
2023
+ entry.branch,
2024
+ `${tip.slice(0, 12)} is not on ${stack.trunk}, not the head its PR merged at and not on ${remote}/${entry.branch}: deleting it could lose commits (push them, or delete it yourself)`,
2025
+ );
2026
+ continue;
2027
+ }
2028
+ if (!options.dryRun) {
2029
+ const removed = gitRun(cwd, ["branch", "-D", "--", entry.branch]);
2030
+ if (!removed.ok) {
2031
+ keep(entry.branch, `git branch -D failed: ${removed.stderr.trim().split("\n")[0] ?? ""}`);
2032
+ continue;
2033
+ }
2034
+ appendObserved(cwd, {
2035
+ type: "stack.branch.deleted",
2036
+ actor: options.actor,
2037
+ stack: stack.name,
2038
+ branch: entry.branch,
2039
+ head: tip,
2040
+ pr: entry.pr,
2041
+ because: safe,
2042
+ });
2043
+ }
2044
+ outcome.deleted.push({ branch: entry.branch, sha: tip });
2045
+ }
2046
+ return outcome;
2047
+ }
2048
+
2049
+ /** Why deleting `branch` at `tip` loses nothing, or null when it might. */
2050
+ function safeToDelete(
2051
+ cwd: string,
2052
+ ctx: Ctx | null,
2053
+ remote: string,
2054
+ trunkSha: string | null,
2055
+ entry: StackEntry,
2056
+ tip: string,
2057
+ ): "on_trunk" | "merged_head" | "on_remote" | null {
2058
+ if (trunkSha && isAncestor(cwd, tip, trunkSha)) return "on_trunk";
2059
+ if (ctx && entry.pr) {
2060
+ const read = statusDoc(ctx, entry.pr);
2061
+ if (read.ok && read.data.state === "merged" && read.data.head.sha === tip) return "merged_head";
2062
+ }
2063
+ const own = pushRemoteName(cwd, entry.branch) ?? remote;
2064
+ const remoteTip = remoteRefTip(cwd, own, `refs/heads/${entry.branch}`);
2065
+ if (remoteTip.ok && remoteTip.sha) {
2066
+ if (!hasCommit(cwd, remoteTip.sha)) fetchRefs(cwd, own, [remoteTip.sha]);
2067
+ if (hasCommit(cwd, remoteTip.sha) && isAncestor(cwd, tip, remoteTip.sha)) return "on_remote";
2068
+ }
2069
+ return null;
2070
+ }
2071
+
2072
+ const commitCount = (cwd: string, base: string, head: string): number =>
2073
+ Number(gitRun(cwd, ["rev-list", "--count", "--no-merges", `${base}..${head}`]).stdout.trim()) ||
2074
+ 0;
2075
+
2076
+ const shortStat = (cwd: string, base: string, head: string): string =>
2077
+ gitRun(cwd, ["diff", "--shortstat", `${base}...${head}`]).stdout.trim();
2078
+
2079
+ // ---------------------------------------------------------------------------
2080
+ // land
2081
+
2082
+ export type LandOptions = {
2083
+ dryRun: boolean;
2084
+ max: number | null;
2085
+ method: MergeMethod;
2086
+ /** Land without verdicts (`merge: true` only); each bypass is recorded. */
2087
+ unverified?: { reason: string } | null;
2088
+ /** Merge onto a red base (`--base-red-ok`); each merge records the reason. */
2089
+ baseRedOk?: { reason: string } | null;
2090
+ /** Merge PRs another author opened (`--others-pr`, as `pr merge`); each merge records the reason. */
2091
+ othersPr?: { reason: string } | null;
2092
+ /**
2093
+ * Pass these failing optional checks (`--allow-failing`, as `pr merge`):
2094
+ * never a required one; each PR it lets through records its own
2095
+ * `merge.waived` rows.
2096
+ */
2097
+ allowFailing?: CheckWaiver | null;
2098
+ /** How long to wait for CI on a PR that was just restacked. */
2099
+ timeoutMs: number;
2100
+ intervalMs: number;
2101
+ };
2102
+
2103
+ export type LandStop = {
2104
+ pr: number | null;
2105
+ branch: string;
2106
+ reason: StopReason;
2107
+ detail: string;
2108
+ unblock: string;
2109
+ /** Everything but the merge grant holds: "verified, ready". */
2110
+ ready: boolean;
2111
+ /** The forge, not the PR, must move first (mergeability still UNKNOWN after the re-reads). */
2112
+ babysit?: "wait-forge";
2113
+ };
2114
+
2115
+ /**
2116
+ * One PR in the pre-flight: `refused` is why it would not land (null: it
2117
+ * passes); `waives` names the failing optional checks `--allow-failing`
2118
+ * passes on its head.
2119
+ */
2120
+ export type PreflightEntry = {
2121
+ pr: number | null;
2122
+ branch: string;
2123
+ waives: string[];
2124
+ refused: LandStop | null;
2125
+ };
2126
+
2127
+ export type LandOutcome = {
2128
+ name: string;
2129
+ dryRun: boolean;
2130
+ /**
2131
+ * `unverified`: the ledger row of an `--unverified` bypass, else null;
2132
+ * `waived`: the failing optional checks `--allow-failing` passed on this PR.
2133
+ */
2134
+ landed: Array<{
2135
+ pr: number;
2136
+ branch: string;
2137
+ mergeSha: string | null;
2138
+ unverified: string | null;
2139
+ waived: WaivedCheck[];
2140
+ }>;
2141
+ /** Dry run: the PRs that qualify now, in order (later ones assume a clean restack). */
2142
+ wouldLand: Array<{ pr: number; branch: string }>;
2143
+ /**
2144
+ * Every PR this run would land, judged before anything merges (#739), in
2145
+ * order; any refusal stops the land with nothing merged. A dry run prints
2146
+ * the same.
2147
+ */
2148
+ preflight: PreflightEntry[];
2149
+ stoppedAt: LandStop | null;
2150
+ retargeted: number[];
2151
+ restacked: string[];
2152
+ complete: boolean;
2153
+ };
2154
+
2155
+ /** Poll the PR until its checks settle or the wait budget is spent (sleep-time based). */
2156
+ async function awaitChecks(
2157
+ ctx: Ctx,
2158
+ pr: number,
2159
+ head: string,
2160
+ options: LandOptions,
2161
+ ): Promise<ForgeResult<PrStatusDoc>> {
2162
+ let waited = 0;
2163
+ for (let poll = 0; ; poll += 1) {
2164
+ touchStackLock();
2165
+ const doc = statusDoc(ctx, pr);
2166
+ if (!doc.ok) return doc;
2167
+ const verdict = waitVerdict(doc.data, { head, elapsedMs: waited });
2168
+ if (verdict.state !== "waiting" || waited >= options.timeoutMs) return doc;
2169
+ const delay = Math.min(
2170
+ pollDelay(options.intervalMs, poll),
2171
+ Math.max(1, options.timeoutMs - waited),
2172
+ );
2173
+ await ctx.sleep(delay);
2174
+ waited += delay;
2175
+ }
2176
+ }
2177
+
2178
+ export async function landStack(
2179
+ ctx: Ctx,
2180
+ stack: StackFile,
2181
+ options: LandOptions,
2182
+ ): Promise<StackResult<LandOutcome>> {
2183
+ const outcome: LandOutcome = {
2184
+ name: stack.name,
2185
+ dryRun: options.dryRun,
2186
+ landed: [],
2187
+ wouldLand: [],
2188
+ preflight: [],
2189
+ stoppedAt: null,
2190
+ retargeted: [],
2191
+ restacked: [],
2192
+ complete: false,
2193
+ };
2194
+ const absorb = (sync: SyncOutcome) => {
2195
+ for (const step of sync.steps) {
2196
+ if (step.retarget && step.pr && !outcome.retargeted.includes(step.pr))
2197
+ outcome.retargeted.push(step.pr);
2198
+ if (step.restack?.head && !outcome.restacked.includes(step.branch))
2199
+ outcome.restacked.push(step.branch);
2200
+ }
2201
+ };
2202
+ // A stop after a merge names what already landed.
2203
+ const failWith = (error: StackError): StackError => ({
2204
+ ...error,
2205
+ error: outcome.landed.length
2206
+ ? `${error.error}; landed before the stop: ${outcome.landed.map((item) => `${noun(ctx.resolved, item.pr)} (${item.branch})`).join(", ")}`
2207
+ : error.error,
2208
+ data: { ...error.data, land: outcome },
2209
+ });
2210
+ const mismatch = repoMismatch(ctx, stack);
2211
+ if (mismatch) return failWith(mismatch);
2212
+ const bypass = options.unverified ?? null;
2213
+ if (bypass) {
2214
+ const grant = requireGrant(ctx.cwd, "merge", { target: baseTarget(ctx.resolved) });
2215
+ if (!grant.allowed)
2216
+ return failWith(
2217
+ stackFail("blocked", grant.error, grant.unblock, {
2218
+ reason: "grant_required",
2219
+ }),
2220
+ );
2221
+ if (!grant.allowUnverified)
2222
+ return failWith(
2223
+ stackFail(
2224
+ "blocked",
2225
+ `unverified_refused: workspace ${grant.workspace ? `"${grant.workspace}"` : "(none)"} grants merge: "verified", which never lands without an accepted independent verdict`,
2226
+ "a non-author session verifies each head (workit-review), then land without --unverified",
2227
+ { reason: "unverified_refused" },
2228
+ ),
2229
+ );
2230
+ }
2231
+
2232
+ // Catch up first: a parent merged elsewhere leaves the rest to restack.
2233
+ if (!options.dryRun) {
2234
+ const synced = syncStack(
2235
+ ctx.cwd,
2236
+ ctx,
2237
+ stack,
2238
+ { publish: true, dryRun: false, limit: 1 },
2239
+ ctx.actor,
2240
+ );
2241
+ if (!synced.ok) return failWith(synced);
2242
+ absorb(synced.data);
2243
+ }
2244
+ const ledgerRows = () => {
2245
+ const ledger = readLedger(ctx.cwd, { integrity: true });
2246
+ return ledger.ok ? ledger.value.rows : [];
2247
+ };
2248
+ let justRestacked = new Set(outcome.restacked);
2249
+
2250
+ // Judge every PR first: a refusal known now lands nothing (#739).
2251
+ const pre = preflightLand(ctx, stack, options, justRestacked, ledgerRows());
2252
+ if (!pre.ok) return failWith(pre);
2253
+ outcome.preflight = pre.data.entries;
2254
+ const refused = outcome.preflight.flatMap((entry) => (entry.refused ? [entry.refused] : []));
2255
+ if (refused.length) {
2256
+ outcome.stoppedAt = refused[0];
2257
+ if (refused.some((stop) => stop.reason === "waiver_refused"))
2258
+ return failWith(
2259
+ stackFail(
2260
+ "blocked",
2261
+ `waiver_refused: nothing landed; the pre-flight refused ${refused.map((stop) => `${stop.pr ? noun(ctx.resolved, stop.pr) : stop.branch} (${stop.branch}) ${stop.detail}`).join("; ")}`,
2262
+ refused.find((stop) => stop.reason === "waiver_refused")?.unblock,
2263
+ { reason: "waiver_refused" },
2264
+ ),
2265
+ );
2266
+ return { ok: true, data: outcome };
2267
+ }
2268
+ if (options.dryRun) {
2269
+ outcome.wouldLand = outcome.preflight.map((entry) => ({
2270
+ pr: entry.pr as number,
2271
+ branch: entry.branch,
2272
+ }));
2273
+ outcome.stoppedAt = pre.data.cut;
2274
+ outcome.complete = pre.data.cut === null;
2275
+ return { ok: true, data: outcome };
2276
+ }
2277
+
2278
+ // Merge bottom-up, re-judging each PR at its turn: what only the merge can
2279
+ // show (a head that moved, a forge race) stops here, after the PRs below it.
2280
+ for (;;) {
2281
+ touchStackLock();
2282
+ const entry = stack.branches.find((candidate) => !candidate.merged);
2283
+ if (!entry) {
2284
+ outcome.complete = true;
2285
+ break;
2286
+ }
2287
+ if (options.max !== null && outcome.landed.length >= options.max) {
2288
+ outcome.stoppedAt = maxReached(entry, options.max);
2289
+ break;
2290
+ }
2291
+ // `pr merge`'s gate, one per PR: UNKNOWN mergeability is re-read with
2292
+ // backoff, and an `--allow-failing` waiver passes the PR's failing
2293
+ // optional checks, recorded only once the forge accepts its merge (a PR
2294
+ // that stops records none). Sync keeps the raw forge: it lands nothing.
2295
+ const gate = landGate(ctx, options);
2296
+ const gated: Ctx = { ...ctx, resolved: { ...ctx.resolved, forge: gate } };
2297
+ let doc: PrStatusDoc | null = null;
2298
+ if (entry.pr) {
2299
+ const head = branchTip(ctx.cwd, entry.branch);
2300
+ const read =
2301
+ justRestacked.has(entry.branch) && head
2302
+ ? await awaitChecks(gated, entry.pr, head, options)
2303
+ : statusDoc(gated, entry.pr);
2304
+ if (!read.ok) return failWith(fromForge(read));
2305
+ doc = read.data;
2306
+ }
2307
+ const grant = requireGrant(ctx.cwd, "merge", { target: baseTarget(ctx.resolved) });
2308
+ const verdict = verdictOf(ctx.cwd, entry.branch, ledgerRows(), doc);
2309
+ const q = qualify(
2310
+ ctx,
2311
+ stack,
2312
+ entry,
2313
+ doc,
2314
+ verdict,
2315
+ grant,
2316
+ doc ? prBinding(ctx, entry, doc) : null,
2317
+ bypass !== null,
2318
+ );
2319
+ if (!q.ok) {
2320
+ outcome.stoppedAt = landStop(entry, q, doc);
2321
+ break;
2322
+ }
2323
+ const pr = entry.pr as number;
2324
+ const merged = await mergePullRequest(
2325
+ ctx.cwd,
2326
+ gated.resolved,
2327
+ {
2328
+ pr,
2329
+ method: options.method,
2330
+ deleteBranch: false,
2331
+ actor: ctx.actor,
2332
+ expect: { base: stack.trunk, branch: entry.branch },
2333
+ ...(bypass && !verdict.satisfied ? { unverified: bypass } : {}),
2334
+ ...(options.baseRedOk ? { baseRedOk: options.baseRedOk } : {}),
2335
+ ...(options.othersPr ? { othersPr: options.othersPr } : {}),
2336
+ },
2337
+ ctx.sleep,
2338
+ );
2339
+ if (!merged.ok) {
2340
+ outcome.stoppedAt = {
2341
+ pr,
2342
+ branch: entry.branch,
2343
+ reason: "merge_refused",
2344
+ detail: merged.error,
2345
+ unblock: merged.unblock ?? `workit pr status --pr ${pr}`,
2346
+ ready: false,
2347
+ };
2348
+ if (merged.code === "blocked" && "refusal" in merged && merged.refusal) break;
2349
+ return failWith(stackFail(merged.code, merged.error, merged.unblock));
2350
+ }
2351
+ outcome.landed.push({
2352
+ pr,
2353
+ branch: entry.branch,
2354
+ mergeSha: merged.data.mergeSha,
2355
+ unverified: merged.data.unverified?.recorded ?? null,
2356
+ waived: [...gate.waived],
2357
+ });
2358
+ entry.merged = {
2359
+ pr,
2360
+ mergeSha: merged.data.mergeSha,
2361
+ at: new Date().toISOString(),
2362
+ };
2363
+ const written = writeStack(ctx.cwd, stack);
2364
+ if (!written.ok) return failWith(written);
2365
+ // Move the next PR onto the trunk: restack, lease push, retarget. The
2366
+ // ones above it wait (restacking them now would only spend CI that the
2367
+ // next merge restarts); `workit stack sync` moves them all.
2368
+ const before = new Set(outcome.restacked);
2369
+ const synced = syncStack(
2370
+ ctx.cwd,
2371
+ ctx,
2372
+ stack,
2373
+ { publish: true, dryRun: false, limit: 1 },
2374
+ ctx.actor,
2375
+ );
2376
+ if (!synced.ok) return failWith(synced);
2377
+ absorb(synced.data);
2378
+ justRestacked = new Set(outcome.restacked.filter((branch) => !before.has(branch)));
2379
+ for (const step of synced.data.steps) if (step.restack?.head) justRestacked.add(step.branch);
2380
+ }
2381
+ return { ok: true, data: outcome };
2382
+ }
2383
+
2384
+ /**
2385
+ * `pr merge`'s gate for one land read. The `--allow-failing` waiver applies
2386
+ * per PR: a PR whose head does not run a named check is not refused for it.
2387
+ */
2388
+ const landGate = (ctx: Ctx, options: LandOptions) =>
2389
+ mergeGateForge(ctx.resolved.forge, ctx.cwd, {
2390
+ waiver: options.allowFailing ?? null,
2391
+ actor: ctx.actor,
2392
+ perPr: true,
2393
+ });
2394
+
2395
+ const maxReached = (entry: StackEntry, max: number): LandStop => ({
2396
+ pr: entry.pr,
2397
+ branch: entry.branch,
2398
+ reason: "max_reached",
2399
+ detail: `--max ${max} reached`,
2400
+ unblock: "workit stack land",
2401
+ ready: false,
2402
+ });
2403
+
2404
+ const landStop = (
2405
+ entry: StackEntry,
2406
+ q: Extract<Qualification, { ok: false }>,
2407
+ doc: PrStatusDoc | null,
2408
+ ): LandStop => ({
2409
+ pr: entry.pr,
2410
+ branch: entry.branch,
2411
+ reason: q.reason,
2412
+ detail: q.detail,
2413
+ unblock: q.unblock,
2414
+ ready: q.ready,
2415
+ ...(q.reason === "not_ready" && doc?.babysit === "wait-forge"
2416
+ ? { babysit: "wait-forge" as const }
2417
+ : {}),
2418
+ });
2419
+
2420
+ /** Blockers the land clears itself on a PR it restacks before judging it again. */
2421
+ const SETTLED_BY_RESTACK: ReadonlySet<string> = new Set(["checks_pending", "behind_base_required"]);
2422
+
2423
+ /**
2424
+ * The PR as it will stand at its turn. With a PR landing below it, its base
2425
+ * is its parent until the land retargets it to the trunk; restacked (or about
2426
+ * to be), its CI runs again and the land awaits it.
2427
+ */
2428
+ function atItsTurn(
2429
+ doc: PrStatusDoc,
2430
+ stack: StackFile,
2431
+ entry: StackEntry,
2432
+ below: boolean,
2433
+ restacked: boolean,
2434
+ ): PrStatusDoc {
2435
+ let view = doc;
2436
+ if (below && (doc.base === entry.parent || doc.base === stack.trunk))
2437
+ view = { ...view, base: stack.trunk };
2438
+ if (
2439
+ restacked &&
2440
+ doc.next !== "READY" &&
2441
+ doc.blockers.length > 0 &&
2442
+ doc.blockers.every((blocker) => SETTLED_BY_RESTACK.has(blocker))
2443
+ )
2444
+ view = { ...view, next: "READY", blockers: [] };
2445
+ return view;
2446
+ }
2447
+
2448
+ /**
2449
+ * `pr merge`'s others_pr gate, read before anything merges: the refusal for
2450
+ * a PR whose author is not the forge login (or cannot be shown to be), else
2451
+ * null. The login is read once.
2452
+ */
2453
+ function othersPrGate(
2454
+ resolved: ResolvedForge,
2455
+ ): (pr: number) => { detail: string; unblock: string } | null {
2456
+ let login: string | null | undefined;
2457
+ return (pr) => {
2458
+ const label = noun(resolved, pr);
2459
+ if (login === undefined) login = forgeLogin(resolved);
2460
+ const other = otherAuthor(resolved, pr, login);
2461
+ if (other === null) return null;
2462
+ if (!other.ok)
2463
+ return {
2464
+ detail: `identity_unknown: ${other.error}, so ${label} cannot be shown to be yours`,
2465
+ unblock:
2466
+ "fix: gh auth status / gh auth login (or glab auth login), then workit stack land again",
2467
+ };
2468
+ return {
2469
+ detail: `others_pr: ${label} is by ${other.author}, not ${other.login} (the forge login); workit never merges another author's PR on its own`,
2470
+ unblock: `only if the user asks you to land it: workit stack land --others-pr --reason "<why>"`,
2471
+ };
2472
+ };
2473
+ }
2474
+
2475
+ /**
2476
+ * The pre-flight (#739): every PR this run would land (each open one, or the
2477
+ * first `--max`) is judged before anything merges, by the checks the merge
2478
+ * loop runs: `qualify` (PR, base, READY after the mergeability re-read,
2479
+ * verdict, grant), `pr merge`'s others_pr gate and the `--allow-failing`
2480
+ * waiver per PR (a required check named is `waiver_refused`). A PR above the
2481
+ * first is judged as it will stand at its turn (`atItsTurn`). A name no PR
2482
+ * runs is `unknown_check`. `cut` is the `--max` stop.
2483
+ */
2484
+ function preflightLand(
2485
+ ctx: Ctx,
2486
+ stack: StackFile,
2487
+ options: LandOptions,
2488
+ justRestacked: ReadonlySet<string>,
2489
+ rows: readonly ReadRow[],
2490
+ ): StackResult<{ entries: PreflightEntry[]; cut: LandStop | null }> {
2491
+ const gate = landGate(ctx, options);
2492
+ const gated: Ctx = { ...ctx, resolved: { ...ctx.resolved, forge: gate } };
2493
+ const grant = requireGrant(ctx.cwd, "merge", { target: baseTarget(ctx.resolved) });
2494
+ const others = options.othersPr ? null : othersPrGate(ctx.resolved);
2495
+ const entries: PreflightEntry[] = [];
2496
+ // A PR below this one lands first (or, in a dry run, merged elsewhere).
2497
+ let below = false;
2498
+ for (const entry of stack.branches) {
2499
+ if (entry.merged) continue;
2500
+ touchStackLock();
2501
+ if (options.max !== null && entries.length >= options.max)
2502
+ return {
2503
+ ok: true,
2504
+ data: { entries, cut: maxReached(entry, options.max) },
2505
+ };
2506
+ const judged = (refused: LandStop | null, waives: string[] = []) => {
2507
+ entries.push({ pr: entry.pr, branch: entry.branch, waives, refused });
2508
+ below = true;
2509
+ };
2510
+ let doc: PrStatusDoc | null = null;
2511
+ if (entry.pr) {
2512
+ const read = statusDoc(gated, entry.pr);
2513
+ if (!read.ok && read.code === "blocked" && read.error.startsWith("waiver_refused:")) {
2514
+ judged({
2515
+ pr: entry.pr,
2516
+ branch: entry.branch,
2517
+ reason: "waiver_refused",
2518
+ detail: read.error,
2519
+ unblock: read.unblock ?? `workit pr status --pr ${entry.pr}`,
2520
+ ready: false,
2521
+ });
2522
+ continue;
2523
+ }
2524
+ if (!read.ok) return fromForge(read);
2525
+ doc = read.data;
2526
+ if (options.dryRun && doc.state === "merged") {
2527
+ // Merged elsewhere; the real run would restack past it.
2528
+ below = true;
2529
+ continue;
2530
+ }
2531
+ }
2532
+ const q = qualify(
2533
+ ctx,
2534
+ stack,
2535
+ entry,
2536
+ doc && atItsTurn(doc, stack, entry, below, below || justRestacked.has(entry.branch)),
2537
+ verdictOf(ctx.cwd, entry.branch, rows, doc),
2538
+ grant,
2539
+ doc ? prBinding(ctx, entry, doc) : null,
2540
+ Boolean(options.unverified),
2541
+ );
2542
+ if (!q.ok) {
2543
+ judged(landStop(entry, q, doc));
2544
+ continue;
2545
+ }
2546
+ const pr = entry.pr as number;
2547
+ const foreign = others?.(pr) ?? null;
2548
+ judged(
2549
+ foreign
2550
+ ? {
2551
+ pr,
2552
+ branch: entry.branch,
2553
+ reason: "merge_refused",
2554
+ ...foreign,
2555
+ ready: false,
2556
+ }
2557
+ : null,
2558
+ gate.waives(pr),
2559
+ );
2560
+ }
2561
+ const unknown = options.allowFailing?.checks.filter((name) => !gate.matched.has(name)) ?? [];
2562
+ if (unknown.length)
2563
+ return stackFail(
2564
+ "invalid_input",
2565
+ `unknown_check: --allow-failing ${unknown.map((name) => JSON.stringify(name)).join(", ")} matches no check on any PR this land would merge (${entries.map((entry) => (entry.pr ? noun(ctx.resolved, entry.pr) : entry.branch)).join(", ") || "none"})`,
2566
+ "workit pr status --pr <n> # copy the check name exactly",
2567
+ { reason: "unknown_check" },
2568
+ );
2569
+ return { ok: true, data: { entries, cut: null } };
2570
+ }