@pmelab/gtd 28.1.0 → 29.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 (54) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +28 -17
  3. package/claude/hooks/assist.ts +1 -1
  4. package/claude/hooks/register.tsx +1 -1
  5. package/claude/hooks/ship.ts +963 -143
  6. package/dist/chunks/{reserved-DeBS4Ymh.mjs → reserved-DoHUII2D.mjs} +206 -121
  7. package/dist/chunks/{tail-B2SER2Ih.mjs → tail-C6YBG5zh.mjs} +19 -12
  8. package/dist/testkit.mjs +2 -1
  9. package/dist/workflow-kit.mjs +51 -21
  10. package/dist/workflows/cleanup.mjs +2 -2
  11. package/dist/workflows/feature.mjs +247 -96
  12. package/dist/workflows/fix.mjs +2 -2
  13. package/dist/workflows/review.mjs +2 -2
  14. package/dist/workflows/rollout.mjs +1 -1
  15. package/dist/workflows.mjs +528 -262
  16. package/package.json +1 -1
  17. package/src/testkit/TestRepo.ts +3 -0
  18. package/src/testkit/index.ts +1 -1
  19. package/src/testkit/steps.ts +44 -0
  20. package/src/workflow-kit/review.test.ts +366 -8
  21. package/src/workflow-kit/review.ts +109 -26
  22. package/src/workflows/feature/feature.test.ts +313 -1
  23. package/src/workflows/feature/feature.ts +48 -43
  24. package/src/workflows/feature/one-step-land.feature +4 -0
  25. package/src/workflows/feature/refactor-first.feature +54 -24
  26. package/src/workflows/feature/refactor-review.feature +1706 -0
  27. package/src/workflows/feature/refactor.test.ts +592 -2
  28. package/src/workflows/feature/refactor.ts +160 -42
  29. package/src/workflows/feature/review.test.ts +322 -5
  30. package/src/workflows/feature/review.ts +77 -10
  31. package/src/workflows/feature/seam-rejection.feature +4 -0
  32. package/src/workflows/feature/seam.feature +3 -4
  33. package/src/workflows/feature/seam.test.ts +196 -1
  34. package/src/workflows/feature/seam.ts +23 -9
  35. package/src/workflows/feature/steps.test.ts +54 -2
  36. package/src/workflows/feature/steps.ts +45 -9
  37. package/src/workflows/feature/tier.test.ts +4 -0
  38. package/src/workflows/feature/tier.ts +2 -20
  39. package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +97 -0
  40. package/src/workflows/shared/access.test.ts +31 -1
  41. package/src/workflows/shared/access.ts +7 -1
  42. package/src/workflows/shared/fences.ts +19 -0
  43. package/src/workflows/shared/health.ts +32 -12
  44. package/src/workflows/shared/options.test.ts +18 -0
  45. package/src/workflows/shared/options.ts +1 -0
  46. package/src/workflows/shared/prose.ts +4 -2
  47. package/src/workflows/shared/skills.test.ts +20 -1
  48. package/src/workflows/shared/skills.ts +43 -32
  49. package/src/workflows/shared/steps.ts +33 -19
  50. package/src/workflows/shared/tail.test.ts +84 -1
  51. package/src/workflows/shared/tail.ts +30 -12
  52. package/src/workflows/shared/text.test.ts +266 -4
  53. package/src/workflows/shared/text.ts +183 -70
  54. package/src/workflows/shared/vars.ts +12 -2
@@ -1,7 +1,3 @@
1
- // Squash the finished gtd process into one commit, push, and open or refresh
2
- // the branch's pull request. A port of the `ship` fish function: same steps,
3
- // same prompts, with the text turns answered by a subagent of the session.
4
-
5
1
  export type Ran = { code: number; out: string; err: string }
6
2
 
