gh-stack 0.17.1 → 0.18.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/README.md CHANGED
@@ -83,12 +83,20 @@ create <branch-name> [--description <desc>]
83
83
  Create a new git branch off the current branch and add it to
84
84
  the stack. The current branch must already be tracked.
85
85
 
86
- add [branch]
86
+ add [branch] [--parent <branch>]
87
87
  Track an EXISTING local branch (default: the current branch) in a
88
88
  stack — without pushing or opening a PR. Walks local ancestry: if the
89
89
  branch sits on top of an existing stack it's adopted into it (parent
90
90
  auto-detected); otherwise a new stack is created from the chain.
91
91
 
92
+ --parent <branch>: stack it directly on <branch> (which it must
93
+ already be based on), tracking <branch> first if needed. Use this
94
+ for a branch with no commits of its own yet — it sits AT its
95
+ parent's tip, so ancestry can't tell which is the parent and it
96
+ would otherwise be rooted at trunk (init/add warn when this
97
+ happens). It also re-homes an already-tracked branch, but only
98
+ when it's alone in its stack and has no PR.
99
+
92
100
  Use this for a WIP branch you've created with `git checkout -b` but
93
101
  aren't ready to submit. It fills the gap between creating a branch and
94
102
  `submit`: until a branch is tracked, `sync`/`restack`/navigation don't
@@ -152,11 +160,16 @@ ls
152
160
  ### Stack Management
153
161
 
154
162
  ```
155
- restack [--resume] [--dry-run] [--verbose]
163
+ restack [--resume] [--dry-run] [--no-push] [--verbose]
156
164
  Rebase the current branch and all descendants onto their parents.
157
165
  Uses metadata snapshots to recover the correct rebase base even
158
166
  after a parent's history has been rewritten.
159
167
 
168
+ --no-push (or GH_STACK_NO_PUSH=1): rebase locally only, and list
169
+ the refs left ahead of origin. It overrides --yes for pushes, so
170
+ an agent can restack unattended without publishing anything. An
171
+ interrupted --no-push run stays local-only on --resume.
172
+
160
173
  On conflict:
161
174
  git rebase --continue
162
175
  gh-stack restack --resume
@@ -169,7 +182,7 @@ restack --onto <ref>
169
182
  onto main once the parent stack has merged — only the stack's own
170
183
  commits replay onto the new base.
171
184
 
172
- sync [--dry-run]
185
+ sync [--dry-run] [--no-push]
173
186
  Fetch main, rebase the base branch onto main, then restack all
174
187
  children. Snapshots every branch's pre-sync tip so children can be
175
188
  correctly rebased even though their parent's history was rewritten.
@@ -179,6 +192,10 @@ sync [--dry-run]
179
192
  own commits replay — instead of replaying the now-squashed commits
180
193
  and conflicting.
181
194
 
195
+ --no-push (or GH_STACK_NO_PUSH=1): as for restack. Also skips
196
+ retargeting a PR's base after advancing past a merged bottom;
197
+ the next `submit` pushes and retargets together.
198
+
182
199
  merge [--dry-run] [-d|--delete-branch] [--collapse]
183
200
  merge --approved [--collapse] [--dry-run] [-d|--delete-branch]
184
201
  merge --base [--dry-run] [-d|--delete-branch]
@@ -266,9 +283,13 @@ log
266
283
 
267
284
  update-prs [--force]
268
285
  Refresh the "📚 Stacked on" visualization block in every PR
269
- description for the current stack. PRs whose rendered block is
270
- unchanged are skipped; --force rewrites all of them. (submit does
271
- this automatically — use this to refresh without pushing.)
286
+ description for the current stack. Each PR's live description is
287
+ checked, so a block removed by an outside edit (`gh pr edit`, the
288
+ GitHub UI) is restored; PRs already showing the current block are
289
+ skipped, and --force rewrites all of them. A tracked branch with no
290
+ recorded PR is looked up on GitHub first, so a PR opened with
291
+ `gh pr create` is adopted. (submit refreshes the block too, but
292
+ compares against what it last wrote — use update-prs to repair.)
272
293
 
273
294
  stacks [--current] [--json]
274
295
  List all stacks and their topology. Read-only and network-free —
@@ -322,11 +343,14 @@ learn [--skill] [--harness <name>] [--global] [--force]
322
343
 
323
344
  `--plain` is auto-enabled when `--yes` / `GH_STACK_YES=1` is set, so agents get clean, easily-filtered output by default. Use `--plain` alone if you want plain output but still interactive prompts.
324
345
 
346
+ If a command needs to prompt but stdin isn't a TTY, it exits 1 with the prompt it would have shown, instead of waiting for an answer that can't arrive. Set `GH_STACK_YES=1` (or pass `--yes`) in that context.
347
+
325
348
  ## Environment Variables
326
349
 
327
350
  ```
328
351
  GH_STACK_YES=1 Skip all confirmations (same as --yes; also enables --plain)
329
352
  GH_STACK_PLAIN=1 Plain output (same as --plain)
353
+ GH_STACK_NO_PUSH=1 restack/sync never push (same as --no-push; wins over --yes)
330
354
  GH_STACK_NO_COLOR=1 Disable colored output
331
355
  ```
332
356
 
@@ -342,6 +366,8 @@ The critical insight: after a parent branch's history is rewritten (e.g., rebase
342
366
 
343
367
  gh-stack solves this by snapshotting every branch's tip **before** any destructive operation. When restacking a child whose parent has been rewritten, gh-stack walks the snapshots newest-first and finds the most recent recorded tip that's no longer an ancestor of the parent's current tip. That orphaned SHA is the correct rebase base — `git rebase --onto <new-parent-tip> <orphaned-old-tip> <child>` replays only the child's unique commits.
344
368
 
369
+ If the child sat **behind** the parent when the snapshot was taken (a placeholder branch created before any work landed on it), the recorded tip isn't in the child's history — but it still pins the parent's pre-rewrite history, so the boundary is derived as the child's fork point on it (`merge-base(child, recorded-tip)`). A child with no unique commits is simply moved to the parent's tip, no rebase involved. When no safe boundary can be determined at all, restack refuses with a manual `git rebase --onto` recipe rather than guessing.
370
+
345
371
  Snapshots also power `gh-stack undo`, so the same data structure does double duty.
346
372
 
347
373
  ### Stack Position in PR Titles
@@ -516,6 +542,9 @@ gh-stack submit -n # Or auto-generate titles
516
542
  gh-stack sync
517
543
  gh-stack restack
518
544
 
545
+ # Rebase without publishing (e.g. before a human approves the push)
546
+ GH_STACK_NO_PUSH=1 gh-stack restack
547
+
519
548
  # Structured output
520
549
  gh-stack status --json
521
550
  gh-stack status --current --json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.17.1",
3
+ "version": "0.18.0",
4
4
  "description": "Stacked PR manager for squash-merge workflows",
5
5
  "author": "Kiliman <kiliman@gmail.com>",
6
6
  "license": "MIT",
@@ -13,7 +13,7 @@ import pc from "picocolors";
13
13
  import * as git from "../lib/git.ts";
14
14
  import { gateLegacyMetadata } from "../lib/safety.ts";
15
15
  import { readMetadata, writeMetadata, findStackForBranch, stackBase } from "../lib/metadata.ts";
16
- import { resolveOrCreateStack } from "../lib/chain.ts";
16
+ import { colocatedBranches, resolveOrCreateStack } from "../lib/chain.ts";
17
17
  import type { StackMetadata } from "../types.ts";
18
18
 
19
19
  export default async function add(args: string[]): Promise<void> {
@@ -22,26 +22,40 @@ export default async function add(args: string[]): Promise<void> {
22
22
  gh-stack add — track an existing local branch in a stack (no push, no PR)
23
23
 
24
24
  USAGE
25
- gh-stack add [branch]
25
+ gh-stack add [branch] [--parent <branch>]
26
26
 
27
27
  Registers the branch (default: the current branch) into a stack so sync,
28
28
  restack, and navigation work before you've submitted it. Walks local ancestry:
29
29
  if the branch sits on top of an existing stack it's adopted into that stack;
30
30
  otherwise a new stack is created from the chain, rooted at the trunk.
31
31
 
32
+ OPTIONS
33
+ --parent <branch> Stack the branch directly on <branch> instead of detecting
34
+ it (which must already be based on it). Needed when the
35
+ branch has no commits of its own yet, since it then sits AT
36
+ its parent's tip and ancestry can't tell them apart.
37
+ Also re-homes an already-tracked branch, when it's alone in
38
+ its stack and has no PR (e.g. a mistaken ${"`"}init${"`"} at trunk).
39
+
32
40
  Network-free — nothing is pushed and no PR is opened. Run ${"`"}gh-stack submit${"`"} when
33
41
  you're ready to publish.
34
42
 
35
43
  EXAMPLES
36
44
  gh-stack add Track the current branch
37
45
  gh-stack add my-wip-branch Track a specific local branch
46
+ gh-stack add --parent feat-1 Track the current branch on top of feat-1
38
47
  `);
