gh-stack 0.19.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.
package/README.md CHANGED
@@ -276,7 +276,7 @@ sync [--dry-run] [--no-push|--push] [--resume | --fresh] [--overwrite-remote]
276
276
 
277
277
  merge [--dry-run] [-d|--delete-branch] [--collapse] [--skip-collapse-check]
278
278
  merge --approved [--collapse] [--dry-run] [-d|--delete-branch]
279
- merge --base [--dry-run] [-d|--delete-branch]
279
+ merge --base [--dry-run] [-d|--delete-branch] [--no-push|--push]
280
280
  Default (top-down collapse): squash-merge the whole stack top-down
281
281
  via GitHub (PR3 → PR2 → PR1), then enables auto-merge for the base PR
282
282
  into main. The whole stack lands on main as a single squash commit.
@@ -323,7 +323,18 @@ merge --base [--dry-run] [-d|--delete-branch]
323
323
  just its own commits). One PR per run — run it again to
324
324
  land the next. Approval is enforced by GitHub branch
325
325
  protection, not gh-stack. A base PR already merged outside
326
- gh-stack is detected and advanced past.
326
+ gh-stack is detected and advanced past. The re-root waits
327
+ until origin's main shows the merge commit, and stops (exit
328
+ 2) if it never does. When a re-root stops (a conflict, or
329
+ main not showing the merge yet), finish it by re-running
330
+ `gh-stack merge --base` after `git rebase --continue`: it
331
+ advances past the merged PR and picks up a branch that's
332
+ already re-rooted. Don't run `restack` there: it would put
333
+ the branch back on the merged one.
334
+ --no-push (or GH_STACK_NO_PUSH=1) still merges the PR on
335
+ GitHub but keeps the re-root and the restack above it
336
+ local, PR bases included, until `gh-stack submit`. The
337
+ `[restack] push` config default doesn't apply to merge.
327
338
  --collapse With the default or --approved, stop after collapsing into
328
339
  the base PR WITHOUT merging it to main, so you can review
329
340
  the cumulative diff on GitHub. Re-run `gh-stack merge` to
@@ -520,7 +531,7 @@ If a command needs to prompt but stdin isn't a TTY, it exits 1 with the prompt i
520
531
  ```
521
532
  GH_STACK_YES=1 Skip all confirmations (same as --yes; also enables --plain)
522
533
  GH_STACK_PLAIN=1 Plain output (same as --plain)
523
- GH_STACK_NO_PUSH=1 restack/sync never push (same as --no-push; wins over --yes; 0 = push)
534
+ GH_STACK_NO_PUSH=1 restack, sync and merge --base never push (same as --no-push; wins over --yes; 0 = push)
524
535
  GH_STACK_DRAFT=1 submit creates new PRs as drafts (same as --draft; 0 = ready)
525
536
  (both accept 1/0, true/false, yes/no; anything else is an error)
526
537
  GH_STACK_NO_COLOR=1 Disable colored output
@@ -552,8 +563,10 @@ says so in its output.
552
563
  | `[restack] push` | `--push` / `--no-push` | `GH_STACK_NO_PUSH` (inverted) | `true` |
553
564
 
554
565
  `[restack] push` governs `restack` and `sync` only. `merge` and
555
- `submit --restack` always push the branches they restack: publishing is their
556
- job, and child PRs left stale on GitHub would be worse than any default. A
566
+ `submit --restack` push the branches they restack whatever the config says:
567
+ publishing is their job, and child PRs left stale on GitHub would be worse
568
+ than any default. Only `merge --base` can be kept local, and only by an
569
+ explicit `--no-push` or `GH_STACK_NO_PUSH=1`. A
557
570
  `restack --resume` keeps the push decision of the run it resumes unless you
558
571
  pass `--push` or `--no-push`.
559
572
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.19.0",
3
+ "version": "0.19.1",
4
4
  "description": "Stacked PR manager for squash-merge workflows",
5
5
  "author": "Kiliman <kiliman@gmail.com>",
6
6
  "license": "MIT",
@@ -38,7 +38,7 @@ FILES
38
38
 
39
39
  ENVIRONMENT (1/0, true/false, or yes/no)
40
40
  GH_STACK_DRAFT submit draft default
41
- GH_STACK_NO_PUSH restack/sync push default (1 = don't push)
41
+ GH_STACK_NO_PUSH restack/sync/merge --base push default (1 = don't push)
42
42
 
43
43
  --yes is not configurable from a file: auto-confirming destructive operations
44
44
  must be asked for per run (--yes or GH_STACK_YES=1).
@@ -32,10 +32,15 @@ import {
32
32
  type BranchReview,
33
33
  type MergeAction,
34
34
  } from "../lib/advance.ts";
35
+ import { flagSetting, parseEnvBool, type Resolved } from "../lib/config.ts";
35
36
 
36
37
  const MAX_RETRIES = 12;
37
38
  const RETRY_DELAY_MS = 5000;
38
39
 
40
+ // Waits between fetches while origin's trunk catches up to a merge GitHub has
41
+ // already reported (one entry per retry; ~7s in all).
42
+ const TRUNK_MERGE_WAIT_MS = [1000, 2000, 4000];
43
+
39
44
  // After GitHub rejects a merge with the "still recomputing" signature, back off
40
45
  // (capped) and re-poll before retrying. One entry per retry; length+1 = max
41
46
  // attempts. Bounds total transient wait per PR to ~30s.
@@ -260,7 +265,7 @@ gh-stack merge — Squash-merge a stack via GitHub
260
265
  USAGE
261
266
  gh-stack merge [--dry-run] [-d|--delete-branch] [--collapse]
262
267
  gh-stack merge --approved [--collapse] [--dry-run] [-d|--delete-branch]
263
- gh-stack merge --base [--dry-run] [-d|--delete-branch]
268
+ gh-stack merge --base [--dry-run] [-d|--delete-branch] [--no-push|--push]
264
269
 
265
270
  DEFAULT (top-down collapse)
266
271
  Squash-merges the whole stack from top to bottom via GitHub:
@@ -280,6 +285,11 @@ DEFAULT (top-down collapse)
280
285
  Squash-merges only the bottom PR into main as its own commit, then re-roots
281
286
  the next branch onto main. One PR per run — run it again to land the next.
282
287
  Approval is enforced by GitHub branch protection, not gh-stack.
288
+ With --no-push (or GH_STACK_NO_PUSH=1) the PR still merges on GitHub, but
289
+ the re-rooted branches stay local until ${pc.green("gh-stack submit")}. The
290
+ \`[restack] push\` config default doesn't apply to merge. If the re-root
291
+ stops (a conflict, or main not showing the merge yet), finish the fix, then
292
+ re-run ${pc.green("gh-stack merge --base")}: it picks up the already-merged PR.
283
293
 
284
294
  --collapse (stop before main)
285
295
  With the default or --approved, stop after collapsing into the base PR
@@ -313,6 +323,8 @@ OPTIONS
313
323
  --skip-collapse-check
314
324
  Don't verify the collapsed children's content is in
315
325
  the base PR (above)
326
+ --no-push With --base: re-root and restack locally only (also
327
+ GH_STACK_NO_PUSH=1); --push overrides the variable
316
328
  --dry-run Show what would happen without doing anything
317
329
  `);
318
330
  return;
@@ -948,6 +960,43 @@ async function collapsedChildren(
948
960
  return { collapsed, unknown: null };
949
961
  }
950
962
 
963
+ /**
964
+ * Fetch the trunk and confirm it holds PR `prNumber`'s merge commit (#51).
965
+ * The re-root rebases onto `origin/<trunk>`; a trunk fetched before the squash
966
+ * landed puts the next branch on a main without the merged PR's changes, so
967
+ * its PR shows them deleted. Not ok when the merge commit can't be confirmed
968
+ * on the trunk, and the caller must stop rather than re-root.
969
+ */
970
+ async function fetchTrunkWithMerge(
971
+ trunk: string,
972
+ prNumber: number,
973
+ ): Promise<{ ok: boolean; mergeCommit: string | null }> {
974
+ let mergeCommit: string | null = null;
975
+ for (let attempt = 0; ; attempt++) {
976
+ mergeCommit ??= (await getPrMergeState(prNumber))?.mergeCommit ?? null;
977
+ await $`git fetch origin ${trunk}`.nothrow().quiet();
978
+ if (mergeCommit && (await git.isAncestor(mergeCommit, `origin/${trunk}`))) {
979
+ return { ok: true, mergeCommit };
980
+ }
981
+ if (attempt >= TRUNK_MERGE_WAIT_MS.length) return { ok: false, mergeCommit };
982
+ await Bun.sleep(TRUNK_MERGE_WAIT_MS[attempt]!);
983
+ }
984
+ }
985
+
986
+ /**
987
+ * Whether `merge --base` pushes the branches it re-roots and restacks:
988
+ * `--push`/`--no-push`, else GH_STACK_NO_PUSH (#51). The squash itself always
989
+ * lands on GitHub. The `[restack] push` config default deliberately doesn't
990
+ * apply: a repo default for restack shouldn't change what a merge publishes.
991
+ */
992
+ function resolveMergePush(args: string[]): Resolved {
993
+ const flag = flagSetting("restack.push", args);
994
+ if (flag) return flag;
995
+ const noPush = parseEnvBool("GH_STACK_NO_PUSH", process.env.GH_STACK_NO_PUSH);
996
+ if (noPush === undefined) return { value: true, source: "default" };
997
+ return { value: !noPush, source: "env", origin: "GH_STACK_NO_PUSH" };
998
+ }
999
+
951
1000
  /**
952
1001
  * `gh-stack merge --base` — squash-merge ONLY the bottom PR into main, then
953
1002
  * re-root the next branch onto main (replaying just its own commits, via
@@ -974,7 +1023,17 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
974
1023
  }
975
1024
 
976
1025
  const stack = meta.stacks[stackName]!;
977
- await ensureValidStack(meta, stackName);
1026
+ const base = stackBase(stack);
1027
+ // The bottom branch may be gone locally once its PR merged: --delete-branch
1028
+ // deletes it, and a re-run then only finishes the re-root (#51).
1029
+ const root = Object.keys(stack.branches).find((b) => stack.branches[b]!.parent === base);
1030
+ const rootPr = root ? stack.branches[root]!.pr : undefined;
1031
+ const rootGone =
1032
+ root != null &&
1033
+ rootPr != null &&
1034
+ !(await git.localBranchExists(root)) &&
1035
+ (await getPrMergeState(rootPr))?.state === "MERGED";
1036
+ await ensureValidStack(meta, stackName, { allowMissing: rootGone ? [root] : [] });
978
1037
  const ordered = getOrderedBranches(stack);
979
1038
  if (ordered.length === 0) {
980
1039
  p.cancel("No branches in this stack");
@@ -983,7 +1042,6 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
983
1042
 
984
1043
  // Like top-down merge: a split stack rooted off another stack can't drain to
985
1044
  // main until it's re-rooted. Require a trunk base.
986
- const base = stackBase(stack);
987
1045
  if (base !== "main" && base !== "master") {
988
1046
  p.cancel(
989
1047
  `${pc.yellow(stackName)} is based on ${pc.yellow(base)}, not main — can't merge yet.\n\n` +
@@ -994,6 +1052,7 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
994
1052
  }
995
1053
  const trunk = base;
996
1054
  const targetRef = `origin/${trunk}`;
1055
+ const push = resolveMergePush(args).value;
997
1056
 
998
1057
  p.intro(pc.cyan("Merge Base PR (via GitHub)"));
999
1058
  p.log.info(`Stack: ${pc.yellow(stackName)}`);
@@ -1027,6 +1086,11 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
1027
1086
  console.log(
1028
1087
  ` ${pc.dim(`then re-root ${nextChild} onto ${trunk} — the rest stays a stack`)}`,
1029
1088
  );
1089
+ if (!push) {
1090
+ console.log(
1091
+ ` ${pc.dim(`(--no-push: re-rooted branches stay local; ${pc.green("gh-stack submit")} publishes them)`)}`,
1092
+ );
1093
+ }
1030
1094
  }
1031
1095
  console.log();
1032
1096
 
@@ -1097,8 +1161,6 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
1097
1161
  // Keep a copy of the original topology to archive if the stack fully drains.
1098
1162
  const originalStack = structuredClone(stack);
1099
1163
 
1100
- await $`git fetch origin ${trunk}`.quiet();
1101
-
1102
1164
  for (let i = 0; i < plan.steps.length; i++) {
1103
1165
  const step = plan.steps[i]!;
1104
1166
  const mergedBranch = step.branch;
@@ -1151,6 +1213,25 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
1151
1213
  }
1152
1214
 
1153
1215
  if (child) {
1216
+ // Re-root onto the trunk as it is now, with the merge in it (#51).
1217
+ const trunkState = await fetchTrunkWithMerge(trunk, step.pr!);
1218
+ if (!trunkState.ok) {
1219
+ console.log();
1220
+ p.log.error(
1221
+ `PR #${step.pr} is merged, but ${targetRef} doesn't show ${
1222
+ trunkState.mergeCommit
1223
+ ? `its merge commit ${trunkState.mergeCommit.slice(0, 8)}`
1224
+ : "its merge commit (GitHub didn't say which it is)"
1225
+ } yet.`,
1226
+ );
1227
+ console.log(
1228
+ ` Stopped before re-rooting ${pc.yellow(child)}: a ${trunk} without the merge would drop its changes from the stack.`,
1229
+ );
1230
+ console.log(` Once ${trunk} has it, finish with:`);
1231
+ console.log(` ${pc.green("gh-stack merge --base")}`);
1232
+ process.exit(2);
1233
+ }
1234
+
1154
1235
  const res = await advanceNewBottom({
1155
1236
  meta,
1156
1237
  stackName,
@@ -1159,16 +1240,17 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
1159
1240
  childPr,
1160
1241
  targetRef,
1161
1242
  recordedBase: trunk,
1243
+ push,
1162
1244
  expect: verified.get(child),
1163
1245
  });
1164
1246
  if (!res.ok) {
1165
1247
  console.log();
1166
1248
  if (res.reason === "conflict") {
1167
1249
  p.log.error(`Rebase conflict re-rooting ${pc.yellow(child)} onto ${trunk}.`);
1168
- console.log(` Resolve the conflict, then:`);
1250
+ console.log(` Resolve the conflict, then finish with:`);
1169
1251
  console.log(` ${pc.green("git rebase --continue")}`);
1170
1252
  console.log(
1171
- ` ${pc.green("gh-stack restack")} ${pc.dim("(restacks the rest of the tail)")}`,
1253
+ ` ${pc.green("gh-stack merge --base")} ${pc.dim(`(pushes ${child}, retargets its PR, restacks the rest)`)}`,
1172
1254
  );
1173
1255
  console.log();
1174
1256
  console.log(
@@ -1228,10 +1310,10 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
1228
1310
  if (await git.localBranchExists(newBottom)) {
1229
1311
  await git.checkout(newBottom);
1230
1312
  const { default: restack } = await import("./restack.ts");
1231
- // Always push: merge publishes by nature (the advance above already
1232
- // pushed), and children left stale on GitHub are worse than any default.
1233
- // An explicit flag also means no config is read mid-merge.
1234
- await restack(["--push", ...(overwrite ? ["--overwrite-remote"] : [])]);
1313
+ // Push unless this run is local-only (--no-push / GH_STACK_NO_PUSH): the
1314
+ // tail must match the re-root above. An explicit flag also means the
1315
+ // `[restack] push` config isn't read mid-merge.
1316
+ await restack([push ? "--push" : "--no-push", ...(overwrite ? ["--overwrite-remote"] : [])]);
1235
1317
  }
1236
1318
 
1237
1319
  p.outro(
@@ -337,9 +337,11 @@ async function advanceMergedBottoms(
337
337
  console.log();
338
338
  if (res.reason === "conflict") {
339
339
  p.log.error(`Rebase conflict re-rooting ${pc.yellow(child)} onto ${trunk}.`);
340
- console.log(` Resolve it, then finish the restack:`);
340
+ console.log(` Resolve it, then finish the sync:`);
341
341
  console.log(` ${pc.green("git rebase --continue")}`);
342
- console.log(` ${pc.green("gh-stack restack")}`);
342
+ console.log(
343
+ ` ${pc.green("gh-stack sync")} ${pc.dim(`(picks up the re-rooted ${child}; not restack, which would put it back on ${mergedBranch})`)}`,
344
+ );
343
345
  } else {
344
346
  p.log.error(
345
347
  `Could not determine a safe rebase base for ${pc.yellow(child)} (${res.reason}).`,
package/src/index.ts CHANGED
@@ -246,7 +246,7 @@ ${bold("ENVIRONMENT")}
246
246
  GH_STACK_PLAIN=1 Same as --plain
247
247
  GH_STACK_NO_COLOR Disable colored output
248
248
  GH_STACK_DRAFT=1 submit creates new PRs as drafts (0 = ready)
249
- GH_STACK_NO_PUSH=1 restack/sync never push (0 = push)
249
+ GH_STACK_NO_PUSH=1 restack/sync/merge --base never push (0 = push)
250
250
 
251
251
  ${bold("CONFIG")}
252
252
  .gh-stack.toml (repo root) and ~/.config/gh-stack/config.toml set flag
@@ -120,6 +120,17 @@ export async function resolveForkPoint(
120
120
  mergedBranch: string,
121
121
  child: string,
122
122
  ): Promise<string | null> {
123
+ // `gh pr merge --delete-branch` deletes the merged branch locally too, which
124
+ // leaves the shared resolver no ref to read snapshots against. Its pre-merge
125
+ // tip is in the snapshot the caller took: use the newest recorded tip the
126
+ // child still builds on.
127
+ if (!(await git.localBranchExists(mergedBranch))) {
128
+ for (const snap of (meta.snapshots ?? []).toReversed()) {
129
+ const tip = snap.branches[mergedBranch];
130
+ if (tip && (await git.isAncestor(tip, child))) return tip;
131
+ }
132
+ return null;
133
+ }
123
134
  const boundary = await resolveRestackBoundary(meta, mergedBranch, child);
124
135
  return boundary?.sha ?? null;
125
136
  }
@@ -137,6 +148,9 @@ export type AdvanceResult =
137
148
  *
138
149
  * The caller MUST have taken a snapshot first — on a rebase conflict this leaves
139
150
  * the in-progress `git rebase` for the user to resolve and returns `conflict`.
151
+ * Metadata still names the merged branch as the child's parent then, so the
152
+ * fix is to re-run the caller: this skips the finished rebase and does the rest.
153
+ * (`restack` would rebase the child back onto the merged branch.)
140
154
  */
141
155
  export async function advanceNewBottom(opts: {
142
156
  meta: StackMetadata;
@@ -158,21 +172,32 @@ export async function advanceNewBottom(opts: {
158
172
  const push = opts.push ?? true;
159
173
  let retargeted = false;
160
174
 
161
- const forkPoint = await resolveForkPoint(meta, mergedBranch, child);
162
- if (!forkPoint) return { ok: false, reason: "no-fork-point" };
175
+ // Already on the target: an earlier run stopped on a conflict the user then
176
+ // resolved, or the branch was re-rooted by hand (#51). Its fork point is
177
+ // behind it now, so skip the rebase and finish the rest (push, PR base,
178
+ // metadata). Before a re-root the child can't contain the target, which
179
+ // holds the merged PR's new squash commit.
180
+ if (await git.isAncestor(targetRef, child)) {
181
+ p.log.info(
182
+ `${pc.yellow(child)} is already on ${pc.green(recordedBase)} — finishing its re-root`,
183
+ );
184
+ } else {
185
+ const forkPoint = await resolveForkPoint(meta, mergedBranch, child);
186
+ if (!forkPoint) return { ok: false, reason: "no-fork-point" };
187
+
188
+ // Guard: the fork-point must be an ancestor of the child, else `--onto` would
189
+ // replay an unrelated range. Bail loudly rather than mangle history.
190
+ if (!(await git.isAncestor(forkPoint, child))) {
191
+ return { ok: false, reason: "bad-fork-point", forkPoint };
192
+ }
163
193
 
164
- // Guard: the fork-point must be an ancestor of the child, else `--onto` would
165
- // replay an unrelated range. Bail loudly rather than mangle history.
166
- if (!(await git.isAncestor(forkPoint, child))) {
167
- return { ok: false, reason: "bad-fork-point", forkPoint };
194
+ p.log.info(
195
+ `Re-rooting ${pc.yellow(child)} onto ${pc.green(recordedBase)} ${pc.dim(`(base: ${forkPoint.slice(0, 8)})`)}`,
196
+ );
197
+ const ok = await git.rebaseOnto(targetRef, forkPoint, child);
198
+ if (!ok) return { ok: false, reason: "conflict", forkPoint };
168
199
  }
169
200
 
170
- p.log.info(
171
- `Re-rooting ${pc.yellow(child)} onto ${pc.green(recordedBase)} ${pc.dim(`(base: ${forkPoint.slice(0, 8)})`)}`,
172
- );
173
- const ok = await git.rebaseOnto(targetRef, forkPoint, child);
174
- if (!ok) return { ok: false, reason: "conflict", forkPoint };
175
-
176
201
  if (push) {
177
202
  // Push the rebased head first so the PR diff is clean before its base flips.
178
203
  // If it didn't land, leave the base alone: retargeting onto the trunk with
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)}`);
@@ -74,8 +74,9 @@ export GH_STACK_YES=1 # skip every confirmation, plain output
74
74
  human approves), add `--no-push` to `restack`/`sync`, or export
75
75
  `GH_STACK_NO_PUSH=1` (a repo may also set `[restack] push = false`). It wins
76
76
  over `--yes` for pushes and lists what's ahead of origin at the end; publish
77
- later with `gh-stack restack --push`. `merge` and `submit --restack` always
78
- push. `restack --resume` keeps the interrupted run's push decision unless you
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
79
80
  pass `--push`/`--no-push`. `--yes` itself can't come from a config file:
80
81
  always pass it or `GH_STACK_YES=1`.
81
82
  - A prompt reached without a TTY on stdin **exits 1** (naming the prompt)
@@ -335,7 +336,12 @@ Merge is **top-down everywhere** (collapse PRn → PRn-1 → … → base PR, th
335
336
  - **`merge --base`**: squash-merge **only the bottom PR** into trunk and re-root
336
337
  the next branch onto trunk — one PR per run, re-run for the next. Use this to
337
338
  land reviewed PRs one at a time. Auto-advances past a bottom PR already merged
338
- 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`.
339
345
  - **`--collapse`** (with `merge` or `merge --approved`): stop after collapsing
340
346
  into the base PR — review on GitHub, then re-run `merge` to finish.
341
347