gh-stack 0.17.0 → 0.17.2

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
@@ -342,6 +342,8 @@ The critical insight: after a parent branch's history is rewritten (e.g., rebase
342
342
 
343
343
  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
344
 
345
+ 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.
346
+
345
347
  Snapshots also power `gh-stack undo`, so the same data structure does double duty.
346
348
 
347
349
  ### Stack Position in PR Titles
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.17.0",
3
+ "version": "0.17.2",
4
4
  "description": "Stacked PR manager for squash-merge workflows",
5
5
  "author": "Kiliman <kiliman@gmail.com>",
6
6
  "license": "MIT",
@@ -15,7 +15,7 @@ import {
15
15
  writeMetadata,
16
16
  } from "../lib/metadata.ts";
17
17
  import { ensureMetadata, ensureCleanWorkingTree, ensureValidStack } from "../lib/safety.ts";
18
- import { takeSnapshot, findPreRewriteSha } 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
 
@@ -345,6 +345,36 @@ async function handleFreshRestack(
345
345
  * the new base (the now-merged parent commits are dropped). Children then
346
346
  * restack onto the moved root via the normal chain logic.
347
347
  */
348
+ // Resolve the boundary commit for re-rooting a stack's root off its old base:
349
+ // the tip of the old base branch that the root was built on. Returns null when
350
+ // it can't be determined safely, so the caller refuses rather than replaying
351
+ // already-merged commits (issue #13).
352
+ async function resolveRerootBoundary(
353
+ meta: StackMetadata,
354
+ oldBase: string,
355
+ root: string,
356
+ ): Promise<{ sha: string | null; source: string }> {
357
+ // 1. The live base branch tip — but only if the root still sits on it. If the
358
+ // base was rewritten after the root forked, its current tip isn't the
359
+ // boundary; fall through to the recorded tip.
360
+ if (await git.localBranchExists(oldBase)) {
361
+ const tip = await git.revParse(oldBase);
362
+ if (tip && (await git.isAncestor(tip, root))) {
363
+ return { sha: tip, source: "old-base branch tip" };
364
+ }
365
+ }
366
+
367
+ // 2. The tip recorded in a snapshot — survives the base branch being deleted
368
+ // or rewritten by a squash-merge of the parent stack.
369
+ const recorded = await findRecordedBaseTip(meta, oldBase, root);
370
+ if (recorded) {
371
+ return { sha: recorded, source: "snapshot (old base branch gone/rewritten)" };
372
+ }
373
+
374
+ // 3. Unknown — refuse rather than replay merged commits.
375
+ return { sha: null, source: "" };
376
+ }
377
+
348
378
  async function handleReroot(
349
379
  meta: StackMetadata,
350
380
  newBase: string,
@@ -383,20 +413,25 @@ async function handleReroot(
383
413
  }
384
414
 
385
415
  // The rebase base = the tip the root was built on (the old base branch).
386
- // Prefer the live branch tip; fall back to merge-base if it's already gone.
387
- let oldBaseTip: string | null = null;
388
- let oldBaseSource = "old-base branch tip";
389
- if (await git.localBranchExists(oldBase)) {
390
- oldBaseTip = await git.revParse(oldBase);
391
- } else {
392
- oldBaseTip = await git.mergeBase(root, newBase);
393
- oldBaseSource = "merge-base (old base branch is gone — verify the result)";
394
- }
416
+ // Getting this right matters: rebasing from the wrong boundary replays the
417
+ // old base's commits onto the new base, and when the old base was a
418
+ // squash-merged parent stack those commits collide with their own squashed
419
+ // result on main — a self-inflicted conflict storm (issue #13). So we never
420
+ // guess with merge-base; we refuse instead.
421
+ const { sha: oldBaseTip, source: oldBaseSource } = await resolveRerootBoundary(
422
+ meta,
423
+ oldBase,
424
+ root,
425
+ );
395
426
 
396
427
  if (!oldBaseTip) {
397
428
  p.cancel(
398
- `Could not determine the old base for ${pc.yellow(root)}.\n\n Re-root manually with: ${pc.green(
399
- `git rebase --onto ${newBase} <old-base> ${root}`,
429
+ `Can't safely re-root ${pc.yellow(stackName)}: its old base ${pc.yellow(
430
+ oldBase,
431
+ )} is gone (or was rewritten) and no snapshot recorded where ${pc.yellow(
432
+ root,
433
+ )} forked off it.\n\n Rebasing from the merge-base would replay already-merged commits and conflict.\n Re-root manually once you know the old base tip:\n ${pc.green(
434
+ `git rebase --onto ${newBase} <old-base-tip> ${root}`,
400
435
  )}`,
401
436
  );
402
437
  process.exit(1);
@@ -514,49 +549,45 @@ async function processChain(
514
549
  continue;
515
550
  }
516
551
 
517
- // Resolve the rebase base.
518
- //
519
- // Strategy:
520
- // 1. Look for a recent snapshot where the parent's recorded tip is no
521
- // longer an ancestor of the parent's current tip AND is still an
522
- // ancestor of the child. That recorded SHA is the orphaned
523
- // old-parent tip — exactly the rebase base we need.
524
- // 2. Fall back to `merge-base(branch, parent)` if no snapshot tells us
525
- // the parent was rewritten (typical case: parent only had commits
526
- // appended, not rebased).
527
- //
528
- // The child-ancestry check (issue #5) prevents stale snapshots from
529
- // older sessions — where the child has already been rebased past the
530
- // recorded point — from being used as a rebase base.
552
+ // Resolve the rebase boundary — snapshot pre-rewrite tip, a fork point
553
+ // derived from a rejected snapshot (#30), or merge-base, in that order.
554
+ // See resolveRestackBoundary for the full strategy.
531
555
  //
532
556
  // Re-read metadata fresh because previous iterations of this loop may
533
557
  // have updated snapshots.
534
558
  const freshMeta = (await readMetadata()) ?? meta;
535
- const oldBaseFromSnapshot = await findPreRewriteSha(freshMeta, parent, branch);
536
- const oldBaseFromMergeBase = await git.mergeBase(branch, parent);
537
- const oldBase = oldBaseFromSnapshot ?? oldBaseFromMergeBase;
559
+ const boundary = await resolveRestackBoundary(freshMeta, parent, branch);
538
560
 
539
561
  if (verbose) {
540
562
  console.log();
541
563
  console.log(pc.yellow(" Diagnostic: base resolution"));
542
564
  console.log(
543
- ` ${pc.cyan("Snapshot pre-rewrite tip:")} ${oldBaseFromSnapshot?.slice(0, 8) ?? "(none)"}`,
544
- );
545
- console.log(
546
- ` ${pc.cyan("Current merge-base:")} ${oldBaseFromMergeBase?.slice(0, 8) ?? "(none)"}`,
565
+ ` ${pc.cyan("Boundary:")} ${boundary ? `${boundary.sha.slice(0, 8)} (${boundary.source})` : "(none)"}`,
547
566
  );
548
- if (
549
- oldBaseFromSnapshot &&
550
- oldBaseFromMergeBase &&
551
- oldBaseFromSnapshot !== oldBaseFromMergeBase
552
- ) {
567
+ if (boundary && boundary.source !== "merge-base") {
553
568
  console.log(
554
- ` ${pc.yellow("⚠ Parent was rewritten")} — using snapshot base (would have replayed parent commits otherwise)`,
569
+ ` ${pc.yellow("⚠ Parent was rewritten")} — merge-base alone would have replayed parent commits`,
555
570
  );
556
571
  }
557
572
  console.log();
558
573
  }
559
574
 
575
+ if (!boundary) {
576
+ // No snapshot evidence and no common history — any base would be a
577
+ // guess, and a wrong guess replays the parent's history onto itself.
578
+ // Refuse with the manual command, consistent with the --onto reroot
579
+ // path. (#30: never silently guess when we can't derive the boundary.)
580
+ p.log.error(
581
+ `Cannot determine a safe rebase base for ${pc.yellow(branch)} onto ${pc.yellow(parent)}.`,
582
+ );
583
+ console.log();
584
+ console.log(" Rebase it manually with an explicit boundary:");
585
+ console.log(` ${pc.green(`git rebase --onto ${parent} <old-parent-tip> ${branch}`)}`);
586
+ console.log();
587
+ console.log(` Then re-run ${pc.green("gh-stack restack")} to continue the chain.`);
588
+ process.exit(1);
589
+ }
590
+
560
591
  // Checkout the branch
561
592
  p.log.info(`Checking out ${pc.yellow(branch)}...`);
562
593
  await git.checkout(branch);
@@ -569,21 +600,28 @@ async function processChain(
569
600
  });
570
601
 
571
602
  let success: boolean;
572
- if (oldBase) {
573
- const sourceLabel = oldBaseFromSnapshot ? "snapshot" : "merge-base";
603
+ const branchTip = await git.revParse(branch);
604
+ if (boundary.sha === branchTip) {
605
+ // The child has no commits of its own (a placeholder branch created off
606
+ // the parent before any work landed). Nothing to replay — move the ref
607
+ // instead of invoking rebase. (#30)
608
+ p.log.info(
609
+ `${pc.yellow(branch)} has no unique commits — moving it to ${pc.yellow(parent)}'s tip`,
610
+ );
611
+ success = await git.resetHard(parent);
612
+ } else {
613
+ const sourceLabel =
614
+ boundary.source === "snapshot-derived" ? "snapshot (derived fork-point)" : boundary.source;
574
615
  p.log.info(
575
- `Rebasing onto ${pc.yellow(parent)} (base: ${oldBase.slice(0, 8)} from ${sourceLabel})`,
616
+ `Rebasing onto ${pc.yellow(parent)} (base: ${boundary.sha.slice(0, 8)} from ${sourceLabel})`,
576
617
  );
577
618
 
578
619
  if (verbose) {
579
- const count = await git.commitCount(oldBase, branch);
620
+ const count = await git.commitCount(boundary.sha, branch);
580
621
  console.log(` Moving ${count} commit(s)`);
581
622
  }
582
623
 
583
- success = await git.rebaseOnto(parent, oldBase, branch);
584
- } else {
585
- p.log.warn("Could not determine rebase base — using simple rebase");
586
- success = await git.rebase(parent);
624
+ success = await git.rebaseOnto(parent, boundary.sha, branch);
587
625
  }
