gh-stack 0.3.0 → 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 +12 -5
- package/package.json +1 -1
- package/src/commands/merge.ts +77 -10
- package/src/commands/restack.ts +68 -140
- package/src/commands/sync.ts +13 -23
- package/src/lib/github.ts +15 -0
- package/src/lib/snapshot.ts +48 -1
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
|
|
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,13 +128,15 @@ 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.
|
|
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),
|
|
134
136
|
then enables auto-merge for the base PR into main. All merges
|
|
135
137
|
happen on GitHub so PRs show as "Merged", Linear tickets close
|
|
136
|
-
automatically, and GitHub Actions fire normally.
|
|
138
|
+
automatically, and GitHub Actions fire normally. Skips already-
|
|
139
|
+
merged PRs (safe to re-run). Waits for GitHub between merges.
|
|
137
140
|
|
|
138
141
|
delete [<branch>]
|
|
139
142
|
Remove a branch from the stack and re-parent its children.
|
|
@@ -180,9 +183,13 @@ GH_STACK_NO_COLOR=1 Disable colored output
|
|
|
180
183
|
|
|
181
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.
|
|
182
185
|
|
|
183
|
-
###
|
|
186
|
+
### Snapshot-Based Rebasing
|
|
184
187
|
|
|
185
|
-
The critical insight: after
|
|
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.
|
|
186
193
|
|
|
187
194
|
### Stack Visualization
|
|
188
195
|
|
package/package.json
CHANGED
package/src/commands/merge.ts
CHANGED
|
@@ -6,8 +6,38 @@ import * as git from "../lib/git.ts";
|
|
|
6
6
|
import { findStackForBranch, getOrderedBranches, writeMetadata } from "../lib/metadata.ts";
|
|
7
7
|
import { ensureMetadata, ensureValidStack } from "../lib/safety.ts";
|
|
8
8
|
import { takeSnapshot } from "../lib/snapshot.ts";
|
|
9
|
+
import { getPrMergeState } from "../lib/github.ts";
|
|
9
10
|
import { confirmAction } from "../lib/ui.ts";
|
|
10
11
|
|
|
12
|
+
const MAX_RETRIES = 12;
|
|
13
|
+
const RETRY_DELAY_MS = 5000;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Wait for a PR to become mergeable after a previous merge lands.
|
|
17
|
+
* GitHub needs time to process the new commit on the target branch.
|
|
18
|
+
*/
|
|
19
|
+
async function waitForMergeable(
|
|
20
|
+
prNumber: number,
|
|
21
|
+
spinner: ReturnType<typeof p.spinner>,
|
|
22
|
+
): Promise<boolean> {
|
|
23
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
24
|
+
const state = await getPrMergeState(prNumber);
|
|
25
|
+
if (!state) return false;
|
|
26
|
+
|
|
27
|
+
if (state.state === "MERGED") return true; // Already merged
|
|
28
|
+
if (state.state === "CLOSED") return false;
|
|
29
|
+
|
|
30
|
+
// "CLEAN", "HAS_HOOKS", "UNSTABLE" are all mergeable states
|
|
31
|
+
if (state.mergeable === "MERGEABLE" && state.mergeStateStatus !== "BLOCKED") {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
spinner.message(`Waiting for PR #${prNumber} to be ready... (${attempt}/${MAX_RETRIES})`);
|
|
36
|
+
await Bun.sleep(RETRY_DELAY_MS);
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
11
41
|
export default async function merge(args: string[]): Promise<void> {
|
|
12
42
|
const dryRun = args.includes("--dry-run");
|
|
13
43
|
const deleteFlag = args.includes("--delete-branch") || args.includes("-d");
|
|
@@ -25,6 +55,9 @@ Squash-merges the stack from top to bottom via GitHub:
|
|
|
25
55
|
All merges happen on GitHub, so PRs show as "Merged", Linear tickets
|
|
26
56
|
close automatically, and all GitHub Actions/webhooks fire normally.
|
|
27
57
|
|
|
58
|
+
Skips PRs that are already merged (safe to re-run after partial failure).
|
|
59
|
+
Waits for GitHub to process between merges if needed.
|
|
60
|
+
|
|
28
61
|
OPTIONS
|
|
29
62
|
-d, --delete-branch Delete remote branches after merging
|
|
30
63
|
--dry-run Show what would happen without doing anything
|
|
@@ -65,23 +98,29 @@ OPTIONS
|
|
|
65
98
|
}
|
|
66
99
|
|
|
67
100
|
p.intro(pc.cyan("Stack Merge (via GitHub)"));
|
|
68
|
-
|
|
69
101
|
p.log.info(`Stack: ${pc.yellow(stackName)}`);
|
|
70
102
|
console.log();
|
|
71
103
|
|
|
72
|
-
//
|
|
104
|
+
// Check PR states and build the merge plan
|
|
73
105
|
const reversed = ordered.toReversed();
|
|
106
|
+
const baseBranch = ordered[0]!;
|
|
107
|
+
const basePr = stack.branches[baseBranch]?.pr;
|
|
108
|
+
|
|
74
109
|
console.log(` ${pc.bold("Merge plan:")}`);
|
|
75
110
|
for (let i = 0; i < reversed.length - 1; i++) {
|
|
76
111
|
const child = reversed[i]!;
|
|
77
112
|
const parent = stack.branches[child]?.parent || "???";
|
|
78
113
|
const childPr = stack.branches[child]?.pr;
|
|
114
|
+
|
|
115
|
+
// Check if already merged
|
|
116
|
+
const prState = childPr ? await getPrMergeState(childPr) : null;
|
|
117
|
+
const isMerged = prState?.state === "MERGED";
|
|
118
|
+
const mergedLabel = isMerged ? pc.dim(" (already merged)") : "";
|
|
119
|
+
|
|
79
120
|
console.log(
|
|
80
|
-
` ${pc.yellow(child)}${childPr ? ` (#${childPr})` : ""} → squash into ${pc.blue(parent)}`,
|
|
121
|
+
` ${pc.yellow(child)}${childPr ? ` (#${childPr})` : ""} → squash into ${pc.blue(parent)}${mergedLabel}`,
|
|
81
122
|
);
|
|
82
123
|
}
|
|
83
|
-
const baseBranch = ordered[0]!;
|
|
84
|
-
const basePr = stack.branches[baseBranch]?.pr;
|
|
85
124
|
console.log(
|
|
86
125
|
` ${pc.blue(baseBranch)}${basePr ? ` (#${basePr})` : ""} → ${pc.green("main")} (auto-merge)`,
|
|
87
126
|
);
|
|
@@ -118,6 +157,13 @@ OPTIONS
|
|
|
118
157
|
|
|
119
158
|
if (parentBranch === "main" || parentBranch === "master") continue;
|
|
120
159
|
|
|
160
|
+
// Check if already merged (skip)
|
|
161
|
+
const prState = await getPrMergeState(childPrNum);
|
|
162
|
+
if (prState?.state === "MERGED") {
|
|
163
|
+
p.log.info(`${pc.dim("✓")} PR #${childPrNum} already merged — skipping`);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
121
167
|
console.log();
|
|
122
168
|
console.log(pc.cyan("━".repeat(40)));
|
|
123
169
|
console.log(
|
|
@@ -125,8 +171,19 @@ OPTIONS
|
|
|
125
171
|
);
|
|
126
172
|
console.log(pc.cyan("━".repeat(40)));
|
|
127
173
|
|
|
174
|
+
// Wait for PR to be mergeable (GitHub may need time after previous merge)
|
|
128
175
|
const s = p.spinner();
|
|
129
|
-
s.start(`
|
|
176
|
+
s.start(`Checking PR #${childPrNum} is ready to merge...`);
|
|
177
|
+
const ready = await waitForMergeable(childPrNum, s);
|
|
178
|
+
|
|
179
|
+
if (!ready) {
|
|
180
|
+
s.stop(pc.red(`PR #${childPrNum} is not mergeable`));
|
|
181
|
+
p.log.info("Check the PR on GitHub for merge conflicts or required checks.");
|
|
182
|
+
p.log.info("Re-run merge to continue from where you left off.");
|
|
183
|
+
process.exit(2);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
s.message(`Squash-merging PR #${childPrNum} via GitHub...`);
|
|
130
187
|
|
|
131
188
|
const ghArgs = ["pr", "merge", String(childPrNum), "--squash"];
|
|
132
189
|
if (deleteFlag) ghArgs.push("--delete-branch");
|
|
@@ -143,7 +200,7 @@ OPTIONS
|
|
|
143
200
|
} else {
|
|
144
201
|
s.stop(pc.red(`Failed to merge PR #${childPrNum}`));
|
|
145
202
|
p.log.error(stderr.trim());
|
|
146
|
-
p.log.info("
|
|
203
|
+
p.log.info("Re-run merge to continue from where you left off.");
|
|
147
204
|
process.exit(2);
|
|
148
205
|
}
|
|
149
206
|
}
|
|
@@ -157,12 +214,23 @@ OPTIONS
|
|
|
157
214
|
console.log(pc.cyan("━".repeat(40)));
|
|
158
215
|
|
|
159
216
|
const autoSpinner = p.spinner();
|
|
160
|
-
|
|
217
|
+
|
|
218
|
+
// Wait for base PR to be ready (may need time after last intermediate merge)
|
|
219
|
+
autoSpinner.start(`Waiting for PR #${basePr} to be ready...`);
|
|
220
|
+
await waitForMergeable(basePr!, autoSpinner);
|
|
221
|
+
|
|
222
|
+
autoSpinner.message(`Enabling auto-merge for PR #${basePr}...`);
|
|
161
223
|
|
|
162
224
|
try {
|
|
163
225
|
const ghArgs = ["pr", "merge", String(basePr), "--squash", "--auto"];
|
|
164
226
|
if (deleteFlag) ghArgs.push("--delete-branch");
|
|
165
|
-
|
|
227
|
+
|
|
228
|
+
const proc = Bun.spawn(["gh", ...ghArgs], {
|
|
229
|
+
stdout: "pipe",
|
|
230
|
+
stderr: "pipe",
|
|
231
|
+
});
|
|
232
|
+
await proc.exited;
|
|
233
|
+
|
|
166
234
|
autoSpinner.stop(`Auto-merge enabled for PR #${basePr} — will merge into main when CI passes`);
|
|
167
235
|
} catch {
|
|
168
236
|
autoSpinner.stop(pc.yellow(`Could not enable auto-merge for PR #${basePr}`));
|
|
@@ -175,7 +243,6 @@ OPTIONS
|
|
|
175
243
|
pullSpinner.start("Syncing local branches...");
|
|
176
244
|
try {
|
|
177
245
|
await $`git fetch origin`.quiet();
|
|
178
|
-
// Checkout main and pull so local is up to date
|
|
179
246
|
await git.checkout("main");
|
|
180
247
|
await $`git pull origin main`.quiet();
|
|
181
248
|
pullSpinner.stop("Local branches synced");
|
package/src/commands/restack.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
|
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,
|
|
341
|
+
success = await git.rebaseOnto(parent, oldBase, branch);
|
|
358
342
|
} else {
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
}
|
package/src/commands/sync.ts
CHANGED
|
@@ -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)));
|
package/src/lib/github.ts
CHANGED
|
@@ -124,6 +124,21 @@ export async function getPrBody(prNumber: number): Promise<string | null> {
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Get the merge state of a PR.
|
|
129
|
+
* Returns { state, mergeable, mergeStateStatus }.
|
|
130
|
+
*/
|
|
131
|
+
export async function getPrMergeState(
|
|
132
|
+
prNumber: number,
|
|
133
|
+
): Promise<{ state: string; mergeable: string; mergeStateStatus: string } | null> {
|
|
134
|
+
try {
|
|
135
|
+
const result = await $`gh pr view ${prNumber} --json state,mergeable,mergeStateStatus`.text();
|
|
136
|
+
return JSON.parse(result.trim());
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
127
142
|
/**
|
|
128
143
|
* Close a PR with a comment.
|
|
129
144
|
*/
|
package/src/lib/snapshot.ts
CHANGED
|
@@ -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
|
/**
|