gh-stack 0.19.5 → 0.19.7
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 +28 -18
- package/package.json +1 -1
- package/src/commands/add.ts +2 -1
- package/src/commands/delete.ts +18 -3
- package/src/commands/doctor.ts +51 -6
- package/src/commands/merge.ts +73 -63
- package/src/commands/submit.ts +3 -3
- package/src/commands/sync.ts +68 -46
- package/src/lib/advance.ts +123 -87
- package/src/lib/chain.ts +19 -3
- package/src/lib/metadata.ts +68 -24
- package/src/lib/safety.ts +10 -3
- package/src/lib/using-gh-stack.md +13 -5
package/README.md
CHANGED
|
@@ -24,12 +24,12 @@ bun install -g gh-stack
|
|
|
24
24
|
|
|
25
25
|
## Terms
|
|
26
26
|
|
|
27
|
-
| Term
|
|
28
|
-
|
|
29
|
-
| **trunk**
|
|
30
|
-
| **stack**
|
|
31
|
-
| **upstack**
|
|
32
|
-
| **downstack** | Branches the current branch depends on (ancestors)
|
|
27
|
+
| Term | Definition |
|
|
28
|
+
| ------------- | ----------------------------------------------------- |
|
|
29
|
+
| **trunk** | Base branch of the repository (usually `main`) |
|
|
30
|
+
| **stack** | A chain of dependent branches |
|
|
31
|
+
| **upstack** | Branches that depend on the current branch (children) |
|
|
32
|
+
| **downstack** | Branches the current branch depends on (ancestors) |
|
|
33
33
|
|
|
34
34
|
## Quick Start
|
|
35
35
|
|
|
@@ -92,6 +92,9 @@ add [branch] [--parent <branch>]
|
|
|
92
92
|
stack — without pushing or opening a PR. Walks local ancestry: if the
|
|
93
93
|
branch sits on top of an existing stack it's adopted into it (parent
|
|
94
94
|
auto-detected); otherwise a new stack is created from the chain.
|
|
95
|
+
A PR that gh-stack already recorded for a branch in git config (in
|
|
96
|
+
another worktree of the repo) is kept, so a stack submitted from a
|
|
97
|
+
worktree can be tracked in the main checkout and merged from there.
|
|
95
98
|
|
|
96
99
|
--parent <branch>: stack it directly on <branch> (which it must
|
|
97
100
|
already be based on), tracking <branch> first if needed. Use this
|
|
@@ -265,9 +268,11 @@ sync [--dry-run] [--no-push|--push] [--resume | --fresh] [--overwrite-remote]
|
|
|
265
268
|
If the bottom PR(s) already merged outside gh-stack (e.g. the GitHub
|
|
266
269
|
web UI, or the base of a `merge --approved` landing with the PRs
|
|
267
270
|
collapsed into it), sync detects this and advances the stack past
|
|
268
|
-
them: it drops every merged branch and re-roots
|
|
269
|
-
|
|
270
|
-
instead of replaying the now-squashed commits and conflicting.
|
|
271
|
+
them: it drops every merged branch and re-roots each unmerged branch
|
|
272
|
+
sitting on them onto main with `--onto`, so only their own commits
|
|
273
|
+
replay, instead of replaying the now-squashed commits and conflicting.
|
|
274
|
+
When a merged branch had several children, each child (with the
|
|
275
|
+
branches above it) becomes its own stack and is synced in turn. It
|
|
271
276
|
warns about a merged PR whose changes never reached main (merged
|
|
272
277
|
into a branch after that branch's own PR had landed).
|
|
273
278
|
|
|
@@ -323,8 +328,9 @@ merge --base [--dry-run] [-d|--delete-branch] [--no-push|--push]
|
|
|
323
328
|
full merge.
|
|
324
329
|
--base Squash-merge ONLY the bottom PR into main as its own
|
|
325
330
|
commit, then re-root the next branch onto main (replaying
|
|
326
|
-
just its own commits).
|
|
327
|
-
|
|
331
|
+
just its own commits). If the bottom PR has several
|
|
332
|
+
children, each is re-rooted and becomes its own stack.
|
|
333
|
+
One PR per run — run it again to land the next. Approval is enforced by GitHub branch
|
|
328
334
|
protection, not gh-stack. A base PR already merged outside
|
|
329
335
|
gh-stack is detected and advanced past, along with
|
|
330
336
|
PRs already collapsed into it. The re-root waits
|
|
@@ -430,7 +436,8 @@ delete [<branch>] [-k|--keep-branch] [--no-remote]
|
|
|
430
436
|
delete the underlying local git branch (and the remote branch if
|
|
431
437
|
it was pushed). The <branch> argument names its own stack, so this
|
|
432
438
|
works from anywhere. Interactive selector (current stack) if none
|
|
433
|
-
specified.
|
|
439
|
+
specified. Deleting a stack's root that has several children leaves
|
|
440
|
+
them side by side on the base, so each becomes its own stack.
|
|
434
441
|
--keep-branch leaves the git branches untouched (metadata only);
|
|
435
442
|
--no-remote deletes the local branch but keeps the remote.
|
|
436
443
|
```
|
|
@@ -487,9 +494,12 @@ config [--json]
|
|
|
487
494
|
only). Read-only; edit the TOML files directly.
|
|
488
495
|
|
|
489
496
|
doctor
|
|
490
|
-
Migrate old (v2) metadata to the v3 layout,
|
|
491
|
-
|
|
492
|
-
|
|
497
|
+
Migrate old (v2) metadata to the v3 layout, split any stack left
|
|
498
|
+
with several root branches into one stack per root, reconcile git
|
|
499
|
+
branch config against the topology files, flag stacks whose base
|
|
500
|
+
stack appears already-merged into main, and report stacks that
|
|
501
|
+
still fail the checks other commands run before loading a stack.
|
|
502
|
+
Safe to run repeatedly.
|
|
493
503
|
|
|
494
504
|
learn [--skill] [--harness <name>] [--global] [--force]
|
|
495
505
|
Teach a coding agent how to drive gh-stack. By default prints the
|
|
@@ -550,10 +560,10 @@ remember to type them:
|
|
|
550
560
|
# .gh-stack.toml at the repo root (commit it), or
|
|
551
561
|
# ~/.config/gh-stack/config.toml for personal defaults (honors XDG_CONFIG_HOME)
|
|
552
562
|
[submit]
|
|
553
|
-
draft = true
|
|
563
|
+
draft = true # new PRs open as drafts; --no-draft overrides for one PR
|
|
554
564
|
|
|
555
565
|
[restack]
|
|
556
|
-
push = false
|
|
566
|
+
push = false # restack and sync stay local; --push overrides
|
|
557
567
|
```
|
|
558
568
|
|
|
559
569
|
Precedence, highest first: **CLI flag > env var > repo config > user config >
|
|
@@ -667,7 +677,7 @@ A single `active/<stack>.json` looks like:
|
|
|
667
677
|
}
|
|
668
678
|
```
|
|
669
679
|
|
|
670
|
-
Per-branch membership is
|
|
680
|
+
Per-branch membership is _also_ recorded in git's own config, so renaming or
|
|
671
681
|
deleting a branch updates/cleans it automatically:
|
|
672
682
|
|
|
673
683
|
```
|
package/package.json
CHANGED
package/src/commands/add.ts
CHANGED
|
@@ -38,7 +38,8 @@ OPTIONS
|
|
|
38
38
|
its stack and has no PR (e.g. a mistaken ${"`"}init${"`"} at trunk).
|
|
39
39
|
|
|
40
40
|
Network-free — nothing is pushed and no PR is opened. Run ${"`"}gh-stack submit${"`"} when
|
|
41
|
-
you're ready to publish.
|
|
41
|
+
you're ready to publish. A PR already recorded for the branch in git config
|
|
42
|
+
(by gh-stack in another worktree of this repo) is kept.
|
|
42
43
|
|
|
43
44
|
EXAMPLES
|
|
44
45
|
gh-stack add Track the current branch
|
package/src/commands/delete.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// gh-stack delete — Delete a branch from the stack (re-link parent chain)
|
|
2
2
|
import * as p from "../lib/output.ts";
|
|
3
3
|
import pc from "picocolors";
|
|
4
|
-
import { removeBranchFromStack, getChildren } from "../lib/metadata.ts";
|
|
4
|
+
import { removeBranchFromStack, getChildren, stackBase } from "../lib/metadata.ts";
|
|
5
5
|
import {
|
|
6
6
|
ensureMetadata,
|
|
7
7
|
ensureCurrentStack,
|
|
@@ -24,6 +24,9 @@ Removes the branch from the stack and deletes the underlying git branch,
|
|
|
24
24
|
re-parenting any children to the removed branch's parent. If the deleted
|
|
25
25
|
branch was pushed, its remote counterpart is deleted too.
|
|
26
26
|
|
|
27
|
+
Deleting a stack's root that has several children leaves them side by side on
|
|
28
|
+
the base, so each child (with the branches above it) becomes its own stack.
|
|
29
|
+
|
|
27
30
|
If no branch is specified, shows an interactive selector.
|
|
28
31
|
|
|
29
32
|
OPTIONS
|
|
@@ -84,6 +87,13 @@ OPTIONS
|
|
|
84
87
|
for (const child of children) {
|
|
85
88
|
console.log(` ${pc.dim("→")} ${child}`);
|
|
86
89
|
}
|
|
90
|
+
if (children.length > 1 && branch.parent === stackBase(stack)) {
|
|
91
|
+
p.log.info(
|
|
92
|
+
pc.dim(
|
|
93
|
+
`They'll sit side by side on ${branch.parent}, so each becomes its own stack (${children[0]} keeps ${stackName}).`,
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
87
97
|
}
|
|
88
98
|
|
|
89
99
|
if (keepBranch) {
|
|
@@ -116,8 +126,13 @@ OPTIONS
|
|
|
116
126
|
p.log.info(`Switched to ${pc.blue(fallback)} (was on the deleted branch)`);
|
|
117
127
|
}
|
|
118
128
|
|
|
119
|
-
// Remove from metadata (re-parents children, fixes last_branch)
|
|
120
|
-
await removeBranchFromStack(meta, stackName, branchName);
|
|
129
|
+
// Remove from metadata (re-parents children, fixes last_branch, splits siblings)
|
|
130
|
+
const split = await removeBranchFromStack(meta, stackName, branchName);
|
|
131
|
+
if (split.length > 0) {
|
|
132
|
+
p.log.success(
|
|
133
|
+
`Split off ${split.map((s) => pc.yellow(s)).join(", ")} beside ${pc.blue(stackName)}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
121
136
|
|
|
122
137
|
// Delete the actual git branch(es)
|
|
123
138
|
if (hasRemote) {
|
package/src/commands/doctor.ts
CHANGED
|
@@ -11,7 +11,10 @@ import {
|
|
|
11
11
|
v3Exists,
|
|
12
12
|
legacyMetadataExists,
|
|
13
13
|
stackBase,
|
|
14
|
+
stackRoots,
|
|
15
|
+
splitMultiRootStack,
|
|
14
16
|
} from "../lib/metadata.ts";
|
|
17
|
+
import { validateStack } from "../lib/safety.ts";
|
|
15
18
|
import { legacyMetadataPath, legacyRestackStatePath, restackStateFile } from "../lib/paths.ts";
|
|
16
19
|
import { writeSnapshotFile } from "../lib/snapshot.ts";
|
|
17
20
|
import { allBranchMemberships } from "../lib/branch-config.ts";
|
|
@@ -23,9 +26,11 @@ USAGE
|
|
|
23
26
|
gh-stack doctor
|
|
24
27
|
|
|
25
28
|
Migrates old (v2) metadata to the v3 layout (per-stack files under
|
|
26
|
-
.git/.gh-stack/ + git branch config),
|
|
27
|
-
|
|
28
|
-
already-merged
|
|
29
|
+
.git/.gh-stack/ + git branch config), splits any stack left with several
|
|
30
|
+
root branches into one stack per root, reconciles branch config against
|
|
31
|
+
the topology files, flags stacks whose base stack looks already-merged
|
|
32
|
+
into main, and reports stacks that still fail the checks other commands
|
|
33
|
+
run before they load a stack.
|
|
29
34
|
|
|
30
35
|
Safe to run repeatedly — it's idempotent.
|
|
31
36
|
`;
|
|
@@ -54,19 +59,59 @@ export default async function doctor(args: string[]): Promise<void> {
|
|
|
54
59
|
process.exit(1);
|
|
55
60
|
}
|
|
56
61
|
|
|
57
|
-
// ── 2.
|
|
62
|
+
// ── 2. Split stacks left with several roots (#60) ──
|
|
63
|
+
splitMultiRootStacks(meta);
|
|
64
|
+
|
|
65
|
+
// ── 3. Reconcile branch config ↔ topology (also writes the split) ──
|
|
58
66
|
await reconcile(meta);
|
|
59
67
|
|
|
60
|
-
// ──
|
|
68
|
+
// ── 4. Flag base-stack-merged situations (issue #13) ──
|
|
61
69
|
await flagMergedBaseStacks(meta);
|
|
62
70
|
|
|
63
|
-
|
|
71
|
+
// ── 5. Run the checks every command runs before it loads a stack ──
|
|
72
|
+
const invalid = await reportInvalidStacks(meta);
|
|
73
|
+
|
|
74
|
+
if (invalid > 0) {
|
|
75
|
+
p.outro(pc.yellow(`${invalid} stack(s) still need repair (see above).`));
|
|
76
|
+
} else if (migrated) {
|
|
64
77
|
p.outro(pc.green("Migration complete — metadata is now v3."));
|
|
65
78
|
} else {
|
|
66
79
|
p.outro(pc.green("Metadata looks healthy."));
|
|
67
80
|
}
|
|
68
81
|
}
|
|
69
82
|
|
|
83
|
+
// ── Multi-root repair (#60) ──
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A stack whose root left with several children has several roots, which every
|
|
87
|
+
* command refuses. Siblings on the same base are independent stacks, so give
|
|
88
|
+
* each its own: lossless, and only metadata changes. The caller writes.
|
|
89
|
+
*/
|
|
90
|
+
function splitMultiRootStacks(meta: StackMetadata): void {
|
|
91
|
+
for (const name of Object.keys(meta.stacks)) {
|
|
92
|
+
const roots = stackRoots(meta.stacks[name]!);
|
|
93
|
+
const split = splitMultiRootStack(meta, name);
|
|
94
|
+
if (split.length === 0) continue;
|
|
95
|
+
p.log.success(
|
|
96
|
+
`Split ${pc.yellow(name)}, which had ${roots.length} root branches: ${pc.yellow(roots[0]!)} stays, ${split.map((s) => pc.yellow(s)).join(", ")} split off`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Report the stacks that still fail validation; returns how many. */
|
|
102
|
+
async function reportInvalidStacks(meta: StackMetadata): Promise<number> {
|
|
103
|
+
let invalid = 0;
|
|
104
|
+
for (const name of Object.keys(meta.stacks)) {
|
|
105
|
+
const errors = await validateStack(meta, name);
|
|
106
|
+
if (errors.length === 0) continue;
|
|
107
|
+
invalid++;
|
|
108
|
+
console.log();
|
|
109
|
+
p.log.warn(`Stack ${pc.yellow(name)} is invalid:`);
|
|
110
|
+
for (const error of errors) console.log(` ${pc.red("•")} ${error}`);
|
|
111
|
+
}
|
|
112
|
+
return invalid;
|
|
113
|
+
}
|
|
114
|
+
|
|
70
115
|
// ── Migration ──
|
|
71
116
|
|
|
72
117
|
async function migrateIfNeeded(): Promise<boolean> {
|
package/src/commands/merge.ts
CHANGED
|
@@ -6,7 +6,6 @@ import * as git from "../lib/git.ts";
|
|
|
6
6
|
import {
|
|
7
7
|
findStackForBranch,
|
|
8
8
|
getOrderedBranches,
|
|
9
|
-
removeBranchFromStack,
|
|
10
9
|
stackBase,
|
|
11
10
|
writeMetadata,
|
|
12
11
|
} from "../lib/metadata.ts";
|
|
@@ -28,7 +27,8 @@ import { guardRemoteContent } from "../lib/remote-guard.ts";
|
|
|
28
27
|
import { ensureNoPendingRun } from "./restack.ts";
|
|
29
28
|
import {
|
|
30
29
|
planMerges,
|
|
31
|
-
|
|
30
|
+
advancePastMerged,
|
|
31
|
+
survivorsOf,
|
|
32
32
|
type BranchReview,
|
|
33
33
|
type MergeAction,
|
|
34
34
|
} from "../lib/advance.ts";
|
|
@@ -1032,7 +1032,8 @@ function resolveMergePush(args: string[]): Resolved {
|
|
|
1032
1032
|
/**
|
|
1033
1033
|
* `gh-stack merge --base` — squash-merge ONLY the bottom PR into main, then
|
|
1034
1034
|
* re-root the next branch onto main (replaying just its own commits, via
|
|
1035
|
-
* advance.ts's fork-point resolution)
|
|
1035
|
+
* advance.ts's fork-point resolution); every child, each its own stack, when
|
|
1036
|
+
* the bottom PR has several (#60). One PR per run — run it again to land the
|
|
1036
1037
|
* next. Approval is NOT checked here; GitHub branch protection enforces it.
|
|
1037
1038
|
* A base PR already merged outside gh-stack is detected and advanced past.
|
|
1038
1039
|
*/
|
|
@@ -1107,7 +1108,9 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
1107
1108
|
// own) go with it: dropped, never re-rooted, since replaying their commits
|
|
1108
1109
|
// onto a trunk that holds them squashed conflicts (#54).
|
|
1109
1110
|
const mergedAbove = await mergedRunAbove(stack, ordered);
|
|
1110
|
-
const
|
|
1111
|
+
const mergedRun = [baseBranch, ...mergedAbove];
|
|
1112
|
+
// Every unmerged branch on the run is re-rooted: one per child (#60).
|
|
1113
|
+
const survivors = survivorsOf(stack, mergedRun);
|
|
1111
1114
|
|
|
1112
1115
|
console.log();
|
|
1113
1116
|
console.log(` ${pc.bold("Plan:")}`);
|
|
@@ -1123,10 +1126,16 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
1123
1126
|
` ${pc.dim(`drop ${mergedAbove.join(", ")} (already merged into the branch below)`)}`,
|
|
1124
1127
|
);
|
|
1125
1128
|
}
|
|
1126
|
-
if (
|
|
1129
|
+
if (survivors.length === 1) {
|
|
1127
1130
|
console.log(
|
|
1128
|
-
` ${pc.dim(`then re-root ${
|
|
1131
|
+
` ${pc.dim(`then re-root ${survivors[0]} onto ${trunk} — the rest stays a stack`)}`,
|
|
1129
1132
|
);
|
|
1133
|
+
} else if (survivors.length > 1) {
|
|
1134
|
+
console.log(
|
|
1135
|
+
` ${pc.dim(`then re-root ${survivors.join(", ")} onto ${trunk} — each becomes its own stack`)}`,
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
1138
|
+
if (survivors.length > 0) {
|
|
1130
1139
|
if (!push) {
|
|
1131
1140
|
console.log(
|
|
1132
1141
|
` ${pc.dim(`(--no-push: re-rooted branches stay local; ${pc.green("gh-stack submit")} publishes them)`)}`,
|
|
@@ -1141,8 +1150,8 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
1141
1150
|
}
|
|
1142
1151
|
|
|
1143
1152
|
const confirmed = await confirmAction(
|
|
1144
|
-
|
|
1145
|
-
? `Merge base PR #${basePr} into ${trunk} and re-root ${
|
|
1153
|
+
survivors.length > 0
|
|
1154
|
+
? `Merge base PR #${basePr} into ${trunk} and re-root ${survivors.join(", ")}?`
|
|
1146
1155
|
: `Merge base PR #${basePr} into ${trunk}?`,
|
|
1147
1156
|
);
|
|
1148
1157
|
if (!confirmed) {
|
|
@@ -1201,12 +1210,11 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
1201
1210
|
await takeSnapshot(meta, stackName, "merge-base");
|
|
1202
1211
|
// Keep a copy of the original topology to archive if the stack fully drains.
|
|
1203
1212
|
const originalStack = structuredClone(stack);
|
|
1213
|
+
let split: string[] = [];
|
|
1204
1214
|
|
|
1205
1215
|
for (let i = 0; i < plan.steps.length; i++) {
|
|
1206
1216
|
const step = plan.steps[i]!;
|
|
1207
1217
|
const mergedBranch = step.branch;
|
|
1208
|
-
const child = nextChild;
|
|
1209
|
-
const childPr = child ? (stack.branches[child]?.pr ?? null) : null;
|
|
1210
1218
|
|
|
1211
1219
|
console.log();
|
|
1212
1220
|
console.log(pc.cyan("━".repeat(40)));
|
|
@@ -1253,7 +1261,7 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
1253
1261
|
p.log.info(`PR #${step.pr} already merged — advancing the stack past it.`);
|
|
1254
1262
|
}
|
|
1255
1263
|
|
|
1256
|
-
if (
|
|
1264
|
+
if (survivors.length > 0) {
|
|
1257
1265
|
// Re-root onto the trunk as it is now, with the merge in it (#51).
|
|
1258
1266
|
const trunkState = await fetchTrunkWithMerge(trunk, step.pr!);
|
|
1259
1267
|
if (!trunkState.ok) {
|
|
@@ -1266,56 +1274,53 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
1266
1274
|
} yet.`,
|
|
1267
1275
|
);
|
|
1268
1276
|
console.log(
|
|
1269
|
-
` Stopped before re-rooting ${pc.yellow(
|
|
1277
|
+
` Stopped before re-rooting ${pc.yellow(survivors.join(", "))}: a ${trunk} without the merge would drop its changes from the stack.`,
|
|
1270
1278
|
);
|
|
1271
1279
|
console.log(` Once ${trunk} has it, finish with:`);
|
|
1272
1280
|
console.log(` ${pc.green("gh-stack merge --base")}`);
|
|
1273
1281
|
process.exit(2);
|
|
1274
1282
|
}
|
|
1283
|
+
}
|
|
1275
1284
|
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1285
|
+
const res = await advancePastMerged({
|
|
1286
|
+
meta,
|
|
1287
|
+
stackName,
|
|
1288
|
+
merged: mergedRun,
|
|
1289
|
+
survivors: survivors.map((branch) => ({
|
|
1290
|
+
branch,
|
|
1291
|
+
pr: stack.branches[branch]?.pr ?? null,
|
|
1292
|
+
expect: verified.get(branch),
|
|
1293
|
+
})),
|
|
1294
|
+
targetRef,
|
|
1295
|
+
recordedBase: trunk,
|
|
1296
|
+
push,
|
|
1297
|
+
});
|
|
1298
|
+
if (!res.ok) {
|
|
1299
|
+
console.log();
|
|
1300
|
+
if (res.reason === "conflict") {
|
|
1301
|
+
p.log.error(`Rebase conflict re-rooting ${pc.yellow(res.child)} onto ${trunk}.`);
|
|
1302
|
+
console.log(` Resolve the conflict, then finish with:`);
|
|
1303
|
+
console.log(` ${pc.green("git rebase --continue")}`);
|
|
1304
|
+
console.log(
|
|
1305
|
+
` ${pc.green("gh-stack merge --base")} ${pc.dim(`(pushes ${res.child}, retargets its PR, restacks the rest)`)}`,
|
|
1306
|
+
);
|
|
1291
1307
|
console.log();
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
)
|
|
1303
|
-
|
|
1304
|
-
p.log.error(
|
|
1305
|
-
`Could not determine a safe rebase base for ${pc.yellow(child)} (${res.reason}).`,
|
|
1306
|
-
);
|
|
1307
|
-
console.log(
|
|
1308
|
-
` Re-root it manually:\n ${pc.green(`git rebase --onto ${targetRef} <merged-tip> ${child}`)}`,
|
|
1309
|
-
);
|
|
1310
|
-
}
|
|
1311
|
-
process.exit(2);
|
|
1312
|
-
}
|
|
1313
|
-
} else {
|
|
1314
|
-
// Nothing unmerged rides above it. Just drop the merged run.
|
|
1315
|
-
for (const branch of [mergedBranch, ...mergedAbove]) {
|
|
1316
|
-
await removeBranchFromStack(meta, stackName, branch);
|
|
1308
|
+
console.log(
|
|
1309
|
+
pc.dim(
|
|
1310
|
+
` The merged PR(s) below ${res.child} are done; only the re-root needs finishing.`,
|
|
1311
|
+
),
|
|
1312
|
+
);
|
|
1313
|
+
} else {
|
|
1314
|
+
p.log.error(
|
|
1315
|
+
`Could not determine a safe rebase base for ${pc.yellow(res.child)} (${res.reason}).`,
|
|
1316
|
+
);
|
|
1317
|
+
console.log(
|
|
1318
|
+
` Re-root it manually:\n ${pc.green(`git rebase --onto ${targetRef} <merged-tip> ${res.child}`)}`,
|
|
1319
|
+
);
|
|
1317
1320
|
}
|
|
1321
|
+
process.exit(2);
|
|
1318
1322
|
}
|
|
1323
|
+
split = res.split;
|
|
1319
1324
|
}
|
|
1320
1325
|
|
|
1321
1326
|
// Did the whole stack drain, or is there a tail to restack?
|
|
@@ -1345,17 +1350,20 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
1345
1350
|
return;
|
|
1346
1351
|
}
|
|
1347
1352
|
|
|
1348
|
-
// Restack the surviving tail onto the newly re-rooted bottom
|
|
1349
|
-
//
|
|
1350
|
-
// propagates onto its children using the
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1353
|
+
// Restack the surviving tail onto the newly re-rooted bottom, then each stack
|
|
1354
|
+
// split off beside it (#60). Each new bottom is already rebased onto main;
|
|
1355
|
+
// restack skips it (base branch) and propagates onto its children using the
|
|
1356
|
+
// snapshot taken above.
|
|
1357
|
+
const { default: restack } = await import("./restack.ts");
|
|
1358
|
+
for (const name of [stackName, ...split]) {
|
|
1359
|
+
const tail = meta.stacks[name] ? getOrderedBranches(meta.stacks[name]) : [];
|
|
1360
|
+
const newBottom = tail[0];
|
|
1361
|
+
if (!newBottom || !(await git.localBranchExists(newBottom))) continue;
|
|
1362
|
+
console.log();
|
|
1363
|
+
p.log.info(
|
|
1364
|
+
`Restacking ${pc.yellow(name)} (${tail.length} branch(es)) onto ${pc.yellow(newBottom)}...`,
|
|
1365
|
+
);
|
|
1357
1366
|
await git.checkout(newBottom);
|
|
1358
|
-
const { default: restack } = await import("./restack.ts");
|
|
1359
1367
|
// Push unless this run is local-only (--no-push / GH_STACK_NO_PUSH): the
|
|
1360
1368
|
// tail must match the re-root above. An explicit flag also means the
|
|
1361
1369
|
// `[restack] push` config isn't read mid-merge.
|
|
@@ -1364,7 +1372,9 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
1364
1372
|
|
|
1365
1373
|
p.outro(
|
|
1366
1374
|
pc.green(
|
|
1367
|
-
|
|
1375
|
+
split.length > 0
|
|
1376
|
+
? `Merged base PR #${basePr} into ${trunk}; the rest is now ${split.length + 1} stacks: ${[stackName, ...split].join(", ")}.`
|
|
1377
|
+
: `Merged base PR #${basePr} into ${trunk}; ${remaining.length} branch(es) left as a stack.`,
|
|
1368
1378
|
),
|
|
1369
1379
|
);
|
|
1370
1380
|
}
|
package/src/commands/submit.ts
CHANGED
|
@@ -393,13 +393,13 @@ async function runSubmit(args: string[], run: RunState, jsonMode: boolean): Prom
|
|
|
393
393
|
const outcomes = run.outcomes;
|
|
394
394
|
|
|
395
395
|
// The branch's PR: recorded in metadata, else looked up on GitHub. A PR
|
|
396
|
-
// found that way (opened outside gh-stack) is recorded for next time
|
|
396
|
+
// found that way (opened outside gh-stack) is recorded for next time, except
|
|
397
|
+
// in a dry run, which still looks so its plan doesn't offer to create it.
|
|
397
398
|
const findPr = async (name: string): Promise<number | null> => {
|
|
398
399
|
const bm = stack.branches[name]!;
|
|
399
400
|
if (bm.pr) return bm.pr;
|
|
400
|
-
if (dryRun) return null;
|
|
401
401
|
const found = await getPrNumber(name);
|
|
402
|
-
if (found) {
|
|
402
|
+
if (found && !dryRun) {
|
|
403
403
|
bm.pr = found;
|
|
404
404
|
metadataChanged = true;
|
|
405
405
|
}
|
package/src/commands/sync.ts
CHANGED
|
@@ -6,7 +6,6 @@ import * as git from "../lib/git.ts";
|
|
|
6
6
|
import {
|
|
7
7
|
findStackForBranch,
|
|
8
8
|
getOrderedBranches,
|
|
9
|
-
removeBranchFromStack,
|
|
10
9
|
stackBase,
|
|
11
10
|
writeMetadata,
|
|
12
11
|
} from "../lib/metadata.ts";
|
|
@@ -17,8 +16,9 @@ import { guardRemoteContent } from "../lib/remote-guard.ts";
|
|
|
17
16
|
import { getPrMergeState } from "../lib/github.ts";
|
|
18
17
|
import {
|
|
19
18
|
planMerges,
|
|
20
|
-
|
|
19
|
+
advancePastMerged,
|
|
21
20
|
strandedMerges,
|
|
21
|
+
survivorsOf,
|
|
22
22
|
type BranchReview,
|
|
23
23
|
type MergePlan,
|
|
24
24
|
type MergedRunEntry,
|
|
@@ -198,9 +198,29 @@ adds on GitHub). Pull or cherry-pick them first; see \`gh-stack restack --help\`
|
|
|
198
198
|
// need to find when restacking children. (See resolveRestackBoundary.)
|
|
199
199
|
await takeSnapshot(meta, stackName, "sync");
|
|
200
200
|
|
|
201
|
+
if (advance.split.length > 0) {
|
|
202
|
+
p.log.info(
|
|
203
|
+
`Split off ${advance.split.map((s) => pc.yellow(s)).join(", ")} — each is synced after ${pc.yellow(stackName)}. ` +
|
|
204
|
+
pc.dim(`If a run stops, finish it, then run gh-stack sync on the stacks it didn't reach.`),
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
201
208
|
// Rebase the root onto main, push it, then restack every branch above it —
|
|
202
209
|
// one run, saved after every step, so `restack --resume` can finish it.
|
|
203
210
|
await syncChain(meta, stackName, ordered, { trunkRef: "origin/main", push, verified });
|
|
211
|
+
|
|
212
|
+
// Stacks split off by the advance are already re-rooted onto the trunk, but
|
|
213
|
+
// the branches above their roots still sit on the old roots (#60).
|
|
214
|
+
for (const name of advance.split) {
|
|
215
|
+
const splitStack = meta.stacks[name];
|
|
216
|
+
if (!splitStack) continue;
|
|
217
|
+
await takeSnapshot(meta, name, "sync");
|
|
218
|
+
await syncChain(meta, name, getOrderedBranches(splitStack), {
|
|
219
|
+
trunkRef: "origin/main",
|
|
220
|
+
push,
|
|
221
|
+
verified,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
204
224
|
}
|
|
205
225
|
|
|
206
226
|
/**
|
|
@@ -257,6 +277,8 @@ interface AdvanceOutcome {
|
|
|
257
277
|
advanced: boolean;
|
|
258
278
|
/** The whole stack drained (every branch was already merged) and was archived. */
|
|
259
279
|
drained: boolean;
|
|
280
|
+
/** Stacks split off because several branches sat on the merged run (#60). */
|
|
281
|
+
split: string[];
|
|
260
282
|
}
|
|
261
283
|
|
|
262
284
|
/** The contiguous merged bottom branches, and how each one's PR merged. */
|
|
@@ -312,16 +334,15 @@ async function advanceMergedBottoms(
|
|
|
312
334
|
verified: Map<string, string>,
|
|
313
335
|
): Promise<AdvanceOutcome> {
|
|
314
336
|
const stack = meta.stacks[stackName]!;
|
|
315
|
-
const ordered = getOrderedBranches(stack);
|
|
316
337
|
const { plan, run } = merged;
|
|
317
338
|
|
|
318
339
|
if (plan.steps.length === 0) {
|
|
319
|
-
return { detected: false, advanced: false, drained: false };
|
|
340
|
+
return { detected: false, advanced: false, drained: false, split: [] };
|
|
320
341
|
}
|
|
321
342
|
|
|
322
343
|
const mergedBranches = plan.steps.map((step) => step.branch);
|
|
323
|
-
//
|
|
324
|
-
const
|
|
344
|
+
// Every unmerged branch sitting on the run: one per child of a merged branch.
|
|
345
|
+
const survivors = survivorsOf(stack, mergedBranches);
|
|
325
346
|
|
|
326
347
|
p.log.warn(`${plan.steps.length} bottom PR(s) already merged:`);
|
|
327
348
|
for (const step of plan.steps) {
|
|
@@ -329,17 +350,22 @@ async function advanceMergedBottoms(
|
|
|
329
350
|
}
|
|
330
351
|
console.log(
|
|
331
352
|
` ${pc.dim(
|
|
332
|
-
|
|
333
|
-
? `Dropping them
|
|
334
|
-
: `Dropping them
|
|
353
|
+
survivors.length === 0
|
|
354
|
+
? `Dropping them: nothing is left above them.`
|
|
355
|
+
: `Dropping them and re-rooting ${survivors.join(", ")} onto ${trunk}, so only their own commits replay.`,
|
|
335
356
|
)}`,
|
|
336
357
|
);
|
|
358
|
+
if (survivors.length > 1) {
|
|
359
|
+
console.log(
|
|
360
|
+
` ${pc.dim(`Each of ${survivors.join(", ")} becomes its own stack: siblings on ${trunk} don't depend on each other.`)}`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
337
363
|
console.log();
|
|
338
364
|
warnStranded(strandedMerges(run, trunk), trunk);
|
|
339
365
|
|
|
340
366
|
if (dryRun) {
|
|
341
367
|
p.log.warn("[DRY RUN] Would advance the stack past the merged bottom PR(s)");
|
|
342
|
-
return { detected: true, advanced: false, drained: false };
|
|
368
|
+
return { detected: true, advanced: false, drained: false, split: [] };
|
|
343
369
|
}
|
|
344
370
|
|
|
345
371
|
const targetRef = `origin/${trunk}`;
|
|
@@ -347,41 +373,37 @@ async function advanceMergedBottoms(
|
|
|
347
373
|
await takeSnapshot(meta, stackName, "sync-advance");
|
|
348
374
|
const originalStack = structuredClone(stack);
|
|
349
375
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
);
|
|
380
|
-
}
|
|
381
|
-
process.exit(2);
|
|
376
|
+
const res = await advancePastMerged({
|
|
377
|
+
meta,
|
|
378
|
+
stackName,
|
|
379
|
+
merged: mergedBranches,
|
|
380
|
+
survivors: survivors.map((branch) => ({
|
|
381
|
+
branch,
|
|
382
|
+
pr: stack.branches[branch]?.pr ?? null,
|
|
383
|
+
expect: verified.get(branch),
|
|
384
|
+
})),
|
|
385
|
+
targetRef,
|
|
386
|
+
recordedBase: trunk,
|
|
387
|
+
push: !noPush,
|
|
388
|
+
});
|
|
389
|
+
if (!res.ok) {
|
|
390
|
+
console.log();
|
|
391
|
+
if (res.reason === "conflict") {
|
|
392
|
+
p.log.error(`Rebase conflict re-rooting ${pc.yellow(res.child)} onto ${trunk}.`);
|
|
393
|
+
console.log(` Resolve it, then finish the sync:`);
|
|
394
|
+
console.log(` ${pc.green("git rebase --continue")}`);
|
|
395
|
+
console.log(
|
|
396
|
+
` ${pc.green("gh-stack sync")} ${pc.dim(`(picks up the re-rooted ${res.child}; not restack, which would put it back on ${res.mergedBranch})`)}`,
|
|
397
|
+
);
|
|
398
|
+
} else {
|
|
399
|
+
p.log.error(
|
|
400
|
+
`Could not determine a safe rebase base for ${pc.yellow(res.child)} (${res.reason}).`,
|
|
401
|
+
);
|
|
402
|
+
console.log(
|
|
403
|
+
` Re-root manually:\n ${pc.green(`git rebase --onto ${targetRef} <${res.mergedBranch}'s pre-merge tip> ${res.child}`)}`,
|
|
404
|
+
);
|
|
382
405
|
}
|
|
383
|
-
|
|
384
|
-
for (const branch of mergedBranches) await removeBranchFromStack(meta, stackName, branch);
|
|
406
|
+
process.exit(2);
|
|
385
407
|
}
|
|
386
408
|
|
|
387
409
|
// Whole stack drained? Archive the original topology and land on the trunk.
|
|
@@ -398,10 +420,10 @@ async function advanceMergedBottoms(
|
|
|
398
420
|
delete meta.stacks[stackName];
|
|
399
421
|
if (meta.current_stack === stackName) meta.current_stack = null;
|
|
400
422
|
await writeMetadata(meta);
|
|
401
|
-
return { detected: true, advanced: true, drained: true };
|
|
423
|
+
return { detected: true, advanced: true, drained: true, split: [] };
|
|
402
424
|
}
|
|
403
425
|
|
|
404
|
-
return { detected: true, advanced: true, drained: false };
|
|
426
|
+
return { detected: true, advanced: true, drained: false, split: res.split };
|
|
405
427
|
}
|
|
406
428
|
|
|
407
429
|
/**
|
package/src/lib/advance.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// `gh-stack merge --approved` or squash-merged outside the tool (the GitHub UI)
|
|
5
5
|
// — the rest of the stack must be *advanced*: drop the merged branch from the
|
|
6
6
|
// stack and re-root the new bottom onto `main`, replaying ONLY its own commits.
|
|
7
|
+
// A merged branch with several children leaves several new bottoms: each is
|
|
8
|
+
// re-rooted and becomes its own stack (#60).
|
|
7
9
|
//
|
|
8
10
|
// The subtle part is the rebase fork-point. After a squash-merge, `main` carries
|
|
9
11
|
// a brand-new commit that is NOT in the child's history, so `merge-base(child,
|
|
@@ -19,8 +21,8 @@
|
|
|
19
21
|
import * as p from "./output.ts";
|
|
20
22
|
import pc from "picocolors";
|
|
21
23
|
import * as git from "./git.ts";
|
|
22
|
-
import type { StackMetadata } from "../types.ts";
|
|
23
|
-
import { removeBranchFromStack, writeMetadata } from "./metadata.ts";
|
|
24
|
+
import type { Stack, StackMetadata } from "../types.ts";
|
|
25
|
+
import { getOrderedBranches, removeBranchFromStack, writeMetadata } from "./metadata.ts";
|
|
24
26
|
import { resolveRestackBoundary } from "./snapshot.ts";
|
|
25
27
|
import { setPrBase } from "./github.ts";
|
|
26
28
|
|
|
@@ -182,119 +184,153 @@ export async function resolveForkPoint(
|
|
|
182
184
|
return boundary?.sha ?? null;
|
|
183
185
|
}
|
|
184
186
|
|
|
185
|
-
/**
|
|
187
|
+
/** An unmerged branch sitting on a merged run: it gets re-rooted onto the trunk. */
|
|
188
|
+
export interface Survivor {
|
|
189
|
+
branch: string;
|
|
190
|
+
pr: number | null;
|
|
191
|
+
/**
|
|
192
|
+
* Its origin tip as the remote guard checked and cleared it: the push
|
|
193
|
+
* leases on exactly that sha (see git.forcePushWithLease).
|
|
194
|
+
*/
|
|
195
|
+
expect?: string;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The unmerged branches whose parent is in the merged run, parents first.
|
|
200
|
+
* Usually one, but a merged branch with several children leaves one per child,
|
|
201
|
+
* and each needs its own re-root (#60).
|
|
202
|
+
*/
|
|
203
|
+
export function survivorsOf(stack: Stack, merged: string[]): string[] {
|
|
204
|
+
const run = new Set(merged);
|
|
205
|
+
return getOrderedBranches(stack).filter((b) => !run.has(b) && run.has(stack.branches[b]!.parent));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Outcome of advancing a stack past a merged run. */
|
|
186
209
|
export type AdvanceResult =
|
|
187
|
-
| {
|
|
188
|
-
|
|
210
|
+
| {
|
|
211
|
+
ok: true;
|
|
212
|
+
/** Stacks split off because the run left several branches on the trunk (#60). */
|
|
213
|
+
split: string[];
|
|
214
|
+
}
|
|
215
|
+
| {
|
|
216
|
+
ok: false;
|
|
217
|
+
/** The survivor that couldn't be re-rooted, and the merged branch it sat on. */
|
|
218
|
+
child: string;
|
|
219
|
+
mergedBranch: string;
|
|
220
|
+
reason: "conflict" | "no-fork-point" | "bad-fork-point";
|
|
221
|
+
forkPoint?: string;
|
|
222
|
+
};
|
|
189
223
|
|
|
190
224
|
/**
|
|
191
|
-
* Re-root
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
* `recordedBase`.
|
|
225
|
+
* Re-root each survivor onto `targetRef`, dropping its merged parent's
|
|
226
|
+
* now-squashed commits, push it, and repoint its PR base to `recordedBase`.
|
|
227
|
+
* Then drop the whole merged run from the stack metadata. Survivors left side
|
|
228
|
+
* by side on the trunk become one stack each (see splitMultiRootStack).
|
|
196
229
|
*
|
|
197
230
|
* The caller MUST have taken a snapshot first — on a rebase conflict this leaves
|
|
198
231
|
* the in-progress `git rebase` for the user to resolve and returns `conflict`.
|
|
199
|
-
* Metadata still names the merged
|
|
200
|
-
*
|
|
201
|
-
* (`restack` would rebase
|
|
232
|
+
* Metadata still names the merged branches then, so the fix is to re-run the
|
|
233
|
+
* caller: survivors already on the target skip their rebase and finish the
|
|
234
|
+
* rest. (`restack` would rebase them back onto the merged branch.)
|
|
202
235
|
*/
|
|
203
|
-
export async function
|
|
236
|
+
export async function advancePastMerged(opts: {
|
|
204
237
|
meta: StackMetadata;
|
|
205
238
|
stackName: string;
|
|
206
|
-
/** The merged branch `child` sits on: its pre-merge tip is the fork point. */
|
|
207
|
-
mergedBranch: string;
|
|
208
239
|
/**
|
|
209
|
-
* The
|
|
210
|
-
*
|
|
240
|
+
* The merged run, every branch of it. Their commits are in the trunk, so
|
|
241
|
+
* they're dropped without being re-rooted themselves (#54).
|
|
211
242
|
*/
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
childPr: number | null;
|
|
243
|
+
merged: string[];
|
|
244
|
+
survivors: Survivor[];
|
|
215
245
|
targetRef: string;
|
|
216
246
|
recordedBase: string;
|
|
217
247
|
/** false = `--no-push`: rebase + metadata only, leave origin and the PR alone (#36). */
|
|
218
248
|
push?: boolean;
|
|
219
|
-
/**
|
|
220
|
-
* The child's origin tip as the remote guard checked and cleared it: the
|
|
221
|
-
* push leases on exactly that sha (see git.forcePushWithLease).
|
|
222
|
-
*/
|
|
223
|
-
expect?: string;
|
|
224
249
|
}): Promise<AdvanceResult> {
|
|
225
|
-
const { meta, stackName,
|
|
250
|
+
const { meta, stackName, targetRef, recordedBase } = opts;
|
|
226
251
|
const push = opts.push ?? true;
|
|
227
|
-
|
|
252
|
+
const prBases = new Map<string, string>();
|
|
228
253
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
if (await git.isAncestor(targetRef, child)) {
|
|
235
|
-
p.log.info(
|
|
236
|
-
`${pc.yellow(child)} is already on ${pc.green(recordedBase)} — finishing its re-root`,
|
|
237
|
-
);
|
|
238
|
-
} else {
|
|
239
|
-
const forkPoint = await resolveForkPoint(meta, mergedBranch, child);
|
|
240
|
-
if (!forkPoint) return { ok: false, reason: "no-fork-point" };
|
|
254
|
+
for (const survivor of opts.survivors) {
|
|
255
|
+
const child = survivor.branch;
|
|
256
|
+
const childPr = survivor.pr;
|
|
257
|
+
const mergedBranch = meta.stacks[stackName]!.branches[child]!.parent;
|
|
258
|
+
let retargeted = false;
|
|
241
259
|
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
260
|
+
// Already on the target: an earlier run stopped on a conflict the user then
|
|
261
|
+
// resolved, or the branch was re-rooted by hand (#51). Its fork point is
|
|
262
|
+
// behind it now, so skip the rebase and finish the rest (push, PR base,
|
|
263
|
+
// metadata). Before a re-root the child can't contain the target, which
|
|
264
|
+
// holds the merged PR's new squash commit.
|
|
265
|
+
if (await git.isAncestor(targetRef, child)) {
|
|
266
|
+
p.log.info(
|
|
267
|
+
`${pc.yellow(child)} is already on ${pc.green(recordedBase)} — finishing its re-root`,
|
|
268
|
+
);
|
|
269
|
+
} else {
|
|
270
|
+
const forkPoint = await resolveForkPoint(meta, mergedBranch, child);
|
|
271
|
+
if (!forkPoint) return { ok: false, child, mergedBranch, reason: "no-fork-point" };
|
|
247
272
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
273
|
+
// Guard: the fork-point must be an ancestor of the child, else `--onto` would
|
|
274
|
+
// replay an unrelated range. Bail loudly rather than mangle history.
|
|
275
|
+
if (!(await git.isAncestor(forkPoint, child))) {
|
|
276
|
+
return { ok: false, child, mergedBranch, reason: "bad-fork-point", forkPoint };
|
|
277
|
+
}
|
|
254
278
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
// If it didn't land, leave the base alone: retargeting onto the trunk with
|
|
258
|
-
// the old head on origin would show the merged commits in the PR (#29).
|
|
259
|
-
const pushed = await git.forcePushWithLease(child, { expect: opts.expect });
|
|
260
|
-
if (!pushed.ok) {
|
|
261
|
-
p.log.error(
|
|
262
|
-
`Push of ${pc.yellow(child)} failed — PR${childPr != null ? ` #${childPr}` : ""} left on ${mergedBranch} for now. The next push of ${child} that lands (gh-stack restack --resume, sync) retargets it.`,
|
|
279
|
+
p.log.info(
|
|
280
|
+
`Re-rooting ${pc.yellow(child)} onto ${pc.green(recordedBase)} ${pc.dim(`(base: ${forkPoint.slice(0, 8)})`)}`,
|
|
263
281
|
);
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
282
|
+
const ok = await git.rebaseOnto(targetRef, forkPoint, child);
|
|
283
|
+
if (!ok) return { ok: false, child, mergedBranch, reason: "conflict", forkPoint };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (push) {
|
|
287
|
+
// Push the rebased head first so the PR diff is clean before its base flips.
|
|
288
|
+
// If it didn't land, leave the base alone: retargeting onto the trunk with
|
|
289
|
+
// the old head on origin would show the merged commits in the PR (#29).
|
|
290
|
+
const pushed = await git.forcePushWithLease(child, { expect: survivor.expect });
|
|
291
|
+
if (!pushed.ok) {
|
|
292
|
+
p.log.error(
|
|
293
|
+
`Push of ${pc.yellow(child)} failed — PR${childPr != null ? ` #${childPr}` : ""} left on ${mergedBranch} for now. The next push of ${child} that lands (gh-stack restack --resume, sync) retargets it.`,
|
|
273
294
|
);
|
|
295
|
+
for (const line of git.pushFailureHint(child, pushed)) console.log(` ${line}`);
|
|
296
|
+
} else if (childPr != null) {
|
|
297
|
+
const moved = await setPrBase(childPr, recordedBase);
|
|
298
|
+
if (moved) {
|
|
299
|
+
retargeted = true;
|
|
300
|
+
p.log.success(`PR #${childPr} base → ${pc.green(recordedBase)}`);
|
|
301
|
+
} else {
|
|
302
|
+
p.log.warn(
|
|
303
|
+
`Could not retarget PR #${childPr} — the next push of ${child} retries, or set its base to ${recordedBase} manually`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
274
306
|
}
|
|
307
|
+
} else if (childPr != null) {
|
|
308
|
+
// Retargeting the base without pushing the rebased head would show the
|
|
309
|
+
// merged commits in the PR diff, so leave both for `submit` to reconcile.
|
|
310
|
+
p.log.info(
|
|
311
|
+
`Not pushing ${pc.yellow(child)} or retargeting PR #${childPr} (--no-push) — ${pc.green("gh-stack submit")} publishes both`,
|
|
312
|
+
);
|
|
275
313
|
}
|
|
276
|
-
} else if (childPr != null) {
|
|
277
|
-
// Retargeting the base without pushing the rebased head would show the
|
|
278
|
-
// merged commits in the PR diff, so leave both for `submit` to reconcile.
|
|
279
|
-
p.log.info(
|
|
280
|
-
`Not pushing ${pc.yellow(child)} or retargeting PR #${childPr} (--no-push) — ${pc.green("gh-stack submit")} publishes both`,
|
|
281
|
-
);
|
|
282
|
-
}
|
|
283
314
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
await removeBranchFromStack(meta, stackName, merged);
|
|
315
|
+
// Where the child's PR really points. Until it's retargeted it still
|
|
316
|
+
// targets the merged branch, and the next successful push of the child
|
|
317
|
+
// retargets it (restack's pushStep), so it's never left there.
|
|
318
|
+
if (childPr != null) prBases.set(child, retargeted ? recordedBase : mergedBranch);
|
|
289
319
|
}
|
|
290
320
|
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
321
|
+
// Metadata last: drop the merged run in one go (survivors move onto the
|
|
322
|
+
// trunk) and write. Done after the git work succeeds so a failure never
|
|
323
|
+
// leaves metadata describing a state git didn't reach.
|
|
324
|
+
const split = await removeBranchFromStack(meta, stackName, opts.merged);
|
|
325
|
+
|
|
326
|
+
if (prBases.size > 0) {
|
|
327
|
+
for (const stack of [stackName, ...split].map((name) => meta.stacks[name])) {
|
|
328
|
+
for (const [branch, prBase] of prBases) {
|
|
329
|
+
const entry = stack?.branches[branch];
|
|
330
|
+
if (entry) entry.prBase = prBase;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
297
333
|
await writeMetadata(meta);
|
|
298
334
|
}
|
|
299
|
-
return { ok: true };
|
|
335
|
+
return { ok: true, split };
|
|
300
336
|
}
|
package/src/lib/chain.ts
CHANGED
|
@@ -4,9 +4,25 @@
|
|
|
4
4
|
// auto-create/register a stack from a bare set of local branches so the
|
|
5
5
|
// end state is always stack registered + branches pushed + PRs created).
|
|
6
6
|
import * as git from "./git.ts";
|
|
7
|
+
import { allBranchMemberships, type BranchMembership } from "./branch-config.ts";
|
|
7
8
|
import { findStackForBranch, getOrderedBranches, stackBase } from "./metadata.ts";
|
|
8
9
|
import type { StackMetadata, Branch } from "../types.ts";
|
|
9
10
|
|
|
11
|
+
/**
|
|
12
|
+
* A new stack entry for a branch, keeping the PR number and id already
|
|
13
|
+
* recorded for it in git config. Git config is shared by every worktree of a
|
|
14
|
+
* repo, but stack files are not: a branch submitted in one worktree and then
|
|
15
|
+
* tracked in another (the dependency sweep's handoff) would otherwise lose its
|
|
16
|
+
* PR, and `sync` and `merge --base` would not see it merge.
|
|
17
|
+
*/
|
|
18
|
+
function newBranchEntry(parent: string, recorded: BranchMembership | undefined): Branch {
|
|
19
|
+
return {
|
|
20
|
+
parent,
|
|
21
|
+
...(recorded?.pr != null && { pr: recorded.pr }),
|
|
22
|
+
...(recorded?.id && { id: recorded.id }),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
10
26
|
/**
|
|
11
27
|
* Detect a chain of branches from trunk to the current branch.
|
|
12
28
|
*
|
|
@@ -107,6 +123,7 @@ export async function resolveOrCreateStack(
|
|
|
107
123
|
trunk: string,
|
|
108
124
|
): Promise<StackResolution> {
|
|
109
125
|
const fullChain = await detectBranchChain(branch, trunk); // trunk→current
|
|
126
|
+
const recorded = await allBranchMemberships();
|
|
110
127
|
|
|
111
128
|
// Walk from just below `branch` downward; the first tracked ancestor is the
|
|
112
129
|
// attachment point. Everything above it (up to `branch`) is the untracked
|
|
@@ -143,7 +160,7 @@ export async function resolveOrCreateStack(
|
|
|
143
160
|
}
|
|
144
161
|
|
|
145
162
|
if (stack.branches[name]) continue; // already tracked — leave as-is
|
|
146
|
-
stack.branches[name] =
|
|
163
|
+
stack.branches[name] = newBranchEntry(parent, recorded.get(name));
|
|
147
164
|
stack.last_branch = name;
|
|
148
165
|
addedBranches.push(name);
|
|
149
166
|
}
|
|
@@ -177,8 +194,7 @@ export async function resolveOrCreateStack(
|
|
|
177
194
|
const name = fullChain[i]!;
|
|
178
195
|
if (stack.branches[name]) continue;
|
|
179
196
|
const parent = i === 0 ? trunk : fullChain[i - 1]!;
|
|
180
|
-
|
|
181
|
-
stack.branches[name] = branchData;
|
|
197
|
+
stack.branches[name] = newBranchEntry(parent, recorded.get(name));
|
|
182
198
|
stack.last_branch = name;
|
|
183
199
|
addedBranches.push(name);
|
|
184
200
|
}
|
package/src/lib/metadata.ts
CHANGED
|
@@ -535,48 +535,92 @@ export async function addBranchToStack(
|
|
|
535
535
|
}
|
|
536
536
|
|
|
537
537
|
/**
|
|
538
|
-
* Remove
|
|
538
|
+
* Remove branches from a stack, re-parenting each one's children to its
|
|
539
|
+
* parent. If that leaves several branches on the stack's base (a removed root
|
|
540
|
+
* had more than one child), they're split into one stack each; see
|
|
541
|
+
* splitMultiRootStack. Returns the names of the stacks split off.
|
|
542
|
+
*
|
|
543
|
+
* Pass every branch that leaves at once (e.g. a whole merged run): splitting
|
|
544
|
+
* after each removal could move a branch still to be removed into another stack.
|
|
539
545
|
*/
|
|
540
546
|
export async function removeBranchFromStack(
|
|
541
547
|
meta: StackMetadata,
|
|
542
548
|
stackName: string,
|
|
543
|
-
|
|
544
|
-
): Promise<
|
|
549
|
+
branchNames: string | string[],
|
|
550
|
+
): Promise<string[]> {
|
|
545
551
|
const stack = meta.stacks[stackName];
|
|
546
552
|
if (!stack) {
|
|
547
553
|
throw new Error(`Stack "${stackName}" not found`);
|
|
548
554
|
}
|
|
549
555
|
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
556
|
+
for (const branchName of typeof branchNames === "string" ? [branchNames] : branchNames) {
|
|
557
|
+
const branch = stack.branches[branchName];
|
|
558
|
+
if (!branch) {
|
|
559
|
+
throw new Error(`Branch "${branchName}" not found in stack "${stackName}"`);
|
|
560
|
+
}
|
|
554
561
|
|
|
555
|
-
|
|
562
|
+
for (const b of Object.values(stack.branches)) {
|
|
563
|
+
if (b.parent === branchName) b.parent = branch.parent;
|
|
564
|
+
}
|
|
565
|
+
delete stack.branches[branchName];
|
|
556
566
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
567
|
+
// The removed branch may still exist in git but is no longer a member —
|
|
568
|
+
// drop its membership config explicitly (syncBranchConfig also would, but
|
|
569
|
+
// be direct since the branch is gone from `meta`).
|
|
570
|
+
await clearBranchMembership(branchName);
|
|
571
|
+
|
|
572
|
+
if (stack.last_branch === branchName) {
|
|
573
|
+
const remaining = Object.keys(stack.branches);
|
|
574
|
+
stack.last_branch = remaining.length > 0 ? remaining[remaining.length - 1]! : null;
|
|
561
575
|
}
|
|
562
576
|
}
|
|
563
577
|
|
|
564
|
-
|
|
565
|
-
|
|
578
|
+
const split = splitMultiRootStack(meta, stackName);
|
|
579
|
+
await writeMetadata(meta);
|
|
580
|
+
return split;
|
|
581
|
+
}
|
|
566
582
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
583
|
+
/** The branches sitting directly on the stack's base, in insertion order. */
|
|
584
|
+
export function stackRoots(stack: Stack): string[] {
|
|
585
|
+
const base = stackBase(stack);
|
|
586
|
+
return Object.keys(stack.branches).filter((b) => stack.branches[b]!.parent === base);
|
|
587
|
+
}
|
|
571
588
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
589
|
+
/**
|
|
590
|
+
* Give each root of a stack with several roots its own stack (#60). A stack has
|
|
591
|
+
* exactly one root, so this happens when a removed root had several children:
|
|
592
|
+
* siblings on the same base are independent stacks. The first root keeps the
|
|
593
|
+
* stack; each other root's subtree moves to a new stack named after that root
|
|
594
|
+
* (suffixed `-2`, `-3`… on a clash), on the same base. Metadata only: no git
|
|
595
|
+
* branch moves. The caller writes. Returns the new stacks' names.
|
|
596
|
+
*/
|
|
597
|
+
export function splitMultiRootStack(meta: StackMetadata, stackName: string): string[] {
|
|
598
|
+
const stack = meta.stacks[stackName];
|
|
599
|
+
if (!stack) return [];
|
|
600
|
+
const [, ...others] = stackRoots(stack);
|
|
601
|
+
const created: string[] = [];
|
|
602
|
+
|
|
603
|
+
for (const root of others) {
|
|
604
|
+
let name = root;
|
|
605
|
+
for (let n = 2; meta.stacks[name]; n++) name = `${root}-${n}`;
|
|
606
|
+
|
|
607
|
+
const moved: Stack = {
|
|
608
|
+
description: stack.description ? `Split from ${stackName}` : "",
|
|
609
|
+
last_branch: null,
|
|
610
|
+
branches: {},
|
|
611
|
+
base: stackBase(stack),
|
|
612
|
+
};
|
|
613
|
+
for (const b of buildRebaseChain(stack, root)) {
|
|
614
|
+
moved.branches[b] = stack.branches[b]!;
|
|
615
|
+
delete stack.branches[b];
|
|
616
|
+
}
|
|
617
|
+
moved.last_branch = getOrderedBranches(moved).at(-1) ?? null;
|
|
618
|
+
meta.stacks[name] = moved;
|
|
619
|
+
created.push(name);
|
|
576
620
|
}
|
|
577
621
|
|
|
578
|
-
|
|
579
|
-
return
|
|
622
|
+
if (created.length > 0) stack.last_branch = getOrderedBranches(stack).at(-1) ?? null;
|
|
623
|
+
return created;
|
|
580
624
|
}
|
|
581
625
|
|
|
582
626
|
/**
|
package/src/lib/safety.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
readMetadata,
|
|
7
7
|
findStackForBranch,
|
|
8
8
|
stackBase,
|
|
9
|
+
stackRoots,
|
|
9
10
|
} from "./metadata.ts";
|
|
10
11
|
import type { StackMetadata, Stack } from "../types.ts";
|
|
11
12
|
import * as p from "./output.ts";
|
|
@@ -219,9 +220,15 @@ export async function ensureValidStack(
|
|
|
219
220
|
console.log(` ${pc.red("•")} ${error}`);
|
|
220
221
|
}
|
|
221
222
|
console.log();
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
223
|
+
if (stackRoots(meta.stacks[stackName]!).length > 1) {
|
|
224
|
+
console.log(
|
|
225
|
+
pc.dim(`Run ${pc.green("gh-stack doctor")} to split it into one stack per root, then retry.`),
|
|
226
|
+
);
|
|
227
|
+
} else {
|
|
228
|
+
console.log(
|
|
229
|
+
pc.dim(`Repair the local branches, or run ${pc.green("gh-stack doctor")}, before retrying.`),
|
|
230
|
+
);
|
|
231
|
+
}
|
|
225
232
|
process.exit(1);
|
|
226
233
|
}
|
|
227
234
|
|
|
@@ -129,6 +129,11 @@ This resolves the catch-22 where `sync`/`restack` said "not in any stack" for a
|
|
|
129
129
|
tip you hadn't submitted yet. (`submit` still auto-tracks too — `add` is just the
|
|
130
130
|
no-publish path.)
|
|
131
131
|
|
|
132
|
+
**Handing a stack over from another worktree.** Stack files are per worktree,
|
|
133
|
+
but the PR numbers gh-stack records in git config are shared. So after
|
|
134
|
+
submitting from a worktree, `gh-stack add <top-branch>` in the main checkout
|
|
135
|
+
tracks the whole chain _with_ its PRs, and `sync`/`merge --base` work there.
|
|
136
|
+
|
|
132
137
|
**Splice an existing branch/PR into a stack — `gh-stack join`.** For a branch
|
|
133
138
|
that should have been stacked but was cut from main on its own (often with its
|
|
134
139
|
own PR already open):
|
|
@@ -310,8 +315,10 @@ deletes them from the PR.
|
|
|
310
315
|
|
|
311
316
|
**`sync` is self-correcting:** if you merged the bottom PR in the GitHub UI, or
|
|
312
317
|
the base of a `merge --approved` landed with the PRs collapsed into it, `sync`
|
|
313
|
-
detects it, drops every merged branch, and re-roots
|
|
314
|
-
onto trunk instead of replaying squashed commits and conflicting.
|
|
318
|
+
detects it, drops every merged branch, and re-roots each unmerged branch sitting
|
|
319
|
+
on them onto trunk instead of replaying squashed commits and conflicting. A
|
|
320
|
+
merged branch with several children (the dependency sweep's batch PR with one
|
|
321
|
+
breaking PR per upgrade) leaves one stack per child, and sync syncs each. It rebases
|
|
315
322
|
onto `origin/main` (fetched itself), so a stale local `main` doesn't matter.
|
|
316
323
|
|
|
317
324
|
### 5. Status
|
|
@@ -343,7 +350,8 @@ Merge is **top-down everywhere** (collapse PRn → PRn-1 → … → base PR, th
|
|
|
343
350
|
allow auto-merge, merge the base PR yourself once its checks pass
|
|
344
351
|
(`gh pr merge <N> --squash`), then `gh-stack sync`.
|
|
345
352
|
- **`merge --base`**: squash-merge **only the bottom PR** into trunk and re-root
|
|
346
|
-
the next branch onto trunk
|
|
353
|
+
the next branch onto trunk (every child, each as its own stack, when the
|
|
354
|
+
bottom PR has several) — one PR per run, re-run for the next. Use this to
|
|
347
355
|
land reviewed PRs one at a time. Auto-advances past a bottom PR already merged
|
|
348
356
|
outside gh-stack. If the re-root stops (a conflict, or "origin/main doesn't
|
|
349
357
|
show its merge commit"), fix it (`git rebase --continue`) and **re-run
|
|
@@ -372,13 +380,13 @@ cherry-pick them, don't force past it.
|
|
|
372
380
|
### 7. Maintenance
|
|
373
381
|
|
|
374
382
|
```bash
|
|
375
|
-
gh-stack delete # remove a branch from the stack (re-parents children)
|
|
383
|
+
gh-stack delete # remove a branch from the stack (re-parents children; a root's several children become one stack each)
|
|
376
384
|
gh-stack split # cut a stack into two at a branch
|
|
377
385
|
gh-stack join <b> # splice an existing branch/PR into the stack (--above <b>, --dry-run)
|
|
378
386
|
gh-stack rename # rename a tracked branch (keeps metadata in sync)
|
|
379
387
|
gh-stack undo # restore from the last snapshot
|
|
380
388
|
gh-stack archive # manage archived stacks
|
|
381
|
-
gh-stack doctor # migrate & repair stack metadata
|
|
389
|
+
gh-stack doctor # migrate & repair stack metadata (splits a stack with several roots)
|
|
382
390
|
gh-stack update-prs # repair stack viz in PR descriptions + adopt PRs opened with gh pr create
|
|
383
391
|
```
|
|
384
392
|
|