7
3
  export type ShipIo = {
@@ -50,10 +46,51 @@ export function costTrailers(log: string) {
50
46
  .map(([model, n]) => `Gtd-Cost: ${Number(n.toPrecision(10))} ${model}`)
51
47
  }
52
48
 
53
- // `gtd summary` is the only place gtd names the process's start parent.
54
- // `gtd base` is the review anchor and would squash only part of the process.
55
- const summaryRange = (summary: string) =>
56
- /git log ([0-9a-f]{7,64})\.\.([0-9a-f]{7,64})/.exec(summary)?.slice(1, 3)
49
+ export type ShipMode = "stack" | "commits"
50
+
51
+ export function shipMode(raw: string | undefined): ShipMode | { refusal: string } {
52
+ if (!raw) return "stack"
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
+ }
57
94
 
58
95
  const COMMIT_FORMAT = `
59
96
  --- MESSAGE FORMAT (overrides any formatting the instructions above imply) ---
@@ -114,50 +151,223 @@ const attempt = async (io: ShipIo, argv: string[]): Promise<Ran & { killed: bool
114
151
  }
115
152
  }
116
153
 
117
- export async function ship(io: ShipIo, isDry: boolean): Promise<Shipped> {
118
- const git = async (...args: string[]) => (await io.run(["git", ...args])).out.trim()
119
- const ok = async (...args: string[]) => (await io.run(["git", ...args])).code === 0
154
+ async function resolveRef(ok: Ok, baseBranch: string): Promise<string | undefined> {
155
+ for (const candidate of [baseBranch, `origin/${baseBranch}`]) {
156
+ if (await ok("rev-parse", "--verify", "--quiet", candidate)) return candidate
157
+ }
158
+ return undefined
159
+ }
120
160
 
161
+ async function preflight(git: Git, ok: Ok) {
121
162
  const branch = await git("rev-parse", "--abbrev-ref", "HEAD")
122
- if (!branch || branch === "HEAD") return fail("Detached HEAD: there is no branch to ship.")
163
+ if (!branch || branch === "HEAD") return { refusal: "Detached HEAD: there is no branch to ship." }
123
164
  const head = await git("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD")
124
165
  const baseBranch = head.replace(/^origin\//, "") || "main"
125
- if (branch === baseBranch) return fail(`You are on the default branch (${baseBranch}).`)
126
- let baseRef: string | undefined
127
- for (const candidate of [baseBranch, `origin/${baseBranch}`]) {
128
- if (await ok("rev-parse", "--verify", "--quiet", candidate)) {
129
- baseRef = candidate
130
- break
131
- }
132
- }
133
- if (!baseRef) return fail(`There is no ref '${baseBranch}' (tried origin/${baseBranch} too).`)
166
+ if (branch === baseBranch) return { refusal: `You are on the default branch (${baseBranch}).` }
167
+ const baseRef = await resolveRef(ok, baseBranch)
168
+ if (!baseRef)
169
+ return { refusal: `There is no ref '${baseBranch}' (tried origin/${baseBranch} too).` }
134
170
  // `reset --soft` would fold pending changes into the squash without saying so.
135
171
  if (await git("status", "--porcelain"))
136
- return fail("The working tree is dirty. Commit or stash first.")
172
+ return { refusal: "The working tree is dirty. Commit or stash first." }
173
+ return { branch, baseBranch, baseRef }
174
+ }
175
+
176
+ export async function ship(io: ShipIo, isDry: boolean, rawMode?: string): Promise<Shipped> {
177
+ const mode = shipMode(rawMode)
178
+ if (typeof mode === "object") return fail(mode.refusal)
179
+ const git = async (...args: string[]) => (await io.run(["git", ...args])).out.trim()
180
+ const ok = async (...args: string[]) => (await io.run(["git", ...args])).code === 0
181
+
182
+ const pre = await preflight(git, ok)
183
+ if ("refusal" in pre) return fail(pre.refusal)
184
+ const { branch, baseBranch, baseRef } = pre
137
185
 
138
- const squashed = await squash(io, git, ok, branch, isDry)
186
+ // Read before any squash: the record names the slice branches a PR may sit on.
187
+ // A retired record (see below) counts too, so a retry still retargets the PR.
188
+ const live = names(await git("config", "--local", "--get", STACK_KEY(branch)))
189
+ const retired = names(await git("config", "--local", "--get", RETIRED_KEY(branch)))
190
+ const recorded = [...live, ...retired]
191
+ const squashed = await squash(io, git, ok, branch, isDry, mode)
139
192
  if (!squashed.ok) return squashed
140
- if (isDry) return preview(io, git, branch, baseRef, squashed.text, squashed.history)
141
- return pullRequest(io, git, ok, branch, baseBranch, baseRef)
193
+ // Until the PR step succeeds the record only moves aside: a retry must not
194
+ // resume the stack this ship dropped, yet must still find its slice bases.
195
+ if (squashed.forget && live.length && !isDry) await retireStack(io, branch, live)
196
+ const { stack, floor } = squashed
197
+ if (stack && isDry) {
198
+ const text = await stackPreview(io, stack, baseBranch, squashed.messages, squashed.history)
199
+ if (typeof text !== "string") return fail(text.refusal)
200
+ return withWarning({ ok: true, text }, squashed.warning)
201
+ }
202
+ if (isDry)
203
+ return preview(io, git, branch, baseRef, squashed.text, squashed.history, squashed.warning)
204
+ const opened = stack
205
+ ? await stackPullRequests(io, git, ok, stack, baseBranch, baseRef, floor)
206
+ : await pullRequest(io, git, ok, branch, baseBranch, baseRef, recorded)
207
+ // A stale retired record must not take a stack this ship just recorded with it.
208
+ if (opened.ok && squashed.forget) await forgetStack(io, branch)
209
+ else if (opened.ok && retired.length)
210
+ await io.run(["git", "config", "--local", "--unset", RETIRED_KEY(branch)])
211
+ return withWarning(opened, squashed.warning)
212
+ }
213
+
214
+ function withWarning(r: Shipped, warning?: string): Shipped {
215
+ return warning ? { ...r, text: [r.text, warning].filter(Boolean).join("\n\n") } : r
142
216
  }
143
217
 
144
218
  type Git = (...args: string[]) => Promise<string>
145
219
  type Ok = (...args: string[]) => Promise<boolean>
146
220
 
147
- // The process's own commits, base exclusive, and only while it ends at HEAD;
148
- // a string is why ship refuses to squash.
149
- async function processRange(git: Git, summary: string) {
150
- const range = summaryRange(summary)
151
- if (!range) return "The gtd summary names no commit range, so ship refuses to guess a base."
152
- const base = await git("rev-parse", "--verify", `${range[0]}^{commit}`)
153
- const tip = await git("rev-parse", "--verify", `${range[1]}^{commit}`)
154
- if (!base || !tip) return "The gtd summary's range does not resolve in this repository."
155
- if (tip !== (await git("rev-parse", "HEAD"))) {
156
- return `The process tip (${short(tip)}) is not HEAD: something landed since.`
221
+ type Group = { name: string; base: string; tip: string }
222
+
223
+ // Slices that changed a file outside .gtd/ each land a commit; one that did not
224
+ // folds into the next landed slice, or the previous when it is last.
225
+ async function landed(ok: Ok, slices: Slice[]) {
226
+ const groups: Group[] = []
227
+ let carry: string | undefined
228
+ for (const s of slices) {
229
+ // gtd churns its own steering files every process, so `.gtd/` alone is no change.
230
+ if (await ok("diff", "--quiet", s.base, s.tip, "--", ":(exclude).gtd/")) {
231
+ carry ??= s.base
232
+ continue
233
+ }
234
+ groups.push({ name: s.name, base: carry ?? s.base, tip: s.tip })
235
+ carry = undefined
157
236
  }
237
+ if (carry && groups.length) groups.at(-1)!.tip = slices.at(-1)!.tip
238
+ return groups
239
+ }
240
+
241
+ async function rebaseWarning(io: ShipIo) {
242
+ const read = await attempt(io, [
243
+ "gh",
244
+ "repo",
245
+ "view",
246
+ "--json",
247
+ "rebaseMergeAllowed",
248
+ "-q",
249
+ ".rebaseMergeAllowed",
250
+ ])
251
+ if (read.code === 0 && read.out.trim() === "true") return undefined
252
+ 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."
253
+ }
254
+
255
+ // A failure text while the process is not a finished, shippable one.
256
+ async function unfinished(io: ShipIo, slices: Slice[]) {
257
+ const state = async () => (await io.run(["gtd", "next", "--json=state"])).out.trim()
258
+ // `gtd summary` also describes a process still underway; shipping that
259
+ // would publish half of it.
260
+ const open = slices.find((s) => !s.closed)
261
+ if (open)
262
+ return `The gtd process is still underway (slice ${open.name} is open, at ${await state()}). Finish it, then ship.`
263
+ const initial = (await io.run(["gtd", "next", "--json=initial"])).out.trim()
264
+ if (initial !== "true")
265
+ return `The gtd process is still underway (at ${await state()}). Finish it, then ship.`
266
+ return undefined
267
+ }
268
+
269
+ async function commitMessages(io: ShipIo, git: Git, groups: Group[], tip: string) {
270
+ const parts: { message: string; trailers: string[] }[] = []
271
+ for (const g of groups) {
272
+ const trailers = costTrailers(await git("log", "--format=%B%n", `${g.base}..${g.tip}`))
273
+ const summary = await io.run(["gtd", "summary", `--slice=${g.name}`])
274
+ if (summary.code !== 0)
275
+ return fail(
276
+ `gtd summary --slice=${g.name} failed; nothing changed.\n${summary.err}`.trimEnd(),
277
+ )
278
+ const reply = await io.complete(summary.out + "\n" + COMMIT_FORMAT)
279
+ const message = reply && cleanReply(reply)
280
+ if (!message) return fail("The commit-message turn produced nothing.")
281
+ parts.push({ message, trailers })
282
+ }
283
+ const history = await historyRef(io, tip)
284
+ const fulls = parts.map(({ message, trailers }) => {
285
+ const all = history ? [...trailers, `Gtd-History: ${history}`] : trailers
286
+ return all.length ? `${message}\n\n${all.join("\n")}` : message
287
+ })
288
+ return { ok: true as const, fulls, history }
289
+ }
290
+
291
+ type Plan = { groups: Group[]; base: string; tip: string; count: number }
292
+
293
+ type Link = { name: string; branch: string; commit: string }
294
+
295
+ const STACK_KEY = (branch: string) => `branch.${branch}.gtdStack`
296
+ // The commit the stack was built at, never moved; missing slice branches are
297
+ // rebuilt off it while HEAD still contains it.
298
+ const TOP_KEY = (branch: string) => `branch.${branch}.gtdStackTop`
299
+
300
+ const RETIRED_KEY = (branch: string) => `branch.${branch}.gtdStackRetired`
301
+
302
+ async function forgetStack(io: ShipIo, branch: string) {
303
+ for (const key of [STACK_KEY, TOP_KEY, RETIRED_KEY])
304
+ await io.run(["git", "config", "--local", "--unset", key(branch)])
305
+ }
306
+
307
+ async function retireStack(io: ShipIo, branch: string, live: string[]) {
308
+ const set = await io.run(["git", "config", "--local", RETIRED_KEY(branch), live.join(" ")])
309
+ if (set.code !== 0) return
310
+ await io.run(["git", "config", "--local", "--unset", STACK_KEY(branch)])
311
+ await io.run(["git", "config", "--local", "--unset", TOP_KEY(branch)])
312
+ }
313
+
314
+ const names = (raw: string) => raw.split(/\s+/).filter(Boolean)
315
+
316
+ async function plan(
317
+ io: ShipIo,
318
+ git: Git,
319
+ ok: Ok,
320
+ branch: string,
321
+ isDry: boolean,
322
+ mode: ShipMode,
323
+ ): Promise<Plan | (Shipped & { stack?: Link[]; floor?: string; forget?: boolean })> {
324
+ const probe = await io.run(["gtd", "summary"])
325
+ if (probe.code !== 0) {
326
+ io.log(`No gtd process at HEAD, so nothing is squashed; ${branch} is described as it stands.`)
327
+ if (mode !== "stack") return { ok: true, text: "", forget: true }
328
+ const found = await resumable(io, git, ok, branch, isDry, "HEAD")
329
+ if (typeof found === "string") return fail(found)
330
+ const failed = await found.revive?.()
331
+ if (failed) return fail(failed)
332
+ return { ok: true, text: "", stack: found.links, floor: found.floor, forget: found.forget }
333
+ }
334
+ const listed = await io.run(["gtd", "slices", "--json"])
335
+ if (listed.code !== 0) return fail(`gtd slices failed; nothing changed.\n${listed.err}`.trimEnd())
336
+ const slices = readSlices(listed.out)
337
+ if ("refusal" in slices) return fail(`${slices.refusal} Nothing changed.`)
338
+
339
+ const underway = await unfinished(io, slices)
340
+ if (underway) return fail(underway)
341
+ const base = slices[0]!.base
342
+ const tip = slices.at(-1)!.tip
343
+ if (tip !== (await git("rev-parse", "HEAD")))
344
+ return fail(`The process tip (${short(tip)}) is not HEAD: something landed since.`)
158
345
  const count = Number(await git("rev-list", "--count", `${base}..HEAD`))
159
- if (!count) return `Nothing to squash: ${short(base)}..HEAD is empty.`
160
- return { base, tip, count }
346
+ if (!count) return fail(`Nothing to squash: ${short(base)}..HEAD is empty.`)
347
+
348
+ const groups = await landed(ok, slices)
349
+ if (!groups.length) return dropProcess(io, ok, base, count, isDry)
350
+ return { groups, base, tip, count }
351
+ }
352
+
353
+ function dryRun(
354
+ groups: Group[],
355
+ fulls: string[],
356
+ history: string | undefined,
357
+ warning: string | undefined,
358
+ stack: Link[] | undefined,
359
+ ) {
360
+ return {
361
+ ok: true as const,
362
+ text:
363
+ groups.length > 1
364
+ ? fulls.map((f, i) => `[${groups[i]!.name}]\n${f}`).join("\n\n---\n\n")
365
+ : fulls[0]!,
366
+ history,
367
+ warning,
368
+ stack,
369
+ messages: fulls,
370
+ }
161
371
  }
162
372
 
163
373
  async function squash(
@@ -166,38 +376,513 @@ async function squash(
166
376
  ok: Ok,
167
377
  branch: string,
168
378
  isDry: boolean,
169
- ): Promise<Shipped & { history?: string }> {
170
- const summary = await io.run(["gtd", "summary"])
171
- if (summary.code !== 0) {
172
- io.log(`No gtd process at HEAD, so nothing is squashed; ${branch} is described as it stands.`)
173
- return { ok: true, text: "" }
379
+ mode: ShipMode,
380
+ ): Promise<
381
+ Shipped & {
382
+ history?: string
383
+ warning?: string
384
+ stack?: Link[]
385
+ messages?: string[]
386
+ floor?: string
387
+ forget?: boolean
174
388
  }
175
- // `gtd summary` also describes a process still underway; shipping that
176
- // would publish half of it.
177
- const initial = (await io.run(["gtd", "next", "--json=initial"])).out.trim()
178
- if (initial !== "true") {
179
- const state = (await io.run(["gtd", "next", "--json=state"])).out.trim()
180
- return fail(`The gtd process is still underway (at ${state}). Finish it, then ship.`)
389
+ > {
390
+ const planned = await plan(io, git, ok, branch, isDry, mode)
391
+ if ("ok" in planned) return planned
392
+ const { groups, base, tip, count } = planned
393
+ const many = groups.length > 1
394
+ const stacked = mode === "stack" && many
395
+
396
+ // Before the history push: a refusal changes nothing.
397
+ const resume = await stackReads(io, git, ok, branch, planned, isDry, mode)
398
+ if (typeof resume === "string") return fail(resume)
399
+ io.log(`Squashing ${count} commit(s) since ${short(base)} into ${groups.length} commit(s)…`)
400
+ const msgs = await commitMessages(io, git, groups, tip)
401
+ if (!msgs.ok) return msgs
402
+ const { fulls, history } = msgs
403
+ const warn = () => (mode === "commits" && many ? rebaseWarning(io) : undefined)
404
+ if (resume?.links)
405
+ return squashOntoStack(io, git, ok, groups[0]!, fulls[0]!, history, resume, isDry)
406
+ if (isDry)
407
+ return dryRun(
408
+ groups,
409
+ fulls,
410
+ history,
411
+ await warn(),
412
+ stacked ? sliceLinks(branch, groups, []) : undefined,
413
+ )
414
+
415
+ const pushed = await pushHistory(io, history)
416
+ if (pushed) return fail(pushed)
417
+ return land(io, git, ok, branch, groups, fulls, stacked, await warn())
418
+ }
419
+
420
+ async function land(
421
+ io: ShipIo,
422
+ git: Git,
423
+ ok: Ok,
424
+ branch: string,
425
+ groups: Group[],
426
+ fulls: string[],
427
+ stacked: boolean,
428
+ warning: string | undefined,
429
+ ): Promise<Awaited<ReturnType<typeof squash>>> {
430
+ if (groups.length === 1) {
431
+ const one = await commitSquash(io, git, ok, groups[0]!.base, fulls[0]!)
432
+ return one.ok ? { ...one, forget: true } : one
181
433
  }
182
- const range = await processRange(git, summary.out)
183
- if (typeof range === "string") return fail(range)
184
- const { base, tip, count } = range
434
+ const built = await commitChain(io, git, ok, groups, fulls)
435
+ if (!built.ok) return built
436
+ // a record of an earlier stack must not outlive this ship
437
+ if (!stacked) return { ok: true, text: "", warning, forget: true }
438
+ const stack = sliceLinks(branch, groups, built.commits)
439
+ const recorded = await recordStack(io, ok, branch, stack)
440
+ return recorded ? fail(recorded) : { ok: true as const, text: "", stack }
441
+ }
185
442
 
186
- // gtd churns its own steering files every process, so `.gtd/` alone is no change.
187
- if (await ok("diff", "--quiet", base, "HEAD", "--", ":(exclude).gtd/"))
188
- return dropProcess(io, ok, base, count, isDry)
443
+ const sliceLinks = (branch: string, groups: Group[], commits: string[]): Link[] =>
444
+ groups.map((g, i) => ({
445
+ name: g.name,
446
+ branch: i < groups.length - 1 ? `${branch}-${g.name}` : branch,
447
+ commit: commits[i] ?? "",
448
+ }))
189
449
 
190
- io.log(`Squashing ${count} commit(s) since ${short(base)}…`)
191
- const trailers = costTrailers(await git("log", "--format=%B%n", `${base}..${tip}`))
192
- const history = await historyRef(io, tip)
193
- if (history) trailers.push(`Gtd-History: ${history}`)
194
- const reply = await io.complete(summary.out + "\n" + COMMIT_FORMAT)
195
- const message = reply && cleanReply(reply)
196
- if (!message) return fail("The commit-message turn produced nothing.")
197
- const full = trailers.length ? `${message}\n\n${trailers.join("\n")}` : message
198
- if (isDry) return { ok: true, text: full, history }
450
+ async function stackReads(
451
+ io: ShipIo,
452
+ git: Git,
453
+ ok: Ok,
454
+ branch: string,
455
+ { groups, base }: Plan,
456
+ isDry: boolean,
457
+ mode: ShipMode,
458
+ ): Promise<Resumed | string | undefined> {
459
+ if (mode !== "stack") return undefined
460
+ if (groups.length > 1) return await stackPrecheck(io, git, ok, branch, groups)
461
+ return resumable(io, git, ok, branch, isDry, base)
462
+ }
463
+
464
+ // One slice on a recorded stack: its commit becomes the new top of the stack, and
465
+ // the record, including the recorded top the missing-branch fallbacks read, stays.
466
+ async function squashOntoStack(
467
+ io: ShipIo,
468
+ git: Git,
469
+ ok: Ok,
470
+ group: Group,
471
+ full: string,
472
+ history: string | undefined,
473
+ resume: Resumed,
474
+ isDry: boolean,
475
+ ) {
476
+ const links = resume.links!
477
+ const top = links.length - 1
478
+ if (isDry) {
479
+ const messages = links.map((_, i) => (i === top ? full : ""))
480
+ return { ...dryRun([group], [full], history, undefined, links), messages }
481
+ }
482
+ const pushed = await pushHistory(io, history)
483
+ if (pushed) return fail(pushed)
484
+ const one = await commitSquash(io, git, ok, group.base, full)
485
+ if (!one.ok) return one
486
+ const failed = await resume.revive?.()
487
+ if (failed) return fail(failed)
488
+ const head = await git("rev-parse", "HEAD")
489
+ const stack = links.map((l, i) => (i === top ? { ...l, commit: head } : l))
490
+ return { ok: true as const, text: "", stack, floor: resume.floor }
491
+ }
492
+
493
+ async function recordStack(io: ShipIo, ok: Ok, branch: string, stack: Link[]) {
494
+ const top = await io.run(["git", "config", "--local", TOP_KEY(branch), stack.at(-1)!.commit])
495
+ const recorded =
496
+ top.code !== 0
497
+ ? top
498
+ : await io.run([
499
+ "git",
500
+ "config",
501
+ "--local",
502
+ STACK_KEY(branch),
503
+ stack.map((l) => l.name).join(" "),
504
+ ])
505
+ if (recorded.code !== 0) {
506
+ // Without the record a retry would ship one pull request: put the process back.
507
+ // The two keys only ever exist as a matched pair.
508
+ await forgetStack(io, branch)
509
+ const undone = await ok("reset", "--soft", "ORIG_HEAD")
510
+ 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()
511
+ }
512
+ for (const l of stack.slice(0, -1)) {
513
+ if (!(await ok("branch", "-f", l.branch, l.commit)))
514
+ return `git branch -f ${l.branch} failed; the squash is kept. /gtd ship resumes.`
515
+ }
516
+ return undefined
517
+ }
518
+
519
+ type View = {
520
+ number: number
521
+ title?: string
522
+ body?: string
523
+ url: string
524
+ state: string
525
+ baseRefName?: string
526
+ }
527
+
528
+ // gh's stderr for a branch with no PR (cli/cli pr view); tests script it by this.
529
+ export const NO_PR_FOUND = "no pull requests found"
530
+
531
+ // `gh pr view <branch>`: only NO_PR_FOUND reads as none; any
532
+ // other failure (auth, network) must refuse, or ship would open a duplicate PR.
533
+ async function viewPr(io: ShipIo, branch: string): Promise<View | undefined | string> {
534
+ const v = await io.run([
535
+ "gh",
536
+ "pr",
537
+ "view",
538
+ branch,
539
+ "--json",
540
+ "number,title,body,url,state,baseRefName",
541
+ ])
542
+ if (v.code !== 0) {
543
+ if (v.err.includes(NO_PR_FOUND)) return undefined
544
+ return `gh pr view ${branch} failed: ${v.err.trim()}`
545
+ }
546
+ try {
547
+ return JSON.parse(v.out) as View
548
+ } catch {
549
+ return `gh pr view ${branch} returned something unreadable.`
550
+ }
551
+ }
552
+
553
+ // `dead` names belong to a retired record gtd built itself: not a collision.
554
+ async function takenSlice(git: Git, ok: Ok, branch: string, groups: Group[], dead: string[]) {
555
+ for (const g of groups.slice(0, -1)) {
556
+ if (dead.includes(g.name)) continue
557
+ const b = `${branch}-${g.name}`
558
+ if (
559
+ (await ok("rev-parse", "--verify", "--quiet", `refs/heads/${b}`)) ||
560
+ (await ok("rev-parse", "--verify", "--quiet", `origin/${b}`))
561
+ )
562
+ 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.`
563
+ }
564
+ return undefined
565
+ }
566
+
567
+ async function recordedRefusal(
568
+ io: ShipIo,
569
+ git: Git,
570
+ ok: Ok,
571
+ branch: string,
572
+ recorded: string[],
573
+ ): Promise<string | undefined> {
574
+ // The staleness test a resume uses: a dead record is not a live stack.
575
+ const quiet = { ...io, log: () => {} }
576
+ const found = await resumable(quiet, git, ok, branch, true, "HEAD")
577
+ // resumable refuses (a missing branch, a closed slice PR) before it tests the
578
+ // chain; only a record that still chains to HEAD may refuse a fresh stack.
579
+ if (typeof found === "string")
580
+ return (await chainsToHead(git, ok, branch, recorded)) ? found : undefined
581
+ if (!found.forget)
582
+ return `${branch} already has a recorded stack (${recorded.join(", ")}); nothing changed. Ship with GTD_SHIP_MODE=commits, or run git config --local --unset branch.${branch}.gtdStack and git config --local --unset branch.${branch}.gtdStackTop, delete or rename the ${branch}-<slice> branches, then ship again.`
583
+ return undefined
584
+ }
585
+
586
+ async function chainsToHead(git: Git, ok: Ok, branch: string, recorded: string[]) {
587
+ const top = await git("config", "--local", "--get", TOP_KEY(branch))
588
+ const held = top && (await ok("merge-base", "--is-ancestor", top, "HEAD")) ? top : ""
589
+ return (await chain(git, ok, branch, recorded, 0, held, "HEAD")).valid
590
+ }
591
+
592
+ async function prStateRefusal(
593
+ io: ShipIo,
594
+ branch: string,
595
+ groups: Group[],
596
+ ): Promise<string | undefined> {
597
+ for (const [i, g] of groups.entries()) {
598
+ const top = i === groups.length - 1
599
+ const b = top ? branch : `${branch}-${g.name}`
600
+ const view = await viewPr(io, b)
601
+ if (typeof view === "string") return `${view} Nothing changed.`
602
+ // Commits mode ships to the top branch too, so for it only a rename helps.
603
+ if (view && view.state !== "OPEN")
604
+ 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"}.`
605
+ }
606
+ return undefined
607
+ }
608
+
609
+ // Before the history push: a refusal leaves the repository exactly as it was.
610
+ async function stackPrecheck(
611
+ io: ShipIo,
612
+ git: Git,
613
+ ok: Ok,
614
+ branch: string,
615
+ groups: Group[],
616
+ ): Promise<string | undefined> {
617
+ const recorded = names(await git("config", "--local", "--get", STACK_KEY(branch)))
618
+ if (recorded.length) {
619
+ const refused = await recordedRefusal(io, git, ok, branch, recorded)
620
+ if (refused) return refused
621
+ }
622
+ const taken = await takenSlice(git, ok, branch, groups, recorded)
623
+ if (taken) return taken
624
+ return prStateRefusal(io, branch, groups)
625
+ }
626
+
627
+ // A missing branch is read off the recorded top, not HEAD: the top slice may span
628
+ // several commits after a re-ship.
629
+ async function chain(
630
+ git: Git,
631
+ ok: Ok,
632
+ branch: string,
633
+ recorded: string[],
634
+ from: number,
635
+ top: string,
636
+ at: string,
637
+ ) {
638
+ const commits: string[] = []
639
+ const missing: number[] = []
640
+ let valid = true
641
+ for (let i = from; i < recorded.length - 1; i++) {
642
+ let sha = await git("rev-parse", "--verify", "--quiet", `refs/heads/${branch}-${recorded[i]}`)
643
+ if (!sha && top) {
644
+ sha = await git("rev-parse", "--verify", "--quiet", `${top}~${recorded.length - 1 - i}`)
645
+ missing.push(i)
646
+ }
647
+ if (!sha) valid = false
648
+ commits.push(sha)
649
+ }
650
+ for (const [i, c] of commits.entries()) {
651
+ if (!valid) break
652
+ if (!(await ok("merge-base", "--is-ancestor", c, commits[i + 1] ?? at))) valid = false
653
+ }
654
+ return { commits, missing, valid }
655
+ }
656
+
657
+ // Rebuilding a branch from <top>~n is only safe while HEAD still contains the
658
+ // recorded top, which is never moved after the stack was built.
659
+ async function topGuard(git: Git, ok: Ok, branch: string, recorded: string[], from: number) {
660
+ let missing = ""
661
+ for (const name of recorded.slice(from, -1)) {
662
+ const b = `${branch}-${name}`
663
+ if (await ok("rev-parse", "--verify", "--quiet", `refs/heads/${b}`)) continue
664
+ missing = b
665
+ break
666
+ }
667
+ if (!missing && !from) return { top: "" }
668
+ const top = await git("config", "--local", "--get", TOP_KEY(branch))
669
+ if (top && (await ok("merge-base", "--is-ancestor", top, "HEAD"))) return { top }
670
+ // only the merged floor needs the top: a rebase dropped it, so the stack ends
671
+ if (!missing) return { rebased: true as const }
672
+ return {
673
+ 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.`,
674
+ }
675
+ }
676
+
677
+ type Resumed = {
678
+ links?: Link[]
679
+ floor?: string
680
+ forget?: boolean
681
+ revive?: () => Promise<string | undefined>
682
+ }
683
+
684
+ async function mergedSlices(
685
+ io: ShipIo,
686
+ recorded: string[],
687
+ branchOf: (i: number) => string,
688
+ ): Promise<number | string> {
689
+ const states: (View | undefined)[] = []
690
+ for (const i of recorded.keys()) {
691
+ const view = await viewPr(io, branchOf(i))
692
+ if (typeof view === "string") return `${view} Nothing changed.`
693
+ states.push(view)
694
+ }
695
+ let done = 0
696
+ while (done < recorded.length - 1 && states[done]?.state === "MERGED") done++
697
+ for (const [i, view] of states.entries()) {
698
+ if (i >= done && view && view.state !== "OPEN")
699
+ return `The pull request for slice ${recorded[i]} (#${view.number}, ${branchOf(i)}) is ${view.state}, not open; nothing changed. ${i === recorded.length - 1 ? "Rename the branch" : "Merged slices drop out of a resumed stack on their own; a closed one needs GTD_SHIP_MODE=commits to ship the branch alone"}.`
700
+ }
701
+ return done
702
+ }
199
703
 
200
- return commitSquash(io, git, ok, base, full, history)
704
+ // Every read happens before any write, so a refusal (a string) changes nothing.
705
+ // `revive` is left to the caller so a squash can come first.
706
+ async function resumable(
707
+ io: ShipIo,
708
+ git: Git,
709
+ ok: Ok,
710
+ branch: string,
711
+ isDry: boolean,
712
+ at: string,
713
+ ): Promise<Resumed | string> {
714
+ const recorded = names(await git("config", "--local", "--get", STACK_KEY(branch)))
715
+ if (!recorded.length) return {}
716
+ const last = recorded.length - 1
717
+ const branchOf = (i: number) => (i < last ? `${branch}-${recorded[i]}` : branch)
718
+ const done = await mergedSlices(io, recorded, branchOf)
719
+ if (typeof done === "string") return done
720
+ const guard = await topGuard(git, ok, branch, recorded, done)
721
+ if ("refusal" in guard) return guard.refusal
722
+ const top = "top" in guard ? guard.top : ""
723
+ const { commits, missing, valid } = await chain(git, ok, branch, recorded, done, top, at)
724
+ if (!valid || "rebased" in guard) {
725
+ io.log(
726
+ `The recorded stack for ${branch} no longer chains to HEAD: ${isDry ? "" : "dropped it, "}shipping the branch alone.`,
727
+ )
728
+ return { forget: true }
729
+ }
730
+ const floor = done
731
+ ? await git("rev-parse", "--verify", "--quiet", `${top}~${last - (done - 1)}`)
732
+ : undefined
733
+ if (done && !floor)
734
+ return `The commit of merged slice ${recorded[done - 1]} cannot be found; nothing changed. Ship the branch alone with GTD_SHIP_MODE=commits.`
735
+ const head = await git("rev-parse", "HEAD")
736
+ const links = recorded.slice(done).map((name, j) => ({
737
+ name,
738
+ branch: branchOf(done + j),
739
+ commit: commits[j] ?? head,
740
+ }))
741
+ const revive = isDry
742
+ ? undefined
743
+ : async () => {
744
+ for (const i of missing) {
745
+ const b = branchOf(i)
746
+ if (!(await ok("branch", "-f", b, commits[i - done]!)))
747
+ return `git branch -f ${b} failed; /gtd ship resumes.`
748
+ }
749
+ return undefined
750
+ }
751
+ return { links, floor, revive }
752
+ }
753
+
754
+ const baseOf = (links: Link[], i: number, baseBranch: string) =>
755
+ i ? links[i - 1]!.branch : baseBranch
756
+
757
+ async function stackPreview(
758
+ io: ShipIo,
759
+ links: Link[],
760
+ baseBranch: string,
761
+ messages: string[] | undefined,
762
+ history?: string,
763
+ ): Promise<string | { refusal: string }> {
764
+ const parts: string[] = []
765
+ for (const [i, l] of links.entries()) {
766
+ const base = baseOf(links, i, baseBranch)
767
+ const view = await viewPr(io, l.branch)
768
+ if (typeof view === "string") return { refusal: `${view} Nothing changed.` }
769
+ const lines = [`[${l.name}]`]
770
+ if (messages?.[i]) lines.push(`Commit message:\n\n${messages[i]}`)
771
+ lines.push(`Branch: ${l.branch}`)
772
+ if (view) {
773
+ lines.push(`Pull request: would refresh ${view.url}.`)
774
+ if (view.baseRefName && view.baseRefName !== base)
775
+ lines.push(`Retarget: would retarget #${view.number} from ${view.baseRefName} to ${base}.`)
776
+ } else lines.push(`Pull request: would open into ${base}.`)
777
+ parts.push(lines.join("\n"))
778
+ }
779
+ if (history) parts.push(`History: would push ${history} to origin.`)
780
+ return parts.join("\n\n---\n\n")
781
+ }
782
+
783
+ const STACKED_ON = /^Stacked on #\d+ — merge that first(?:\r?\n(?:\r?\n)?|$)/
784
+
785
+ export function stackLine(body: string, below?: number) {
786
+ const rest = body.replace(STACKED_ON, "")
787
+ return below === undefined ? rest : `Stacked on #${below} — merge that first\n\n${rest}`
788
+ }
789
+
790
+ type Pr = Shipped & { number?: number }
791
+
792
+ async function sliceStep(
793
+ io: ShipIo,
794
+ git: Git,
795
+ ok: Ok,
796
+ l: Link,
797
+ view: View | undefined,
798
+ base: string,
799
+ lowerRef: string,
800
+ ctx: Ctx,
801
+ ): Promise<Pr | { step: string; why: string }> {
802
+ let done: Pr
803
+ if (!view) done = await createPr(io, git, l.branch, base, lowerRef, ctx)
804
+ else if (view.state !== "OPEN")
805
+ return { step: "pull request", why: `#${view.number} is ${view.state}, not open.` }
806
+ else {
807
+ // Before the thrown split: a thrown draft keeps its THROWN marker if this fails.
808
+ if (view.baseRefName !== base) {
809
+ const moved = await io.run(["gh", "pr", "edit", String(view.number), "--base", base])
810
+ if (moved.code !== 0)
811
+ return {
812
+ step: "retarget",
813
+ why: `gh pr edit --base ${base} failed.\n${moved.err}`.trimEnd(),
814
+ }
815
+ }
816
+ done = view.body?.includes(THROWN)
817
+ ? await createPr(io, git, l.branch, base, lowerRef, ctx, view)
818
+ : await updatePr(io, git, ok, l.branch, ctx, view)
819
+ }
820
+ return done.ok ? done : { step: "pull request", why: done.text }
821
+ }
822
+
823
+ // The writer turns below may touch refs: push only the commit ship built.
824
+ async function pushLink(io: ShipIo, git: Git, l: Link, upper: boolean) {
825
+ if (upper && (await git("rev-parse", `refs/heads/${l.branch}`)) !== l.commit)
826
+ return `refs/heads/${l.branch} no longer points at ${short(l.commit)}.`
827
+ return pushBranch(io, l.branch, l.commit)
828
+ }
829
+
830
+ async function shipLink(
831
+ io: ShipIo,
832
+ git: Git,
833
+ ok: Ok,
834
+ links: Link[],
835
+ i: number,
836
+ baseBranch: string,
837
+ baseRef: string,
838
+ mergeBase: string,
839
+ below: number | undefined,
840
+ ): Promise<Pr | { step: string; why: string }> {
841
+ const l = links[i]!
842
+ const lower = i ? links[i - 1]! : undefined
843
+ const base = baseOf(links, i, baseBranch)
844
+ const view = await viewPr(io, l.branch)
845
+ if (typeof view === "string")
846
+ return { step: "pull request", why: `${view} ${l.branch} was not pushed; /gtd ship retries.` }
847
+ const pushed = await pushLink(io, git, l, i < links.length - 1)
848
+ if (pushed) return { step: "push", why: pushed }
849
+ const lowerRef = lower?.branch ?? baseRef
850
+ const ctx: Ctx = { to: l.commit, from: lower?.commit ?? mergeBase, below, stacked: true }
851
+ const done = await sliceStep(io, git, ok, l, view, base, lowerRef, ctx)
852
+ if ("step" in done) return done
853
+ if (done.number === undefined && i < links.length - 1)
854
+ return { step: "pull request", why: "its number could not be read from gh's reply." }
855
+ return done
856
+ }
857
+
858
+ // Bottom first, so each PR can name the number of the one below it. A failure
859
+ // keeps every slice that already landed; /gtd ship resumes from the recorded stack.
860
+ async function stackPullRequests(
861
+ io: ShipIo,
862
+ git: Git,
863
+ ok: Ok,
864
+ links: Link[],
865
+ baseBranch: string,
866
+ baseRef: string,
867
+ floor?: string,
868
+ ): Promise<Shipped> {
869
+ const mergeBase = floor ?? (await git("merge-base", baseRef, "HEAD"))
870
+ if (!mergeBase) return fail(`${baseRef} and HEAD share no ancestor.`)
871
+ const landed: string[] = []
872
+ const texts: string[] = []
873
+ let below: number | undefined
874
+ for (const [i, l] of links.entries()) {
875
+ const stop = (step: string, why: string) =>
876
+ fail(
877
+ `Slice ${l.name} failed at ${step}: ${why}\nLanded: ${landed.length ? landed.join(", ") : "none"}. /gtd ship resumes.`,
878
+ )
879
+ const done = await shipLink(io, git, ok, links, i, baseBranch, baseRef, mergeBase, below)
880
+ if ("step" in done) return stop(done.step, done.why)
881
+ below = done.number
882
+ landed.push(l.name)
883
+ texts.push(`[${l.name}] ${done.text}`)
884
+ }
885
+ return { ok: true, text: texts.join("\n\n") }
201
886
  }
202
887
 
203
888
  async function dropProcess(
@@ -216,30 +901,28 @@ async function dropProcess(
216
901
  return { ok: true, text: "" }
217
902
  }
218
903
 
904
+ // Before the squash: a failure leaves the branch as it was, so a retry finds the process.
905
+ async function pushHistory(io: ShipIo, history: string | undefined) {
906
+ if (!history) return undefined
907
+ // Forced so a retry after the tip changed lands. The ref is keyed by the entry
908
+ // commit, which every clone of the process shares: a second clone shipping the
909
+ // same entry replaces origin's copy.
910
+ io.log(`Pushing ${history} to origin: the process's step commits become fetchable there.`)
911
+ const pushed = await attempt(io, ["git", "push", "origin", `+${history}:${history}`])
912
+ if (pushed.killed)
913
+ return `git push of ${history} did not finish; nothing was squashed. Retry.\n${pushed.err}`.trimEnd()
914
+ if (pushed.code !== 0)
915
+ return `git push of ${history} failed; nothing was squashed or pushed.\n${pushed.err}`.trimEnd()
916
+ return undefined
917
+ }
918
+
219
919
  async function commitSquash(
220
920
  io: ShipIo,
221
921
  git: Git,
222
922
  ok: Ok,
223
923
  base: string,
224
924
  full: string,
225
- history: string | undefined,
226
925
  ): Promise<Shipped> {
227
- // Before the squash: a failure leaves the branch as it was, so a retry finds the process.
228
- if (history) {
229
- // Forced so a retry after the tip changed lands. The ref is keyed by the entry
230
- // commit, which every clone of the process shares: a second clone shipping the
231
- // same entry replaces origin's copy.
232
- io.log(`Pushing ${history} to origin: the process's step commits become fetchable there.`)
233
- const pushed = await attempt(io, ["git", "push", "origin", `+${history}:${history}`])
234
- if (pushed.killed)
235
- return fail(
236
- `git push of ${history} did not finish; nothing was squashed. Retry.\n${pushed.err}`.trimEnd(),
237
- )
238
- if (pushed.code !== 0)
239
- return fail(
240
- `git push of ${history} failed; nothing was squashed or pushed.\n${pushed.err}`.trimEnd(),
241
- )
242
- }
243
926
  if (!(await ok("reset", "--soft", base))) return fail("git reset failed; nothing changed.")
244
927
  const commit = await io.run(["git", "commit", "--cleanup=strip", "-F", "-"], full + "\n")
245
928
  if (commit.code !== 0) {
@@ -253,6 +936,59 @@ async function commitSquash(
253
936
  return { ok: true, text: "" }
254
937
  }
255
938
 
939
+ // A slice tip's tree, with `.gtd/` as HEAD holds it: steering files are
940
+ // process-private, and a mid-process tip still carries some. Null on failure.
941
+ async function sliceTree(io: ShipIo, git: Git, tip: string): Promise<string | null> {
942
+ const gtdOf = (rev: string) => git("rev-parse", "--verify", "--quiet", `${rev}:.gtd`)
943
+ if ((await gtdOf(tip)) === (await gtdOf("HEAD"))) return `${tip}^{tree}`
944
+ const entries = (await io.run(["git", "ls-tree", "-z", tip])).out
945
+ .split("\0")
946
+ .filter((e) => e && e.split("\t")[1] !== ".gtd")
947
+ const gtd = (await io.run(["git", "ls-tree", "-z", "HEAD", ".gtd"])).out.split("\0")[0]
948
+ if (gtd) entries.push(gtd)
949
+ const made = await io.run(["git", "mktree", "-z"], entries.map((e) => `${e}\0`).join(""))
950
+ return made.code === 0 && made.out.trim() ? made.out.trim() : null
951
+ }
952
+
953
+ // Every commit exists before any ref moves, so a failure part-way changes nothing.
954
+ // commit-tree skips commit-msg hooks, but signing is carried over.
955
+ async function commitChain(
956
+ io: ShipIo,
957
+ git: Git,
958
+ ok: Ok,
959
+ groups: Group[],
960
+ fulls: string[],
961
+ ): Promise<Shipped & { commits: string[] }> {
962
+ let prev = groups[0]!.base
963
+ const commits: string[] = []
964
+ const sign = (await git("config", "--type=bool", "--get", "commit.gpgSign")) === "true"
965
+ for (const [i, g] of groups.entries()) {
966
+ const clean = await io.run(["git", "stripspace", "--strip-comments"], fulls[i]! + "\n")
967
+ if (clean.code !== 0)
968
+ return { ...fail(`git stripspace failed; nothing changed.\n${clean.err}`), commits }
969
+ const tree = await sliceTree(io, git, g.tip)
970
+ if (!tree)
971
+ return { ...fail(`git mktree failed for slice ${g.name}; nothing changed.`), commits }
972
+ const made = await io.run(
973
+ ["git", "commit-tree", tree, ...(sign ? ["-S"] : []), "-p", prev, "-F", "-"],
974
+ clean.out,
975
+ )
976
+ prev = made.out.trim()
977
+ if (made.code !== 0 || !prev)
978
+ return {
979
+ ...fail(`git commit-tree failed for slice ${g.name}; nothing changed.\n${made.err}`),
980
+ commits,
981
+ }
982
+ commits.push(prev)
983
+ }
984
+ if (!(await ok("reset", "--soft", prev)))
985
+ return { ...fail("git reset failed; nothing changed."), commits }
986
+ io.log(
987
+ `Squashed into ${groups.length} commits: ${await git("log", "-1", "--format=%h %s")}. Undo with git reset --soft ORIG_HEAD.`,
988
+ )
989
+ return { ok: true, text: "", commits }
990
+ }
991
+
256
992
  async function historyRef(io: ShipIo, tip: string) {
257
993
  const list = await io.run(["gtd", "history", "list", "--json"])
258
994
  let entries: { ref?: string; entry?: string; tip?: string }[] = []
@@ -285,17 +1021,19 @@ async function preview(
285
1021
  baseRef: string,
286
1022
  message: string,
287
1023
  history?: string,
288
- ) {
1024
+ warning?: string,
1025
+ ): Promise<Shipped> {
289
1026
  const mergeBase = await git("merge-base", baseRef, "HEAD")
290
- const existing = await io.run(["gh", "pr", "view", "--json", "url"])
291
- const pr =
292
- existing.code === 0 && existing.out.trim()
293
- ? `would refresh ${JSON.parse(existing.out).url}`
294
- : `would open a pull request for ${branch} into ${baseRef} (${await git("rev-list", "--count", `${mergeBase}..HEAD`)} commit(s) before the squash)`
1027
+ const existing = await viewPr(io, branch)
1028
+ if (typeof existing === "string") return fail(`${existing} Nothing changed.`)
1029
+ const pr = existing
1030
+ ? `would refresh ${existing.url}`
1031
+ : `would open a pull request for ${branch} into ${baseRef} (${await git("rev-list", "--count", `${mergeBase}..HEAD`)} commit(s) before the squash)`
295
1032
  return {
296
1033
  ok: true,
297
1034
  text: [
298
1035
  message && `Commit message:\n\n${message}`,
1036
+ warning,
299
1037
  history && `History: would push ${history} to origin.`,
300
1038
  `Pull request: ${pr}.`,
301
1039
  ]
@@ -304,6 +1042,36 @@ async function preview(
304
1042
  }
305
1043
  }
306
1044
 
1045
+ async function unstack(
1046
+ io: ShipIo,
1047
+ existing: View,
1048
+ baseBranch: string,
1049
+ onSlice: boolean,
1050
+ strip: boolean,
1051
+ edit: boolean,
1052
+ ) {
1053
+ if (onSlice) {
1054
+ const moved = await io.run(["gh", "pr", "edit", String(existing.number), "--base", baseBranch])
1055
+ if (moved.code !== 0)
1056
+ return fail(
1057
+ `gh pr edit --base ${baseBranch} failed; ${existing.url} is unchanged.\n${moved.err}`.trimEnd(),
1058
+ )
1059
+ }
1060
+ const was = existing.body ?? ""
1061
+ const body = strip ? stackLine(was) : was
1062
+ if (edit && body !== was) {
1063
+ const edited = await io.run(
1064
+ ["gh", "pr", "edit", String(existing.number), "--body-file", "-"],
1065
+ body,
1066
+ )
1067
+ if (edited.code !== 0)
1068
+ return fail(
1069
+ `gh pr edit failed; the stale stack line of ${existing.url} is unchanged.\n${edited.err}`.trimEnd(),
1070
+ )
1071
+ }
1072
+ return { unstacked: onSlice || (edit && body !== was), body: edit ? body : was }
1073
+ }
1074
+
307
1075
  async function pullRequest(
308
1076
  io: ShipIo,
309
1077
  git: Git,
@@ -311,36 +1079,50 @@ async function pullRequest(
311
1079
  branch: string,
312
1080
  baseBranch: string,
313
1081
  baseRef: string,
1082
+ recorded: string[],
314
1083
  ) {
315
1084
  const mergeBase = await git("merge-base", baseRef, "HEAD")
316
1085
  if (!mergeBase) return fail(`${baseRef} and HEAD share no ancestor.`)
317
1086
  if (mergeBase === (await git("rev-parse", "HEAD")))
318
1087
  return fail(`HEAD is an ancestor of ${baseRef}: nothing to describe.`)
319
1088
 
1089
+ const existing = await viewPr(io, branch)
1090
+ if (typeof existing === "string")
1091
+ return fail(
1092
+ `${existing} ${branch} was not pushed; the squash and history ref stay, /gtd ship retries.`,
1093
+ )
1094
+ if (existing && existing.state !== "OPEN")
1095
+ return fail(`The pull request for ${branch} is ${existing.state}, not open.`)
320
1096
  const pushed = await pushBranch(io, branch)
321
1097
  if (pushed) return fail(pushed)
322
-
323
- const view = await io.run(["gh", "pr", "view", "--json", "number,title,body,url,state"])
324
- if (view.code !== 0) return createPr(io, git, branch, baseBranch, baseRef, mergeBase)
325
- const existing = JSON.parse(view.out) as {
326
- number: number
327
- body?: string
328
- url: string
329
- state: string
330
- }
331
- if (existing.state !== "OPEN")
332
- return fail(`The pull request for ${branch} is ${existing.state}, not open.`)
1098
+ if (!existing)
1099
+ return createPr(io, git, branch, baseBranch, baseRef, { from: mergeBase, to: "HEAD" })
1100
+ // A branch once shipped as a stack keeps the PR aimed at a slice branch; any
1101
+ // other base is the author's own and stays.
1102
+ const onSlice = recorded.some((n) => existing.baseRefName === `${branch}-${n}`)
1103
+ const ctx: Ctx = { from: mergeBase, to: "HEAD" }
333
1104
  // A thrown draft only ever described a hand-off: ship writes it properly.
334
- if (existing.body?.includes(THROWN)) {
335
- return createPr(io, git, branch, baseBranch, baseRef, mergeBase, existing)
336
- }
337
- return updatePr(io, git, ok, branch, mergeBase, existing)
1105
+ const thrown = existing.body?.includes(THROWN)
1106
+ // gtd's own line is safe to strip on the default branch too: GitHub retargets
1107
+ // there itself when a lower PR merges.
1108
+ const strip = onSlice || existing.baseRefName === baseBranch
1109
+ const fixed = await unstack(io, existing, baseBranch, onSlice, strip, !thrown)
1110
+ if ("ok" in fixed) return fixed
1111
+ if (thrown) return createPr(io, git, branch, baseBranch, baseRef, ctx, existing)
1112
+ return updatePr(
1113
+ io,
1114
+ git,
1115
+ ok,
1116
+ branch,
1117
+ { ...ctx, unstacked: fixed.unstacked },
1118
+ { ...existing, body: fixed.body },
1119
+ )
338
1120
  }
339
1121
 
340
1122
  // A pull request describes what the remote branch holds. A squash rewrote the
341
1123
  // branch, so an existing origin/<branch> needs --force-with-lease. Returns why
342
1124
  // the push failed, or nothing.
343
- export async function pushBranch(io: ShipIo, branch: string) {
1125
+ export async function pushBranch(io: ShipIo, branch: string, head = "HEAD") {
344
1126
  const ok = async (...args: string[]) => (await io.run(["git", ...args])).code === 0
345
1127
  const push = async (args: string[], failure: string) => {
346
1128
  const r = await attempt(io, ["git", "push", ...args])
@@ -353,7 +1135,7 @@ export async function pushBranch(io: ShipIo, branch: string) {
353
1135
  io.log(`Pushing ${branch} to origin…`)
354
1136
  return push(["--set-upstream", "origin", branch], "git push failed.")
355
1137
  }
356
- if (!(await ok("merge-base", "--is-ancestor", "HEAD", `origin/${branch}`))) {
1138
+ if (!(await ok("merge-base", "--is-ancestor", head, `origin/${branch}`))) {
357
1139
  io.log(`Pushing ${branch} to origin…`)
358
1140
  return push(
359
1141
  ["--force-with-lease", "origin", branch],
@@ -365,24 +1147,32 @@ export async function pushBranch(io: ShipIo, branch: string) {
365
1147
 
366
1148
  // The shell commands ship's writer may run: it reads commit messages anyone
367
1149
  // on the branch wrote, so read-only git and no shell syntax to chain more on.
1150
+ // `--output` writes a file and `branch` with arguments moves refs, so neither is allowed.
1151
+ // For diff, quotes and backslashes are refused outright: the `..` path check cannot see
1152
+ // through shell quoting. The other subcommands' flag check still sees through them.
1153
+ // git diff falls back to --no-index by itself when a path lies outside the work
1154
+ // tree, so diff refuses absolute, `~` and `..` paths.
368
1155
  export const READ_ONLY_GIT =
369
- /^git (log|show|diff|status|rev-parse|rev-list|merge-base|branch)\b[^;&|<>`$()\n]*$/
1156
+ /^git (?!diff\b.*["'\\])(?!diff\b[^;&|<>`$()\n{}*?[\]]*(?:\s["'\\]*(?:[/~]|\.\.(?![^\s/"'\\]))|\/\.\.(?![^\s/"'\\])))(?:(?:log|show|diff|status|rev-parse|rev-list|merge-base)\b(?![^;&|<>`$()\n{}*?[\]]*\s["'\\]*-["'\\]*(?:o|-["'\\]*(?:o["'\\]*u|n["'\\]*o["'\\]*-["'\\]*i|e["'\\]*x["'\\]*t["'\\]*-|t["'\\]*e["'\\]*x["'\\]*t["'\\]*c)))[^;&|<>`$()\n{}*?[\]]*|branch(?:\s+(?:--list|-a|-r|--show-current))*(?:\s+--contains\s+[\w./^~-]+)?)$/
370
1157
 
371
1158
  const TITLE = /^(feat|fix|refactor|perf|docs|test|build|ci|chore)(\([\w./-]+\))?!?: \S.{0,70}$/
372
1159
 
373
1160
  // Marks a draft pull request `/gtd throw` opened for a hand-off.
374
1161
  export const THROWN = "<!-- gtd:thrown -->"
375
1162
 
1163
+ // unstacked: this run took the PR off a stack, so nothing new is still a success.
1164
+ type Ctx = { from: string; to: string; stacked?: boolean; below?: number; unstacked?: boolean }
1165
+
376
1166
  async function createPr(
377
1167
  io: ShipIo,
378
1168
  git: Git,
379
1169
  branch: string,
380
1170
  baseBranch: string,
381
1171
  baseRef: string,
382
- mergeBase: string,
1172
+ { from: mergeBase, to, stacked, below }: Ctx,
383
1173
  thrown?: { number: number; url: string },
384
- ) {
385
- const count = await git("rev-list", "--count", `${mergeBase}..HEAD`)
1174
+ ): Promise<Shipped & { number?: number }> {
1175
+ const count = await git("rev-list", "--count", `${mergeBase}..${to}`)
386
1176
  io.log(
387
1177
  thrown
388
1178
  ? `#${thrown.number} was thrown as a draft: describing ${count} commit(s) properly…`
@@ -413,10 +1203,10 @@ Print the title and description and nothing else — no preamble, and no code
413
1203
  fence wrapped around the whole reply.
414
1204
 
415
1205
  --- COMMITS ON ${branch} (against ${baseRef}), oldest first ---
416
- ${await git("log", "--no-merges", "--reverse", "--format=%H%n%B%n---", `${mergeBase}..HEAD`)}
1206
+ ${await git("log", "--no-merges", "--reverse", "--format=%H%n%B%n---", `${mergeBase}..${to}`)}
417
1207
 
418
1208
  --- DIFF STAT ---
419
- ${await git("diff", "--stat", `${mergeBase}..HEAD`)}`
1209
+ ${await git("diff", "--stat", `${mergeBase}..${to}`)}`
420
1210
  const reply = await io.complete(prompt)
421
1211
  const out = reply && cleanReply(reply)
422
1212
  if (!out) return fail("The pull-request turn produced nothing.")
@@ -425,8 +1215,9 @@ ${await git("diff", "--stat", `${mergeBase}..HEAD`)}`
425
1215
  // a reply shaped like the title it was asked for.
426
1216
  if (!TITLE.test(title))
427
1217
  return fail(`The pull-request turn returned no usable title: ${title.slice(0, 120)}`)
428
- const body = rest.join("\n").replace(/^\s*\n/, "")
429
- const head = await git("rev-parse", "HEAD")
1218
+ const written = rest.join("\n").replace(/^\s*\n/, "")
1219
+ const body = stacked ? stackLine(written, below) : written
1220
+ const head = await git("rev-parse", to)
430
1221
  if (thrown) {
431
1222
  const n = String(thrown.number)
432
1223
  // Ready first: a failure here leaves the THROWN marker, so a retry comes back.
@@ -442,7 +1233,11 @@ ${await git("diff", "--stat", `${mergeBase}..HEAD`)}`
442
1233
  `gh pr edit failed; ${thrown.url} is marked ready but keeps its thrown title and description.\n${edited.err}`,
443
1234
  )
444
1235
  await io.run(["git", "config", "--local", `branch.${branch}.prSyncHead`, head])
445
- return { ok: true, text: `Rewrote ${thrown.url} and marked it ready for review.` }
1236
+ return {
1237
+ ok: true,
1238
+ text: `Rewrote ${thrown.url} and marked it ready for review.`,
1239
+ number: thrown.number,
1240
+ }
446
1241
  }
447
1242
  const created = await io.run(
448
1243
  [
@@ -461,14 +1256,22 @@ ${await git("diff", "--stat", `${mergeBase}..HEAD`)}`
461
1256
  body + "\n",
462
1257
  )
463
1258
  if (created.code !== 0) return fail(`gh pr create failed; nothing was opened.\n${created.err}`)
464
- await io.run([
465
- "git",
466
- "config",
467
- "--local",
468
- `branch.${branch}.prSyncHead`,
469
- await git("rev-parse", "HEAD"),
470
- ])
471
- return { ok: true, text: `Opened ${created.out.trim()}` }
1259
+ await io.run(["git", "config", "--local", `branch.${branch}.prSyncHead`, head])
1260
+ const number = /\/pull\/(\d+)/.exec(created.out)?.[1]
1261
+ return {
1262
+ ok: true,
1263
+ text: `Opened ${created.out.trim()}`,
1264
+ number: number ? Number(number) : undefined,
1265
+ }
1266
+ }
1267
+
1268
+ async function sinceHead(git: Git, ok: Ok, branch: string, mergeBase: string, to: string) {
1269
+ const synced = await git("config", "--local", "--get", `branch.${branch}.prSyncHead`)
1270
+ const isSynced =
1271
+ synced &&
1272
+ (await ok("rev-parse", "--verify", "--quiet", `${synced}^{commit}`)) &&
1273
+ (await ok("merge-base", "--is-ancestor", synced, to))
1274
+ return isSynced ? synced : mergeBase
472
1275
  }
473
1276
 
474
1277
  async function updatePr(
@@ -476,18 +1279,34 @@ async function updatePr(
476
1279
  git: Git,
477
1280
  ok: Ok,
478
1281
  branch: string,
479
- mergeBase: string,
1282
+ { from: mergeBase, to, stacked, below, unstacked }: Ctx,
480
1283
  existing: { number: number; body?: string; url: string },
481
- ) {
1284
+ ): Promise<Shipped & { number?: number }> {
1285
+ const restack = (body: string) => (stacked ? stackLine(body, below) : body)
1286
+ const edit = async (body: string) =>
1287
+ io.run(["gh", "pr", "edit", String(existing.number), "--body-file", "-"], body)
1288
+ // The stack line is re-applied on every refresh, but only written when it changed.
1289
+ const restacked = async (pr: { body?: string }, text: string) => {
1290
+ const fixed = restack(pr.body ?? "")
1291
+ if (fixed === (pr.body ?? "")) return { ok: true, text, number: existing.number }
1292
+ const edited = await edit(fixed)
1293
+ if (edited.code !== 0)
1294
+ return {
1295
+ ...fail(
1296
+ `gh pr edit failed; the stack line of ${existing.url} is unchanged.\n${edited.err}`,
1297
+ ),
1298
+ number: existing.number,
1299
+ }
1300
+ return { ok: true, text, number: existing.number }
1301
+ }
482
1302
  // Only what landed since the last run. A stored head a squash left behind
483
1303
  // falls back to the whole branch, the only honest range left.
484
- const synced = await git("config", "--local", "--get", `branch.${branch}.prSyncHead`)
485
- const isSynced =
486
- synced &&
487
- (await ok("rev-parse", "--verify", "--quiet", `${synced}^{commit}`)) &&
488
- (await ok("merge-base", "--is-ancestor", synced, "HEAD"))
489
- const since = isSynced ? synced : mergeBase
490
- const count = Number(await git("rev-list", "--count", `${since}..HEAD`))
1304
+ const since = await sinceHead(git, ok, branch, mergeBase, to)
1305
+ // ^mergeBase keeps a synced head from before a main merge from re-weighing
1306
+ // the commits that merge brought in.
1307
+ const count = Number(await git("rev-list", "--count", `${since}..${to}`, `^${mergeBase}`))
1308
+ if (!count && (stacked || unstacked))
1309
+ return restacked(existing, `Nothing new since the last ship: ${existing.url}`)
491
1310
  if (!count) return fail(`No commits since the last ship: ${existing.url}`)
492
1311
  io.log(`#${existing.number}: weighing ${count} commit(s) against the standing description…`)
493
1312
 
@@ -519,33 +1338,34 @@ for you), no preamble, no code fence around the whole reply.
519
1338
  ${existing.body ?? ""}
520
1339
 
521
1340
  --- COMMITS ADDED SINCE IT WAS LAST CONSIDERED, oldest first ---
522
- ${await git("log", "--no-merges", "--reverse", "--format=%H%n%B%n---", `${since}..HEAD`)}
1341
+ ${await git("log", "--no-merges", "--reverse", "--format=%H%n%B%n---", `${since}..${to}`, `^${mergeBase}`)}
523
1342
 
524
1343
  --- DIFF STAT (whole branch) ---
525
- ${await git("diff", "--stat", `${mergeBase}..HEAD`)}`
1344
+ ${await git("diff", "--stat", `${mergeBase}..${to}`)}`
526
1345
  const reply = await io.complete(prompt)
527
1346
  const out = reply && cleanReply(reply)
528
1347
  if (!out) return fail("The pull-request turn produced nothing.")
529
1348
 
530
1349
  // The sync head advances either way: NO-UPDATE is a real answer.
531
- const sha = await git("rev-parse", "HEAD")
1350
+ const sha = await git("rev-parse", to)
532
1351
  if (/^\s*NO-UPDATE\s*$/.test(out)) {
533
1352
  await io.run(["git", "config", "--local", `branch.${branch}.prSyncHead`, sha])
534
- return {
535
- ok: true,
536
- text: `Nothing at that level changed; the description of ${existing.url} is left alone.`,
537
- }
1353
+ return restacked(
1354
+ existing,
1355
+ `Nothing at that level changed; the description of ${existing.url} is left alone.`,
1356
+ )
538
1357
  }
539
1358
  // Appended, dated and anchored to its commit, so the standing text keeps saying what it said.
540
1359
  const repo = (await io.run(["gh", "repo", "view", "--json", "url", "-q", ".url"])).out.trim()
541
1360
  const anchor = repo ? `[\`${sha.slice(0, 7)}\`](${repo}/commit/${sha})` : `\`${sha.slice(0, 7)}\``
542
- const body = `${existing.body ?? ""}\n\n## Update ${io.today()} — ${anchor}\n\n${out}\n`
543
- const edited = await io.run(
544
- ["gh", "pr", "edit", String(existing.number), "--body-file", "-"],
545
- body,
546
- )
1361
+ const body = restack(`${existing.body ?? ""}\n\n## Update ${io.today()} — ${anchor}\n\n${out}\n`)
1362
+ const edited = await edit(body)
547
1363
  if (edited.code !== 0)
548
1364
  return fail(`gh pr edit failed; the pull request is unchanged.\n${edited.err}`)
549
1365
  await io.run(["git", "config", "--local", `branch.${branch}.prSyncHead`, sha])
550
- return { ok: true, text: `Appended an update to ${existing.url}:\n\n${out}` }
1366
+ return {
1367
+ ok: true,
1368
+ text: `Appended an update to ${existing.url}:\n\n${out}`,
1369
+ number: existing.number,
1370
+ }
551
1371
  }