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