@pmelab/gtd 52.0.0 → 53.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 (108) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +96 -131
  3. package/claude/hooks/drive.ts +0 -3
  4. package/claude/hooks/register.tsx +3 -68
  5. package/claude/hooks/ship.ts +85 -1595
  6. package/dist/chunks/{reserved-D0MKVFFa.mjs → reserved-CQ7amcKG.mjs} +87 -133
  7. package/dist/chunks/{review-ZzUQH1KZ.mjs → review-CNgN_V8T.mjs} +2 -2
  8. package/dist/chunks/{tail-I1-vVq0P.mjs → tail-JZ9PAOwp.mjs} +2 -14
  9. package/dist/chunks/{wire-BJuZWR3C.mjs → wire-C12dahrH.mjs} +2 -130
  10. package/dist/flows.mjs +1 -16
  11. package/dist/gtd.bundle.mjs +476 -982
  12. package/dist/testkit.mjs +58 -417
  13. package/dist/wire.mjs +2 -2
  14. package/dist/workflows/cleanup.mjs +2 -2
  15. package/dist/workflows/feature.mjs +159 -363
  16. package/dist/workflows/fix.mjs +2 -2
  17. package/dist/workflows/review.mjs +3 -3
  18. package/dist/workflows/rollout.mjs +1 -1
  19. package/dist/workflows.mjs +400 -675
  20. package/package.json +5 -4
  21. package/src/cli/Cli.test.ts +1 -86
  22. package/src/cli/Cli.ts +13 -133
  23. package/src/cli/Metrics.test.ts +21 -31
  24. package/src/cli/Metrics.ts +25 -36
  25. package/src/cli/Program.test.ts +1 -35
  26. package/src/cli/Program.ts +5 -64
  27. package/src/cli/ToolCommands.ts +0 -3
  28. package/src/edge/Edge.test.ts +20 -322
  29. package/src/edge/Edge.ts +10 -181
  30. package/src/edge/index.ts +0 -1
  31. package/src/flows/runtime.test.ts +0 -27
  32. package/src/flows/runtime.ts +1 -49
  33. package/src/install/Install.ts +15 -24
  34. package/src/replay/Replay.test.ts +0 -169
  35. package/src/replay/Replay.ts +4 -93
  36. package/src/replay/index.ts +0 -1
  37. package/src/testkit/Layers.ts +0 -1
  38. package/src/testkit/steps.ts +13 -0
  39. package/src/wire/BeatDocument.ts +2 -12
  40. package/src/wire/BeatStatus.ts +0 -3
  41. package/src/wire/WireSchema.test.ts +0 -18
  42. package/src/wire/index.ts +0 -2
  43. package/src/wire/types.ts +0 -7
  44. package/src/workflow/Workflow.ts +1 -9
  45. package/src/workflows/feature/acceptance-criteria.feature +159 -3
  46. package/src/workflows/feature/criteria.test.ts +23 -15
  47. package/src/workflows/feature/criteria.ts +72 -39
  48. package/src/workflows/feature/fast-path.feature +4 -0
  49. package/src/workflows/feature/feature.test.ts +6 -5
  50. package/src/workflows/feature/feature.ts +34 -118
  51. package/src/workflows/feature/flag-mode.feature +4 -0
  52. package/src/workflows/feature/hands-on-skip.feature +37 -65
  53. package/src/workflows/feature/index.ts +0 -1
  54. package/src/workflows/feature/lens-models.feature +4 -0
  55. package/src/workflows/feature/lens-review.feature +4 -0
  56. package/src/workflows/feature/naive-acceptance.feature +4 -0
  57. package/src/workflows/feature/one-step-land.feature +4 -4
  58. package/src/workflows/feature/planning.ts +2 -1
  59. package/src/workflows/feature/prototype-rounds.feature +5 -1
  60. package/src/workflows/feature/prototype.feature +11 -3
  61. package/src/workflows/feature/prototype.ts +18 -29
  62. package/src/workflows/feature/refactor-absorbs.feature +7 -8
  63. package/src/workflows/feature/refactor-first.feature +4 -5
  64. package/src/workflows/feature/refactor-lens-models.feature +4 -0
  65. package/src/workflows/feature/refactor-rebuild.feature +4 -6
  66. package/src/workflows/feature/refactor-review.feature +31 -11
  67. package/src/workflows/feature/refactor.ts +30 -64
  68. package/src/workflows/feature/review-answers.feature +4 -0
  69. package/src/workflows/feature/review-gate.feature +13 -1
  70. package/src/workflows/feature/reviewer-walkthrough.feature +4 -0
  71. package/src/workflows/feature/risk-tier.feature +32 -0
  72. package/src/workflows/feature/seam-rejection.feature +8 -4
  73. package/src/workflows/feature/seam.feature +8 -0
  74. package/src/workflows/feature/seam.test.ts +11 -28
  75. package/src/workflows/feature/seam.ts +52 -165
  76. package/src/workflows/feature/severity-budget.feature +4 -0
  77. package/src/workflows/feature/severity-sorted-review.feature +4 -0
  78. package/src/workflows/feature/standards.feature +4 -0
  79. package/src/workflows/feature/tier-pin.feature +4 -0
  80. package/src/workflows/index.ts +0 -1
  81. package/src/workflows/shared/lenses.ts +0 -13
  82. package/src/workflows/shared/options.test.ts +2 -11
  83. package/src/workflows/shared/options.ts +4 -21
  84. package/src/workflows/shared/review.test.ts +3 -3
  85. package/src/workflows/shared/steps.ts +0 -2
  86. package/src/workflows/shared/text.test.ts +41 -71
  87. package/src/workflows/shared/text.ts +82 -212
  88. package/src/workflows/shared/vars.ts +2 -8
  89. package/src/workflows/shared/workflows.steps.ts +0 -13
  90. package/claude/hooks/context.ts +0 -66
  91. package/src/replay/Slices.test.ts +0 -73
  92. package/src/replay/Slices.ts +0 -45
  93. package/src/wire/ContextLimit.fixture.ts +0 -30
  94. package/src/wire/ContextLimit.test.ts +0 -96
  95. package/src/wire/ContextLimit.ts +0 -169
  96. package/src/wire/Slices.ts +0 -25
  97. package/src/wire/golden/1/slices.json +0 -11
  98. package/src/workflows/feature/context-limit.feature +0 -37
  99. package/src/workflows/feature/continuation-cap.feature +0 -65
  100. package/src/workflows/feature/continuation-rebuild.feature +0 -88
  101. package/src/workflows/feature/continuation-slices.feature +0 -64
  102. package/src/workflows/feature/continuation.feature +0 -205
  103. package/src/workflows/feature/slice-findings.feature +0 -252
  104. package/src/workflows/feature/slicing-cap.feature +0 -23
  105. package/src/workflows/feature/slicing.feature +0 -265
  106. package/src/workflows/feature/slicing.test.ts +0 -49
  107. package/src/workflows/feature/slicing.ts +0 -105
  108. package/src/workflows/shared/continuation.ts +0 -92
@@ -46,55 +46,12 @@ export function costTrailers(log: string) {
46
46
  .map(([model, n]) => `Gtd-Cost: ${Number(n.toPrecision(10))} ${model}`)
47
47
  }
48
48
 
49
- export type ShipMode = "stack" | "commits"
49
+ // The process's range, from the plain `gtd summary`: it names it as `git log <base>..<tip>`.
50
+ const summaryRange = (summary: string) =>
51
+ /git log ([0-9a-f]{7,64})\.\.([0-9a-f]{7,64})/.exec(summary)?.slice(1, 3)
50
52
 
51
- export function shipMode(raw: string | undefined): ShipMode | { refusal: string } {
52
- if (!raw) return "commits"
53
- if (raw === "stack" || raw === "commits") return raw
54
- return { refusal: `GTD_SHIP_MODE must be 'stack' or 'commits', not '${raw}'.` }
55
- }
56
-
57
- export type Slice = { name: string; base: string; tip: string; closed: boolean }
58
-
59
- const HASH = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
60
-
61
- // The slice's problem as text, or the slice itself.
62
- function checkSlice(raw: Record<string, unknown> | undefined, done: Slice[]): Slice | string {
63
- const { name, base, tip, closed } = raw ?? {}
64
- if (typeof name !== "string" || !/^[a-z][a-z0-9-]*$/.test(name))
65
- return `slice name ${JSON.stringify(name)}`
66
- if (done.some((s) => s.name === name)) return `slice ${name} repeats`
67
- if (typeof base !== "string" || !HASH.test(base) || typeof tip !== "string" || !HASH.test(tip))
68
- return `slice ${name} has a malformed commit`
69
- if (typeof closed !== "boolean") return `slice ${name} has no closed flag`
70
- const prev = done.at(-1)
71
- if (prev && prev.tip !== base) return `slice ${name} does not start at ${prev.name}'s tip`
72
- return { name, base, tip, closed }
73
- }
74
-
75
- // `gtd slices --json` is the only place gtd names the process's slice ranges.
76
- export function readSlices(stdout: string): Slice[] | { refusal: string } {
77
- const bad = (why: string) => ({ refusal: `gtd slices --json is unusable: ${why}.` })
78
- let doc: { schema?: unknown; slices?: unknown }
79
- try {
80
- doc = JSON.parse(stdout)
81
- } catch {
82
- return bad("not JSON")
83
- }
84
- if (!doc || typeof doc !== "object" || doc.schema !== 1) return bad("schema is not 1")
85
- if (!Array.isArray(doc.slices) || !doc.slices.length) return bad("no slices")
86
- const slices: Slice[] = []
87
- for (const raw of doc.slices as Record<string, unknown>[]) {
88
- const checked = checkSlice(raw, slices)
89
- if (typeof checked === "string") return bad(checked)
90
- slices.push(checked)
91
- }
92
- return slices
93
- }
94
-
95
- // Also given in `gtd summary --slice`, but a workflow's own summary template may not say it.
96
- const TYPE_RULE = `- Pick the type from what the change does, never from a slice's or branch's
97
- name: adding or changing a public surface — anything a user, a config or a
53
+ const TYPE_RULE = `- Pick the type from what the change does, never from the branch's name:
54
+ adding or changing a public surface — anything a user, a config or a
98
55
  driver reaches: the published package's exports, a command, flag, config
99
56
  key, environment variable, workflow step or machine-read output — is feat,
100
57
  fix or perf, the types that cut a release. Docs or tests alone change no
@@ -137,18 +94,6 @@ print is the commit message verbatim.
137
94
 
138
95
  Print the message and nothing else — no code fences, no preamble.`
139
96
 
140
- // `gtd summary --slice` asks only the process's last slice to close its issue;
141
- // a group that absorbed that slice is the last commit all the same.
142
- const ABSORBED = `
143
- --- THIS IS THE PROCESS'S LAST COMMIT ---
144
-
145
- The slices after this one changed nothing outside .gtd/ and fold into it. If
146
- the process was started for an issue — its entry commit or
147
- .gtd/REQUIREMENTS.md names one as #N, owner/repo#N or an issue URL — end the
148
- message with a literal 'Closes #N' footer per issue. Name only an issue the
149
- work resolves, never one it merely mentions.
150
- `
151
-
152
97
  const DESCRIPTION = `The description is a STANDING statement, not a changelog. It covers exactly three
153
98
  things, and a reviewer reads it once:
154
99
 
@@ -174,8 +119,8 @@ const attempt = async (io: ShipIo, argv: string[]): Promise<Ran & { killed: bool
174
119
  }
175
120
  }
176
121
 
177
- // A restack never moves the local default branch, so a later ship would read a
178
- // stale merge base from it and describe other people's changes. Origin's copy
122
+ // The local default branch may lag origin's, so a ship would read a stale
123
+ // merge base from it and describe other people's changes. Origin's copy
179
124
  // wins when it descends from the local one, or when the branch sits on it and
180
125
  // not on the local one; otherwise the local branch stays first.
