gh-stack 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,117 @@
1
+ // The saved state of a restack/sync/join run, read back: is a run still
2
+ // pending, where did it stop, and what did it do (#15, #29).
3
+ //
4
+ // The state itself is written by restack.ts as the run goes (see RestackState).
5
+ // This module only interprets it, so `restack`, `sync`, `merge`, `join` and
6
+ // `undo` agree on what "an interrupted run" means.
7
+ import pc from "picocolors";
8
+ import * as p from "./output.ts";
9
+ import { InvalidRunStateError, clearRestackState, loadRestackState } from "./metadata.ts";
10
+ import type { RestackState, RunLogEntry } from "../types.ts";
11
+
12
+ /**
13
+ * The saved run state, or null. A state that can't be used (unreadable, or
14
+ * from another gh-stack version) is never upgraded: with `discard` (the
15
+ * `--fresh` path) it's deleted with a note, otherwise this exits 1 saying how
16
+ * to discard it.
17
+ */
18
+ export async function loadRunState(opts: { discard?: boolean } = {}): Promise<RestackState | null> {
19
+ try {
20
+ return await loadRestackState();
21
+ } catch (err) {
22
+ if (!(err instanceof InvalidRunStateError)) throw err;
23
+ if (opts.discard) {
24
+ p.log.warn(`Discarding the saved run state at ${err.path}: ${err.reason}.`);
25
+ await clearRestackState();
26
+ return null;
27
+ }
28
+ p.cancel(
29
+ `${err.message}\n\n Discard it and restack from the current branch:\n ${pc.green("gh-stack restack --fresh")}`,
30
+ );
31
+ process.exit(1);
32
+ }
33
+ }
34
+
35
+ /**
36
+ * A saved run that hasn't finished: a conflict stop, a kill, failed or held
37
+ * pushes, or a rebase that was saved as started but never ran (or was
38
+ * aborted). All of them are resumable; nothing is inferred from the branch
39
+ * position, since a kill before the rebase started looks just like an abort.
40
+ */
41
+ export async function findPendingRun(
42
+ opts: { discard?: boolean } = {},
43
+ ): Promise<RestackState | null> {
44
+ const state = await loadRunState(opts);
45
+ return state && !state.finished_at ? state : null;
46
+ }
47
+
48
+ /** Branches whose push failed, was declined or held, and hasn't succeeded since. */
49
+ export function outstandingPushes(state: RestackState): string[] {
50
+ const out = new Set<string>();
51
+ for (const e of state.log) {
52
+ if (e.action === "push-failed" || e.action === "push-declined" || e.action === "push-held") {
53
+ out.add(e.branch);
54
+ } else if (e.action === "pushed") {
55
+ out.delete(e.branch);
56
+ }
57
+ }
58
+ return [...out];
59
+ }
60
+
61
+ /** Where the run stopped, for "An interrupted sync was detected (stopped at …)". */
62
+ export function stoppedAt(state: RestackState): string {
63
+ if (state.rebase) return `${state.rebase.branch}: rebasing onto ${state.rebase.onto}`;
64
+ const next = state.chain[state.current_index];
65
+ if (next) return next;
66
+ const unpushed = outstandingPushes(state);
67
+ if (unpushed.length > 0) return `${unpushed.join(", ")}: not pushed`;
68
+ return "the end of its chain";
69
+ }
70
+
71
+ /** What's left for a resume to do, one line per item. */
72
+ export function remainingSteps(state: RestackState): string[] {
73
+ const steps: string[] = [];
74
+ for (const b of outstandingPushes(state)) steps.push(`push ${b}`);
75
+ if (state.rebase) steps.push(`finish rebasing ${state.rebase.branch}, then push it`);
76
+ const from = state.current_index + (state.rebase ? 1 : 0);
77
+ const rest = state.chain.slice(from);
78
+ if (rest.length > 0) steps.push(`restack and push ${rest.join(", ")}`);
79
+ return steps;
80
+ }
81
+
82
+ export function formatLogEntry(e: RunLogEntry): string {
83
+ const b = pc.yellow(e.branch);
84
+ switch (e.action) {
85
+ case "rebased":
86
+ return `${pc.green("✓")} rebased ${b} onto ${e.onto ?? "its parent"}`;
87
+ case "up-to-date":
88
+ return `${pc.dim("·")} ${b} already on ${e.onto ?? "its parent"}`;
89
+ case "rebase-declined":
90
+ return `${pc.dim("–")} skipped rebasing ${b} (declined)`;
91
+ case "pushed":
92
+ return `${pc.green("✓")} pushed ${b}`;
93
+ case "push-failed":
94
+ return `${pc.red("✗")} push of ${b} failed`;
95
+ case "push-declined":
96
+ return `${pc.yellow("–")} push of ${b} declined`;
97
+ case "push-held":
98
+ return `${pc.yellow("⏸")} held ${b} back${e.detail ? ` (${e.detail})` : ""}`;
99
+ case "local-only":
100
+ return `${pc.dim("·")} ${b} is local-only (not submitted), not pushed`;
101
+ }
102
+ }
103
+
104
+ /** The run's log as indented lines (empty when nothing happened yet). */
105
+ export function formatRunLog(state: RestackState): string[] {
106
+ return state.log.map((e) => ` ${formatLogEntry(e)}`);
107
+ }
108
+
109
+ /** "sync, started 2026-10-27 14:03" (local time) */
110
+ const two = (n: number) => String(n).padStart(2, "0");
111
+
112
+ export function describeRun(state: RestackState): string {
113
+ const at = state.started_at ? new Date(state.started_at) : null;
114
+ if (!at || Number.isNaN(at.getTime())) return state.command;
115
+ const when = `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
116
+ return `${state.command}, started ${when}`;
117
+ }
package/src/lib/safety.ts CHANGED
@@ -170,7 +170,11 @@ export function ensureNotMain(branch: string): void {
170
170
  /**
171
171
  * Validate stack metadata before destructive operations.
172
172
  */
173
- export async function validateStack(meta: StackMetadata, stackName: string): Promise<string[]> {
173
+ export async function validateStack(
174
+ meta: StackMetadata,
175
+ stackName: string,
176
+ opts: { allowMissing?: string[] } = {},
177
+ ): Promise<string[]> {
174
178
  const stack = meta.stacks[stackName];
175
179
  if (!stack) return [`Stack "${stackName}" not found`];
176
180
 
@@ -181,7 +185,7 @@ export async function validateStack(meta: StackMetadata, stackName: string): Pro
181
185
  for (const branchName of branchNames) {
182
186
  const branch = stack.branches[branchName]!;
183
187
 
184
- if (!(await git.localBranchExists(branchName))) {
188
+ if (!opts.allowMissing?.includes(branchName) && !(await git.localBranchExists(branchName))) {
185
189
  errors.push(`Branch "${branchName}" does not exist locally`);
186
190
  }
187
191
 
@@ -201,8 +205,12 @@ export async function validateStack(meta: StackMetadata, stackName: string): Pro
201
205
  return errors;
202
206
  }
203
207
 
204
- export async function ensureValidStack(meta: StackMetadata, stackName: string): Promise<void> {
205
- const errors = await validateStack(meta, stackName);
208
+ export async function ensureValidStack(
209
+ meta: StackMetadata,
210
+ stackName: string,
211
+ opts: { allowMissing?: string[] } = {},
212
+ ): Promise<void> {
213
+ const errors = await validateStack(meta, stackName, opts);
206
214
  if (errors.length === 0) return;
207
215
 
208
216
  p.cancel(`Invalid stack metadata for ${pc.yellow(stackName)}`);
@@ -62,12 +62,23 @@ export GH_STACK_YES=1 # skip every confirmation, plain output
62
62
  (network-free) or `gh-stack status --json` (adds PR/CI/review state).
63
63
  - **Always pass `-t`/`-b` to `submit`** so PRs get real titles/bodies instead
64
64
  of generic auto-generated ones (see below).
65
+ - **Run `gh-stack config` once per repo** to see its defaults. A repo whose
66
+ `.gh-stack.toml` sets `[submit] draft = true` opens every new PR as a draft
67
+ without `--draft`; pass `--no-draft` only when you were asked for a
68
+ ready-for-review PR. `submit --json` reports `draft` for PRs it created.
69
+ - **Need the PR number or URL? Add `--json` to `submit`** instead of running
70
+ `gh pr view` afterwards. stdout is only the JSON; logs go to stderr.
65
71
  - Commands that rebase (`restack`, `sync`, `merge`) **refuse to run on a dirty
66
72
  tree** — commit or stash first.
67
73
  - **`--yes` also accepts pushes.** To rebase without publishing (e.g. until a
68
74
  human approves), add `--no-push` to `restack`/`sync`, or export
69
- `GH_STACK_NO_PUSH=1`. It wins over `--yes` for pushes and lists what's ahead
70
- of origin at the end.
75
+ `GH_STACK_NO_PUSH=1` (a repo may also set `[restack] push = false`). It wins
76
+ over `--yes` for pushes and lists what's ahead of origin at the end; publish
77
+ later with `gh-stack restack --push`. `merge --base` honors `--no-push` and
78
+ `GH_STACK_NO_PUSH` (not the config); other `merge` modes and `submit --restack`
79
+ always push. `restack --resume` keeps the interrupted run's push decision unless you
80
+ pass `--push`/`--no-push`. `--yes` itself can't come from a config file:
81
+ always pass it or `GH_STACK_YES=1`.
71
82
  - A prompt reached without a TTY on stdin **exits 1** (naming the prompt)
72
83
  instead of hanging. If you see `refusing to prompt`, re-run with
73
84
  `GH_STACK_YES=1`.
@@ -118,6 +129,30 @@ This resolves the catch-22 where `sync`/`restack` said "not in any stack" for a
118
129
  tip you hadn't submitted yet. (`submit` still auto-tracks too — `add` is just the
119
130
  no-publish path.)
120
131
 
132
+ **Splice an existing branch/PR into a stack — `gh-stack join`.** For a branch
133
+ that should have been stacked but was cut from main on its own (often with its
134
+ own PR already open):
135
+
136
+ ```bash
137
+ gh-stack join <branch> --dry-run # preview: resulting stack + each PR's projected diff
138
+ gh-stack join <branch> # insert as the new bottom of the current stack
139
+ gh-stack join <branch> --above <parent> # insert between <parent> and its children
140
+ ```
141
+
142
+ `join` syncs the incoming branch onto its new parent first, replaying only its
143
+ own commits, then restacks everything above it, pushes, and retargets the PR
144
+ bases on GitHub. **Don't do this by hand with `git rebase`:** rebasing children
145
+ onto a branch that's behind main silently drags merged main commits into their
146
+ diffs. Run `--dry-run` first and check each PR's commit/file count. A conflict
147
+ syncing the incoming branch leaves the stack untouched (resolve,
148
+ `git rebase --continue`, re-run the join); a conflict restacking above it resumes
149
+ with `gh-stack restack --resume`. A branch alone in its own one-branch stack is
150
+ absorbed; one in a larger stack is refused. Unless you pass `--no-push`, the
151
+ incoming branch must already be on origin (`git push -u origin <branch>`). If a
152
+ child was cut from an older, since-rewritten version of the incoming branch,
153
+ join refuses and prints the `git rebase --onto` that drops the stale copies: run
154
+ it, then re-run the join.
155
+
121
156
  ### 2. Push & create PRs — `submit`
122
157
 
123
158
  `submit` is the workhorse: **idempotent, scope-aware, self-healing**. It pushes
@@ -128,6 +163,7 @@ visualization in every PR body.
128
163
  ```bash
129
164
  gh-stack submit # push downstack + create/update PRs
130
165
  gh-stack submit -d # create new PRs as drafts
166
+ gh-stack submit --no-draft # ready for review despite a draft default
131
167
  gh-stack submit --dry-run # preview, no side effects
132
168
  gh-stack submit --restack # then propagate up to children (see below)
133
169
  gh-stack submit -t "<title>" -b "<body>" # explicit title/body for new PRs
@@ -151,6 +187,54 @@ EOF
151
187
  )"