588
626
 
589
627
  if (success) {
@@ -146,7 +146,7 @@ with the base branch included.
146
146
 
147
147
  // Take snapshot BEFORE rebasing. This records every branch's pre-rebase
148
148
  // tip — including the orphaned base-branch tip that restack will later
149
- // need to find when restacking children. (See findPreRewriteSha.)
149
+ // need to find when restacking children. (See resolveRestackBoundary.)
150
150
  await takeSnapshot(meta, stackName, "sync");
151
151
 
152
152
  // Step 1: Rebase base branch onto main
@@ -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. */
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.
@@ -7,80 +7,142 @@
7
7
  // stack's only record while a busy sibling stack churned (see issue #13).
8
8
  import type { StackMetadata, Snapshot } from "../types.ts";
9
9
  import * as git from "./git.ts";
10
- import { getOrderedBranches } from "./metadata.ts";
10
+ import { getOrderedBranches, stackBase } from "./metadata.ts";
11
11
  import { snapshotsDir } from "./paths.ts";
12
12
  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;
70
+ try {
71
+ currentChildTip = await git.revParse(childBranch);
72
+ } catch {
73
+ currentChildTip = null;
74
+ }
75
+
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;
60
89
  }
61
90
  }
62
91
 
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;
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
+ }
81
103
  }
