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.
@@ -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 { getBranchRefSha, getPrInfo, getPrMergeState, resyncPrHead } from "../lib/github.ts";
16
- import { confirmAction } from "../lib/ui.ts";
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
- await restack([]);
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(