gh-stack 0.3.1 → 0.4.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
@@ -117,7 +117,8 @@ ls
117
117
  ```
118
118
  restack [--resume] [--dry-run] [--verbose]
119
119
  Rebase the current branch and all descendants onto their parents.
120
- Uses tag-based references for stable rebasing across the chain.
120
+ Uses metadata snapshots to recover the correct rebase base even
121
+ after a parent's history has been rewritten.
121
122
 
122
123
  On conflict:
123
124
  git rebase --continue
@@ -127,7 +128,8 @@ restack [--resume] [--dry-run] [--verbose]
127
128
 
128
129
  sync [--dry-run]
129
130
  Fetch main, rebase the base branch onto main, then restack all
130
- children. Creates tags for ALL branches before any rebasing starts.
131
+ children. Snapshots every branch's pre-sync tip so children can be
132
+ correctly rebased even though their parent's history was rewritten.
131
133
 
132
134
  merge [--dry-run] [-d|--delete-branch]
133
135
  Squash-merge the stack top-down via GitHub (PR3 → PR2 → PR1),
@@ -181,9 +183,13 @@ GH_STACK_NO_COLOR=1 Disable colored output
181
183
 
182
184
  `gh-stack init` auto-detects branch chains by walking git ancestry. From the top branch, it finds all local branches whose tips are strict ancestors (but not already merged into trunk) and reconstructs the chain with correct parent relationships.
183
185
 
184
- ### Tag-Based Rebasing
186
+ ### Snapshot-Based Rebasing
185
187
 
186
- The critical insight: after rebasing a parent branch, `git merge-base` returns wrong results for its children. gh-stack solves this by creating temporary `stack-sync-*` tags marking each branch's divergence point **before** any rebasing starts, then using those stable references for `git rebase --onto`.
188
+ The critical insight: after a parent branch's history is rewritten (e.g., rebased onto a new main), `git merge-base(child, parent)` falls all the way back to the original main — and using that as a rebase base would replay the parent's old commits onto the child, producing ghost-conflicts on the parent's own work.
189
+
190
+ 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.
191
+
192
+ Snapshots also power `gh-stack undo`, so the same data structure does double duty.
187
193
 
188
194
  ### Stack Visualization
189
195
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Stacked PR manager for squash-merge workflows",
5
5
  "author": "Kiliman <kiliman@gmail.com>",
6
6
  "license": "MIT",
@@ -9,6 +9,7 @@ import {
9
9
  saveRestackState,
10
10
  loadRestackState,
11
11
  clearRestackState,
12
+ readMetadata,
12
13
  } from "../lib/metadata.ts";
13
14
  import {
14
15
  ensureMetadata,
@@ -16,7 +17,7 @@ import {
16
17
  ensureNotMain,
17
18
  ensureValidStack,
18
19
  } from "../lib/safety.ts";
19
- import { takeSnapshot } from "../lib/snapshot.ts";
20
+ import { takeSnapshot, findPreRewriteSha } from "../lib/snapshot.ts";
20
21
  import { confirmAction } from "../lib/ui.ts";
21
22
  import type { StackMetadata } from "../types.ts";
22
23
 
@@ -36,7 +37,7 @@ OPTIONS
36
37
  --yes, -y Skip confirmations (auto-accept all rebases and pushes)
37
38
  --resume Resume after resolving rebase conflicts
38
39
  --dry-run Show what would happen without executing
39
- --verbose Show diagnostic info (tag vs merge-base)
40
+ --verbose Show diagnostic info (snapshot vs merge-base)
40
41
 
41
42
  EXAMPLES
42
43
  gh-stack restack Interactive (prompts before each rebase)
@@ -49,6 +50,16 @@ ALIASES
49
50
  return;
50
51
  }
51
52
 
53
+ // Reject if a git rebase is already in progress (unless we're resuming).
54
+ // Letting commands run on top of a half-finished rebase produces deeply
55
+ // confusing state — better to make the user finish or abort first.
56
+ if (!resume && (await git.isRebaseInProgress())) {
57
+ p.cancel(
58
+ `Git rebase already in progress.\n\n Finish or abort it first:\n ${pc.green("git rebase --continue")}\n ${pc.red("git rebase --abort")}\n\n Then re-run ${pc.green("gh-stack restack")} (or ${pc.green("gh-stack restack --resume")} to continue).`,
59
+ );
60
+ process.exit(1);
61
+ }
62
+
52
63
  // Ensure clean working tree (unless resuming)
53
64
  if (!resume) {
54
65
  await ensureCleanWorkingTree();
@@ -59,6 +70,10 @@ ALIASES
59
70
 
60
71
  p.intro(pc.cyan("Git Stack Restack"));
61
72
 
73
+ // Silently drop any leftover temp tags from older versions of gh-stack
74
+ // (pre-snapshot). Keeps users upgrading from <0.4 from seeing stale tags.
75
+ await git.deleteTagsMatching(git.STACK_SYNC_TAG_GLOB);
76
+
62
77
  if (resume) {
63
78
  await handleResume(meta, rebasedBranches, verbose);
64
79
  } else {
@@ -76,9 +91,6 @@ ALIASES
76
91
  }
77
92
  }
78
93
 
79
- // Cleanup tags
80
- await git.deleteTagsMatching(git.STACK_SYNC_TAG_GLOB);
81
-
82
94
  p.outro(pc.green("Stack Restack Complete!"));
83
95
  }
84
96
 
@@ -137,38 +149,8 @@ async function handleResume(
137
149
  const currentIndex = state.current_index + 1;
138
150
 
139
151
  if (currentIndex < chain.length) {
140
- // Ensure temporary tags exist for remaining branches.
141
- // If sync created them upfront (correct!), they'll already be here.
142
- // If not (e.g., old state file), we create them as a fallback —
143
- // but NOTE: tags created after parent was rebased may be inaccurate.
144
- p.log.info("Checking base tags for remaining branches...");
145
- for (let i = currentIndex; i < chain.length; i++) {
146
- const branch = chain[i]!;
147
- const parent = stack.branches[branch]?.parent;
148
- if (!parent) continue;
149
-
150
- const tagName = git.tempBaseTagName(branch);
151
- if (await git.tagExists(tagName)) {
152
- const tagSha = await git.revParse(tagName);
153
- console.log(
154
- ` ${pc.green("✓")} Pre-existing tag for ${branch}: ${pc.cyan(tagName)} (${tagSha.slice(0, 8)})`,
155
- );
156
- } else {
157
- // Fallback: create tag now (may be inaccurate if parent already rebased)
158
- p.log.warn(`No pre-existing tag for ${branch} — creating from current merge-base`);
159
- const mb = await git.mergeBase(branch, parent);
160
- if (mb) {
161
- await git.createTag(tagName, mb);
162
- console.log(
163
- ` ${pc.yellow("⚠")} Tagged base for ${branch}: ${pc.cyan(tagName)} (${mb.slice(0, 8)})`,
164
- );
165
- }
166
- }
167
- }
168
- console.log();
152
+ await processChain(meta, stackName, chain, currentIndex, rebasedBranches, false, verbose);
169
153
  }
170
-
171
- await processChain(meta, stackName, chain, currentIndex, rebasedBranches, false, verbose);
172
154
  }
173
155
 
174
156
  async function handleFreshRestack(
@@ -190,9 +172,6 @@ async function handleFreshRestack(
190
172
  const stack = meta.stacks[stackName]!;
191
173
  await ensureValidStack(meta, stackName);
192
174
 
193
- // Check for stale tags from previous runs
194
- await handleStaleTags();
195
-
196
175
  // Build the rebase chain from current branch
197
176
  p.log.info("Building rebase chain...");
198
177
  let chain = buildRebaseChain(stack, currentBranch);
@@ -242,25 +221,10 @@ async function handleFreshRestack(
242
221
  }
243
222
 
244
223
  if (!dryRun) {
245
- // Take snapshot before destructive operation
224
+ // Take snapshot before destructive operation. This preserves the current
225
+ // tip of every branch so subsequent restacks (after a parent is rewritten)
226
+ // can find the correct rebase base.
246
227
  await takeSnapshot(meta, stackName, "restack");
247
-
248
- // Create temporary tags for stable base references
249
- p.log.info("Creating temporary base tags...");
250
- for (const branch of chain) {
251
- const parent = stack.branches[branch]?.parent;
252
- if (!parent) continue;
253
-
254
- const mb = await git.mergeBase(branch, parent);
255
- if (mb) {
256
- const tagName = git.tempBaseTagName(branch);
257
- await git.createTag(tagName, mb);
258
- console.log(
259
- ` ${pc.green("✓")} Tagged base for ${branch}: ${pc.cyan(tagName)} (${mb.slice(0, 8)})`,
260
- );
261
- }
262
- }
263
- console.log();
264
228
  }
265
229
 
266
230
  // Process the chain
@@ -313,6 +277,44 @@ async function processChain(
313
277
  continue;
314
278
  }
315
279
 
280
+ // Resolve the rebase base.
281
+ //
282
+ // Strategy:
283
+ // 1. Look for a recent snapshot where the parent's recorded tip is no
284
+ // longer an ancestor of the parent's current tip. That recorded SHA
285
+ // is the orphaned old-parent tip — exactly the rebase base we need.
286
+ // 2. Fall back to `merge-base(branch, parent)` if no snapshot tells us
287
+ // the parent was rewritten (typical case: parent only had commits
288
+ // appended, not rebased).
289
+ //
290
+ // Re-read metadata fresh because previous iterations of this loop may
291
+ // have updated snapshots.
292
+ const freshMeta = (await readMetadata()) ?? meta;
293
+ const oldBaseFromSnapshot = await findPreRewriteSha(freshMeta, parent);
294
+ const oldBaseFromMergeBase = await git.mergeBase(branch, parent);
295
+ const oldBase = oldBaseFromSnapshot ?? oldBaseFromMergeBase;
296
+
297
+ if (verbose) {
298
+ console.log();
299
+ console.log(pc.yellow(" Diagnostic: base resolution"));
300
+ console.log(
301
+ ` ${pc.cyan("Snapshot pre-rewrite tip:")} ${oldBaseFromSnapshot?.slice(0, 8) ?? "(none)"}`,
302
+ );
303
+ console.log(
304
+ ` ${pc.cyan("Current merge-base:")} ${oldBaseFromMergeBase?.slice(0, 8) ?? "(none)"}`,
305
+ );
306
+ if (
307
+ oldBaseFromSnapshot &&
308
+ oldBaseFromMergeBase &&
309
+ oldBaseFromSnapshot !== oldBaseFromMergeBase
310
+ ) {
311
+ console.log(
312
+ ` ${pc.yellow("⚠ Parent was rewritten")} — using snapshot base (would have replayed parent commits otherwise)`,
313
+ );
314
+ }
315
+ console.log();
316
+ }
317
+
316
318
  // Checkout the branch
317
319
  p.log.info(`Checking out ${pc.yellow(branch)}...`);
318
320
  await git.checkout(branch);
@@ -324,49 +326,22 @@ async function processChain(
324
326
  chain,
325
327
  });
326
328
 
327
- // Use temporary tag as stable base reference
328
- const tagName = git.tempBaseTagName(branch);
329
329
  let success: boolean;
330
-
331
- if (await git.tagExists(tagName)) {
332
- const taggedBase = await git.revParse(tagName);
333
- p.log.info(`Using tagged base: ${pc.cyan(tagName)} (${taggedBase.slice(0, 8)})`);
330
+ if (oldBase) {
331
+ const sourceLabel = oldBaseFromSnapshot ? "snapshot" : "merge-base";
332
+ p.log.info(
333
+ `Rebasing onto ${pc.yellow(parent)} (base: ${oldBase.slice(0, 8)} from ${sourceLabel})`,
334
+ );
334
335
 
335
336
  if (verbose) {
336
- const currentMb = await git.mergeBase(branch, parent);
337
- console.log();
338
- console.log(pc.yellow(" Diagnostic: Tag vs Merge-Base"));
339
- console.log(` ${pc.cyan("Tagged base:")} ${taggedBase.slice(0, 8)}`);
340
- if (currentMb) {
341
- console.log(` ${pc.cyan("Current merge-base:")} ${currentMb.slice(0, 8)}`);
342
- if (taggedBase === currentMb) {
343
- console.log(` ${pc.green("✓ Same")} — Parent hasn't been updated`);
344
- } else {
345
- console.log(` ${pc.yellow("⚠ Different")} — Parent was rebased (tag protects us!)`);
346
- }
347
- }
348
- console.log();
349
- }
350
-
351
- p.log.info(`Rebasing onto ${pc.yellow(parent)}...`);
352
- const count = await git.commitCount(taggedBase, branch);
353
- if (verbose) {
337
+ const count = await git.commitCount(oldBase, branch);
354
338
  console.log(` Moving ${count} commit(s)`);
355
339
  }
