@pmelab/gtd 27.9.0 → 28.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +53 -27
- package/claude/hooks/doors.ts +12 -0
- package/claude/hooks/register.tsx +2 -4
- package/claude/hooks/ship.ts +115 -20
- package/dist/chunks/{reserved-UdSuHDUJ.mjs → reserved-DeBS4Ymh.mjs} +178 -32
- package/dist/chunks/{tail-fj60rQ3y.mjs → tail-B2SER2Ih.mjs} +1 -1
- package/dist/gtd.bundle.mjs +209 -72
- package/dist/testkit.mjs +209 -72
- package/dist/workflows/cleanup.mjs +3 -4
- package/dist/workflows/feature.mjs +2 -2
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +2 -2
- package/dist/workflows/rollout.mjs +19 -4
- package/dist/workflows.mjs +196 -132
- package/package.json +1 -1
- package/skills/authoring/SKILL.md +4 -3
- package/src/cli/Cli.test.ts +39 -0
- package/src/cli/Cli.ts +41 -9
- package/src/cli/Program.test.ts +31 -0
- package/src/cli/Program.ts +14 -7
- package/src/edge/Edge.test.ts +259 -3
- package/src/edge/Edge.ts +175 -58
- package/src/flows/runtime.ts +20 -1
- package/src/install/ConfigScaffold.ts +4 -1
- package/src/install/Install.test.ts +10 -0
- package/src/install/Install.ts +5 -2
- package/src/workflow/load.test.ts +82 -5
- package/src/workflow/load.ts +40 -16
- package/src/workflows/cleanup/cleanup.feature +21 -0
- package/src/workflows/cleanup/cleanup.ts +1 -1
- package/src/workflows/index.ts +2 -1
- package/src/workflows/rollout/rollout.feature +282 -2
- package/src/workflows/rollout/rollout.test.ts +96 -2
- package/src/workflows/rollout/rollout.ts +41 -3
- package/src/workflows/shared/options.test.ts +25 -2
- package/src/workflows/shared/options.ts +7 -4
- package/src/workflows/shared/registry.test.ts +41 -1
- package/src/workflows/shared/registry.ts +26 -1
- package/src/workflows/shared/text.test.ts +377 -2
- package/src/workflows/shared/text.ts +65 -7
- package/src/workflows/shared/vars.ts +2 -0
- package/dist/chunks/registry-CP3YuV9j.mjs +0 -101
package/README.md
CHANGED
|
@@ -327,23 +327,25 @@ Then, in any repository:
|
|
|
327
327
|
That starts a process from your requirements and drives it until it needs you. A
|
|
328
328
|
long, multi-line requirements paste still starts a process; if Claude Code never
|
|
329
329
|
expanded the paste, gtd says it could not read it, and you write the
|
|
330
|
-
requirements to `.gtd/TODO.md` and run `/gtd`. `/gtd fix
|
|
331
|
-
`/gtd
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
and
|
|
330
|
+
requirements to `.gtd/TODO.md` and run `/gtd`. `/gtd fix`, `/gtd review [base]`,
|
|
331
|
+
`/gtd rollout <flag>` and `/gtd cleanup <flag>` (where the config exports them)
|
|
332
|
+
take the doors described below; text whose word count does not fit a door's args
|
|
333
|
+
(bare `/gtd rollout` included) starts a process from that text as requirements.
|
|
334
|
+
At every human rest Claude itself takes over: it walks you through the open
|
|
335
|
+
questions one at a time, answers your own questions about the plan or the diff,
|
|
336
|
+
records what you decide in the steering file the way the step asks, and moves
|
|
337
|
+
the process on only when you say so; a review you leave untouched signs off.
|
|
338
|
+
While a step runs you can keep asking; requests for changes wait for the next
|
|
339
|
+
rest. When the process finishes, Claude offers to squash it into one commit and
|
|
340
|
+
open its pull request (or run `/gtd ship`). A process can change hands at any
|
|
341
|
+
gate: ask Claude to hand it off (or run `/gtd throw @dev`) and it opens a draft
|
|
342
|
+
pull request assigned to them, and `/gtd catch <pr>` picks it up exactly where
|
|
343
|
+
it waits, so whoever wrote the requirements can hand the architecture to someone
|
|
344
|
+
else. Ship, hand-off and throw answer at once and run in the background, with
|
|
345
|
+
progress on the status line; they report by waking Claude, a toast, a transcript
|
|
346
|
+
line and a push notification, and a failed push or `gh` call reports as a
|
|
347
|
+
failure. While a ship or hand-off runs or is queued, `/gtd catch`, the doors,
|
|
348
|
+
`/gtd <requirements>` and bare `/gtd` wait for its report. See
|
|
347
349
|
[Inside Claude Code](https://github.com/pmelab/gtd/blob/main/docs/driver.md#inside-claude-code-the-gtd-mod).
|
|
348
350
|
|
|
349
351
|
### Then let an agent build your own
|
|
@@ -362,8 +364,9 @@ state name to choose. The four commands: **`gtd-build`** drives beats until the
|
|
|
362
364
|
process rests; **`gtd-edit`** opens the steering file the process is waiting on
|
|
363
365
|
right now — falling back to `.gtd/TODO.md` when the resting state declares none;
|
|
364
366
|
**`gtd-door <name> [args...]`** starts a process through a door (`gtd-door fix`,
|
|
365
|
-
`gtd-door review [base]`
|
|
366
|
-
|
|
367
|
+
`gtd-door review [base]`, `gtd-door rollout <flag>`, `gtd-door cleanup <flag>`
|
|
368
|
+
where the config exports them) and drives it. The `.gtd/TODO.md` fallback is
|
|
369
|
+
also how you begin: on a clean repository the edit command opens the empty
|
|
367
370
|
`.gtd/TODO.md`, and whatever you write there is the first sketch the whole
|
|
368
371
|
process gets planned from. As a final step, if it finds an LSP-capable editor,
|
|
369
372
|
the briefing also offers to wire up live diagnostics and review actions in it —
|
|
@@ -580,11 +583,25 @@ gate.
|
|
|
580
583
|
|
|
581
584
|
### Rolling out and cleaning up release flags
|
|
582
585
|
|
|
583
|
-
Two more bundled workflows retire a release flag:
|
|
584
|
-
`gtd
|
|
585
|
-
`
|
|
586
|
+
Two more bundled workflows retire a release flag. They are opt-in: the
|
|
587
|
+
`gtd.config.ts` that `gtd install` writes does not export them, so add `rollout`
|
|
588
|
+
from `@pmelab/gtd/workflows/rollout` and `cleanup` from
|
|
589
|
+
`@pmelab/gtd/workflows/cleanup` to its exports first. `gtd door rollout <flag>`
|
|
590
|
+
turns it default-on, `gtd door cleanup <flag>` removes it afterwards
|
|
591
|
+
(`/gtd rollout <flag>` and `/gtd cleanup <flag>` in Claude Code;
|
|
592
|
+
`gtd --workflow rollout --var flag=<name>` works too). See
|
|
586
593
|
[Feature flags](docs/configuration.md#rolling-out-and-cleaning-up-a-release-flag).
|
|
587
594
|
|
|
595
|
+
Rollout makes "on under `GTD_FLAG_<NAME>=1`" prose wrong until cleanup, so once
|
|
596
|
+
the suite passes it lists every line outside the registry that names the flag
|
|
597
|
+
(as `path:line`, the first 50, then a `git grep` command over tracked files for
|
|
598
|
+
the rest) and edits none of them, leaving no file behind. The squash carries the
|
|
599
|
+
list only through the closing message `gtd summary` writes; the `Gtd-Mark:`
|
|
600
|
+
trailers survive only in the process history ref `refs/gtd/history/<entry>`,
|
|
601
|
+
recovered with `gtd history fetch | sh` and read with
|
|
602
|
+
`git log --format=%B <ref>`. An empty list says so in one line. Fix the prose by
|
|
603
|
+
hand or leave it for cleanup.
|
|
604
|
+
|
|
588
605
|
The delivery model these workflows implement: [Delivery model](SDLC.md).
|
|
589
606
|
|
|
590
607
|
## Checking a range for stray files
|
|
@@ -604,8 +621,11 @@ or `.gitmodules` `ignore` say. Works in any git repository, no process needed.
|
|
|
604
621
|
|
|
605
622
|
When a process finishes, the landing that closes it also keeps the whole run
|
|
606
623
|
behind a ref, `refs/gtd/history/<entry>`, so a squash or a deleted branch
|
|
607
|
-
doesn't lose it. `/gtd ship`
|
|
608
|
-
|
|
624
|
+
doesn't lose it. `/gtd ship` pushes this process's own ref to origin alongside
|
|
625
|
+
the branch (never the others) and writes a `Gtd-History: <ref>` trailer on the
|
|
626
|
+
squash commit. The push is forced, so it replaces origin's copy of that one ref.
|
|
627
|
+
If that push fails, the ship fails and nothing is squashed. Recover the full
|
|
628
|
+
trail with `gtd history fetch | sh`, then `gtd history show <ref>`.
|
|
609
629
|
|
|
610
630
|
```sh
|
|
611
631
|
gtd history list # one line per kept process
|
|
@@ -614,7 +634,9 @@ gtd history fetch | sh # pull every kept ref from origin
|
|
|
614
634
|
```
|
|
615
635
|
|
|
616
636
|
`gtd history fetch` only prints a fetch script; pipe it into `sh` to run it.
|
|
617
|
-
|
|
637
|
+
Beyond that one ship push, refs are not pushed or fetched by default. The ref
|
|
638
|
+
holds every step commit, including steering files and code a later step removed,
|
|
639
|
+
so anyone who can fetch the remote can read it. To share the rest:
|
|
618
640
|
|
|
619
641
|
```sh
|
|
620
642
|
git push origin 'refs/gtd/history/*'
|
|
@@ -622,7 +644,9 @@ git config --add remote.origin.fetch 'refs/gtd/history/*:refs/gtd/history/*'
|
|
|
622
644
|
```
|
|
623
645
|
|
|
624
646
|
The second line makes every plain `git fetch` include them. It has no leading
|
|
625
|
-
`+`, so a conflicting local ref fails instead of being overwritten
|
|
647
|
+
`+`, so a conflicting local ref fails instead of being overwritten: after a
|
|
648
|
+
forced `/gtd ship` push, a clone holding the old tip deletes its local ref
|
|
649
|
+
(`git update-ref -d <ref>`) and fetches again.
|
|
626
650
|
|
|
627
651
|
A step declared with `keep: true` files its turn under `refs/gtd/kept/` instead
|
|
628
652
|
of on the branch (see [Kept tips](docs/configuration.md#kept-tips)). Those refs
|
|
@@ -670,7 +694,9 @@ listed in the
|
|
|
670
694
|
`gtd slices` lists the spans a flow's `slice()` calls mark. It refuses (exit 1)
|
|
671
695
|
when HEAD neither closes nor sits inside a process, or when today's config
|
|
672
696
|
disagrees with history about the process; a version mismatch only warns on
|
|
673
|
-
stderr.
|
|
697
|
+
stderr. `gtd summary --slice <name>` prints the closing-message prompt for one
|
|
698
|
+
of those slices — its own range, human commits and cost — and inherits those
|
|
699
|
+
refusals; an unknown name is refused with every valid slice listed.
|
|
674
700
|
|
|
675
701
|
## License
|
|
676
702
|
|
package/claude/hooks/doors.ts
CHANGED
|
@@ -32,3 +32,15 @@ export async function enter(io: ShipIo, name: string, args: string[]): Promise<S
|
|
|
32
32
|
if (started.code !== 0) return fail(`The door script failed.\n${started.err.trim()}`)
|
|
33
33
|
return { ok: true, text: `Started ${name}.` }
|
|
34
34
|
}
|
|
35
|
+
|
|
36
|
+
// A door is entered only when the words fit its args; anything else is requirements.
|
|
37
|
+
export function doorFor(
|
|
38
|
+
listed: readonly Door[],
|
|
39
|
+
verb: string,
|
|
40
|
+
args: readonly string[],
|
|
41
|
+
): Door | undefined {
|
|
42
|
+
const door = listed.find((d) => d.name === verb)
|
|
43
|
+
if (!door) return undefined
|
|
44
|
+
const required = door.args.filter((a) => !a.optional).length
|
|
45
|
+
return args.length >= required && args.length <= door.args.length ? door : undefined
|
|
46
|
+
}
|
|
@@ -7,7 +7,7 @@ import type { AccessDef } from "./access"
|
|
|
7
7
|
import { jobPrompt, wakePrompt, wakes } from "./assist"
|
|
8
8
|
import { afterReload, drive, isTrue } from "./drive"
|
|
9
9
|
import type { Io, Opening, Turn, TurnEnd } from "./drive"
|
|
10
|
-
import { doors, enter } from "./doors"
|
|
10
|
+
import { doorFor, doors, enter } from "./doors"
|
|
11
11
|
import { subagentModel } from "./models"
|
|
12
12
|
import { pastedCommand } from "./paste"
|
|
13
13
|
import { personaSpec, resumable } from "./persona"
|
|
@@ -824,9 +824,7 @@ async function command($: $, arg: string): Promise<string | undefined> {
|
|
|
824
824
|
case "":
|
|
825
825
|
return resume($)
|
|
826
826
|
default:
|
|
827
|
-
return (await doors(shipIo($)))
|
|
828
|
-
? enterNow($, verb, rest)
|
|
829
|
-
: begin($, arg)
|
|
827
|
+
return doorFor(await doors(shipIo($)), verb, rest) ? enterNow($, verb, rest) : begin($, arg)
|
|
830
828
|
}
|
|
831
829
|
}
|
|
832
830
|
|
package/claude/hooks/ship.ts
CHANGED
|
@@ -53,7 +53,7 @@ export function costTrailers(log: string) {
|
|
|
53
53
|
// `gtd summary` is the only place gtd names the process's start parent.
|
|
54
54
|
// `gtd base` is the review anchor and would squash only part of the process.
|
|
55
55
|
const summaryRange = (summary: string) =>
|
|
56
|
-
/git log ([0-9a-f]{7,
|
|
56
|
+
/git log ([0-9a-f]{7,64})\.\.([0-9a-f]{7,64})/.exec(summary)?.slice(1, 3)
|
|
57
57
|
|
|
58
58
|
const COMMIT_FORMAT = `
|
|
59
59
|
--- MESSAGE FORMAT (overrides any formatting the instructions above imply) ---
|
|
@@ -102,6 +102,18 @@ Nothing else. No file-by-file list, no per-commit narration, no implementation
|
|
|
102
102
|
detail the diff already shows, no test-plan boilerplate, no sign-off, no footer
|
|
103
103
|
trailers. Short headings and lists are fine. Under 250 words.`
|
|
104
104
|
|
|
105
|
+
// A rejection becomes `killed`, so the caller can tell a timeout from a refused
|
|
106
|
+
// push. Relies on the host's process runner rejecting at its timeout; a spawn
|
|
107
|
+
// failure lands here too. Only pushes: any other crashed read must abort the
|
|
108
|
+
// ship, not pass as an answer.
|
|
109
|
+
const attempt = async (io: ShipIo, argv: string[]): Promise<Ran & { killed: boolean }> => {
|
|
110
|
+
try {
|
|
111
|
+
return { ...(await io.run(argv)), killed: false }
|
|
112
|
+
} catch (err) {
|
|
113
|
+
return { code: 1, out: "", err: err instanceof Error ? err.message : String(err), killed: true }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
105
117
|
export async function ship(io: ShipIo, isDry: boolean): Promise<Shipped> {
|
|
106
118
|
const git = async (...args: string[]) => (await io.run(["git", ...args])).out.trim()
|
|
107
119
|
const ok = async (...args: string[]) => (await io.run(["git", ...args])).code === 0
|
|
@@ -125,7 +137,7 @@ export async function ship(io: ShipIo, isDry: boolean): Promise<Shipped> {
|
|
|
125
137
|
|
|
126
138
|
const squashed = await squash(io, git, ok, branch, isDry)
|
|
127
139
|
if (!squashed.ok) return squashed
|
|
128
|
-
if (isDry) return preview(io, git, branch, baseRef, squashed.text)
|
|
140
|
+
if (isDry) return preview(io, git, branch, baseRef, squashed.text, squashed.history)
|
|
129
141
|
return pullRequest(io, git, ok, branch, baseBranch, baseRef)
|
|
130
142
|
}
|
|
131
143
|
|
|
@@ -154,7 +166,7 @@ async function squash(
|
|
|
154
166
|
ok: Ok,
|
|
155
167
|
branch: string,
|
|
156
168
|
isDry: boolean,
|
|
157
|
-
): Promise<Shipped> {
|
|
169
|
+
): Promise<Shipped & { history?: string }> {
|
|
158
170
|
const summary = await io.run(["gtd", "summary"])
|
|
159
171
|
if (summary.code !== 0) {
|
|
160
172
|
io.log(`No gtd process at HEAD, so nothing is squashed; ${branch} is described as it stands.`)
|
|
@@ -172,24 +184,62 @@ async function squash(
|
|
|
172
184
|
const { base, tip, count } = range
|
|
173
185
|
|
|
174
186
|
// gtd churns its own steering files every process, so `.gtd/` alone is no change.
|
|
175
|
-
if (await ok("diff", "--quiet", base, "HEAD", "--", ":(exclude).gtd/"))
|
|
176
|
-
|
|
177
|
-
return { ok: true, text: `No file changes outside .gtd/: would drop ${count} commit(s).` }
|
|
178
|
-
if (!(await ok("reset", "--hard", base))) return fail("git reset failed; nothing changed.")
|
|
179
|
-
io.log(
|
|
180
|
-
`No file changes outside .gtd/: dropped ${count} commit(s). Undo with git reset --hard ORIG_HEAD.`,
|
|
181
|
-
)
|
|
182
|
-
return { ok: true, text: "" }
|
|
183
|
-
}
|
|
187
|
+
if (await ok("diff", "--quiet", base, "HEAD", "--", ":(exclude).gtd/"))
|
|
188
|
+
return dropProcess(io, ok, base, count, isDry)
|
|
184
189
|
|
|
185
190
|
io.log(`Squashing ${count} commit(s) since ${short(base)}…`)
|
|
186
191
|
const trailers = costTrailers(await git("log", "--format=%B%n", `${base}..${tip}`))
|
|
192
|
+
const history = await historyRef(io, tip)
|
|
193
|
+
if (history) trailers.push(`Gtd-History: ${history}`)
|
|
187
194
|
const reply = await io.complete(summary.out + "\n" + COMMIT_FORMAT)
|
|
188
195
|
const message = reply && cleanReply(reply)
|
|
189
196
|
if (!message) return fail("The commit-message turn produced nothing.")
|
|
190
197
|
const full = trailers.length ? `${message}\n\n${trailers.join("\n")}` : message
|
|
191
|
-
if (isDry) return { ok: true, text: full }
|
|
198
|
+
if (isDry) return { ok: true, text: full, history }
|
|
199
|
+
|
|
200
|
+
return commitSquash(io, git, ok, base, full, history)
|
|
201
|
+
}
|
|
192
202
|
|
|
203
|
+
async function dropProcess(
|
|
204
|
+
io: ShipIo,
|
|
205
|
+
ok: Ok,
|
|
206
|
+
base: string,
|
|
207
|
+
count: number,
|
|
208
|
+
isDry: boolean,
|
|
209
|
+
): Promise<Shipped> {
|
|
210
|
+
if (isDry)
|
|
211
|
+
return { ok: true, text: `No file changes outside .gtd/: would drop ${count} commit(s).` }
|
|
212
|
+
if (!(await ok("reset", "--hard", base))) return fail("git reset failed; nothing changed.")
|
|
213
|
+
io.log(
|
|
214
|
+
`No file changes outside .gtd/: dropped ${count} commit(s). Undo with git reset --hard ORIG_HEAD.`,
|
|
215
|
+
)
|
|
216
|
+
return { ok: true, text: "" }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function commitSquash(
|
|
220
|
+
io: ShipIo,
|
|
221
|
+
git: Git,
|
|
222
|
+
ok: Ok,
|
|
223
|
+
base: string,
|
|
224
|
+
full: string,
|
|
225
|
+
history: string | undefined,
|
|
226
|
+
): 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
|
+
}
|
|
193
243
|
if (!(await ok("reset", "--soft", base))) return fail("git reset failed; nothing changed.")
|
|
194
244
|
const commit = await io.run(["git", "commit", "--cleanup=strip", "-F", "-"], full + "\n")
|
|
195
245
|
if (commit.code !== 0) {
|
|
@@ -203,7 +253,39 @@ async function squash(
|
|
|
203
253
|
return { ok: true, text: "" }
|
|
204
254
|
}
|
|
205
255
|
|
|
206
|
-
async function
|
|
256
|
+
async function historyRef(io: ShipIo, tip: string) {
|
|
257
|
+
const list = await io.run(["gtd", "history", "list", "--json"])
|
|
258
|
+
let entries: { ref?: string; entry?: string; tip?: string }[] = []
|
|
259
|
+
try {
|
|
260
|
+
entries = JSON.parse(list.out)
|
|
261
|
+
} catch {
|
|
262
|
+
// unreadable list reads as no entry
|
|
263
|
+
}
|
|
264
|
+
// The ref becomes a refspec: push it only when it is exactly the full-sha
|
|
265
|
+
// name of its entry (40 hex, or 64 under SHA-256).
|
|
266
|
+
const found = Array.isArray(entries)
|
|
267
|
+
? entries.find(
|
|
268
|
+
(e) =>
|
|
269
|
+
e.tip === tip &&
|
|
270
|
+
/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(e.entry ?? "") &&
|
|
271
|
+
e.ref === `refs/gtd/history/${e.entry}`,
|
|
272
|
+
)
|
|
273
|
+
: undefined
|
|
274
|
+
if (!found) {
|
|
275
|
+
io.log("No gtd history ref keeps this process: shipping without a Gtd-History trailer.")
|
|
276
|
+
return undefined
|
|
277
|
+
}
|
|
278
|
+
return found.ref
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function preview(
|
|
282
|
+
io: ShipIo,
|
|
283
|
+
git: Git,
|
|
284
|
+
branch: string,
|
|
285
|
+
baseRef: string,
|
|
286
|
+
message: string,
|
|
287
|
+
history?: string,
|
|
288
|
+
) {
|
|
207
289
|
const mergeBase = await git("merge-base", baseRef, "HEAD")
|
|
208
290
|
const existing = await io.run(["gh", "pr", "view", "--json", "url"])
|
|
209
291
|
const pr =
|
|
@@ -212,7 +294,11 @@ async function preview(io: ShipIo, git: Git, branch: string, baseRef: string, me
|
|
|
212
294
|
: `would open a pull request for ${branch} into ${baseRef} (${await git("rev-list", "--count", `${mergeBase}..HEAD`)} commit(s) before the squash)`
|
|
213
295
|
return {
|
|
214
296
|
ok: true,
|
|
215
|
-
text: [
|
|
297
|
+
text: [
|
|
298
|
+
message && `Commit message:\n\n${message}`,
|
|
299
|
+
history && `History: would push ${history} to origin.`,
|
|
300
|
+
`Pull request: ${pr}.`,
|
|
301
|
+
]
|
|
216
302
|
.filter(Boolean)
|
|
217
303
|
.join("\n\n"),
|
|
218
304
|
}
|
|
@@ -256,14 +342,23 @@ async function pullRequest(
|
|
|
256
342
|
// the push failed, or nothing.
|
|
257
343
|
export async function pushBranch(io: ShipIo, branch: string) {
|
|
258
344
|
const ok = async (...args: string[]) => (await io.run(["git", ...args])).code === 0
|
|
345
|
+
const push = async (args: string[], failure: string) => {
|
|
346
|
+
const r = await attempt(io, ["git", "push", ...args])
|
|
347
|
+
if (r.killed)
|
|
348
|
+
return `git push of ${branch} did not finish; nothing was lost. Retry without fetching.\n${r.err}`.trimEnd()
|
|
349
|
+
if (r.code !== 0) return `${failure}\n${r.err}`.trimEnd()
|
|
350
|
+
return undefined
|
|
351
|
+
}
|
|
259
352
|
if (!(await ok("rev-parse", "--verify", "--quiet", `origin/${branch}`))) {
|
|
260
353
|
io.log(`Pushing ${branch} to origin…`)
|
|
261
|
-
|
|
262
|
-
}
|
|
354
|
+
return push(["--set-upstream", "origin", branch], "git push failed.")
|
|
355
|
+
}
|
|
356
|
+
if (!(await ok("merge-base", "--is-ancestor", "HEAD", `origin/${branch}`))) {
|
|
263
357
|
io.log(`Pushing ${branch} to origin…`)
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
358
|
+
return push(
|
|
359
|
+
["--force-with-lease", "origin", branch],
|
|
360
|
+
`git push failed: origin/${branch} moved. Fetch and reconcile first.`,
|
|
361
|
+
)
|
|
267
362
|
}
|
|
268
363
|
return undefined
|
|
269
364
|
}
|