104
+ }
105
+
106
+ return mbCurrent ? { sha: mbCurrent, source: "merge-base" } : null;
107
+ }
82
108
 
83
- return recorded;
109
+ /**
110
+ * Find the recorded tip of a stack's (non-main) base branch — the boundary a
111
+ * re-root needs to replay only the dependent stack's own commits.
112
+ *
113
+ * Walks snapshots newest-first for one whose recorded `baseBranch` matches and
114
+ * whose recorded `baseTip` is still an ancestor of `rootBranch` (the dependent
115
+ * stack's root). That tip is exactly where the root forked off the base, so
116
+ * `git rebase --onto <new-base> <baseTip> <root>` drops the already-merged
117
+ * prefix instead of replaying it.
118
+ *
119
+ * Unlike `resolveRestackBoundary`, this does NOT require the base branch to still
120
+ * exist locally — that's the whole point: after a squash-merge deletes the base
121
+ * branch, its tip survives only in these snapshots. The ancestor-of-root check
122
+ * rejects stale candidates (an older base tip the root no longer sits on).
123
+ *
124
+ * Returns null if no snapshot recorded this base, or none is still an ancestor
125
+ * of the root (in which case the caller must refuse rather than guess).
126
+ */
127
+ export async function findRecordedBaseTip(
128
+ meta: StackMetadata,
129
+ baseBranch: string,
130
+ rootBranch: string,
131
+ ): Promise<string | null> {
132
+ if (!meta.snapshots || meta.snapshots.length === 0) return null;
133
+
134
+ let rootTip: string;
135
+ try {
136
+ rootTip = await git.revParse(rootBranch);
137
+ } catch {
138
+ return null;
139
+ }
140
+
141
+ // Walk newest → oldest; first base tip that the root still sits on wins.
142
+ for (let i = meta.snapshots.length - 1; i >= 0; i--) {
143
+ const snap = meta.snapshots[i]!;
144
+ if (snap.baseBranch !== baseBranch || !snap.baseTip) continue;
145
+ if (await git.isAncestor(snap.baseTip, rootTip)) return snap.baseTip;
84
146
  }
85
147
 
86
148
  return null;
@@ -161,6 +223,20 @@ export async function takeSnapshot(
161
223
  stack: stackName,
162
224
  };
163
225
 
226
+ // For a non-main base (a stack stacked on top of another stack), record the
227
+ // base branch's tip too — kept out of `branches` so `undo` won't reset a
228
+ // branch that belongs to the parent stack. This is the boundary a later
229
+ // re-root needs if the base branch is deleted by a squash-merge (issue #13).
230
+ const base = stackBase(stack);
231
+ if (base !== "main" && base !== "master") {
232
+ try {
233
+ snapshot.baseTip = await git.revParse(base);
234
+ snapshot.baseBranch = base;
235
+ } catch {
236
+ // Base branch already gone locally — nothing to record.
237
+ }
238
+ }
239
+
164
240
  await writeSnapshotFile(snapshot);
165
241
 
166
242
  // Keep in-memory array consistent for callers that read it afterward.
package/src/types.ts CHANGED
@@ -50,6 +50,13 @@ export interface Snapshot {
50
50
  operation: string; // "restack" | "merge" | "sync" | "remove"
51
51
  branches: Record<string, string>; // branch name -> commit SHA
52
52
  stack?: string; // owning stack (v3: drives per-stack retention + file naming)
53
+ // For a stack with a non-main base: the base branch and its tip at snapshot
54
+ // time. Recorded separately from `branches` (which `undo` force-resets) so a
55
+ // dependent stack can re-root onto main after its base branch is deleted by a
56
+ // squash-merge — without that recorded boundary the reroot would replay the
57
+ // already-merged commits and conflict (issue #13).
58
+ baseBranch?: string;
59
+ baseTip?: string;
53
60
  }
54
61
 
55
62
  // Resume state for restack --resume