@mutmutco/cli 3.55.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 +111 -67
  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 };
@@ -24401,34 +24478,7 @@ async function runDoctorClean(opts, io, deps) {
24401
24478
  const cli = checkCliVersion(cliInput, releasedNote);
24402
24479
  if (cli) checks.push(cli);
24403
24480
  }
24404
- const cacheProbe = deps.pluginCache();
24405
- const cacheStale = cacheProbe.stale.length > 0 || cacheProbe.staging.length > 0;
24406
- if (applyEnv && deps.prunePluginCache && cacheStale) {
24407
- const pruned = await deps.prunePluginCache();
24408
- const removed = pruned.removed ?? [];
24409
- const pruneEvidence = [
24410
- `cached versions: ${cacheProbe.cached}`,
24411
- `stale: ${cacheProbe.stale.length ? cacheProbe.stale.join(", ") : "(none)"}`,
24412
- `orphaned staging dirs: ${cacheProbe.staging.length ? cacheProbe.staging.join(", ") : "(none)"}`,
24413
- `prune: ${pruned.detail}`,
24414
- ...removed.map((r) => `removed: ${r}`)
24415
- ];
24416
- checks.push(pruned.ok ? {
24417
- ok: true,
24418
- label: "plugin cache",
24419
- detail: removed.length ? `pruned ${removed.length} stale entr${removed.length === 1 ? "y" : "ies"}` : "nothing to prune",
24420
- verbose: pruneEvidence
24421
- } : {
24422
- ok: false,
24423
- label: "plugin cache",
24424
- // Same split as the other env heals (#3489): a contention skip is not this run's gap.
24425
- ...pruned.skipped ? { reportOnly: true } : {},
24426
- fix: pruned.skipped ? `${pruned.detail} \u2014 another doctor on this machine is pruning it now; re-run once it finishes` : `prune failed (${pruned.detail}) \u2014 run \`mmi-cli plugin prune --apply\``,
24427
- verbose: pruneEvidence
24428
- });
24429
- } else {
24430
- checks.push(checkPluginCache(cacheProbe));
24431
- }
24481
+ checks.push(checkPluginCache(deps.pluginCache()));
24432
24482
  if (deps.sessionPayload) {
24433
24483
  const payload = checkSessionPayload(deps.sessionPayload());
24434
24484
  if (payload) checks.push(payload);
@@ -24847,32 +24897,6 @@ function mmiDoctorDeps(opts = {}) {
24847
24897
  stagingBytes: plan.stagingBytes
24848
24898
  };
24849
24899
  },
24850
- // #3485 wave 2 item 11: the --apply reap, through the SAME plan + reaper `plugin prune --apply` drives.
24851
- // The plan's own keep-policy refuses the running and newest versions, so this cannot delete what is
24852
- // loaded. Under the #3489 env-heal lock: deleting cache dirs while another doctor is mid-reinstall is
24853
- // the same machine-global hazard the lock exists for.
24854
- prunePluginCache: () => withEnvHealLock("plugin cache prune", async () => {
24855
- const plan = buildPluginCachePlan(
24856
- (0, import_node_os9.homedir)(),
24857
- runningPluginVersion(process.env, resolveClientVersion()),
24858
- pluginCacheFsDeps((0, import_node_os9.homedir)(), directoryBytes),
24859
- { withBytes: true }
24860
- );
24861
- const result = applyPluginCachePlan(
24862
- plan,
24863
- (p) => (0, import_node_fs32.rmSync)(p, { recursive: true, force: true }),
24864
- stagingApplyFsGuard((0, import_node_os9.homedir)())
24865
- );
24866
- const removed = [...result.removed, ...result.removedStaging ?? []];
24867
- if (result.failed.length) {
24868
- return {
24869
- ok: false,
24870
- removed,
24871
- detail: `${removed.length} removed, ${result.failed.length} failed \u2014 ${result.failed.map((f) => `${f.version}: ${f.error}`).join("; ")}`
24872
- };
24873
- }
24874
- return { ok: true, removed, detail: removed.length ? `removed ${removed.join(", ")}` : "nothing to prune" };
24875
- }),
24876
24900
  // #3008: the schedules-notebook drift probe, compacted for the check. Full doctor only — the gather in
24877
24901
  // runDoctorClean skips this dep entirely on fast/banner/preflight runs.
24878
24902
  schedulesNotebook: async () => {
@@ -26567,6 +26591,7 @@ pr.command("land <number>").description("agent merge path (#1440): train probe \
26567
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) => {
26568
26592
  const method = o.rebase ? "--rebase" : o.merge ? "--merge" : "--squash";
26569
26593
  const repoArgs = o.repo ? ["--repo", o.repo] : [];
26594
+ const repoForPostCleanup = await resolveRepo(o.repo) ?? o.repo;
26570
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+/);
26571
26596
  const headIsProtected = isProtectedBranch(headRef);
26572
26597
  const startingPath = (await execFileP2("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim();
@@ -26642,18 +26667,37 @@ jsonParity(pr.command("merge <number>").description("merge a PR (squash by defau
26642
26667
  }
26643
26668
  if (!ghPrMergeLocalBranchDeleteWarning(message)) throw e;
26644
26669
  });
26645
- if (o.auto || upgradedToAuto) {
26646
- const state = (await execFileP2("gh", ["pr", "view", number, ...repoArgs, "--json", "state", "--jq", ".state"], { timeout: GC_GH_TIMEOUT_MS2 }).catch(() => ({ stdout: "" }))).stdout.trim();
26647
- if (state !== "MERGED") {
26648
- const enqueued = describePrMergeEnqueuedReason(await pollGhPrChecks(number, repoArgs).catch(() => void 0));
26649
- console.log(JSON.stringify({ mergeStatus: "auto-merge-enqueued", enqueuedReason: enqueued.reason, pr: number, branch: headRef, state: state || "unknown", upgradedToAuto: upgradedToAuto || void 0 }));
26650
- console.warn(`pr merge: PR #${number} is ENQUEUED, not merged \u2014 ${enqueued.message}.`);
26651
- if (upgradedToAuto && !o.auto) {
26652
- console.warn(`pr merge: exiting ${PR_MERGE_ENQUEUED_EXIT_CODE} so a chained command does not treat this as a completed merge.`);
26653
- process.exitCode = PR_MERGE_ENQUEUED_EXIT_CODE;
26654
- }
26655
- 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;
26656
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;
26657
26701
  }
26658
26702
  if (!remoteNotAttemptedReason) remoteDeleteAttempted = true;
26659
26703
  const remoteBranch = await buildPrMergeRemoteBranchCleanupReport(headRef, {
@@ -26694,7 +26738,7 @@ jsonParity(pr.command("merge <number>").description("merge a PR (squash by defau
26694
26738
  }
26695
26739
  };
26696
26740
  }
26697
- const boardAdvance = await advanceClosedIssuesToDone2(number, o.repo);
26741
+ const boardAdvance = await advanceClosedIssuesToDone2(number, repoForPostCleanup);
26698
26742
  console.log(JSON.stringify({
26699
26743
  ...buildPrMergeResultPayload({
26700
26744
  number,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "3.55.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": {