356
340
 
357
- success = await git.rebaseOnto(parent, tagName, branch);
341
+ success = await git.rebaseOnto(parent, oldBase, branch);
358
342
  } else {
359
- // Fallback to merge-base
360
- p.log.warn("Tag not found, calculating merge-base...");
361
- const oldBase = await git.mergeBase(branch, parent);
362
-
363
- if (!oldBase) {
364
- p.log.warn("Could not find merge-base, using simple rebase");
365
- success = await git.rebase(parent);
366
- } else {
367
- p.log.info(`Found merge-base: ${oldBase.slice(0, 8)}`);
368
- success = await git.rebaseOnto(parent, oldBase, branch);
369
- }
343
+ p.log.warn("Could not determine rebase base — using simple rebase");
344
+ success = await git.rebase(parent);
370
345
  }
371
346
 
372
347
  if (success) {
@@ -435,50 +410,3 @@ async function promptPush(branch: string): Promise<void> {
435
410
  p.log.warn("Skipping push (you'll need to push manually)");
436
411
  }
437
412
  }
438
-
439
- async function handleStaleTags(): Promise<void> {
440
- try {
441
- const { $ } = await import("bun");
442
- const result = await $`git tag -l ${git.STACK_SYNC_TAG_GLOB}`.text();
443
- const tags = result.trim();
444
- if (!tags) return;
445
-
446
- p.log.warn(`Found stale ${git.STACK_SYNC_TAG_GLOB} tags from a previous run`);
447
- console.log();
448
-
449
- const action = await p.select({
450
- message: "What would you like to do?",
451
- options: [
452
- {
453
- value: "clean",
454
- label: "Clean up tags and start fresh",
455
- },
456
- {
457
- value: "resume",
458
- label: "Resume from previous sync (--resume)",
459
- },
460
- { value: "abort", label: "Abort (keep tags, exit)" },
461
- ],
462
- });
463
-
464
- if (p.isCancel(action) || action === "abort") {
465
- p.cancel("Aborted");
466
- process.exit(0);
467
- }
468
-
469
- if (action === "clean") {
470
- p.log.info("Cleaning up stale tags...");
471
- await git.deleteTagsMatching(git.STACK_SYNC_TAG_GLOB);
472
- p.log.success("Tags cleaned");
473
- }
474
-
475
- if (action === "resume") {
476
- // Re-run with --resume flag
477
- const { default: restack } = await import("./restack.ts");
478
- await restack(["--resume"]);
479
- process.exit(0);
480
- }
481
- } catch {
482
- // No stale tags — carry on
483
- }
484
- }
@@ -24,6 +24,16 @@ with the base branch included.
24
24
  return;