39
48
  return;
40
49
  }
41
50
 
42
51
  await gateLegacyMetadata();
43
52
 
44
- const positional = args.find((a) => !a.startsWith("-"));
53
+ let parentFlag: string | undefined;
54
+ let positional: string | undefined;
55
+ for (let i = 0; i < args.length; i++) {
56
+ if (args[i] === "--parent" && args[i + 1]) parentFlag = args[++i];
57
+ else if (!args[i]!.startsWith("-")) positional ??= args[i];
58
+ }
45
59
  const branch = positional ?? (await git.currentBranch());
46
60
 
47
61
  if (!(await git.localBranchExists(branch))) {
@@ -65,9 +79,15 @@ EXAMPLES
65
79
  stacks: {},
66
80
  };
67
81
 
82
+ if (parentFlag) {
83
+ await addUnderParent(meta, branch, parentFlag, trunk);
84
+ return;
85
+ }
86
+
68
87
  const already = findStackForBranch(meta, branch);
69
88
  if (already) {
70
89
  p.log.info(`${pc.yellow(branch)} is already tracked in stack ${pc.yellow(already)}.`);
90
+ await warnIfColocated(branch, trunk);
71
91
  return;
72
92
  }
73
93
 
@@ -100,7 +120,115 @@ EXAMPLES
100
120
  for (const b of resolution.addedBranches) {
101
121
  console.log(` ${pc.green("+")} ${b}`);
102
122
  }
123
+ if (resolution.created && resolution.chain.length === 1) await warnIfColocated(branch, trunk);
124
+
125
+ p.outro(
126
+ `Tracked — ${pc.green("gh-stack sync")}/${pc.green("restack")} now work. ${pc.dim("Run")} ${pc.green("gh-stack submit")} ${pc.dim("to open PRs.")}`,
127
+ );
128
+ }
103
129
 