152
188
  ```
153
189
 
190
+ **`submit --json` returns what happened to each branch** (stdout is only the
191
+ JSON document, on every exit; logs go to stderr, so capture stdout alone):
192
+
193
+ ```bash
194
+ gh-stack submit --json -t "<title>" --body-file /tmp/body.md > /tmp/submit.json
195
+ jq -r '.branches[-1].url' /tmp/submit.json # the current branch's PR
196
+ ```
197
+
198
+ ```json
199
+ {
200
+ "stack": "my-stack",
201
+ "current_branch": "feat-b",
202
+ "branches": [
203
+ {
204
+ "branch": "feat-a",
205
+ "parent": "main",
206
+ "pr": 101,
207
+ "url": "https://github.com/org/repo/pull/101",
208
+ "title": "Feat A (1/2)",
209
+ "action": "unchanged",
210
+ "pushed": false,
211
+ "draft": null
212
+ },
213
+ {
214
+ "branch": "feat-b",
215
+ "parent": "feat-a",
216
+ "pr": 102,
217
+ "url": "https://github.com/org/repo/pull/102",
218
+ "title": "Feat B (2/2)",
219
+ "action": "created",
220
+ "pushed": true,
221
+ "draft": false
222
+ }
223
+ ],
224
+ "error": null
225
+ }
226
+ ```
227
+
228
+ `branches` is the submit scope (trunk → current) in stack order, so on success
229
+ the last entry is the branch you're on. `action` is `created`, `updated`
230
+ (pushed, or base/title/description edited), `unchanged`, or `failed` (with an
231
+ `error`). `draft` is only known for PRs created this run (else `null`). When
232
+ the run stops early (nothing to submit, a bad `--body-file`, a push failure),
233
+ top-level `error` is `{ "message": "..." }` and `branches` lists what happened
234
+ before it. Exit code is 1 whenever `error` is set or any branch `failed`.
235
+ `--json` implies `--no-edit` and can't be combined with `--dry-run` or
236
+ `--restack`; run `gh-stack restack` as a separate step.
237
+
154
238
  **`submit -t`/`-b` also updates an existing PR in place** — pass them while on a
155
239
  branch that already has a PR to change just that one (visualization-safe).
156
240
 
@@ -178,6 +262,7 @@ gh-stack sync # fetch trunk, rebase base onto it, restack ALL children
178
262
  gh-stack restack # rebase current branch + descendants onto their parents
179
263
  gh-stack restack --dry-run
180
264
  gh-stack restack --no-push # rebase locally only (also: GH_STACK_NO_PUSH=1)
265
+ gh-stack restack --push # push despite GH_STACK_NO_PUSH / [restack] push = false
181
266
  ```
182
267
 
183
268
  **⚠️ Restack propagates UPWARD from the current branch.** Run it from the
@@ -196,7 +281,27 @@ edit several branches then restack once at the end (only the last branch ends
196
281
  up pushed).
197
282
 
198
283
  **On rebase conflict:** resolve, `git add <files>`, `git rebase --continue`,
199
- then `gh-stack restack --resume` to finish the remaining branches.
284
+ then `gh-stack restack --resume` to finish the remaining branches. That's the
285
+ command even when the conflict came from `sync`. If you forget `--resume`, a
286
+ bare `restack`/`sync` notices the interrupted run and, under `--yes`, resumes
287
+ it. Don't pass `--fresh` unless you were asked to abandon the run: it discards
288
+ the run's remaining steps. `git rebase --abort` does not end the run; a resume
289
+ runs that rebase again.
290
+
291
+ **Interrupted or partly pushed run** (killed, timed out, or a push failed):
292
+ run `gh-stack restack --resume`. It continues from the saved state and retries
293
+ the pushes, parents first. If everything already finished it prints the run's
294
+ log and exits 0. A branch whose parent didn't reach origin is never pushed onto
295
+ it (its PR would be diffed against the parent's old history); the run lists it
296
+ as held and exits 1 until `--resume` gets the parent pushed. `merge` and `join`
297
+ refuse to run while a restack/sync is pending.
298
+
299
+ **"Origin has commits that the local branches don't"** (restack/sync exit 1
300
+ before changing anything): a branch's copy on origin has commits it never had,
301
+ often the squash commit a `merge --collapse` added on GitHub. Run the
302
+ `git pull --ff-only` / `git cherry-pick` lines it prints, then re-run. Don't
303
+ pass `--overwrite-remote` unless you were asked to drop those commits: it
304
+ deletes them from the PR.
200
305
 
201
306
  **`sync` is self-correcting:** if you merged the bottom PR in the GitHub UI,
202
307
  `sync` detects it and advances the stack past the merged branch (re-rooting onto
@@ -231,15 +336,36 @@ Merge is **top-down everywhere** (collapse PRn → PRn-1 → … → base PR, th
231
336
  - **`merge --base`**: squash-merge **only the bottom PR** into trunk and re-root
232
337
  the next branch onto trunk — one PR per run, re-run for the next. Use this to
233
338
  land reviewed PRs one at a time. Auto-advances past a bottom PR already merged
234
- outside gh-stack.
339
+ outside gh-stack. If the re-root stops (a conflict, or "origin/main doesn't
340
+ show its merge commit"), fix it (`git rebase --continue`) and **re-run
341
+ `merge --base`**: it advances past the merged PR and finishes a branch that's
342
+ already re-rooted. Don't run `restack` there: it would put the branch back on
343
+ the merged one. `--no-push` / `GH_STACK_NO_PUSH=1` still merges on GitHub but
344
+ keeps the re-root and the tail local until `gh-stack submit`.
235
345
  - **`--collapse`** (with `merge` or `merge --approved`): stop after collapsing
236
346
  into the base PR — review on GitHub, then re-run `merge` to finish.
237
347
 
348
+ After a collapse, the base branch on origin holds the children's squash
349
+ commits. Pull it before touching it locally (`git pull --ff-only origin <base>`).
350
+ Before the base PR goes to main, `merge` checks those commits are still in it.
351
+ If it exits 2 with "PR #N's changes are missing from PR #base", the base was
352
+ rewritten without them: follow the printed steps (`gh api … | git am`) and
353
+ re-run `merge`. If it exits 2 saying PR #N's changes are UNVERIFIED, nothing
354
+ is known to be lost, but it couldn't be checked (often a GitHub or network
355
+ hiccup: re-run first): compare `gh pr diff <N> --name-only` against the base
356
+ PR's files. `merge --base` does the same check for a base with collapsed PRs. Use `--skip-collapse-check` only after
357
+ confirming the base PR's diff on GitHub really has PR #N's changes.
358
+
359
+ A push that fails with "fetched but never brought into local <branch>" means
360
+ origin has commits you haven't integrated (git's --force-if-includes): pull or
361
+ cherry-pick them, don't force past it.
362
+
238
363
  ### 7. Maintenance
239
364
 
240
365
  ```bash
241
366
  gh-stack delete # remove a branch from the stack (re-parents children)
242
367
  gh-stack split # cut a stack into two at a branch
368
+ gh-stack join <b> # splice an existing branch/PR into the stack (--above <b>, --dry-run)
243
369
  gh-stack rename # rename a tracked branch (keeps metadata in sync)
244
370
  gh-stack undo # restore from the last snapshot
245
371
  gh-stack archive # manage archived stacks
@@ -264,7 +390,7 @@ needed.
264
390
  - **Never force-pushes trunk.**
265
391
  - **Metadata is local and never committed:** per-stack files under
266
392
  `.git/.gh-stack/` plus git branch config. Inspect it via `gh-stack stacks
267
- --json`, not by reading the store. (`gh-stack doctor` migrates the old v2
393
+ --json`, not by reading the store. (`gh-stack doctor` migrates the old v2
268
394
  `.git/gh-stack-metadata.json` monolith.)
269
395
 
270
396
  ## Don'ts
package/src/types.ts CHANGED
@@ -59,13 +59,71 @@ export interface Snapshot {
59
59
  baseTip?: string;
60
60
  }
61
61
 
62
- // Resume state for restack --resume
62
+ /**
63
+ * One step of a restack/sync/join run, appended to the run's action log as it
64
+ * happens (#15), so an interrupted run can say what it finished and what's left.
65
+ */
66
+ export interface RunLogEntry {
67
+ at: string; // ISO 8601
68
+ branch: string;
69
+ action:
70
+ | "rebased" // rebased onto `onto`
71
+ | "up-to-date" // already on its parent: nothing to rebase
72
+ | "rebase-declined"
73
+ | "pushed"
74
+ | "push-failed"
75
+ | "push-declined"
76
+ | "push-held" // not pushed: its parent isn't on origin at the commit it sits on
77
+ | "local-only"; // no remote and no PR: left for `submit`
78
+ onto?: string;
79
+ detail?: string;
80
+ }
81
+
82
+ /** A rebase that has started but not been seen to finish (a conflict stop, or a kill). */
83
+ export interface InFlightRebase {
84
+ branch: string;
85
+ onto: string; // ref the branch is being rebased onto
86
+ onto_sha: string; // that ref's tip when the rebase started
87
+ pre_tip: string; // the branch's tip before the rebase (what `git rebase --abort` restores)
88
+ /** The `--onto` upstream (`git rebase --onto <onto> <boundary> <branch>`); null for a plain `git rebase <onto>`. */
89
+ boundary: string | null;
90
+ }
91
+
92
+ /** Bumped when RestackState changes shape; a file with another version is refused, not upgraded. */
93
+ export const RUN_STATE_VERSION = 2;
94
+
95
+ /**
96
+ * The saved state of a restack/sync/join run, at `.git/.gh-stack/restack-state.json`.
97
+ * Written when the run starts and after every step, and kept after it finishes
98
+ * (`finished_at` set) so `restack --resume` can report a completed run.
99
+ */
63
100
  export interface RestackState {
64
- current_index: number;
101
+ version: typeof RUN_STATE_VERSION;
102
+ /** What started the run: "restack", "sync", "join" or "restack --onto". */
103
+ command: string;
65
104
  stack_name: string;
105
+ /** Branches the run covers, parents before children. */
66
106
  chain: string[];
67
- /** The interrupted run was `--no-push`; `--resume` keeps it local-only (#36). */
68
- no_push?: boolean;
107
+ /**
108
+ * Branches in `chain` with only their push left: never rebased by the run
109
+ * (restack's trunk-parented roots, sync's root) or already rebased by it.
110
+ */
111
+ push_only: string[];
112
+ /** First branch in `chain` not yet processed. */
113
+ current_index: number;
114
+ /** The rebase in progress at `chain[current_index]`, if any. */
115
+ rebase: InFlightRebase | null;
116
+ /** Branches whose rebase was declined: not done, so a resume offers them again. */
117
+ declined: string[];
118
+ /**
119
+ * The run's push decision. `--resume` keeps it (a local-only run stays
120
+ * local) unless the resume passes --push/--no-push (#36, #39).
121
+ */
122
+ push: boolean;
123
+ started_at: string;
124
+ /** Set once every step, pushes included, is done. */
125
+ finished_at: string | null;
126
+ log: RunLogEntry[];
69
127
  }
70
128
 
71
129
  // PR info from GitHub API