25
25
  }
26
26
 
27
+ // Reject if a git rebase is already in progress. Letting sync run on top
28
+ // of a half-finished rebase produces deeply confusing state — make the
29
+ // user finish or abort it first.
30
+ if (await git.isRebaseInProgress()) {
31
+ p.cancel(
32
+ `Git rebase already in progress.\n\n Finish or abort it first:\n ${pc.green("git rebase --continue")}\n ${pc.red("git rebase --abort")}\n\n Then re-run ${pc.green("gh-stack sync")}.`,
33
+ );
34
+ process.exit(1);
35
+ }
36
+
27
37
  await ensureCleanWorkingTree();
28
38
 
29
39
  const meta = await ensureMetadata();
@@ -83,31 +93,11 @@ with the base branch included.
83
93
  await git.fetchMain();
84
94
  fetchSpinner.stop("Fetched latest main");
85
95
 
86
- // Take snapshot
96
+ // Take snapshot BEFORE rebasing. This records every branch's pre-rebase
97
+ // tip — including the orphaned base-branch tip that restack will later
98
+ // need to find when restacking children. (See findPreRewriteSha.)
87
99
  await takeSnapshot(meta, stackName, "sync");
88
100
 
89
- // Create temporary tags for ALL branches BEFORE any rebasing starts.
90
- // This is critical: after rebasing PR1, `git merge-base PR2 PR1` returns
91
- // wrong results. Tags capture the true divergence points while history
92
- // is still intact.
93
- p.log.info("Creating temporary base tags for all branches...");
94
- for (const branch of ordered) {
95
- const parent = stack.branches[branch]?.parent;
96
- if (!parent) continue;
97
-
98
- // For the base branch, the "parent" is main — tag its merge-base with main
99
- const parentRef = parent === "main" ? "origin/main" : parent;
100
- const mb = await git.mergeBase(branch, parentRef);
101
- if (mb) {
102
- const tagName = git.tempBaseTagName(branch);
103
- await git.createTag(tagName, mb);
104
- console.log(
105
- ` ${pc.green("✓")} Tagged base for ${branch}: ${pc.cyan(tagName)} (${mb.slice(0, 8)})`,
106
- );
107
- }
108
- }
109
- console.log();
110
-
111
101
  // Step 1: Rebase base branch onto main
