gh-stack 0.18.0 → 0.19.1
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 +247 -18
- package/package.json +9 -8
- package/src/commands/config.ts +133 -0
- package/src/commands/join.ts +877 -0
- package/src/commands/learn.ts +295 -36
- package/src/commands/merge.ts +352 -12
- package/src/commands/restack.ts +943 -237
- package/src/commands/submit.ts +257 -51
- package/src/commands/sync.ts +144 -156
- package/src/commands/undo.ts +11 -0
- package/src/commands/update-prs.ts +11 -6
- package/src/index.ts +155 -125
- package/src/lib/advance.ts +67 -17
- package/src/lib/collapse-guard.ts +325 -0
- package/src/lib/config.ts +259 -0
- package/src/lib/git.ts +84 -22
- package/src/lib/github.ts +63 -5
- package/src/lib/metadata.ts +34 -7
- package/src/lib/output.ts +29 -8
- package/src/lib/paths.ts +1 -1
- package/src/lib/remote-guard.ts +281 -0
- package/src/lib/run-log.ts +117 -0
- package/src/lib/safety.ts +12 -4
- package/src/lib/using-gh-stack.md +131 -5
- package/src/types.ts +62 -4
package/src/commands/merge.ts
CHANGED
|
@@ -12,18 +12,35 @@ import {
|
|
|
12
12
|
} from "../lib/metadata.ts";
|
|
13
13
|
import { ensureMetadata, ensureCleanWorkingTree, ensureValidStack } from "../lib/safety.ts";
|
|
14
14
|
import { takeSnapshot } from "../lib/snapshot.ts";
|
|
15
|
-
import {
|
|
16
|
-
|
|
15
|
+
import {
|
|
16
|
+
getBranchRefSha,
|
|
17
|
+
getCommitPatch,
|
|
18
|
+
getPrInfo,
|
|
19
|
+
getCommitFiles,
|
|
20
|
+
getPrMergeState,
|
|
21
|
+
getRepoNwo,
|
|
22
|
+
resyncPrHead,
|
|
23
|
+
} from "../lib/github.ts";
|
|
24
|
+
import { verifyCollapse, type CollapsedChild } from "../lib/collapse-guard.ts";
|
|
25
|
+
import type { Stack } from "../types.ts";
|
|
26
|
+
import { confirmAction, isAutoYes } from "../lib/ui.ts";
|
|
27
|
+
import { guardRemoteContent } from "../lib/remote-guard.ts";
|
|
28
|
+
import { ensureNoPendingRun } from "./restack.ts";
|
|
17
29
|
import {
|
|
18
30
|
planMerges,
|
|
19
31
|
advanceNewBottom,
|
|
20
32
|
type BranchReview,
|
|
21
33
|
type MergeAction,
|
|
22
34
|
} from "../lib/advance.ts";
|
|
35
|
+
import { flagSetting, parseEnvBool, type Resolved } from "../lib/config.ts";
|
|
23
36
|
|
|
24
37
|
const MAX_RETRIES = 12;
|
|
25
38
|
const RETRY_DELAY_MS = 5000;
|
|
26
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
|
+
|
|
27
44
|
// After GitHub rejects a merge with the "still recomputing" signature, back off
|
|
28
45
|
// (capped) and re-poll before retrying. One entry per retry; length+1 = max
|
|
29
46
|
// attempts. Bounds total transient wait per PR to ~30s.
|
|
@@ -248,7 +265,7 @@ gh-stack merge — Squash-merge a stack via GitHub
|
|
|
248
265
|
USAGE
|
|
249
266
|
gh-stack merge [--dry-run] [-d|--delete-branch] [--collapse]
|
|
250
267
|
gh-stack merge --approved [--collapse] [--dry-run] [-d|--delete-branch]
|
|
251
|
-
gh-stack merge --base [--dry-run] [-d|--delete-branch]
|
|
268
|
+
gh-stack merge --base [--dry-run] [-d|--delete-branch] [--no-push|--push]
|
|
252
269
|
|
|
253
270
|
DEFAULT (top-down collapse)
|
|
254
271
|
Squash-merges the whole stack from top to bottom via GitHub:
|
|
@@ -268,6 +285,11 @@ DEFAULT (top-down collapse)
|
|
|
268
285
|
Squash-merges only the bottom PR into main as its own commit, then re-roots
|
|
269
286
|
the next branch onto main. One PR per run — run it again to land the next.
|
|
270
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.
|
|
271
293
|
|
|
272
294
|
--collapse (stop before main)
|
|
273
295
|
With the default or --approved, stop after collapsing into the base PR
|
|
@@ -283,19 +305,37 @@ a hard failure.
|
|
|
283
305
|
|
|
284
306
|
A snapshot is taken first; ${pc.green("gh-stack undo")} restores the prior state.
|
|
285
307
|
|
|
308
|
+
Before the base PR is handed to main, merge checks that each collapsed child's
|
|
309
|
+
squash commit is still in the branch it was squashed into (as an ancestor, or a
|
|
310
|
+
rebased copy). If the base branch was rewritten without one (e.g. a sync that
|
|
311
|
+
never pulled it), merge stops and names the PR whose changes would be lost.
|
|
312
|
+
Whatever it can't establish (a GitHub or fetch failure, a shallow clone,
|
|
313
|
+
content it can't compare) is UNVERIFIED, never "missing": merge asks (default
|
|
314
|
+
stop, with a retry for transient failures); with --yes it stops. --base runs
|
|
315
|
+
the same check when the base PR carries PRs collapsed into it earlier.
|
|
316
|
+
|
|
286
317
|
OPTIONS
|
|
287
318
|
-d, --delete-branch Delete remote branches after merging
|
|
288
319
|
--approved Collapse only the approved run (from the base up)
|
|
289
320
|
--base Merge just the bottom PR into main, re-root the next
|
|
290
321
|
--collapse Stop after collapsing into the base PR (don't merge to
|
|
291
322
|
main); alias: --stop-at-base
|
|
323
|
+
--skip-collapse-check
|
|
324
|
+
Don't verify the collapsed children's content is in
|
|
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
|
|
292
328
|
--dry-run Show what would happen without doing anything
|
|
293
329
|
`);
|
|
294
330
|
return;
|
|
295
331
|
}
|
|
296
332
|
|
|
333
|
+
// A pending restack/sync may have left branches un-pushed or half-rebased;
|
|
334
|
+
// merging (or re-rooting) them now would land that stale state.
|
|
335
|
+
if (!dryRun) await ensureNoPendingRun("merge");
|
|
336
|
+
|
|
297
337
|
if (baseOnly) {
|
|
298
|
-
await mergeBaseOnly(deleteFlag, dryRun);
|
|
338
|
+
await mergeBaseOnly(deleteFlag, dryRun, args);
|
|
299
339
|
return;
|
|
300
340
|
}
|
|
301
341
|
|
|
@@ -541,6 +581,12 @@ OPTIONS
|
|
|
541
581
|
}
|
|
542
582
|
}
|
|
543
583
|
|
|
584
|
+
// Every child is squashed into its parent now (this run or an earlier one).
|
|
585
|
+
// Before the base goes anywhere, make sure it still holds their content (#28).
|
|
586
|
+
if (!args.includes("--skip-collapse-check")) {
|
|
587
|
+
await ensureCollapseIntact(stack, rangeOrdered, baseBranch, basePr!);
|
|
588
|
+
}
|
|
589
|
+
|
|
544
590
|
// ─── --collapse: stop here. Base PR holds the cumulative diff; let the
|
|
545
591
|
// user review on GitHub and re-run `gh-stack merge` to finish.
|
|
546
592
|
if (collapse) {
|
|
@@ -740,6 +786,217 @@ OPTIONS
|
|
|
740
786
|
p.outro(pc.green("Stack merge complete! Stack archived."));
|
|
741
787
|
}
|
|
742
788
|
|
|
789
|
+
/**
|
|
790
|
+
* Before the base PR goes to main, check every collapsed child's squash commit
|
|
791
|
+
* is still in what it was squashed into (#28). Fails closed:
|
|
792
|
+
* - proven missing: stop (exit 2), naming the PR and how to put it back;
|
|
793
|
+
* - unconfirmed or unverified (a lookup failed, a shallow clone, content that
|
|
794
|
+
* can't be compared): interactive, ask (default stop, with a retry when the
|
|
795
|
+
* cause looks transient); under --yes, stop (exit 2) and say it's
|
|
796
|
+
* unverified, with the checks to run by hand.
|
|
797
|
+
*/
|
|
798
|
+
async function ensureCollapseIntact(
|
|
799
|
+
stack: Stack,
|
|
800
|
+
rangeOrdered: string[],
|
|
801
|
+
baseBranch: string,
|
|
802
|
+
basePr: number,
|
|
803
|
+
): Promise<void> {
|
|
804
|
+
const children: CollapsedChild[] = [];
|
|
805
|
+
for (const branch of rangeOrdered) {
|
|
806
|
+
if (branch === baseBranch) continue;
|
|
807
|
+
const entry = stack.branches[branch]!;
|
|
808
|
+
children.push({
|
|
809
|
+
branch,
|
|
810
|
+
pr: entry.pr!,
|
|
811
|
+
parent: entry.parent,
|
|
812
|
+
parentPr: entry.parent === baseBranch ? null : (stack.branches[entry.parent]?.pr ?? null),
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
if (children.length === 0) return;
|
|
816
|
+
|
|
817
|
+
const nwo = (await getRepoNwo()) ?? "{owner}/{repo}";
|
|
818
|
+
const patchCommand = (sha: string) =>
|
|
819
|
+
`gh api -H "Accept: application/vnd.github.patch" repos/${nwo}/commits/${sha} | git am`;
|
|
820
|
+
const trunkRef = `origin/${await git.trunkBranch()}`;
|
|
821
|
+
|
|
822
|
+
for (;;) {
|
|
823
|
+
const s = p.spinner();
|
|
824
|
+
s.start(`Checking PR #${basePr} still holds every collapsed PR's changes...`);
|
|
825
|
+
const verdicts = await verifyCollapse({
|
|
826
|
+
base: baseBranch,
|
|
827
|
+
trunkRef,
|
|
828
|
+
children,
|
|
829
|
+
lookup: {
|
|
830
|
+
prResult: getPrMergeState,
|
|
831
|
+
commitPatch: getCommitPatch,
|
|
832
|
+
commitFiles: getCommitFiles,
|
|
833
|
+
},
|
|
834
|
+
});
|
|
835
|
+
const missing = verdicts.flatMap((v) => (v.status === "missing" ? [v] : []));
|
|
836
|
+
const uncertain = verdicts.flatMap((v) =>
|
|
837
|
+
v.status === "unconfirmed" || v.status === "unverified" ? [v] : [],
|
|
838
|
+
);
|
|
839
|
+
s.stop(
|
|
840
|
+
missing.length > 0
|
|
841
|
+
? pc.red(`PR #${basePr} is missing collapsed changes`)
|
|
842
|
+
: uncertain.length > 0
|
|
843
|
+
? pc.yellow(`Couldn't confirm PR #${basePr} holds every collapsed PR's changes`)
|
|
844
|
+
: `PR #${basePr} holds every collapsed PR's changes`,
|
|
845
|
+
);
|
|
846
|
+
|
|
847
|
+
if (missing.length > 0) {
|
|
848
|
+
console.log();
|
|
849
|
+
for (const v of missing) {
|
|
850
|
+
p.log.error(
|
|
851
|
+
`PR #${v.child.pr} (${pc.yellow(v.child.branch)})'s changes are missing from PR #${basePr}: ${v.containerLabel} (${v.container.slice(0, 8)}) has every file its squash commit ${v.mergeCommit.slice(0, 8)} touched exactly as it was before that commit.`,
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
console.log();
|
|
855
|
+
console.log(
|
|
856
|
+
` ${pc.yellow(baseBranch)} was rewritten after the collapse (e.g. a restack or sync that never pulled the squash commit).`,
|
|
857
|
+
);
|
|
858
|
+
console.log(
|
|
859
|
+
` Merging PR #${basePr} now would ship it without those PRs' changes. Nothing was merged to main.`,
|
|
860
|
+
);
|
|
861
|
+
console.log();
|
|
862
|
+
console.log(
|
|
863
|
+
` Put the squash commit(s) back on ${baseBranch}, then re-run ${pc.green("gh-stack merge")}:`,
|
|
864
|
+
);
|
|
865
|
+
console.log(
|
|
866
|
+
` ${pc.green(`git checkout ${baseBranch} && git pull --ff-only origin ${baseBranch}`)}`,
|
|
867
|
+
);
|
|
868
|
+
for (const v of missing) {
|
|
869
|
+
// An orphaned squash commit usually can't be fetched from GitHub, but
|
|
870
|
+
// its patch can: lead with what works.
|
|
871
|
+
console.log(
|
|
872
|
+
` ${pc.green(
|
|
873
|
+
v.fetchable
|
|
874
|
+
? `git fetch origin ${v.mergeCommit} && git cherry-pick ${v.mergeCommit}`
|
|
875
|
+
: patchCommand(v.mergeCommit),
|
|
876
|
+
)}`,
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
console.log(` ${pc.green(`git push --force-with-lease origin ${baseBranch}`)}`);
|
|
880
|
+
console.log(
|
|
881
|
+
` If the changes really are there in another form: ${pc.green("gh-stack merge --skip-collapse-check")}`,
|
|
882
|
+
);
|
|
883
|
+
process.exit(2);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (uncertain.length === 0) return;
|
|
887
|
+
|
|
888
|
+
console.log();
|
|
889
|
+
for (const v of uncertain) {
|
|
890
|
+
p.log.warn(
|
|
891
|
+
`Could not confirm PR #${v.child.pr} (${pc.yellow(v.child.branch)})'s changes are in PR #${basePr} — this is UNVERIFIED, not known to be missing: ${v.reason}.`,
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
console.log();
|
|
895
|
+
console.log(" Check by hand before merging:");
|
|
896
|
+
console.log(
|
|
897
|
+
` ${pc.green(`gh pr diff ${basePr} --name-only`)} ${pc.dim("(the base PR's files)")}`,
|
|
898
|
+
);
|
|
899
|
+
for (const v of uncertain) {
|
|
900
|
+
console.log(
|
|
901
|
+
` ${pc.green(`gh pr diff ${v.child.pr} --name-only`)} ${pc.dim(`(PR #${v.child.pr}'s files: each should be in #${basePr})`)}`,
|
|
902
|
+
);
|
|
903
|
+
if (v.status === "unconfirmed") {
|
|
904
|
+
console.log(
|
|
905
|
+
` ${pc.dim(`if they aren't, put its squash commit back on ${baseBranch}:`)} ${pc.green(patchCommand(v.mergeCommit))}`,
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
console.log(` If they're all there: ${pc.green("gh-stack merge --skip-collapse-check")}`);
|
|
910
|
+
|
|
911
|
+
if (isAutoYes()) {
|
|
912
|
+
p.cancel(
|
|
913
|
+
`Stopped before merging PR #${basePr}: its collapsed content is unverified, and --yes never merges on a guess. Nothing was merged to main.`,
|
|
914
|
+
);
|
|
915
|
+
process.exit(2);
|
|
916
|
+
}
|
|
917
|
+
const transient = uncertain.some((v) => v.transient);
|
|
918
|
+
const choice = await p.select({
|
|
919
|
+
message: `Merge PR #${basePr} without confirming those changes are in it?`,
|
|
920
|
+
initialValue: "stop",
|
|
921
|
+
options: [
|
|
922
|
+
...(transient
|
|
923
|
+
? [{ value: "retry", label: "Retry the check", hint: "a GitHub or network read failed" }]
|
|
924
|
+
: []),
|
|
925
|
+
{ value: "stop", label: "Stop — merge nothing" },
|
|
926
|
+
{ value: "merge", label: "Merge anyway" },
|
|
927
|
+
],
|
|
928
|
+
});
|
|
929
|
+
if (p.isCancel(choice) || choice === "stop") {
|
|
930
|
+
p.cancel(`Stopped before merging PR #${basePr}. Nothing was merged to main.`);
|
|
931
|
+
process.exit(2);
|
|
932
|
+
}
|
|
933
|
+
if (choice === "merge") return;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* The children collapsed into `base` by an earlier `merge --collapse`: walking
|
|
939
|
+
* up from the base, each branch whose PR merged into its stack parent (itself
|
|
940
|
+
* the base or collapsed). `unknown` is the first branch whose PR couldn't be
|
|
941
|
+
* read, when that stopped the walk.
|
|
942
|
+
*/
|
|
943
|
+
async function collapsedChildren(
|
|
944
|
+
stack: Stack,
|
|
945
|
+
ordered: string[],
|
|
946
|
+
base: string,
|
|
947
|
+
): Promise<{ collapsed: string[]; unknown: string | null }> {
|
|
948
|
+
const collapsed: string[] = [];
|
|
949
|
+
const into = new Set([base]);
|
|
950
|
+
for (const branch of ordered) {
|
|
951
|
+
const entry = stack.branches[branch];
|
|
952
|
+
if (branch === base || !entry?.pr || !into.has(entry.parent)) continue;
|
|
953
|
+
const state = await getPrMergeState(entry.pr);
|
|
954
|
+
if (!state) return { collapsed, unknown: branch };
|
|
955
|
+
if (state.state === "MERGED" && state.baseRefName === entry.parent) {
|
|
956
|
+
collapsed.push(branch);
|
|
957
|
+
into.add(branch);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
return { collapsed, unknown: null };
|
|
961
|
+
}
|
|
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
|
+
|
|
743
1000
|
/**
|
|
744
1001
|
* `gh-stack merge --base` — squash-merge ONLY the bottom PR into main, then
|
|
745
1002
|
* re-root the next branch onto main (replaying just its own commits, via
|
|
@@ -747,7 +1004,7 @@ OPTIONS
|
|
|
747
1004
|
* next. Approval is NOT checked here; GitHub branch protection enforces it.
|
|
748
1005
|
* A base PR already merged outside gh-stack is detected and advanced past.
|
|
749
1006
|
*/
|
|
750
|
-
async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean): Promise<void> {
|
|
1007
|
+
async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[]): Promise<void> {
|
|
751
1008
|
// We rebase the surviving branches, so the same pre-flight guards as restack.
|
|
752
1009
|
if (await git.isRebaseInProgress()) {
|
|
753
1010
|
p.cancel(
|
|
@@ -766,7 +1023,17 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean): Promise<void
|
|
|
766
1023
|
}
|
|
767
1024
|
|
|
768
1025
|
const stack = meta.stacks[stackName]!;
|
|
769
|
-
|
|
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] : [] });
|
|
770
1037
|
const ordered = getOrderedBranches(stack);
|
|
771
1038
|
if (ordered.length === 0) {
|
|
772
1039
|
p.cancel("No branches in this stack");
|
|
@@ -775,7 +1042,6 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean): Promise<void
|
|
|
775
1042
|
|
|
776
1043
|
// Like top-down merge: a split stack rooted off another stack can't drain to
|
|
777
1044
|
// main until it's re-rooted. Require a trunk base.
|
|
778
|
-
const base = stackBase(stack);
|
|
779
1045
|
if (base !== "main" && base !== "master") {
|
|
780
1046
|
p.cancel(
|
|
781
1047
|
`${pc.yellow(stackName)} is based on ${pc.yellow(base)}, not main — can't merge yet.\n\n` +
|
|
@@ -786,6 +1052,7 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean): Promise<void
|
|
|
786
1052
|
}
|
|
787
1053
|
const trunk = base;
|
|
788
1054
|
const targetRef = `origin/${trunk}`;
|
|
1055
|
+
const push = resolveMergePush(args).value;
|
|
789
1056
|
|
|
790
1057
|
p.intro(pc.cyan("Merge Base PR (via GitHub)"));
|
|
791
1058
|
p.log.info(`Stack: ${pc.yellow(stackName)}`);
|
|
@@ -819,6 +1086,11 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean): Promise<void
|
|
|
819
1086
|
console.log(
|
|
820
1087
|
` ${pc.dim(`then re-root ${nextChild} onto ${trunk} — the rest stays a stack`)}`,
|
|
821
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
|
+
}
|
|
822
1094
|
}
|
|
823
1095
|
console.log();
|
|
824
1096
|
|
|
@@ -837,14 +1109,58 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean): Promise<void
|
|
|
837
1109
|
process.exit(0);
|
|
838
1110
|
}
|
|
839
1111
|
|
|
1112
|
+
// #28: a base PR that carries PRs collapsed into it by an earlier
|
|
1113
|
+
// `merge --collapse` must still hold their content before it goes to main.
|
|
1114
|
+
// Collapsed children are found from GitHub (merged PRs whose base was their
|
|
1115
|
+
// stack parent); a lookup that fails is uncertainty, and fails closed.
|
|
1116
|
+
let collapsed: string[] = [];
|
|
1117
|
+
if (baseAction === "merge" && !args.includes("--skip-collapse-check")) {
|
|
1118
|
+
const found = await collapsedChildren(stack, ordered, baseBranch);
|
|
1119
|
+
if (found.unknown) {
|
|
1120
|
+
p.log.warn(
|
|
1121
|
+
`Couldn't tell whether PR #${basePr} carries PRs collapsed into it: ${found.unknown}'s PR couldn't be read from GitHub.`,
|
|
1122
|
+
);
|
|
1123
|
+
console.log(
|
|
1124
|
+
` Check by hand: ${pc.green(`gh pr view ${stack.branches[found.unknown]!.pr} --json state,baseRefName`)}`,
|
|
1125
|
+
);
|
|
1126
|
+
console.log(
|
|
1127
|
+
` Then re-run, or skip the check: ${pc.green("gh-stack merge --base --skip-collapse-check")}`,
|
|
1128
|
+
);
|
|
1129
|
+
if (isAutoYes()) {
|
|
1130
|
+
p.cancel(
|
|
1131
|
+
`Stopped before merging PR #${basePr} (--yes never merges on a guess). Nothing was merged.`,
|
|
1132
|
+
);
|
|
1133
|
+
process.exit(2);
|
|
1134
|
+
}
|
|
1135
|
+
const go = await p.confirm({ message: `Merge PR #${basePr} anyway?`, initialValue: false });
|
|
1136
|
+
if (p.isCancel(go) || !go) {
|
|
1137
|
+
p.cancel(`Stopped before merging PR #${basePr}. Nothing was merged.`);
|
|
1138
|
+
process.exit(2);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
collapsed = found.collapsed;
|
|
1142
|
+
if (collapsed.length > 0) {
|
|
1143
|
+
await ensureCollapseIntact(stack, [baseBranch, ...collapsed], baseBranch, basePr);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// The branches this run rewrites and force-pushes (the next child here, the
|
|
1148
|
+
// rest in the restack below) must not drop commits only origin has.
|
|
1149
|
+
// Collapsed children are left out: their origin copies hold the squash
|
|
1150
|
+
// commits of PRs merged into them, which aren't the user's to pull.
|
|
1151
|
+
const overwrite = args.includes("--overwrite-remote");
|
|
1152
|
+
const verified = await guardRemoteContent(
|
|
1153
|
+
meta,
|
|
1154
|
+
ordered.slice(1).filter((b) => !collapsed.includes(b)),
|
|
1155
|
+
{ command: "merge --base", overwrite, dryRun: false },
|
|
1156
|
+
);
|
|
1157
|
+
|
|
840
1158
|
// Snapshot first — this records every branch's pre-merge tip, which both the
|
|
841
1159
|
// fork-point resolver and `gh-stack undo` rely on.
|
|
842
1160
|
await takeSnapshot(meta, stackName, "merge-base");
|
|
843
1161
|
// Keep a copy of the original topology to archive if the stack fully drains.
|
|
844
1162
|
const originalStack = structuredClone(stack);
|
|
845
1163
|
|
|
846
|
-
await $`git fetch origin ${trunk}`.quiet();
|
|
847
|
-
|
|
848
1164
|
for (let i = 0; i < plan.steps.length; i++) {
|
|
849
1165
|
const step = plan.steps[i]!;
|
|
850
1166
|
const mergedBranch = step.branch;
|
|
@@ -897,6 +1213,25 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean): Promise<void
|
|
|
897
1213
|
}
|
|
898
1214
|
|
|
899
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
|
+
|
|
900
1235
|
const res = await advanceNewBottom({
|
|
901
1236
|
meta,
|
|
902
1237
|
stackName,
|
|
@@ -905,15 +1240,17 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean): Promise<void
|
|
|
905
1240
|
childPr,
|
|
906
1241
|
targetRef,
|
|
907
1242
|
recordedBase: trunk,
|
|
1243
|
+
push,
|
|
1244
|
+
expect: verified.get(child),
|
|
908
1245
|
});
|
|
909
1246
|
if (!res.ok) {
|
|
910
1247
|
console.log();
|
|
911
1248
|
if (res.reason === "conflict") {
|
|
912
1249
|
p.log.error(`Rebase conflict re-rooting ${pc.yellow(child)} onto ${trunk}.`);
|
|
913
|
-
console.log(` Resolve the conflict, then:`);
|
|
1250
|
+
console.log(` Resolve the conflict, then finish with:`);
|
|
914
1251
|
console.log(` ${pc.green("git rebase --continue")}`);
|
|
915
1252
|
console.log(
|
|
916
|
-
` ${pc.green("gh-stack
|
|
1253
|
+
` ${pc.green("gh-stack merge --base")} ${pc.dim(`(pushes ${child}, retargets its PR, restacks the rest)`)}`,
|
|
917
1254
|
);
|
|
918
1255
|
console.log();
|
|
919
1256
|
console.log(
|
|
@@ -973,7 +1310,10 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean): Promise<void
|
|
|
973
1310
|
if (await git.localBranchExists(newBottom)) {
|
|
974
1311
|
await git.checkout(newBottom);
|
|
975
1312
|
const { default: restack } = await import("./restack.ts");
|
|
976
|
-
|
|
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"] : [])]);
|
|
977
1317
|
}
|
|
978
1318
|
|
|
979
1319
|
p.outro(
|