gh-stack 0.19.6 → 0.19.8
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 -19
- package/package.json +1 -1
- package/src/commands/delete.ts +18 -3
- package/src/commands/doctor.ts +51 -6
- package/src/commands/merge.ts +97 -65
- package/src/commands/submit.ts +3 -3
- package/src/commands/sync.ts +68 -46
- package/src/lib/advance.ts +123 -87
- package/src/lib/metadata.ts +68 -24
- package/src/lib/safety.ts +10 -3
- package/src/lib/using-gh-stack.md +8 -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
|
|
|
@@ -268,9 +268,11 @@ sync [--dry-run] [--no-push|--push] [--resume | --fresh] [--overwrite-remote]
|
|
|
268
268
|
If the bottom PR(s) already merged outside gh-stack (e.g. the GitHub
|
|
269
269
|
web UI, or the base of a `merge --approved` landing with the PRs
|
|
270
270
|
collapsed into it), sync detects this and advances the stack past
|
|
271
|
-
them: it drops every merged branch and re-roots
|
|
272
|
-
|
|
273
|
-
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
|
|
274
276
|
warns about a merged PR whose changes never reached main (merged
|
|
275
277
|
into a branch after that branch's own PR had landed).
|
|
276
278
|
|
|
@@ -287,7 +289,9 @@ merge --base [--dry-run] [-d|--delete-branch] [--no-push|--push]
|
|
|
287
289
|
via GitHub (PR3 → PR2 → PR1), then enables auto-merge for the base PR
|
|
288
290
|
into main. The whole stack lands on main as a single squash commit.
|
|
289
291
|
All merges happen on GitHub so PRs show as "Merged", Linear tickets
|
|
290
|
-
close automatically, and GitHub Actions fire normally.
|
|
292
|
+
close automatically, and GitHub Actions fire normally. Squash commit
|
|
293
|
+
subjects leave out the ` (N/M)` stack position that `submit` adds to
|
|
294
|
+
PR titles. Skips already-
|
|
291
295
|
merged PRs (safe to re-run). Waits for GitHub between merges, and
|
|
292
296
|
self-heals its spurious "Head branch is out of date" (a stale PR
|
|
293
297
|
head pointer after a child squash) by re-syncing and retrying. Before
|
|
@@ -326,8 +330,9 @@ merge --base [--dry-run] [-d|--delete-branch] [--no-push|--push]
|
|
|
326
330
|
full merge.
|
|
327
331
|
--base Squash-merge ONLY the bottom PR into main as its own
|
|
328
332
|
commit, then re-root the next branch onto main (replaying
|
|
329
|
-
just its own commits).
|
|
330
|
-
|
|
333
|
+
just its own commits). If the bottom PR has several
|
|
334
|
+
children, each is re-rooted and becomes its own stack.
|
|
335
|
+
One PR per run — run it again to land the next. Approval is enforced by GitHub branch
|
|
331
336
|
protection, not gh-stack. A base PR already merged outside
|
|
332
337
|
gh-stack is detected and advanced past, along with
|
|
333
338
|
PRs already collapsed into it. The re-root waits
|
|
@@ -433,7 +438,8 @@ delete [<branch>] [-k|--keep-branch] [--no-remote]
|
|
|
433
438
|
delete the underlying local git branch (and the remote branch if
|
|
434
439
|
it was pushed). The <branch> argument names its own stack, so this
|
|
435
440
|
works from anywhere. Interactive selector (current stack) if none
|
|
436
|
-
specified.
|
|
441
|
+
specified. Deleting a stack's root that has several children leaves
|
|
442
|
+
them side by side on the base, so each becomes its own stack.
|
|
437
443
|
--keep-branch leaves the git branches untouched (metadata only);
|
|
438
444
|
--no-remote deletes the local branch but keeps the remote.
|
|
439
445
|
```
|
|
@@ -490,9 +496,12 @@ config [--json]
|
|
|
490
496
|
only). Read-only; edit the TOML files directly.
|
|
491
497
|
|
|
492
498
|
doctor
|
|
493
|
-
Migrate old (v2) metadata to the v3 layout,
|
|
494
|
-
|
|
495
|
-
|
|
499
|
+
Migrate old (v2) metadata to the v3 layout, split any stack left
|
|
500
|
+
with several root branches into one stack per root, reconcile git
|
|
501
|
+
branch config against the topology files, flag stacks whose base
|
|
502
|
+
stack appears already-merged into main, and report stacks that
|
|
503
|
+
still fail the checks other commands run before loading a stack.
|
|
504
|
+
Safe to run repeatedly.
|
|
496
505
|
|
|
497
506
|
learn [--skill] [--harness <name>] [--global] [--force]
|
|
498
507
|
Teach a coding agent how to drive gh-stack. By default prints the
|
|
@@ -553,10 +562,10 @@ remember to type them:
|
|
|
553
562
|
# .gh-stack.toml at the repo root (commit it), or
|
|
554
563
|
# ~/.config/gh-stack/config.toml for personal defaults (honors XDG_CONFIG_HOME)
|
|
555
564
|
[submit]
|
|
556
|
-
draft = true
|
|
565
|
+
draft = true # new PRs open as drafts; --no-draft overrides for one PR
|
|
557
566
|
|
|
558
567
|
[restack]
|
|
559
|
-
push = false
|
|
568
|
+
push = false # restack and sync stay local; --push overrides
|
|
560
569
|
```
|
|
561
570
|
|
|
562
571
|
Precedence, highest first: **CLI flag > env var > repo config > user config >
|
|
@@ -670,7 +679,7 @@ A single `active/<stack>.json` looks like:
|
|
|
670
679
|
}
|
|
671
680
|
```
|
|
672
681
|
|
|
673
|
-
Per-branch membership is
|
|
682
|
+
Per-branch membership is _also_ recorded in git's own config, so renaming or
|
|
674
683
|
deleting a branch updates/cleans it automatically:
|
|
675
684
|
|
|
676
685
|
```
|
package/package.json
CHANGED
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";
|
|
@@ -26,9 +25,11 @@ import type { Stack } from "../types.ts";
|
|
|
26
25
|
import { confirmAction, isAutoYes } from "../lib/ui.ts";
|
|
27
26
|
import { guardRemoteContent } from "../lib/remote-guard.ts";
|
|
28
27
|
import { ensureNoPendingRun } from "./restack.ts";
|
|
28
|
+
import { stripSeqSuffix } from "./update-prs.ts";
|
|
29
29
|
import {
|
|
30
30
|
planMerges,
|
|
31
|
-
|
|
31
|
+
advancePastMerged,
|
|
32
|
+
survivorsOf,
|
|
32
33
|
type BranchReview,
|
|
33
34
|
type MergeAction,
|
|
34
35
|
} from "../lib/advance.ts";
|
|
@@ -62,6 +63,24 @@ export function isTransientMergeError(stderr: string): boolean {
|
|
|
62
63
|
);
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The squash commit subject for a PR titled `title`, without the ` (N/M)`
|
|
68
|
+
* stack position `submit` adds to PR titles, which GitHub would otherwise put
|
|
69
|
+
* on the trunk's commit. Null when the title has no position, so GitHub's own
|
|
70
|
+
* "<title> (#N)" stands.
|
|
71
|
+
*/
|
|
72
|
+
export function squashSubject(title: string, prNumber: number): string | null {
|
|
73
|
+
const base = stripSeqSuffix(title);
|
|
74
|
+
return base === title.trimEnd() ? null : `${base} (#${prNumber})`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `gh pr merge` args setting the squash subject (see squashSubject); empty to keep GitHub's. */
|
|
78
|
+
async function squashSubjectArgs(prNumber: number): Promise<string[]> {
|
|
79
|
+
const title = (await getPrInfo(prNumber))?.title;
|
|
80
|
+
const subject = title ? squashSubject(title, prNumber) : null;
|
|
81
|
+
return subject ? ["--subject", subject] : [];
|
|
82
|
+
}
|
|
83
|
+
|
|
65
84
|
/**
|
|
66
85
|
* Squash-merge a PR, healing the two timing artifacts a top-down cascade
|
|
67
86
|
* provokes — neither of which is a real conflict:
|
|
@@ -87,8 +106,9 @@ async function squashMergeWithRetry(
|
|
|
87
106
|
s: ReturnType<typeof p.spinner>,
|
|
88
107
|
): Promise<{ ok: true } | { ok: false; stderr: string; genuine: boolean }> {
|
|
89
108
|
const maxAttempts = MERGE_RETRY_BACKOFF_MS.length + 1;
|
|
109
|
+
const subject = await squashSubjectArgs(prNumber);
|
|
90
110
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
91
|
-
const ghArgs = ["pr", "merge", String(prNumber), "--squash"];
|
|
111
|
+
const ghArgs = ["pr", "merge", String(prNumber), "--squash", ...subject];
|
|
92
112
|
if (deleteFlag) ghArgs.push("--delete-branch");
|
|
93
113
|
|
|
94
114
|
const proc = Bun.spawn(["gh", ...ghArgs], { stdout: "pipe", stderr: "pipe" });
|
|
@@ -423,7 +443,8 @@ OPTIONS
|
|
|
423
443
|
: "Single branch stack — enabling auto-merge on GitHub.",
|
|
424
444
|
);
|
|
425
445
|
if (!dryRun) {
|
|
426
|
-
const
|
|
446
|
+
const subject = await squashSubjectArgs(basePr);
|
|
447
|
+
const auto = await $`gh pr merge ${basePr} --squash --auto ${subject}`.nothrow().quiet();
|
|
427
448
|
if (auto.exitCode === 0) {
|
|
428
449
|
p.log.success(`Auto-merge enabled for PR #${basePr}`);
|
|
429
450
|
} else {
|
|
@@ -712,6 +733,7 @@ OPTIONS
|
|
|
712
733
|
|
|
713
734
|
const enableAutoMerge = async (): Promise<{ ok: boolean; stderr: string }> => {
|
|
714
735
|
const ghArgs = ["pr", "merge", String(basePr), "--squash", "--auto"];
|
|
736
|
+
if (basePr != null) ghArgs.push(...(await squashSubjectArgs(basePr)));
|
|
715
737
|
if (deleteFlag) ghArgs.push("--delete-branch");
|
|
716
738
|
const proc = Bun.spawn(["gh", ...ghArgs], { stdout: "pipe", stderr: "pipe" });
|
|
717
739
|
const stderr = await new Response(proc.stderr).text();
|
|
@@ -1032,7 +1054,8 @@ function resolveMergePush(args: string[]): Resolved {
|
|
|
1032
1054
|
/**
|
|
1033
1055
|
* `gh-stack merge --base` — squash-merge ONLY the bottom PR into main, then
|
|
1034
1056
|
* re-root the next branch onto main (replaying just its own commits, via
|
|
1035
|
-
* advance.ts's fork-point resolution)
|
|
1057
|
+
* advance.ts's fork-point resolution); every child, each its own stack, when
|
|
1058
|
+
* the bottom PR has several (#60). One PR per run — run it again to land the
|
|
1036
1059
|
* next. Approval is NOT checked here; GitHub branch protection enforces it.
|
|
1037
1060
|
* A base PR already merged outside gh-stack is detected and advanced past.
|
|
1038
1061
|
*/
|
|
@@ -1107,7 +1130,9 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
1107
1130
|
// own) go with it: dropped, never re-rooted, since replaying their commits
|
|
1108
1131
|
// onto a trunk that holds them squashed conflicts (#54).
|
|
1109
1132
|
const mergedAbove = await mergedRunAbove(stack, ordered);
|
|
1110
|
-
const
|
|
1133
|
+
const mergedRun = [baseBranch, ...mergedAbove];
|
|
1134
|
+
// Every unmerged branch on the run is re-rooted: one per child (#60).
|
|
1135
|
+
const survivors = survivorsOf(stack, mergedRun);
|
|
1111
1136
|
|
|
1112
1137
|
console.log();
|
|
1113
1138
|
console.log(` ${pc.bold("Plan:")}`);
|
|
@@ -1123,10 +1148,16 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
1123
1148
|
` ${pc.dim(`drop ${mergedAbove.join(", ")} (already merged into the branch below)`)}`,
|
|
1124
1149
|
);
|
|
1125
1150
|
}
|
|
1126
|
-
if (
|
|
1151
|
+
if (survivors.length === 1) {
|
|
1152
|
+
console.log(
|
|
1153
|
+
` ${pc.dim(`then re-root ${survivors[0]} onto ${trunk} — the rest stays a stack`)}`,
|
|
1154
|
+
);
|
|
1155
|
+
} else if (survivors.length > 1) {
|
|
1127
1156
|
console.log(
|
|
1128
|
-
` ${pc.dim(`then re-root ${
|
|
1157
|
+
` ${pc.dim(`then re-root ${survivors.join(", ")} onto ${trunk} — each becomes its own stack`)}`,
|
|
1129
1158
|
);
|
|
1159
|
+
}
|
|
1160
|
+
if (survivors.length > 0) {
|
|
1130
1161
|
if (!push) {
|
|
1131
1162
|
console.log(
|
|
1132
1163
|
` ${pc.dim(`(--no-push: re-rooted branches stay local; ${pc.green("gh-stack submit")} publishes them)`)}`,
|
|
@@ -1141,8 +1172,8 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
1141
1172
|
}
|
|
1142
1173
|
|
|
1143
1174
|
const confirmed = await confirmAction(
|
|
1144
|
-
|
|
1145
|
-
? `Merge base PR #${basePr} into ${trunk} and re-root ${
|
|
1175
|
+
survivors.length > 0
|
|
1176
|
+
? `Merge base PR #${basePr} into ${trunk} and re-root ${survivors.join(", ")}?`
|
|
1146
1177
|
: `Merge base PR #${basePr} into ${trunk}?`,
|
|
1147
1178
|
);
|
|
1148
1179
|
if (!confirmed) {
|
|
@@ -1201,12 +1232,11 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
1201
1232
|
await takeSnapshot(meta, stackName, "merge-base");
|
|
1202
1233
|
// Keep a copy of the original topology to archive if the stack fully drains.
|
|
1203
1234
|
const originalStack = structuredClone(stack);
|
|
1235
|
+
let split: string[] = [];
|
|
1204
1236
|
|
|
1205
1237
|
for (let i = 0; i < plan.steps.length; i++) {
|
|
1206
1238
|
const step = plan.steps[i]!;
|
|
1207
1239
|
const mergedBranch = step.branch;
|
|
1208
|
-
const child = nextChild;
|
|
1209
|
-
const childPr = child ? (stack.branches[child]?.pr ?? null) : null;
|
|
1210
1240
|
|
|
1211
1241
|
console.log();
|
|
1212
1242
|
console.log(pc.cyan("━".repeat(40)));
|
|
@@ -1253,7 +1283,7 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
1253
1283
|
p.log.info(`PR #${step.pr} already merged — advancing the stack past it.`);
|
|
1254
1284
|
}
|
|
1255
1285
|
|
|
1256
|
-
if (
|
|
1286
|
+
if (survivors.length > 0) {
|
|
1257
1287
|
// Re-root onto the trunk as it is now, with the merge in it (#51).
|
|
1258
1288
|
const trunkState = await fetchTrunkWithMerge(trunk, step.pr!);
|
|
1259
1289
|
if (!trunkState.ok) {
|
|
@@ -1266,56 +1296,53 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
1266
1296
|
} yet.`,
|
|
1267
1297
|
);
|
|
1268
1298
|
console.log(
|
|
1269
|
-
` Stopped before re-rooting ${pc.yellow(
|
|
1299
|
+
` Stopped before re-rooting ${pc.yellow(survivors.join(", "))}: a ${trunk} without the merge would drop its changes from the stack.`,
|
|
1270
1300
|
);
|
|
1271
1301
|
console.log(` Once ${trunk} has it, finish with:`);
|
|
1272
1302
|
console.log(` ${pc.green("gh-stack merge --base")}`);
|
|
1273
1303
|
process.exit(2);
|
|
1274
1304
|
}
|
|
1305
|
+
}
|
|
1275
1306
|
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1307
|
+
const res = await advancePastMerged({
|
|
1308
|
+
meta,
|
|
1309
|
+
stackName,
|
|
1310
|
+
merged: mergedRun,
|
|
1311
|
+
survivors: survivors.map((branch) => ({
|
|
1312
|
+
branch,
|
|
1313
|
+
pr: stack.branches[branch]?.pr ?? null,
|
|
1314
|
+
expect: verified.get(branch),
|
|
1315
|
+
})),
|
|
1316
|
+
targetRef,
|
|
1317
|
+
recordedBase: trunk,
|
|
1318
|
+
push,
|
|
1319
|
+
});
|
|
1320
|
+
if (!res.ok) {
|
|
1321
|
+
console.log();
|
|
1322
|
+
if (res.reason === "conflict") {
|
|
1323
|
+
p.log.error(`Rebase conflict re-rooting ${pc.yellow(res.child)} onto ${trunk}.`);
|
|
1324
|
+
console.log(` Resolve the conflict, then finish with:`);
|
|
1325
|
+
console.log(` ${pc.green("git rebase --continue")}`);
|
|
1326
|
+
console.log(
|
|
1327
|
+
` ${pc.green("gh-stack merge --base")} ${pc.dim(`(pushes ${res.child}, retargets its PR, restacks the rest)`)}`,
|
|
1328
|
+
);
|
|
1291
1329
|
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);
|
|
1330
|
+
console.log(
|
|
1331
|
+
pc.dim(
|
|
1332
|
+
` The merged PR(s) below ${res.child} are done; only the re-root needs finishing.`,
|
|
1333
|
+
),
|
|
1334
|
+
);
|
|
1335
|
+
} else {
|
|
1336
|
+
p.log.error(
|
|
1337
|
+
`Could not determine a safe rebase base for ${pc.yellow(res.child)} (${res.reason}).`,
|
|
1338
|
+
);
|
|
1339
|
+
console.log(
|
|
1340
|
+
` Re-root it manually:\n ${pc.green(`git rebase --onto ${targetRef} <merged-tip> ${res.child}`)}`,
|
|
1341
|
+
);
|
|
1317
1342
|
}
|
|
1343
|
+
process.exit(2);
|
|
1318
1344
|
}
|
|
1345
|
+
split = res.split;
|
|
1319
1346
|
}
|
|
1320
1347
|
|
|
1321
1348
|
// Did the whole stack drain, or is there a tail to restack?
|
|
@@ -1345,17 +1372,20 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
1345
1372
|
return;
|
|
1346
1373
|
}
|
|
1347
1374
|
|
|
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
|
-
|
|
1375
|
+
// Restack the surviving tail onto the newly re-rooted bottom, then each stack
|
|
1376
|
+
// split off beside it (#60). Each new bottom is already rebased onto main;
|
|
1377
|
+
// restack skips it (base branch) and propagates onto its children using the
|
|
1378
|
+
// snapshot taken above.
|
|
1379
|
+
const { default: restack } = await import("./restack.ts");
|
|
1380
|
+
for (const name of [stackName, ...split]) {
|
|
1381
|
+
const tail = meta.stacks[name] ? getOrderedBranches(meta.stacks[name]) : [];
|
|
1382
|
+
const newBottom = tail[0];
|
|
1383
|
+
if (!newBottom || !(await git.localBranchExists(newBottom))) continue;
|
|
1384
|
+
console.log();
|
|
1385
|
+
p.log.info(
|
|
1386
|
+
`Restacking ${pc.yellow(name)} (${tail.length} branch(es)) onto ${pc.yellow(newBottom)}...`,
|
|
1387
|
+
);
|
|
1357
1388
|
await git.checkout(newBottom);
|
|
1358
|
-
const { default: restack } = await import("./restack.ts");
|
|
1359
1389
|
// Push unless this run is local-only (--no-push / GH_STACK_NO_PUSH): the
|
|
1360
1390
|
// tail must match the re-root above. An explicit flag also means the
|
|
1361
1391
|
// `[restack] push` config isn't read mid-merge.
|
|
@@ -1364,7 +1394,9 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
1364
1394
|
|
|
1365
1395
|
p.outro(
|
|
1366
1396
|
pc.green(
|
|
1367
|
-
|
|
1397
|
+
split.length > 0
|
|
1398
|
+
? `Merged base PR #${basePr} into ${trunk}; the rest is now ${split.length + 1} stacks: ${[stackName, ...split].join(", ")}.`
|
|
1399
|
+
: `Merged base PR #${basePr} into ${trunk}; ${remaining.length} branch(es) left as a stack.`,
|
|
1368
1400
|
),
|
|
1369
1401
|
);
|
|
1370
1402
|
}
|
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/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
|
|
|
@@ -315,8 +315,10 @@ deletes them from the PR.
|
|
|
315
315
|
|
|
316
316
|
**`sync` is self-correcting:** if you merged the bottom PR in the GitHub UI, or
|
|
317
317
|
the base of a `merge --approved` landed with the PRs collapsed into it, `sync`
|
|
318
|
-
detects it, drops every merged branch, and re-roots
|
|
319
|
-
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
|
|
320
322
|
onto `origin/main` (fetched itself), so a stale local `main` doesn't matter.
|
|
321
323
|
|
|
322
324
|
### 5. Status
|
|
@@ -348,7 +350,8 @@ Merge is **top-down everywhere** (collapse PRn → PRn-1 → … → base PR, th
|
|
|
348
350
|
allow auto-merge, merge the base PR yourself once its checks pass
|
|
349
351
|
(`gh pr merge <N> --squash`), then `gh-stack sync`.
|
|
350
352
|
- **`merge --base`**: squash-merge **only the bottom PR** into trunk and re-root
|
|
351
|
-
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
|
|
352
355
|
land reviewed PRs one at a time. Auto-advances past a bottom PR already merged
|
|
353
356
|
outside gh-stack. If the re-root stops (a conflict, or "origin/main doesn't
|
|
354
357
|
show its merge commit"), fix it (`git rebase --continue`) and **re-run
|
|
@@ -377,13 +380,13 @@ cherry-pick them, don't force past it.
|
|
|
377
380
|
### 7. Maintenance
|
|
378
381
|
|
|
379
382
|
```bash
|
|
380
|
-
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)
|
|
381
384
|
gh-stack split # cut a stack into two at a branch
|
|
382
385
|
gh-stack join <b> # splice an existing branch/PR into the stack (--above <b>, --dry-run)
|
|
383
386
|
gh-stack rename # rename a tracked branch (keeps metadata in sync)
|
|
384
387
|
gh-stack undo # restore from the last snapshot
|
|
385
388
|
gh-stack archive # manage archived stacks
|
|
386
|
-
gh-stack doctor # migrate & repair stack metadata
|
|
389
|
+
gh-stack doctor # migrate & repair stack metadata (splits a stack with several roots)
|
|
387
390
|
gh-stack update-prs # repair stack viz in PR descriptions + adopt PRs opened with gh pr create
|
|
388
391
|
```
|
|
389
392
|
|