112
102
  console.log();
113
103
  console.log(pc.cyan("━".repeat(40)));
@@ -1,8 +1,55 @@
1
- // Snapshot system for undo support
1
+ // Snapshot system for undo support and pre-rewrite base recovery
2
2
  import type { StackMetadata, Snapshot } from "../types.ts";
3
3
  import * as git from "./git.ts";
4
4
  import { writeMetadata, getOrderedBranches } from "./metadata.ts";
5
5
 
6
+ /**
7
+ * Find the pre-rewrite SHA for a branch by walking snapshots newest-first.
8
+ *
9
+ * Returns the SHA from the most recent snapshot whose recorded tip for
10
+ * `branchName` is NOT an ancestor of the branch's current tip — meaning
11
+ * that snapshot was taken before the branch's history got rewritten
12
+ * (typically by `sync` rebasing it onto a new main).
13
+ *
14
+ * That pre-rewrite SHA is the correct rebase base when restacking a child
15
+ * onto this branch — `git rebase --onto <new-tip> <pre-rewrite-tip> <child>`
16
+ * replays only the child's unique commits, never the parent's old history.
17
+ *
18
+ * Returns null if no snapshots exist or every snapshot's recorded tip is
19
+ * still an ancestor of the current tip (i.e., the branch has only had
20
+ * commits appended, never been rebased — `merge-base` is still correct).
21
+ */
22
+ export async function findPreRewriteSha(
23
+ meta: StackMetadata,
24
+ branchName: string,
25
+ ): Promise<string | null> {
26
+ if (!meta.snapshots || meta.snapshots.length === 0) return null;
27
+
28
+ let currentTip: string;
29
+ try {
30
+ currentTip = await git.revParse(branchName);
31
+ } catch {
32
+ return null;
33
+ }
34
+
35
+ // Walk newest → oldest
36
+ for (let i = meta.snapshots.length - 1; i >= 0; i--) {
37
+ const recorded = meta.snapshots[i]!.branches[branchName];
38
+ if (!recorded) continue;
39
+ if (recorded === currentTip) continue;
40
+
41
+ // If the recorded SHA is no longer an ancestor of the current tip,
42
+ // the branch's history was rewritten between then and now.
43
+ // That recorded SHA is the orphaned tip we need.
44
+ const stillAncestor = await git.isAncestor(recorded, currentTip);
45
+ if (!stillAncestor) {
46
+ return recorded;
47
+ }
48
+ }
49
+
50
+ return null;
51
+ }
52
+
6
53
  const MAX_SNAPSHOTS = 10;
7
54
 
8
55
  /**