gh-stack 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +232 -16
- 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 +263 -5
- package/src/commands/restack.ts +943 -237
- package/src/commands/submit.ts +257 -51
- package/src/commands/sync.ts +140 -154
- 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 +29 -4
- 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/using-gh-stack.md +124 -4
- package/src/types.ts +62 -4
package/src/commands/merge.ts
CHANGED
|
@@ -12,8 +12,20 @@ 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,
|
|
@@ -283,19 +295,35 @@ a hard failure.
|
|
|
283
295
|
|
|
284
296
|
A snapshot is taken first; ${pc.green("gh-stack undo")} restores the prior state.
|
|
285
297
|
|
|
298
|
+
Before the base PR is handed to main, merge checks that each collapsed child's
|
|
299
|
+
squash commit is still in the branch it was squashed into (as an ancestor, or a
|
|
300
|
+
rebased copy). If the base branch was rewritten without one (e.g. a sync that
|
|
301
|
+
never pulled it), merge stops and names the PR whose changes would be lost.
|
|
302
|
+
Whatever it can't establish (a GitHub or fetch failure, a shallow clone,
|
|
303
|
+
content it can't compare) is UNVERIFIED, never "missing": merge asks (default
|
|
304
|
+
stop, with a retry for transient failures); with --yes it stops. --base runs
|
|
305
|
+
the same check when the base PR carries PRs collapsed into it earlier.
|
|
306
|
+
|
|
286
307
|
OPTIONS
|
|
287
308
|
-d, --delete-branch Delete remote branches after merging
|
|
288
309
|
--approved Collapse only the approved run (from the base up)
|
|
289
310
|
--base Merge just the bottom PR into main, re-root the next
|
|
290
311
|
--collapse Stop after collapsing into the base PR (don't merge to
|
|
291
312
|
main); alias: --stop-at-base
|
|
313
|
+
--skip-collapse-check
|
|
314
|
+
Don't verify the collapsed children's content is in
|
|
315
|
+
the base PR (above)
|
|
292
316
|
--dry-run Show what would happen without doing anything
|
|
293
317
|
`);
|
|
294
318
|
return;
|
|
295
319
|
}
|
|
296
320
|
|
|
321
|
+
// A pending restack/sync may have left branches un-pushed or half-rebased;
|
|
322
|
+
// merging (or re-rooting) them now would land that stale state.
|
|
323
|
+
if (!dryRun) await ensureNoPendingRun("merge");
|
|
324
|
+
|
|
297
325
|
if (baseOnly) {
|
|
298
|
-
await mergeBaseOnly(deleteFlag, dryRun);
|
|
326
|
+
await mergeBaseOnly(deleteFlag, dryRun, args);
|
|
299
327
|
return;
|
|
300
328
|
}
|
|
301
329
|
|
|
@@ -541,6 +569,12 @@ OPTIONS
|
|
|
541
569
|
}
|
|
542
570
|
}
|
|
543
571
|
|
|
572
|
+
// Every child is squashed into its parent now (this run or an earlier one).
|
|
573
|
+
// Before the base goes anywhere, make sure it still holds their content (#28).
|
|
574
|
+
if (!args.includes("--skip-collapse-check")) {
|
|
575
|
+
await ensureCollapseIntact(stack, rangeOrdered, baseBranch, basePr!);
|
|
576
|
+
}
|
|
577
|
+
|
|
544
578
|
// ─── --collapse: stop here. Base PR holds the cumulative diff; let the
|
|
545
579
|
// user review on GitHub and re-run `gh-stack merge` to finish.
|
|
546
580
|
if (collapse) {
|
|
@@ -740,6 +774,180 @@ OPTIONS
|
|
|
740
774
|
p.outro(pc.green("Stack merge complete! Stack archived."));
|
|
741
775
|
}
|
|
742
776
|
|
|
777
|
+
/**
|
|
778
|
+
* Before the base PR goes to main, check every collapsed child's squash commit
|
|
779
|
+
* is still in what it was squashed into (#28). Fails closed:
|
|
780
|
+
* - proven missing: stop (exit 2), naming the PR and how to put it back;
|
|
781
|
+
* - unconfirmed or unverified (a lookup failed, a shallow clone, content that
|
|
782
|
+
* can't be compared): interactive, ask (default stop, with a retry when the
|
|
783
|
+
* cause looks transient); under --yes, stop (exit 2) and say it's
|
|
784
|
+
* unverified, with the checks to run by hand.
|
|
785
|
+
*/
|
|
786
|
+
async function ensureCollapseIntact(
|
|
787
|
+
stack: Stack,
|
|
788
|
+
rangeOrdered: string[],
|
|
789
|
+
baseBranch: string,
|
|
790
|
+
basePr: number,
|
|
791
|
+
): Promise<void> {
|
|
792
|
+
const children: CollapsedChild[] = [];
|
|
793
|
+
for (const branch of rangeOrdered) {
|
|
794
|
+
if (branch === baseBranch) continue;
|
|
795
|
+
const entry = stack.branches[branch]!;
|
|
796
|
+
children.push({
|
|
797
|
+
branch,
|
|
798
|
+
pr: entry.pr!,
|
|
799
|
+
parent: entry.parent,
|
|
800
|
+
parentPr: entry.parent === baseBranch ? null : (stack.branches[entry.parent]?.pr ?? null),
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
if (children.length === 0) return;
|
|
804
|
+
|
|
805
|
+
const nwo = (await getRepoNwo()) ?? "{owner}/{repo}";
|
|
806
|
+
const patchCommand = (sha: string) =>
|
|
807
|
+
`gh api -H "Accept: application/vnd.github.patch" repos/${nwo}/commits/${sha} | git am`;
|
|
808
|
+
const trunkRef = `origin/${await git.trunkBranch()}`;
|
|
809
|
+
|
|
810
|
+
for (;;) {
|
|
811
|
+
const s = p.spinner();
|
|
812
|
+
s.start(`Checking PR #${basePr} still holds every collapsed PR's changes...`);
|
|
813
|
+
const verdicts = await verifyCollapse({
|
|
814
|
+
base: baseBranch,
|
|
815
|
+
trunkRef,
|
|
816
|
+
children,
|
|
817
|
+
lookup: {
|
|
818
|
+
prResult: getPrMergeState,
|
|
819
|
+
commitPatch: getCommitPatch,
|
|
820
|
+
commitFiles: getCommitFiles,
|
|
821
|
+
},
|
|
822
|
+
});
|
|
823
|
+
const missing = verdicts.flatMap((v) => (v.status === "missing" ? [v] : []));
|
|
824
|
+
const uncertain = verdicts.flatMap((v) =>
|
|
825
|
+
v.status === "unconfirmed" || v.status === "unverified" ? [v] : [],
|
|
826
|
+
);
|
|
827
|
+
s.stop(
|
|
828
|
+
missing.length > 0
|
|
829
|
+
? pc.red(`PR #${basePr} is missing collapsed changes`)
|
|
830
|
+
: uncertain.length > 0
|
|
831
|
+
? pc.yellow(`Couldn't confirm PR #${basePr} holds every collapsed PR's changes`)
|
|
832
|
+
: `PR #${basePr} holds every collapsed PR's changes`,
|
|
833
|
+
);
|
|
834
|
+
|
|
835
|
+
if (missing.length > 0) {
|
|
836
|
+
console.log();
|
|
837
|
+
for (const v of missing) {
|
|
838
|
+
p.log.error(
|
|
839
|
+
`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.`,
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
console.log();
|
|
843
|
+
console.log(
|
|
844
|
+
` ${pc.yellow(baseBranch)} was rewritten after the collapse (e.g. a restack or sync that never pulled the squash commit).`,
|
|
845
|
+
);
|
|
846
|
+
console.log(
|
|
847
|
+
` Merging PR #${basePr} now would ship it without those PRs' changes. Nothing was merged to main.`,
|
|
848
|
+
);
|
|
849
|
+
console.log();
|
|
850
|
+
console.log(
|
|
851
|
+
` Put the squash commit(s) back on ${baseBranch}, then re-run ${pc.green("gh-stack merge")}:`,
|
|
852
|
+
);
|
|
853
|
+
console.log(
|
|
854
|
+
` ${pc.green(`git checkout ${baseBranch} && git pull --ff-only origin ${baseBranch}`)}`,
|
|
855
|
+
);
|
|
856
|
+
for (const v of missing) {
|
|
857
|
+
// An orphaned squash commit usually can't be fetched from GitHub, but
|
|
858
|
+
// its patch can: lead with what works.
|
|
859
|
+
console.log(
|
|
860
|
+
` ${pc.green(
|
|
861
|
+
v.fetchable
|
|
862
|
+
? `git fetch origin ${v.mergeCommit} && git cherry-pick ${v.mergeCommit}`
|
|
863
|
+
: patchCommand(v.mergeCommit),
|
|
864
|
+
)}`,
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
console.log(` ${pc.green(`git push --force-with-lease origin ${baseBranch}`)}`);
|
|
868
|
+
console.log(
|
|
869
|
+
` If the changes really are there in another form: ${pc.green("gh-stack merge --skip-collapse-check")}`,
|
|
870
|
+
);
|
|
871
|
+
process.exit(2);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (uncertain.length === 0) return;
|
|
875
|
+
|
|
876
|
+
console.log();
|
|
877
|
+
for (const v of uncertain) {
|
|
878
|
+
p.log.warn(
|
|
879
|
+
`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}.`,
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
console.log();
|
|
883
|
+
console.log(" Check by hand before merging:");
|
|
884
|
+
console.log(
|
|
885
|
+
` ${pc.green(`gh pr diff ${basePr} --name-only`)} ${pc.dim("(the base PR's files)")}`,
|
|
886
|
+
);
|
|
887
|
+
for (const v of uncertain) {
|
|
888
|
+
console.log(
|
|
889
|
+
` ${pc.green(`gh pr diff ${v.child.pr} --name-only`)} ${pc.dim(`(PR #${v.child.pr}'s files: each should be in #${basePr})`)}`,
|
|
890
|
+
);
|
|
891
|
+
if (v.status === "unconfirmed") {
|
|
892
|
+
console.log(
|
|
893
|
+
` ${pc.dim(`if they aren't, put its squash commit back on ${baseBranch}:`)} ${pc.green(patchCommand(v.mergeCommit))}`,
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
console.log(` If they're all there: ${pc.green("gh-stack merge --skip-collapse-check")}`);
|
|
898
|
+
|
|
899
|
+
if (isAutoYes()) {
|
|
900
|
+
p.cancel(
|
|
901
|
+
`Stopped before merging PR #${basePr}: its collapsed content is unverified, and --yes never merges on a guess. Nothing was merged to main.`,
|
|
902
|
+
);
|
|
903
|
+
process.exit(2);
|
|
904
|
+
}
|
|
905
|
+
const transient = uncertain.some((v) => v.transient);
|
|
906
|
+
const choice = await p.select({
|
|
907
|
+
message: `Merge PR #${basePr} without confirming those changes are in it?`,
|
|
908
|
+
initialValue: "stop",
|
|
909
|
+
options: [
|
|
910
|
+
...(transient
|
|
911
|
+
? [{ value: "retry", label: "Retry the check", hint: "a GitHub or network read failed" }]
|
|
912
|
+
: []),
|
|
913
|
+
{ value: "stop", label: "Stop — merge nothing" },
|
|
914
|
+
{ value: "merge", label: "Merge anyway" },
|
|
915
|
+
],
|
|
916
|
+
});
|
|
917
|
+
if (p.isCancel(choice) || choice === "stop") {
|
|
918
|
+
p.cancel(`Stopped before merging PR #${basePr}. Nothing was merged to main.`);
|
|
919
|
+
process.exit(2);
|
|
920
|
+
}
|
|
921
|
+
if (choice === "merge") return;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* The children collapsed into `base` by an earlier `merge --collapse`: walking
|
|
927
|
+
* up from the base, each branch whose PR merged into its stack parent (itself
|
|
928
|
+
* the base or collapsed). `unknown` is the first branch whose PR couldn't be
|
|
929
|
+
* read, when that stopped the walk.
|
|
930
|
+
*/
|
|
931
|
+
async function collapsedChildren(
|
|
932
|
+
stack: Stack,
|
|
933
|
+
ordered: string[],
|
|
934
|
+
base: string,
|
|
935
|
+
): Promise<{ collapsed: string[]; unknown: string | null }> {
|
|
936
|
+
const collapsed: string[] = [];
|
|
937
|
+
const into = new Set([base]);
|
|
938
|
+
for (const branch of ordered) {
|
|
939
|
+
const entry = stack.branches[branch];
|
|
940
|
+
if (branch === base || !entry?.pr || !into.has(entry.parent)) continue;
|
|
941
|
+
const state = await getPrMergeState(entry.pr);
|
|
942
|
+
if (!state) return { collapsed, unknown: branch };
|
|
943
|
+
if (state.state === "MERGED" && state.baseRefName === entry.parent) {
|
|
944
|
+
collapsed.push(branch);
|
|
945
|
+
into.add(branch);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
return { collapsed, unknown: null };
|
|
949
|
+
}
|
|
950
|
+
|
|
743
951
|
/**
|
|
744
952
|
* `gh-stack merge --base` — squash-merge ONLY the bottom PR into main, then
|
|
745
953
|
* re-root the next branch onto main (replaying just its own commits, via
|
|
@@ -747,7 +955,7 @@ OPTIONS
|
|
|
747
955
|
* next. Approval is NOT checked here; GitHub branch protection enforces it.
|
|
748
956
|
* A base PR already merged outside gh-stack is detected and advanced past.
|
|
749
957
|
*/
|
|
750
|
-
async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean): Promise<void> {
|
|
958
|
+
async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[]): Promise<void> {
|
|
751
959
|
// We rebase the surviving branches, so the same pre-flight guards as restack.
|
|
752
960
|
if (await git.isRebaseInProgress()) {
|
|
753
961
|
p.cancel(
|
|
@@ -837,6 +1045,52 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean): Promise<void
|
|
|
837
1045
|
process.exit(0);
|
|
838
1046
|
}
|
|
839
1047
|
|
|
1048
|
+
// #28: a base PR that carries PRs collapsed into it by an earlier
|
|
1049
|
+
// `merge --collapse` must still hold their content before it goes to main.
|
|
1050
|
+
// Collapsed children are found from GitHub (merged PRs whose base was their
|
|
1051
|
+
// stack parent); a lookup that fails is uncertainty, and fails closed.
|
|
1052
|
+
let collapsed: string[] = [];
|
|
1053
|
+
if (baseAction === "merge" && !args.includes("--skip-collapse-check")) {
|
|
1054
|
+
const found = await collapsedChildren(stack, ordered, baseBranch);
|
|
1055
|
+
if (found.unknown) {
|
|
1056
|
+
p.log.warn(
|
|
1057
|
+
`Couldn't tell whether PR #${basePr} carries PRs collapsed into it: ${found.unknown}'s PR couldn't be read from GitHub.`,
|
|
1058
|
+
);
|
|
1059
|
+
console.log(
|
|
1060
|
+
` Check by hand: ${pc.green(`gh pr view ${stack.branches[found.unknown]!.pr} --json state,baseRefName`)}`,
|
|
1061
|
+
);
|
|
1062
|
+
console.log(
|
|
1063
|
+
` Then re-run, or skip the check: ${pc.green("gh-stack merge --base --skip-collapse-check")}`,
|
|
1064
|
+
);
|
|
1065
|
+
if (isAutoYes()) {
|
|
1066
|
+
p.cancel(
|
|
1067
|
+
`Stopped before merging PR #${basePr} (--yes never merges on a guess). Nothing was merged.`,
|
|
1068
|
+
);
|
|
1069
|
+
process.exit(2);
|
|
1070
|
+
}
|
|
1071
|
+
const go = await p.confirm({ message: `Merge PR #${basePr} anyway?`, initialValue: false });
|
|
1072
|
+
if (p.isCancel(go) || !go) {
|
|
1073
|
+
p.cancel(`Stopped before merging PR #${basePr}. Nothing was merged.`);
|
|
1074
|
+
process.exit(2);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
collapsed = found.collapsed;
|
|
1078
|
+
if (collapsed.length > 0) {
|
|
1079
|
+
await ensureCollapseIntact(stack, [baseBranch, ...collapsed], baseBranch, basePr);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// The branches this run rewrites and force-pushes (the next child here, the
|
|
1084
|
+
// rest in the restack below) must not drop commits only origin has.
|
|
1085
|
+
// Collapsed children are left out: their origin copies hold the squash
|
|
1086
|
+
// commits of PRs merged into them, which aren't the user's to pull.
|
|
1087
|
+
const overwrite = args.includes("--overwrite-remote");
|
|
1088
|
+
const verified = await guardRemoteContent(
|
|
1089
|
+
meta,
|
|
1090
|
+
ordered.slice(1).filter((b) => !collapsed.includes(b)),
|
|
1091
|
+
{ command: "merge --base", overwrite, dryRun: false },
|
|
1092
|
+
);
|
|
1093
|
+
|
|
840
1094
|
// Snapshot first — this records every branch's pre-merge tip, which both the
|
|
841
1095
|
// fork-point resolver and `gh-stack undo` rely on.
|
|
842
1096
|
await takeSnapshot(meta, stackName, "merge-base");
|
|
@@ -905,6 +1159,7 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean): Promise<void
|
|
|
905
1159
|
childPr,
|
|
906
1160
|
targetRef,
|
|
907
1161
|
recordedBase: trunk,
|
|
1162
|
+
expect: verified.get(child),
|
|
908
1163
|
});
|
|
909
1164
|
if (!res.ok) {
|
|
910
1165
|
console.log();
|
|
@@ -973,7 +1228,10 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean): Promise<void
|
|
|
973
1228
|
if (await git.localBranchExists(newBottom)) {
|
|
974
1229
|
await git.checkout(newBottom);
|
|
975
1230
|
const { default: restack } = await import("./restack.ts");
|
|
976
|
-
|
|
1231
|
+
// Always push: merge publishes by nature (the advance above already
|
|
1232
|
+
// pushed), and children left stale on GitHub are worse than any default.
|
|
1233
|
+
// An explicit flag also means no config is read mid-merge.
|
|
1234
|
+
await restack(["--push", ...(overwrite ? ["--overwrite-remote"] : [])]);
|
|
977
1235
|
}
|
|
978
1236
|
|
|
979
1237
|
p.outro(
|