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 +2 -0
- package/package.json +1 -1
- package/src/commands/restack.ts +39 -36
- 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 +72 -52
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
|
|
|
@@ -549,49 +549,45 @@ async function processChain(
|
|
|
549
549
|
continue;
|
|
550
550
|
}
|
|
551
551
|
|
|
552
|
-
// Resolve the rebase
|
|
553
|
-
//
|
|
554
|
-
//
|
|
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
|
|
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("
|
|
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")} —
|
|
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
|
-
|
|
608
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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) {
|
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
|
@@ -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
|
-
*
|
|
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
|
-
// 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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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 `
|
|
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).
|