130
+ /**
131
+ * A branch with no commits of its own sits AT its parent's tip, so ancestry
132
+ * can't find the parent and it gets rooted at trunk. Say so, and how to fix it,
133
+ * rather than leaving a stack that `sync` would later rebase onto trunk —
134
+ * replaying the parent's commits. (#37)
135
+ */
136
+ export async function warnIfColocated(branch: string, trunk: string): Promise<void> {
137
+ const same = await colocatedBranches(branch, trunk);
138
+ if (same.length === 0) return;
139
+ p.log.warn(
140
+ `${pc.yellow(branch)} has no commits of its own — it shares its tip with ${same.map((b) => pc.yellow(b)).join(", ")}, so it was rooted at ${trunk}.\n` +
141
+ ` If it belongs on one of those, re-home it:\n` +
142
+ same.map((b) => ` ${pc.green(`gh-stack add ${branch} --parent ${b}`)}`).join("\n"),
143
+ );
144
+ }
145
+
146
+ /**
147
+ * `add --parent`: stack `branch` directly on `parent`, tracking `parent` first
148
+ * (from its own ancestry) if needed. An already-tracked branch is re-homed only
149
+ * when that can't lose anything: it's alone in its stack and has no PR — the
150
+ * shape a mistaken `init` at trunk leaves behind. Metadata-only; the branch
151
+ * must already be based on `parent`. (#37)
152
+ */
153
+ async function addUnderParent(
154
+ meta: StackMetadata,
155
+ branch: string,
156
+ parent: string,
157
+ trunk: string,
158
+ ): Promise<void> {
159
+ if (parent === branch) {
160
+ p.cancel(`A branch can't be its own parent.`);
161
+ process.exit(1);
162
+ }
163
+ if (parent === trunk || parent === "main" || parent === "master") {
164
+ p.cancel(
165
+ `--parent is for stacking on a feature branch. To root ${pc.yellow(branch)} at ${trunk}, run ${pc.green("gh-stack add")} without --parent.`,
166
+ );
167
+ process.exit(1);
168
+ }
169
+ if (!(await git.localBranchExists(parent))) {
170
+ p.cancel(`Branch ${pc.yellow(parent)} doesn't exist locally.`);
171
+ process.exit(1);
172
+ }
173
+ if (!(await git.isAncestor(parent, branch))) {
174
+ p.cancel(
175
+ `${pc.yellow(branch)} isn't based on ${pc.yellow(parent)} — rebase it first:\n\n ${pc.green(`git rebase ${parent} ${branch}`)}`,
176
+ );
177
+ process.exit(1);
178
+ }
179
+
180
+ const oldStackName = findStackForBranch(meta, branch);
181
+ const oldBranch = oldStackName ? meta.stacks[oldStackName]!.branches[branch]! : null;
182
+
183
+ if (oldStackName && oldBranch) {
184
+ if (oldBranch.parent === parent) {
185
+ p.log.info(`${pc.yellow(branch)} is already stacked on ${pc.yellow(parent)}.`);
186
+ return;
187
+ }
188
+ const oldStack = meta.stacks[oldStackName]!;
189
+ const alone = Object.keys(oldStack.branches).length === 1;
190
+ if (!alone || oldBranch.pr != null) {
191
+ p.cancel(
192
+ `${pc.yellow(branch)} is already tracked in stack ${pc.yellow(oldStackName)}` +
193
+ (oldBranch.pr != null ? ` with PR #${oldBranch.pr}` : " alongside other branches") +
194
+ ` — re-homing it could strand them, so add --parent won't move it.`,
195
+ );
196
+ process.exit(1);
197
+ }
198
+ }
199
+
200
+ // Track the parent first, from its own ancestry, if it isn't yet. Resolved
201
+ // before dropping the branch's old stack so a failure leaves nothing changed.
202
+ let targetStackName = findStackForBranch(meta, parent);
203
+ const adoptedParents: string[] = [];
204
+ if (!targetStackName) {
205
+ try {
206
+ const res = await resolveOrCreateStack(meta, parent, trunk);
207
+ targetStackName = res.stackName;
208
+ adoptedParents.push(...res.addedBranches);
209
+ } catch (err) {
210
+ p.cancel((err as Error).message);
211
+ process.exit(1);
212
+ }
213
+ }
214
+
215
+ // Drop the throwaway one-branch stack. Deleted, not archived: restoring it
216
+ // would track the branch in two stacks at once.
217
+ if (oldStackName && oldStackName !== targetStackName) delete meta.stacks[oldStackName];
218
+
219
+ const target = meta.stacks[targetStackName]!;
220
+ target.branches[branch] = { ...oldBranch, parent };
221
+ target.last_branch = branch;
222
+ meta.current_stack = targetStackName;
223
+ await writeMetadata(meta);
224
+
225
+ p.intro(pc.cyan("Add to Stack"));
226
+ for (const b of adoptedParents) console.log(` ${pc.green("+")} ${b}`);
227
+ p.log.success(
228
+ oldStackName
229
+ ? `Re-homed ${pc.yellow(branch)} onto ${pc.yellow(parent)} in stack ${pc.yellow(targetStackName)} ${pc.dim(`(removed one-branch stack ${oldStackName})`)}`
230
+ : `Added ${pc.yellow(branch)} onto ${pc.yellow(parent)} in stack ${pc.yellow(targetStackName)}`,
231
+ );
104
232
  p.outro(
105
233
  `Tracked — ${pc.green("gh-stack sync")}/${pc.green("restack")} now work. ${pc.dim("Run")} ${pc.green("gh-stack submit")} ${pc.dim("to open PRs.")}`,
106
234
  );
@@ -13,6 +13,7 @@ import {
13
13
  import { getPrNumber } from "../lib/github.ts";
14
14
  import { isAutoYes } from "../lib/ui.ts";
15
15
  import { detectBranchChain } from "../lib/chain.ts";
16
+ import { warnIfColocated } from "./add.ts";
16
17
  import { gateLegacyMetadata } from "../lib/safety.ts";
17
18
  import type { StackMetadata, Branch } from "../types.ts";
18
19
 
@@ -151,6 +152,9 @@ export default async function init(args: string[]): Promise<void> {
151
152
 
152
153
  p.outro(pc.green(`Stack ${pc.yellow(stackName)} created with ${chain.length} branch(es)!`));
153
154
 
155
+ // A zero-commit branch can't see its parent through ancestry (#37).
156
+ if (chain.length === 1) await warnIfColocated(branch, trunk);
157
+
154
158
  // Next steps
155
159
  console.log();
156
160
  if (chain.length === 1) {
@@ -15,12 +15,25 @@ import {
15
15
  writeMetadata,
16
16
  } from "../lib/metadata.ts";
17
17
  import { ensureMetadata, ensureCleanWorkingTree, ensureValidStack } from "../lib/safety.ts";
18
- import { takeSnapshot, findPreRewriteSha, findRecordedBaseTip } from "../lib/snapshot.ts";
18
+ import { takeSnapshot, resolveRestackBoundary, findRecordedBaseTip } from "../lib/snapshot.ts";
19
19
  import { confirmAction } from "../lib/ui.ts";
20
20
  import type { StackMetadata } from "../types.ts";
21
21
 
22
+ /**
23
+ * `--no-push` / `GH_STACK_NO_PUSH=1`: rebase locally, never publish (#36).
24
+ * Module-level because every rebase path funnels through `pushBranchRef`.
25
+ * Wins over `--yes`, which still auto-accepts the rebases themselves.
26
+ */
27
+ let noPush = false;
28
+
29
+ /** Whether `--no-push` (or `GH_STACK_NO_PUSH=1`) is in effect for these args. */
30
+ export function wantsNoPush(args: string[]): boolean {
31
+ return args.includes("--no-push") || process.env.GH_STACK_NO_PUSH === "1";
32
+ }
33
+
22
34
  export default async function restack(args: string[]): Promise<void> {
23
35
  const dryRun = args.includes("--dry-run");
36
+ noPush = wantsNoPush(args);
24
37
  const resume = args.includes("--resume");
25
38
  const verbose = args.includes("--verbose") || args.includes("-v");
26
39
 
@@ -42,6 +55,8 @@ USAGE
42
55
  OPTIONS
43
56
  --onto <ref> Re-root the stack onto a new base ref (changes stack base)
44
57
  --yes, -y Skip confirmations (auto-accept all rebases and pushes)
58
+ --no-push Rebase locally only — never push (also GH_STACK_NO_PUSH=1).
59
+ Wins over --yes for pushes; rebases are still auto-accepted
45
60
  --resume Resume after resolving rebase conflicts
46
61
  --dry-run Show what would happen without executing
47
62
  --verbose Show diagnostic info (snapshot vs merge-base)
@@ -49,6 +64,8 @@ OPTIONS
49
64
  EXAMPLES
50
65
  gh-stack restack Interactive (prompts before each rebase)
51
66
  gh-stack restack --yes Non-interactive (for agents/CI)
67
+ gh-stack restack --yes --no-push
68
+ Non-interactive, local-only (review first)
52
69
  gh-stack restack --resume Continue after resolving conflicts
53
70
  gh-stack restack --onto main Re-root a split stack onto main after its
54
71
  parent stack has merged
@@ -110,6 +127,21 @@ ALIASES
110
127
  // when a push didn't take. (#12)
111
128
  if (!dryRun) {
112
129
  const stale = await findStaleRefs(touched, meta);
130
+ if (stale.length > 0 && noPush) {
131
+ // Expected under --no-push: report what's ahead of origin so a later
132
+ // submit/restack knows what to publish, but don't fail the run.
133
+ console.log();
134
+ p.log.info("Not pushed (--no-push) — these refs are ahead of origin:");
135
+ for (const { branch, local, remote } of stale) {
136
+ console.log(` ${pc.yellow("↑")} ${branch} local ${local.slice(0, 8)} origin ${remote}`);
137
+ }
138
+ console.log();
139
+ console.log(
140
+ ` Publish when ready: ${pc.green("gh-stack restack")} or ${pc.green("gh-stack submit")}`,
141
+ );
142
+ p.outro(pc.green("Stack Restack Complete (local only)"));
143
+ return;
144
+ }
113
145
  if (stale.length > 0) {
114
146
  console.log();
115
147
  p.log.error("Some branch refs are NOT in sync with origin:");
@@ -185,6 +217,8 @@ async function handleResume(
185
217
  p.cancel("No saved state found. Nothing to resume.");
186
218
  process.exit(1);
187
219
  }
220
+ // An interrupted --no-push run stays local-only when resumed.
221
+ if (state.no_push) noPush = true;
188
222
 
189
223
  // Check if rebase is still in progress
190
224
  if (await git.isRebaseInProgress()) {
@@ -549,49 +583,45 @@ async function processChain(
549
583
  continue;
550
584
  }
551
585
 
552
- // Resolve the rebase base.
553
- //
554
- // Strategy:
555
- // 1. Look for a recent snapshot where the parent's recorded tip is no
556
- // longer an ancestor of the parent's current tip AND is still an
557
- // ancestor of the child. That recorded SHA is the orphaned
558
- // old-parent tip — exactly the rebase base we need.
559
- // 2. Fall back to `merge-base(branch, parent)` if no snapshot tells us
560
- // the parent was rewritten (typical case: parent only had commits
561
- // appended, not rebased).
562
- //
563
- // The child-ancestry check (issue #5) prevents stale snapshots from
564
- // older sessions — where the child has already been rebased past the
565
- // recorded point — from being used as a rebase base.
586
+ // Resolve the rebase boundary — snapshot pre-rewrite tip, a fork point
587
+ // derived from a rejected snapshot (#30), or merge-base, in that order.
588
+ // See resolveRestackBoundary for the full strategy.
566
589
  //
567
590
  // Re-read metadata fresh because previous iterations of this loop may
568
591
  // have updated snapshots.
569
592
  const freshMeta = (await readMetadata()) ?? meta;
570
- const oldBaseFromSnapshot = await findPreRewriteSha(freshMeta, parent, branch);
571
- const oldBaseFromMergeBase = await git.mergeBase(branch, parent);
572
- const oldBase = oldBaseFromSnapshot ?? oldBaseFromMergeBase;
593
+ const boundary = await resolveRestackBoundary(freshMeta, parent, branch);
573
594
 
574
595
  if (verbose) {
575
596
  console.log();
576
597
  console.log(pc.yellow(" Diagnostic: base resolution"));
577
598
  console.log(
578
- ` ${pc.cyan("Snapshot pre-rewrite tip:")} ${oldBaseFromSnapshot?.slice(0, 8) ?? "(none)"}`,
579
- );
580
- console.log(
581
- ` ${pc.cyan("Current merge-base:")} ${oldBaseFromMergeBase?.slice(0, 8) ?? "(none)"}`,
599
+ ` ${pc.cyan("Boundary:")} ${boundary ? `${boundary.sha.slice(0, 8)} (${boundary.source})` : "(none)"}`,
582
600
  );
583
- if (
584
- oldBaseFromSnapshot &&
585
- oldBaseFromMergeBase &&
586
- oldBaseFromSnapshot !== oldBaseFromMergeBase
587
- ) {
601
+ if (boundary && boundary.source !== "merge-base") {
588
602
  console.log(
589
- ` ${pc.yellow("⚠ Parent was rewritten")} — using snapshot base (would have replayed parent commits otherwise)`,
603
+ ` ${pc.yellow("⚠ Parent was rewritten")} — merge-base alone would have replayed parent commits`,
590
604
  );
591
605
  }
592
606
  console.log();
593
607
  }
594
608
 
609
+ if (!boundary) {
610
+ // No snapshot evidence and no common history — any base would be a
611
+ // guess, and a wrong guess replays the parent's history onto itself.
612
+ // Refuse with the manual command, consistent with the --onto reroot
613
+ // path. (#30: never silently guess when we can't derive the boundary.)
614
+ p.log.error(
615
+ `Cannot determine a safe rebase base for ${pc.yellow(branch)} onto ${pc.yellow(parent)}.`,
616
+ );
617
+ console.log();
618
+ console.log(" Rebase it manually with an explicit boundary:");
619
+ console.log(` ${pc.green(`git rebase --onto ${parent} <old-parent-tip> ${branch}`)}`);
620
+ console.log();
621
+ console.log(` Then re-run ${pc.green("gh-stack restack")} to continue the chain.`);
622
+ process.exit(1);
623
+ }
624
+
595
625
  // Checkout the branch
596
626
  p.log.info(`Checking out ${pc.yellow(branch)}...`);
597
627
  await git.checkout(branch);
@@ -601,24 +631,32 @@ async function processChain(
601
631
  current_index: i,
602
632
  stack_name: stackName,
603
633
  chain,
634
+ ...(noPush && { no_push: true }),
604
635
  });
605
636
 
606
637
  let success: boolean;
607
- if (oldBase) {
608
- const sourceLabel = oldBaseFromSnapshot ? "snapshot" : "merge-base";
638
+ const branchTip = await git.revParse(branch);
639
+ if (boundary.sha === branchTip) {
640
+ // The child has no commits of its own (a placeholder branch created off
641
+ // the parent before any work landed). Nothing to replay — move the ref
642
+ // instead of invoking rebase. (#30)
643
+ p.log.info(
644
+ `${pc.yellow(branch)} has no unique commits — moving it to ${pc.yellow(parent)}'s tip`,
645
+ );
646
+ success = await git.resetHard(parent);
647
+ } else {
648
+ const sourceLabel =
649
+ boundary.source === "snapshot-derived" ? "snapshot (derived fork-point)" : boundary.source;
609
650
  p.log.info(
610
- `Rebasing onto ${pc.yellow(parent)} (base: ${oldBase.slice(0, 8)} from ${sourceLabel})`,
651
+ `Rebasing onto ${pc.yellow(parent)} (base: ${boundary.sha.slice(0, 8)} from ${sourceLabel})`,
611
652
  );
612
653
 
613
654
  if (verbose) {
614
- const count = await git.commitCount(oldBase, branch);
655
+ const count = await git.commitCount(boundary.sha, branch);
615
656
  console.log(` Moving ${count} commit(s)`);
616
657
  }
617
658
 
618
- success = await git.rebaseOnto(parent, oldBase, branch);
619
- } else {
620
- p.log.warn("Could not determine rebase base — using simple rebase");
621
- success = await git.rebase(parent);
659
+ success = await git.rebaseOnto(parent, boundary.sha, branch);
622
660
  }
623
661
 
624
662
  if (success) {
@@ -669,6 +707,8 @@ function branchHasPr(meta: StackMetadata, branch: string): boolean {
669
707
 
670
708
  async function pushBranchRef(meta: StackMetadata, branch: string): Promise<void> {
671
709
  if (branch === "main" || branch === "master") return;
710
+ // --no-push: leave origin alone; the end-of-run summary lists what's ahead.
711
+ if (noPush) return;
672
712
 
673
713
  const localSha = await git.revParse(branch);
674
714
  const hasRemote = await git.remoteBranchExists(branch);
@@ -17,20 +17,27 @@ import { confirmAction } from "../lib/ui.ts";
17
17
  import { getPrInfo } from "../lib/github.ts";
18
18
  import { planMerges, advanceNewBottom, type BranchReview } from "../lib/advance.ts";
19
19
  import type { StackMetadata } from "../types.ts";
20
+ import { wantsNoPush } from "./restack.ts";
20
21
 
21
22
  export default async function sync(args: string[]): Promise<void> {
22
23
  const dryRun = args.includes("--dry-run");
24
+ const noPush = wantsNoPush(args);
23
25
 
24
26
  if (args.includes("--help")) {
25
27
  console.log(`
26
28
  gh-stack sync — Sync base with main + restack all
27
29
 
28
30
  USAGE
29
- gh-stack sync [--dry-run]
31
+ gh-stack sync [--dry-run] [--no-push]
30
32
 
31
33
  Fetches latest main, rebases the base branch onto main,
32
34
  then restacks all children. Equivalent to running restack
33
35
  with the base branch included.
36
+
37
+ OPTIONS
38
+ --dry-run Show what would happen without executing
39
+ --no-push Rebase locally only — never push or retarget PRs
40
+ (also GH_STACK_NO_PUSH=1). Wins over --yes for pushes
34
41
  `);
35
42
  return;
36
43
  }
@@ -91,7 +98,7 @@ with the base branch included.
91
98
  // them. A plain rebase onto main would then replay their now-squashed commits
92
99
  // and conflict — so advance past them (re-rooting the survivor onto main with
93
100
  // `--onto`) before the normal sync runs. (#20)
94
- const advance = await advanceMergedBottoms(meta, stackName, base, dryRun);
101
+ const advance = await advanceMergedBottoms(meta, stackName, base, dryRun, noPush);
95
102
  if (advance.drained) {
96
103
  p.outro(pc.green("Bottom PR(s) already merged — stack fully drained & archived."));
97
104
  return;
@@ -146,7 +153,7 @@ with the base branch included.
146
153
 
147
154
  // Take snapshot BEFORE rebasing. This records every branch's pre-rebase
148
155
  // tip — including the orphaned base-branch tip that restack will later
149
- // need to find when restacking children. (See findPreRewriteSha.)
156
+ // need to find when restacking children. (See resolveRestackBoundary.)
150
157
  await takeSnapshot(meta, stackName, "sync");
151
158
 
152
159
  // Step 1: Rebase base branch onto main
@@ -178,6 +185,7 @@ with the base branch included.
178
185
  current_index: 0,
179
186
  stack_name: stackName,
180
187
  chain: ordered,
188
+ ...(noPush && { no_push: true }),
181
189
  });
182
190
 
183
191
  p.log.info("Rebasing onto origin/main...");
@@ -194,7 +202,11 @@ with the base branch included.
194
202
  p.log.success("Base branch rebased onto main");
195
203
 
196
204
  // Prompt to push base
197
- await promptForcePush(baseBranch);
205
+ if (noPush) {
206
+ p.log.info(`Not pushing ${pc.yellow(baseBranch)} (--no-push) — it's now ahead of origin`);
207
+ } else {
208
+ await promptForcePush(baseBranch);
209
+ }
198
210
  }
199
211
 
200
212
  // Step 2: Restack children
@@ -218,7 +230,7 @@ with the base branch included.
218
230
  // Switch to first child and run restack
219
231
  if (children.length > 0) {
220
232
  await git.checkout(children[0]!);
221
- await restack(dryRun ? ["--dry-run"] : []);
233
+ await restack([...(dryRun ? ["--dry-run"] : []), ...(noPush ? ["--no-push"] : [])]);
222
234
  }
223
235
  }
224
236
 
@@ -246,6 +258,7 @@ async function advanceMergedBottoms(
246
258
  stackName: string,
247
259
  trunk: string,
248
260
  dryRun: boolean,
261
+ noPush: boolean,
249
262
  ): Promise<AdvanceOutcome> {
250
263
  const stack = meta.stacks[stackName]!;
251
264
  const ordered = getOrderedBranches(stack);
@@ -302,6 +315,7 @@ async function advanceMergedBottoms(
302
315
  childPr,
303
316
  targetRef,
304
317
  recordedBase: trunk,
318
+ push: !noPush,
305
319
  });
306
320
  if (!res.ok) {
307
321
  console.log();
@@ -17,6 +17,7 @@ import {
17
17
  } from "../lib/metadata.ts";
18
18
  import { ensureMetadata } from "../lib/safety.ts";
19
19
  import {
20
+ findOpenPrForBranch,
20
21
  getPrInfo,
21
22
  getPrBody,
22
23
  updatePrBody,
@@ -102,6 +103,32 @@ function vizHash(viz: string): string {
102
103
  return createHash("sha1").update(viz).digest("hex");
103
104
  }
104
105
 
106
+ /**
107
+ * Whether a live PR body already carries exactly this viz block. GitHub
108
+ * stores bodies edited in its web UI with CRLF line endings, so normalize
109
+ * before comparing — otherwise every such PR would look perpetually stale.
110
+ */
111
+ export function bodyHasViz(body: string, viz: string): boolean {
112
+ return body.replace(/\r\n/g, "\n").includes(viz);
113
+ }
114
+
115
+ /**
116
+ * Record PR numbers for tracked branches whose PR was opened outside gh-stack
117
+ * (e.g. `gh pr create`), by looking up the open PR for each PR-less branch.
118
+ * Only an unambiguous single match is adopted. Returns the branches adopted —
119
+ * the caller persists metadata when non-empty. (#35)
120
+ */
121
+ export async function discoverMissingPrs(stack: Stack, ordered: string[]): Promise<string[]> {
122
+ const missing = ordered.filter((b) => stack.branches[b]!.pr == null);
123
+ const found = await mapLimit(missing, 8, async (branch) => {
124
+ const pr = await findOpenPrForBranch(branch);
125
+ if (pr == null) return null;
126
+ stack.branches[branch]!.pr = pr;
127
+ return branch;
128
+ });
129
+ return found.filter((b): b is string => b != null);
130
+ }
131
+
105
132
  /** Splice the rendered viz block into a PR body, replacing any existing one. */
106
133
  function mergeViz(body: string, viz: string): string {
107
134
  let base = body;
@@ -201,6 +228,7 @@ export interface VizResult {
201
228
  updated: number; // PRs whose description we PATCHed
202
229
  unchanged: number; // PRs skipped because their rendered block was identical
203
230
  skipped: number; // branches with no PR
231
+ skippedBranches: string[]; // …and their names, so callers can say which
204
232
  dirty: boolean; // metadata mutated (caller should persist)
205
233
  }
206
234
 
@@ -213,12 +241,17 @@ export interface VizResult {
213
241
  * body: the override is used as the base instead of the current GitHub body,
214
242
  * the managed `📚 Stacked on` block is re-merged into it, and the PR is updated
215
243
  * even if the viz block itself didn't change.
244
+ *
245
+ * `verifyLive` checks each PR whose cached hash matches against its actual
246
+ * body, so a viz block removed by an external edit (`gh pr edit`, the web UI)
247
+ * is restored rather than reported "unchanged". It costs one read per PR, so
248
+ * `update-prs` opts in while `submit` stays on the cheap hash check. (#34)
216
249
  */
217
250
  export async function refreshStackViz(
218
251
  meta: StackMetadata,
219
252
  stack: Stack,
220
253
  ordered: string[],
221
- opts: { force?: boolean; bodyOverrides?: Record<string, string> } = {},
254
+ opts: { force?: boolean; verifyLive?: boolean; bodyOverrides?: Record<string, string> } = {},
222
255
  ): Promise<VizResult> {
223
256
  const force = opts.force ?? false;
224
257
  const bodyOverrides = opts.bodyOverrides ?? {};
@@ -239,18 +272,34 @@ export async function refreshStackViz(
239
272
  const bm = stack.branches[info.branch]!;
240
273
  // A body override forces the PATCH even when the viz block is unchanged.
241
274
  const overridden = bodyOverrides[info.branch] != null;
242
- return { info, viz, hash, bm, needsUpdate: force || overridden || bm.vizHash !== hash };
275
+ const needsUpdate = force || overridden || bm.vizHash !== hash;
276
+ return { info, viz, hash, bm, needsUpdate, liveBody: null as string | null };
243
277
  });
244
278
 
279
+ if (opts.verifyLive) {
280
+ await mapLimit(
281
+ plans.filter((plan) => !plan.needsUpdate),
282
+ 8,
283
+ async (plan) => {
284
+ const body = await getPrBody(plan.info.prNumber!);
285
+ if (body !== null && !bodyHasViz(body, plan.viz)) {
286
+ plan.needsUpdate = true;
287
+ plan.liveBody = body;
288
+ }
289
+ },
290
+ );
291
+ }
292
+
245
293
  const toUpdate = plans.filter((plan) => plan.needsUpdate);
246
294
  const unchanged = plans.length - toUpdate.length;
247
- const skipped = branchInfos.length - plans.length;
295
+ const skippedBranches = branchInfos.filter((b) => b.prNumber == null).map((b) => b.branch);
296
+ const skipped = skippedBranches.length;
248
297
 
249
298
  const oks = await mapLimit(toUpdate, 8, async (plan) => {
250
299
  // `submit -b` replaces the body; otherwise keep the PR's current body and
251
300
  // just re-merge the viz block into it.
252
301
  const override = bodyOverrides[plan.info.branch];
253
- const body = override ?? (await getPrBody(plan.info.prNumber!));
302
+ const body = override ?? plan.liveBody ?? (await getPrBody(plan.info.prNumber!));
254
303
  if (body === null) return false;
255
304
  const ok = await updatePrBody(plan.info.prNumber!, mergeViz(body, plan.viz));
256
305
  if (ok) plan.bm.vizHash = plan.hash;
@@ -260,7 +309,7 @@ export async function refreshStackViz(
260
309
  const updated = oks.filter(Boolean).length;
261
310
  if (updated > 0) dirty = true;
262
311
 
263
- return { updated, unchanged, skipped, dirty };
312
+ return { updated, unchanged, skipped, skippedBranches, dirty };
264
313
  }
265
314
 
266
315
  export default async function updatePrs(args: string[]): Promise<void> {
@@ -272,8 +321,13 @@ USAGE
272
321
  gh-stack update-prs [--force]
273
322
 
274
323
  Updates all PRs in the current stack with a standardized stack section
275
- showing the tree structure, PR links, and per-PR stack index. By default
276
- PRs whose rendered block is unchanged are skipped; --force rewrites all.
324
+ showing the tree structure, PR links, and per-PR stack index. Each PR's
325
+ live description is checked, so a stack section removed by an outside
326
+ edit (gh pr edit, the GitHub UI) is restored. PRs that already show the
327
+ current section are left alone; --force rewrites all.
328
+
329
+ Tracked branches with no recorded PR are looked up on GitHub first, so a
330
+ PR opened outside gh-stack (e.g. gh pr create) is adopted automatically.
277
331
  `);
278
332
  return;
279
333
  }
@@ -298,17 +352,30 @@ PRs whose rendered block is unchanged are skipped; --force rewrites all.
298
352
 
299
353
  const s = p.spinner();
300
354
  s.start("Updating PR titles & descriptions...");
355
+ const adopted = await discoverMissingPrs(stack, ordered);
301
356
  const titles = await reconcilePrTitles(stack, ordered);
302
- const result = await refreshStackViz(meta, stack, ordered, { force });
357
+ const result = await refreshStackViz(meta, stack, ordered, { force, verifyLive: true });
303
358
  s.stop("Done");
304
359
 
305
- if (titles.dirty || result.dirty) await writeMetadata(meta);
360
+ if (adopted.length > 0 || titles.dirty || result.dirty) await writeMetadata(meta);
361
+
362
+ for (const b of adopted) {
363
+ p.log.success(
364
+ `Adopted PR #${stack.branches[b]!.pr} for ${pc.blue(b)} (opened outside gh-stack)`,
365
+ );
366
+ }
367
+ if (result.skippedBranches.length > 0) {
368
+ p.log.warn(
369
+ `Skipped ${result.skippedBranches.length} branch(es) with no open PR: ${result.skippedBranches.map((b) => pc.blue(b)).join(", ")}\n` +
370
+ ` Open them with ${pc.green("gh-stack submit")}`,
371
+ );
372
+ }
306
373
 
307
374
  console.log();
308
375
  p.outro(
309
376
  pc.green(
310
377
  `Updated ${result.updated} description(s), ${titles.updated} title(s)` +
311
- (result.unchanged > 0 ? `, ${result.unchanged} unchanged` : "") +
378
+ (result.unchanged > 0 ? `, ${result.unchanged} already up to date` : "") +
312
379
  (result.skipped > 0 ? `, ${result.skipped} without PRs` : ""),
313
380
  ),
314
381
  );
@@ -21,7 +21,7 @@ import pc from "picocolors";
21
21
  import * as git from "./git.ts";
22
22
  import type { StackMetadata } from "../types.ts";
23
23
  import { removeBranchFromStack } from "./metadata.ts";
24
- import { findPreRewriteSha } from "./snapshot.ts";
24
+ import { resolveRestackBoundary } from "./snapshot.ts";
25
25
  import { setPrBase } from "./github.ts";
26
26
 
27
27
  /** What a single bottom branch needs: an active squash-merge, or just advancing
@@ -109,21 +109,19 @@ function reviewReason(decision: string | null): string {
109
109
 
110
110
  /**
111
111
  * Resolve the rebase fork-point for re-rooting `child` after `mergedBranch`
112
- * landed on the trunk. Mirrors restack's resolver:
113
- * 1. snapshot pre-rewrite tip of `mergedBranch` (handles the case where the
114
- * merged branch was itself rebased earlier in a multi-merge cascade), then
115
- * 2. `merge-base(child, mergedBranch)` for the common case where the merged
116
- * branch still sits at the tip the child was stacked on.
117
- * Returns null if neither resolves (caller must abort rather than guess).
112
+ * landed on the trunk. Shares restack's resolver (snapshot pre-rewrite tip →
113
+ * derived fork point → merge-base; see resolveRestackBoundary) — the snapshot
114
+ * paths handle a merged branch that was itself rebased earlier in a
115
+ * multi-merge cascade, including a child that sat behind it (#30).
116
+ * Returns null if nothing resolves (caller must abort rather than guess).
118
117
  */
119
118
  export async function resolveForkPoint(
120
119
  meta: StackMetadata,
121
120
  mergedBranch: string,
122
121
  child: string,
123
122
  ): Promise<string | null> {
124
- const fromSnapshot = await findPreRewriteSha(meta, mergedBranch, child);
125
- if (fromSnapshot) return fromSnapshot;
126
- return git.mergeBase(child, mergedBranch);
123
+ const boundary = await resolveRestackBoundary(meta, mergedBranch, child);
124
+ return boundary?.sha ?? null;
127
125
  }
128
126
 
129
127
  /** Outcome of advancing a single new bottom onto the trunk. */
@@ -148,8 +146,11 @@ export async function advanceNewBottom(opts: {
148
146
  childPr: number | null;
149
147
  targetRef: string;
150
148
  recordedBase: string;
149
+ /** false = `--no-push`: rebase + metadata only, leave origin and the PR alone (#36). */
150
+ push?: boolean;
151
151
  }): Promise<AdvanceResult> {
152
152
  const { meta, stackName, mergedBranch, child, childPr, targetRef, recordedBase } = opts;
153
+ const push = opts.push ?? true;
153
154
 
154
155
  const forkPoint = await resolveForkPoint(meta, mergedBranch, child);
155
156
  if (!forkPoint) return { ok: false, reason: "no-fork-point" };
@@ -166,15 +167,23 @@ export async function advanceNewBottom(opts: {
166
167
  const ok = await git.rebaseOnto(targetRef, forkPoint, child);
167
168
  if (!ok) return { ok: false, reason: "conflict", forkPoint };
168
169
 
169
- // Push the rebased head first so the PR diff is clean before its base flips.
170
- await git.pushStackedBranch(child);
171
- if (childPr != null) {
172
- const moved = await setPrBase(childPr, recordedBase);
173
- if (moved) {
174
- p.log.success(`PR #${childPr} base → ${pc.green(recordedBase)}`);
175
- } else {
176
- p.log.warn(`Could not retarget PR #${childPr} — set its base to ${recordedBase} manually`);
170
+ if (push) {
171
+ // Push the rebased head first so the PR diff is clean before its base flips.
172
+ await git.pushStackedBranch(child);
173
+ if (childPr != null) {
174
+ const moved = await setPrBase(childPr, recordedBase);
175
+ if (moved) {
176
+ p.log.success(`PR #${childPr} base → ${pc.green(recordedBase)}`);
177
+ } else {
178
+ p.log.warn(`Could not retarget PR #${childPr} — set its base to ${recordedBase} manually`);
179
+ }
177
180
  }
181
+ } else if (childPr != null) {
182
+ // Retargeting the base without pushing the rebased head would show the
183
+ // merged commits in the PR diff, so leave both for `submit` to reconcile.
184
+ p.log.info(
185
+ `Not pushing ${pc.yellow(child)} or retargeting PR #${childPr} (--no-push) — ${pc.green("gh-stack submit")} publishes both`,
186
+ );
178
187
  }
179
188
 
180
189
  // Metadata last: re-parents child (and siblings) onto the merged branch's
package/src/lib/chain.ts CHANGED
@@ -53,6 +53,26 @@ export async function detectBranchChain(currentBranch: string, trunk: string): P
53
53
  return chain;
54
54
  }
55
55
 
56
+ /**
57
+ * Local branches (other than trunk) whose tip is the SAME commit as `branch`.
58
+ * A branch with no commits of its own sits at its parent's tip rather than
59
+ * above it, so `detectBranchChain` can't see the parent — and from ancestry
60
+ * alone the two are indistinguishable. Callers use this to warn instead of
61
+ * silently rooting the branch at trunk. (#37)
62
+ */
63
+ export async function colocatedBranches(branch: string, trunk: string): Promise<string[]> {
64
+ const tip = await git.revParse(branch);
65
+ const excluded = new Set([branch, trunk, "main", "master"]);
66
+ const out: string[] = [];
67
+ for (const [other, sha] of await git.localBranchTips()) {
68
+ if (excluded.has(other) || sha !== tip) continue;
69
+ // A branch sitting on trunk's tip isn't a stack parent, just trunk.
70
+ if (await git.isAncestor(other, trunk)) continue;
71
+ out.push(other);
72
+ }
73
+ return out;
74
+ }
75
+
56
76
  export interface StackResolution {
57
77
  stackName: string;
58
78
  base: string; // effective base of the resulting stack (for display)
package/src/lib/git.ts CHANGED
@@ -285,6 +285,15 @@ export async function rebaseOnto(
285
285
  return exitCode === 0;
286
286
  }
287
287
 
288
+ /**
289
+ * Move the current branch's ref to `ref` (working tree included).
290
+ * Returns true if successful. Caller must have verified the tree is clean.
291
+ */
292
+ export async function resetHard(ref: string): Promise<boolean> {
293
+ const { exitCode } = await $`git reset --hard ${ref}`.nothrow().quiet();
294
+ return exitCode === 0;
295
+ }
296
+
288
297
  /**
289
298
  * Simple rebase onto a target.
290
299
  * Returns true if successful, false if conflicts occurred.
@@ -337,6 +346,20 @@ export async function allLocalBranches(): Promise<string[]> {
337
346
  return result.trim().split("\n").filter(Boolean);
338
347
  }
339
348
 
349
+ /**
350
+ * Map every local branch to its tip SHA, in one call.
351
+ */
352
+ export async function localBranchTips(): Promise<Map<string, string>> {
353
+ const result =
354
+ await $`git for-each-ref --format=${"%(refname:short) %(objectname)"} refs/heads`.text();
355
+ const tips = new Map<string, string>();
356
+ for (const line of result.trim().split("\n").filter(Boolean)) {
357
+ const [name, sha] = line.split(" ");
358
+ if (name && sha) tips.set(name, sha);
359
+ }
360
+ return tips;
361
+ }
362
+
340
363
  /**
341
364
  * Determine the trunk branch name. Prefers "main", falls back to "master"
342
365
  * if only that exists locally. Defaults to "main" when neither is present.
package/src/lib/github.ts CHANGED
@@ -40,6 +40,26 @@ export async function getPrNumber(branch: string): Promise<number | null> {
40
40
  }
41
41
  }
42
42
 
43
+ /**
44
+ * Find the open PR whose head is `branch`, for adopting a PR opened outside
45
+ * gh-stack. Unlike getPrNumber, returns null unless there is exactly one match
46
+ * — a fork with the same branch name must never be adopted by accident.
47
+ */
48
+ export async function findOpenPrForBranch(branch: string): Promise<number | null> {
49
+ try {
50
+ const out =
51
+ await $`gh pr list --head ${branch} --state open --json number --jq '.[].number'`.text();
52
+ const nums = out
53
+ .trim()
54
+ .split(/\s+/)
55
+ .filter(Boolean)
56
+ .map((n) => parseInt(n, 10));
57
+ return nums.length === 1 && !isNaN(nums[0]!) ? nums[0]! : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
43
63
  /**
44
64
  * Get PR info for a specific PR number.
45
65
  */
package/src/lib/output.ts CHANGED
@@ -7,9 +7,11 @@
7
7
  // - no ANSI colors (picocolors auto-disables via NO_COLOR; we also strip
8
8
  // any residual escapes defensively)
9
9
  //
10
- // Interactive prompts (text, confirm, select) are re-exported unchanged —
10
+ // Interactive prompts (text, confirm, select) are unchanged by plain mode —
11
11
  // plain mode is about output, not interactivity. Agents already pair --plain
12
- // with --yes/GH_STACK_YES so the interactive paths aren't reached.
12
+ // with --yes/GH_STACK_YES so the interactive paths aren't reached. If one IS
13
+ // reached with no TTY on stdin, nothing can ever answer it, so we fail fast
14
+ // instead of blocking forever (#33).
13
15
  import * as clack from "@clack/prompts";
14
16
 
15
17
  let _plain = false;
@@ -27,11 +29,38 @@ function strip(s: string | undefined): string {
27
29
  return (s ?? "").replace(ANSI_RE, "");
28
30
  }
29
31
 
30
- // ── Interactive prompts (unchanged) ──
32
+ // ── Interactive prompts ──
31
33
  export const isCancel = clack.isCancel;
32
- export const text = clack.text;
33
- export const confirm = clack.confirm;
34
- export const select = clack.select;
34
+
35
+ /**
36
+ * Exit non-zero instead of prompting when stdin isn't a TTY. A prompt there
37
+ * can never be answered, and it would otherwise hang (holding the repo) past
38
+ * the caller's timeout. (#33)
39
+ */
40
+ function ensureInteractive(message: string): void {
41
+ if (process.stdin.isTTY) return;
42
+ console.error(
43
+ `gh-stack: refusing to prompt — stdin is not a TTY.\n` +
44
+ ` Prompt: ${strip(message)}\n` +
45
+ ` Pass --yes (or set GH_STACK_YES=1) to confirm automatically.`,
46
+ );
47
+ process.exit(1);
48
+ }
49
+
50
+ export const text: typeof clack.text = (opts) => {
51
+ ensureInteractive(opts.message);
52
+ return clack.text(opts);
53
+ };
54
+
55
+ export const confirm: typeof clack.confirm = (opts) => {
56
+ ensureInteractive(opts.message);
57
+ return clack.confirm(opts);
58
+ };
59
+
60
+ export const select: typeof clack.select = (opts) => {
61
+ ensureInteractive(opts.message);
62
+ return clack.select(opts);
63
+ };
35
64
 
36
65
  // ── Banner / error ──
37
66
  export function intro(message?: string): void {
@@ -13,77 +13,97 @@ import { mkdir, readdir, unlink } from "node:fs/promises";
13
13
 
14
14
  const MAX_SNAPSHOTS_PER_STACK = 10;
15
15
 
16
+ /** Where a resolved restack boundary came from. */
17
+ export type BoundarySource = "snapshot" | "snapshot-derived" | "merge-base";
18
+
19
+ export type RestackBoundary = { sha: string; source: BoundarySource };
20
+
16
21
  /**
17
- * Find the pre-rewrite SHA for a parent branch by walking snapshots newest-first.
22
+ * Resolve the rebase boundary for restacking `childBranch` onto
23
+ * `parentBranch`: the commit whose range `boundary..child` contains exactly
24
+ * the child's own commits, for `git rebase --onto <parent> <boundary> <child>`.
25
+ *
26
+ * Resolution order:
18
27
  *
19
- * Returns the SHA from the most recent snapshot whose recorded tip for
20
- * `parentBranch` is NOT an ancestor of the parent's current tip — meaning
21
- * that snapshot was taken before the parent's history got rewritten
22
- * (typically by `sync` rebasing it onto a new main).
28
+ * 1. `"snapshot"` (issue #2) — the most recent snapshot whose recorded tip for
29
+ * the parent is no longer an ancestor of the parent's current tip: that
30
+ * snapshot predates a rewrite of the parent (typically `sync` rebasing it
31
+ * onto a new main), and its orphaned old tip is exactly where the child's
32
+ * own commits start. The recorded tip must also still be an ancestor of the
33
+ * child (issue #5) — a stale tip the child was already rebased past would
34
+ * replay a wildly wrong range; those candidates are skipped, not used.
23
35
  *
24
- * That pre-rewrite SHA is the correct rebase base when restacking a child
25
- * onto this parent — `git rebase --onto <new-tip> <pre-rewrite-tip> <child>`
26
- * replays only the child's unique commits, never the parent's old history.
36
+ * 2. `"snapshot-derived"` (issue #30) — a snapshot proves the parent was
37
+ * rewritten but its recorded tip is not an ancestor of the child, e.g. the
38
+ * child sat BEHIND the parent at snapshot time (committed to the parent,
39
+ * then synced, before the child got its first commit). The recorded tip
40
+ * still pins the parent's pre-rewrite history, and the child forked
41
+ * somewhere on it: `merge-base(child, recordedTip)` is that fork point.
42
+ * Only used when it is a DESCENDANT of `merge-base(child, parent)` — the
43
+ * higher boundary is always the safe one, since everything below it already
44
+ * exists in the new base's history. (A stale #5-shaped snapshot derives a
45
+ * fork point BELOW the current merge-base; using it would replay the
46
+ * parent's commits, so the current merge-base wins there.)
27
47
  *
28
- * **Child validation (issue #5)**: When `childBranch` is provided, any candidate
29
- * SHA must also still be an ancestor of the child. If the child has already
30
- * been rebased past this point (e.g., a previous restack already moved it),
31
- * the recorded SHA is stale for this child and would cause `rebase --onto`
32
- * to replay a wildly wrong range. We skip those candidates and keep walking.
48
+ * 3. `"merge-base"` — `merge-base(child, parent)`, correct whenever the parent
49
+ * has only had commits appended, never been rewritten.
33
50
  *
34
- * Returns null if no snapshots exist, no recorded tip qualifies, or every
35
- * snapshot's recorded tip is still an ancestor of the parent's current tip
36
- * (i.e., the parent has only had commits appended, never been rebased —
37
- * `merge-base` is still correct).
51
+ * Returns null when nothing resolves (no snapshot evidence and no common
52
+ * history) — the caller must refuse rather than guess.
53
+ *
54
+ * If the child branch doesn't exist locally, snapshot validation degrades to
55
+ * parent-only (the newest rewritten tip is accepted without the child check).
38
56
  */
39
- export async function findPreRewriteSha(
57
+ export async function resolveRestackBoundary(
40
58
  meta: StackMetadata,
41
59
  parentBranch: string,
42
- childBranch?: string,
43
- ): Promise<string | null> {
44
- if (!meta.snapshots || meta.snapshots.length === 0) return null;
45
-
46
- let currentParentTip: string;
60
+ childBranch: string,
61
+ ): Promise<RestackBoundary | null> {
62
+ let currentParentTip: string | null = null;
47
63
  try {
48
64
  currentParentTip = await git.revParse(parentBranch);
49
65
  } catch {
50
- return null;
66
+ currentParentTip = null;
51
67
  }
52
68
 
53
69
  let currentChildTip: string | null = null;
54
- if (childBranch) {
55
- try {
56
- currentChildTip = await git.revParse(childBranch);
57
- } catch {
58
- // Child doesn't exist locally — fall back to parent-only validation
59
- currentChildTip = null;
60
- }
70
+ try {
71
+ currentChildTip = await git.revParse(childBranch);
72
+ } catch {
73
+ currentChildTip = null;
61
74
  }
62
75
 
63
- // Walk newest → oldest
64
- for (let i = meta.snapshots.length - 1; i >= 0; i--) {
65
- const recorded = meta.snapshots[i]!.branches[parentBranch];
66
- if (!recorded) continue;
67
- if (recorded === currentParentTip) continue;
68
-
69
- // The recorded SHA must no longer be an ancestor of the parent's current
70
- // tip — that's how we detect the parent's history was rewritten.
71
- const stillAncestorOfParent = await git.isAncestor(recorded, currentParentTip);
72
- if (stillAncestorOfParent) continue;
73
-
74
- // Issue #5 guard: if we know the child, the recorded SHA must still be
75
- // an ancestor of the child. Otherwise the child has already been rebased
76
- // past this point (or never branched off it) and using it as a base
77
- // would replay unrelated history.
78
- if (currentChildTip) {
79
- const stillAncestorOfChild = await git.isAncestor(recorded, currentChildTip);
80
- if (!stillAncestorOfChild) continue;
76
+ // Walk snapshots newest → oldest for evidence the parent was rewritten.
77
+ let accepted: string | null = null;
78
+ let rejected: string | null = null; // newest rewritten tip that failed the child check
79
+ if (currentParentTip && meta.snapshots) {
80
+ for (let i = meta.snapshots.length - 1; i >= 0; i--) {
81
+ const recorded = meta.snapshots[i]!.branches[parentBranch];
82
+ if (!recorded || recorded === currentParentTip) continue;
83
+ if (await git.isAncestor(recorded, currentParentTip)) continue; // appended-to, not rewritten
84
+ if (!currentChildTip || (await git.isAncestor(recorded, currentChildTip))) {
85
+ accepted = recorded;
86
+ break;
87
+ }
88
+ rejected ??= recorded;
81
89
  }
90
+ }
82
91
 
83
- return recorded;
92
+ if (accepted) return { sha: accepted, source: "snapshot" };
93
+
94
+ const mbCurrent = await git.mergeBase(childBranch, parentBranch);
95
+
96
+ if (rejected && currentChildTip) {
97
+ const derived = await git.mergeBase(childBranch, rejected);
98
+ if (derived) {
99
+ if (!mbCurrent) return { sha: derived, source: "snapshot-derived" };
100
+ if (derived !== mbCurrent && (await git.isAncestor(mbCurrent, derived))) {
101
+ return { sha: derived, source: "snapshot-derived" };
102
+ }
103
+ }
84
104
  }
85
105
 
86
- return null;
106
+ return mbCurrent ? { sha: mbCurrent, source: "merge-base" } : null;
87
107
  }
88
108
 
89
109
  /**
@@ -96,7 +116,7 @@ export async function findPreRewriteSha(
96
116
  * `git rebase --onto <new-base> <baseTip> <root>` drops the already-merged
97
117
  * prefix instead of replaying it.
98
118
  *
99
- * Unlike `findPreRewriteSha`, this does NOT require the base branch to still
119
+ * Unlike `resolveRestackBoundary`, this does NOT require the base branch to still
100
120
  * exist locally — that's the whole point: after a squash-merge deletes the base
101
121
  * branch, its tip survives only in these snapshots. The ancestor-of-root check
102
122
  * rejects stale candidates (an older base tip the root no longer sits on).
@@ -64,6 +64,13 @@ export GH_STACK_YES=1 # skip every confirmation, plain output
64
64
  of generic auto-generated ones (see below).
65
65
  - Commands that rebase (`restack`, `sync`, `merge`) **refuse to run on a dirty
66
66
  tree** — commit or stash first.
67
+ - **`--yes` also accepts pushes.** To rebase without publishing (e.g. until a
68
+ 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.
71
+ - A prompt reached without a TTY on stdin **exits 1** (naming the prompt)
72
+ instead of hanging. If you see `refusing to prompt`, re-run with
73
+ `GH_STACK_YES=1`.
67
74
 
68
75
  ## Core workflow
69
76
 
@@ -94,10 +101,17 @@ created a branch with `git checkout -b` and want to `sync`/`restack` it _before_
94
101
  opening a PR, `add` registers it in a stack (network-free — no push, no PR):
95
102
 
96
103
  ```bash
97
- gh-stack add # track the current branch
98
- gh-stack add <branch> # track a named branch
104
+ gh-stack add # track the current branch
105
+ gh-stack add <branch> # track a named branch
106
+ gh-stack add --parent <branch> # stack the current branch directly on <branch>
99
107
  ```
100
108
 
109
+ **A branch with no commits yet can't be placed by ancestry.** It sits _at_ its
110
+ parent's tip, so `init`/`add` root it at trunk and warn that it "has no commits
111
+ of its own". Fix it with `gh-stack add --parent <parent>`, which tracks the
112
+ parent too if needed. `--parent` also re-homes a branch that's already tracked,
113
+ but only when it's alone in its stack with no PR.
114
+
101
115
  It walks local ancestry: a branch sitting on top of an existing stack is adopted
102
116
  into it (parent auto-detected); otherwise a new stack is created from the chain.
103
117
  This resolves the catch-22 where `sync`/`restack` said "not in any stack" for a
@@ -163,6 +177,7 @@ gh-stack checkout # interactive picker (alias: co)
163
177
  gh-stack sync # fetch trunk, rebase base onto it, restack ALL children
164
178
  gh-stack restack # rebase current branch + descendants onto their parents
165
179
  gh-stack restack --dry-run
180
+ gh-stack restack --no-push # rebase locally only (also: GH_STACK_NO_PUSH=1)
166
181
  ```
167
182
 
168
183
  **⚠️ Restack propagates UPWARD from the current branch.** Run it from the
@@ -229,7 +244,7 @@ gh-stack rename # rename a tracked branch (keeps metadata in sync)
229
244
  gh-stack undo # restore from the last snapshot
230
245
  gh-stack archive # manage archived stacks
231
246
  gh-stack doctor # migrate & repair stack metadata
232
- gh-stack update-prs # refresh stack visualization in PR descriptions
247
+ gh-stack update-prs # repair stack viz in PR descriptions + adopt PRs opened with gh pr create
233
248
  ```
234
249
 
235
250
  ## Stack visualization
package/src/types.ts CHANGED
@@ -64,6 +64,8 @@ export interface RestackState {
64
64
  current_index: number;
65
65
  stack_name: string;
66
66
  chain: string[];
67
+ /** The interrupted run was `--no-push`; `--resume` keeps it local-only (#36). */
68
+ no_push?: boolean;
67
69
  }
68
70
 
69
71
  // PR info from GitHub API