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 +2 -0
- package/package.json +1 -1
- package/src/commands/restack.ts +85 -47
- package/src/commands/sync.ts +1 -1
- package/src/lib/advance.ts +8 -10
- package/src/lib/git.ts +9 -0
- package/src/lib/snapshot.ts +126 -50
- package/src/types.ts +7 -0
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
package/src/commands/restack.ts
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
`
|
|
399
|
-
|
|
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
|
|
518
|
-
//
|
|
519
|
-
//
|
|
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
|
|
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("
|
|
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")} —
|
|
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
|
-
|
|
573
|
-
|
|
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: ${
|
|
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(
|
|
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,
|
|
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) {
|
package/src/commands/sync.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/lib/advance.ts
CHANGED
|
@@ -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 {
|
|
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.
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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
|
|
125
|
-
|
|
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.
|
package/src/lib/snapshot.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
*
|
|
29
|
-
*
|
|
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
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
57
|
+
export async function resolveRestackBoundary(
|
|
40
58
|
meta: StackMetadata,
|
|
41
59
|
parentBranch: string,
|
|
42
|
-
childBranch
|
|
43
|
-
): Promise<
|
|
44
|
-
|
|
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
|
-
|
|
66
|
+
currentParentTip = null;
|
|
51
67
|
}
|
|
52
68
|
|
|
53
69
|
let currentChildTip: string | null = null;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|