@mutmutco/cli 3.56.0 → 3.57.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.
Files changed (2) hide show
  1. package/dist/main.cjs +110 -13
  2. package/package.json +2 -2
package/dist/main.cjs CHANGED
@@ -9605,6 +9605,23 @@ function describePrMergeEnqueuedReason(checks) {
9605
9605
  };
9606
9606
  }
9607
9607
  }
9608
+ function decidePrMergeCleanupGate(input) {
9609
+ const state = (input.state ?? "").trim().toUpperCase();
9610
+ if (state === "MERGED") return { action: "cleanup" };
9611
+ if (!state) {
9612
+ return {
9613
+ action: "refuse",
9614
+ reason: "state-unreadable",
9615
+ message: `could not read the PR's state after the merge attempt${input.stateError ? `: ${input.stateError}` : ""} \u2014 cleanup skipped because the merge is unproven`
9616
+ };
9617
+ }
9618
+ if (input.autoRequested) return { action: "enqueued" };
9619
+ return {
9620
+ action: "refuse",
9621
+ reason: "not-merged",
9622
+ message: `the PR is ${state}, not MERGED \u2014 the merge did not happen, so the branch and worktree were left untouched`
9623
+ };
9624
+ }
9608
9625
  function parseGhPrChecksOutput(stdout) {
9609
9626
  const text = stdout.trim();
9610
9627
  if (!text) return "no-checks-reported";
@@ -21808,6 +21825,54 @@ function formatStaleLeaks(leaks) {
21808
21825
  async function repoRootOf() {
21809
21826
  return (await execFileP2("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim() || process.cwd();
21810
21827
  }
21828
+ function decideWorktreeLandRefCleanup(input) {
21829
+ if (input.keepRemote) return { action: "proceed" };
21830
+ if (!input.prs) {
21831
+ return {
21832
+ action: "keep-remote",
21833
+ reason: "pr-state-unreadable",
21834
+ message: `could not read the pull-request state for '${input.branch}' \u2014 keeping the remote branch so an unmerged head ref cannot be deleted on a guess`
21835
+ };
21836
+ }
21837
+ const open2 = input.prs.filter((pr2) => pr2.state.toUpperCase() === "OPEN");
21838
+ if (open2.length) {
21839
+ return {
21840
+ action: "refuse",
21841
+ reason: "open-pr",
21842
+ message: `'${input.branch}' still has an OPEN pull request (#${open2.map((pr2) => pr2.number).join(", #")}). Deleting its head ref would auto-close the PR and leave the work on no branch. Merge it first, or re-run with --keep-remote for a local-only clean`
21843
+ };
21844
+ }
21845
+ if (input.prs.length && !input.prs.some((pr2) => pr2.state.toUpperCase() === "MERGED")) {
21846
+ return {
21847
+ action: "refuse",
21848
+ reason: "unmerged-pr",
21849
+ message: `'${input.branch}' has a pull request that never merged (#${input.prs.map((pr2) => pr2.number).join(", #")}). The remote branch is the only thing still holding that work. Re-run with --keep-remote for a local-only clean`
21850
+ };
21851
+ }
21852
+ return { action: "proceed" };
21853
+ }
21854
+ async function fetchLandBranchPrs(branch, repo) {
21855
+ try {
21856
+ const { stdout } = await execFileP2("gh", [
21857
+ "pr",
21858
+ "list",
21859
+ "--head",
21860
+ branch,
21861
+ "--state",
21862
+ "all",
21863
+ "--limit",
21864
+ "20",
21865
+ ...repo ? ["--repo", repo] : [],
21866
+ "--json",
21867
+ "number,state"
21868
+ ], { timeout: GH_TIMEOUT_MS });
21869
+ const parsed = JSON.parse(stdout.trim() || "null");
21870
+ if (!Array.isArray(parsed)) return void 0;
21871
+ return parsed.filter((pr2) => Boolean(pr2) && typeof pr2 === "object" && typeof pr2.number === "number" && typeof pr2.state === "string").map((pr2) => ({ number: pr2.number, state: pr2.state }));
21872
+ } catch {
21873
+ return void 0;
21874
+ }
21875
+ }
21811
21876
  async function fetchIssueTitle(repo, number) {
21812
21877
  try {
21813
21878
  const { stdout } = await execFileP2("gh", ["issue", "view", String(number), "--repo", repo, "--json", "title", "--jq", ".title"], { timeout: GH_TIMEOUT_MS });
@@ -21869,6 +21934,16 @@ function registerWorktreeCommands(program3) {
21869
21934
  if (landDirty) {
21870
21935
  return fail(`worktree land: refusing to land '${branch}' \u2014 uncommitted changes in ${wtPath}; commit, stash, or remove the worktree manually`, { code: ERROR_CODES.ERR_BAD_ENUM });
21871
21936
  }
21937
+ const landRefs = decideWorktreeLandRefCleanup({
21938
+ branch,
21939
+ prs: await fetchLandBranchPrs(branch),
21940
+ keepRemote: Boolean(o.keepRemote)
21941
+ });
21942
+ if (landRefs.action === "refuse") {
21943
+ return fail(`worktree land: ${landRefs.message}`, { code: ERROR_CODES.ERR_BAD_ENUM });
21944
+ }
21945
+ const keepRemoteBranch = Boolean(o.keepRemote) || landRefs.action === "keep-remote";
21946
+ if (landRefs.action === "keep-remote") console.warn(`worktree land: ${landRefs.message}.`);
21872
21947
  const report = [];
21873
21948
  if (hasStage) {
21874
21949
  try {
@@ -21888,8 +21963,10 @@ function registerWorktreeCommands(program3) {
21888
21963
  status: removeOutcome.status === "removed" ? removeOutcome.recovery ? `done (${removeOutcome.recovery})` : "done" : `failed: ${removeOutcome.error ?? "lock held"} \u2014 run: git -C "${primaryCheckout}" worktree remove --force "${wtPath}"`
21889
21964
  });
21890
21965
  report.push(await bestEffortGit(["branch", "-D", branch], primaryCheckout, `delete local branch ${branch}`));
21891
- if (!o.keepRemote) {
21966
+ if (!keepRemoteBranch) {
21892
21967
  report.push(await bestEffortGit(["push", o.remote, "--delete", branch], void 0, `delete remote branch ${branch}`, GH_MUTATION_TIMEOUT_MS));
21968
+ } else if (!o.keepRemote) {
21969
+ report.push({ step: `delete remote branch ${branch}`, status: `skipped: ${landRefs.action === "keep-remote" ? landRefs.reason : "kept"}` });
21893
21970
  }
21894
21971
  report.push(await bestEffortGit(["worktree", "prune"], primaryCheckout, "prune worktree metadata"));
21895
21972
  const result = { dryRun: false, ...plan, ...o.keepRemote ? { keepRemote: true } : {}, report };
@@ -26514,6 +26591,7 @@ pr.command("land <number>").description("agent merge path (#1440): train probe \
26514
26591
  jsonParity(pr.command("merge <number>").description("merge a PR (squash by default) and clean up its branch + worktree \u2014 no leftover local branch; on no-ci repos run pr ci-policy / checks-wait first (#1432)").option("--squash", "squash merge (default)").option("--merge", "create a merge commit").option("--rebase", "rebase merge").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--auto", "enable auto-merge \u2014 merge once the base-branch policy is satisfied (use for policy-gated repos)").option("--wait", `wait for checks to reach a terminal passing verdict before merging (default budget ${PR_CHECKS_TIMEOUT_MS / 6e4}m)`).option("--preserve-worktree", "keep the local PR worktree/stage/branch for an active multi-issue batch (#1888)").option("--force", "acknowledge and merge past a remaining prunable/severe scratch housekeeping block (advisory kept plans/ never blocks, #3012)")).action(async (number, o) => {
26515
26592
  const method = o.rebase ? "--rebase" : o.merge ? "--merge" : "--squash";
26516
26593
  const repoArgs = o.repo ? ["--repo", o.repo] : [];
26594
+ const repoForPostCleanup = await resolveRepo(o.repo) ?? o.repo;
26517
26595
  const [headRef, baseRef, headRefOid] = (await execFileP2("gh", ["pr", "view", number, ...repoArgs, "--json", "headRefName,baseRefName,headRefOid", "--jq", '.headRefName + " " + .baseRefName + " " + (.headRefOid // "")'], { timeout: GC_GH_TIMEOUT_MS2 })).stdout.trim().split(/\s+/);
26518
26596
  const headIsProtected = isProtectedBranch(headRef);
26519
26597
  const startingPath = (await execFileP2("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim();
@@ -26589,18 +26667,37 @@ jsonParity(pr.command("merge <number>").description("merge a PR (squash by defau
26589
26667
  }
26590
26668
  if (!ghPrMergeLocalBranchDeleteWarning(message)) throw e;
26591
26669
  });
26592
- if (o.auto || upgradedToAuto) {
26593
- const state = (await execFileP2("gh", ["pr", "view", number, ...repoArgs, "--json", "state", "--jq", ".state"], { timeout: GC_GH_TIMEOUT_MS2 }).catch(() => ({ stdout: "" }))).stdout.trim();
26594
- if (state !== "MERGED") {
26595
- const enqueued = describePrMergeEnqueuedReason(await pollGhPrChecks(number, repoArgs).catch(() => void 0));
26596
- console.log(JSON.stringify({ mergeStatus: "auto-merge-enqueued", enqueuedReason: enqueued.reason, pr: number, branch: headRef, state: state || "unknown", upgradedToAuto: upgradedToAuto || void 0 }));
26597
- console.warn(`pr merge: PR #${number} is ENQUEUED, not merged \u2014 ${enqueued.message}.`);
26598
- if (upgradedToAuto && !o.auto) {
26599
- console.warn(`pr merge: exiting ${PR_MERGE_ENQUEUED_EXIT_CODE} so a chained command does not treat this as a completed merge.`);
26600
- process.exitCode = PR_MERGE_ENQUEUED_EXIT_CODE;
26601
- }
26602
- return;
26670
+ const stateRead = await readGhPrStateWithRetry(
26671
+ () => execFileP2("gh", ["pr", "view", number, ...repoArgs, "--json", "state", "--jq", ".state"], { timeout: GC_GH_TIMEOUT_MS2 }).then((r) => r.stdout)
26672
+ );
26673
+ const gate = decidePrMergeCleanupGate({
26674
+ state: stateRead.ok ? stateRead.state : void 0,
26675
+ stateError: stateRead.ok ? void 0 : stateRead.error,
26676
+ autoRequested: Boolean(o.auto || upgradedToAuto)
26677
+ });
26678
+ if (gate.action === "enqueued") {
26679
+ const state = stateRead.ok ? stateRead.state : "";
26680
+ const enqueued = describePrMergeEnqueuedReason(await pollGhPrChecks(number, repoArgs).catch(() => void 0));
26681
+ console.log(JSON.stringify({ mergeStatus: "auto-merge-enqueued", enqueuedReason: enqueued.reason, pr: number, branch: headRef, state: state || "unknown", upgradedToAuto: upgradedToAuto || void 0 }));
26682
+ console.warn(`pr merge: PR #${number} is ENQUEUED, not merged \u2014 ${enqueued.message}.`);
26683
+ if (upgradedToAuto && !o.auto) {
26684
+ console.warn(`pr merge: exiting ${PR_MERGE_ENQUEUED_EXIT_CODE} so a chained command does not treat this as a completed merge.`);
26685
+ process.exitCode = PR_MERGE_ENQUEUED_EXIT_CODE;
26603
26686
  }
26687
+ return;
26688
+ }
26689
+ if (gate.action === "refuse") {
26690
+ console.log(JSON.stringify({
26691
+ mergeStatus: "not-merged",
26692
+ reason: gate.reason,
26693
+ pr: number,
26694
+ branch: headRef,
26695
+ state: stateRead.ok ? stateRead.state : "unknown",
26696
+ cleanupStatus: "skipped"
26697
+ }));
26698
+ console.error(`pr merge: ${gate.message}. Nothing was deleted \u2014 re-check the PR and retry.`);
26699
+ process.exitCode = 1;
26700
+ return;
26604
26701
  }
26605
26702
  if (!remoteNotAttemptedReason) remoteDeleteAttempted = true;
26606
26703
  const remoteBranch = await buildPrMergeRemoteBranchCleanupReport(headRef, {
@@ -26641,7 +26738,7 @@ jsonParity(pr.command("merge <number>").description("merge a PR (squash by defau
26641
26738
  }
26642
26739
  };
26643
26740
  }
26644
- const boardAdvance = await advanceClosedIssuesToDone2(number, o.repo);
26741
+ const boardAdvance = await advanceClosedIssuesToDone2(number, repoForPostCleanup);
26645
26742
  console.log(JSON.stringify({
26646
26743
  ...buildPrMergeResultPayload({
26647
26744
  number,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "3.56.0",
3
+ "version": "3.57.0",
4
4
  "description": "MMI Future CLI — the org dev toolbox (board, registry, keyless secrets, release train, bootstrap, doctor) and the cross-IDE engine the plugin's session-start hook drives.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -31,7 +31,7 @@
31
31
  },
32
32
  "scripts": {
33
33
  "build": "node build.mjs",
34
- "test": "vitest run --reporter=default --reporter=../scripts/test-budget-reporter.mjs",
34
+ "test": "vitest run",
35
35
  "typecheck": "tsc --noEmit"
36
36
  },
37
37
  "dependencies": {