gh-stack 0.19.0 → 0.19.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -5
- package/package.json +1 -1
- package/src/commands/config.ts +1 -1
- package/src/commands/merge.ts +93 -11
- package/src/commands/sync.ts +4 -2
- package/src/index.ts +1 -1
- package/src/lib/advance.ts +37 -12
- package/src/lib/safety.ts +12 -4
- package/src/lib/using-gh-stack.md +9 -3
package/README.md
CHANGED
|
@@ -276,7 +276,7 @@ sync [--dry-run] [--no-push|--push] [--resume | --fresh] [--overwrite-remote]
|
|
|
276
276
|
|
|
277
277
|
merge [--dry-run] [-d|--delete-branch] [--collapse] [--skip-collapse-check]
|
|
278
278
|
merge --approved [--collapse] [--dry-run] [-d|--delete-branch]
|
|
279
|
-
merge --base [--dry-run] [-d|--delete-branch]
|
|
279
|
+
merge --base [--dry-run] [-d|--delete-branch] [--no-push|--push]
|
|
280
280
|
Default (top-down collapse): squash-merge the whole stack top-down
|
|
281
281
|
via GitHub (PR3 → PR2 → PR1), then enables auto-merge for the base PR
|
|
282
282
|
into main. The whole stack lands on main as a single squash commit.
|
|
@@ -323,7 +323,18 @@ merge --base [--dry-run] [-d|--delete-branch]
|
|
|
323
323
|
just its own commits). One PR per run — run it again to
|
|
324
324
|
land the next. Approval is enforced by GitHub branch
|
|
325
325
|
protection, not gh-stack. A base PR already merged outside
|
|
326
|
-
gh-stack is detected and advanced past.
|
|
326
|
+
gh-stack is detected and advanced past. The re-root waits
|
|
327
|
+
until origin's main shows the merge commit, and stops (exit
|
|
328
|
+
2) if it never does. When a re-root stops (a conflict, or
|
|
329
|
+
main not showing the merge yet), finish it by re-running
|
|
330
|
+
`gh-stack merge --base` after `git rebase --continue`: it
|
|
331
|
+
advances past the merged PR and picks up a branch that's
|
|
332
|
+
already re-rooted. Don't run `restack` there: it would put
|
|
333
|
+
the branch back on the merged one.
|
|
334
|
+
--no-push (or GH_STACK_NO_PUSH=1) still merges the PR on
|
|
335
|
+
GitHub but keeps the re-root and the restack above it
|
|
336
|
+
local, PR bases included, until `gh-stack submit`. The
|
|
337
|
+
`[restack] push` config default doesn't apply to merge.
|
|
327
338
|
--collapse With the default or --approved, stop after collapsing into
|
|
328
339
|
the base PR WITHOUT merging it to main, so you can review
|
|
329
340
|
the cumulative diff on GitHub. Re-run `gh-stack merge` to
|
|
@@ -520,7 +531,7 @@ If a command needs to prompt but stdin isn't a TTY, it exits 1 with the prompt i
|
|
|
520
531
|
```
|
|
521
532
|
GH_STACK_YES=1 Skip all confirmations (same as --yes; also enables --plain)
|
|
522
533
|
GH_STACK_PLAIN=1 Plain output (same as --plain)
|
|
523
|
-
GH_STACK_NO_PUSH=1 restack
|
|
534
|
+
GH_STACK_NO_PUSH=1 restack, sync and merge --base never push (same as --no-push; wins over --yes; 0 = push)
|
|
524
535
|
GH_STACK_DRAFT=1 submit creates new PRs as drafts (same as --draft; 0 = ready)
|
|
525
536
|
(both accept 1/0, true/false, yes/no; anything else is an error)
|
|
526
537
|
GH_STACK_NO_COLOR=1 Disable colored output
|
|
@@ -552,8 +563,10 @@ says so in its output.
|
|
|
552
563
|
| `[restack] push` | `--push` / `--no-push` | `GH_STACK_NO_PUSH` (inverted) | `true` |
|
|
553
564
|
|
|
554
565
|
`[restack] push` governs `restack` and `sync` only. `merge` and
|
|
555
|
-
`submit --restack`
|
|
556
|
-
job, and child PRs left stale on GitHub would be worse
|
|
566
|
+
`submit --restack` push the branches they restack whatever the config says:
|
|
567
|
+
publishing is their job, and child PRs left stale on GitHub would be worse
|
|
568
|
+
than any default. Only `merge --base` can be kept local, and only by an
|
|
569
|
+
explicit `--no-push` or `GH_STACK_NO_PUSH=1`. A
|
|
557
570
|
`restack --resume` keeps the push decision of the run it resumes unless you
|
|
558
571
|
pass `--push` or `--no-push`.
|
|
559
572
|
|
package/package.json
CHANGED
package/src/commands/config.ts
CHANGED
|
@@ -38,7 +38,7 @@ FILES
|
|
|
38
38
|
|
|
39
39
|
ENVIRONMENT (1/0, true/false, or yes/no)
|
|
40
40
|
GH_STACK_DRAFT submit draft default
|
|
41
|
-
GH_STACK_NO_PUSH restack/sync push default (1 = don't push)
|
|
41
|
+
GH_STACK_NO_PUSH restack/sync/merge --base push default (1 = don't push)
|
|
42
42
|
|
|
43
43
|
--yes is not configurable from a file: auto-confirming destructive operations
|
|
44
44
|
must be asked for per run (--yes or GH_STACK_YES=1).
|
package/src/commands/merge.ts
CHANGED
|
@@ -32,10 +32,15 @@ import {
|
|
|
32
32
|
type BranchReview,
|
|
33
33
|
type MergeAction,
|
|
34
34
|
} from "../lib/advance.ts";
|
|
35
|
+
import { flagSetting, parseEnvBool, type Resolved } from "../lib/config.ts";
|
|
35
36
|
|
|
36
37
|
const MAX_RETRIES = 12;
|
|
37
38
|
const RETRY_DELAY_MS = 5000;
|
|
38
39
|
|
|
40
|
+
// Waits between fetches while origin's trunk catches up to a merge GitHub has
|
|
41
|
+
// already reported (one entry per retry; ~7s in all).
|
|
42
|
+
const TRUNK_MERGE_WAIT_MS = [1000, 2000, 4000];
|
|
43
|
+
|
|
39
44
|
// After GitHub rejects a merge with the "still recomputing" signature, back off
|
|
40
45
|
// (capped) and re-poll before retrying. One entry per retry; length+1 = max
|
|
41
46
|
// attempts. Bounds total transient wait per PR to ~30s.
|
|
@@ -260,7 +265,7 @@ gh-stack merge — Squash-merge a stack via GitHub
|
|
|
260
265
|
USAGE
|
|
261
266
|
gh-stack merge [--dry-run] [-d|--delete-branch] [--collapse]
|
|
262
267
|
gh-stack merge --approved [--collapse] [--dry-run] [-d|--delete-branch]
|
|
263
|
-
gh-stack merge --base [--dry-run] [-d|--delete-branch]
|
|
268
|
+
gh-stack merge --base [--dry-run] [-d|--delete-branch] [--no-push|--push]
|
|
264
269
|
|
|
265
270
|
DEFAULT (top-down collapse)
|
|
266
271
|
Squash-merges the whole stack from top to bottom via GitHub:
|
|
@@ -280,6 +285,11 @@ DEFAULT (top-down collapse)
|
|
|
280
285
|
Squash-merges only the bottom PR into main as its own commit, then re-roots
|
|
281
286
|
the next branch onto main. One PR per run — run it again to land the next.
|
|
282
287
|
Approval is enforced by GitHub branch protection, not gh-stack.
|
|
288
|
+
With --no-push (or GH_STACK_NO_PUSH=1) the PR still merges on GitHub, but
|
|
289
|
+
the re-rooted branches stay local until ${pc.green("gh-stack submit")}. The
|
|
290
|
+
\`[restack] push\` config default doesn't apply to merge. If the re-root
|
|
291
|
+
stops (a conflict, or main not showing the merge yet), finish the fix, then
|
|
292
|
+
re-run ${pc.green("gh-stack merge --base")}: it picks up the already-merged PR.
|
|
283
293
|
|
|
284
294
|
--collapse (stop before main)
|
|
285
295
|
With the default or --approved, stop after collapsing into the base PR
|
|
@@ -313,6 +323,8 @@ OPTIONS
|
|
|
313
323
|
--skip-collapse-check
|
|
314
324
|
Don't verify the collapsed children's content is in
|
|
315
325
|
the base PR (above)
|
|
326
|
+
--no-push With --base: re-root and restack locally only (also
|
|
327
|
+
GH_STACK_NO_PUSH=1); --push overrides the variable
|
|
316
328
|
--dry-run Show what would happen without doing anything
|
|
317
329
|
`);
|
|
318
330
|
return;
|
|
@@ -948,6 +960,43 @@ async function collapsedChildren(
|
|
|
948
960
|
return { collapsed, unknown: null };
|
|
949
961
|
}
|
|
950
962
|
|
|
963
|
+
/**
|
|
964
|
+
* Fetch the trunk and confirm it holds PR `prNumber`'s merge commit (#51).
|
|
965
|
+
* The re-root rebases onto `origin/<trunk>`; a trunk fetched before the squash
|
|
966
|
+
* landed puts the next branch on a main without the merged PR's changes, so
|
|
967
|
+
* its PR shows them deleted. Not ok when the merge commit can't be confirmed
|
|
968
|
+
* on the trunk, and the caller must stop rather than re-root.
|
|
969
|
+
*/
|
|
970
|
+
async function fetchTrunkWithMerge(
|
|
971
|
+
trunk: string,
|
|
972
|
+
prNumber: number,
|
|
973
|
+
): Promise<{ ok: boolean; mergeCommit: string | null }> {
|
|
974
|
+
let mergeCommit: string | null = null;
|
|
975
|
+
for (let attempt = 0; ; attempt++) {
|
|
976
|
+
mergeCommit ??= (await getPrMergeState(prNumber))?.mergeCommit ?? null;
|
|
977
|
+
await $`git fetch origin ${trunk}`.nothrow().quiet();
|
|
978
|
+
if (mergeCommit && (await git.isAncestor(mergeCommit, `origin/${trunk}`))) {
|
|
979
|
+
return { ok: true, mergeCommit };
|
|
980
|
+
}
|
|
981
|
+
if (attempt >= TRUNK_MERGE_WAIT_MS.length) return { ok: false, mergeCommit };
|
|
982
|
+
await Bun.sleep(TRUNK_MERGE_WAIT_MS[attempt]!);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Whether `merge --base` pushes the branches it re-roots and restacks:
|
|
988
|
+
* `--push`/`--no-push`, else GH_STACK_NO_PUSH (#51). The squash itself always
|
|
989
|
+
* lands on GitHub. The `[restack] push` config default deliberately doesn't
|
|
990
|
+
* apply: a repo default for restack shouldn't change what a merge publishes.
|
|
991
|
+
*/
|
|
992
|
+
function resolveMergePush(args: string[]): Resolved {
|
|
993
|
+
const flag = flagSetting("restack.push", args);
|
|
994
|
+
if (flag) return flag;
|
|
995
|
+
const noPush = parseEnvBool("GH_STACK_NO_PUSH", process.env.GH_STACK_NO_PUSH);
|
|
996
|
+
if (noPush === undefined) return { value: true, source: "default" };
|
|
997
|
+
return { value: !noPush, source: "env", origin: "GH_STACK_NO_PUSH" };
|
|
998
|
+
}
|
|
999
|
+
|
|
951
1000
|
/**
|
|
952
1001
|
* `gh-stack merge --base` — squash-merge ONLY the bottom PR into main, then
|
|
953
1002
|
* re-root the next branch onto main (replaying just its own commits, via
|
|
@@ -974,7 +1023,17 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
974
1023
|
}
|
|
975
1024
|
|
|
976
1025
|
const stack = meta.stacks[stackName]!;
|
|
977
|
-
|
|
1026
|
+
const base = stackBase(stack);
|
|
1027
|
+
// The bottom branch may be gone locally once its PR merged: --delete-branch
|
|
1028
|
+
// deletes it, and a re-run then only finishes the re-root (#51).
|
|
1029
|
+
const root = Object.keys(stack.branches).find((b) => stack.branches[b]!.parent === base);
|
|
1030
|
+
const rootPr = root ? stack.branches[root]!.pr : undefined;
|
|
1031
|
+
const rootGone =
|
|
1032
|
+
root != null &&
|
|
1033
|
+
rootPr != null &&
|
|
1034
|
+
!(await git.localBranchExists(root)) &&
|
|
1035
|
+
(await getPrMergeState(rootPr))?.state === "MERGED";
|
|
1036
|
+
await ensureValidStack(meta, stackName, { allowMissing: rootGone ? [root] : [] });
|
|
978
1037
|
const ordered = getOrderedBranches(stack);
|
|
979
1038
|
if (ordered.length === 0) {
|
|
980
1039
|
p.cancel("No branches in this stack");
|
|
@@ -983,7 +1042,6 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
983
1042
|
|
|
984
1043
|
// Like top-down merge: a split stack rooted off another stack can't drain to
|
|
985
1044
|
// main until it's re-rooted. Require a trunk base.
|
|
986
|
-
const base = stackBase(stack);
|
|
987
1045
|
if (base !== "main" && base !== "master") {
|
|
988
1046
|
p.cancel(
|
|
989
1047
|
`${pc.yellow(stackName)} is based on ${pc.yellow(base)}, not main — can't merge yet.\n\n` +
|
|
@@ -994,6 +1052,7 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
994
1052
|
}
|
|
995
1053
|
const trunk = base;
|
|
996
1054
|
const targetRef = `origin/${trunk}`;
|
|
1055
|
+
const push = resolveMergePush(args).value;
|
|
997
1056
|
|
|
998
1057
|
p.intro(pc.cyan("Merge Base PR (via GitHub)"));
|
|
999
1058
|
p.log.info(`Stack: ${pc.yellow(stackName)}`);
|
|
@@ -1027,6 +1086,11 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
1027
1086
|
console.log(
|
|
1028
1087
|
` ${pc.dim(`then re-root ${nextChild} onto ${trunk} — the rest stays a stack`)}`,
|
|
1029
1088
|
);
|
|
1089
|
+
if (!push) {
|
|
1090
|
+
console.log(
|
|
1091
|
+
` ${pc.dim(`(--no-push: re-rooted branches stay local; ${pc.green("gh-stack submit")} publishes them)`)}`,
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1030
1094
|
}
|
|
1031
1095
|
console.log();
|
|
1032
1096
|
|
|
@@ -1097,8 +1161,6 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
1097
1161
|
// Keep a copy of the original topology to archive if the stack fully drains.
|
|
1098
1162
|
const originalStack = structuredClone(stack);
|
|
1099
1163
|
|
|
1100
|
-
await $`git fetch origin ${trunk}`.quiet();
|
|
1101
|
-
|
|
1102
1164
|
for (let i = 0; i < plan.steps.length; i++) {
|
|
1103
1165
|
const step = plan.steps[i]!;
|
|
1104
1166
|
const mergedBranch = step.branch;
|
|
@@ -1151,6 +1213,25 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
1151
1213
|
}
|
|
1152
1214
|
|
|
1153
1215
|
if (child) {
|
|
1216
|
+
// Re-root onto the trunk as it is now, with the merge in it (#51).
|
|
1217
|
+
const trunkState = await fetchTrunkWithMerge(trunk, step.pr!);
|
|
1218
|
+
if (!trunkState.ok) {
|
|
1219
|
+
console.log();
|
|
1220
|
+
p.log.error(
|
|
1221
|
+
`PR #${step.pr} is merged, but ${targetRef} doesn't show ${
|
|
1222
|
+
trunkState.mergeCommit
|
|
1223
|
+
? `its merge commit ${trunkState.mergeCommit.slice(0, 8)}`
|
|
1224
|
+
: "its merge commit (GitHub didn't say which it is)"
|
|
1225
|
+
} yet.`,
|
|
1226
|
+
);
|
|
1227
|
+
console.log(
|
|
1228
|
+
` Stopped before re-rooting ${pc.yellow(child)}: a ${trunk} without the merge would drop its changes from the stack.`,
|
|
1229
|
+
);
|
|
1230
|
+
console.log(` Once ${trunk} has it, finish with:`);
|
|
1231
|
+
console.log(` ${pc.green("gh-stack merge --base")}`);
|
|
1232
|
+
process.exit(2);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1154
1235
|
const res = await advanceNewBottom({
|
|
1155
1236
|
meta,
|
|
1156
1237
|
stackName,
|
|
@@ -1159,16 +1240,17 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
1159
1240
|
childPr,
|
|
1160
1241
|
targetRef,
|
|
1161
1242
|
recordedBase: trunk,
|
|
1243
|
+
push,
|
|
1162
1244
|
expect: verified.get(child),
|
|
1163
1245
|
});
|
|
1164
1246
|
if (!res.ok) {
|
|
1165
1247
|
console.log();
|
|
1166
1248
|
if (res.reason === "conflict") {
|
|
1167
1249
|
p.log.error(`Rebase conflict re-rooting ${pc.yellow(child)} onto ${trunk}.`);
|
|
1168
|
-
console.log(` Resolve the conflict, then:`);
|
|
1250
|
+
console.log(` Resolve the conflict, then finish with:`);
|
|
1169
1251
|
console.log(` ${pc.green("git rebase --continue")}`);
|
|
1170
1252
|
console.log(
|
|
1171
|
-
` ${pc.green("gh-stack
|
|
1253
|
+
` ${pc.green("gh-stack merge --base")} ${pc.dim(`(pushes ${child}, retargets its PR, restacks the rest)`)}`,
|
|
1172
1254
|
);
|
|
1173
1255
|
console.log();
|
|
1174
1256
|
console.log(
|
|
@@ -1228,10 +1310,10 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
1228
1310
|
if (await git.localBranchExists(newBottom)) {
|
|
1229
1311
|
await git.checkout(newBottom);
|
|
1230
1312
|
const { default: restack } = await import("./restack.ts");
|
|
1231
|
-
//
|
|
1232
|
-
//
|
|
1233
|
-
//
|
|
1234
|
-
await restack(["--push", ...(overwrite ? ["--overwrite-remote"] : [])]);
|
|
1313
|
+
// Push unless this run is local-only (--no-push / GH_STACK_NO_PUSH): the
|
|
1314
|
+
// tail must match the re-root above. An explicit flag also means the
|
|
1315
|
+
// `[restack] push` config isn't read mid-merge.
|
|
1316
|
+
await restack([push ? "--push" : "--no-push", ...(overwrite ? ["--overwrite-remote"] : [])]);
|
|
1235
1317
|
}
|
|
1236
1318
|
|
|
1237
1319
|
p.outro(
|
package/src/commands/sync.ts
CHANGED
|
@@ -337,9 +337,11 @@ async function advanceMergedBottoms(
|
|
|
337
337
|
console.log();
|
|
338
338
|
if (res.reason === "conflict") {
|
|
339
339
|
p.log.error(`Rebase conflict re-rooting ${pc.yellow(child)} onto ${trunk}.`);
|
|
340
|
-
console.log(` Resolve it, then finish the
|
|
340
|
+
console.log(` Resolve it, then finish the sync:`);
|
|
341
341
|
console.log(` ${pc.green("git rebase --continue")}`);
|
|
342
|
-
console.log(
|
|
342
|
+
console.log(
|
|
343
|
+
` ${pc.green("gh-stack sync")} ${pc.dim(`(picks up the re-rooted ${child}; not restack, which would put it back on ${mergedBranch})`)}`,
|
|
344
|
+
);
|
|
343
345
|
} else {
|
|
344
346
|
p.log.error(
|
|
345
347
|
`Could not determine a safe rebase base for ${pc.yellow(child)} (${res.reason}).`,
|
package/src/index.ts
CHANGED
|
@@ -246,7 +246,7 @@ ${bold("ENVIRONMENT")}
|
|
|
246
246
|
GH_STACK_PLAIN=1 Same as --plain
|
|
247
247
|
GH_STACK_NO_COLOR Disable colored output
|
|
248
248
|
GH_STACK_DRAFT=1 submit creates new PRs as drafts (0 = ready)
|
|
249
|
-
GH_STACK_NO_PUSH=1 restack/sync never push (0 = push)
|
|
249
|
+
GH_STACK_NO_PUSH=1 restack/sync/merge --base never push (0 = push)
|
|
250
250
|
|
|
251
251
|
${bold("CONFIG")}
|
|
252
252
|
.gh-stack.toml (repo root) and ~/.config/gh-stack/config.toml set flag
|
package/src/lib/advance.ts
CHANGED
|
@@ -120,6 +120,17 @@ export async function resolveForkPoint(
|
|
|
120
120
|
mergedBranch: string,
|
|
121
121
|
child: string,
|
|
122
122
|
): Promise<string | null> {
|
|
123
|
+
// `gh pr merge --delete-branch` deletes the merged branch locally too, which
|
|
124
|
+
// leaves the shared resolver no ref to read snapshots against. Its pre-merge
|
|
125
|
+
// tip is in the snapshot the caller took: use the newest recorded tip the
|
|
126
|
+
// child still builds on.
|
|
127
|
+
if (!(await git.localBranchExists(mergedBranch))) {
|
|
128
|
+
for (const snap of (meta.snapshots ?? []).toReversed()) {
|
|
129
|
+
const tip = snap.branches[mergedBranch];
|
|
130
|
+
if (tip && (await git.isAncestor(tip, child))) return tip;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
123
134
|
const boundary = await resolveRestackBoundary(meta, mergedBranch, child);
|
|
124
135
|
return boundary?.sha ?? null;
|
|
125
136
|
}
|
|
@@ -137,6 +148,9 @@ export type AdvanceResult =
|
|
|
137
148
|
*
|
|
138
149
|
* The caller MUST have taken a snapshot first — on a rebase conflict this leaves
|
|
139
150
|
* the in-progress `git rebase` for the user to resolve and returns `conflict`.
|
|
151
|
+
* Metadata still names the merged branch as the child's parent then, so the
|
|
152
|
+
* fix is to re-run the caller: this skips the finished rebase and does the rest.
|
|
153
|
+
* (`restack` would rebase the child back onto the merged branch.)
|
|
140
154
|
*/
|
|
141
155
|
export async function advanceNewBottom(opts: {
|
|
142
156
|
meta: StackMetadata;
|
|
@@ -158,21 +172,32 @@ export async function advanceNewBottom(opts: {
|
|
|
158
172
|
const push = opts.push ?? true;
|
|
159
173
|
let retargeted = false;
|
|
160
174
|
|
|
161
|
-
|
|
162
|
-
|
|
175
|
+
// Already on the target: an earlier run stopped on a conflict the user then
|
|
176
|
+
// resolved, or the branch was re-rooted by hand (#51). Its fork point is
|
|
177
|
+
// behind it now, so skip the rebase and finish the rest (push, PR base,
|
|
178
|
+
// metadata). Before a re-root the child can't contain the target, which
|
|
179
|
+
// holds the merged PR's new squash commit.
|
|
180
|
+
if (await git.isAncestor(targetRef, child)) {
|
|
181
|
+
p.log.info(
|
|
182
|
+
`${pc.yellow(child)} is already on ${pc.green(recordedBase)} — finishing its re-root`,
|
|
183
|
+
);
|
|
184
|
+
} else {
|
|
185
|
+
const forkPoint = await resolveForkPoint(meta, mergedBranch, child);
|
|
186
|
+
if (!forkPoint) return { ok: false, reason: "no-fork-point" };
|
|
187
|
+
|
|
188
|
+
// Guard: the fork-point must be an ancestor of the child, else `--onto` would
|
|
189
|
+
// replay an unrelated range. Bail loudly rather than mangle history.
|
|
190
|
+
if (!(await git.isAncestor(forkPoint, child))) {
|
|
191
|
+
return { ok: false, reason: "bad-fork-point", forkPoint };
|
|
192
|
+
}
|
|
163
193
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
194
|
+
p.log.info(
|
|
195
|
+
`Re-rooting ${pc.yellow(child)} onto ${pc.green(recordedBase)} ${pc.dim(`(base: ${forkPoint.slice(0, 8)})`)}`,
|
|
196
|
+
);
|
|
197
|
+
const ok = await git.rebaseOnto(targetRef, forkPoint, child);
|
|
198
|
+
if (!ok) return { ok: false, reason: "conflict", forkPoint };
|
|
168
199
|
}
|
|
169
200
|
|
|
170
|
-
p.log.info(
|
|
171
|
-
`Re-rooting ${pc.yellow(child)} onto ${pc.green(recordedBase)} ${pc.dim(`(base: ${forkPoint.slice(0, 8)})`)}`,
|
|
172
|
-
);
|
|
173
|
-
const ok = await git.rebaseOnto(targetRef, forkPoint, child);
|
|
174
|
-
if (!ok) return { ok: false, reason: "conflict", forkPoint };
|
|
175
|
-
|
|
176
201
|
if (push) {
|
|
177
202
|
// Push the rebased head first so the PR diff is clean before its base flips.
|
|
178
203
|
// If it didn't land, leave the base alone: retargeting onto the trunk with
|
package/src/lib/safety.ts
CHANGED
|
@@ -170,7 +170,11 @@ export function ensureNotMain(branch: string): void {
|
|
|
170
170
|
/**
|
|
171
171
|
* Validate stack metadata before destructive operations.
|
|
172
172
|
*/
|
|
173
|
-
export async function validateStack(
|
|
173
|
+
export async function validateStack(
|
|
174
|
+
meta: StackMetadata,
|
|
175
|
+
stackName: string,
|
|
176
|
+
opts: { allowMissing?: string[] } = {},
|
|
177
|
+
): Promise<string[]> {
|
|
174
178
|
const stack = meta.stacks[stackName];
|
|
175
179
|
if (!stack) return [`Stack "${stackName}" not found`];
|
|
176
180
|
|
|
@@ -181,7 +185,7 @@ export async function validateStack(meta: StackMetadata, stackName: string): Pro
|
|
|
181
185
|
for (const branchName of branchNames) {
|
|
182
186
|
const branch = stack.branches[branchName]!;
|
|
183
187
|
|
|
184
|
-
if (!(await git.localBranchExists(branchName))) {
|
|
188
|
+
if (!opts.allowMissing?.includes(branchName) && !(await git.localBranchExists(branchName))) {
|
|
185
189
|
errors.push(`Branch "${branchName}" does not exist locally`);
|
|
186
190
|
}
|
|
187
191
|
|
|
@@ -201,8 +205,12 @@ export async function validateStack(meta: StackMetadata, stackName: string): Pro
|
|
|
201
205
|
return errors;
|
|
202
206
|
}
|
|
203
207
|
|
|
204
|
-
export async function ensureValidStack(
|
|
205
|
-
|
|
208
|
+
export async function ensureValidStack(
|
|
209
|
+
meta: StackMetadata,
|
|
210
|
+
stackName: string,
|
|
211
|
+
opts: { allowMissing?: string[] } = {},
|
|
212
|
+
): Promise<void> {
|
|
213
|
+
const errors = await validateStack(meta, stackName, opts);
|
|
206
214
|
if (errors.length === 0) return;
|
|
207
215
|
|
|
208
216
|
p.cancel(`Invalid stack metadata for ${pc.yellow(stackName)}`);
|
|
@@ -74,8 +74,9 @@ export GH_STACK_YES=1 # skip every confirmation, plain output
|
|
|
74
74
|
human approves), add `--no-push` to `restack`/`sync`, or export
|
|
75
75
|
`GH_STACK_NO_PUSH=1` (a repo may also set `[restack] push = false`). It wins
|
|
76
76
|
over `--yes` for pushes and lists what's ahead of origin at the end; publish
|
|
77
|
-
later with `gh-stack restack --push`. `merge`
|
|
78
|
-
|
|
77
|
+
later with `gh-stack restack --push`. `merge --base` honors `--no-push` and
|
|
78
|
+
`GH_STACK_NO_PUSH` (not the config); other `merge` modes and `submit --restack`
|
|
79
|
+
always push. `restack --resume` keeps the interrupted run's push decision unless you
|
|
79
80
|
pass `--push`/`--no-push`. `--yes` itself can't come from a config file:
|
|
80
81
|
always pass it or `GH_STACK_YES=1`.
|
|
81
82
|
- A prompt reached without a TTY on stdin **exits 1** (naming the prompt)
|
|
@@ -335,7 +336,12 @@ Merge is **top-down everywhere** (collapse PRn → PRn-1 → … → base PR, th
|
|
|
335
336
|
- **`merge --base`**: squash-merge **only the bottom PR** into trunk and re-root
|
|
336
337
|
the next branch onto trunk — one PR per run, re-run for the next. Use this to
|
|
337
338
|
land reviewed PRs one at a time. Auto-advances past a bottom PR already merged
|
|
338
|
-
outside gh-stack.
|
|
339
|
+
outside gh-stack. If the re-root stops (a conflict, or "origin/main doesn't
|
|
340
|
+
show its merge commit"), fix it (`git rebase --continue`) and **re-run
|
|
341
|
+
`merge --base`**: it advances past the merged PR and finishes a branch that's
|
|
342
|
+
already re-rooted. Don't run `restack` there: it would put the branch back on
|
|
343
|
+
the merged one. `--no-push` / `GH_STACK_NO_PUSH=1` still merges on GitHub but
|
|
344
|
+
keeps the re-root and the tail local until `gh-stack submit`.
|
|
339
345
|
- **`--collapse`** (with `merge` or `merge --approved`): stop after collapsing
|
|
340
346
|
into the base PR — review on GitHub, then re-run `merge` to finish.
|
|
341
347
|
|