gh-stack 0.7.0 → 0.9.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 +97 -27
- package/package.json +1 -1
- package/src/commands/delete.ts +66 -12
- package/src/commands/doctor.ts +268 -0
- package/src/commands/down.ts +14 -6
- package/src/commands/init.ts +4 -1
- package/src/commands/log.ts +2 -0
- package/src/commands/merge.ts +19 -1
- package/src/commands/restack.ts +270 -44
- package/src/commands/split.ts +159 -0
- package/src/commands/status.ts +7 -0
- package/src/commands/submit.ts +30 -88
- package/src/commands/sync.ts +24 -4
- package/src/commands/update-prs.ts +201 -100
- package/src/index.ts +15 -0
- package/src/lib/branch-config.ts +126 -0
- package/src/lib/chain.ts +83 -50
- package/src/lib/git.ts +29 -0
- package/src/lib/github.ts +24 -0
- package/src/lib/metadata.ts +246 -60
- package/src/lib/paths.ts +69 -0
- package/src/lib/safety.ts +33 -7
- package/src/lib/snapshot.ts +83 -14
- package/src/lib/ui.ts +5 -2
- package/src/types.ts +21 -2
package/src/commands/merge.ts
CHANGED
|
@@ -3,7 +3,12 @@ import * as p from "../lib/output.ts";
|
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import { $ } from "bun";
|
|
5
5
|
import * as git from "../lib/git.ts";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
findStackForBranch,
|
|
8
|
+
getOrderedBranches,
|
|
9
|
+
stackBase,
|
|
10
|
+
writeMetadata,
|
|
11
|
+
} from "../lib/metadata.ts";
|
|
7
12
|
import { ensureMetadata, ensureValidStack } from "../lib/safety.ts";
|
|
8
13
|
import { takeSnapshot } from "../lib/snapshot.ts";
|
|
9
14
|
import { getPrMergeState } from "../lib/github.ts";
|
|
@@ -84,6 +89,19 @@ OPTIONS
|
|
|
84
89
|
await ensureValidStack(meta, stackName);
|
|
85
90
|
const ordered = getOrderedBranches(stack);
|
|
86
91
|
|
|
92
|
+
// A split stack is rooted on a branch in another stack, so its base PR
|
|
93
|
+
// doesn't target main — merging it now would land it against the wrong base.
|
|
94
|
+
// Re-root onto main first (after the parent stack merges).
|
|
95
|
+
const base = stackBase(stack);
|
|
96
|
+
if (base !== "main" && base !== "master") {
|
|
97
|
+
p.cancel(
|
|
98
|
+
`${pc.yellow(stackName)} is based on ${pc.yellow(base)}, not main — can't merge yet.\n\n` +
|
|
99
|
+
` Merge (or finish) the parent stack first, then re-root onto main:\n` +
|
|
100
|
+
` ${pc.green("gh-stack restack --onto main")}`,
|
|
101
|
+
);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
|
|
87
105
|
if (ordered.length <= 1) {
|
|
88
106
|
// Single branch — just enable auto-merge
|
|
89
107
|
const basePr = stack.branches[ordered[0]!]?.pr;
|
package/src/commands/restack.ts
CHANGED
|
@@ -5,18 +5,16 @@ import pc from "picocolors";
|
|
|
5
5
|
import * as git from "../lib/git.ts";
|
|
6
6
|
import {
|
|
7
7
|
findStackForBranch,
|
|
8
|
+
stackBase,
|
|
8
9
|
buildRebaseChain,
|
|
10
|
+
getOrderedBranches,
|
|
9
11
|
saveRestackState,
|
|
10
12
|
loadRestackState,
|
|
11
13
|
clearRestackState,
|
|
12
14
|
readMetadata,
|
|
15
|
+
writeMetadata,
|
|
13
16
|
} from "../lib/metadata.ts";
|
|
14
|
-
import {
|
|
15
|
-
ensureMetadata,
|
|
16
|
-
ensureCleanWorkingTree,
|
|
17
|
-
ensureNotMain,
|
|
18
|
-
ensureValidStack,
|
|
19
|
-
} from "../lib/safety.ts";
|
|
17
|
+
import { ensureMetadata, ensureCleanWorkingTree, ensureValidStack } from "../lib/safety.ts";
|
|
20
18
|
import { takeSnapshot, findPreRewriteSha } from "../lib/snapshot.ts";
|
|
21
19
|
import { confirmAction } from "../lib/ui.ts";
|
|
22
20
|
import type { StackMetadata } from "../types.ts";
|
|
@@ -26,14 +24,23 @@ export default async function restack(args: string[]): Promise<void> {
|
|
|
26
24
|
const resume = args.includes("--resume");
|
|
27
25
|
const verbose = args.includes("--verbose") || args.includes("-v");
|
|
28
26
|
|
|
27
|
+
// --onto <branch>: re-root the current stack onto a new base (e.g. move a
|
|
28
|
+
// split stack from its parent branch onto main once the parent has merged).
|
|
29
|
+
let ontoFlag: string | undefined;
|
|
30
|
+
for (let i = 0; i < args.length; i++) {
|
|
31
|
+
if (args[i] === "--onto" && args[i + 1]) ontoFlag = args[++i];
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
if (args.includes("--help")) {
|
|
30
35
|
console.log(`
|
|
31
36
|
gh-stack restack — Rebase children onto updated parents
|
|
32
37
|
|
|
33
38
|
USAGE
|
|
34
39
|
gh-stack restack [options]
|
|
40
|
+
gh-stack restack --onto <branch> Re-root the current stack onto <branch>
|
|
35
41
|
|
|
36
42
|
OPTIONS
|
|
43
|
+
--onto <ref> Re-root the stack onto a new base ref (changes stack base)
|
|
37
44
|
--yes, -y Skip confirmations (auto-accept all rebases and pushes)
|
|
38
45
|
--resume Resume after resolving rebase conflicts
|
|
39
46
|
--dry-run Show what would happen without executing
|
|
@@ -43,6 +50,8 @@ EXAMPLES
|
|
|
43
50
|
gh-stack restack Interactive (prompts before each rebase)
|
|
44
51
|
gh-stack restack --yes Non-interactive (for agents/CI)
|
|
45
52
|
gh-stack restack --resume Continue after resolving conflicts
|
|
53
|
+
gh-stack restack --onto main Re-root a split stack onto main after its
|
|
54
|
+
parent stack has merged
|
|
46
55
|
|
|
47
56
|
ALIASES
|
|
48
57
|
gh-stack rebase
|
|
@@ -67,6 +76,9 @@ ALIASES
|
|
|
67
76
|
|
|
68
77
|
const meta = await ensureMetadata();
|
|
69
78
|
const rebasedBranches: string[] = [];
|
|
79
|
+
// Every branch restack is responsible for this run (rebased OR already
|
|
80
|
+
// up to date) — we push each one and verify its ref landed at the end.
|
|
81
|
+
const touched: string[] = [];
|
|
70
82
|
|
|
71
83
|
p.intro(pc.cyan("Git Stack Restack"));
|
|
72
84
|
|
|
@@ -74,10 +86,12 @@ ALIASES
|
|
|
74
86
|
// (pre-snapshot). Keeps users upgrading from <0.4 from seeing stale tags.
|
|
75
87
|
await git.deleteTagsMatching(git.STACK_SYNC_TAG_GLOB);
|
|
76
88
|
|
|
77
|
-
if (
|
|
78
|
-
await
|
|
89
|
+
if (ontoFlag) {
|
|
90
|
+
await handleReroot(meta, ontoFlag, rebasedBranches, touched, dryRun, verbose);
|
|
91
|
+
} else if (resume) {
|
|
92
|
+
await handleResume(meta, rebasedBranches, touched, verbose);
|
|
79
93
|
} else {
|
|
80
|
-
await handleFreshRestack(meta, rebasedBranches, dryRun, verbose);
|
|
94
|
+
await handleFreshRestack(meta, rebasedBranches, touched, dryRun, verbose);
|
|
81
95
|
}
|
|
82
96
|
|
|
83
97
|
// Summary
|
|
@@ -91,12 +105,60 @@ ALIASES
|
|
|
91
105
|
}
|
|
92
106
|
}
|
|
93
107
|
|
|
108
|
+
// Verification pass: assert every touched branch's ref actually landed on
|
|
109
|
+
// origin. Fail loudly (listing the stale ones) rather than reporting success
|
|
110
|
+
// when a push didn't take. (#12)
|
|
111
|
+
if (!dryRun) {
|
|
112
|
+
const stale = await findStaleRefs(touched);
|
|
113
|
+
if (stale.length > 0) {
|
|
114
|
+
console.log();
|
|
115
|
+
p.log.error("Some branch refs are NOT in sync with origin:");
|
|
116
|
+
for (const { branch, local, remote } of stale) {
|
|
117
|
+
console.log(` ${pc.red("✗")} ${branch} local ${local.slice(0, 8)} origin ${remote}`);
|
|
118
|
+
}
|
|
119
|
+
console.log();
|
|
120
|
+
console.log(
|
|
121
|
+
` Reconcile with: ${pc.green("gh-stack restack")} (re-runs the push for stale refs)`,
|
|
122
|
+
);
|
|
123
|
+
p.outro(pc.red("Restack finished with un-pushed refs"));
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
94
128
|
p.outro(pc.green("Stack Restack Complete!"));
|
|
95
129
|
}
|
|
96
130
|
|
|
131
|
+
/**
|
|
132
|
+
* For each branch, compare its local tip against origin. Returns the ones that
|
|
133
|
+
* differ (or whose remote ref is missing), so restack can report exactly which
|
|
134
|
+
* refs didn't land instead of falsely claiming success. (#12)
|
|
135
|
+
*/
|
|
136
|
+
async function findStaleRefs(
|
|
137
|
+
branches: string[],
|
|
138
|
+
): Promise<{ branch: string; local: string; remote: string }[]> {
|
|
139
|
+
const stale: { branch: string; local: string; remote: string }[] = [];
|
|
140
|
+
const seen = new Set<string>();
|
|
141
|
+
for (const branch of branches) {
|
|
142
|
+
if (seen.has(branch) || branch === "main" || branch === "master") continue;
|
|
143
|
+
seen.add(branch);
|
|
144
|
+
if (!(await git.localBranchExists(branch))) continue;
|
|
145
|
+
const local = await git.revParse(branch);
|
|
146
|
+
if (!(await git.remoteBranchExists(branch))) {
|
|
147
|
+
stale.push({ branch, local, remote: "(missing)" });
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const remote = await git.revParse(`origin/${branch}`).catch(() => "");
|
|
151
|
+
if (remote !== local) {
|
|
152
|
+
stale.push({ branch, local, remote: remote ? remote.slice(0, 8) : "(unknown)" });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return stale;
|
|
156
|
+
}
|
|
157
|
+
|
|
97
158
|
async function handleResume(
|
|
98
159
|
meta: StackMetadata,
|
|
99
160
|
rebasedBranches: string[],
|
|
161
|
+
touched: string[],
|
|
100
162
|
verbose: boolean,
|
|
101
163
|
): Promise<void> {
|
|
102
164
|
p.log.warn("Resuming from saved state...");
|
|
@@ -135,10 +197,11 @@ async function handleResume(
|
|
|
135
197
|
// Rebase completed successfully
|
|
136
198
|
p.log.success("Rebase completed successfully");
|
|
137
199
|
rebasedBranches.push(expectedBranch);
|
|
200
|
+
touched.push(expectedBranch);
|
|
138
201
|
await clearRestackState();
|
|
139
202
|
|
|
140
203
|
// Prompt to push the resumed branch
|
|
141
|
-
await
|
|
204
|
+
await pushBranchRef(expectedBranch);
|
|
142
205
|
|
|
143
206
|
// Continue with remaining branches in chain
|
|
144
207
|
const stackName = state.stack_name;
|
|
@@ -149,13 +212,23 @@ async function handleResume(
|
|
|
149
212
|
const currentIndex = state.current_index + 1;
|
|
150
213
|
|
|
151
214
|
if (currentIndex < chain.length) {
|
|
152
|
-
await processChain(
|
|
215
|
+
await processChain(
|
|
216
|
+
meta,
|
|
217
|
+
stackName,
|
|
218
|
+
chain,
|
|
219
|
+
currentIndex,
|
|
220
|
+
rebasedBranches,
|
|
221
|
+
touched,
|
|
222
|
+
false,
|
|
223
|
+
verbose,
|
|
224
|
+
);
|
|
153
225
|
}
|
|
154
226
|
}
|
|
155
227
|
|
|
156
228
|
async function handleFreshRestack(
|
|
157
229
|
meta: StackMetadata,
|
|
158
230
|
rebasedBranches: string[],
|
|
231
|
+
touched: string[],
|
|
159
232
|
dryRun: boolean,
|
|
160
233
|
verbose: boolean,
|
|
161
234
|
): Promise<void> {
|
|
@@ -189,26 +262,40 @@ async function handleFreshRestack(
|
|
|
189
262
|
}
|
|
190
263
|
console.log();
|
|
191
264
|
|
|
192
|
-
// Skip
|
|
193
|
-
|
|
265
|
+
// Skip the stack's root branch (parent = base) when the base is the trunk —
|
|
266
|
+
// rebasing onto trunk is `sync`'s job, not restack's. For a split stack
|
|
267
|
+
// whose base is a sibling branch, the root SHOULD be rebased onto that base
|
|
268
|
+
// so changes propagate, so we don't skip it.
|
|
269
|
+
const base = stackBase(stack);
|
|
270
|
+
const baseIsTrunk = base === "main" || base === "master";
|
|
271
|
+
const baseBranches = baseIsTrunk ? chain.filter((b) => stack.branches[b]?.parent === base) : [];
|
|
194
272
|
if (baseBranches.length > 0) {
|
|
195
|
-
p.log.warn(
|
|
196
|
-
for (const
|
|
197
|
-
console.log(` ${pc.cyan("Skip:")} ${
|
|
273
|
+
p.log.warn(`Skipping base branch(es) (parent: ${base}):`);
|
|
274
|
+
for (const baseBranch of baseBranches) {
|
|
275
|
+
console.log(` ${pc.cyan("Skip:")} ${baseBranch} ${pc.blue(`(parent: ${base})`)}`);
|
|
198
276
|
}
|
|
199
277
|
console.log();
|
|
200
278
|
console.log(` ${pc.dim("This is a restack — propagating changes from parent to children.")}`);
|
|
201
|
-
console.log(
|
|
279
|
+
console.log(
|
|
280
|
+
` ${pc.dim(`To include rebasing onto ${base}, use: ${pc.green("gh-stack sync")}`)}`,
|
|
281
|
+
);
|
|
202
282
|
console.log();
|
|
203
283
|
|
|
204
|
-
chain = chain.filter((b) => stack.branches[b]?.parent !==
|
|
284
|
+
chain = chain.filter((b) => stack.branches[b]?.parent !== base);
|
|
285
|
+
|
|
286
|
+
// We don't rebase the base branch, but if you committed on it its ref is
|
|
287
|
+
// now stale on origin — push it so the PR reflects the new commit. (#12)
|
|
288
|
+
if (!dryRun) {
|
|
289
|
+
for (const baseBranch of baseBranches) {
|
|
290
|
+
if (await git.localBranchExists(baseBranch)) {
|
|
291
|
+
touched.push(baseBranch);
|
|
292
|
+
await pushBranchRef(baseBranch);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
205
296
|
|
|
206
297
|
if (chain.length === 0) {
|
|
207
|
-
p.log.
|
|
208
|
-
console.log();
|
|
209
|
-
console.log(" Either:");
|
|
210
|
-
console.log(" 1. Run from a child branch (e.g., PR2)");
|
|
211
|
-
console.log(` 2. Use ${pc.green("gh-stack sync")} to include rebasing base onto main`);
|
|
298
|
+
p.log.info("No child branches to rebase — base branch ref reconciled above.");
|
|
212
299
|
return;
|
|
213
300
|
}
|
|
214
301
|
|
|
@@ -228,7 +315,130 @@ async function handleFreshRestack(
|
|
|
228
315
|
}
|
|
229
316
|
|
|
230
317
|
// Process the chain
|
|
231
|
-
await processChain(meta, stackName, chain, 0, rebasedBranches, dryRun, verbose);
|
|
318
|
+
await processChain(meta, stackName, chain, 0, rebasedBranches, touched, dryRun, verbose);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Re-root the current stack onto a new base (`restack --onto <newBase>`).
|
|
323
|
+
*
|
|
324
|
+
* Used to move a split stack off its parent-stack branch and onto the trunk
|
|
325
|
+
* once that parent stack has merged. The root branch is rebased with
|
|
326
|
+
* `git rebase --onto <newBase> <oldBaseTip> <root>`, where oldBaseTip is the
|
|
327
|
+
* tip of the current base branch — so only the root's own commits replay onto
|
|
328
|
+
* the new base (the now-merged parent commits are dropped). Children then
|
|
329
|
+
* restack onto the moved root via the normal chain logic.
|
|
330
|
+
*/
|
|
331
|
+
async function handleReroot(
|
|
332
|
+
meta: StackMetadata,
|
|
333
|
+
newBase: string,
|
|
334
|
+
rebasedBranches: string[],
|
|
335
|
+
touched: string[],
|
|
336
|
+
dryRun: boolean,
|
|
337
|
+
verbose: boolean,
|
|
338
|
+
): Promise<void> {
|
|
339
|
+
const currentBranch = await git.currentBranch();
|
|
340
|
+
const stackName = findStackForBranch(meta, currentBranch);
|
|
341
|
+
|
|
342
|
+
if (!stackName) {
|
|
343
|
+
p.cancel(`Branch ${pc.blue(currentBranch)} is not in any stack.`);
|
|
344
|
+
process.exit(1);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const stack = meta.stacks[stackName]!;
|
|
348
|
+
await ensureValidStack(meta, stackName);
|
|
349
|
+
|
|
350
|
+
const oldBase = stackBase(stack);
|
|
351
|
+
if (oldBase === newBase) {
|
|
352
|
+
p.log.warn(
|
|
353
|
+
`${pc.yellow(stackName)} is already based on ${pc.yellow(newBase)} — nothing to do.`,
|
|
354
|
+
);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// The root is the branch whose parent is the current base.
|
|
359
|
+
const ordered = getOrderedBranches(stack);
|
|
360
|
+
const root = ordered[0];
|
|
361
|
+
if (!root) {
|
|
362
|
+
p.cancel("Stack has no branches");
|
|
363
|
+
process.exit(1);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// The rebase base = the tip the root was built on (the old base branch).
|
|
367
|
+
// Prefer the live branch tip; fall back to merge-base if it's already gone.
|
|
368
|
+
let oldBaseTip: string | null = null;
|
|
369
|
+
let oldBaseSource = "old-base branch tip";
|
|
370
|
+
if (await git.localBranchExists(oldBase)) {
|
|
371
|
+
oldBaseTip = await git.revParse(oldBase);
|
|
372
|
+
} else {
|
|
373
|
+
oldBaseTip = await git.mergeBase(root, newBase);
|
|
374
|
+
oldBaseSource = "merge-base (old base branch is gone — verify the result)";
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (!oldBaseTip) {
|
|
378
|
+
p.cancel(
|
|
379
|
+
`Could not determine the old base for ${pc.yellow(root)}.\n\n Re-root manually with: ${pc.green(
|
|
380
|
+
`git rebase --onto ${newBase} <old-base> ${root}`,
|
|
381
|
+
)}`,
|
|
382
|
+
);
|
|
383
|
+
process.exit(1);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
p.log.info(`Re-rooting ${pc.yellow(stackName)}: ${pc.dim(oldBase)} → ${pc.green(newBase)}`);
|
|
387
|
+
p.log.info(`Root branch: ${pc.yellow(root)}`);
|
|
388
|
+
if (verbose) {
|
|
389
|
+
p.log.info(`Rebase base: ${oldBaseTip.slice(0, 8)} (${oldBaseSource})`);
|
|
390
|
+
}
|
|
391
|
+
console.log();
|
|
392
|
+
|
|
393
|
+
if (dryRun) {
|
|
394
|
+
p.log.warn(`[DRY RUN] Would rebase ${root} onto ${newBase} (from ${oldBaseTip.slice(0, 8)})`);
|
|
395
|
+
const children = ordered.slice(1);
|
|
396
|
+
if (children.length > 0) {
|
|
397
|
+
p.log.info(`[DRY RUN] Would restack ${children.length} child branch(es) onto the moved root`);
|
|
398
|
+
}
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const confirmed = await confirmAction(
|
|
403
|
+
`Re-root ${pc.yellow(stackName)} onto ${pc.yellow(newBase)}?`,
|
|
404
|
+
);
|
|
405
|
+
if (!confirmed) {
|
|
406
|
+
p.cancel("Cancelled");
|
|
407
|
+
process.exit(0);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Snapshot before rewriting, then commit the new base into metadata.
|
|
411
|
+
await takeSnapshot(meta, stackName, "reroot");
|
|
412
|
+
stack.base = newBase;
|
|
413
|
+
stack.branches[root]!.parent = newBase;
|
|
414
|
+
await writeMetadata(meta);
|
|
415
|
+
|
|
416
|
+
// Rebase the root onto the new base, dropping the old base's commits.
|
|
417
|
+
p.log.info(`Checking out ${pc.yellow(root)}...`);
|
|
418
|
+
await git.checkout(root);
|
|
419
|
+
p.log.info(`Rebasing ${pc.yellow(root)} onto ${pc.yellow(newBase)}...`);
|
|
420
|
+
const ok = await git.rebaseOnto(newBase, oldBaseTip, root);
|
|
421
|
+
|
|
422
|
+
if (!ok) {
|
|
423
|
+
console.log();
|
|
424
|
+
p.log.error("Rebase conflict — resolve and run:");
|
|
425
|
+
console.log(` ${pc.green("git rebase --continue")}`);
|
|
426
|
+
console.log(
|
|
427
|
+
` ${pc.green("gh-stack restack")} ${pc.dim("(from the first child, to restack the rest)")}`,
|
|
428
|
+
);
|
|
429
|
+
process.exit(2);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
rebasedBranches.push(root);
|
|
433
|
+
touched.push(root);
|
|
434
|
+
await pushBranchRef(root);
|
|
435
|
+
|
|
436
|
+
// Restack the children onto the moved root.
|
|
437
|
+
const children = ordered.slice(1);
|
|
438
|
+
if (children.length > 0) {
|
|
439
|
+
const chain = buildRebaseChain(stack, children[0]!);
|
|
440
|
+
await processChain(meta, stackName, chain, 0, rebasedBranches, touched, false, verbose);
|
|
441
|
+
}
|
|
232
442
|
}
|
|
233
443
|
|
|
234
444
|
async function processChain(
|
|
@@ -237,6 +447,7 @@ async function processChain(
|
|
|
237
447
|
chain: string[],
|
|
238
448
|
startIndex: number,
|
|
239
449
|
rebasedBranches: string[],
|
|
450
|
+
touched: string[],
|
|
240
451
|
dryRun: boolean,
|
|
241
452
|
verbose: boolean,
|
|
242
453
|
): Promise<void> {
|
|
@@ -258,9 +469,16 @@ async function processChain(
|
|
|
258
469
|
console.log(pc.cyan("━".repeat(40)));
|
|
259
470
|
console.log();
|
|
260
471
|
|
|
261
|
-
// Check if already up to date
|
|
472
|
+
// Check if already up to date. The branch doesn't need rebasing, but its
|
|
473
|
+
// remote ref may still be stale — e.g. this is the branch you just
|
|
474
|
+
// committed on, which triggered the restack. Push it so origin (and its
|
|
475
|
+
// PR) reflects the new commit. (#12)
|
|
262
476
|
if (await git.isAncestor(parent, branch)) {
|
|
263
477
|
p.log.success("Already up to date with parent");
|
|
478
|
+
if (!dryRun) {
|
|
479
|
+
touched.push(branch);
|
|
480
|
+
await pushBranchRef(branch);
|
|
481
|
+
}
|
|
264
482
|
continue;
|
|
265
483
|
}
|
|
266
484
|
|
|
@@ -352,10 +570,12 @@ async function processChain(
|
|
|
352
570
|
if (success) {
|
|
353
571
|
p.log.success("Rebase successful");
|
|
354
572
|
rebasedBranches.push(branch);
|
|
573
|
+
touched.push(branch);
|
|
355
574
|
await clearRestackState();
|
|
356
575
|
|
|
357
|
-
//
|
|
358
|
-
|
|
576
|
+
// Push immediately (don't batch) so an interrupt can't leave later
|
|
577
|
+
// branches un-pushed.
|
|
578
|
+
await pushBranchRef(branch);
|
|
359
579
|
} else {
|
|
360
580
|
// Conflict!
|
|
361
581
|
console.log();
|
|
@@ -376,36 +596,42 @@ async function processChain(
|
|
|
376
596
|
}
|
|
377
597
|
}
|
|
378
598
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
599
|
+
/**
|
|
600
|
+
* Reconcile a single branch's ref with origin. Pushes when the local tip
|
|
601
|
+
* differs from origin OR the remote branch doesn't exist yet (so a branch
|
|
602
|
+
* that was never pushed still lands). No-op when already in sync. (#12)
|
|
603
|
+
*/
|
|
604
|
+
async function pushBranchRef(branch: string): Promise<void> {
|
|
605
|
+
if (branch === "main" || branch === "master") return;
|
|
383
606
|
|
|
384
607
|
const localSha = await git.revParse(branch);
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
remoteSha = await git.revParse(`origin/${branch}`);
|
|
388
|
-
} catch {
|
|
389
|
-
return;
|
|
390
|
-
}
|
|
608
|
+
const hasRemote = await git.remoteBranchExists(branch);
|
|
609
|
+
const remoteSha = hasRemote ? await git.revParse(`origin/${branch}`).catch(() => null) : null;
|
|
391
610
|
|
|
392
|
-
if (
|
|
611
|
+
if (hasRemote && remoteSha === localSha) return; // already in sync — nothing to do
|
|
393
612
|
|
|
394
613
|
console.log();
|
|
395
614
|
console.log(pc.cyan("━".repeat(40)));
|
|
396
|
-
console.log(pc.blue(" Push Rebased Branch?"));
|
|
615
|
+
console.log(pc.blue(hasRemote ? " Push Rebased Branch?" : " Push New Branch?"));
|
|
397
616
|
console.log(pc.cyan("━".repeat(40)));
|
|
398
617
|
console.log();
|
|
399
618
|
console.log(`${pc.blue("Branch:")} ${branch}`);
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
619
|
+
if (hasRemote) {
|
|
620
|
+
console.log(pc.yellow("⚠ Branch is out of sync with remote"));
|
|
621
|
+
console.log(` Local: ${localSha.slice(0, 8)}`);
|
|
622
|
+
console.log(` Remote: ${remoteSha ? remoteSha.slice(0, 8) : "(unknown)"}`);
|
|
623
|
+
} else {
|
|
624
|
+
console.log(pc.yellow("⚠ Branch has no remote ref yet"));
|
|
625
|
+
console.log(` Local: ${localSha.slice(0, 8)}`);
|
|
626
|
+
}
|
|
403
627
|
console.log();
|
|
404
628
|
|
|
405
|
-
const confirmed = await confirmAction(
|
|
629
|
+
const confirmed = await confirmAction(
|
|
630
|
+
hasRemote ? "Push with --force-with-lease?" : `Push ${branch} to origin?`,
|
|
631
|
+
);
|
|
406
632
|
if (confirmed) {
|
|
407
633
|
p.log.info(`Pushing ${branch}...`);
|
|
408
|
-
const ok = await git.
|
|
634
|
+
const ok = await git.pushStackedBranch(branch);
|
|
409
635
|
if (ok) {
|
|
410
636
|
p.log.success("Pushed successfully");
|
|
411
637
|
} else {
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// gh-stack split — Cut a stack into two at a given branch.
|
|
2
|
+
//
|
|
3
|
+
// The branch you cut at (and everything above it) moves into a NEW stack whose
|
|
4
|
+
// base is the cut branch's parent — a branch that stays in the original stack.
|
|
5
|
+
// This is a pure metadata operation: no branches are moved or rebased in git,
|
|
6
|
+
// since stack membership is just bookkeeping on top of the real branch graph.
|
|
7
|
+
//
|
|
8
|
+
// Typical use: a long chain is in review and can't merge yet, but new work is
|
|
9
|
+
// piling on top. Split at the first "new work" branch so the original stack
|
|
10
|
+
// stays the review unit and the new stack rides on its tip. Once the original
|
|
11
|
+
// stack merges, re-root the new stack onto main with `gh-stack restack --onto main`.
|
|
12
|
+
import * as p from "../lib/output.ts";
|
|
13
|
+
import pc from "picocolors";
|
|
14
|
+
import { buildRebaseChain, getOrderedBranches, stackBase, writeMetadata } from "../lib/metadata.ts";
|
|
15
|
+
import { ensureMetadata, ensureCurrentStack, ensureValidStack } from "../lib/safety.ts";
|
|
16
|
+
import { selectBranch, confirmAction } from "../lib/ui.ts";
|
|
17
|
+
import * as git from "../lib/git.ts";
|
|
18
|
+
import type { Stack } from "../types.ts";
|
|
19
|
+
|
|
20
|
+
const HELP = `
|
|
21
|
+
gh-stack split — Cut a stack into two at a branch
|
|
22
|
+
|
|
23
|
+
USAGE
|
|
24
|
+
gh-stack split [<branch>] [options]
|
|
25
|
+
|
|
26
|
+
The branch you cut at — and every branch above it — moves into a new stack
|
|
27
|
+
whose base is the cut branch's parent (which stays in the original stack).
|
|
28
|
+
Purely a metadata operation: no git branches are moved or rebased.
|
|
29
|
+
|
|
30
|
+
If no branch is given, shows an interactive selector. You cannot split at the
|
|
31
|
+
stack's root branch (that would just rename the whole stack).
|
|
32
|
+
|
|
33
|
+
OPTIONS
|
|
34
|
+
--name <name> Name for the new stack (default: the cut branch name)
|
|
35
|
+
|
|
36
|
+
EXAMPLES
|
|
37
|
+
gh-stack split feat-12 # feat-12.. become a new stack based on feat-11
|
|
38
|
+
gh-stack split feat-12 --name phase-2 # ...named "phase-2"
|
|
39
|
+
|
|
40
|
+
AFTER THE ORIGINAL STACK MERGES
|
|
41
|
+
gh-stack restack --onto main # re-root the split stack onto main
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
export default async function split(args: string[]): Promise<void> {
|
|
45
|
+
if (args.includes("--help")) {
|
|
46
|
+
console.log(HELP);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Parse flags
|
|
51
|
+
let nameFlag: string | undefined;
|
|
52
|
+
for (let i = 0; i < args.length; i++) {
|
|
53
|
+
if (args[i] === "--name" && args[i + 1]) nameFlag = args[++i];
|
|
54
|
+
}
|
|
55
|
+
const positional = args.filter((a) => !a.startsWith("-"));
|
|
56
|
+
|
|
57
|
+
const meta = await ensureMetadata();
|
|
58
|
+
const stackName = ensureCurrentStack(meta);
|
|
59
|
+
await ensureValidStack(meta, stackName);
|
|
60
|
+
const stack = meta.stacks[stackName]!;
|
|
61
|
+
const currentBranch = await git.currentBranch();
|
|
62
|
+
const base = stackBase(stack);
|
|
63
|
+
|
|
64
|
+
p.intro(pc.cyan("Split Stack"));
|
|
65
|
+
|
|
66
|
+
// Determine the cut branch
|
|
67
|
+
let cutBranch = positional[0];
|
|
68
|
+
if (!cutBranch) {
|
|
69
|
+
cutBranch =
|
|
70
|
+
(await selectBranch(
|
|
71
|
+
stack,
|
|
72
|
+
"Select the branch to cut at (it becomes the new stack's root)",
|
|
73
|
+
currentBranch,
|
|
74
|
+
)) ?? undefined;
|
|
75
|
+
if (!cutBranch) {
|
|
76
|
+
p.cancel("Cancelled");
|
|
77
|
+
process.exit(0);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Validate
|
|
82
|
+
const cut = stack.branches[cutBranch];
|
|
83
|
+
if (!cut) {
|
|
84
|
+
p.cancel(`Branch ${pc.yellow(cutBranch)} not found in stack ${pc.blue(stackName)}`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (cut.parent === base) {
|
|
89
|
+
p.cancel(
|
|
90
|
+
`${pc.yellow(cutBranch)} is the root of ${pc.blue(stackName)} — there's nothing below it to split off.\n\n Pick a branch higher up the stack.`,
|
|
91
|
+
);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The new stack is the subtree rooted at the cut branch.
|
|
96
|
+
const moving = buildRebaseChain(stack, cutBranch);
|
|
97
|
+
const newBase = cut.parent; // stays in the original stack
|
|
98
|
+
|
|
99
|
+
// Pick a name for the new stack (default = cut branch, auto-suffix on clash)
|
|
100
|
+
let newName = nameFlag || cutBranch;
|
|
101
|
+
if (!nameFlag) {
|
|
102
|
+
let n = 2;
|
|
103
|
+
while (meta.stacks[newName]) newName = `${cutBranch}-${n++}`;
|
|
104
|
+
}
|
|
105
|
+
if (meta.stacks[newName]) {
|
|
106
|
+
p.cancel(`Stack "${newName}" already exists`);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Report the plan
|
|
111
|
+
p.log.info(`Stack: ${pc.yellow(stackName)} (base: ${pc.dim(base)})`);
|
|
112
|
+
p.log.info(`Cut at: ${pc.yellow(cutBranch)} (parent: ${pc.dim(newBase)})`);
|
|
113
|
+
p.log.info(
|
|
114
|
+
`Moving ${moving.length} branch${moving.length > 1 ? "es" : ""} into new stack ${pc.yellow(newName)} (base: ${pc.dim(newBase)})`,
|
|
115
|
+
);
|
|
116
|
+
for (const b of moving) {
|
|
117
|
+
console.log(` ${pc.dim("→")} ${b}`);
|
|
118
|
+
}
|
|
119
|
+
console.log();
|
|
120
|
+
|
|
121
|
+
const confirmed = await confirmAction(
|
|
122
|
+
`Split ${moving.length} branch(es) into ${pc.yellow(newName)} based on ${pc.yellow(newBase)}?`,
|
|
123
|
+
);
|
|
124
|
+
if (!confirmed) {
|
|
125
|
+
p.cancel("Cancelled");
|
|
126
|
+
process.exit(0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Pure metadata move ──
|
|
130
|
+
const newStack: Stack = {
|
|
131
|
+
description: stack.description ? `Split from ${stackName}` : "",
|
|
132
|
+
last_branch: null,
|
|
133
|
+
branches: {},
|
|
134
|
+
base: newBase,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
for (const b of moving) {
|
|
138
|
+
newStack.branches[b] = stack.branches[b]!; // parent links stay valid within the subtree
|
|
139
|
+
delete stack.branches[b];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
meta.stacks[newName] = newStack;
|
|
143
|
+
|
|
144
|
+
// Recompute last_branch for both stacks from their now-final graphs
|
|
145
|
+
newStack.last_branch = getOrderedBranches(newStack).at(-1) ?? null;
|
|
146
|
+
stack.last_branch = getOrderedBranches(stack).at(-1) ?? null;
|
|
147
|
+
|
|
148
|
+
// Follow the user: if they're standing on a moved branch, make the new stack
|
|
149
|
+
// current; otherwise leave the original stack current.
|
|
150
|
+
meta.current_stack = moving.includes(currentBranch) ? newName : stackName;
|
|
151
|
+
|
|
152
|
+
await writeMetadata(meta);
|
|
153
|
+
|
|
154
|
+
p.outro(
|
|
155
|
+
pc.green(
|
|
156
|
+
`Split ${pc.yellow(stackName)} → ${pc.yellow(newName)} (${moving.length} branch(es), based on ${pc.yellow(newBase)})`,
|
|
157
|
+
),
|
|
158
|
+
);
|
|
159
|
+
}
|
package/src/commands/status.ts
CHANGED
|
@@ -5,6 +5,7 @@ import * as git from "../lib/git.ts";
|
|
|
5
5
|
import {
|
|
6
6
|
readMetadata,
|
|
7
7
|
metadataExists,
|
|
8
|
+
legacyMetadataExists,
|
|
8
9
|
getOrderedBranches,
|
|
9
10
|
findStackForBranch,
|
|
10
11
|
} from "../lib/metadata.ts";
|
|
@@ -32,6 +33,12 @@ Progress/spinner output goes to stderr so stdout stays clean for piping.
|
|
|
32
33
|
const jsonMode = args.includes("--json");
|
|
33
34
|
const currentOnly = args.includes("--current");
|
|
34
35
|
|
|
36
|
+
// Read-only command, so don't hard-block — but if v2 metadata hasn't been
|
|
37
|
+
// migrated, stacks won't be grouped. Nudge to stderr (keeps stdout/JSON clean).
|
|
38
|
+
if (!(await metadataExists()) && (await legacyMetadataExists())) {
|
|
39
|
+
process.stderr.write("Note: stack metadata is unmigrated (v2). Run `gh-stack doctor`.\n");
|
|
40
|
+
}
|
|
41
|
+
|
|
35
42
|
// Progress goes to stderr so stdout is clean for agents
|
|
36
43
|
process.stderr.write("Fetching open PRs...\n");
|
|
37
44
|
|