181
126
  async function resolveRef(git: Git, ok: Ok, baseBranch: string): Promise<string | undefined> {
@@ -208,9 +153,7 @@ async function preflight(git: Git, ok: Ok) {
208
153
  return { branch, baseBranch, baseRef }
209
154
  }
210
155
 
211
- export async function ship(io: ShipIo, isDry: boolean, rawMode?: string): Promise<Shipped> {
212
- const valid = shipMode(rawMode)
213
- if (typeof valid === "object") return fail(valid.refusal)
156
+ export async function ship(io: ShipIo, isDry: boolean): Promise<Shipped> {
214
157
  const git = async (...args: string[]) => (await io.run(["git", ...args])).out.trim()
215
158
  const ok = async (...args: string[]) => (await io.run(["git", ...args])).code === 0
216
159
 
@@ -218,251 +161,38 @@ export async function ship(io: ShipIo, isDry: boolean, rawMode?: string): Promis
218
161
  if ("refusal" in pre) return fail(pre.refusal)
219
162
  const { branch, baseBranch, baseRef } = pre
220
163
 
221
- // Read before any squash: the record names the slice branches a PR may sit on.
222
- // A retired record (see below) counts too, so a retry still retargets the PR.
223
- const live = names(await git("config", "--local", "--get", STACK_KEY(branch)))
224
- const retired = names(await git("config", "--local", "--get", RETIRED_KEY(branch)))
225
- const recorded = [...live, ...retired]
226
- // Unset keeps a live stack stacking: commits mode would retarget its top PR
227
- // and strand the lower ones. A dead record ships as commits.
228
- const stacked = !rawMode && live.length > 0 && (await chainsToHead(git, ok, branch, live))
229
- const mode: ShipMode = stacked ? "stack" : valid
230
- const squashed = await squash(io, git, ok, branch, baseBranch, isDry, mode)
164
+ const squashed = await squash(io, git, ok, branch, isDry)
231
165
  if (!squashed.ok) return squashed
232
- // Until the PR step succeeds the record only moves aside: a retry must not
233
- // resume the stack this ship dropped, yet must still find its slice bases.
234
- if (squashed.forget && live.length && !isDry) await retireStack(io, branch, live)
235
- const { stack, floor } = squashed
236
- if (stack && isDry) {
237
- const text = await stackPreview(
238
- io,
239
- stack,
240
- baseBranch,
241
- squashed.messages,
242
- squashed.history,
243
- squashed.restack,
244
- )
245
- if (typeof text !== "string") return fail(text.refusal)
246
- return withWarning({ ok: true, text }, squashed.warning)
247
- }
248
- if (isDry)
249
- return preview(io, git, branch, baseRef, squashed.text, squashed.history, squashed.warning)
250
- const opened = stack
251
- ? await stackPullRequests(io, git, ok, stack, baseBranch, baseRef, floor)
252
- : await pullRequest(io, git, ok, branch, baseBranch, baseRef, recorded)
253
- // A stale retired record must not take a stack this ship just recorded with it.
254
- if (opened.ok) await settleRecords(io, branch, squashed, live.length > 0, retired.length > 0)
255
- return withWarning(opened, squashed.warning)
256
- }
257
-
258
- async function settleRecords(
259
- io: ShipIo,
260
- branch: string,
261
- squashed: { forget?: boolean; recorded?: boolean },
262
- hadLive: boolean,
263
- hadRetired: boolean,
264
- ): Promise<void> {
265
- if (squashed.forget) return forgetStack(io, branch)
266
- if (hadRetired && (!hadLive || squashed.recorded))
267
- await io.run(["git", "config", "--local", "--unset", RETIRED_KEY(branch)])
268
- }
269
-
270
- function withWarning(r: Shipped, warning?: string): Shipped {
271
- return warning ? { ...r, text: [r.text, warning].filter(Boolean).join("\n\n") } : r
166
+ if (isDry) return preview(io, git, branch, baseRef, squashed.text, squashed.history)
167
+ return pullRequest(io, git, ok, branch, baseBranch, baseRef)
272
168
  }
273
169
 
274
170
  type Git = (...args: string[]) => Promise<string>
275
171
  type Ok = (...args: string[]) => Promise<boolean>
276
172
 
277
- // `absorbed`: the group took in a trailing .gtd/-only slice, so it closes the process.
278
- type Group = { name: string; base: string; tip: string; absorbed?: boolean }
279
-
280
- // Slices that changed a file outside .gtd/ each land a commit; one that did not
281
- // folds into the next landed slice, or the previous when it is last.
282
- async function landed(ok: Ok, slices: Slice[]) {
283
- const groups: Group[] = []
284
- let carry: string | undefined
285
- for (const s of slices) {
286
- // gtd churns its own steering files every process, so `.gtd/` alone is no change.
287
- if (await ok("diff", "--quiet", s.base, s.tip, "--", ":(exclude).gtd/")) {
288
- carry ??= s.base
289
- continue
290
- }
291
- groups.push({ name: s.name, base: carry ?? s.base, tip: s.tip })
292
- carry = undefined
293
- }
294
- if (carry && groups.length)
295
- Object.assign(groups.at(-1)!, { tip: slices.at(-1)!.tip, absorbed: true })
296
- return groups
297
- }
298
-
299
- async function rebaseWarning(io: ShipIo) {
300
- const read = await attempt(io, [
301
- "gh",
302
- "repo",
303
- "view",
304
- "--json",
305
- "rebaseMergeAllowed",
306
- "-q",
307
- ".rebaseMergeAllowed",
308
- ])
309
- if (read.code === 0 && read.out.trim() === "true") return undefined
310
- return "Warning: commits mode keeps one commit per slice only through a rebase merge, and this repository does not confirm rebase merges are allowed; any other merge method squashes them."
311
- }
312
-
313
173
  // A failure text while the process is not a finished, shippable one.
314
- async function unfinished(io: ShipIo, slices: Slice[]) {
315
- const state = async () => (await io.run(["gtd", "next", "--json=state"])).out.trim()
174
+ async function unfinished(io: ShipIo) {
316
175
  // `gtd summary` also describes a process still underway; shipping that
317
176
  // would publish half of it.
318
- const open = slices.find((s) => !s.closed)
319
- if (open)
320
- return `The gtd process is still underway (slice ${open.name} is open, at ${await state()}). Finish it, then ship.`
321
177
  const initial = (await io.run(["gtd", "next", "--json=initial"])).out.trim()
322
- if (initial !== "true")
323
- return `The gtd process is still underway (at ${await state()}). Finish it, then ship.`
324
- return undefined
325
- }
326
-
327
- async function commitMessages(io: ShipIo, git: Git, groups: Group[], tip: string) {
328
- const parts: { message: string; trailers: string[] }[] = []
329
- for (const g of groups) {
330
- const trailers = costTrailers(await git("log", "--format=%B%n", `${g.base}..${g.tip}`))
331
- const summary = await io.run(["gtd", "summary", `--slice=${g.name}`])
332
- if (summary.code !== 0)
333
- return fail(
334
- `gtd summary --slice=${g.name} failed; nothing changed.\n${summary.err}`.trimEnd(),
335
- )
336
- const reply = await io.complete(
337
- summary.out + "\n" + (g.absorbed ? ABSORBED : "") + COMMIT_FORMAT,
338
- )
339
- const message = reply && cleanReply(reply)
340
- if (!message) return fail("The commit-message turn produced nothing.")
341
- parts.push({ message, trailers })
342
- }
343
- const history = await historyRef(io, tip)
344
- const fulls = parts.map(({ message, trailers }) => {
345
- const all = history ? [...trailers, `Gtd-History: ${history}`] : trailers
346
- return all.length ? `${message}\n\n${all.join("\n")}` : message
347
- })
348
- return { ok: true as const, fulls, history }
349
- }
350
-
351
- type Plan = { groups: Group[]; base: string; tip: string; count: number }
352
-
353
- // `lease`: origin's sha for the branch, read before a restack rebased it.
354
- // `fresh`: ls-remote showed origin lacks the branch, so any tracking ref is stale.
355
- type Link = { name: string; branch: string; commit: string; lease?: string; fresh?: boolean }
356
-
357
- const STACK_KEY = (branch: string) => `branch.${branch}.gtdStack`
358
- // The commit the stack was built at; missing slice branches are rebuilt off it
359
- // while HEAD still contains it. Only a restack moves it, to a top with one
360
- // commit per lower slice beneath it.
361
- const TOP_KEY = (branch: string) => `branch.${branch}.gtdStackTop`
362
-
363
- // Slice names of a moved-aside record, awaiting PR success.
364
- const RETIRED_KEY = (branch: string) => `branch.${branch}.gtdStackRetired`
365
- // Origin's sha before a restack moved the branch; outlives the record so a rejected
366
- // push keeps its lease, and is never cleared by forgetStack.
367
- const LEASE_KEY = (branch: string) => `branch.${branch}.gtdLease`
368
-
369
- async function forgetStack(io: ShipIo, branch: string) {
370
- for (const key of [STACK_KEY, TOP_KEY, RETIRED_KEY])
371
- await io.run(["git", "config", "--local", "--unset", key(branch)])
372
- }
373
-
374
- async function retireStack(io: ShipIo, branch: string, live: string[]) {
375
- const had = names((await io.run(["git", "config", "--local", "--get", RETIRED_KEY(branch)])).out)
376
- const union = [...new Set([...had, ...live])]
377
- const set = await io.run(["git", "config", "--local", RETIRED_KEY(branch), union.join(" ")])
378
- if (set.code !== 0) return
379
- await io.run(["git", "config", "--local", "--unset", STACK_KEY(branch)])
380
- await io.run(["git", "config", "--local", "--unset", TOP_KEY(branch)])
381
- }
382
-
383
- const names = (raw: string) => raw.split(/\s+/).filter(Boolean)
384
-
385
- // No gtd process at HEAD: nothing to squash, so only a recorded stack can resume.
386
- async function resumeAlone(
387
- io: ShipIo,
388
- git: Git,
389
- ok: Ok,
390
- branch: string,
391
- baseBranch: string,
392
- isDry: boolean,
393
- mode: ShipMode,
394
- ): Promise<Shipped & { stack?: Link[]; floor?: string; forget?: boolean; restack?: Restack }> {
395
- io.log(`No gtd process at HEAD, so nothing is squashed; ${branch} is described as it stands.`)
396
- if (mode !== "stack") return { ok: true, text: "", forget: true }
397
- const found = await resumable(io, git, ok, branch, baseBranch, isDry, "HEAD")
398
- if (typeof found === "string") return fail(found)
399
- const failed = await found.revive?.()
400
- if (failed) return fail(failed)
401
- if (found.restack && !isDry) {
402
- const moved = await found.restack.run()
403
- if (typeof moved === "string") return fail(moved)
404
- return { ok: true, text: "", stack: moved.links, floor: moved.floor, forget: moved.forget }
405
- }
406
- return {
407
- ok: true,
408
- text: "",
409
- stack: found.links,
410
- floor: found.floor,
411
- forget: found.forget,
412
- restack: found.restack,
413
- }
414
- }
415
-
416
- async function plan(
417
- io: ShipIo,
418
- git: Git,
419
- ok: Ok,
420
- branch: string,
421
- baseBranch: string,
422
- isDry: boolean,
423
- mode: ShipMode,
424
- ): Promise<
425
- Plan | (Shipped & { stack?: Link[]; floor?: string; forget?: boolean; restack?: Restack })
426
- > {
427
- const probe = await io.run(["gtd", "summary"])
428
- if (probe.code !== 0) return resumeAlone(io, git, ok, branch, baseBranch, isDry, mode)
429
- const listed = await io.run(["gtd", "slices", "--json"])
430
- if (listed.code !== 0) return fail(`gtd slices failed; nothing changed.\n${listed.err}`.trimEnd())
431
- const slices = readSlices(listed.out)
432
- if ("refusal" in slices) return fail(`${slices.refusal} Nothing changed.`)
433
-
434
- const underway = await unfinished(io, slices)
435
- if (underway) return fail(underway)
436
- const base = slices[0]!.base
437
- const tip = slices.at(-1)!.tip
178
+ if (initial === "true") return undefined
179
+ const state = (await io.run(["gtd", "next", "--json=state"])).out.trim()
180
+ return `The gtd process is still underway (at ${state}). Finish it, then ship.`
181
+ }
182
+
183
+ // The process's own commits, base exclusive, and only while it ends at HEAD;
184
+ // a string is why ship refuses to squash.
185
+ async function processRange(git: Git, summary: string) {
186
+ const range = summaryRange(summary)
187
+ if (!range) return "The gtd summary names no commit range, so ship refuses to guess a base."
188
+ const base = await git("rev-parse", "--verify", `${range[0]}^{commit}`)
189
+ const tip = await git("rev-parse", "--verify", `${range[1]}^{commit}`)
190
+ if (!base || !tip) return "The gtd summary's range does not resolve in this repository."
438
191
  if (tip !== (await git("rev-parse", "HEAD")))
439
- return fail(`The process tip (${short(tip)}) is not HEAD: something landed since.`)
192
+ return `The process tip (${short(tip)}) is not HEAD: something landed since.`
440
193
  const count = Number(await git("rev-list", "--count", `${base}..HEAD`))
441
- if (!count) return fail(`Nothing to squash: ${short(base)}..HEAD is empty.`)
442
-
443
- const groups = await landed(ok, slices)
444
- if (!groups.length) return dropProcess(io, ok, base, count, isDry)
445
- return { groups, base, tip, count }
446
- }
447
-
448
- function dryRun(
449
- groups: Group[],
450
- fulls: string[],
451
- history: string | undefined,
452
- warning: string | undefined,
453
- stack: Link[] | undefined,
454
- ) {
455
- return {
456
- ok: true as const,
457
- text:
458
- groups.length > 1
459
- ? fulls.map((f, i) => `[${groups[i]!.name}]\n${f}`).join("\n\n---\n\n")
460
- : fulls[0]!,
461
- history,
462
- warning,
463
- stack,
464
- messages: fulls,
465
- }
194
+ if (!count) return `Nothing to squash: ${short(base)}..HEAD is empty.`
195
+ return { base, tip, count }
466
196
  }
467
197
 
468
198
  async function squash(
@@ -470,167 +200,36 @@ async function squash(
470
200
  git: Git,
471
201
  ok: Ok,
472
202
  branch: string,
473
- baseBranch: string,
474
203
  isDry: boolean,
475
- mode: ShipMode,
476
- ): Promise<
477
- Shipped & {
478
- history?: string
479
- warning?: string
480
- stack?: Link[]
481
- messages?: string[]
482
- floor?: string
483
- forget?: boolean
484
- restack?: Restack
485
- recorded?: boolean
204
+ ): Promise<Shipped & { history?: string | undefined }> {
205
+ const summary = await io.run(["gtd", "summary"])
206
+ if (summary.code !== 0) {
207
+ io.log(`No gtd process at HEAD, so nothing is squashed; ${branch} is described as it stands.`)
208
+ return { ok: true, text: "" }
486
209
  }
487
- > {
488
- const planned = await plan(io, git, ok, branch, baseBranch, isDry, mode)
489
- if ("ok" in planned) return planned
490
- const { groups, base, tip, count } = planned
491
- const many = groups.length > 1
492
- const stacked = mode === "stack" && many
493
-
494
- // Before the history push: a refusal changes nothing.
495
- const resume = await stackReads(io, git, ok, branch, baseBranch, planned, isDry, mode)
496
- if (typeof resume === "string") return fail(resume)
497
- io.log(`Squashing ${count} commit(s) since ${short(base)} into ${groups.length} commit(s)…`)
498
- const msgs = await commitMessages(io, git, groups, tip)
499
- if (!msgs.ok) return msgs
500
- const { fulls, history } = msgs
501
- const warn = () => (mode === "commits" && many ? rebaseWarning(io) : undefined)
502
- if (resume?.links) return squashOntoStack(io, git, ok, groups, fulls, history, resume, isDry, tip)
503
- if (isDry)
504
- return dryRun(
505
- groups,
506
- fulls,
507
- history,
508
- await warn(),
509
- stacked ? sliceLinks(branch, groups, []) : undefined,
510
- )
511
-
512
- const pushed = await pushHistory(io, history)
513
- if (pushed) return fail(pushed)
514
- return land(io, git, ok, branch, groups, fulls, stacked, await warn())
515
- }
516
-
517
- async function land(
518
- io: ShipIo,
519
- git: Git,
520
- ok: Ok,
521
- branch: string,
522
- groups: Group[],
523
- fulls: string[],
524
- stacked: boolean,
525
- warning: string | undefined,
526
- ): Promise<Awaited<ReturnType<typeof squash>>> {
527
- if (groups.length === 1) {
528
- const one = await commitSquash(io, git, ok, groups[0]!.base, fulls[0]!)
529
- return one.ok ? { ...one, forget: true } : one
530
- }
531
- const built = await commitChain(io, git, ok, groups, fulls)
532
- if (!built.ok) return built
533
- // a record of an earlier stack must not outlive this ship
534
- if (!stacked) return { ok: true, text: "", warning, forget: true }
535
- const stack = sliceLinks(branch, groups, built.commits)
536
- const recorded = await recordStack(io, ok, branch, stack)
537
- return recorded ? fail(recorded) : { ok: true as const, text: "", stack, recorded: true }
538
- }
539
-
540
- const sliceLinks = (branch: string, groups: Group[], commits: string[]): Link[] =>
541
- groups.map((g, i) => ({
542
- name: g.name,
543
- branch: i < groups.length - 1 ? `${branch}-${g.name}` : branch,
544
- commit: commits[i] ?? "",
545
- }))
546
-
547
- async function stackReads(
548
- io: ShipIo,
549
- git: Git,
550
- ok: Ok,
551
- branch: string,
552
- baseBranch: string,
553
- { groups, base }: Plan,
554
- isDry: boolean,
555
- mode: ShipMode,
556
- ): Promise<Resumed | string | undefined> {
557
- if (mode !== "stack") return undefined
558
- if (groups.length > 1)
559
- return await stackPrecheck(io, git, ok, branch, baseBranch, groups, base, isDry)
560
- return resumable(io, git, ok, branch, baseBranch, isDry, base)
561
- }
210
+ const underway = await unfinished(io)
211
+ if (underway) return fail(underway)
212
+ const range = await processRange(git, summary.out)
213
+ if (typeof range === "string") return fail(`${range} Nothing changed.`)
214
+ const { base, tip, count } = range
215
+
216
+ // gtd churns its own steering files every process, so `.gtd/` alone is no change.
217
+ if (await ok("diff", "--quiet", base, tip, "--", ":(exclude).gtd/"))
218
+ return dropProcess(io, ok, base, count, isDry)
219
+
220
+ io.log(`Squashing ${count} commit(s) since ${short(base)}…`)
221
+ const trailers = costTrailers(await git("log", "--format=%B%n", `${base}..${tip}`))
222
+ const reply = await io.complete(summary.out + "\n" + COMMIT_FORMAT)
223
+ const message = reply && cleanReply(reply)
224
+ if (!message) return fail("The commit-message turn produced nothing.")
225
+ const history = await historyRef(io, tip)
226
+ const all = history ? [...trailers, `Gtd-History: ${history}`] : trailers
227
+ const full = all.length ? `${message}\n\n${all.join("\n")}` : message
228
+ if (isDry) return { ok: true, text: full, history }
562
229
 
563
- // A process on a recorded stack — a re-ship, or a follow-up such as review fixes —
564
- // lands its slice commits on the top branch, so they join the top pull request.
565
- // The record, including the recorded top the missing-branch fallbacks and the
566
- // restack read, stays.
567
- async function squashOntoStack(
568
- io: ShipIo,
569
- git: Git,
570
- ok: Ok,
571
- groups: Group[],
572
- fulls: string[],
573
- history: string | undefined,
574
- resume: Resumed,
575
- isDry: boolean,
576
- preShip: string,
577
- ) {
578
- const links = resume.links!
579
- const top = links.length - 1
580
- if (isDry) {
581
- // the follow-up's slice names are not stack slices: no [name] headers
582
- const messages = links.map((_, i) => (i === top ? fulls.join("\n\n") : ""))
583
- return { ok: true as const, text: "", history, stack: links, messages, restack: resume.restack }
584
- }
585
230
  const pushed = await pushHistory(io, history)
586
231
  if (pushed) return fail(pushed)
587
- const one =
588
- groups.length === 1
589
- ? await commitSquash(io, git, ok, groups[0]!.base, fulls[0]!)
590
- : await commitChain(io, git, ok, groups, fulls)
591
- if (!one.ok) return one
592
- const failed = await resume.revive?.()
593
- if (failed) return fail(failed)
594
- if (resume.restack) {
595
- const moved = await resume.restack.run(preShip)
596
- if (typeof moved === "string") return fail(moved)
597
- return {
598
- ok: true as const,
599
- text: "",
600
- stack: moved.links,
601
- floor: moved.floor,
602
- forget: moved.forget,
603
- }
604
- }
605
- const head = await git("rev-parse", "HEAD")
606
- const stack = links.map((l, i) => (i === top ? { ...l, commit: head } : l))
607
- return { ok: true as const, text: "", stack, floor: resume.floor }
608
- }
609
-
610
- async function recordStack(io: ShipIo, ok: Ok, branch: string, stack: Link[]) {
611
- const top = await io.run(["git", "config", "--local", TOP_KEY(branch), stack.at(-1)!.commit])
612
- const recorded =
613
- top.code !== 0
614
- ? top
615
- : await io.run([
616
- "git",
617
- "config",
618
- "--local",
619
- STACK_KEY(branch),
620
- stack.map((l) => l.name).join(" "),
621
- ])
622
- if (recorded.code !== 0) {
623
- // Without the record a retry would ship one pull request: put the process back.
624
- // The two keys only ever exist as a matched pair.
625
- await forgetStack(io, branch)
626
- const undone = await ok("reset", "--soft", "ORIG_HEAD")
627
- return `git config failed; ${undone ? "the squash is undone, retry" : "the squash is kept, but a retry will not find the stack"}.\n${recorded.err}`.trimEnd()
628
- }
629
- for (const l of stack.slice(0, -1)) {
630
- if (!(await ok("branch", "-f", l.branch, l.commit)))
631
- return `git branch -f ${l.branch} failed; the squash is kept. /gtd ship resumes.`
632
- }
633
- return undefined
232
+ return commitSquash(io, git, ok, base, full)
634
233
  }
635
234
 
636
235
  type View = {
@@ -639,8 +238,6 @@ type View = {
639
238
  body?: string
640
239
  url: string
641
240
  state: string
642
- baseRefName?: string
643
- mergedAt?: string
644
241
  }
645
242
 
646
243
  // gh's stderr for a branch with no PR (cli/cli pr view); tests script it by this.
@@ -655,14 +252,7 @@ async function readPr(
655
252
  io: ShipIo,
656
253
  branch: string,
657
254
  ): Promise<{ view?: View; gone?: boolean; abandoned?: number } | string> {
658
- const v = await io.run([
659
- "gh",
660
- "pr",
661
- "view",
662
- branch,
663
- "--json",
664
- "number,title,body,url,state,baseRefName,mergedAt",
665
- ])
255
+ const v = await io.run(["gh", "pr", "view", branch, "--json", "number,title,body,url,state"])
666
256
  if (v.code !== 0) {
667
257
  if (v.err.includes(NO_PR_FOUND)) return {}
668
258
  return `gh pr view ${branch} failed: ${v.err.trim()}`
@@ -696,934 +286,6 @@ async function abandon(io: ShipIo, branch: string, pr: number | undefined) {
696
286
  return set.code === 0 ? undefined : `git config failed.\n${set.err}`.trimEnd()
697
287
  }
698
288
 
699
- async function viewPr(io: ShipIo, branch: string): Promise<View | undefined | string> {
700
- const read = await readPr(io, branch)
701
- return typeof read === "string" ? read : read.view
702
- }
703
-
704
- // `dead` names belong to a retired record gtd built itself: not a collision.
705
- async function takenSlice(git: Git, ok: Ok, branch: string, groups: Group[], dead: string[]) {
706
- for (const g of groups.slice(0, -1)) {
707
- if (dead.includes(g.name)) continue
708
- const b = `${branch}-${g.name}`
709
- if (
710
- (await ok("rev-parse", "--verify", "--quiet", `refs/heads/${b}`)) ||
711
- (await ok("rev-parse", "--verify", "--quiet", `origin/${b}`))
712
- )
713
- return `Branch ${b} (slice ${g.name}) already exists and is not part of a stack gtd built; nothing changed. Rename or delete it, then ship.`
714
- }
715
- return undefined
716
- }
717
-
718
- // A live record takes the process onto its top branch; a dead one (no links)
719
- // leaves the way open for a fresh stack.
720
- async function liveRecord(
721
- io: ShipIo,
722
- git: Git,
723
- ok: Ok,
724
- branch: string,
725
- baseBranch: string,
726
- recorded: string[],
727
- base: string,
728
- isDry: boolean,
729
- ): Promise<Resumed | string> {
730
- // The staleness test a resume uses, without the restack's origin and worktree
731
- // reads: a dead record is not a live stack, and its dropped-record line is
732
- // silenced since a fresh stack replaces it.
733
- const quiet = { ...io, log: () => {} }
734
- const found = await resumable(quiet, git, ok, branch, baseBranch, true, "HEAD", false)
735
- // resumable refuses (a missing branch, a closed slice PR) before it tests the
736
- // chain; only a record that still chains to HEAD may refuse a fresh stack.
737
- if (typeof found === "string") return (await chainsToHead(git, ok, branch, recorded)) ? found : {}
738
- // `forget` with links is a live stack a restack ends, not a dead record.
739
- if (!found.links) return {}
740
- // Live: any refusal of the full resume (origin, worktrees) stands.
741
- return resumable(io, git, ok, branch, baseBranch, isDry, base)
742
- }
743
-
744
- async function chainsToHead(git: Git, ok: Ok, branch: string, recorded: string[]) {
745
- const top = await git("config", "--local", "--get", TOP_KEY(branch))
746
- const held = top && (await ok("merge-base", "--is-ancestor", top, "HEAD")) ? top : ""
747
- return (await chain(git, ok, branch, recorded, 0, held, "HEAD")).valid
748
- }
749
-
750
- async function prStateRefusal(
751
- io: ShipIo,
752
- branch: string,
753
- groups: Group[],
754
- ): Promise<string | undefined> {
755
- for (const [i, g] of groups.entries()) {
756
- const top = i === groups.length - 1
757
- const b = top ? branch : `${branch}-${g.name}`
758
- const view = await viewPr(io, b)
759
- if (typeof view === "string") return `${view} Nothing changed.`
760
- // Commits mode ships to the top branch too, so for it only a rename helps.
761
- if (view && view.state !== "OPEN")
762
- return `The pull request for slice ${g.name} (#${view.number}, ${b}) is ${view.state}, not open; nothing changed. ${top ? "Rename the branch" : "Ship with GTD_SHIP_MODE=commits, or rename the branch"}.`
763
- }
764
- return undefined
765
- }
766
-
767
- // Before the history push: a refusal leaves the repository exactly as it was.
768
- async function stackPrecheck(
769
- io: ShipIo,
770
- git: Git,
771
- ok: Ok,
772
- branch: string,
773
- baseBranch: string,
774
- groups: Group[],
775
- base: string,
776
- isDry: boolean,
777
- ): Promise<Resumed | string | undefined> {
778
- const recorded = names(await git("config", "--local", "--get", STACK_KEY(branch)))
779
- if (recorded.length) {
780
- const live = await liveRecord(io, git, ok, branch, baseBranch, recorded, base, isDry)
781
- // Without links the squash builds a fresh stack, so the collision checks still run.
782
- if (typeof live === "string" || live?.links) return live
783
- }
784
- const retired = names(await git("config", "--local", "--get", RETIRED_KEY(branch)))
785
- const taken = await takenSlice(git, ok, branch, groups, [...recorded, ...retired])
786
- if (taken) return taken
787
- return prStateRefusal(io, branch, groups)
788
- }
789
-
790
- // A missing branch is read off the recorded top, not HEAD: the top slice may span
791
- // several commits after a re-ship.
792
- async function chain(
793
- git: Git,
794
- ok: Ok,
795
- branch: string,
796
- recorded: string[],
797
- from: number,
798
- top: string,
799
- at: string,
800
- ) {
801
- const commits: string[] = []
802
- const missing: number[] = []
803
- let valid = true
804
- for (let i = from; i < recorded.length - 1; i++) {
805
- let sha = await git("rev-parse", "--verify", "--quiet", `refs/heads/${branch}-${recorded[i]}`)
806
- if (!sha && top) {
807
- sha = await git("rev-parse", "--verify", "--quiet", `${top}~${recorded.length - 1 - i}`)
808
- missing.push(i)
809
- }
810
- if (!sha) valid = false
811
- commits.push(sha)
812
- }
813
- for (const [i, c] of commits.entries()) {
814
- if (!valid) break
815
- if (!(await ok("merge-base", "--is-ancestor", c, commits[i + 1] ?? at))) valid = false
816
- }
817
- return { commits, missing, valid }
818
- }
819
-
820
- // Rebuilding a branch from <top>~n is only safe while HEAD still contains the
821
- // recorded top, which only a restack moves.
822
- async function topGuard(
823
- git: Git,
824
- ok: Ok,
825
- branch: string,
826
- recorded: string[],
827
- from: number,
828
- ): Promise<{ top: string } | { rebased: true; top: string } | { refusal: string }> {
829
- let missing = ""
830
- for (const name of recorded.slice(from, -1)) {
831
- const b = `${branch}-${name}`
832
- if (await ok("rev-parse", "--verify", "--quiet", `refs/heads/${b}`)) continue
833
- missing = b
834
- break
835
- }
836
- if (!missing && !from) return { top: "" }
837
- const top = await git("config", "--local", "--get", TOP_KEY(branch))
838
- if (top && (await ok("merge-base", "--is-ancestor", top, "HEAD"))) return { top }
839
- // only the merged floor needs the top: a rebase dropped it, so the stack ends
840
- if (!missing) return { rebased: true, top }
841
- return {
842
- refusal: `Slice branch ${missing} is missing and HEAD no longer contains the recorded top of the stack; nothing changed. Ship the branch alone with GTD_SHIP_MODE=commits.`,
843
- }
844
- }
845
-
846
- type Resumed = {
847
- links?: Link[]
848
- floor?: string
849
- forget?: boolean
850
- revive?: () => Promise<string | undefined>
851
- // Read-only until run(): the caller runs it after the squash and revive, so
852
- // "squash first, then restack" holds.
853
- restack?: Restack
854
- }
855
-
856
- type Restack = {
857
- preview: string[]
858
- // A string is the failure text; `preShip` is HEAD before this run's squash.
859
- run(preShip?: string): Promise<{ links: Link[]; floor?: string; forget: boolean } | string>
860
- }
861
-
862
- // Merged into a slice branch counts only when that branch landed earlier: its
863
- // chain then reached the default branch. A missing `mergedAt` proves nothing.
864
- function landedOn(c: Merging, i: number): boolean {
865
- const { states, branchOf, baseBranch } = c
866
- const view = states[i]!
867
- if (view.baseRefName === baseBranch) return true
868
- const k = [...Array(i).keys()].find((j) => view.baseRefName === branchOf(j))
869
- const time = (v?: View) => (v?.mergedAt ? Date.parse(v.mergedAt) : NaN)
870
- return k !== undefined && time(view) <= time(states[k])
871
- }
872
-
873
- type Merging = {
874
- recorded: string[]
875
- states: (View | undefined)[]
876
- branchOf: (i: number) => string
877
- baseBranch: string
878
- }
879
-
880
- const sliceNamed = (c: Merging, i: number) =>
881
- `The pull request for slice ${c.recorded[i]} (#${c.states[i]!.number}, ${c.branchOf(i)})`
882
-
883
- const mergedElsewhere = (c: Merging, i: number) =>
884
- `${sliceNamed(c, i)} merged into ${c.states[i]!.baseRefName ?? "no base"}, not ${c.baseBranch}; nothing changed. Ship the branch alone with GTD_SHIP_MODE=commits.`
885
-
886
- // What a slice at or above `done` may not be: closed, or merged anywhere but the
887
- // default branch.
888
- function unsettled(c: Merging, done: number): string | undefined {
889
- for (const [i, view] of c.states.entries()) {
890
- if (i < done || !view || view.state === "OPEN") continue
891
- if (view.state === "MERGED" && !landedOn(c, i)) return mergedElsewhere(c, i)
892
- const way =
893
- i === c.recorded.length - 1
894
- ? "Rename the branch"
895
- : "Merged slices drop out of a resumed stack on their own; a closed one needs GTD_SHIP_MODE=commits to ship the branch alone"
896
- return `${sliceNamed(c, i)} is ${view.state}, not open; nothing changed. ${way}.`
897
- }
898
- return undefined
899
- }
900
-
901
- async function mergedSlices(
902
- io: ShipIo,
903
- recorded: string[],
904
- branchOf: (i: number) => string,
905
- baseBranch: string,
906
- ): Promise<{ done: number } | string> {
907
- const states: (View | undefined)[] = []
908
- for (const i of recorded.keys()) {
909
- const view = await viewPr(io, branchOf(i))
910
- if (typeof view === "string") return `${view} Nothing changed.`
911
- states.push(view)
912
- }
913
- const c = { recorded, states, branchOf, baseBranch }
914
- let done = 0
915
- while (done < recorded.length - 1 && states[done]?.state === "MERGED") {
916
- if (!landedOn(c, done)) return mergedElsewhere(c, done)
917
- done++
918
- }
919
- return unsettled(c, done) ?? { done }
920
- }
921
-
922
- // Every read happens before any write, so a refusal (a string) changes nothing.
923
- // `revive` is left to the caller so a squash can come first. `planRestack` false
924
- // skips the network and worktree reads a restack needs: a caller that only asks
925
- // whether the record is live must not depend on them. A hand-restacked record
926
- // is still live then: its shape test is local.
927
- async function resumable(
928
- io: ShipIo,
929
- git: Git,
930
- ok: Ok,
931
- branch: string,
932
- baseBranch: string,
933
- isDry: boolean,
934
- at: string,
935
- planRestack = true,
936
- ): Promise<Resumed | string> {
937
- const recorded = names(await git("config", "--local", "--get", STACK_KEY(branch)))
938
- if (!recorded.length) return {}
939
- const last = recorded.length - 1
940
- const branchOf = (i: number) => (i < last ? `${branch}-${recorded[i]}` : branch)
941
- const merged = await mergedSlices(io, recorded, branchOf, baseBranch)
942
- if (typeof merged === "string") return merged
943
- const { done } = merged
944
- const guard = await topGuard(git, ok, branch, recorded, done)
945
- if ("refusal" in guard) return guard.refusal
946
- const { top } = guard
947
- const { commits, missing, valid } = await chain(git, ok, branch, recorded, done, top, at)
948
- if (valid && "rebased" in guard && done) {
949
- const { from, links } = await stackRest(git, { branch, recorded, done, top, commits, branchOf })
950
- const hand =
951
- from &&
952
- (await handRestack(io, git, ok, {
953
- branch,
954
- baseBranch,
955
- top,
956
- from,
957
- links,
958
- plan: planRestack,
959
- }))
960
- if (hand) return hand
961
- }
962
- if (!valid || "rebased" in guard) {
963
- io.log(
964
- `The recorded stack for ${branch} no longer chains to HEAD: ${isDry ? "" : "dropped it, "}shipping the branch alone.`,
965
- )
966
- return { forget: true }
967
- }
968
- return finishResume(io, git, ok, {
969
- branch,
970
- baseBranch,
971
- recorded,
972
- done,
973
- top,
974
- commits,
975
- missing,
976
- isDry,
977
- planRestack,
978
- branchOf,
979
- })
980
- }
981
-
982
- // `from` is the merged slice's commit, read off the recorded top: the local
983
- // slice branch may have moved since.
984
- async function stackRest(
985
- git: Git,
986
- c: {
987
- branch: string
988
- recorded: string[]
989
- done: number
990
- top: string
991
- commits: string[]
992
- branchOf: (i: number) => string
993
- },
994
- ): Promise<{ from?: string; links: Link[] }> {
995
- const { recorded, done, top, commits, branchOf } = c
996
- const from =
997
- done && top
998
- ? (await git(
999
- "rev-parse",
1000
- "--verify",
1001
- "--quiet",
1002
- `${top}~${recorded.length - 1 - (done - 1)}`,
1003
- )) || undefined
1004
- : undefined
1005
- const head = await git("rev-parse", "HEAD")
1006
- const links = recorded.slice(done).map((name, j) => ({
1007
- name,
1008
- branch: branchOf(done + j),
1009
- commit: commits[j] ?? head,
1010
- }))
1011
- return { from, links }
1012
- }
1013
-
1014
- async function finishResume(
1015
- io: ShipIo,
1016
- git: Git,
1017
- ok: Ok,
1018
- c: {
1019
- branch: string
1020
- baseBranch: string
1021
- recorded: string[]
1022
- done: number
1023
- top: string
1024
- commits: string[]
1025
- missing: number[]
1026
- isDry: boolean
1027
- planRestack: boolean
1028
- branchOf: (i: number) => string
1029
- },
1030
- ): Promise<Resumed | string> {
1031
- const { branch, baseBranch, recorded, done, commits, missing, isDry, planRestack, branchOf } = c
1032
- const { from, links } = await stackRest(git, c)
1033
- if (done && !from)
1034
- return `The commit of merged slice ${recorded[done - 1]} cannot be found; nothing changed. Ship the branch alone with GTD_SHIP_MODE=commits.`
1035
- const restack =
1036
- done && planRestack
1037
- ? await restackOf(io, git, ok, {
1038
- branch,
1039
- baseBranch,
1040
- from: from!,
1041
- links,
1042
- merged: recorded.slice(0, done),
1043
- isDry,
1044
- })
1045
- : undefined
1046
- if (typeof restack === "string") return restack
1047
- const revive = isDry
1048
- ? undefined
1049
- : async () => {
1050
- for (const i of missing) {
1051
- const b = branchOf(i)
1052
- if (!(await ok("branch", "-f", b, commits[i - done]!)))
1053
- return `git branch -f ${b} failed; /gtd ship resumes.`
1054
- }
1055
- return undefined
1056
- }
1057
- return { links, floor: from, revive, restack }
1058
- }
1059
-
1060
- // The lease is origin's sha as read here, never origin/<b>: a fetch rewrites
1061
- // that. Checked before any rebase, squash or push, so a refusal changes
1062
- // nothing.
1063
- async function originLeases(
1064
- io: ShipIo,
1065
- ok: Ok,
1066
- links: Link[],
1067
- kept: (l: Link, sha: string) => Promise<boolean>,
1068
- ): Promise<Link[] | string> {
1069
- const read = await io.run([
1070
- "git",
1071
- "ls-remote",
1072
- "origin",
1073
- ...links.map((l) => `refs/heads/${l.branch}`),
1074
- ])
1075
- if (read.code !== 0) return `git ls-remote origin failed; nothing changed.\n${read.err}`.trimEnd()
1076
- const origin = new Map<string, string>()
1077
- for (const line of read.out.split("\n")) {
1078
- const [sha, ref] = line.trim().split(/\s+/)
1079
- if (sha && ref?.startsWith("refs/heads/")) origin.set(ref.slice("refs/heads/".length), sha)
1080
- }
1081
- for (const l of links) {
1082
- const sha = origin.get(l.branch)
1083
- if (sha && !((await ok("cat-file", "-e", sha)) && (await kept(l, sha))))
1084
- return `origin/${l.branch} holds commits ${l.branch} lacks; nothing changed. Pull or reconcile ${l.branch}, then ship.`
1085
- }
1086
- return links.map((l) => ({ ...l, lease: origin.get(l.branch), fresh: !origin.has(l.branch) }))
1087
- }
1088
-
1089
- const pushLines = (links: Link[]) =>
1090
- links.map((l) =>
1091
- l.lease
1092
- ? `Push: would run git push --force-with-lease=${l.branch}:${l.lease} origin ${l.branch}`
1093
- : `Push: would run git push --set-upstream origin ${l.branch}`,
1094
- )
1095
-
1096
- // `git worktree list` is the one place git says a branch is checked out in
1097
- // another worktree, where `rebase --update-refs` silently skips it.
1098
- async function checkedOutElsewhere(
1099
- io: ShipIo,
1100
- branches: string[],
1101
- ): Promise<{ branch: string; path: string } | undefined | string> {
1102
- const listed = await io.run(["git", "worktree", "list", "--porcelain"])
1103
- if (listed.code !== 0)
1104
- return `git worktree list failed; nothing changed.\n${listed.err}`.trimEnd()
1105
- const held = new Map<string, string>()
1106
- let path = ""
1107
- for (const line of listed.out.split("\n")) {
1108
- if (line.startsWith("worktree ")) path = line.slice("worktree ".length)
1109
- else if (line.startsWith("branch refs/heads/")) {
1110
- const b = line.slice("branch refs/heads/".length)
1111
- if (!held.has(b)) held.set(b, path)
1112
- }
1113
- }
1114
- for (const b of branches) if (held.has(b)) return { branch: b, path: held.get(b)! }
1115
- return undefined
1116
- }
1117
-
1118
- // The hand lines of whatever could not be restored: an empty list is a clean rollback.
1119
- async function rollback(git: Git, ok: Ok, pre: Map<string, string>, preHead: string) {
1120
- const failed: string[] = []
1121
- for (const [r, sha] of pre) {
1122
- const now = await git("rev-parse", `refs/heads/${r}`)
1123
- if (!(await ok("update-ref", `refs/heads/${r}`, sha, now)))
1124
- failed.push(`git update-ref refs/heads/${r} ${sha} ${now}`)
1125
- }
1126
- if (!(await ok("reset", "--keep", preHead))) failed.push(`git reset --keep ${preHead}`)
1127
- return failed
1128
- }
1129
-
1130
- const rollbackFailed = (why: string, failed: string[], undo: string | undefined, err = "") =>
1131
- [
1132
- `${why}, and the rollback failed: slice branches stay rebased and nothing was pushed or recorded. Run by hand, then /gtd ship:`,
1133
- ...failed,
1134
- err,
1135
- undo,
1136
- ]
1137
- .filter(Boolean)
1138
- .join("\n")
1139
-
1140
- // Each remaining lower slice sits one commit above the one below it, and the top
1141
- // keeps a commit of its own: a rebase that drops an already-upstream commit exits
1142
- // 0 and leaves that branch on `onto` itself.
1143
- async function brokenShape(
1144
- git: Git,
1145
- links: Link[],
1146
- branch: string,
1147
- onto: string,
1148
- ): Promise<string | undefined> {
1149
- const lower = links.filter((l) => l.branch !== branch)
1150
- for (const [i, l] of lower.entries()) {
1151
- const below = i ? `refs/heads/${lower[i - 1]!.branch}` : onto
1152
- if ((await git("rev-parse", `refs/heads/${l.branch}~1`)) !== (await git("rev-parse", below)))
1153
- return l.name
1154
- }
1155
- const under = lower.length ? `refs/heads/${lower.at(-1)!.branch}` : onto
1156
- if (Number(await git("rev-list", "--count", `${under}..HEAD`)) < 1) return links.at(-1)!.name
1157
- return undefined
1158
- }
1159
-
1160
- // --update-refs skips a branch it cannot move; leaving the rest rebased would
1161
- // make the next ship see a broken chain and silently drop the stack.
1162
- async function unmovedSlice(
1163
- io: ShipIo,
1164
- git: Git,
1165
- ok: Ok,
1166
- c: {
1167
- lower: string[]
1168
- onto: string
1169
- from: string
1170
- pre: Map<string, string>
1171
- preHead: string
1172
- undo: string | undefined
1173
- },
1174
- ): Promise<string | undefined> {
1175
- const { lower, onto, from, pre, preHead, undo } = c
1176
- for (const b of lower) {
1177
- const at = `refs/heads/${b}`
1178
- const moved =
1179
- (await ok("merge-base", "--is-ancestor", onto, at)) &&
1180
- !(await ok("merge-base", "--is-ancestor", from, at))
1181
- if (moved) continue
1182
- const failed = await rollback(git, ok, pre, preHead)
1183
- if (failed.length) return rollbackFailed(`Slice branch ${b} was not restacked`, failed, undo)
1184
- const held = await checkedOutElsewhere(io, [b])
1185
- const where = typeof held === "object" ? ` (checked out in worktree ${held.path})` : ""
1186
- return [
1187
- `Slice branch ${b} was not restacked${where}; the slice branches and HEAD are restored and nothing was pushed or recorded.`,
1188
- undo,
1189
- ]
1190
- .filter(Boolean)
1191
- .join("\n")
1192
- }
1193
- return undefined
1194
- }
1195
-
1196
- // --update-refs also drags any other local branch pointing into the range along,
1197
- // and no rollback restores it. Over-refuses a branch sitting on an unsquashed
1198
- // top-slice process commit: accepted, a refusal changes nothing.
1199
- async function strayBranch(
1200
- git: Git,
1201
- links: Link[],
1202
- merged: string[],
1203
- from: string,
1204
- branch: string,
1205
- ): Promise<string | undefined> {
1206
- const inRange = new Set(
1207
- (await git("rev-list", `${from}..refs/heads/${branch}`)).split("\n").filter(Boolean),
1208
- )
1209
- if (!inRange.size) return undefined
1210
- const known = new Set([...links.map((l) => l.branch), ...merged.map((n) => `${branch}-${n}`)])
1211
- const refs = await git("for-each-ref", "--format=%(objectname) %(refname)", "refs/heads/")
1212
- for (const line of refs.split("\n")) {
1213
- const [sha, ref] = line.trim().split(/\s+/)
1214
- const b = ref?.replace(/^refs\/heads\//, "")
1215
- if (sha && b && inRange.has(sha) && !known.has(b))
1216
- return `Branch ${b} points into the commits the restack rebases, so git rebase --update-refs would move it too; nothing changed. Move or delete it, then /gtd ship.`
1217
- }
1218
- return undefined
1219
- }
1220
-
1221
- const lines = (...parts: (string | undefined)[]) => parts.filter(Boolean).join("\n")
1222
-
1223
- type Rebased = {
1224
- links: Link[]
1225
- branch: string
1226
- rebase: string
1227
- pre: Map<string, string>
1228
- preHead: string
1229
- undo: string | undefined
1230
- }
1231
-
1232
- // A non-zero `git rebase`: a conflict is aborted; no rebase dir means either git
1233
- // older than 2.38 refused --update-refs, or every pick finished and only the final
1234
- // ref update failed, so anything that moved is put back.
1235
- async function rebaseFailed(
1236
- io: ShipIo,
1237
- git: Git,
1238
- ok: Ok,
1239
- x: Rebased & { done: Ran },
1240
- ): Promise<string> {
1241
- const { done, links, branch, rebase, pre, preHead, undo } = x
1242
- const stopped = await git("rev-parse", "--verify", "--quiet", "REBASE_HEAD")
1243
- const dir = await git("rev-parse", "--git-path", "rebase-merge")
1244
- if (!dir || (await io.run(["test", "-d", dir])).code !== 0) {
1245
- let moved = (await git("rev-parse", "HEAD")) !== preHead
1246
- for (const [b, sha] of pre)
1247
- if ((await git("rev-parse", `refs/heads/${b}`)) !== sha) moved = true
1248
- if (!moved) return lines(`git rebase failed; refs are unchanged.\n${done.err}`.trimEnd(), undo)
1249
- const failed = await rollback(git, ok, pre, preHead)
1250
- if (failed.length)
1251
- return rollbackFailed("git rebase failed after moving refs", failed, undo, done.err.trimEnd())
1252
- return lines(
1253
- `git rebase failed after moving refs; the slice branches and HEAD are restored and nothing was pushed or recorded.\n${done.err}`.trimEnd(),
1254
- undo,
1255
- )
1256
- }
1257
- const aborted = await io.run(["git", "rebase", "--abort"])
1258
- const name =
1259
- links.find((l) => l.branch !== branch && l.commit === stopped)?.name ?? links.at(-1)!.name
1260
- const byHand = `Rebase by hand, which restacks every slice branch:\n${rebase}\nthen /gtd ship.`
1261
- if (aborted.code === 0)
1262
- return lines(
1263
- `Slice ${name} failed at restack: git rebase conflicted; tree and refs are as before.`,
1264
- byHand,
1265
- undo,
1266
- )
1267
- return lines(
1268
- `Slice ${name} failed at restack: git rebase conflicted, and git rebase --abort failed too: the repository is mid-rebase. Run git rebase --abort by hand, then rebase by hand, which restacks every slice branch:\n${rebase}\nthen /gtd ship.\n${aborted.err}`.trimEnd(),
1269
- undo,
1270
- )
1271
- }
1272
-
1273
- async function shapeRefusal(
1274
- git: Git,
1275
- ok: Ok,
1276
- x: Rebased & { onto: string },
1277
- ): Promise<string | undefined> {
1278
- const { links, branch, onto, pre, preHead, undo } = x
1279
- const broken = await brokenShape(git, links, branch, onto)
1280
- if (broken === undefined) return undefined
1281
- const why = `Slice ${broken} keeps no commit of its own after the restack (its change is already in ${onto})`
1282
- const failed = await rollback(git, ok, pre, preHead)
1283
- if (failed.length) return rollbackFailed(why, failed, undo)
1284
- return lines(
1285
- `${why}; the slice branches and HEAD are restored and nothing was pushed or recorded. Ship the branch alone with GTD_SHIP_MODE=commits.`,
1286
- undo,
1287
- )
1288
- }
1289
-
1290
- // The rebase target is origin's copy: the local default branch is never touched.
1291
- async function restackOf(
1292
- io: ShipIo,
1293
- git: Git,
1294
- ok: Ok,
1295
- c: {
1296
- branch: string
1297
- baseBranch: string
1298
- from: string
1299
- links: Link[]
1300
- merged: string[]
1301
- isDry: boolean
1302
- },
1303
- ): Promise<Restack | string | undefined> {
1304
- const { branch, baseBranch, from, isDry } = c
1305
- const onto = `origin/${baseBranch}`
1306
- if (!isDry) {
1307
- const refspec = `+refs/heads/${baseBranch}:refs/remotes/origin/${baseBranch}`
1308
- const fetched = await io.run(["git", "fetch", "origin", refspec])
1309
- if (fetched.code !== 0)
1310
- return `git fetch origin ${refspec} failed; nothing changed.\n${fetched.err}`.trimEnd()
1311
- }
1312
- // A merge commit keeps the slice's commit: only the retarget is left to do.
1313
- if (await ok("merge-base", "--is-ancestor", from, onto)) return undefined
1314
- const lower = c.links.filter((l) => l.branch !== branch).map((l) => l.branch)
1315
- if (lower.length) {
1316
- const held = await checkedOutElsewhere(io, lower)
1317
- if (typeof held === "string") return held
1318
- if (held)
1319
- return `Slice branch ${held.branch} is checked out in worktree ${held.path}, so git rebase --update-refs would skip it; nothing changed. Free it there (git -C '${held.path}' switch --detach), then /gtd ship.`
1320
- }
1321
- const stray = await strayBranch(git, c.links, c.merged, from, branch)
1322
- if (stray) return stray
1323
- const links = await originLeases(io, ok, c.links, (l, sha) =>
1324
- ok("merge-base", "--is-ancestor", sha, l.commit),
1325
- )
1326
- if (typeof links === "string") return links
1327
- const rebase = `git rebase --update-refs --onto ${onto} ${from} ${branch}`
1328
- const preview = [
1329
- `Restack: would run ${rebase} (planned from the last fetch of ${onto})`,
1330
- ...pushLines(links),
1331
- ]
1332
- const run = async (preShip?: string) => {
1333
- const undo = preShip && `the squash was kept; undo it with git reset --soft ${preShip}`
1334
- const preHead = await git("rev-parse", "HEAD")
1335
- const pre = new Map<string, string>()
1336
- for (const b of lower) pre.set(b, await git("rev-parse", `refs/heads/${b}`))
1337
- const done = await io.run(["git", "rebase", "--update-refs", "--onto", onto, from, branch])
1338
- const x = { links, branch, rebase, pre, preHead, undo }
1339
- if (done.code !== 0) return rebaseFailed(io, git, ok, { ...x, done })
1340
- const failure = await unmovedSlice(io, git, ok, { lower, onto, from, pre, preHead, undo })
1341
- if (failure) return failure
1342
- const broken = await shapeRefusal(git, ok, { ...x, onto })
1343
- if (broken) return broken
1344
- return restacked(io, git, branch, baseBranch, links)
1345
- }
1346
- return { preview, run }
1347
- }
1348
-
1349
- // The record shrinks to the remaining slices, before any push: a failed push then
1350
- // resumes from a record that matches the rebased refs.
1351
- async function restacked(io: ShipIo, git: Git, branch: string, baseBranch: string, links: Link[]) {
1352
- // Before the record shrinks: a push rejected after it must keep its lease on
1353
- // retry, or a fetch + re-ship overwrites a collaborator's commit.
1354
- for (const l of links) {
1355
- if (!l.lease) continue
1356
- const kept = await io.run(["git", "config", "--local", LEASE_KEY(l.branch), l.lease])
1357
- if (kept.code !== 0)
1358
- return `git config failed after the restack; the branches are rebased and /gtd ship picks that up.\n${kept.err}`.trimEnd()
1359
- }
1360
- const moved: Link[] = []
1361
- for (const l of links)
1362
- moved.push({
1363
- ...l,
1364
- commit: await git("rev-parse", l.branch === branch ? "HEAD" : `refs/heads/${l.branch}`),
1365
- })
1366
- const floor =
1367
- (await git("merge-base", `origin/${baseBranch}`, `refs/heads/${moved[0]!.branch}`)) || undefined
1368
- // One slice left ends the stack, through the usual retire-then-forget path.
1369
- if (moved.length === 1) return { links: moved, floor, forget: true }
1370
- const above = moved.at(-2)!.branch
1371
- const first = (
1372
- await git("rev-list", "--reverse", `refs/heads/${above}..refs/heads/${branch}`)
1373
- ).split("\n")[0]!
1374
- // The record first: a failed top write leaves a stale top that topGuard still
1375
- // guards, where the reverse order leaves the new top with the old names.
1376
- const written = await io.run([
1377
- "git",
1378
- "config",
1379
- "--local",
1380
- STACK_KEY(branch),
1381
- moved.map((l) => l.name).join(" "),
1382
- ])
1383
- const set =
1384
- written.code !== 0
1385
- ? written
1386
- : await io.run(["git", "config", "--local", TOP_KEY(branch), first])
1387
- if (set.code !== 0)
1388
- return `git config failed after the restack; the branches are rebased and /gtd ship picks that up.\n${set.err}`.trimEnd()
1389
- return { links: moved, floor, forget: false }
1390
- }
1391
-
1392
- // A re-shipped top spans several commits, so `<lowest>~1` is not on origin: the
1393
- // first commit above it must instead be the recorded top's copy, by author, date
1394
- // and message (a rebase keeps those, a patch-id would not survive a hand-resolved
1395
- // conflict). Any linear branch passes a bare parent test, so that alone would loosen
1396
- // the guard against an unrelated rebase.
1397
- async function isRecordedTop(git: Git, ok: Ok, onto: string, lowest: string, top: string) {
1398
- if (!top) return false
1399
- const first = (await git("rev-list", "--reverse", `${onto}..refs/heads/${lowest}`)).split("\n")[0]
1400
- if (!first || !(await ok("merge-base", "--is-ancestor", `${first}^`, onto))) return false
1401
- const identity = (sha: string) => git("log", "-1", `--format=${IDENTITY}`, sha)
1402
- const mine = await identity(first)
1403
- return mine !== "" && mine === (await identity(top))
1404
- }
1405
-
1406
- // Someone rebased by hand after a conflict. Every condition must hold, or an
1407
- // unrelated rebase would turn into a force push: the merged slice's commit is
1408
- // off the lowest remaining branch, origin/<default> is under it, and origin's
1409
- // copies hold nothing the local branch lacks. Anything else keeps the
1410
- // drop-and-ship-alone rule.
1411
- async function handRestack(
1412
- io: ShipIo,
1413
- git: Git,
1414
- ok: Ok,
1415
- c: {
1416
- branch: string
1417
- baseBranch: string
1418
- top: string
1419
- from: string
1420
- links: Link[]
1421
- plan: boolean
1422
- },
1423
- ): Promise<Resumed | string | undefined> {
1424
- const { branch, baseBranch, top, from, links: slices } = c
1425
- const lowest = slices[0]!.branch
1426
- const lowestRef = `refs/heads/${lowest}`
1427
- if (await ok("merge-base", "--is-ancestor", from, lowestRef)) return undefined
1428
- // origin may have moved on since the hand rebase: the lowest branch only has
1429
- // to sit on a commit of origin/<default>.
1430
- const onto = `origin/${baseBranch}`
1431
- if (
1432
- !(await ok("merge-base", "--is-ancestor", `${lowestRef}~1`, onto)) &&
1433
- !(slices.length === 1 && (await isRecordedTop(git, ok, onto, lowest, top)))
1434
- )
1435
- return undefined
1436
- // A liveness probe needs only the shape: origin is not asked.
1437
- if (!c.plan) return { links: slices, forget: slices.length === 1 }
1438
- const links = await originLeases(io, ok, slices, (l, sha) =>
1439
- keptByHand(git, ok, { branch: l.branch, sha, top, from, onto }),
1440
- )
1441
- if (typeof links === "string") return links
1442
- const restack: Restack = {
1443
- preview: [
1444
- `Restack: spotted a hand restack of ${links.map((l) => l.branch).join(", ")}; no rebase`,
1445
- ...pushLines(links),
1446
- ],
1447
- run: () => restacked(io, git, branch, baseBranch, links),
1448
- }
1449
- const floor = (await git("merge-base", onto, `refs/heads/${links[0]!.branch}`)) || undefined
1450
- return { links, floor, forget: links.length === 1, restack }
1451
- }
1452
-
1453
- const IDENTITY = "%an%x00%ae%x00%at%x00%B"
1454
-
1455
- // The pre-rebase tips are gone, and the reflog also holds commits pulled in and
1456
- // dropped on purpose: origin's copy passes only when every commit it holds
1457
- // beyond the recorded top (or the merge base, once that top is gone) is in the
1458
- // local branch by identity and diff (index and hunk-offset lines ignored), a
1459
- // rebase keeping author, date, message and patch. The merged slice's commits
1460
- // are expected to be gone. git prints each patch after its header, so the
1461
- // separator leads the header to keep a commit and its patch in one entry.
1462
- async function keptByHand(
1463
- git: Git,
1464
- ok: Ok,
1465
- c: { branch: string; sha: string; top: string; from: string; onto: string },
1466
- ): Promise<boolean> {
1467
- const { branch, sha, top, from, onto } = c
1468
- const local = `refs/heads/${branch}`
1469
- const lower = (await ok("cat-file", "-e", top)) ? top : await git("merge-base", sha, local)
1470
- if (!lower) return false
1471
- const identities = async (...range: string[]) =>
1472
- (await git("log", "-p", "--no-color", `--format=%x1e${IDENTITY}`, ...range))
1473
- .split("\x1e")
1474
- .map((i) => i.replace(/^index .*\n|^(@@) .* @@/gm, "$1").trim())
1475
- .filter(Boolean)
1476
- const mine = new Set(await identities(`${onto}..${local}`))
1477
- const theirs = await identities(sha, `^${lower}`, `^${onto}`, `^${from}`)
1478
- return theirs.every((i) => mine.has(i))
1479
- }
1480
-
1481
- const baseOf = (links: Link[], i: number, baseBranch: string) =>
1482
- i ? links[i - 1]!.branch : baseBranch
1483
-
1484
- async function stackPreview(
1485
- io: ShipIo,
1486
- links: Link[],
1487
- baseBranch: string,
1488
- messages: string[] | undefined,
1489
- history?: string,
1490
- restack?: Restack,
1491
- ): Promise<string | { refusal: string }> {
1492
- const parts: string[] = restack ? [restack.preview.join("\n")] : []
1493
- for (const [i, l] of links.entries()) {
1494
- const base = baseOf(links, i, baseBranch)
1495
- const view = await viewPr(io, l.branch)
1496
- if (typeof view === "string") return { refusal: `${view} Nothing changed.` }
1497
- const lines = [`[${l.name}]`]
1498
- if (messages?.[i]) lines.push(`Commit message:\n\n${messages[i]}`)
1499
- lines.push(`Branch: ${l.branch}`)
1500
- if (view) {
1501
- lines.push(`Pull request: would refresh ${view.url}.`)
1502
- if (view.baseRefName && view.baseRefName !== base)
1503
- lines.push(`Retarget: would retarget #${view.number} from ${view.baseRefName} to ${base}.`)
1504
- } else lines.push(`Pull request: would open into ${base}.`)
1505
- parts.push(lines.join("\n"))
1506
- }
1507
- if (history) parts.push(`History: would push ${history} to origin.`)
1508
- return parts.join("\n\n---\n\n")
1509
- }
1510
-
1511
- const STACKED_ON = /^Stacked on #\d+ — merge that first(?:\r?\n(?:\r?\n)?|$)/
1512
-
1513
- export function stackLine(body: string, below?: number) {
1514
- const rest = body.replace(STACKED_ON, "")
1515
- return below === undefined ? rest : `Stacked on #${below} — merge that first\n\n${rest}`
1516
- }
1517
-
1518
- type Pr = Shipped & { number?: number }
1519
-
1520
- async function sliceStep(
1521
- io: ShipIo,
1522
- git: Git,
1523
- ok: Ok,
1524
- l: Link,
1525
- view: View | undefined,
1526
- base: string,
1527
- lowerRef: string,
1528
- ctx: Ctx,
1529
- ): Promise<Pr | { step: string; why: string }> {
1530
- let done: Pr
1531
- if (!view) done = await createPr(io, git, l.branch, base, lowerRef, ctx)
1532
- else if (view.state !== "OPEN")
1533
- return { step: "pull request", why: `#${view.number} is ${view.state}, not open.` }
1534
- else {
1535
- // Before the thrown split: a thrown draft keeps its THROWN marker if this fails.
1536
- if (view.baseRefName !== base) {
1537
- const moved = await io.run(["gh", "pr", "edit", String(view.number), "--base", base])
1538
- if (moved.code !== 0)
1539
- return {
1540
- step: "retarget",
1541
- why: `gh pr edit --base ${base} failed.\n${moved.err}`.trimEnd(),
1542
- }
1543
- }
1544
- done = view.body?.includes(THROWN)
1545
- ? await createPr(io, git, l.branch, base, lowerRef, ctx, view)
1546
- : await updatePr(io, git, ok, l.branch, ctx, view)
1547
- }
1548
- return done.ok ? done : { step: "pull request", why: done.text }
1549
- }
1550
-
1551
- // The writer turns below may touch refs: push only the commit ship built.
1552
- async function pushLink(io: ShipIo, git: Git, l: Link, upper: boolean) {
1553
- if (upper && (await git("rev-parse", `refs/heads/${l.branch}`)) !== l.commit)
1554
- return `refs/heads/${l.branch} no longer points at ${short(l.commit)}.`
1555
- return pushBranch(io, l.branch, l.commit, l.lease, l.fresh)
1556
- }
1557
-
1558
- async function shipLink(
1559
- io: ShipIo,
1560
- git: Git,
1561
- ok: Ok,
1562
- links: Link[],
1563
- i: number,
1564
- baseBranch: string,
1565
- baseRef: string,
1566
- mergeBase: string,
1567
- below: number | undefined,
1568
- ): Promise<Pr | { step: string; why: string }> {
1569
- const l = links[i]!
1570
- const lower = i ? links[i - 1]! : undefined
1571
- const base = baseOf(links, i, baseBranch)
1572
- const read = await readPr(io, l.branch)
1573
- if (typeof read === "string")
1574
- return { step: "pull request", why: `${read} ${l.branch} was not pushed; /gtd ship retries.` }
1575
- const { view, gone } = read
1576
- const marked = await abandon(io, l.branch, read.abandoned)
1577
- if (marked) return { step: "push", why: marked }
1578
- const pushed = await pushLink(io, git, { ...l, fresh: l.fresh || gone }, i < links.length - 1)
1579
- if (pushed) return { step: "push", why: pushed }
1580
- const lowerRef = lower?.branch ?? baseRef
1581
- const ctx: Ctx = {
1582
- to: l.commit,
1583
- from: lower?.commit ?? mergeBase,
1584
- below,
1585
- stacked: true,
1586
- lower: i < links.length - 1,
1587
- floor: mergeBase,
1588
- }
1589
- const done = await sliceStep(io, git, ok, l, view, base, lowerRef, ctx)
1590
- if ("step" in done) return done
1591
- if (!view) await settled(io, l.branch, done)
1592
- if (done.number === undefined && i < links.length - 1)
1593
- return { step: "pull request", why: "its number could not be read from gh's reply." }
1594
- return done
1595
- }
1596
-
1597
- // Bottom first, so each PR can name the number of the one below it. A failure
1598
- // keeps every slice that already landed; /gtd ship resumes from the recorded stack.
1599
- async function stackPullRequests(
1600
- io: ShipIo,
1601
- git: Git,
1602
- ok: Ok,
1603
- links: Link[],
1604
- baseBranch: string,
1605
- baseRef: string,
1606
- floor?: string,
1607
- ): Promise<Shipped> {
1608
- const mergeBase = floor ?? (await git("merge-base", baseRef, "HEAD"))
1609
- if (!mergeBase) return fail(`${baseRef} and HEAD share no ancestor.`)
1610
- const landed: string[] = []
1611
- const texts: string[] = []
1612
- let below: number | undefined
1613
- for (const [i, l] of links.entries()) {
1614
- const stop = (step: string, why: string) =>
1615
- fail(
1616
- `Slice ${l.name} failed at ${step}: ${why}\nLanded: ${landed.length ? landed.join(", ") : "none"}. /gtd ship resumes.`,
1617
- )
1618
- const done = await shipLink(io, git, ok, links, i, baseBranch, baseRef, mergeBase, below)
1619
- if ("step" in done) return stop(done.step, done.why)
1620
- below = done.number
1621
- landed.push(l.name)
1622
- texts.push(`[${l.name}] ${done.text}`)
1623
- }
1624
- return { ok: true, text: texts.join("\n\n") }
1625
- }
1626
-
1627
289
  async function dropProcess(
1628
290
  io: ShipIo,
1629
291
  ok: Ok,
@@ -1675,59 +337,6 @@ async function commitSquash(
1675
337
  return { ok: true, text: "" }
1676
338
  }
1677
339
 
1678
- // A slice tip's tree, with `.gtd/` as HEAD holds it: steering files are
1679
- // process-private, and a mid-process tip still carries some. Null on failure.
1680
- async function sliceTree(io: ShipIo, git: Git, tip: string): Promise<string | null> {
1681
- const gtdOf = (rev: string) => git("rev-parse", "--verify", "--quiet", `${rev}:.gtd`)
1682
- if ((await gtdOf(tip)) === (await gtdOf("HEAD"))) return `${tip}^{tree}`
1683
- const entries = (await io.run(["git", "ls-tree", "-z", tip])).out
1684
- .split("\0")
1685
- .filter((e) => e && e.split("\t")[1] !== ".gtd")
1686
- const gtd = (await io.run(["git", "ls-tree", "-z", "HEAD", ".gtd"])).out.split("\0")[0]
1687
- if (gtd) entries.push(gtd)
1688
- const made = await io.run(["git", "mktree", "-z"], entries.map((e) => `${e}\0`).join(""))
1689
- return made.code === 0 && made.out.trim() ? made.out.trim() : null
1690
- }
1691
-
1692
- // Every commit exists before any ref moves, so a failure part-way changes nothing.
1693
- // commit-tree skips commit-msg hooks, but signing is carried over.
1694
- async function commitChain(
1695
- io: ShipIo,
1696
- git: Git,
1697
- ok: Ok,
1698
- groups: Group[],
1699
- fulls: string[],
1700
- ): Promise<Shipped & { commits: string[] }> {
1701
- let prev = groups[0]!.base
1702
- const commits: string[] = []
1703
- const sign = (await git("config", "--type=bool", "--get", "commit.gpgSign")) === "true"
1704
- for (const [i, g] of groups.entries()) {
1705
- const clean = await io.run(["git", "stripspace", "--strip-comments"], fulls[i]! + "\n")
1706
- if (clean.code !== 0)
1707
- return { ...fail(`git stripspace failed; nothing changed.\n${clean.err}`), commits }
1708
- const tree = await sliceTree(io, git, g.tip)
1709
- if (!tree)
1710
- return { ...fail(`git mktree failed for slice ${g.name}; nothing changed.`), commits }
1711
- const made = await io.run(
1712
- ["git", "commit-tree", tree, ...(sign ? ["-S"] : []), "-p", prev, "-F", "-"],
1713
- clean.out,
1714
- )
1715
- prev = made.out.trim()
1716
- if (made.code !== 0 || !prev)
1717
- return {
1718
- ...fail(`git commit-tree failed for slice ${g.name}; nothing changed.\n${made.err}`),
1719
- commits,
1720
- }
1721
- commits.push(prev)
1722
- }
1723
- if (!(await ok("reset", "--soft", prev)))
1724
- return { ...fail("git reset failed; nothing changed."), commits }
1725
- io.log(
1726
- `Squashed into ${groups.length} commits: ${await git("log", "-1", "--format=%h %s")}. Undo with git reset --soft ORIG_HEAD.`,
1727
- )
1728
- return { ok: true, text: "", commits }
1729
- }
1730
-
1731
340
  async function historyRef(io: ShipIo, tip: string) {
1732
341
  const list = await io.run(["gtd", "history", "list", "--json"])
1733
342
  let entries: { ref?: string; entry?: string; tip?: string }[] = []
@@ -1760,11 +369,11 @@ async function preview(
1760
369
  baseRef: string,
1761
370
  message: string,
1762
371
  history?: string,
1763
- warning?: string,
1764
372
  ): Promise<Shipped> {
1765
373
  const mergeBase = await git("merge-base", baseRef, "HEAD")
1766
- const existing = await viewPr(io, branch)
1767
- if (typeof existing === "string") return fail(`${existing} Nothing changed.`)
374
+ const read = await readPr(io, branch)
375
+ if (typeof read === "string") return fail(`${read} Nothing changed.`)
376
+ const existing = read.view
1768
377
  const pr = existing
1769
378
  ? `would refresh ${existing.url}`
1770
379
  : `would open a pull request for ${branch} into ${baseRef} (${await git("rev-list", "--count", `${mergeBase}..HEAD`)} commit(s) before the squash)`
@@ -1772,7 +381,6 @@ async function preview(
1772
381
  ok: true,
1773
382
  text: [
1774
383
  message && `Commit message:\n\n${message}`,
1775
- warning,
1776
384
  history && `History: would push ${history} to origin.`,
1777
385
  `Pull request: ${pr}.`,
1778
386
  ]
@@ -1781,36 +389,6 @@ async function preview(
1781
389
  }
1782
390
  }
1783
391
 
1784
- async function unstack(
1785
- io: ShipIo,
1786
- existing: View,
1787
- baseBranch: string,
1788
- onSlice: boolean,
1789
- strip: boolean,
1790
- edit: boolean,
1791
- ) {
1792
- if (onSlice) {
1793
- const moved = await io.run(["gh", "pr", "edit", String(existing.number), "--base", baseBranch])
1794
- if (moved.code !== 0)
1795
- return fail(
1796
- `gh pr edit --base ${baseBranch} failed; ${existing.url} is unchanged.\n${moved.err}`.trimEnd(),
1797
- )
1798
- }
1799
- const was = existing.body ?? ""
1800
- const body = strip ? stackLine(was) : was
1801
- if (edit && body !== was) {
1802
- const edited = await io.run(
1803
- ["gh", "pr", "edit", String(existing.number), "--body-file", "-"],
1804
- body,
1805
- )
1806
- if (edited.code !== 0)
1807
- return fail(
1808
- `gh pr edit failed; the stale stack line of ${existing.url} is unchanged.\n${edited.err}`.trimEnd(),
1809
- )
1810
- }
1811
- return { unstacked: onSlice || (edit && body !== was), body: edit ? body : was }
1812
- }
1813
-
1814
392
  async function pullRequest(
1815
393
  io: ShipIo,
1816
394
  git: Git,
@@ -1818,7 +396,6 @@ async function pullRequest(
1818
396
  branch: string,
1819
397
  baseBranch: string,
1820
398
  baseRef: string,
1821
- recorded: string[],
1822
399
  ) {
1823
400
  const mergeBase = await git("merge-base", baseRef, "HEAD")
1824
401
  if (!mergeBase) return fail(`${baseRef} and HEAD share no ancestor.`)
@@ -1835,100 +412,35 @@ async function pullRequest(
1835
412
  return fail(`The pull request for ${branch} is ${existing.state}, not open.`)
1836
413
  const marked = await abandon(io, branch, read.abandoned)
1837
414
  if (marked) return fail(`${marked} ${branch} was not pushed; /gtd ship retries.`)
1838
- const pushed = await pushBranch(io, branch, "HEAD", undefined, gone)
415
+ const pushed = await pushBranch(io, branch, gone)
1839
416
  if (pushed) return fail(pushed)
1840
- if (!existing)
1841
- return settled(
1842
- io,
1843
- branch,
1844
- await createPr(io, git, branch, baseBranch, baseRef, { from: mergeBase, to: "HEAD" }),
1845
- )
1846
- // A branch once shipped as a stack keeps the PR aimed at a slice branch; any
1847
- // other base is the author's own and stays.
1848
- const onSlice = recorded.some((n) => existing.baseRefName === `${branch}-${n}`)
1849
417
  const ctx: Ctx = { from: mergeBase, to: "HEAD" }
418
+ if (!existing)
419
+ return settled(io, branch, await createPr(io, git, branch, baseBranch, baseRef, ctx))
1850
420
  // A thrown draft only ever described a hand-off: ship writes it properly.
1851
- const thrown = existing.body?.includes(THROWN)
1852
- // gtd's own line is safe to strip on the default branch too: GitHub retargets
1853
- // there itself when a lower PR merges.
1854
- const strip = onSlice || existing.baseRefName === baseBranch
1855
- const fixed = await unstack(io, existing, baseBranch, onSlice, strip, !thrown)
1856
- if ("ok" in fixed) return fixed
1857
- if (thrown) return createPr(io, git, branch, baseBranch, baseRef, ctx, existing)
1858
- return updatePr(
1859
- io,
1860
- git,
1861
- ok,
1862
- branch,
1863
- { ...ctx, unstacked: fixed.unstacked },
1864
- { ...existing, body: fixed.body },
1865
- )
421
+ if (existing.body?.includes(THROWN))
422
+ return createPr(io, git, branch, baseBranch, baseRef, ctx, existing)
423
+ return updatePr(io, git, ok, branch, ctx, existing)
1866
424
  }
1867
425
 
1868
426
  // A pull request describes what the remote branch holds. A squash rewrote the
1869
- // branch, so an existing origin/<branch> needs --force-with-lease. Returns why
1870
- // the push failed, or nothing.
1871
- export async function pushBranch(
1872
- io: ShipIo,
1873
- branch: string,
1874
- head = "HEAD",
1875
- lease?: string,
1876
- fresh = false,
1877
- ) {
427
+ // branch, so an existing origin/<branch> needs --force-with-lease. `fresh`:
428
+ // origin lacks the branch, so any tracking ref is stale. Returns why the push
429
+ // failed, or nothing.
430
+ export async function pushBranch(io: ShipIo, branch: string, fresh = false) {
1878
431
  const ok = async (...args: string[]) => (await io.run(["git", ...args])).code === 0
1879
- // A rejected restack push persisted its lease: a fetch moves the tracking ref, so
1880
- // only the stored sha still tells a collaborator's commit from ours.
1881
- let stored = (await io.run(["git", "config", "--local", "--get", LEASE_KEY(branch)])).out.trim()
1882
- let kept = lease || fresh ? "" : stored
1883
- let gone = fresh
1884
- if (kept) {
1885
- // The key may be days old: origin's own sha decides whether it still applies.
1886
- const read = await io.run(["git", "ls-remote", "origin", `refs/heads/${branch}`])
1887
- const at = read.code === 0 ? read.out.trim().split(/\s+/)[0] : undefined
1888
- if (read.code === 0 && !at) {
1889
- // Origin lost the branch: nothing to lease on, so push it fresh.
1890
- await io.run(["git", "config", "--local", "--unset", LEASE_KEY(branch)])
1891
- kept = stored = ""
1892
- gone = true
1893
- } else if (
1894
- at &&
1895
- (await ok("cat-file", "-e", at)) &&
1896
- (await ok("merge-base", "--is-ancestor", at, head))
1897
- )
1898
- // Reconciled: origin's commit is already in what is pushed.
1899
- lease = at
1900
- }
1901
- lease ??= kept || undefined
1902
432
  const push = async (args: string[], failure: string) => {
1903
433
  const r = await attempt(io, ["git", "push", ...args])
1904
434
  if (r.killed)
1905
435
  return `git push of ${branch} did not finish; nothing was lost. Retry without fetching.\n${r.err}`.trimEnd()
1906
- if (r.code !== 0)
1907
- return (
1908
- `${failure}\n${r.err}`.trimEnd() +
1909
- (kept && /stale info|rejected/.test(r.err)
1910
- ? `\nTo overwrite origin's copy on purpose, run git config --local --unset ${LEASE_KEY(branch)} first.`
1911
- : "")
1912
- )
1913
- // Any success settles the key, the restack's own explicit-lease push included:
1914
- // a stale sha left behind would reject every later rewrite of the branch.
1915
- if (stored) await io.run(["git", "config", "--local", "--unset", LEASE_KEY(branch)])
436
+ if (r.code !== 0) return `${failure}\n${r.err}`.trimEnd()
1916
437
  return undefined
1917
438
  }
1918
- // A restack rewrote the branch: lease on origin's sha (it exists, whatever the
1919
- // tracking ref says), with no ancestor shortcut.
1920
- if (lease) {
1921
- io.log(`Pushing ${branch} to origin…`)
1922
- return push(
1923
- [`--force-with-lease=${branch}:${lease}`, "origin", branch],
1924
- `git push failed: origin/${branch} moved. Pull or reconcile ${branch}, then ship.`,
1925
- )
1926
- }
1927
- if (gone || !(await ok("rev-parse", "--verify", "--quiet", `origin/${branch}`))) {
439
+ if (fresh || !(await ok("rev-parse", "--verify", "--quiet", `origin/${branch}`))) {
1928
440
  io.log(`Pushing ${branch} to origin…`)
1929
441
  return push(["--set-upstream", "origin", branch], "git push failed.")
1930
442
  }
1931
- if (!(await ok("merge-base", "--is-ancestor", head, `origin/${branch}`))) {
443
+ if (!(await ok("merge-base", "--is-ancestor", "HEAD", `origin/${branch}`))) {
1932
444
  io.log(`Pushing ${branch} to origin…`)
1933
445
  return push(
1934
446
  ["--force-with-lease", "origin", branch],
@@ -1953,18 +465,7 @@ const TITLE = /^(feat|fix|refactor|perf|docs|test|build|ci|chore)(\([\w./-]+\))?
1953
465
  // Marks a draft pull request `/gtd throw` opened for a hand-off.
1954
466
  export const THROWN = "<!-- gtd:thrown -->"
1955
467
 
1956
- // unstacked: this run took the PR off a stack, so nothing new is still a success.
1957
- // lower: a stacked slice below the top, whose PR merges first and must close nothing.
1958
- type Ctx = {
1959
- from: string
1960
- to: string
1961
- stacked?: boolean
1962
- below?: number
1963
- unstacked?: boolean
1964
- lower?: boolean
1965
- // The stack's floor: the top PR closes what every slice's commits close.
1966
- floor?: string
1967
- }
468
+ type Ctx = { from: string; to: string }
1968
469
 
1969
470
  // [ \t], never \s: a reference on the next line is not what the keyword closes.
1970
471
  const KEYWORD = String.raw`(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?[ \t]+`
@@ -2004,7 +505,7 @@ async function createPr(
2004
505
  branch: string,
2005
506
  baseBranch: string,
2006
507
  baseRef: string,
2007
- { from: mergeBase, to, stacked, below, lower, floor }: Ctx,
508
+ { from: mergeBase, to }: Ctx,
2008
509
  thrown?: { number: number; url: string },
2009
510
  ): Promise<Shipped & { number?: number }> {
2010
511
  const count = await git("rev-list", "--count", `${mergeBase}..${to}`)
@@ -2061,8 +562,7 @@ ${await git("diff", "--stat", `${mergeBase}..${to}`)}`
2061
562
  if (!TITLE.test(title))
2062
563
  return fail(`The pull-request turn returned no usable title: ${title.slice(0, 120)}`)
2063
564
  const text = rest.join("\n").replace(/^\s*\n/, "")
2064
- const written = lower ? text : withCloses(text, floor ? await closingLog(git, floor, to) : log)
2065
- const body = stacked ? stackLine(written, below) : written
565
+ const body = withCloses(text, log)
2066
566
  const head = await git("rev-parse", to)
2067
567
  if (thrown) {
2068
568
  const n = String(thrown.number)
@@ -2125,26 +625,17 @@ const closingLog = (git: Git, from: string, to: string) =>
2125
625
 
2126
626
  type Existing = { number: number; body?: string; url: string }
2127
627
 
2128
- // The standing description re-derived: the stack line, and the issues the
2129
- // branch closes, re-applied on every refresh but only written when changed.
2130
- async function refresher(
2131
- io: ShipIo,
2132
- git: Git,
2133
- { from: mergeBase, to, stacked, below, lower, floor }: Ctx,
2134
- existing: Existing,
2135
- ) {
628
+ // The issues the branch closes, re-applied on every refresh but only written when changed.
629
+ async function refresher(io: ShipIo, git: Git, { from: mergeBase, to }: Ctx, existing: Existing) {
2136
630
  // The whole branch, not just what is new: a PR shipped before its commits
2137
631
  // closed anything, or whose edit failed after the sync head moved, still gains them.
2138
- const log = lower ? "" : await closingLog(git, floor ?? mergeBase, to)
2139
- const restack = (raw: string) => {
2140
- const body = withCloses(raw, log)
2141
- return stacked ? stackLine(body, below) : body
2142
- }
632
+ const log = await closingLog(git, mergeBase, to)
633
+ const closing = (raw: string) => withCloses(raw, log)
2143
634
  const edit = async (body: string) =>
2144
635
  io.run(["gh", "pr", "edit", String(existing.number), "--body-file", "-"], body)
2145
636
  const was = existing.body ?? ""
2146
- const restacked = async (text: string) => {
2147
- const fixed = restack(was)
637
+ const refreshed = async (text: string) => {
638
+ const fixed = closing(was)
2148
639
  if (fixed === was) return { ok: true, text, number: existing.number }
2149
640
  const edited = await edit(fixed)
2150
641
  if (edited.code !== 0)
@@ -2156,7 +647,7 @@ async function refresher(
2156
647
  }
2157
648
  return { ok: true, text, number: existing.number }
2158
649
  }
2159
- return { restack, edit, restacked, stale: restack(was) !== was }
650
+ return { closing, edit, refreshed, stale: closing(was) !== was }
2160
651
  }
2161
652
 
2162
653
  async function updatePr(
@@ -2167,16 +658,15 @@ async function updatePr(
2167
658
  ctx: Ctx,
2168
659
  existing: Existing,
2169
660
  ): Promise<Shipped & { number?: number }> {
2170
- const { from: mergeBase, to, stacked, unstacked } = ctx
2171
- const { restack, edit, restacked, stale } = await refresher(io, git, ctx, existing)
661
+ const { from: mergeBase, to } = ctx
662
+ const { closing, edit, refreshed, stale } = await refresher(io, git, ctx, existing)
2172
663
  // Only what landed since the last run. A stored head a squash left behind
2173
664
  // falls back to the whole branch, the only honest range left.
2174
665
  const since = await sinceHead(git, ok, branch, mergeBase, to)
2175
666
  // ^mergeBase keeps a synced head from before a main merge from re-weighing
2176
667
  // the commits that merge brought in.
2177
668
  const count = Number(await git("rev-list", "--count", `${since}..${to}`, `^${mergeBase}`))
2178
- if (!count && (stacked || unstacked || stale))
2179
- return restacked(`Nothing new since the last ship: ${existing.url}`)
669
+ if (!count && stale) return refreshed(`Nothing new since the last ship: ${existing.url}`)
2180
670
  if (!count) return fail(`No commits since the last ship: ${existing.url}`)
2181
671
  io.log(`#${existing.number}: weighing ${count} commit(s) against the standing description…`)
2182
672
 
@@ -2220,14 +710,14 @@ ${await git("diff", "--stat", `${mergeBase}..${to}`)}`
2220
710
  const sha = await git("rev-parse", to)
2221
711
  if (/^\s*NO-UPDATE\s*$/.test(out)) {
2222
712
  await io.run(["git", "config", "--local", `branch.${branch}.prSyncHead`, sha])
2223
- return restacked(
713
+ return refreshed(
2224
714
  `Nothing at that level changed; the description of ${existing.url} is left alone.`,
2225
715
  )
2226
716
  }
2227
717
  // Appended, dated and anchored to its commit, so the standing text keeps saying what it said.
2228
718
  const repo = (await io.run(["gh", "repo", "view", "--json", "url", "-q", ".url"])).out.trim()
2229
719
  const anchor = repo ? `[\`${sha.slice(0, 7)}\`](${repo}/commit/${sha})` : `\`${sha.slice(0, 7)}\``
2230
- const body = restack(`${existing.body ?? ""}\n\n## Update ${io.today()} — ${anchor}\n\n${out}\n`)
720
+ const body = closing(`${existing.body ?? ""}\n\n## Update ${io.today()} — ${anchor}\n\n${out}\n`)
2231
721
  const edited = await edit(body)
2232
722
  if (edited.code !== 0)
2233
723
  return fail(`gh pr edit failed; the pull request is unchanged.\n${edited.err}`)