@pmelab/gtd 29.0.0 → 29.1.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtd",
3
- "version": "29.0.0",
3
+ "version": "29.1.0",
4
4
  "description": "Drive the gtd loop inside Claude Code: agent turns run as subagents, gtd decides every beat",
5
5
  "author": {
6
6
  "name": "Philipp Melab"
package/README.md CHANGED
@@ -338,16 +338,21 @@ the process on only when you say so; a review you leave untouched signs off.
338
338
  While a step runs you can keep asking; requests for changes wait for the next
339
339
  rest. When the process finishes, Claude offers to squash it and open its pull
340
340
  request(s) (or run `/gtd ship`; `GTD_SHIP_MODE` picks one stacked pull request
341
- per slice or one commit per slice, see the [driver doc](docs/driver.md)). A
342
- process can change hands at any gate: ask Claude to hand it off (or run
343
- `/gtd throw @dev`) and it opens a draft pull request assigned to them, and
344
- `/gtd catch <pr>` picks it up exactly where it waits, so whoever wrote the
345
- requirements can hand the architecture to someone else. Ship, hand-off and throw
346
- answer at once and run in the background, with progress on the status line; they
347
- report by waking Claude, a toast, a transcript line and a push notification, and
348
- a failed push or `gh` call reports as a failure. While a ship or hand-off runs
349
- or is queued, `/gtd catch`, the doors, `/gtd <requirements>` and bare `/gtd`
350
- wait for its report. See
341
+ per slice or one commit per slice, see the [driver doc](docs/driver.md));
342
+ re-running `/gtd ship` after a lower stacked pull request merged restacks the
343
+ rest (it refuses, with nothing changed, when that pull request merged anywhere
344
+ but the default branch (unless into a lower slice branch before that one merged
345
+ into it), a slice branch is checked out in another worktree, an unrelated local
346
+ branch sits inside the rebased commits, or a slice's change is already upstream;
347
+ a failed rebase is rolled back). A process can change hands at any gate: ask
348
+ Claude to hand it off (or run `/gtd throw @dev`) and it opens a draft pull
349
+ request assigned to them, and `/gtd catch <pr>` picks it up exactly where it
350
+ waits, so whoever wrote the requirements can hand the architecture to someone
351
+ else. Ship, hand-off and throw answer at once and run in the background, with
352
+ progress on the status line; they report by waking Claude, a toast, a transcript
353
+ line and a push notification, and a failed push or `gh` call reports as a
354
+ failure. While a ship or hand-off runs or is queued, `/gtd catch`, the doors,
355
+ `/gtd <requirements>` and bare `/gtd` wait for its report. See
351
356
  [Inside Claude Code](https://github.com/pmelab/gtd/blob/main/docs/driver.md#inside-claude-code-the-gtd-mod).
352
357
 
353
358
  ### Then let an agent build your own
@@ -2,7 +2,7 @@
2
2
  // pushing the branch and telling them. `throw` opens (or refreshes) a draft
3
3
  // pull request for the hand-off; `catch` checks it out wherever it was thrown.
4
4
 
5
- import { pushBranch, THROWN } from "./ship"
5
+ import { defaultBranch, pushBranch, THROWN } from "./ship"
6
6
  import type { ShipIo, Shipped } from "./ship"
7
7
 
8
8
  export type Rest = { state?: string; label?: string; content?: string; isIdle: boolean }
@@ -73,8 +73,7 @@ async function throwBranch(io: ShipIo, now: string) {
73
73
  const git = async (...args: string[]) => (await io.run(["git", ...args])).out.trim()
74
74
  const branch = await git("rev-parse", "--abbrev-ref", "HEAD")
75
75
  if (!branch || branch === "HEAD") return "Detached HEAD: there is no branch to throw."
76
- const head = await git("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD")
77
- const base = head.replace(/^origin\//, "") || "main"
76
+ const base = await defaultBranch(git)
78
77
  if (branch !== base) return { branch, base }
79
78
  const fresh = `gtd/${now.replace(/[-:]/g, "").replace("T", "-").slice(0, 13)}`
80
79
  const switched = await io.run(["git", "switch", "-c", fresh])
@@ -151,20 +151,32 @@ const attempt = async (io: ShipIo, argv: string[]): Promise<Ran & { killed: bool
151
151
  }
152
152
  }
153
153
 
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
154
+ // A restack never moves the local default branch, so a later ship would read a
155
+ // stale merge base from it and describe other people's changes. Origin's copy
156
+ // wins when it descends from the local one, or when the branch sits on it and
157
+ // not on the local one; otherwise the local branch stays first.
158
+ async function resolveRef(git: Git, ok: Ok, baseBranch: string): Promise<string | undefined> {
159
+ const remote = `origin/${baseBranch}`
160
+ const local = await ok("rev-parse", "--verify", "--quiet", baseBranch)
161
+ const origin = await ok("rev-parse", "--verify", "--quiet", remote)
162
+ if (!local || !origin) return local ? baseBranch : origin ? remote : undefined
163
+ if (await ok("merge-base", "--is-ancestor", baseBranch, remote)) return remote
164
+ const m = await git("merge-base", remote, "HEAD")
165
+ if (m && !(await ok("merge-base", "--is-ancestor", m, baseBranch))) return remote
166
+ return baseBranch
167
+ }
168
+
169
+ export async function defaultBranch(git: (...args: string[]) => Promise<string>) {
170
+ const head = await git("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD")
171
+ return head.replace(/^origin\//, "") || "main"
159
172
  }
160
173
 
161
174
  async function preflight(git: Git, ok: Ok) {
162
175
  const branch = await git("rev-parse", "--abbrev-ref", "HEAD")
163
176
  if (!branch || branch === "HEAD") return { refusal: "Detached HEAD: there is no branch to ship." }
164
- const head = await git("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD")
165
- const baseBranch = head.replace(/^origin\//, "") || "main"
177
+ const baseBranch = await defaultBranch(git)
166
178
  if (branch === baseBranch) return { refusal: `You are on the default branch (${baseBranch}).` }
167
- const baseRef = await resolveRef(ok, baseBranch)
179
+ const baseRef = await resolveRef(git, ok, baseBranch)
168
180
  if (!baseRef)
169
181
  return { refusal: `There is no ref '${baseBranch}' (tried origin/${baseBranch} too).` }
170
182
  // `reset --soft` would fold pending changes into the squash without saying so.
@@ -188,14 +200,21 @@ export async function ship(io: ShipIo, isDry: boolean, rawMode?: string): Promis
188
200
  const live = names(await git("config", "--local", "--get", STACK_KEY(branch)))
189
201
  const retired = names(await git("config", "--local", "--get", RETIRED_KEY(branch)))
190
202
  const recorded = [...live, ...retired]
191
- const squashed = await squash(io, git, ok, branch, isDry, mode)
203
+ const squashed = await squash(io, git, ok, branch, baseBranch, isDry, mode)
192
204
  if (!squashed.ok) return squashed
193
205
  // Until the PR step succeeds the record only moves aside: a retry must not
194
206
  // resume the stack this ship dropped, yet must still find its slice bases.
195
207
  if (squashed.forget && live.length && !isDry) await retireStack(io, branch, live)
196
208
  const { stack, floor } = squashed
197
209
  if (stack && isDry) {
198
- const text = await stackPreview(io, stack, baseBranch, squashed.messages, squashed.history)
210
+ const text = await stackPreview(
211
+ io,
212
+ stack,
213
+ baseBranch,
214
+ squashed.messages,
215
+ squashed.history,
216
+ squashed.restack,
217
+ )
199
218
  if (typeof text !== "string") return fail(text.refusal)
200
219
  return withWarning({ ok: true, text }, squashed.warning)
201
220
  }
@@ -205,12 +224,22 @@ export async function ship(io: ShipIo, isDry: boolean, rawMode?: string): Promis
205
224
  ? await stackPullRequests(io, git, ok, stack, baseBranch, baseRef, floor)
206
225
  : await pullRequest(io, git, ok, branch, baseBranch, baseRef, recorded)
207
226
  // 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)])
227
+ if (opened.ok) await settleRecords(io, branch, squashed, live.length > 0, retired.length > 0)
211
228
  return withWarning(opened, squashed.warning)
212
229
  }
213
230
 
231
+ async function settleRecords(
232
+ io: ShipIo,
233
+ branch: string,
234
+ squashed: { forget?: boolean; recorded?: boolean },
235
+ hadLive: boolean,
236
+ hadRetired: boolean,
237
+ ): Promise<void> {
238
+ if (squashed.forget) return forgetStack(io, branch)
239
+ if (hadRetired && (!hadLive || squashed.recorded))
240
+ await io.run(["git", "config", "--local", "--unset", RETIRED_KEY(branch)])
241
+ }
242
+
214
243
  function withWarning(r: Shipped, warning?: string): Shipped {
215
244
  return warning ? { ...r, text: [r.text, warning].filter(Boolean).join("\n\n") } : r
216
245
  }
@@ -290,14 +319,21 @@ async function commitMessages(io: ShipIo, git: Git, groups: Group[], tip: string
290
319
 
291
320
  type Plan = { groups: Group[]; base: string; tip: string; count: number }
292
321
 
293
- type Link = { name: string; branch: string; commit: string }
322
+ // `lease`: origin's sha for the branch, read before a restack rebased it.
323
+ // `fresh`: ls-remote showed origin lacks the branch, so any tracking ref is stale.
324
+ type Link = { name: string; branch: string; commit: string; lease?: string; fresh?: boolean }
294
325
 
295
326
  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.
327
+ // The commit the stack was built at; missing slice branches are rebuilt off it
328
+ // while HEAD still contains it. Only a restack moves it, to a top with one
329
+ // commit per lower slice beneath it.
298
330
  const TOP_KEY = (branch: string) => `branch.${branch}.gtdStackTop`
299
331
 
332
+ // Slice names of a moved-aside record, awaiting PR success.
300
333
  const RETIRED_KEY = (branch: string) => `branch.${branch}.gtdStackRetired`
334
+ // Origin's sha before a restack moved the branch; outlives the record so a rejected
335
+ // push keeps its lease, and is never cleared by forgetStack.
336
+ const LEASE_KEY = (branch: string) => `branch.${branch}.gtdLease`
301
337
 
302
338
  async function forgetStack(io: ShipIo, branch: string) {
303
339
  for (const key of [STACK_KEY, TOP_KEY, RETIRED_KEY])
@@ -305,7 +341,9 @@ async function forgetStack(io: ShipIo, branch: string) {
305
341
  }
306
342
 
307
343
  async function retireStack(io: ShipIo, branch: string, live: string[]) {
308
- const set = await io.run(["git", "config", "--local", RETIRED_KEY(branch), live.join(" ")])
344
+ const had = names((await io.run(["git", "config", "--local", "--get", RETIRED_KEY(branch)])).out)
345
+ const union = [...new Set([...had, ...live])]
346
+ const set = await io.run(["git", "config", "--local", RETIRED_KEY(branch), union.join(" ")])
309
347
  if (set.code !== 0) return
310
348
  await io.run(["git", "config", "--local", "--unset", STACK_KEY(branch)])
311
349
  await io.run(["git", "config", "--local", "--unset", TOP_KEY(branch)])
@@ -313,24 +351,50 @@ async function retireStack(io: ShipIo, branch: string, live: string[]) {
313
351
 
314
352
  const names = (raw: string) => raw.split(/\s+/).filter(Boolean)
315
353
 
354
+ // No gtd process at HEAD: nothing to squash, so only a recorded stack can resume.
355
+ async function resumeAlone(
356
+ io: ShipIo,
357
+ git: Git,
358
+ ok: Ok,
359
+ branch: string,
360
+ baseBranch: string,
361
+ isDry: boolean,
362
+ mode: ShipMode,
363
+ ): Promise<Shipped & { stack?: Link[]; floor?: string; forget?: boolean; restack?: Restack }> {
364
+ io.log(`No gtd process at HEAD, so nothing is squashed; ${branch} is described as it stands.`)
365
+ if (mode !== "stack") return { ok: true, text: "", forget: true }
366
+ const found = await resumable(io, git, ok, branch, baseBranch, isDry, "HEAD")
367
+ if (typeof found === "string") return fail(found)
368
+ const failed = await found.revive?.()
369
+ if (failed) return fail(failed)
370
+ if (found.restack && !isDry) {
371
+ const moved = await found.restack.run()
372
+ if (typeof moved === "string") return fail(moved)
373
+ return { ok: true, text: "", stack: moved.links, floor: moved.floor, forget: moved.forget }
374
+ }
375
+ return {
376
+ ok: true,
377
+ text: "",
378
+ stack: found.links,
379
+ floor: found.floor,
380
+ forget: found.forget,
381
+ restack: found.restack,
382
+ }
383
+ }
384
+
316
385
  async function plan(
317
386
  io: ShipIo,
318
387
  git: Git,
319
388
  ok: Ok,
320
389
  branch: string,
390
+ baseBranch: string,
321
391
  isDry: boolean,
322
392
  mode: ShipMode,
323
- ): Promise<Plan | (Shipped & { stack?: Link[]; floor?: string; forget?: boolean })> {
393
+ ): Promise<
394
+ Plan | (Shipped & { stack?: Link[]; floor?: string; forget?: boolean; restack?: Restack })
395
+ > {
324
396
  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
- }
397
+ if (probe.code !== 0) return resumeAlone(io, git, ok, branch, baseBranch, isDry, mode)
334
398
  const listed = await io.run(["gtd", "slices", "--json"])
335
399
  if (listed.code !== 0) return fail(`gtd slices failed; nothing changed.\n${listed.err}`.trimEnd())
336
400
  const slices = readSlices(listed.out)
@@ -375,6 +439,7 @@ async function squash(
375
439
  git: Git,
376
440
  ok: Ok,
377
441
  branch: string,
442
+ baseBranch: string,
378
443
  isDry: boolean,
379
444
  mode: ShipMode,
380
445
  ): Promise<
@@ -385,16 +450,18 @@ async function squash(
385
450
  messages?: string[]
386
451
  floor?: string
387
452
  forget?: boolean
453
+ restack?: Restack
454
+ recorded?: boolean
388
455
  }
389
456
  > {
390
- const planned = await plan(io, git, ok, branch, isDry, mode)
457
+ const planned = await plan(io, git, ok, branch, baseBranch, isDry, mode)
391
458
  if ("ok" in planned) return planned
392
459
  const { groups, base, tip, count } = planned
393
460
  const many = groups.length > 1
394
461
  const stacked = mode === "stack" && many
395
462
 
396
463
  // Before the history push: a refusal changes nothing.
397
- const resume = await stackReads(io, git, ok, branch, planned, isDry, mode)
464
+ const resume = await stackReads(io, git, ok, branch, baseBranch, planned, isDry, mode)
398
465
  if (typeof resume === "string") return fail(resume)
399
466
  io.log(`Squashing ${count} commit(s) since ${short(base)} into ${groups.length} commit(s)…`)
400
467
  const msgs = await commitMessages(io, git, groups, tip)
@@ -402,7 +469,7 @@ async function squash(
402
469
  const { fulls, history } = msgs
403
470
  const warn = () => (mode === "commits" && many ? rebaseWarning(io) : undefined)
404
471
  if (resume?.links)
405
- return squashOntoStack(io, git, ok, groups[0]!, fulls[0]!, history, resume, isDry)
472
+ return squashOntoStack(io, git, ok, groups[0]!, fulls[0]!, history, resume, isDry, tip)
406
473
  if (isDry)
407
474
  return dryRun(
408
475
  groups,
@@ -437,7 +504,7 @@ async function land(
437
504
  if (!stacked) return { ok: true, text: "", warning, forget: true }
438
505
  const stack = sliceLinks(branch, groups, built.commits)
439
506
  const recorded = await recordStack(io, ok, branch, stack)
440
- return recorded ? fail(recorded) : { ok: true as const, text: "", stack }
507
+ return recorded ? fail(recorded) : { ok: true as const, text: "", stack, recorded: true }
441
508
  }
442
509
 
443
510
  const sliceLinks = (branch: string, groups: Group[], commits: string[]): Link[] =>
@@ -452,13 +519,14 @@ async function stackReads(
452
519
  git: Git,
453
520
  ok: Ok,
454
521
  branch: string,
522
+ baseBranch: string,
455
523
  { groups, base }: Plan,
456
524
  isDry: boolean,
457
525
  mode: ShipMode,
458
526
  ): Promise<Resumed | string | undefined> {
459
527
  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)
528
+ if (groups.length > 1) return await stackPrecheck(io, git, ok, branch, baseBranch, groups)
529
+ return resumable(io, git, ok, branch, baseBranch, isDry, base)
462
530
  }
463
531
 
464
532
  // One slice on a recorded stack: its commit becomes the new top of the stack, and
@@ -472,12 +540,17 @@ async function squashOntoStack(
472
540
  history: string | undefined,
473
541
  resume: Resumed,
474
542
  isDry: boolean,
543
+ preShip: string,
475
544
  ) {
476
545
  const links = resume.links!
477
546
  const top = links.length - 1
478
547
  if (isDry) {
479
548
  const messages = links.map((_, i) => (i === top ? full : ""))
480
- return { ...dryRun([group], [full], history, undefined, links), messages }
549
+ return {
550
+ ...dryRun([group], [full], history, undefined, links),
551
+ messages,
552
+ restack: resume.restack,
553
+ }
481
554
  }
482
555
  const pushed = await pushHistory(io, history)
483
556
  if (pushed) return fail(pushed)
@@ -485,6 +558,17 @@ async function squashOntoStack(
485
558
  if (!one.ok) return one
486
559
  const failed = await resume.revive?.()
487
560
  if (failed) return fail(failed)
561
+ if (resume.restack) {
562
+ const moved = await resume.restack.run(preShip)
563
+ if (typeof moved === "string") return fail(moved)
564
+ return {
565
+ ok: true as const,
566
+ text: "",
567
+ stack: moved.links,
568
+ floor: moved.floor,
569
+ forget: moved.forget,
570
+ }
571
+ }
488
572
  const head = await git("rev-parse", "HEAD")
489
573
  const stack = links.map((l, i) => (i === top ? { ...l, commit: head } : l))
490
574
  return { ok: true as const, text: "", stack, floor: resume.floor }
@@ -523,6 +607,7 @@ type View = {
523
607
  url: string
524
608
  state: string
525
609
  baseRefName?: string
610
+ mergedAt?: string
526
611
  }
527
612
 
528
613
  // gh's stderr for a branch with no PR (cli/cli pr view); tests script it by this.
@@ -537,7 +622,7 @@ async function viewPr(io: ShipIo, branch: string): Promise<View | undefined | st
537
622
  "view",
538
623
  branch,
539
624
  "--json",
540
- "number,title,body,url,state,baseRefName",
625
+ "number,title,body,url,state,baseRefName,mergedAt",
541
626
  ])
542
627
  if (v.code !== 0) {
543
628
  if (v.err.includes(NO_PR_FOUND)) return undefined
@@ -569,11 +654,12 @@ async function recordedRefusal(
569
654
  git: Git,
570
655
  ok: Ok,
571
656
  branch: string,
657
+ baseBranch: string,
572
658
  recorded: string[],
573
659
  ): Promise<string | undefined> {
574
660
  // The staleness test a resume uses: a dead record is not a live stack.
575
661
  const quiet = { ...io, log: () => {} }
576
- const found = await resumable(quiet, git, ok, branch, true, "HEAD")
662
+ const found = await resumable(quiet, git, ok, branch, baseBranch, true, "HEAD", false)
577
663
  // resumable refuses (a missing branch, a closed slice PR) before it tests the
578
664
  // chain; only a record that still chains to HEAD may refuse a fresh stack.
579
665
  if (typeof found === "string")
@@ -612,14 +698,16 @@ async function stackPrecheck(
612
698
  git: Git,
613
699
  ok: Ok,
614
700
  branch: string,
701
+ baseBranch: string,
615
702
  groups: Group[],
616
703
  ): Promise<string | undefined> {
617
704
  const recorded = names(await git("config", "--local", "--get", STACK_KEY(branch)))
618
705
  if (recorded.length) {
619
- const refused = await recordedRefusal(io, git, ok, branch, recorded)
706
+ const refused = await recordedRefusal(io, git, ok, branch, baseBranch, recorded)
620
707
  if (refused) return refused
621
708
  }
622
- const taken = await takenSlice(git, ok, branch, groups, recorded)
709
+ const retired = names(await git("config", "--local", "--get", RETIRED_KEY(branch)))
710
+ const taken = await takenSlice(git, ok, branch, groups, [...recorded, ...retired])
623
711
  if (taken) return taken
624
712
  return prStateRefusal(io, branch, groups)
625
713
  }
@@ -655,8 +743,14 @@ async function chain(
655
743
  }
656
744
 
657
745
  // 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) {
746
+ // recorded top, which only a restack moves.
747
+ async function topGuard(
748
+ git: Git,
749
+ ok: Ok,
750
+ branch: string,
751
+ recorded: string[],
752
+ from: number,
753
+ ): Promise<{ top: string } | { rebased: true; top: string } | { refusal: string }> {
660
754
  let missing = ""
661
755
  for (const name of recorded.slice(from, -1)) {
662
756
  const b = `${branch}-${name}`
@@ -668,7 +762,7 @@ async function topGuard(git: Git, ok: Ok, branch: string, recorded: string[], fr
668
762
  const top = await git("config", "--local", "--get", TOP_KEY(branch))
669
763
  if (top && (await ok("merge-base", "--is-ancestor", top, "HEAD"))) return { top }
670
764
  // only the merged floor needs the top: a rebase dropped it, so the stack ends
671
- if (!missing) return { rebased: true as const }
765
+ if (!missing) return { rebased: true, top }
672
766
  return {
673
767
  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
768
  }
@@ -679,65 +773,202 @@ type Resumed = {
679
773
  floor?: string
680
774
  forget?: boolean
681
775
  revive?: () => Promise<string | undefined>
776
+ // Read-only until run(): the caller runs it after the squash and revive, so
777
+ // "squash first, then restack" holds.
778
+ restack?: Restack
779
+ }
780
+
781
+ type Restack = {
782
+ preview: string[]
783
+ // A string is the failure text; `preShip` is HEAD before this run's squash.
784
+ run(preShip?: string): Promise<{ links: Link[]; floor?: string; forget: boolean } | string>
785
+ }
786
+
787
+ // Merged into a slice branch counts only when that branch landed earlier: its
788
+ // chain then reached the default branch. A missing `mergedAt` proves nothing.
789
+ function landedOn(c: Merging, i: number): boolean {
790
+ const { states, branchOf, baseBranch } = c
791
+ const view = states[i]!
792
+ if (view.baseRefName === baseBranch) return true
793
+ const k = [...Array(i).keys()].find((j) => view.baseRefName === branchOf(j))
794
+ const time = (v?: View) => (v?.mergedAt ? Date.parse(v.mergedAt) : NaN)
795
+ return k !== undefined && time(view) <= time(states[k])
796
+ }
797
+
798
+ type Merging = {
799
+ recorded: string[]
800
+ states: (View | undefined)[]
801
+ branchOf: (i: number) => string
802
+ baseBranch: string
803
+ }
804
+
805
+ const sliceNamed = (c: Merging, i: number) =>
806
+ `The pull request for slice ${c.recorded[i]} (#${c.states[i]!.number}, ${c.branchOf(i)})`
807
+
808
+ const mergedElsewhere = (c: Merging, i: number) =>
809
+ `${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.`
810
+
811
+ // What a slice at or above `done` may not be: closed, or merged anywhere but the
812
+ // default branch.
813
+ function unsettled(c: Merging, done: number): string | undefined {
814
+ for (const [i, view] of c.states.entries()) {
815
+ if (i < done || !view || view.state === "OPEN") continue
816
+ if (view.state === "MERGED" && !landedOn(c, i)) return mergedElsewhere(c, i)
817
+ const way =
818
+ i === c.recorded.length - 1
819
+ ? "Rename the branch"
820
+ : "Merged slices drop out of a resumed stack on their own; a closed one needs GTD_SHIP_MODE=commits to ship the branch alone"
821
+ return `${sliceNamed(c, i)} is ${view.state}, not open; nothing changed. ${way}.`
822
+ }
823
+ return undefined
682
824
  }
683
825
 
684
826
  async function mergedSlices(
685
827
  io: ShipIo,
686
828
  recorded: string[],
687
829
  branchOf: (i: number) => string,
688
- ): Promise<number | string> {
830
+ baseBranch: string,
831
+ ): Promise<{ done: number } | string> {
689
832
  const states: (View | undefined)[] = []
690
833
  for (const i of recorded.keys()) {
691
834
  const view = await viewPr(io, branchOf(i))
692
835
  if (typeof view === "string") return `${view} Nothing changed.`
693
836
  states.push(view)
694
837
  }
838
+ const c = { recorded, states, branchOf, baseBranch }
695
839
  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"}.`
840
+ while (done < recorded.length - 1 && states[done]?.state === "MERGED") {
841
+ if (!landedOn(c, done)) return mergedElsewhere(c, done)
842
+ done++
700
843
  }
701
- return done
844
+ return unsettled(c, done) ?? { done }
702
845
  }
703
846
 
704
847
  // 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.
848
+ // `revive` is left to the caller so a squash can come first. `planRestack` false
849
+ // skips the network and worktree reads a restack needs: a caller that only asks
850
+ // whether the record is live must not depend on them. A hand-restacked record
851
+ // is still live then: its shape test is local.
706
852
  async function resumable(
707
853
  io: ShipIo,
708
854
  git: Git,
709
855
  ok: Ok,
710
856
  branch: string,
857
+ baseBranch: string,
711
858
  isDry: boolean,
712
859
  at: string,
860
+ planRestack = true,
713
861
  ): Promise<Resumed | string> {
714
862
  const recorded = names(await git("config", "--local", "--get", STACK_KEY(branch)))
715
863
  if (!recorded.length) return {}
716
864
  const last = recorded.length - 1
717
865
  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
866
+ const merged = await mergedSlices(io, recorded, branchOf, baseBranch)
867
+ if (typeof merged === "string") return merged
868
+ const { done } = merged
720
869
  const guard = await topGuard(git, ok, branch, recorded, done)
721
870
  if ("refusal" in guard) return guard.refusal
722
- const top = "top" in guard ? guard.top : ""
871
+ const { top } = guard
723
872
  const { commits, missing, valid } = await chain(git, ok, branch, recorded, done, top, at)
873
+ if (valid && "rebased" in guard && done) {
874
+ const { from, links } = await stackRest(git, { branch, recorded, done, top, commits, branchOf })
875
+ const hand =
876
+ from &&
877
+ (await handRestack(io, git, ok, {
878
+ branch,
879
+ baseBranch,
880
+ top,
881
+ from,
882
+ links,
883
+ plan: planRestack,
884
+ }))
885
+ if (hand) return hand
886
+ }
724
887
  if (!valid || "rebased" in guard) {
725
888
  io.log(
726
889
  `The recorded stack for ${branch} no longer chains to HEAD: ${isDry ? "" : "dropped it, "}shipping the branch alone.`,
727
890
  )
728
891
  return { forget: true }
729
892
  }
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.`
893
+ return finishResume(io, git, ok, {
894
+ branch,
895
+ baseBranch,
896
+ recorded,
897
+ done,
898
+ top,
899
+ commits,
900
+ missing,
901
+ isDry,
902
+ planRestack,
903
+ branchOf,
904
+ })
905
+ }
906
+
907
+ // `from` is the merged slice's commit, read off the recorded top: the local
908
+ // slice branch may have moved since.
909
+ async function stackRest(
910
+ git: Git,
911
+ c: {
912
+ branch: string
913
+ recorded: string[]
914
+ done: number
915
+ top: string
916
+ commits: string[]
917
+ branchOf: (i: number) => string
918
+ },
919
+ ): Promise<{ from?: string; links: Link[] }> {
920
+ const { recorded, done, top, commits, branchOf } = c
921
+ const from =
922
+ done && top
923
+ ? (await git(
924
+ "rev-parse",
925
+ "--verify",
926
+ "--quiet",
927
+ `${top}~${recorded.length - 1 - (done - 1)}`,
928
+ )) || undefined
929
+ : undefined
735
930
  const head = await git("rev-parse", "HEAD")
736
931
  const links = recorded.slice(done).map((name, j) => ({
737
932
  name,
738
933
  branch: branchOf(done + j),
739
934
  commit: commits[j] ?? head,
740
935
  }))
936
+ return { from, links }
937
+ }
938
+
939
+ async function finishResume(
940
+ io: ShipIo,
941
+ git: Git,
942
+ ok: Ok,
943
+ c: {
944
+ branch: string
945
+ baseBranch: string
946
+ recorded: string[]
947
+ done: number
948
+ top: string
949
+ commits: string[]
950
+ missing: number[]
951
+ isDry: boolean
952
+ planRestack: boolean
953
+ branchOf: (i: number) => string
954
+ },
955
+ ): Promise<Resumed | string> {
956
+ const { branch, baseBranch, recorded, done, commits, missing, isDry, planRestack, branchOf } = c
957
+ const { from, links } = await stackRest(git, c)
958
+ if (done && !from)
959
+ return `The commit of merged slice ${recorded[done - 1]} cannot be found; nothing changed. Ship the branch alone with GTD_SHIP_MODE=commits.`
960
+ const restack =
961
+ done && planRestack
962
+ ? await restackOf(io, git, ok, {
963
+ branch,
964
+ baseBranch,
965
+ from: from!,
966
+ links,
967
+ merged: recorded.slice(0, done),
968
+ isDry,
969
+ })
970
+ : undefined
971
+ if (typeof restack === "string") return restack
741
972
  const revive = isDry
742
973
  ? undefined
743
974
  : async () => {
@@ -748,7 +979,428 @@ async function resumable(
748
979
  }
749
980
  return undefined
750
981
  }
751
- return { links, floor, revive }
982
+ return { links, floor: from, revive, restack }
983
+ }
984
+
985
+ // The lease is origin's sha as read here, never origin/<b>: a fetch rewrites
986
+ // that. Checked before any rebase, squash or push, so a refusal changes
987
+ // nothing.
988
+ async function originLeases(
989
+ io: ShipIo,
990
+ ok: Ok,
991
+ links: Link[],
992
+ kept: (l: Link, sha: string) => Promise<boolean>,
993
+ ): Promise<Link[] | string> {
994
+ const read = await io.run([
995
+ "git",
996
+ "ls-remote",
997
+ "origin",
998
+ ...links.map((l) => `refs/heads/${l.branch}`),
999
+ ])
1000
+ if (read.code !== 0) return `git ls-remote origin failed; nothing changed.\n${read.err}`.trimEnd()
1001
+ const origin = new Map<string, string>()
1002
+ for (const line of read.out.split("\n")) {
1003
+ const [sha, ref] = line.trim().split(/\s+/)
1004
+ if (sha && ref?.startsWith("refs/heads/")) origin.set(ref.slice("refs/heads/".length), sha)
1005
+ }
1006
+ for (const l of links) {
1007
+ const sha = origin.get(l.branch)
1008
+ if (sha && !((await ok("cat-file", "-e", sha)) && (await kept(l, sha))))
1009
+ return `origin/${l.branch} holds commits ${l.branch} lacks; nothing changed. Pull or reconcile ${l.branch}, then ship.`
1010
+ }
1011
+ return links.map((l) => ({ ...l, lease: origin.get(l.branch), fresh: !origin.has(l.branch) }))
1012
+ }
1013
+
1014
+ const pushLines = (links: Link[]) =>
1015
+ links.map((l) =>
1016
+ l.lease
1017
+ ? `Push: would run git push --force-with-lease=${l.branch}:${l.lease} origin ${l.branch}`
1018
+ : `Push: would run git push --set-upstream origin ${l.branch}`,
1019
+ )
1020
+
1021
+ // `git worktree list` is the one place git says a branch is checked out in
1022
+ // another worktree, where `rebase --update-refs` silently skips it.
1023
+ async function checkedOutElsewhere(
1024
+ io: ShipIo,
1025
+ branches: string[],
1026
+ ): Promise<{ branch: string; path: string } | undefined | string> {
1027
+ const listed = await io.run(["git", "worktree", "list", "--porcelain"])
1028
+ if (listed.code !== 0)
1029
+ return `git worktree list failed; nothing changed.\n${listed.err}`.trimEnd()
1030
+ const held = new Map<string, string>()
1031
+ let path = ""
1032
+ for (const line of listed.out.split("\n")) {
1033
+ if (line.startsWith("worktree ")) path = line.slice("worktree ".length)
1034
+ else if (line.startsWith("branch refs/heads/")) {
1035
+ const b = line.slice("branch refs/heads/".length)
1036
+ if (!held.has(b)) held.set(b, path)
1037
+ }
1038
+ }
1039
+ for (const b of branches) if (held.has(b)) return { branch: b, path: held.get(b)! }
1040
+ return undefined
1041
+ }
1042
+
1043
+ // The hand lines of whatever could not be restored: an empty list is a clean rollback.
1044
+ async function rollback(git: Git, ok: Ok, pre: Map<string, string>, preHead: string) {
1045
+ const failed: string[] = []
1046
+ for (const [r, sha] of pre) {
1047
+ const now = await git("rev-parse", `refs/heads/${r}`)
1048
+ if (!(await ok("update-ref", `refs/heads/${r}`, sha, now)))
1049
+ failed.push(`git update-ref refs/heads/${r} ${sha} ${now}`)
1050
+ }
1051
+ if (!(await ok("reset", "--keep", preHead))) failed.push(`git reset --keep ${preHead}`)
1052
+ return failed
1053
+ }
1054
+
1055
+ const rollbackFailed = (why: string, failed: string[], undo: string | undefined, err = "") =>
1056
+ [
1057
+ `${why}, and the rollback failed: slice branches stay rebased and nothing was pushed or recorded. Run by hand, then /gtd ship:`,
1058
+ ...failed,
1059
+ err,
1060
+ undo,
1061
+ ]
1062
+ .filter(Boolean)
1063
+ .join("\n")
1064
+
1065
+ // Each remaining lower slice sits one commit above the one below it, and the top
1066
+ // keeps a commit of its own: a rebase that drops an already-upstream commit exits
1067
+ // 0 and leaves that branch on `onto` itself.
1068
+ async function brokenShape(
1069
+ git: Git,
1070
+ links: Link[],
1071
+ branch: string,
1072
+ onto: string,
1073
+ ): Promise<string | undefined> {
1074
+ const lower = links.filter((l) => l.branch !== branch)
1075
+ for (const [i, l] of lower.entries()) {
1076
+ const below = i ? `refs/heads/${lower[i - 1]!.branch}` : onto
1077
+ if ((await git("rev-parse", `refs/heads/${l.branch}~1`)) !== (await git("rev-parse", below)))
1078
+ return l.name
1079
+ }
1080
+ const under = lower.length ? `refs/heads/${lower.at(-1)!.branch}` : onto
1081
+ if (Number(await git("rev-list", "--count", `${under}..HEAD`)) < 1) return links.at(-1)!.name
1082
+ return undefined
1083
+ }
1084
+
1085
+ // --update-refs skips a branch it cannot move; leaving the rest rebased would
1086
+ // make the next ship see a broken chain and silently drop the stack.
1087
+ async function unmovedSlice(
1088
+ io: ShipIo,
1089
+ git: Git,
1090
+ ok: Ok,
1091
+ c: {
1092
+ lower: string[]
1093
+ onto: string
1094
+ from: string
1095
+ pre: Map<string, string>
1096
+ preHead: string
1097
+ undo: string | undefined
1098
+ },
1099
+ ): Promise<string | undefined> {
1100
+ const { lower, onto, from, pre, preHead, undo } = c
1101
+ for (const b of lower) {
1102
+ const at = `refs/heads/${b}`
1103
+ const moved =
1104
+ (await ok("merge-base", "--is-ancestor", onto, at)) &&
1105
+ !(await ok("merge-base", "--is-ancestor", from, at))
1106
+ if (moved) continue
1107
+ const failed = await rollback(git, ok, pre, preHead)
1108
+ if (failed.length) return rollbackFailed(`Slice branch ${b} was not restacked`, failed, undo)
1109
+ const held = await checkedOutElsewhere(io, [b])
1110
+ const where = typeof held === "object" ? ` (checked out in worktree ${held.path})` : ""
1111
+ return [
1112
+ `Slice branch ${b} was not restacked${where}; the slice branches and HEAD are restored and nothing was pushed or recorded.`,
1113
+ undo,
1114
+ ]
1115
+ .filter(Boolean)
1116
+ .join("\n")
1117
+ }
1118
+ return undefined
1119
+ }
1120
+
1121
+ // --update-refs also drags any other local branch pointing into the range along,
1122
+ // and no rollback restores it. Over-refuses a branch sitting on an unsquashed
1123
+ // top-slice process commit: accepted, a refusal changes nothing.
1124
+ async function strayBranch(
1125
+ git: Git,
1126
+ links: Link[],
1127
+ merged: string[],
1128
+ from: string,
1129
+ branch: string,
1130
+ ): Promise<string | undefined> {
1131
+ const inRange = new Set(
1132
+ (await git("rev-list", `${from}..refs/heads/${branch}`)).split("\n").filter(Boolean),
1133
+ )
1134
+ if (!inRange.size) return undefined
1135
+ const known = new Set([...links.map((l) => l.branch), ...merged.map((n) => `${branch}-${n}`)])
1136
+ const refs = await git("for-each-ref", "--format=%(objectname) %(refname)", "refs/heads/")
1137
+ for (const line of refs.split("\n")) {
1138
+ const [sha, ref] = line.trim().split(/\s+/)
1139
+ const b = ref?.replace(/^refs\/heads\//, "")
1140
+ if (sha && b && inRange.has(sha) && !known.has(b))
1141
+ 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.`
1142
+ }
1143
+ return undefined
1144
+ }
1145
+
1146
+ const lines = (...parts: (string | undefined)[]) => parts.filter(Boolean).join("\n")
1147
+
1148
+ type Rebased = {
1149
+ links: Link[]
1150
+ branch: string
1151
+ rebase: string
1152
+ pre: Map<string, string>
1153
+ preHead: string
1154
+ undo: string | undefined
1155
+ }
1156
+
1157
+ // A non-zero `git rebase`: a conflict is aborted; no rebase dir means either git
1158
+ // older than 2.38 refused --update-refs, or every pick finished and only the final
1159
+ // ref update failed, so anything that moved is put back.
1160
+ async function rebaseFailed(
1161
+ io: ShipIo,
1162
+ git: Git,
1163
+ ok: Ok,
1164
+ x: Rebased & { done: Ran },
1165
+ ): Promise<string> {
1166
+ const { done, links, branch, rebase, pre, preHead, undo } = x
1167
+ const stopped = await git("rev-parse", "--verify", "--quiet", "REBASE_HEAD")
1168
+ const dir = await git("rev-parse", "--git-path", "rebase-merge")
1169
+ if (!dir || (await io.run(["test", "-d", dir])).code !== 0) {
1170
+ let moved = (await git("rev-parse", "HEAD")) !== preHead
1171
+ for (const [b, sha] of pre)
1172
+ if ((await git("rev-parse", `refs/heads/${b}`)) !== sha) moved = true
1173
+ if (!moved) return lines(`git rebase failed; refs are unchanged.\n${done.err}`.trimEnd(), undo)
1174
+ const failed = await rollback(git, ok, pre, preHead)
1175
+ if (failed.length)
1176
+ return rollbackFailed("git rebase failed after moving refs", failed, undo, done.err.trimEnd())
1177
+ return lines(
1178
+ `git rebase failed after moving refs; the slice branches and HEAD are restored and nothing was pushed or recorded.\n${done.err}`.trimEnd(),
1179
+ undo,
1180
+ )
1181
+ }
1182
+ const aborted = await io.run(["git", "rebase", "--abort"])
1183
+ const name =
1184
+ links.find((l) => l.branch !== branch && l.commit === stopped)?.name ?? links.at(-1)!.name
1185
+ const byHand = `Rebase by hand, which restacks every slice branch:\n${rebase}\nthen /gtd ship.`
1186
+ if (aborted.code === 0)
1187
+ return lines(
1188
+ `Slice ${name} failed at restack: git rebase conflicted; tree and refs are as before.`,
1189
+ byHand,
1190
+ undo,
1191
+ )
1192
+ return lines(
1193
+ `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(),
1194
+ undo,
1195
+ )
1196
+ }
1197
+
1198
+ async function shapeRefusal(
1199
+ git: Git,
1200
+ ok: Ok,
1201
+ x: Rebased & { onto: string },
1202
+ ): Promise<string | undefined> {
1203
+ const { links, branch, onto, pre, preHead, undo } = x
1204
+ const broken = await brokenShape(git, links, branch, onto)
1205
+ if (broken === undefined) return undefined
1206
+ const why = `Slice ${broken} keeps no commit of its own after the restack (its change is already in ${onto})`
1207
+ const failed = await rollback(git, ok, pre, preHead)
1208
+ if (failed.length) return rollbackFailed(why, failed, undo)
1209
+ return lines(
1210
+ `${why}; the slice branches and HEAD are restored and nothing was pushed or recorded. Ship the branch alone with GTD_SHIP_MODE=commits.`,
1211
+ undo,
1212
+ )
1213
+ }
1214
+
1215
+ // The rebase target is origin's copy: the local default branch is never touched.
1216
+ async function restackOf(
1217
+ io: ShipIo,
1218
+ git: Git,
1219
+ ok: Ok,
1220
+ c: {
1221
+ branch: string
1222
+ baseBranch: string
1223
+ from: string
1224
+ links: Link[]
1225
+ merged: string[]
1226
+ isDry: boolean
1227
+ },
1228
+ ): Promise<Restack | string | undefined> {
1229
+ const { branch, baseBranch, from, isDry } = c
1230
+ const onto = `origin/${baseBranch}`
1231
+ if (!isDry) {
1232
+ const refspec = `+refs/heads/${baseBranch}:refs/remotes/origin/${baseBranch}`
1233
+ const fetched = await io.run(["git", "fetch", "origin", refspec])
1234
+ if (fetched.code !== 0)
1235
+ return `git fetch origin ${refspec} failed; nothing changed.\n${fetched.err}`.trimEnd()
1236
+ }
1237
+ // A merge commit keeps the slice's commit: only the retarget is left to do.
1238
+ if (await ok("merge-base", "--is-ancestor", from, onto)) return undefined
1239
+ const lower = c.links.filter((l) => l.branch !== branch).map((l) => l.branch)
1240
+ if (lower.length) {
1241
+ const held = await checkedOutElsewhere(io, lower)
1242
+ if (typeof held === "string") return held
1243
+ if (held)
1244
+ 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.`
1245
+ }
1246
+ const stray = await strayBranch(git, c.links, c.merged, from, branch)
1247
+ if (stray) return stray
1248
+ const links = await originLeases(io, ok, c.links, (l, sha) =>
1249
+ ok("merge-base", "--is-ancestor", sha, l.commit),
1250
+ )
1251
+ if (typeof links === "string") return links
1252
+ const rebase = `git rebase --update-refs --onto ${onto} ${from} ${branch}`
1253
+ const preview = [
1254
+ `Restack: would run ${rebase} (planned from the last fetch of ${onto})`,
1255
+ ...pushLines(links),
1256
+ ]
1257
+ const run = async (preShip?: string) => {
1258
+ const undo = preShip && `the squash was kept; undo it with git reset --soft ${preShip}`
1259
+ const preHead = await git("rev-parse", "HEAD")
1260
+ const pre = new Map<string, string>()
1261
+ for (const b of lower) pre.set(b, await git("rev-parse", `refs/heads/${b}`))
1262
+ const done = await io.run(["git", "rebase", "--update-refs", "--onto", onto, from, branch])
1263
+ const x = { links, branch, rebase, pre, preHead, undo }
1264
+ if (done.code !== 0) return rebaseFailed(io, git, ok, { ...x, done })
1265
+ const failure = await unmovedSlice(io, git, ok, { lower, onto, from, pre, preHead, undo })
1266
+ if (failure) return failure
1267
+ const broken = await shapeRefusal(git, ok, { ...x, onto })
1268
+ if (broken) return broken
1269
+ return restacked(io, git, branch, baseBranch, links)
1270
+ }
1271
+ return { preview, run }
1272
+ }
1273
+
1274
+ // The record shrinks to the remaining slices, before any push: a failed push then
1275
+ // resumes from a record that matches the rebased refs.
1276
+ async function restacked(io: ShipIo, git: Git, branch: string, baseBranch: string, links: Link[]) {
1277
+ // Before the record shrinks: a push rejected after it must keep its lease on
1278
+ // retry, or a fetch + re-ship overwrites a collaborator's commit.
1279
+ for (const l of links) {
1280
+ if (!l.lease) continue
1281
+ const kept = await io.run(["git", "config", "--local", LEASE_KEY(l.branch), l.lease])
1282
+ if (kept.code !== 0)
1283
+ return `git config failed after the restack; the branches are rebased and /gtd ship picks that up.\n${kept.err}`.trimEnd()
1284
+ }
1285
+ const moved: Link[] = []
1286
+ for (const l of links)
1287
+ moved.push({
1288
+ ...l,
1289
+ commit: await git("rev-parse", l.branch === branch ? "HEAD" : `refs/heads/${l.branch}`),
1290
+ })
1291
+ const floor =
1292
+ (await git("merge-base", `origin/${baseBranch}`, `refs/heads/${moved[0]!.branch}`)) || undefined
1293
+ // One slice left ends the stack, through the usual retire-then-forget path.
1294
+ if (moved.length === 1) return { links: moved, floor, forget: true }
1295
+ const above = moved.at(-2)!.branch
1296
+ const first = (
1297
+ await git("rev-list", "--reverse", `refs/heads/${above}..refs/heads/${branch}`)
1298
+ ).split("\n")[0]!
1299
+ // The record first: a failed top write leaves a stale top that topGuard still
1300
+ // guards, where the reverse order leaves the new top with the old names.
1301
+ const written = await io.run([
1302
+ "git",
1303
+ "config",
1304
+ "--local",
1305
+ STACK_KEY(branch),
1306
+ moved.map((l) => l.name).join(" "),
1307
+ ])
1308
+ const set =
1309
+ written.code !== 0
1310
+ ? written
1311
+ : await io.run(["git", "config", "--local", TOP_KEY(branch), first])
1312
+ if (set.code !== 0)
1313
+ return `git config failed after the restack; the branches are rebased and /gtd ship picks that up.\n${set.err}`.trimEnd()
1314
+ return { links: moved, floor, forget: false }
1315
+ }
1316
+
1317
+ // A re-shipped top spans several commits, so `<lowest>~1` is not on origin: the
1318
+ // first commit above it must instead be the recorded top's copy, by author, date
1319
+ // and message (a rebase keeps those, a patch-id would not survive a hand-resolved
1320
+ // conflict). Any linear branch passes a bare parent test, so that alone would loosen
1321
+ // the guard against an unrelated rebase.
1322
+ async function isRecordedTop(git: Git, ok: Ok, onto: string, lowest: string, top: string) {
1323
+ if (!top) return false
1324
+ const first = (await git("rev-list", "--reverse", `${onto}..refs/heads/${lowest}`)).split("\n")[0]
1325
+ if (!first || !(await ok("merge-base", "--is-ancestor", `${first}^`, onto))) return false
1326
+ const identity = (sha: string) => git("log", "-1", `--format=${IDENTITY}`, sha)
1327
+ const mine = await identity(first)
1328
+ return mine !== "" && mine === (await identity(top))
1329
+ }
1330
+
1331
+ // Someone rebased by hand after a conflict. Every condition must hold, or an
1332
+ // unrelated rebase would turn into a force push: the merged slice's commit is
1333
+ // off the lowest remaining branch, origin/<default> is under it, and origin's
1334
+ // copies hold nothing the local branch lacks. Anything else keeps the
1335
+ // drop-and-ship-alone rule.
1336
+ async function handRestack(
1337
+ io: ShipIo,
1338
+ git: Git,
1339
+ ok: Ok,
1340
+ c: {
1341
+ branch: string
1342
+ baseBranch: string
1343
+ top: string
1344
+ from: string
1345
+ links: Link[]
1346
+ plan: boolean
1347
+ },
1348
+ ): Promise<Resumed | string | undefined> {
1349
+ const { branch, baseBranch, top, from, links: slices } = c
1350
+ const lowest = slices[0]!.branch
1351
+ const lowestRef = `refs/heads/${lowest}`
1352
+ if (await ok("merge-base", "--is-ancestor", from, lowestRef)) return undefined
1353
+ // origin may have moved on since the hand rebase: the lowest branch only has
1354
+ // to sit on a commit of origin/<default>.
1355
+ const onto = `origin/${baseBranch}`
1356
+ if (
1357
+ !(await ok("merge-base", "--is-ancestor", `${lowestRef}~1`, onto)) &&
1358
+ !(slices.length === 1 && (await isRecordedTop(git, ok, onto, lowest, top)))
1359
+ )
1360
+ return undefined
1361
+ // A liveness probe needs only the shape: origin is not asked.
1362
+ if (!c.plan) return { links: slices, forget: slices.length === 1 }
1363
+ const links = await originLeases(io, ok, slices, (l, sha) =>
1364
+ keptByHand(git, ok, { branch: l.branch, sha, top, from, onto }),
1365
+ )
1366
+ if (typeof links === "string") return links
1367
+ const restack: Restack = {
1368
+ preview: [
1369
+ `Restack: spotted a hand restack of ${links.map((l) => l.branch).join(", ")}; no rebase`,
1370
+ ...pushLines(links),
1371
+ ],
1372
+ run: () => restacked(io, git, branch, baseBranch, links),
1373
+ }
1374
+ const floor = (await git("merge-base", onto, `refs/heads/${links[0]!.branch}`)) || undefined
1375
+ return { links, floor, forget: links.length === 1, restack }
1376
+ }
1377
+
1378
+ const IDENTITY = "%an%x00%ae%x00%at%x00%B"
1379
+
1380
+ // The pre-rebase tips are gone, and the reflog also holds commits pulled in and
1381
+ // dropped on purpose: origin's copy passes only when every commit it holds
1382
+ // beyond the recorded top (or the merge base, once that top is gone) is in the
1383
+ // local branch by identity and diff (index and hunk-offset lines ignored), a
1384
+ // rebase keeping author, date, message and patch. The merged slice's commits
1385
+ // are expected to be gone. git prints each patch after its header, so the
1386
+ // separator leads the header to keep a commit and its patch in one entry.
1387
+ async function keptByHand(
1388
+ git: Git,
1389
+ ok: Ok,
1390
+ c: { branch: string; sha: string; top: string; from: string; onto: string },
1391
+ ): Promise<boolean> {
1392
+ const { branch, sha, top, from, onto } = c
1393
+ const local = `refs/heads/${branch}`
1394
+ const lower = (await ok("cat-file", "-e", top)) ? top : await git("merge-base", sha, local)
1395
+ if (!lower) return false
1396
+ const identities = async (...range: string[]) =>
1397
+ (await git("log", "-p", "--no-color", `--format=%x1e${IDENTITY}`, ...range))
1398
+ .split("\x1e")
1399
+ .map((i) => i.replace(/^index .*\n|^(@@) .* @@/gm, "$1").trim())
1400
+ .filter(Boolean)
1401
+ const mine = new Set(await identities(`${onto}..${local}`))
1402
+ const theirs = await identities(sha, `^${lower}`, `^${onto}`, `^${from}`)
1403
+ return theirs.every((i) => mine.has(i))
752
1404
  }
753
1405
 
754
1406
  const baseOf = (links: Link[], i: number, baseBranch: string) =>
@@ -760,8 +1412,9 @@ async function stackPreview(
760
1412
  baseBranch: string,
761
1413
  messages: string[] | undefined,
762
1414
  history?: string,
1415
+ restack?: Restack,
763
1416
  ): Promise<string | { refusal: string }> {
764
- const parts: string[] = []
1417
+ const parts: string[] = restack ? [restack.preview.join("\n")] : []
765
1418
  for (const [i, l] of links.entries()) {
766
1419
  const base = baseOf(links, i, baseBranch)
767
1420
  const view = await viewPr(io, l.branch)
@@ -824,7 +1477,7 @@ async function sliceStep(
824
1477
  async function pushLink(io: ShipIo, git: Git, l: Link, upper: boolean) {
825
1478
  if (upper && (await git("rev-parse", `refs/heads/${l.branch}`)) !== l.commit)
826
1479
  return `refs/heads/${l.branch} no longer points at ${short(l.commit)}.`
827
- return pushBranch(io, l.branch, l.commit)
1480
+ return pushBranch(io, l.branch, l.commit, l.lease, l.fresh)
828
1481
  }
829
1482
 
830
1483
  async function shipLink(
@@ -1122,16 +1775,63 @@ async function pullRequest(
1122
1775
  // A pull request describes what the remote branch holds. A squash rewrote the
1123
1776
  // branch, so an existing origin/<branch> needs --force-with-lease. Returns why
1124
1777
  // the push failed, or nothing.
1125
- export async function pushBranch(io: ShipIo, branch: string, head = "HEAD") {
1778
+ export async function pushBranch(
1779
+ io: ShipIo,
1780
+ branch: string,
1781
+ head = "HEAD",
1782
+ lease?: string,
1783
+ fresh = false,
1784
+ ) {
1126
1785
  const ok = async (...args: string[]) => (await io.run(["git", ...args])).code === 0
1786
+ // A rejected restack push persisted its lease: a fetch moves the tracking ref, so
1787
+ // only the stored sha still tells a collaborator's commit from ours.
1788
+ let stored = (await io.run(["git", "config", "--local", "--get", LEASE_KEY(branch)])).out.trim()
1789
+ let kept = lease || fresh ? "" : stored
1790
+ let gone = fresh
1791
+ if (kept) {
1792
+ // The key may be days old: origin's own sha decides whether it still applies.
1793
+ const read = await io.run(["git", "ls-remote", "origin", `refs/heads/${branch}`])
1794
+ const at = read.code === 0 ? read.out.trim().split(/\s+/)[0] : undefined
1795
+ if (read.code === 0 && !at) {
1796
+ // Origin lost the branch: nothing to lease on, so push it fresh.
1797
+ await io.run(["git", "config", "--local", "--unset", LEASE_KEY(branch)])
1798
+ kept = stored = ""
1799
+ gone = true
1800
+ } else if (
1801
+ at &&
1802
+ (await ok("cat-file", "-e", at)) &&
1803
+ (await ok("merge-base", "--is-ancestor", at, head))
1804
+ )
1805
+ // Reconciled: origin's commit is already in what is pushed.
1806
+ lease = at
1807
+ }
1808
+ lease ??= kept || undefined
1127
1809
  const push = async (args: string[], failure: string) => {
1128
1810
  const r = await attempt(io, ["git", "push", ...args])
1129
1811
  if (r.killed)
1130
1812
  return `git push of ${branch} did not finish; nothing was lost. Retry without fetching.\n${r.err}`.trimEnd()
1131
- if (r.code !== 0) return `${failure}\n${r.err}`.trimEnd()
1813
+ if (r.code !== 0)
1814
+ return (
1815
+ `${failure}\n${r.err}`.trimEnd() +
1816
+ (kept && /stale info|rejected/.test(r.err)
1817
+ ? `\nTo overwrite origin's copy on purpose, run git config --local --unset ${LEASE_KEY(branch)} first.`
1818
+ : "")
1819
+ )
1820
+ // Any success settles the key, the restack's own explicit-lease push included:
1821
+ // a stale sha left behind would reject every later rewrite of the branch.
1822
+ if (stored) await io.run(["git", "config", "--local", "--unset", LEASE_KEY(branch)])
1132
1823
  return undefined
1133
1824
  }
1134
- if (!(await ok("rev-parse", "--verify", "--quiet", `origin/${branch}`))) {
1825
+ // A restack rewrote the branch: lease on origin's sha (it exists, whatever the
1826
+ // tracking ref says), with no ancestor shortcut.
1827
+ if (lease) {
1828
+ io.log(`Pushing ${branch} to origin…`)
1829
+ return push(
1830
+ [`--force-with-lease=${branch}:${lease}`, "origin", branch],
1831
+ `git push failed: origin/${branch} moved. Pull or reconcile ${branch}, then ship.`,
1832
+ )
1833
+ }
1834
+ if (gone || !(await ok("rev-parse", "--verify", "--quiet", `origin/${branch}`))) {
1135
1835
  io.log(`Pushing ${branch} to origin…`)
1136
1836
  return push(["--set-upstream", "origin", branch], "git push failed.")
1137
1837
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "29.0.0",
3
+ "version": "29.1.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {