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.
@@ -12,18 +12,35 @@ import {
12
12
  } from "../lib/metadata.ts";
13
13
  import { ensureMetadata, ensureCleanWorkingTree, ensureValidStack } from "../lib/safety.ts";
14
14
  import { takeSnapshot } from "../lib/snapshot.ts";
15
- import { getBranchRefSha, getPrInfo, getPrMergeState, resyncPrHead } from "../lib/github.ts";
16
- import { confirmAction } from "../lib/ui.ts";
15
+ import {
16
+ getBranchRefSha,
17
+ getCommitPatch,
18
+ getPrInfo,
19
+ getCommitFiles,
20
+ getPrMergeState,
21
+ getRepoNwo,
22
+ resyncPrHead,
23
+ } from "../lib/github.ts";
24
+ import { verifyCollapse, type CollapsedChild } from "../lib/collapse-guard.ts";
25
+ import type { Stack } from "../types.ts";
26
+ import { confirmAction, isAutoYes } from "../lib/ui.ts";
27
+ import { guardRemoteContent } from "../lib/remote-guard.ts";
28
+ import { ensureNoPendingRun } from "./restack.ts";
17
29
  import {
18
30
  planMerges,
19
31
  advanceNewBottom,
20
32
  type BranchReview,
21
33
  type MergeAction,
22
34
  } from "../lib/advance.ts";
35
+ import { flagSetting, parseEnvBool, type Resolved } from "../lib/config.ts";
23
36
 
24
37
  const MAX_RETRIES = 12;
25
38
  const RETRY_DELAY_MS = 5000;
26
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
+
27
44
  // After GitHub rejects a merge with the "still recomputing" signature, back off
28
45
  // (capped) and re-poll before retrying. One entry per retry; length+1 = max
29
46
  // attempts. Bounds total transient wait per PR to ~30s.
@@ -248,7 +265,7 @@ gh-stack merge — Squash-merge a stack via GitHub
248
265
  USAGE
249
266
  gh-stack merge [--dry-run] [-d|--delete-branch] [--collapse]
250
267
  gh-stack merge --approved [--collapse] [--dry-run] [-d|--delete-branch]
251
- gh-stack merge --base [--dry-run] [-d|--delete-branch]
268
+ gh-stack merge --base [--dry-run] [-d|--delete-branch] [--no-push|--push]
252
269
 
253
270
  DEFAULT (top-down collapse)
254
271
  Squash-merges the whole stack from top to bottom via GitHub:
@@ -268,6 +285,11 @@ DEFAULT (top-down collapse)
268
285
  Squash-merges only the bottom PR into main as its own commit, then re-roots
269
286
  the next branch onto main. One PR per run — run it again to land the next.
270
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.
271
293
 
272
294
  --collapse (stop before main)
273
295
  With the default or --approved, stop after collapsing into the base PR
@@ -283,19 +305,37 @@ a hard failure.
283
305
 
284
306
  A snapshot is taken first; ${pc.green("gh-stack undo")} restores the prior state.
285
307
 
308
+ Before the base PR is handed to main, merge checks that each collapsed child's
309
+ squash commit is still in the branch it was squashed into (as an ancestor, or a
310
+ rebased copy). If the base branch was rewritten without one (e.g. a sync that
311
+ never pulled it), merge stops and names the PR whose changes would be lost.
312
+ Whatever it can't establish (a GitHub or fetch failure, a shallow clone,
313
+ content it can't compare) is UNVERIFIED, never "missing": merge asks (default
314
+ stop, with a retry for transient failures); with --yes it stops. --base runs
315
+ the same check when the base PR carries PRs collapsed into it earlier.
316
+
286
317
  OPTIONS
287
318
  -d, --delete-branch Delete remote branches after merging
288
319
  --approved Collapse only the approved run (from the base up)
289
320
  --base Merge just the bottom PR into main, re-root the next
290
321
  --collapse Stop after collapsing into the base PR (don't merge to
291
322
  main); alias: --stop-at-base
323
+ --skip-collapse-check
324
+ Don't verify the collapsed children's content is in
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
292
328
  --dry-run Show what would happen without doing anything
293
329
  `);
294
330
  return;
295
331
  }
296
332
 
333
+ // A pending restack/sync may have left branches un-pushed or half-rebased;
334
+ // merging (or re-rooting) them now would land that stale state.
335
+ if (!dryRun) await ensureNoPendingRun("merge");
336
+
297
337
  if (baseOnly) {
298
- await mergeBaseOnly(deleteFlag, dryRun);
338
+ await mergeBaseOnly(deleteFlag, dryRun, args);
299
339
  return;
300
340
  }
301
341
 
@@ -541,6 +581,12 @@ OPTIONS
541
581
  }
542
582
  }
543
583
 
584
+ // Every child is squashed into its parent now (this run or an earlier one).
585
+ // Before the base goes anywhere, make sure it still holds their content (#28).
586
+ if (!args.includes("--skip-collapse-check")) {
587
+ await ensureCollapseIntact(stack, rangeOrdered, baseBranch, basePr!);
588
+ }
589
+
544
590
  // ─── --collapse: stop here. Base PR holds the cumulative diff; let the
545
591
  // user review on GitHub and re-run `gh-stack merge` to finish.
546
592
  if (collapse) {
@@ -740,6 +786,217 @@ OPTIONS
740
786
  p.outro(pc.green("Stack merge complete! Stack archived."));
741
787
  }
742
788
 
789
+ /**
790
+ * Before the base PR goes to main, check every collapsed child's squash commit
791
+ * is still in what it was squashed into (#28). Fails closed:
792
+ * - proven missing: stop (exit 2), naming the PR and how to put it back;
793
+ * - unconfirmed or unverified (a lookup failed, a shallow clone, content that
794
+ * can't be compared): interactive, ask (default stop, with a retry when the
795
+ * cause looks transient); under --yes, stop (exit 2) and say it's
796
+ * unverified, with the checks to run by hand.
797
+ */
798
+ async function ensureCollapseIntact(
799
+ stack: Stack,
800
+ rangeOrdered: string[],
801
+ baseBranch: string,
802
+ basePr: number,
803
+ ): Promise<void> {
804
+ const children: CollapsedChild[] = [];
805
+ for (const branch of rangeOrdered) {
806
+ if (branch === baseBranch) continue;
807
+ const entry = stack.branches[branch]!;
808
+ children.push({
809
+ branch,
810
+ pr: entry.pr!,
811
+ parent: entry.parent,
812
+ parentPr: entry.parent === baseBranch ? null : (stack.branches[entry.parent]?.pr ?? null),
813
+ });
814
+ }
815
+ if (children.length === 0) return;
816
+
817
+ const nwo = (await getRepoNwo()) ?? "{owner}/{repo}";
818
+ const patchCommand = (sha: string) =>
819
+ `gh api -H "Accept: application/vnd.github.patch" repos/${nwo}/commits/${sha} | git am`;
820
+ const trunkRef = `origin/${await git.trunkBranch()}`;
821
+
822
+ for (;;) {
823
+ const s = p.spinner();
824
+ s.start(`Checking PR #${basePr} still holds every collapsed PR's changes...`);
825
+ const verdicts = await verifyCollapse({
826
+ base: baseBranch,
827
+ trunkRef,
828
+ children,
829
+ lookup: {
830
+ prResult: getPrMergeState,
831
+ commitPatch: getCommitPatch,
832
+ commitFiles: getCommitFiles,
833
+ },
834
+ });
835
+ const missing = verdicts.flatMap((v) => (v.status === "missing" ? [v] : []));
836
+ const uncertain = verdicts.flatMap((v) =>
837
+ v.status === "unconfirmed" || v.status === "unverified" ? [v] : [],
838
+ );
839
+ s.stop(
840
+ missing.length > 0
841
+ ? pc.red(`PR #${basePr} is missing collapsed changes`)
842
+ : uncertain.length > 0
843
+ ? pc.yellow(`Couldn't confirm PR #${basePr} holds every collapsed PR's changes`)
844
+ : `PR #${basePr} holds every collapsed PR's changes`,
845
+ );
846
+
847
+ if (missing.length > 0) {
848
+ console.log();
849
+ for (const v of missing) {
850
+ p.log.error(
851
+ `PR #${v.child.pr} (${pc.yellow(v.child.branch)})'s changes are missing from PR #${basePr}: ${v.containerLabel} (${v.container.slice(0, 8)}) has every file its squash commit ${v.mergeCommit.slice(0, 8)} touched exactly as it was before that commit.`,
852
+ );
853
+ }
854
+ console.log();
855
+ console.log(
856
+ ` ${pc.yellow(baseBranch)} was rewritten after the collapse (e.g. a restack or sync that never pulled the squash commit).`,
857
+ );
858
+ console.log(
859
+ ` Merging PR #${basePr} now would ship it without those PRs' changes. Nothing was merged to main.`,
860
+ );
861
+ console.log();
862
+ console.log(
863
+ ` Put the squash commit(s) back on ${baseBranch}, then re-run ${pc.green("gh-stack merge")}:`,
864
+ );
865
+ console.log(
866
+ ` ${pc.green(`git checkout ${baseBranch} && git pull --ff-only origin ${baseBranch}`)}`,
867
+ );
868
+ for (const v of missing) {
869
+ // An orphaned squash commit usually can't be fetched from GitHub, but
870
+ // its patch can: lead with what works.
871
+ console.log(
872
+ ` ${pc.green(
873
+ v.fetchable
874
+ ? `git fetch origin ${v.mergeCommit} && git cherry-pick ${v.mergeCommit}`
875
+ : patchCommand(v.mergeCommit),
876
+ )}`,
877
+ );
878
+ }
879
+ console.log(` ${pc.green(`git push --force-with-lease origin ${baseBranch}`)}`);
880
+ console.log(
881
+ ` If the changes really are there in another form: ${pc.green("gh-stack merge --skip-collapse-check")}`,
882
+ );
883
+ process.exit(2);
884
+ }
885
+
886
+ if (uncertain.length === 0) return;
887
+
888
+ console.log();
889
+ for (const v of uncertain) {
890
+ p.log.warn(
891
+ `Could not confirm PR #${v.child.pr} (${pc.yellow(v.child.branch)})'s changes are in PR #${basePr} — this is UNVERIFIED, not known to be missing: ${v.reason}.`,
892
+ );
893
+ }
894
+ console.log();
895
+ console.log(" Check by hand before merging:");
896
+ console.log(
897
+ ` ${pc.green(`gh pr diff ${basePr} --name-only`)} ${pc.dim("(the base PR's files)")}`,
898
+ );
899
+ for (const v of uncertain) {
900
+ console.log(
901
+ ` ${pc.green(`gh pr diff ${v.child.pr} --name-only`)} ${pc.dim(`(PR #${v.child.pr}'s files: each should be in #${basePr})`)}`,
902
+ );
903
+ if (v.status === "unconfirmed") {
904
+ console.log(
905
+ ` ${pc.dim(`if they aren't, put its squash commit back on ${baseBranch}:`)} ${pc.green(patchCommand(v.mergeCommit))}`,
906
+ );
907
+ }
908
+ }
909
+ console.log(` If they're all there: ${pc.green("gh-stack merge --skip-collapse-check")}`);
910
+
911
+ if (isAutoYes()) {
912
+ p.cancel(
913
+ `Stopped before merging PR #${basePr}: its collapsed content is unverified, and --yes never merges on a guess. Nothing was merged to main.`,
914
+ );
915
+ process.exit(2);
916
+ }
917
+ const transient = uncertain.some((v) => v.transient);
918
+ const choice = await p.select({
919
+ message: `Merge PR #${basePr} without confirming those changes are in it?`,
920
+ initialValue: "stop",
921
+ options: [
922
+ ...(transient
923
+ ? [{ value: "retry", label: "Retry the check", hint: "a GitHub or network read failed" }]
924
+ : []),
925
+ { value: "stop", label: "Stop — merge nothing" },
926
+ { value: "merge", label: "Merge anyway" },
927
+ ],
928
+ });
929
+ if (p.isCancel(choice) || choice === "stop") {
930
+ p.cancel(`Stopped before merging PR #${basePr}. Nothing was merged to main.`);
931
+ process.exit(2);
932
+ }
933
+ if (choice === "merge") return;
934
+ }
935
+ }
936
+
937
+ /**
938
+ * The children collapsed into `base` by an earlier `merge --collapse`: walking
939
+ * up from the base, each branch whose PR merged into its stack parent (itself
940
+ * the base or collapsed). `unknown` is the first branch whose PR couldn't be
941
+ * read, when that stopped the walk.
942
+ */
943
+ async function collapsedChildren(
944
+ stack: Stack,
945
+ ordered: string[],
946
+ base: string,
947
+ ): Promise<{ collapsed: string[]; unknown: string | null }> {
948
+ const collapsed: string[] = [];
949
+ const into = new Set([base]);
950
+ for (const branch of ordered) {
951
+ const entry = stack.branches[branch];
952
+ if (branch === base || !entry?.pr || !into.has(entry.parent)) continue;
953
+ const state = await getPrMergeState(entry.pr);
954
+ if (!state) return { collapsed, unknown: branch };
955
+ if (state.state === "MERGED" && state.baseRefName === entry.parent) {
956
+ collapsed.push(branch);
957
+ into.add(branch);
958
+ }
959
+ }
960
+ return { collapsed, unknown: null };
961
+ }
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
+
743
1000
  /**
744
1001
  * `gh-stack merge --base` — squash-merge ONLY the bottom PR into main, then
745
1002
  * re-root the next branch onto main (replaying just its own commits, via
@@ -747,7 +1004,7 @@ OPTIONS
747
1004
  * next. Approval is NOT checked here; GitHub branch protection enforces it.
748
1005
  * A base PR already merged outside gh-stack is detected and advanced past.
749
1006
  */
750
- async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean): Promise<void> {
1007
+ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[]): Promise<void> {
751
1008
  // We rebase the surviving branches, so the same pre-flight guards as restack.
752
1009
  if (await git.isRebaseInProgress()) {
753
1010
  p.cancel(
@@ -766,7 +1023,17 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean): Promise<void
766
1023
  }
767
1024
 
768
1025
  const stack = meta.stacks[stackName]!;
769
- 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] : [] });
770
1037
  const ordered = getOrderedBranches(stack);
771
1038
  if (ordered.length === 0) {
772
1039
  p.cancel("No branches in this stack");
@@ -775,7 +1042,6 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean): Promise<void
775
1042
 
776
1043
  // Like top-down merge: a split stack rooted off another stack can't drain to
777
1044
  // main until it's re-rooted. Require a trunk base.
778
- const base = stackBase(stack);
779
1045
  if (base !== "main" && base !== "master") {
780
1046
  p.cancel(
781
1047
  `${pc.yellow(stackName)} is based on ${pc.yellow(base)}, not main — can't merge yet.\n\n` +
@@ -786,6 +1052,7 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean): Promise<void
786
1052
  }
787
1053
  const trunk = base;
788
1054
  const targetRef = `origin/${trunk}`;
1055
+ const push = resolveMergePush(args).value;
789
1056
 
790
1057
  p.intro(pc.cyan("Merge Base PR (via GitHub)"));
791
1058
  p.log.info(`Stack: ${pc.yellow(stackName)}`);
@@ -819,6 +1086,11 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean): Promise<void
819
1086
  console.log(
820
1087
  ` ${pc.dim(`then re-root ${nextChild} onto ${trunk} — the rest stays a stack`)}`,
821
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
+ }
822
1094
  }
823
1095
  console.log();
824
1096
 
@@ -837,14 +1109,58 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean): Promise<void
837
1109
  process.exit(0);
838
1110
  }
839
1111
 
1112
+ // #28: a base PR that carries PRs collapsed into it by an earlier
1113
+ // `merge --collapse` must still hold their content before it goes to main.
1114
+ // Collapsed children are found from GitHub (merged PRs whose base was their
1115
+ // stack parent); a lookup that fails is uncertainty, and fails closed.
1116
+ let collapsed: string[] = [];
1117
+ if (baseAction === "merge" && !args.includes("--skip-collapse-check")) {
1118
+ const found = await collapsedChildren(stack, ordered, baseBranch);
1119
+ if (found.unknown) {
1120
+ p.log.warn(
1121
+ `Couldn't tell whether PR #${basePr} carries PRs collapsed into it: ${found.unknown}'s PR couldn't be read from GitHub.`,
1122
+ );
1123
+ console.log(
1124
+ ` Check by hand: ${pc.green(`gh pr view ${stack.branches[found.unknown]!.pr} --json state,baseRefName`)}`,
1125
+ );
1126
+ console.log(
1127
+ ` Then re-run, or skip the check: ${pc.green("gh-stack merge --base --skip-collapse-check")}`,
1128
+ );
1129
+ if (isAutoYes()) {
1130
+ p.cancel(
1131
+ `Stopped before merging PR #${basePr} (--yes never merges on a guess). Nothing was merged.`,
1132
+ );
1133
+ process.exit(2);
1134
+ }
1135
+ const go = await p.confirm({ message: `Merge PR #${basePr} anyway?`, initialValue: false });
1136
+ if (p.isCancel(go) || !go) {
1137
+ p.cancel(`Stopped before merging PR #${basePr}. Nothing was merged.`);
1138
+ process.exit(2);
1139
+ }
1140
+ }
1141
+ collapsed = found.collapsed;
1142
+ if (collapsed.length > 0) {
1143
+ await ensureCollapseIntact(stack, [baseBranch, ...collapsed], baseBranch, basePr);
1144
+ }
1145
+ }
1146
+
1147
+ // The branches this run rewrites and force-pushes (the next child here, the
1148
+ // rest in the restack below) must not drop commits only origin has.
1149
+ // Collapsed children are left out: their origin copies hold the squash
1150
+ // commits of PRs merged into them, which aren't the user's to pull.
1151
+ const overwrite = args.includes("--overwrite-remote");
1152
+ const verified = await guardRemoteContent(
1153
+ meta,
1154
+ ordered.slice(1).filter((b) => !collapsed.includes(b)),
1155
+ { command: "merge --base", overwrite, dryRun: false },
1156
+ );
1157
+
840
1158
  // Snapshot first — this records every branch's pre-merge tip, which both the
841
1159
  // fork-point resolver and `gh-stack undo` rely on.
842
1160
  await takeSnapshot(meta, stackName, "merge-base");
843
1161
  // Keep a copy of the original topology to archive if the stack fully drains.
844
1162
  const originalStack = structuredClone(stack);
845
1163
 
846
- await $`git fetch origin ${trunk}`.quiet();
847
-
848
1164
  for (let i = 0; i < plan.steps.length; i++) {
849
1165
  const step = plan.steps[i]!;
850
1166
  const mergedBranch = step.branch;
@@ -897,6 +1213,25 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean): Promise<void
897
1213
  }
898
1214
 
899
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
+
900
1235
  const res = await advanceNewBottom({
901
1236
  meta,
902
1237
  stackName,
@@ -905,15 +1240,17 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean): Promise<void
905
1240
  childPr,
906
1241
  targetRef,
907
1242
  recordedBase: trunk,
1243
+ push,
1244
+ expect: verified.get(child),
908
1245
  });
909
1246
  if (!res.ok) {
910
1247
  console.log();
911
1248
  if (res.reason === "conflict") {
912
1249
  p.log.error(`Rebase conflict re-rooting ${pc.yellow(child)} onto ${trunk}.`);
913
- console.log(` Resolve the conflict, then:`);
1250
+ console.log(` Resolve the conflict, then finish with:`);
914
1251
  console.log(` ${pc.green("git rebase --continue")}`);
915
1252
  console.log(
916
- ` ${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)`)}`,
917
1254
  );
918
1255
  console.log();
919
1256
  console.log(
@@ -973,7 +1310,10 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean): Promise<void
973
1310
  if (await git.localBranchExists(newBottom)) {
974
1311
  await git.checkout(newBottom);
975
1312
  const { default: restack } = await import("./restack.ts");
976
- await restack([]);
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"] : [])]);
977
1317
  }
978
1318
 
979
1319
  p.outro(