gh-stack 0.17.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
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, findPreRewriteSha, 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);
@@ -7,7 +7,7 @@
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
 
@@ -86,6 +86,48 @@ export async function findPreRewriteSha(
86
86
  return null;
87
87
  }
88
88
 
89
+ /**
90
+ * Find the recorded tip of a stack's (non-main) base branch — the boundary a
91
+ * re-root needs to replay only the dependent stack's own commits.
92
+ *
93
+ * Walks snapshots newest-first for one whose recorded `baseBranch` matches and
94
+ * whose recorded `baseTip` is still an ancestor of `rootBranch` (the dependent
95
+ * stack's root). That tip is exactly where the root forked off the base, so
96
+ * `git rebase --onto <new-base> <baseTip> <root>` drops the already-merged
97
+ * prefix instead of replaying it.
98
+ *
99
+ * Unlike `findPreRewriteSha`, this does NOT require the base branch to still
100
+ * exist locally — that's the whole point: after a squash-merge deletes the base
101
+ * branch, its tip survives only in these snapshots. The ancestor-of-root check
102
+ * rejects stale candidates (an older base tip the root no longer sits on).
103
+ *
104
+ * Returns null if no snapshot recorded this base, or none is still an ancestor
105
+ * of the root (in which case the caller must refuse rather than guess).
106
+ */
107
+ export async function findRecordedBaseTip(
108
+ meta: StackMetadata,
109
+ baseBranch: string,
110
+ rootBranch: string,
111
+ ): Promise<string | null> {
112
+ if (!meta.snapshots || meta.snapshots.length === 0) return null;
113
+
114
+ let rootTip: string;
115
+ try {
116
+ rootTip = await git.revParse(rootBranch);
117
+ } catch {
118
+ return null;
119
+ }
120
+
121
+ // Walk newest → oldest; first base tip that the root still sits on wins.
122
+ for (let i = meta.snapshots.length - 1; i >= 0; i--) {
123
+ const snap = meta.snapshots[i]!;
124
+ if (snap.baseBranch !== baseBranch || !snap.baseTip) continue;
125
+ if (await git.isAncestor(snap.baseTip, rootTip)) return snap.baseTip;
126
+ }
127
+
128
+ return null;
129
+ }
130
+
89
131
  // Snapshot files are named `<sortable-timestamp>__<encoded-stack>.json`. The
90
132
  // timestamp keeps colons/dots out of the filename (Windows-safe) while sorting
91
133
  // chronologically as a plain string; the stack name is URL-encoded so `/` in a
@@ -161,6 +203,20 @@ export async function takeSnapshot(
161
203
  stack: stackName,
162
204
  };
163
205
 
206
+ // For a non-main base (a stack stacked on top of another stack), record the
207
+ // base branch's tip too — kept out of `branches` so `undo` won't reset a
208
+ // branch that belongs to the parent stack. This is the boundary a later
209
+ // re-root needs if the base branch is deleted by a squash-merge (issue #13).
210
+ const base = stackBase(stack);
211
+ if (base !== "main" && base !== "master") {
212
+ try {
213
+ snapshot.baseTip = await git.revParse(base);
214
+ snapshot.baseBranch = base;
215
+ } catch {
216
+ // Base branch already gone locally — nothing to record.
217
+ }
218
+ }
219
+
164
220
  await writeSnapshotFile(snapshot);
165
221
 
166
222
  // 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