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