@pmelab/gtd 29.0.0 → 29.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +29 -19
- package/claude/hooks/handoff.ts +2 -3
- package/claude/hooks/ship.ts +762 -62
- package/dist/chunks/{reserved-DoHUII2D.mjs → reserved-fqf-c9dc.mjs} +34 -9
- package/dist/chunks/{tail-C6YBG5zh.mjs → tail-Do11DVcw.mjs} +1 -1
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +127 -41
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +2 -2
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +159 -48
- package/package.json +1 -1
- package/src/workflows/feature/feature.ts +6 -5
- package/src/workflows/feature/packages.test.ts +299 -5
- package/src/workflows/feature/packages.ts +109 -26
- package/src/workflows/feature/refactor.ts +9 -2
- package/src/workflows/feature/review.ts +25 -7
- package/src/workflows/feature/seam.ts +28 -8
- package/src/workflows/shared/text.test.ts +44 -0
- package/src/workflows/shared/text.ts +57 -10
package/claude/hooks/ship.ts
CHANGED
|
@@ -151,20 +151,32 @@ const attempt = async (io: ShipIo, argv: string[]): Promise<Ran & { killed: bool
|
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
297
|
-
//
|
|
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
|
|
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<
|
|
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 {
|
|
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
|
|
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
|
|
659
|
-
async function topGuard(
|
|
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
|
|
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
|
-
|
|
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")
|
|
697
|
-
|
|
698
|
-
|
|
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
|
|
719
|
-
if (typeof
|
|
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
|
|
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
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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(
|
|
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)
|
|
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
|
-
|
|
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
|
}
|