gh-stack 0.17.1 → 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.1",
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, 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
 
@@ -549,49 +549,45 @@ async function processChain(
549
549
  continue;
550
550
  }
551
551
 
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.
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.
566
555
  //
567
556
  // Re-read metadata fresh because previous iterations of this loop may
568
557
  // have updated snapshots.
569
558
  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;
559
+ const boundary = await resolveRestackBoundary(freshMeta, parent, branch);
573
560
 
574
561
  if (verbose) {
575
562
  console.log();
576
563
  console.log(pc.yellow(" Diagnostic: base resolution"));
577
564
  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)"}`,
565
+ ` ${pc.cyan("Boundary:")} ${boundary ? `${boundary.sha.slice(0, 8)} (${boundary.source})` : "(none)"}`,
582
566
  );
583
- if (
584
- oldBaseFromSnapshot &&
585
- oldBaseFromMergeBase &&
586
- oldBaseFromSnapshot !== oldBaseFromMergeBase
587
- ) {
567
+ if (boundary && boundary.source !== "merge-base") {
588
568
  console.log(
589
- ` ${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`,
590
570
  );
591
571
  }
592
572
  console.log();
593
573
  }
594
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
+
595
591
  // Checkout the branch
596
592
  p.log.info(`Checking out ${pc.yellow(branch)}...`);
597
593
  await git.checkout(branch);
@@ -604,21 +600,28 @@ async function processChain(
604
600
  });
605
601
 
606
602
  let success: boolean;
607
- if (oldBase) {
608
- 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)
609
608
  p.log.info(
610
- `Rebasing onto ${pc.yellow(parent)} (base: ${oldBase.slice(0, 8)} from ${sourceLabel})`,
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;
615
+ p.log.info(
616
+ `Rebasing onto ${pc.yellow(parent)} (base: ${boundary.sha.slice(0, 8)} from ${sourceLabel})`,
611
617
  );
612
618
 
613
619
  if (verbose) {
614
- const count = await git.commitCount(oldBase, branch);
620
+ const count = await git.commitCount(boundary.sha, branch);
615
621
  console.log(` Moving ${count} commit(s)`);
616
622
  }
617
623
 
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);
624
+ success = await git.rebaseOnto(parent, boundary.sha, branch);
622
625
  }
623
626
 
624
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.
@@ -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).