@mutmutco/cli 3.58.0 → 3.59.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/dist/main.cjs +2003 -1728
- package/package.json +1 -1
package/dist/main.cjs
CHANGED
|
@@ -3411,7 +3411,7 @@ var program = new Command();
|
|
|
3411
3411
|
|
|
3412
3412
|
// src/index.ts
|
|
3413
3413
|
var import_promises8 = require("node:fs/promises");
|
|
3414
|
-
var
|
|
3414
|
+
var import_node_fs33 = require("node:fs");
|
|
3415
3415
|
var import_node_child_process13 = require("node:child_process");
|
|
3416
3416
|
|
|
3417
3417
|
// src/cli-shared.ts
|
|
@@ -9679,7 +9679,7 @@ function commandLadderHint() {
|
|
|
9679
9679
|
}
|
|
9680
9680
|
|
|
9681
9681
|
// src/index.ts
|
|
9682
|
-
var
|
|
9682
|
+
var import_node_path31 = require("node:path");
|
|
9683
9683
|
|
|
9684
9684
|
// src/merge-ci-policy.ts
|
|
9685
9685
|
function resolveMergeCiPolicy(input) {
|
|
@@ -12163,7 +12163,7 @@ async function executeWaveLand(plan, deps, opts = { preserveWorktree: true }) {
|
|
|
12163
12163
|
}
|
|
12164
12164
|
|
|
12165
12165
|
// src/index.ts
|
|
12166
|
-
var
|
|
12166
|
+
var import_node_os10 = require("node:os");
|
|
12167
12167
|
|
|
12168
12168
|
// src/attach-to-project.ts
|
|
12169
12169
|
async function resolveAutoAddBoardAttach(client, cfg, selector, priority, warn = (m) => process.stderr.write(m), retry = {}) {
|
|
@@ -14032,16 +14032,17 @@ async function rollDevelopmentForward(deps, ctx, tag) {
|
|
|
14032
14032
|
}
|
|
14033
14033
|
async function enqueueAlignmentAutoMerge(deps, ctx, prNumber, prUrl, openedNote) {
|
|
14034
14034
|
const base = { status: "pr-pending", prNumber, prUrl };
|
|
14035
|
-
const
|
|
14035
|
+
const retryCommand = `mmi-cli pr merge ${prNumber ?? "<number>"} --auto --merge`;
|
|
14036
|
+
const manual = `${openedNote} \u2014 land it with \`${retryCommand}\``;
|
|
14036
14037
|
if (deps.mergeAuto && prNumber !== void 0) {
|
|
14037
14038
|
try {
|
|
14038
14039
|
const res = await deps.mergeAuto(String(prNumber), ctx.repo);
|
|
14039
14040
|
if (res.mergeStatus !== "failed") {
|
|
14040
14041
|
return { ...base, autoMergeEnqueued: true, note: `${openedNote} \u2014 auto-merge enqueued (merges when checks pass)` };
|
|
14041
14042
|
}
|
|
14042
|
-
deps.warn?.(`alignment PR #${prNumber} auto-merge
|
|
14043
|
+
deps.warn?.(`alignment PR #${prNumber} auto-merge not armed (${res.error ?? "unknown error"}) \u2014 re-run: ${retryCommand}`);
|
|
14043
14044
|
} catch (e) {
|
|
14044
|
-
deps.warn?.(`alignment PR #${prNumber} auto-merge enqueue threw (${e instanceof Error ? e.message : String(e)}) \u2014
|
|
14045
|
+
deps.warn?.(`alignment PR #${prNumber} auto-merge enqueue threw (${e instanceof Error ? e.message : String(e)}) \u2014 re-run: ${retryCommand}`);
|
|
14045
14046
|
}
|
|
14046
14047
|
}
|
|
14047
14048
|
return { ...base, note: manual };
|
|
@@ -19221,13 +19222,13 @@ function registerSchedulesLiftCommand(program3, deps = {}) {
|
|
|
19221
19222
|
// src/edge-tunnel.ts
|
|
19222
19223
|
var HOSTNAME_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
|
|
19223
19224
|
var UPSTREAM_RE = /^https?:\/\/[^/\s]+(?::\d+)?(?:\/.*)?$/;
|
|
19224
|
-
function tunnelNameFromHostname(
|
|
19225
|
-
return
|
|
19225
|
+
function tunnelNameFromHostname(hostname2) {
|
|
19226
|
+
return hostname2.replace(/\./g, "-").slice(0, 63);
|
|
19226
19227
|
}
|
|
19227
|
-
function planInfraTunnel(
|
|
19228
|
-
const host =
|
|
19228
|
+
function planInfraTunnel(hostname2, upstream) {
|
|
19229
|
+
const host = hostname2.trim().toLowerCase();
|
|
19229
19230
|
const origin = upstream.trim();
|
|
19230
|
-
if (!HOSTNAME_RE.test(host)) throw new Error(`invalid hostname ${JSON.stringify(
|
|
19231
|
+
if (!HOSTNAME_RE.test(host)) throw new Error(`invalid hostname ${JSON.stringify(hostname2)}`);
|
|
19231
19232
|
if (!UPSTREAM_RE.test(origin)) throw new Error(`invalid upstream ${JSON.stringify(upstream)} \u2014 expected http(s)://host:port`);
|
|
19232
19233
|
const tunnelName = tunnelNameFromHostname(host);
|
|
19233
19234
|
const configYaml = [
|
|
@@ -21201,9 +21202,9 @@ function registerBoardCommands(program3) {
|
|
|
21201
21202
|
}
|
|
21202
21203
|
|
|
21203
21204
|
// src/merge-cleanup.ts
|
|
21204
|
-
var
|
|
21205
|
+
var import_node_fs27 = require("node:fs");
|
|
21205
21206
|
var import_promises6 = require("node:fs/promises");
|
|
21206
|
-
var
|
|
21207
|
+
var import_node_path26 = require("node:path");
|
|
21207
21208
|
var import_node_child_process11 = require("node:child_process");
|
|
21208
21209
|
|
|
21209
21210
|
// src/board-advance.ts
|
|
@@ -21369,1870 +21370,2100 @@ function makeDeferredWorktreeStore(registryPath, lockOpts = {}) {
|
|
|
21369
21370
|
};
|
|
21370
21371
|
}
|
|
21371
21372
|
|
|
21372
|
-
// src/
|
|
21373
|
-
var
|
|
21374
|
-
|
|
21375
|
-
|
|
21376
|
-
|
|
21377
|
-
|
|
21378
|
-
|
|
21373
|
+
// src/plugin-guard-io.ts
|
|
21374
|
+
var import_node_fs25 = require("node:fs");
|
|
21375
|
+
var import_node_path24 = require("node:path");
|
|
21376
|
+
var import_node_os6 = require("node:os");
|
|
21377
|
+
|
|
21378
|
+
// src/plugin-guard.ts
|
|
21379
|
+
function buildPluginGuardDecision(i) {
|
|
21380
|
+
if (!i.isOrgRepo) return { state: "not-org" };
|
|
21381
|
+
if (!i.installRecordPresent) return { state: "no-install" };
|
|
21382
|
+
if (!i.marketplaceClonePresent || !i.pluginCachePresent) return { state: "unresolved" };
|
|
21383
|
+
return { state: "healthy" };
|
|
21384
|
+
}
|
|
21385
|
+
function buildPluginGuardLine(state, opts = {}) {
|
|
21386
|
+
if (state === "healthy" || state === "not-org") return { exitCode: 0 };
|
|
21387
|
+
const recovery = opts.recovery ?? "mmi-cli plugin heal";
|
|
21388
|
+
const restartHint = opts.restartHint ?? "restart your agent host / reload plugins";
|
|
21389
|
+
const reason = state === "no-install" ? "MMI plugin is not installed for this user/session" : "MMI plugin is installed but its marketplace/cache is unresolved";
|
|
21390
|
+
return {
|
|
21391
|
+
line: `[mmi-guard] ${reason}; run ${recovery} and ${restartHint}.`,
|
|
21392
|
+
exitCode: 1
|
|
21393
|
+
};
|
|
21394
|
+
}
|
|
21395
|
+
|
|
21396
|
+
// src/plugin-guard-io.ts
|
|
21397
|
+
var isWin = process.platform === "win32";
|
|
21398
|
+
var MMI_PLUGIN_ID = "mmi@mutmutco";
|
|
21399
|
+
var LEGACY_MMI_MARKETPLACE = "mmi";
|
|
21400
|
+
function detectSurface(env) {
|
|
21401
|
+
const has = (k) => Boolean(env[k]?.trim());
|
|
21402
|
+
if (env.MMI_AGENT_SURFACE === "codex" || has("CODEX_HOME") || (env.CLAUDE_PLUGIN_ROOT ?? "").includes(".codex")) {
|
|
21403
|
+
return "codex";
|
|
21404
|
+
}
|
|
21405
|
+
if (env.MMI_AGENT_SURFACE === "cursor" || has("CURSOR_TRACE_ID") || has("CURSOR_USER") || has("CURSOR_SESSION_ID") || env.CURSOR_AGENT === "1" || has("CURSOR_EXTENSION_HOST_ROLE")) {
|
|
21406
|
+
return "cursor";
|
|
21379
21407
|
}
|
|
21408
|
+
if (env.MMI_AGENT_SURFACE === "opencode" || has("OPENCODE_CLIENT") || has("OPENCODE_SERVER_USERNAME")) return "opencode";
|
|
21409
|
+
const isClaude = has("CLAUDECODE") || has("CLAUDE_CODE_ENTRYPOINT") || has("CLAUDE_PLUGIN_ROOT") || env.MMI_AGENT_SURFACE === "claude";
|
|
21410
|
+
const isVscode = env.TERM_PROGRAM === "vscode" || has("VSCODE_PID") || has("VSCODE_GIT_ASKPASS_NODE");
|
|
21411
|
+
if (isClaude && isVscode) return "claude-vscode";
|
|
21412
|
+
if (isClaude) return "claude-cli";
|
|
21413
|
+
return "shell";
|
|
21380
21414
|
}
|
|
21381
|
-
|
|
21382
|
-
|
|
21383
|
-
|
|
21384
|
-
|
|
21385
|
-
|
|
21386
|
-
|
|
21387
|
-
|
|
21388
|
-
|
|
21389
|
-
|
|
21415
|
+
function surfaceToken(surface) {
|
|
21416
|
+
switch (surface) {
|
|
21417
|
+
case "claude-cli":
|
|
21418
|
+
case "claude-vscode":
|
|
21419
|
+
return "claude";
|
|
21420
|
+
case "codex":
|
|
21421
|
+
return "codex";
|
|
21422
|
+
case "cursor":
|
|
21423
|
+
return "cursor";
|
|
21424
|
+
case "opencode":
|
|
21425
|
+
return "opencode";
|
|
21426
|
+
case "shell":
|
|
21427
|
+
default:
|
|
21428
|
+
return null;
|
|
21390
21429
|
}
|
|
21391
|
-
|
|
21392
|
-
|
|
21393
|
-
|
|
21394
|
-
|
|
21395
|
-
|
|
21396
|
-
|
|
21397
|
-
|
|
21398
|
-
|
|
21399
|
-
return
|
|
21400
|
-
|
|
21401
|
-
|
|
21402
|
-
|
|
21403
|
-
|
|
21404
|
-
|
|
21430
|
+
}
|
|
21431
|
+
function reloadAction(surface) {
|
|
21432
|
+
switch (surface) {
|
|
21433
|
+
case "claude-vscode":
|
|
21434
|
+
return "restart VS Code";
|
|
21435
|
+
case "codex":
|
|
21436
|
+
return "restart Codex";
|
|
21437
|
+
case "opencode":
|
|
21438
|
+
return "restart OpenCode";
|
|
21439
|
+
case "cursor":
|
|
21440
|
+
return "restart Cursor (or refresh the Team Marketplace from Dashboard \u2192 Settings \u2192 Plugins)";
|
|
21441
|
+
case "claude-cli":
|
|
21442
|
+
case "shell":
|
|
21443
|
+
default:
|
|
21444
|
+
return "restart Claude Code (or run /reload-plugins)";
|
|
21445
|
+
}
|
|
21446
|
+
}
|
|
21447
|
+
var CLAUDE_RECOVERY = `claude plugin marketplace remove ${LEGACY_MMI_MARKETPLACE} && claude plugin marketplace remove mutmutco && claude plugin marketplace add mutmutco/MMI-Hub --ref main && claude plugin install mmi@mutmutco`;
|
|
21448
|
+
var PLUGIN_SURFACE_HEAL = {
|
|
21449
|
+
claude: {
|
|
21450
|
+
delivery: "plugin-cli",
|
|
21451
|
+
recovery: CLAUDE_RECOVERY,
|
|
21452
|
+
healSteps: [
|
|
21453
|
+
{ args: ["plugin", "marketplace", "remove", LEGACY_MMI_MARKETPLACE], gated: false },
|
|
21454
|
+
{ args: ["plugin", "marketplace", "remove", "mutmutco"], gated: false },
|
|
21455
|
+
{ args: ["plugin", "marketplace", "add", "mutmutco/MMI-Hub", "--ref", "main"], gated: true },
|
|
21456
|
+
{ args: ["plugin", "install", "mmi@mutmutco"], gated: true },
|
|
21457
|
+
{ args: ["plugin", "enable", "mmi@mutmutco"], gated: false }
|
|
21458
|
+
],
|
|
21459
|
+
fix: (surface) => `${CLAUDE_RECOVERY} # then ${reloadAction(surface)} to reload MMI commands`,
|
|
21460
|
+
updateRecipe: [CLAUDE_RECOVERY]
|
|
21461
|
+
}
|
|
21462
|
+
};
|
|
21463
|
+
function nonClaudeSurfaceHealMessage(surface) {
|
|
21464
|
+
if (surface === "codex") {
|
|
21465
|
+
return "Codex ships an MMI plugin (#3563). Install or repair it with:\n codex plugin marketplace add mutmutco/MMI-Hub && codex plugin add mmi@mutmutco\n Then run /hooks and TRUST the MMI hooks \u2014 bundled hooks are skipped until reviewed.\n Update the CLI too: npm i -g @mutmutco/cli";
|
|
21466
|
+
}
|
|
21467
|
+
return "No Hub-shipped MMI plugin on this host \u2014 the Hub ships plugins for Claude and Codex only.\n Update the CLI instead: npm i -g @mutmutco/cli (org rules ride an AGENTS.md authored outside the Hub)";
|
|
21468
|
+
}
|
|
21469
|
+
function healStepAborts(step, ok) {
|
|
21470
|
+
return !ok && step.gated;
|
|
21471
|
+
}
|
|
21472
|
+
function marketplaceAddSupportsRef(helpText) {
|
|
21473
|
+
if (!helpText) return false;
|
|
21474
|
+
return /(^|\s)--ref(\b|=)/.test(helpText);
|
|
21475
|
+
}
|
|
21476
|
+
function adaptHealStepsForRefSupport(steps, refSupported) {
|
|
21477
|
+
if (refSupported) return { steps: [...steps], strippedRef: false };
|
|
21478
|
+
let strippedRef = false;
|
|
21479
|
+
const adapted = steps.map((step) => {
|
|
21480
|
+
const refIdx = step.args.indexOf("--ref");
|
|
21481
|
+
const isAdd = step.args.includes("marketplace") && step.args.includes("add");
|
|
21482
|
+
if (!isAdd || refIdx === -1) return step;
|
|
21483
|
+
strippedRef = true;
|
|
21484
|
+
return { ...step, args: [...step.args.slice(0, refIdx), ...step.args.slice(refIdx + 2)] };
|
|
21405
21485
|
});
|
|
21486
|
+
return { steps: adapted, strippedRef };
|
|
21406
21487
|
}
|
|
21407
|
-
function
|
|
21408
|
-
|
|
21409
|
-
|
|
21410
|
-
|
|
21411
|
-
|
|
21412
|
-
|
|
21413
|
-
|
|
21414
|
-
|
|
21415
|
-
|
|
21416
|
-
|
|
21488
|
+
function recoveryWithoutRef(recovery) {
|
|
21489
|
+
return recovery.replace(/ --ref \S+/g, "");
|
|
21490
|
+
}
|
|
21491
|
+
function hasProjectInstallRecord(file, pluginId, projectPath) {
|
|
21492
|
+
const records = file?.plugins?.[pluginId];
|
|
21493
|
+
if (!Array.isArray(records)) return false;
|
|
21494
|
+
return records.some((r) => r.scope === "project" && r.projectPath === projectPath);
|
|
21495
|
+
}
|
|
21496
|
+
function hasUserInstallRecord(file, pluginId) {
|
|
21497
|
+
const records = file?.plugins?.[pluginId];
|
|
21498
|
+
if (!Array.isArray(records)) return false;
|
|
21499
|
+
return records.some((r) => r.scope === "user");
|
|
21500
|
+
}
|
|
21501
|
+
var CLAUDE_PLUGIN_TIMEOUT_MS = 12e4;
|
|
21502
|
+
var NPM_VIEW_TIMEOUT_MS = 15e3;
|
|
21503
|
+
function runHostBin(bin, args, opts) {
|
|
21504
|
+
return isWin ? execFileP2("cmd.exe", ["/c", bin, ...args], opts) : execFileP2(bin, args, opts);
|
|
21505
|
+
}
|
|
21506
|
+
var installedPluginsPath2 = (surface = detectSurface(process.env)) => {
|
|
21507
|
+
const homeDir = surface === "codex" ? ".codex" : ".claude";
|
|
21508
|
+
return (0, import_node_path24.join)((0, import_node_os6.homedir)(), homeDir, "plugins", "installed_plugins.json");
|
|
21509
|
+
};
|
|
21510
|
+
function readInstalledPlugins(surface = detectSurface(process.env)) {
|
|
21511
|
+
try {
|
|
21512
|
+
return JSON.parse((0, import_node_fs25.readFileSync)(installedPluginsPath2(surface), "utf8"));
|
|
21513
|
+
} catch {
|
|
21514
|
+
return null;
|
|
21417
21515
|
}
|
|
21418
|
-
|
|
21419
|
-
|
|
21420
|
-
|
|
21421
|
-
|
|
21422
|
-
|
|
21516
|
+
}
|
|
21517
|
+
function marketplaceCloneCandidates(surface, home) {
|
|
21518
|
+
if (surface === "codex") {
|
|
21519
|
+
return [
|
|
21520
|
+
(0, import_node_path24.join)(home, ".codex", ".tmp", "marketplaces", "mutmutco"),
|
|
21521
|
+
(0, import_node_path24.join)(home, ".codex", "plugins", "marketplaces", "mutmutco")
|
|
21522
|
+
];
|
|
21423
21523
|
}
|
|
21424
|
-
return
|
|
21524
|
+
return [(0, import_node_path24.join)(home, ".claude", "plugins", "marketplaces", "mutmutco")];
|
|
21425
21525
|
}
|
|
21426
|
-
|
|
21427
|
-
|
|
21428
|
-
|
|
21429
|
-
|
|
21430
|
-
|
|
21431
|
-
|
|
21432
|
-
|
|
21433
|
-
|
|
21526
|
+
function marketplaceClonePresent(surface, home, exists = import_node_fs25.existsSync) {
|
|
21527
|
+
return marketplaceCloneCandidates(surface, home).some(exists);
|
|
21528
|
+
}
|
|
21529
|
+
async function fetchNpmReleasedVersion() {
|
|
21530
|
+
try {
|
|
21531
|
+
const { stdout } = await runHostBin("npm", npmReleasedVersionArgs(), { timeout: NPM_VIEW_TIMEOUT_MS });
|
|
21532
|
+
return parseNpmVersion(stdout);
|
|
21533
|
+
} catch {
|
|
21534
|
+
return void 0;
|
|
21434
21535
|
}
|
|
21536
|
+
}
|
|
21537
|
+
var NPM_INSTALL_TIMEOUT_MS = 12e4;
|
|
21538
|
+
async function npmSelfUpdateCli(target) {
|
|
21539
|
+
const command = cliUpdateCommand(target);
|
|
21435
21540
|
try {
|
|
21436
|
-
await
|
|
21437
|
-
|
|
21438
|
-
remote,
|
|
21439
|
-
`--force-with-lease=refs/heads/${branch}:${expectedHeadOid}`,
|
|
21440
|
-
`:refs/heads/${branch}`
|
|
21441
|
-
], { timeout: GH_MUTATION_TIMEOUT_MS });
|
|
21541
|
+
await runHostBin("npm", ["install", "-g", `@mutmutco/cli@${target ?? "latest"}`], { timeout: NPM_INSTALL_TIMEOUT_MS });
|
|
21542
|
+
return { ok: true, detail: `${command} exited 0` };
|
|
21442
21543
|
} catch (e) {
|
|
21443
|
-
|
|
21444
|
-
if (after && !after.stdout.trim().split(/\r?\n/).find(Boolean)) return "already-gone";
|
|
21445
|
-
throw e;
|
|
21544
|
+
return { ok: false, detail: e.message.trim().slice(0, 200).replace(/\s+/g, " ") };
|
|
21446
21545
|
}
|
|
21447
|
-
return "deleted";
|
|
21448
21546
|
}
|
|
21449
|
-
function
|
|
21450
|
-
const
|
|
21451
|
-
const
|
|
21452
|
-
const prunable = scratch?.safeAuto ?? 0;
|
|
21453
|
-
const unprunable = scratch?.skipped ?? 0;
|
|
21454
|
-
if (!(failed || prunable > 0 || unprunable > 0)) return { blocked: false };
|
|
21455
|
-
if (force) return { blocked: false };
|
|
21456
|
-
const detail = failed ? `scratch housekeeping failed${scratch?.error ? `: ${scratch.error}` : ""}` : `${prunable} prunable + ${unprunable} un-prunable scratch item(s) remain after cleanup`;
|
|
21547
|
+
function snapshotPluginGuardInput(surface = detectSurface(process.env), isOrgRepo = false) {
|
|
21548
|
+
const homeDir = surface === "codex" ? ".codex" : ".claude";
|
|
21549
|
+
const installed = readInstalledPlugins(surface);
|
|
21457
21550
|
return {
|
|
21458
|
-
|
|
21459
|
-
|
|
21551
|
+
isOrgRepo,
|
|
21552
|
+
installRecordPresent: hasUserInstallRecord(installed, MMI_PLUGIN_ID) || hasProjectInstallRecord(installed, MMI_PLUGIN_ID, process.cwd()),
|
|
21553
|
+
marketplaceClonePresent: marketplaceClonePresent(surface, (0, import_node_os6.homedir)()),
|
|
21554
|
+
pluginCachePresent: (0, import_node_fs25.existsSync)((0, import_node_path24.join)((0, import_node_os6.homedir)(), homeDir, "plugins", "cache", "mutmutco", "mmi"))
|
|
21460
21555
|
};
|
|
21461
21556
|
}
|
|
21462
|
-
function
|
|
21463
|
-
|
|
21464
|
-
const housekeeping = build(startingPath || process.cwd());
|
|
21465
|
-
const verdict = evaluatePrMergeHousekeeping(housekeeping, context, options.force);
|
|
21466
|
-
if (verdict.blocked) throw new Error(verdict.reason);
|
|
21467
|
-
return housekeeping;
|
|
21557
|
+
function claudePluginGuardState(isOrgRepo) {
|
|
21558
|
+
return buildPluginGuardDecision(snapshotPluginGuardInput(detectSurface(process.env), isOrgRepo)).state;
|
|
21468
21559
|
}
|
|
21469
|
-
async function
|
|
21470
|
-
const result = { removedBranches: [], removedRemoteBranches: [], removedTrackingRefs: [], removedWorktreeDirs: [], failed: [], pruned: false };
|
|
21471
|
-
const beforeWorktrees = parseWorktreePorcelain(
|
|
21472
|
-
(await execFileP2("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout
|
|
21473
|
-
);
|
|
21474
|
-
const deferredStore = await createDeferredWorktreeStore();
|
|
21475
|
-
const branchTracking = await applyBranchAndTrackingCleanup(plan, {
|
|
21476
|
-
localBranchHeads,
|
|
21477
|
-
cleanupBranch: (branch, expectedHeadOid) => {
|
|
21478
|
-
const wtDeps = worktreeRemoveDeps(async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout);
|
|
21479
|
-
return cleanupPrMergeLocalBranch(branch.branch, {
|
|
21480
|
-
beforeWorktrees,
|
|
21481
|
-
startingPath: branch.worktreePath,
|
|
21482
|
-
pathExists: (p) => (0, import_node_fs25.existsSync)(p),
|
|
21483
|
-
execGit: async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout,
|
|
21484
|
-
teardownWorktreeStage,
|
|
21485
|
-
deferredStore,
|
|
21486
|
-
expectedHeadOid,
|
|
21487
|
-
detachReparsePoints: wtDeps.detachReparsePoints,
|
|
21488
|
-
// #3064: junction-safe teardown
|
|
21489
|
-
removeWorktreeDir: wtDeps.removeWorktreeDir
|
|
21490
|
-
});
|
|
21491
|
-
},
|
|
21492
|
-
cleanupRemoteBranch: (branch, expectedHeadOid) => deleteReviewedRemoteBranch(remote, branch.branch, expectedHeadOid),
|
|
21493
|
-
cleanupTrackingRefs: (trackingRefs, unsafeBranches) => applyTrackingRefCleanup(trackingRefs, remote, unsafeBranches, {
|
|
21494
|
-
execGit: async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout
|
|
21495
|
-
})
|
|
21496
|
-
});
|
|
21497
|
-
result.removedBranches.push(...branchTracking.removedBranches);
|
|
21498
|
-
result.removedRemoteBranches.push(...branchTracking.removedRemoteBranches);
|
|
21499
|
-
result.removedTrackingRefs.push(...branchTracking.removedTrackingRefs);
|
|
21500
|
-
result.failed.push(...branchTracking.failed);
|
|
21501
|
-
const removeDeps = worktreeRemoveDeps(async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout);
|
|
21502
|
-
const repoRoot2 = (await execFileP2("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim() || process.cwd();
|
|
21503
|
-
const worktreeGitRoot = await currentRepoWorktreeGitRoot(repoRoot2);
|
|
21504
|
-
const primaryRepoRoot = worktreeGitRoot ? (0, import_node_path24.dirname)((0, import_node_path24.dirname)(worktreeGitRoot)) : repoRoot2;
|
|
21505
|
-
const siblingRoot = opts.root ? resolveExplicitScanRoot(opts.root, primaryRepoRoot) : siblingMmiWorktreesRoot(primaryRepoRoot);
|
|
21506
|
-
for (const wt of plan.worktreeDirs) {
|
|
21507
|
-
try {
|
|
21508
|
-
const cleanupTarget = resolveSafeSiblingWorktreeCleanupTarget(wt.path, siblingRoot, {
|
|
21509
|
-
realpath: (path2) => (0, import_node_fs25.realpathSync)(path2)
|
|
21510
|
-
});
|
|
21511
|
-
if (!cleanupTarget.ok) {
|
|
21512
|
-
result.failed.push(`${wt.path}: ${cleanupTarget.reason}`);
|
|
21513
|
-
continue;
|
|
21514
|
-
}
|
|
21515
|
-
const stillValid = validateDeadWorktreeDirCleanup(wt, inspectSiblingWorktreeDir(wt.path, worktreeGitRoot));
|
|
21516
|
-
if (!stillValid.ok) {
|
|
21517
|
-
result.failed.push(`${wt.path}: skipped dead-dir removal (${stillValid.reason})`);
|
|
21518
|
-
continue;
|
|
21519
|
-
}
|
|
21520
|
-
const contentSafe = validateDeadWorktreeDirContent(wt, inspectDeadWorktreeDirContent(wt.path));
|
|
21521
|
-
if (!contentSafe.ok) {
|
|
21522
|
-
result.failed.push(`${wt.path}: skipped dead-dir removal (${contentSafe.reason})`);
|
|
21523
|
-
continue;
|
|
21524
|
-
}
|
|
21525
|
-
await removeDeps.removeWorktreeDir(cleanupTarget.path);
|
|
21526
|
-
result.removedWorktreeDirs.push(wt.path);
|
|
21527
|
-
} catch (e) {
|
|
21528
|
-
result.failed.push(`${wt.path}: ${e.message.split("\n")[0]}`);
|
|
21529
|
-
}
|
|
21530
|
-
}
|
|
21560
|
+
async function runClaudePlugin(args) {
|
|
21531
21561
|
try {
|
|
21532
|
-
await
|
|
21533
|
-
|
|
21534
|
-
} catch
|
|
21535
|
-
|
|
21562
|
+
await runHostBin("claude", args, { timeout: CLAUDE_PLUGIN_TIMEOUT_MS });
|
|
21563
|
+
return true;
|
|
21564
|
+
} catch {
|
|
21565
|
+
return false;
|
|
21536
21566
|
}
|
|
21537
|
-
return result;
|
|
21538
21567
|
}
|
|
21539
|
-
async function
|
|
21540
|
-
let stdout = "";
|
|
21541
|
-
let stderr = "";
|
|
21568
|
+
async function marketplaceAddRefSupported(bin) {
|
|
21542
21569
|
try {
|
|
21543
|
-
|
|
21544
|
-
|
|
21545
|
-
|
|
21546
|
-
|
|
21547
|
-
|
|
21548
|
-
|
|
21549
|
-
|
|
21550
|
-
const state = parseGhPrChecksResult(stdout, stderr);
|
|
21551
|
-
if (state === "error") {
|
|
21552
|
-
throw new Error(`gh pr checks ${prNumber}: ${(stderr || stdout).trim() || "unknown error"}`);
|
|
21570
|
+
const { stdout, stderr } = await runHostBin(bin, ["plugin", "marketplace", "add", "--help"], {
|
|
21571
|
+
timeout: CLAUDE_PLUGIN_TIMEOUT_MS
|
|
21572
|
+
});
|
|
21573
|
+
return marketplaceAddSupportsRef(`${stdout}
|
|
21574
|
+
${stderr}`);
|
|
21575
|
+
} catch {
|
|
21576
|
+
return false;
|
|
21553
21577
|
}
|
|
21554
|
-
return state;
|
|
21555
21578
|
}
|
|
21556
|
-
function
|
|
21557
|
-
const
|
|
21558
|
-
|
|
21559
|
-
const detail = `${err?.stderr ?? ""}
|
|
21560
|
-
${err?.stdout ?? ""}`.split("\n").map((line) => line.trim()).find((line) => line.length > 0);
|
|
21561
|
-
if (detail && head) return `${head} \u2014 ${detail}`;
|
|
21562
|
-
return detail || head || "unknown error";
|
|
21579
|
+
function healBannerLine(bin, token, refSupported) {
|
|
21580
|
+
const installVerb = token === "codex" ? "add" : "install";
|
|
21581
|
+
return refSupported ? ` \u21BB reinstalling the MMI plugin via \`${bin} plugin\` (marketplace remove \u2192 add --ref main \u2192 ${installVerb})\u2026` : ` \u21BB reinstalling the MMI plugin via \`${bin} plugin\` (marketplace remove \u2192 add \u2192 ${installVerb}; release ref pinned by the marketplace manifest)\u2026`;
|
|
21563
21582
|
}
|
|
21564
|
-
|
|
21565
|
-
|
|
21566
|
-
};
|
|
21567
|
-
async function ghMergeAutoEnqueue(prNumber, repo, method, io = defaultGhMergeAutoIo) {
|
|
21568
|
-
const args = repo ? ["--repo", repo] : [];
|
|
21569
|
-
const headRef = (await io.gh(["pr", "view", prNumber, ...args, "--json", "headRefName", "--jq", ".headRefName"], GC_GH_TIMEOUT_MS2).catch(() => "")).trim();
|
|
21570
|
-
const deleteBranch = !isProtectedBranch(headRef);
|
|
21571
|
-
const readMergeState = () => readGhPrStateWithRetry(
|
|
21572
|
-
() => io.gh(["pr", "view", prNumber, ...args, "--json", "state", "--jq", ".state"], GC_GH_TIMEOUT_MS2)
|
|
21573
|
-
);
|
|
21574
|
-
const confirmEnqueueOutcome = async () => {
|
|
21575
|
-
const stateRead = await readMergeState();
|
|
21576
|
-
if (!stateRead.ok) {
|
|
21577
|
-
return { mergeStatus: "failed", error: `could not read PR state after merge: ${stateRead.error}` };
|
|
21578
|
-
}
|
|
21579
|
-
if (stateRead.state === "MERGED") return { mergeStatus: "merged" };
|
|
21580
|
-
const confirmation = await confirmAutoMergeEnqueued({
|
|
21581
|
-
readAutoMergeRequest: async () => io.gh(["pr", "view", prNumber, ...args, "--json", "autoMergeRequest", "--jq", '.autoMergeRequest // ""'], GC_GH_TIMEOUT_MS2),
|
|
21582
|
-
readMerged: async () => {
|
|
21583
|
-
const s = await readMergeState();
|
|
21584
|
-
return s.ok && s.state === "MERGED";
|
|
21585
|
-
},
|
|
21586
|
-
reEnqueue: async () => {
|
|
21587
|
-
await io.gh(buildPrMergeArgs({ number: prNumber, repoArgs: args, method, auto: true, deleteBranch }), GH_MUTATION_TIMEOUT_MS);
|
|
21588
|
-
}
|
|
21589
|
-
});
|
|
21590
|
-
if (confirmation === "merged") return { mergeStatus: "merged" };
|
|
21591
|
-
if (confirmation === "enqueued") return { mergeStatus: "auto-merge-enqueued" };
|
|
21592
|
-
return { mergeStatus: "failed", error: "auto-merge did not stick \u2014 GitHub reported no autoMergeRequest after enqueue; retry once the PR has a pending check" };
|
|
21593
|
-
};
|
|
21594
|
-
try {
|
|
21595
|
-
await io.gh(buildPrMergeArgs({ number: prNumber, repoArgs: args, method, auto: true, deleteBranch }), GH_MUTATION_TIMEOUT_MS);
|
|
21596
|
-
} catch (e) {
|
|
21597
|
-
const message = String(e.message || "");
|
|
21598
|
-
if (/already been merged/i.test(message)) return { mergeStatus: "merged" };
|
|
21599
|
-
const note = timeoutKillNote(e, GH_MUTATION_TIMEOUT_MS);
|
|
21600
|
-
if (note) return { mergeStatus: "failed", error: note };
|
|
21601
|
-
if (mergeAutoRejectedPrAlreadyClean(message)) {
|
|
21602
|
-
try {
|
|
21603
|
-
await io.gh(buildPrMergeArgs({ number: prNumber, repoArgs: args, method, auto: false, deleteBranch }), GH_MUTATION_TIMEOUT_MS);
|
|
21604
|
-
} catch (e2) {
|
|
21605
|
-
const m2 = String(e2.message || "");
|
|
21606
|
-
if (/already been merged/i.test(m2)) return { mergeStatus: "merged" };
|
|
21607
|
-
if (!ghPrMergeLocalBranchDeleteWarning(m2)) {
|
|
21608
|
-
const afterDirect = await readMergeState();
|
|
21609
|
-
if (afterDirect.ok && afterDirect.state === "MERGED") return { mergeStatus: "merged" };
|
|
21610
|
-
if (!afterDirect.ok) {
|
|
21611
|
-
return { mergeStatus: "failed", error: `could not confirm PR state after clean-status fallback: ${afterDirect.error}` };
|
|
21612
|
-
}
|
|
21613
|
-
try {
|
|
21614
|
-
await io.gh(buildPrMergeArgs({ number: prNumber, repoArgs: args, method, auto: true, deleteBranch }), GH_MUTATION_TIMEOUT_MS);
|
|
21615
|
-
} catch (e3) {
|
|
21616
|
-
const m3 = String(e3.message || "");
|
|
21617
|
-
if (/already been merged/i.test(m3)) return { mergeStatus: "merged" };
|
|
21618
|
-
const afterRetry = await readMergeState();
|
|
21619
|
-
if (afterRetry.ok && afterRetry.state === "MERGED") return { mergeStatus: "merged" };
|
|
21620
|
-
const stuck = (await io.gh(["pr", "view", prNumber, ...args, "--json", "autoMergeRequest", "--jq", '.autoMergeRequest // ""'], GC_GH_TIMEOUT_MS2).catch(() => "")).trim();
|
|
21621
|
-
if (stuck) return { mergeStatus: "auto-merge-enqueued" };
|
|
21622
|
-
return { mergeStatus: "failed", error: `${ghFailureReason(e2)} (re-enqueue also failed: ${ghFailureReason(e3)})` };
|
|
21623
|
-
}
|
|
21624
|
-
return confirmEnqueueOutcome();
|
|
21625
|
-
}
|
|
21626
|
-
}
|
|
21627
|
-
const stateRead = await readMergeState();
|
|
21628
|
-
if (stateRead.ok && stateRead.state === "MERGED") return { mergeStatus: "merged" };
|
|
21629
|
-
return {
|
|
21630
|
-
mergeStatus: "failed",
|
|
21631
|
-
error: stateRead.ok ? "direct merge did not land after clean-status fallback" : `could not confirm PR state after clean-status fallback: ${stateRead.error}`
|
|
21632
|
-
};
|
|
21633
|
-
}
|
|
21634
|
-
if (ghPrMergeLocalBranchDeleteWarning(message)) {
|
|
21635
|
-
const stateRead = await readMergeState();
|
|
21636
|
-
if (stateRead.ok && stateRead.state === "MERGED") return { mergeStatus: "merged" };
|
|
21637
|
-
return {
|
|
21638
|
-
mergeStatus: "failed",
|
|
21639
|
-
error: stateRead.ok ? message.split("\n")[0] : `could not confirm PR state after local branch cleanup warning: ${stateRead.error}`
|
|
21640
|
-
};
|
|
21641
|
-
}
|
|
21642
|
-
if (!basePolicyBlocksImmediateMerge(message)) {
|
|
21643
|
-
return { mergeStatus: "failed", error: ghFailureReason(e) };
|
|
21644
|
-
}
|
|
21645
|
-
return { mergeStatus: "failed", error: `merge blocked: ${ghFailureReason(e)} \u2014 ensure checks are green` };
|
|
21646
|
-
}
|
|
21647
|
-
return confirmEnqueueOutcome();
|
|
21583
|
+
function refAbsenceNote(bin, refSupported) {
|
|
21584
|
+
return refSupported ? "" : `
|
|
21585
|
+
Note: \`${bin}\` has no \`--ref\` option; the marketplace manifest pins the released branch, so none is needed (#2516).`;
|
|
21648
21586
|
}
|
|
21649
|
-
|
|
21650
|
-
|
|
21651
|
-
|
|
21652
|
-
},
|
|
21587
|
+
var PLUGIN_READ_REPO2 = "mutmutco/MMI-Hub";
|
|
21588
|
+
function pluginReadGrantNote(login = "<your-github-login>") {
|
|
21589
|
+
return `
|
|
21590
|
+
If the reinstall 404s on ${PLUGIN_READ_REPO2}, you lack read on it. An org owner must grant it (idempotent):
|
|
21591
|
+
gh api -X PUT repos/${PLUGIN_READ_REPO2}/collaborators/${login} -f permission=pull`;
|
|
21653
21592
|
}
|
|
21654
|
-
|
|
21655
|
-
|
|
21656
|
-
|
|
21593
|
+
async function applyPluginHeal(surface, log, opts) {
|
|
21594
|
+
if (!opts?.force && surfaceToken(surface) !== "claude") return false;
|
|
21595
|
+
const tableSteps = PLUGIN_SURFACE_HEAL.claude.healSteps;
|
|
21596
|
+
if (!tableSteps) return false;
|
|
21597
|
+
const refSupported = await marketplaceAddRefSupported("claude");
|
|
21598
|
+
const { steps } = adaptHealStepsForRefSupport(tableSteps, refSupported);
|
|
21599
|
+
log(healBannerLine("claude", "claude", refSupported));
|
|
21600
|
+
const pinsPath = (0, import_node_path24.join)((0, import_node_os6.homedir)(), ...KNOWN_MARKETPLACES_RELATIVE);
|
|
21601
|
+
const pins = captureMarketplacePins(readKnownMarketplacesFile(pinsPath), [MMI_MARKETPLACE_NAME, JERV_MARKETPLACE_NAME]);
|
|
21602
|
+
for (const step of steps) {
|
|
21603
|
+
if (healStepAborts(step, await runClaudePlugin([...step.args]))) return false;
|
|
21604
|
+
}
|
|
21605
|
+
const restored = restoreMarketplacePinsOnDisk(pinsPath, pins);
|
|
21606
|
+
if (restored) log(` ${restored}`);
|
|
21607
|
+
return true;
|
|
21657
21608
|
}
|
|
21658
|
-
async function
|
|
21609
|
+
async function healClaudePluginForDoctor(surface = detectSurface(process.env)) {
|
|
21610
|
+
if (surfaceToken(surface) !== "claude") {
|
|
21611
|
+
return { ok: false, detail: `not a Claude surface (${surface}) \u2014 no Hub-shipped plugin to reinstall` };
|
|
21612
|
+
}
|
|
21613
|
+
const steps = [];
|
|
21614
|
+
const ok = await applyPluginHeal(surface, (msg) => steps.push(msg.trim()));
|
|
21615
|
+
const pinNote = steps.find((s) => s.startsWith("re-pinned ") || s.includes("re-pin by hand"));
|
|
21616
|
+
return {
|
|
21617
|
+
ok,
|
|
21618
|
+
detail: ok ? `marketplace remove \u2192 add \u2192 install succeeded${pinNote ? `; ${pinNote}` : ""}` : `\`claude plugin\` reinstall failed or was skipped${steps.length ? ` (${steps[steps.length - 1]})` : ""}`
|
|
21619
|
+
};
|
|
21620
|
+
}
|
|
21621
|
+
function readKnownMarketplacesFile(path2) {
|
|
21659
21622
|
try {
|
|
21660
|
-
|
|
21661
|
-
return makeDeferredWorktreeStore(deferredWorktreesRegistryPath(stdout.trim()));
|
|
21623
|
+
return (0, import_node_fs25.existsSync)(path2) ? (0, import_node_fs25.readFileSync)(path2, "utf8") : void 0;
|
|
21662
21624
|
} catch {
|
|
21663
21625
|
return void 0;
|
|
21664
21626
|
}
|
|
21665
21627
|
}
|
|
21666
|
-
|
|
21667
|
-
|
|
21668
|
-
|
|
21669
|
-
|
|
21670
|
-
|
|
21671
|
-
} catch {
|
|
21672
|
-
return null;
|
|
21673
|
-
}
|
|
21674
|
-
if (st.isSymbolicLink()) return "link";
|
|
21675
|
-
try {
|
|
21676
|
-
(0, import_node_fs25.readlinkSync)(p);
|
|
21677
|
-
return "link";
|
|
21678
|
-
} catch {
|
|
21679
|
-
}
|
|
21680
|
-
return st.isDirectory() ? "dir" : "file";
|
|
21681
|
-
},
|
|
21682
|
-
readdir: (p) => {
|
|
21683
|
-
try {
|
|
21684
|
-
return (0, import_node_fs25.readdirSync)(p);
|
|
21685
|
-
} catch {
|
|
21686
|
-
return [];
|
|
21687
|
-
}
|
|
21688
|
-
},
|
|
21689
|
-
// A directory reparse point (junction / dir-symlink) is detached with rmdir (unlinks the mount point,
|
|
21690
|
-
// leaving the target); a file symlink with unlink. rmdir first, fall back to unlink.
|
|
21691
|
-
detachLink: (p) => {
|
|
21692
|
-
try {
|
|
21693
|
-
(0, import_node_fs25.rmdirSync)(p);
|
|
21694
|
-
} catch {
|
|
21695
|
-
(0, import_node_fs25.unlinkSync)(p);
|
|
21696
|
-
}
|
|
21697
|
-
},
|
|
21698
|
-
removeTree: (p) => (0, import_promises6.rm)(p, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
|
21699
|
-
};
|
|
21700
|
-
async function resolvePrimaryCheckout(execGit) {
|
|
21628
|
+
function restoreMarketplacePinsOnDisk(path2, pins) {
|
|
21629
|
+
if (pins.size === 0) return void 0;
|
|
21630
|
+
const after = readKnownMarketplacesFile(path2);
|
|
21631
|
+
const next = restoreMarketplacePins(after, pins);
|
|
21632
|
+
if (next === null) return void 0;
|
|
21701
21633
|
try {
|
|
21702
|
-
|
|
21634
|
+
(0, import_node_fs25.writeFileSync)(path2, next, "utf8");
|
|
21703
21635
|
} catch {
|
|
21704
|
-
return
|
|
21636
|
+
return `could NOT restore ${[...pins.keys()].join(", ")} \u2014 re-pin by hand`;
|
|
21705
21637
|
}
|
|
21638
|
+
const verify = readKnownMarketplacesFile(path2);
|
|
21639
|
+
const failed = [...pins].filter(([name, want]) => {
|
|
21640
|
+
const got = readKnownMarketplace(verify, name);
|
|
21641
|
+
return typeof want.autoUpdate === "boolean" && got.declared !== want.autoUpdate || want.ref !== void 0 && got.ref !== want.ref;
|
|
21642
|
+
});
|
|
21643
|
+
return failed.length ? `restore did NOT take for ${failed.map(([n]) => n).join(", ")} \u2014 re-pin by hand` : `re-pinned ${[...pins.keys()].join(", ")} (auto-update + catalog ref survive the reinstall)`;
|
|
21706
21644
|
}
|
|
21707
|
-
function
|
|
21708
|
-
return {
|
|
21709
|
-
git: execGit,
|
|
21710
|
-
sleep: (ms) => new Promise((resolve5) => setTimeout(resolve5, ms)),
|
|
21711
|
-
// #3064: unlink any reparse point (esp. a `node_modules` junction to base) before the git-native
|
|
21712
|
-
// `worktree remove --force`, which would otherwise recurse through it and empty the base checkout.
|
|
21713
|
-
detachReparsePoints: (worktreePath) => detachReparsePoints(worktreePath, realWorktreeDirRemover),
|
|
21714
|
-
removeWorktreeDir: async (worktreePath) => removeWorktreeTree(worktreePath, await resolvePrimaryCheckout(execGit), realWorktreeDirRemover)
|
|
21715
|
-
};
|
|
21716
|
-
}
|
|
21717
|
-
async function worktreeHasStageState(worktreePath) {
|
|
21718
|
-
if (stageStateFileBelongsToWorktree(stageStatePath(worktreePath), worktreePath)) return true;
|
|
21645
|
+
async function runGuard(readOrigin) {
|
|
21719
21646
|
try {
|
|
21720
|
-
const
|
|
21721
|
-
|
|
21722
|
-
|
|
21647
|
+
const surface = detectSurface(process.env);
|
|
21648
|
+
if (surfaceToken(surface) !== "claude") {
|
|
21649
|
+
process.exitCode = 0;
|
|
21650
|
+
return;
|
|
21651
|
+
}
|
|
21652
|
+
const isOrgRepo = readOrigin ? await isOrgRepoRoot(readOrigin) : await isOrgRepoRoot();
|
|
21653
|
+
const input = snapshotPluginGuardInput(surface, isOrgRepo);
|
|
21654
|
+
const { state } = buildPluginGuardDecision(input);
|
|
21655
|
+
const { line, exitCode } = buildPluginGuardLine(state);
|
|
21656
|
+
if (line) console.error(line);
|
|
21657
|
+
process.exitCode = exitCode;
|
|
21723
21658
|
} catch {
|
|
21724
|
-
|
|
21659
|
+
process.exitCode = 0;
|
|
21725
21660
|
}
|
|
21726
21661
|
}
|
|
21727
|
-
function
|
|
21728
|
-
if (
|
|
21729
|
-
|
|
21730
|
-
|
|
21731
|
-
|
|
21732
|
-
|
|
21733
|
-
|
|
21734
|
-
|
|
21662
|
+
async function runPluginHeal(surface = detectSurface(process.env)) {
|
|
21663
|
+
if (surfaceToken(surface) !== "claude") {
|
|
21664
|
+
console.log(nonClaudeSurfaceHealMessage(surfaceToken(surface) ?? void 0));
|
|
21665
|
+
return;
|
|
21666
|
+
}
|
|
21667
|
+
const descriptor = PLUGIN_SURFACE_HEAL.claude;
|
|
21668
|
+
const healed = await applyPluginHeal(surface, console.log, { force: true });
|
|
21669
|
+
if (healed) {
|
|
21670
|
+
console.log(` \u2713 MMI plugin reinstalled \u2014 ${reloadAction(surface)} to load MMI commands`);
|
|
21671
|
+
} else {
|
|
21672
|
+
const refSupported = await marketplaceAddRefSupported("claude");
|
|
21673
|
+
const recovery = refSupported ? descriptor.recovery : recoveryWithoutRef(descriptor.recovery);
|
|
21674
|
+
const note = refAbsenceNote("claude", refSupported);
|
|
21675
|
+
console.log(` \u2717 Auto-heal failed or was skipped. Run manually:
|
|
21676
|
+
${recovery}${note}${pluginReadGrantNote()}`);
|
|
21735
21677
|
}
|
|
21736
|
-
}
|
|
21737
|
-
function teardownWorktreeStage(worktreePath) {
|
|
21738
|
-
return runWorktreeStageTeardown(worktreePath, {
|
|
21739
|
-
hasStageState: worktreeHasStageState,
|
|
21740
|
-
stopRecordedStage: async (wt) => (await stopStage({ cwd: wt, requiredIdentityCwd: wt })).pid,
|
|
21741
|
-
listComposeProjects: async () => {
|
|
21742
|
-
const { stdout } = await execFileP2("docker", ["compose", "ls", "--all", "--format", "json"], { timeout: GC_GH_TIMEOUT_MS2 });
|
|
21743
|
-
return parseComposeLs(stdout);
|
|
21744
|
-
},
|
|
21745
|
-
composeDown: async (project2) => {
|
|
21746
|
-
await execFileP2("docker", ["compose", "-p", project2, "down", "-v"], { timeout: COMPOSE_TIMEOUT_MS });
|
|
21747
|
-
}
|
|
21748
|
-
});
|
|
21749
21678
|
}
|
|
21750
21679
|
|
|
21751
|
-
// src/
|
|
21752
|
-
var
|
|
21753
|
-
|
|
21754
|
-
|
|
21755
|
-
|
|
21756
|
-
|
|
21757
|
-
|
|
21758
|
-
|
|
21759
|
-
|
|
21760
|
-
|
|
21761
|
-
|
|
21762
|
-
|
|
21763
|
-
return "pass";
|
|
21764
|
-
case "failure":
|
|
21765
|
-
case "timed_out":
|
|
21766
|
-
case "cancelled":
|
|
21767
|
-
case "action_required":
|
|
21768
|
-
case "startup_failure":
|
|
21769
|
-
case "stale":
|
|
21770
|
-
return "fail";
|
|
21771
|
-
default:
|
|
21772
|
-
return "pending";
|
|
21680
|
+
// src/worktree-ownership.ts
|
|
21681
|
+
var import_node_fs26 = require("node:fs");
|
|
21682
|
+
var import_node_os7 = require("node:os");
|
|
21683
|
+
var import_node_path25 = require("node:path");
|
|
21684
|
+
var OWNERS_FILE = "worktree-owners.json";
|
|
21685
|
+
var EVENTS_FILE = "worktree-events.jsonl";
|
|
21686
|
+
var WORKTREE_ACTIVITY_WINDOW_MS = 30 * 6e4;
|
|
21687
|
+
var SESSION_ID_ENV_VARS = ["MMI_SESSION_ID", "CLAUDE_SESSION_ID", "CODEX_SESSION_ID", "CURSOR_SESSION_ID"];
|
|
21688
|
+
function readSessionId(env) {
|
|
21689
|
+
for (const key of SESSION_ID_ENV_VARS) {
|
|
21690
|
+
const value = env[key]?.trim();
|
|
21691
|
+
if (value) return value;
|
|
21773
21692
|
}
|
|
21693
|
+
return void 0;
|
|
21774
21694
|
}
|
|
21775
|
-
function
|
|
21776
|
-
|
|
21777
|
-
|
|
21778
|
-
|
|
21695
|
+
function describeActor(input) {
|
|
21696
|
+
const session = readSessionId(input.env);
|
|
21697
|
+
return {
|
|
21698
|
+
surface: input.surface,
|
|
21699
|
+
host: input.host ?? (0, import_node_os7.hostname)(),
|
|
21700
|
+
pid: input.pid ?? process.pid,
|
|
21701
|
+
cwd: input.cwd,
|
|
21702
|
+
...session ? { session } : {}
|
|
21703
|
+
};
|
|
21779
21704
|
}
|
|
21780
|
-
function
|
|
21781
|
-
|
|
21782
|
-
const unkeyed = [];
|
|
21783
|
-
for (const run of runs) {
|
|
21784
|
-
if (!run.name || typeof run.id !== "number") {
|
|
21785
|
-
unkeyed.push(run);
|
|
21786
|
-
continue;
|
|
21787
|
-
}
|
|
21788
|
-
const key = `${run.app_id ?? "unknown-app"}:${run.name}`;
|
|
21789
|
-
const prior = byIdentity.get(key);
|
|
21790
|
-
if (!prior || run.id >= (prior.id ?? 0)) byIdentity.set(key, run);
|
|
21791
|
-
}
|
|
21792
|
-
return [...byIdentity.values(), ...unkeyed];
|
|
21705
|
+
function sameSession(a, b) {
|
|
21706
|
+
return Boolean(a?.session && b?.session && a.session === b.session);
|
|
21793
21707
|
}
|
|
21794
|
-
function
|
|
21795
|
-
|
|
21796
|
-
const anyFailure = buckets.includes("fail");
|
|
21797
|
-
const anyPending = buckets.includes("pending");
|
|
21798
|
-
if (anyFailure) return anyPending ? "failing" : "failure";
|
|
21799
|
-
return anyPending ? "pending" : "success";
|
|
21708
|
+
function worktreeOwnersPath(primaryRoot) {
|
|
21709
|
+
return repoRuntimeStatePath(primaryRoot, OWNERS_FILE);
|
|
21800
21710
|
}
|
|
21801
|
-
function
|
|
21802
|
-
return
|
|
21711
|
+
function worktreeEventsPath(primaryRoot) {
|
|
21712
|
+
return repoRuntimeStatePath(primaryRoot, EVENTS_FILE);
|
|
21803
21713
|
}
|
|
21804
|
-
function
|
|
21805
|
-
const
|
|
21806
|
-
|
|
21807
|
-
|
|
21808
|
-
const headRepo = pr2.head?.repo?.full_name;
|
|
21809
|
-
const baseRepo = pr2.base?.repo?.full_name;
|
|
21810
|
-
return {
|
|
21811
|
-
headSha,
|
|
21812
|
-
headRef: pr2.head?.ref ?? "",
|
|
21813
|
-
// Only call it a fork when BOTH names are known and differ — an absent name (private/deleted fork)
|
|
21814
|
-
// must not read as same-repo and send a git-ref probe at a branch the base repo does not have.
|
|
21815
|
-
headIsFork: Boolean(headRepo && baseRepo && headRepo !== baseRepo),
|
|
21816
|
-
mergeable: pr2.mergeable === true ? "MERGEABLE" : pr2.mergeable === false ? "CONFLICTING" : "UNKNOWN",
|
|
21817
|
-
baseRef: pr2.base?.ref ?? "development",
|
|
21818
|
-
merged: pr2.merged === true,
|
|
21819
|
-
state: pr2.state ?? ""
|
|
21714
|
+
function sameWorktreePath(a, b, platform2 = process.platform) {
|
|
21715
|
+
const norm = (p) => {
|
|
21716
|
+
const unified = p.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
21717
|
+
return platform2 === "win32" || platform2 === "darwin" ? unified.toLowerCase() : unified;
|
|
21820
21718
|
};
|
|
21719
|
+
return norm(a) === norm(b);
|
|
21821
21720
|
}
|
|
21822
|
-
|
|
21823
|
-
if (!
|
|
21721
|
+
function isActor(value) {
|
|
21722
|
+
if (!value || typeof value !== "object") return false;
|
|
21723
|
+
const a = value;
|
|
21724
|
+
return typeof a.surface === "string" && typeof a.host === "string" && typeof a.pid === "number" && typeof a.cwd === "string";
|
|
21725
|
+
}
|
|
21726
|
+
function parseWorktreeOwners(text) {
|
|
21727
|
+
let parsed;
|
|
21824
21728
|
try {
|
|
21825
|
-
|
|
21826
|
-
const sha = parsed.object?.sha;
|
|
21827
|
-
return typeof sha === "string" && sha ? sha : null;
|
|
21729
|
+
parsed = JSON.parse(text);
|
|
21828
21730
|
} catch {
|
|
21829
|
-
return
|
|
21731
|
+
return [];
|
|
21830
21732
|
}
|
|
21733
|
+
if (!parsed || !Array.isArray(parsed.entries)) return [];
|
|
21734
|
+
return parsed.entries.filter((e) => {
|
|
21735
|
+
if (!e || typeof e !== "object") return false;
|
|
21736
|
+
const entry = e;
|
|
21737
|
+
return typeof entry.path === "string" && entry.path.length > 0 && typeof entry.branch === "string" && typeof entry.createdAt === "string" && typeof entry.lastSeenAt === "string" && isActor(entry.actor);
|
|
21738
|
+
});
|
|
21831
21739
|
}
|
|
21832
|
-
|
|
21833
|
-
return
|
|
21834
|
-
|
|
21835
|
-
async function fetchHeadCheckRuns(headSha, repo, gh) {
|
|
21836
|
-
const runsOut = await gh(["--paginate", `repos/${repo}/commits/${headSha}/check-runs?per_page=100`, "--jq", ".check_runs[] | {id, name, status, conclusion, app_id: .app.id}"]);
|
|
21837
|
-
return dedupeLatestCheckRuns(parseNdjsonLines(runsOut));
|
|
21740
|
+
function serializeWorktreeOwners(entries) {
|
|
21741
|
+
return `${JSON.stringify({ entries }, null, 2)}
|
|
21742
|
+
`;
|
|
21838
21743
|
}
|
|
21839
|
-
|
|
21840
|
-
|
|
21841
|
-
|
|
21842
|
-
|
|
21843
|
-
|
|
21844
|
-
|
|
21845
|
-
|
|
21744
|
+
function upsertWorktreeOwner(entries, entry) {
|
|
21745
|
+
return [...entries.filter((e) => !sameWorktreePath(e.path, entry.path)), entry];
|
|
21746
|
+
}
|
|
21747
|
+
function withoutWorktreeOwner(entries, path2) {
|
|
21748
|
+
return entries.filter((e) => !sameWorktreePath(e.path, path2));
|
|
21749
|
+
}
|
|
21750
|
+
function findWorktreeOwner(entries, path2) {
|
|
21751
|
+
return entries.find((e) => sameWorktreePath(e.path, path2));
|
|
21752
|
+
}
|
|
21753
|
+
function decideWorktreeRemoval(input) {
|
|
21754
|
+
if (input.force) return { action: "proceed", reason: "forced" };
|
|
21755
|
+
const owner = input.owner;
|
|
21756
|
+
if (!owner) return { action: "proceed", reason: "no-owner" };
|
|
21757
|
+
if (sameSession(owner.actor, input.actor)) return { action: "proceed", reason: "self-directed" };
|
|
21758
|
+
const windowMs = input.windowMs ?? WORKTREE_ACTIVITY_WINDOW_MS;
|
|
21759
|
+
const lastSeen = Date.parse(owner.lastSeenAt) || Date.parse(owner.createdAt);
|
|
21760
|
+
const idleMs = Number.isFinite(lastSeen) ? input.now - lastSeen : 0;
|
|
21761
|
+
if (idleMs >= windowMs) return { action: "proceed", reason: "inactive" };
|
|
21762
|
+
const minutes = Math.max(1, Math.round(idleMs / 6e4));
|
|
21763
|
+
return {
|
|
21764
|
+
action: "refuse",
|
|
21765
|
+
reason: "recently-active",
|
|
21766
|
+
message: `${input.path} was created by another session (${describeActorShort(owner.actor)}) and last active ${minutes} minute(s) ago. A worktree this fresh is not abandoned \u2014 it is a session's working tree before its first commit, which is exactly the window work has been lost in (MMI-Hub#3580). Leave it, or pass --force to remove it anyway.`
|
|
21767
|
+
};
|
|
21846
21768
|
}
|
|
21847
|
-
|
|
21848
|
-
|
|
21849
|
-
return derivePollState(await fetchHeadCheckBuckets(snapshot.headSha, repo, gh));
|
|
21769
|
+
function describeActorShort(actor) {
|
|
21770
|
+
return `${actor.surface} pid ${actor.pid} on ${actor.host}${actor.session ? ` session ${actor.session}` : ""}`;
|
|
21850
21771
|
}
|
|
21851
|
-
|
|
21772
|
+
function readOwners(primaryRoot) {
|
|
21852
21773
|
try {
|
|
21853
|
-
return (
|
|
21774
|
+
return parseWorktreeOwners((0, import_node_fs26.readFileSync)(worktreeOwnersPath(primaryRoot), "utf8"));
|
|
21854
21775
|
} catch {
|
|
21855
|
-
return
|
|
21776
|
+
return [];
|
|
21856
21777
|
}
|
|
21857
21778
|
}
|
|
21858
|
-
|
|
21779
|
+
function writeOwners(primaryRoot, entries) {
|
|
21859
21780
|
try {
|
|
21860
|
-
|
|
21781
|
+
const path2 = worktreeOwnersPath(primaryRoot);
|
|
21782
|
+
(0, import_node_fs26.mkdirSync)((0, import_node_path25.dirname)(path2), { recursive: true });
|
|
21783
|
+
(0, import_node_fs26.writeFileSync)(path2, serializeWorktreeOwners(entries), "utf8");
|
|
21861
21784
|
} catch {
|
|
21862
|
-
return false;
|
|
21863
21785
|
}
|
|
21864
21786
|
}
|
|
21865
|
-
|
|
21866
|
-
|
|
21867
|
-
var CI_TOOLCHAIN_ANNOTATION_TITLE = "CI runner toolchain";
|
|
21868
|
-
var RUNNER_INFRA_ANNOTATION_TITLES = /* @__PURE__ */ new Set([
|
|
21869
|
-
CI_BUDGET_ANNOTATION_TITLE,
|
|
21870
|
-
CI_DISK_ANNOTATION_TITLE,
|
|
21871
|
-
CI_TOOLCHAIN_ANNOTATION_TITLE
|
|
21872
|
-
]);
|
|
21873
|
-
function isErrorAnnotation(a) {
|
|
21874
|
-
const level = a.annotation_level?.toLowerCase();
|
|
21875
|
-
return level === "failure" || level === "error";
|
|
21787
|
+
function readWorktreeOwners(primaryRoot) {
|
|
21788
|
+
return readOwners(primaryRoot);
|
|
21876
21789
|
}
|
|
21877
|
-
function
|
|
21878
|
-
|
|
21879
|
-
const otherFailures = [];
|
|
21880
|
-
for (const check of failing) {
|
|
21881
|
-
const errors = check.annotations.filter(isErrorAnnotation);
|
|
21882
|
-
const allInfra = errors.length > 0 && errors.every((a) => RUNNER_INFRA_ANNOTATION_TITLES.has(a.title?.trim() ?? ""));
|
|
21883
|
-
(allInfra ? infraFailures : otherFailures).push(check.name);
|
|
21884
|
-
}
|
|
21885
|
-
if (infraFailures.length && !otherFailures.length) {
|
|
21886
|
-
return {
|
|
21887
|
-
cause: "runner-infra",
|
|
21888
|
-
infraFailures,
|
|
21889
|
-
otherFailures,
|
|
21890
|
-
reason: `${infraFailures.length} check(s) failed on RUNNER INFRASTRUCTURE, not your diff: ${infraFailures.join(", ")}. No test failed \u2014 the shared runner hit a wall-clock budget kill, ran out of disk, or was missing a toolchain (the check annotation names which). Re-run once the runner drains/heals (gh run rerun --failed); do not debug the diff.`
|
|
21891
|
-
};
|
|
21892
|
-
}
|
|
21893
|
-
return {
|
|
21894
|
-
cause: "checks-failure",
|
|
21895
|
-
infraFailures,
|
|
21896
|
-
otherFailures,
|
|
21897
|
-
reason: infraFailures.length ? `checks failed: ${otherFailures.join(", ")}; separately, ${infraFailures.join(", ")} failed on runner infrastructure, not a test.` : `checks failed: ${otherFailures.join(", ") || "unknown"}.`
|
|
21898
|
-
};
|
|
21790
|
+
function lookupWorktreeOwner(primaryRoot, path2) {
|
|
21791
|
+
return findWorktreeOwner(readOwners(primaryRoot), path2);
|
|
21899
21792
|
}
|
|
21900
|
-
|
|
21793
|
+
function recordWorktreeOwner(primaryRoot, entry) {
|
|
21794
|
+
writeOwners(primaryRoot, upsertWorktreeOwner(readOwners(primaryRoot), entry));
|
|
21795
|
+
}
|
|
21796
|
+
function touchWorktreeOwner(primaryRoot, path2, at = /* @__PURE__ */ new Date()) {
|
|
21797
|
+
const entries = readOwners(primaryRoot);
|
|
21798
|
+
const owner = findWorktreeOwner(entries, path2);
|
|
21799
|
+
if (!owner) return;
|
|
21800
|
+
writeOwners(primaryRoot, upsertWorktreeOwner(entries, { ...owner, lastSeenAt: at.toISOString() }));
|
|
21801
|
+
}
|
|
21802
|
+
function dropWorktreeOwner(primaryRoot, path2) {
|
|
21803
|
+
const entries = readOwners(primaryRoot);
|
|
21804
|
+
if (!findWorktreeOwner(entries, path2)) return;
|
|
21805
|
+
writeOwners(primaryRoot, withoutWorktreeOwner(entries, path2));
|
|
21806
|
+
}
|
|
21807
|
+
function appendWorktreeEvent(primaryRoot, event) {
|
|
21901
21808
|
try {
|
|
21902
|
-
const
|
|
21903
|
-
|
|
21904
|
-
|
|
21905
|
-
|
|
21906
|
-
return {
|
|
21907
|
-
cause: "stale-head",
|
|
21908
|
-
infraFailures: [],
|
|
21909
|
-
otherFailures: [],
|
|
21910
|
-
reason: `the PR head (${snapshot.headSha.slice(0, 7)}) is BEHIND the branch tip (${branchTip.slice(0, 7)}) \u2014 GitHub likely dropped a \`synchronize\` event, so the reported red is a superseded commit and NO run exists for your current push. This is not your diff failing. Re-push (an empty commit is enough) or close and reopen the PR to re-sync the head and trigger a fresh run.`
|
|
21911
|
-
};
|
|
21912
|
-
}
|
|
21913
|
-
}
|
|
21914
|
-
const failing = (await fetchHeadCheckRuns(snapshot.headSha, repo, gh)).filter((run) => classifyCheckRun(run) === "fail" && typeof run.id === "number");
|
|
21915
|
-
if (!failing.length) return null;
|
|
21916
|
-
const annotated = await Promise.all(failing.map(async (run) => ({
|
|
21917
|
-
name: run.name ?? `check-run ${run.id}`,
|
|
21918
|
-
annotations: JSON.parse(await gh([`repos/${repo}/check-runs/${run.id}/annotations`]))
|
|
21919
|
-
})));
|
|
21920
|
-
return classifyFailedChecks(annotated);
|
|
21809
|
+
const path2 = worktreeEventsPath(primaryRoot);
|
|
21810
|
+
(0, import_node_fs26.mkdirSync)((0, import_node_path25.dirname)(path2), { recursive: true });
|
|
21811
|
+
(0, import_node_fs26.appendFileSync)(path2, `${JSON.stringify({ at: event.at ?? (/* @__PURE__ */ new Date()).toISOString(), ...event })}
|
|
21812
|
+
`, "utf8");
|
|
21921
21813
|
} catch {
|
|
21922
|
-
return null;
|
|
21923
21814
|
}
|
|
21924
21815
|
}
|
|
21925
|
-
|
|
21816
|
+
function readWorktreeEvents(primaryRoot, limit = 50) {
|
|
21817
|
+
let text;
|
|
21926
21818
|
try {
|
|
21927
|
-
|
|
21928
|
-
const core = parsed.resources?.core;
|
|
21929
|
-
if (typeof core?.remaining !== "number" || typeof core?.reset !== "number") return null;
|
|
21930
|
-
return { remaining: core.remaining, reset: core.reset };
|
|
21819
|
+
text = (0, import_node_fs26.readFileSync)(worktreeEventsPath(primaryRoot), "utf8");
|
|
21931
21820
|
} catch {
|
|
21932
|
-
return
|
|
21821
|
+
return [];
|
|
21822
|
+
}
|
|
21823
|
+
const events = [];
|
|
21824
|
+
for (const line of text.split(/\r?\n/)) {
|
|
21825
|
+
if (!line.trim()) continue;
|
|
21826
|
+
try {
|
|
21827
|
+
const parsed = JSON.parse(line);
|
|
21828
|
+
if (parsed && typeof parsed.target === "string" && typeof parsed.action === "string") events.push(parsed);
|
|
21829
|
+
} catch {
|
|
21830
|
+
}
|
|
21831
|
+
}
|
|
21832
|
+
return limit > 0 ? events.slice(-limit) : events;
|
|
21833
|
+
}
|
|
21834
|
+
function formatWorktreeEvents(events) {
|
|
21835
|
+
if (!events.length) {
|
|
21836
|
+
return "worktree events: none recorded yet (this log starts at the first `worktree create` after #3580 landed)";
|
|
21837
|
+
}
|
|
21838
|
+
const lines = [`worktree events: ${events.length} (newest first)`];
|
|
21839
|
+
for (const e of [...events].reverse()) {
|
|
21840
|
+
lines.push(` ${e.at} ${e.action.toUpperCase()} ${e.target}${e.branch ? ` (branch ${e.branch})` : ""}`);
|
|
21841
|
+
lines.push(` by: ${e.command} \u2014 ${describeActorShort(e.actor)}`);
|
|
21842
|
+
if (e.owner) lines.push(` owner: ${describeActorShort(e.owner.actor)}, created ${e.owner.createdAt}, last active ${e.owner.lastSeenAt}`);
|
|
21843
|
+
if (e.reason) lines.push(` reason: ${e.reason}`);
|
|
21933
21844
|
}
|
|
21845
|
+
return lines.join("\n");
|
|
21934
21846
|
}
|
|
21935
21847
|
|
|
21936
|
-
// src/
|
|
21937
|
-
var
|
|
21938
|
-
|
|
21939
|
-
|
|
21940
|
-
|
|
21941
|
-
|
|
21942
|
-
|
|
21943
|
-
|
|
21944
|
-
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
21945
|
-
return slug.slice(0, 50);
|
|
21848
|
+
// src/merge-cleanup.ts
|
|
21849
|
+
var GC_GH_TIMEOUT_MS2 = 2e4;
|
|
21850
|
+
async function advanceClosedIssuesToDone2(prNumber, repoOption) {
|
|
21851
|
+
try {
|
|
21852
|
+
return await resolveBoardAdvanceForPr(prNumber, repoOption);
|
|
21853
|
+
} catch (e) {
|
|
21854
|
+
return { status: "fetch-failed", entries: [], error: e.message };
|
|
21855
|
+
}
|
|
21946
21856
|
}
|
|
21947
|
-
function
|
|
21948
|
-
const
|
|
21949
|
-
|
|
21857
|
+
async function resolveBoardAdvanceForPr(prNumber, repoOption) {
|
|
21858
|
+
const repoArgs = repoOption ? ["--repo", repoOption] : [];
|
|
21859
|
+
const repo = await resolveRepo(repoOption);
|
|
21860
|
+
if (!repo) {
|
|
21861
|
+
return {
|
|
21862
|
+
status: "fetch-failed",
|
|
21863
|
+
entries: [],
|
|
21864
|
+
error: "could not resolve the PR's repo, so a closing reference cannot be proven to belong to this board"
|
|
21865
|
+
};
|
|
21866
|
+
}
|
|
21867
|
+
return advanceClosedIssuesToDone({
|
|
21868
|
+
repo,
|
|
21869
|
+
fetchClosingIssues: async () => {
|
|
21870
|
+
const { stdout } = await execFileP2("gh", ["pr", "view", prNumber, ...repoArgs, "--json", "closingIssuesReferences"], { timeout: GC_GH_TIMEOUT_MS2 });
|
|
21871
|
+
const parsed = JSON.parse(stdout || "null");
|
|
21872
|
+
if (!parsed || typeof parsed !== "object" || !("closingIssuesReferences" in parsed)) {
|
|
21873
|
+
throw new Error("gh returned no closingIssuesReferences field for this PR");
|
|
21874
|
+
}
|
|
21875
|
+
return parsed.closingIssuesReferences;
|
|
21876
|
+
},
|
|
21877
|
+
moveIssueToDone: async (ref) => {
|
|
21878
|
+
const moved = await moveBoardItem({ config: await loadConfigForBoardSelector2(ref, repoOption), selector: ref, status: "Done", repo: repoOption, allowPartial: true });
|
|
21879
|
+
return moved.partial ? { moved: false, error: moved.warning } : { moved: true };
|
|
21880
|
+
}
|
|
21881
|
+
});
|
|
21950
21882
|
}
|
|
21951
|
-
function
|
|
21952
|
-
|
|
21883
|
+
function recordGcWorktreeRemoval(primaryRoot, actor, owners, path2, branch) {
|
|
21884
|
+
const owner = findWorktreeOwner(owners, path2);
|
|
21885
|
+
appendWorktreeEvent(primaryRoot, {
|
|
21886
|
+
action: "removed",
|
|
21887
|
+
command: "worktree gc",
|
|
21888
|
+
target: path2,
|
|
21889
|
+
branch: branch ?? owner?.branch,
|
|
21890
|
+
actor,
|
|
21891
|
+
owner: owner ? { createdAt: owner.createdAt, lastSeenAt: owner.lastSeenAt, actor: owner.actor } : void 0,
|
|
21892
|
+
reason: owner ? "branch merged/closed or directory dead; owner registration was stale" : "branch merged/closed or directory dead; no owner registered"
|
|
21893
|
+
});
|
|
21894
|
+
dropWorktreeOwner(primaryRoot, path2);
|
|
21953
21895
|
}
|
|
21954
|
-
function
|
|
21955
|
-
const
|
|
21896
|
+
function renderGcApplyResult(result) {
|
|
21897
|
+
const lines = ["worktree gc apply result:"];
|
|
21898
|
+
lines.push(` branches removed: ${result.removedBranches.length ? result.removedBranches.join(", ") : "none"}`);
|
|
21899
|
+
lines.push(` remote branches removed: ${result.removedRemoteBranches.length ? result.removedRemoteBranches.join(", ") : "none"}`);
|
|
21900
|
+
lines.push(` tracking refs removed: ${result.removedTrackingRefs.length ? result.removedTrackingRefs.join(", ") : "none"}`);
|
|
21901
|
+
lines.push(` dead sibling dirs removed: ${result.removedWorktreeDirs.length ? result.removedWorktreeDirs.join(", ") : "none"}`);
|
|
21902
|
+
lines.push(` stale registrations pruned: ${result.pruned ? "yes" : "no"} (clears registrations only \u2014 never deletes a directory)`);
|
|
21903
|
+
if (result.refused.length) {
|
|
21904
|
+
lines.push(` refused \u2014 owned by another session (${result.refused.length}):`);
|
|
21905
|
+
for (const r of result.refused) lines.push(` - ${r}`);
|
|
21906
|
+
}
|
|
21907
|
+
if (result.failed.length) {
|
|
21908
|
+
lines.push(` failed (${result.failed.length}):`);
|
|
21909
|
+
for (const f of result.failed) lines.push(` - ${f}`);
|
|
21910
|
+
}
|
|
21911
|
+
const removedNothing = result.removedBranches.length === 0 && result.removedRemoteBranches.length === 0 && result.removedTrackingRefs.length === 0 && result.removedWorktreeDirs.length === 0;
|
|
21912
|
+
if (removedNothing && !result.refused.length) {
|
|
21913
|
+
lines.push(" NOTHING WAS REMOVED. A stale worktree whose directory still exists is not reachable by");
|
|
21914
|
+
lines.push(" this verb \u2014 remove it from inside it with `mmi-cli worktree land --apply`, or delete the");
|
|
21915
|
+
lines.push(" directory and re-run to clear its registration.");
|
|
21916
|
+
}
|
|
21917
|
+
return lines.join("\n");
|
|
21918
|
+
}
|
|
21919
|
+
async function deleteReviewedRemoteBranch(remote, branch, expectedHeadOid) {
|
|
21920
|
+
if (!isSafeGitRemoteName(remote)) throw new Error(`unsafe remote name ${remote}`);
|
|
21921
|
+
const { stdout } = await execFileP2("git", ["ls-remote", "--heads", remote, branch], { timeout: GIT_TIMEOUT_MS });
|
|
21922
|
+
const line = stdout.trim().split(/\r?\n/).find(Boolean);
|
|
21923
|
+
if (!line) return "already-gone";
|
|
21924
|
+
const remoteHead = line.split(/\s+/)[0]?.trim();
|
|
21925
|
+
if (!remoteHead || remoteHead !== expectedHeadOid) {
|
|
21926
|
+
throw new Error(`${remoteHead || "unknown"} != ${expectedHeadOid}`);
|
|
21927
|
+
}
|
|
21928
|
+
try {
|
|
21929
|
+
await execFileP2("git", [
|
|
21930
|
+
"push",
|
|
21931
|
+
remote,
|
|
21932
|
+
`--force-with-lease=refs/heads/${branch}:${expectedHeadOid}`,
|
|
21933
|
+
`:refs/heads/${branch}`
|
|
21934
|
+
], { timeout: GH_MUTATION_TIMEOUT_MS });
|
|
21935
|
+
} catch (e) {
|
|
21936
|
+
const after = await execFileP2("git", ["ls-remote", "--heads", remote, branch], { timeout: GIT_TIMEOUT_MS }).catch(() => void 0);
|
|
21937
|
+
if (after && !after.stdout.trim().split(/\r?\n/).find(Boolean)) return "already-gone";
|
|
21938
|
+
throw e;
|
|
21939
|
+
}
|
|
21940
|
+
return "deleted";
|
|
21941
|
+
}
|
|
21942
|
+
function evaluatePrMergeHousekeeping(report, context, force = false) {
|
|
21943
|
+
const scratch = report.scratch;
|
|
21944
|
+
const failed = scratch?.status === "failed";
|
|
21945
|
+
const prunable = scratch?.safeAuto ?? 0;
|
|
21946
|
+
const unprunable = scratch?.skipped ?? 0;
|
|
21947
|
+
if (!(failed || prunable > 0 || unprunable > 0)) return { blocked: false };
|
|
21948
|
+
if (force) return { blocked: false };
|
|
21949
|
+
const detail = failed ? `scratch housekeeping failed${scratch?.error ? `: ${scratch.error}` : ""}` : `${prunable} prunable + ${unprunable} un-prunable scratch item(s) remain after cleanup`;
|
|
21956
21950
|
return {
|
|
21957
|
-
|
|
21958
|
-
|
|
21959
|
-
issueForm: isIssueRef(target),
|
|
21960
|
-
claim: Boolean(opts.claim),
|
|
21961
|
-
slug: opts.slug || void 0,
|
|
21962
|
-
from: opts.base || opts.from || DEFAULT_BASE,
|
|
21963
|
-
remote: opts.remote || DEFAULT_REMOTE,
|
|
21964
|
-
repo: opts.repo || void 0,
|
|
21965
|
-
assignee: opts.for || void 0
|
|
21951
|
+
blocked: true,
|
|
21952
|
+
reason: `${context} housekeeping blocked merge: ${detail}. Run \`mmi-cli worktree gc --scratch --apply\` to clear it, then retry \u2014 or re-run \`mmi-cli ${context} --force\` to acknowledge and land anyway. (Kept advisory plans/ scratch never blocks: it is gitignored and can never reach origin.)`
|
|
21966
21953
|
};
|
|
21967
21954
|
}
|
|
21968
|
-
function
|
|
21969
|
-
const
|
|
21970
|
-
|
|
21971
|
-
|
|
21972
|
-
|
|
21973
|
-
|
|
21974
|
-
steps.push("prune worktree metadata");
|
|
21975
|
-
return steps;
|
|
21955
|
+
function assertPrMergeHousekeepingClean(startingPath, context, options = {}) {
|
|
21956
|
+
const build = options.buildHousekeeping ?? ((repoRoot2) => buildPrMergeScratchHousekeeping(repoRoot2));
|
|
21957
|
+
const housekeeping = build(startingPath || process.cwd());
|
|
21958
|
+
const verdict = evaluatePrMergeHousekeeping(housekeeping, context, options.force);
|
|
21959
|
+
if (verdict.blocked) throw new Error(verdict.reason);
|
|
21960
|
+
return housekeeping;
|
|
21976
21961
|
}
|
|
21977
|
-
function
|
|
21978
|
-
const
|
|
21979
|
-
const
|
|
21980
|
-
|
|
21981
|
-
|
|
21982
|
-
|
|
21983
|
-
|
|
21984
|
-
|
|
21985
|
-
|
|
21986
|
-
|
|
21987
|
-
|
|
21988
|
-
|
|
21989
|
-
|
|
21990
|
-
|
|
21991
|
-
|
|
21962
|
+
async function applyGcPlan(plan, remote, opts = {}) {
|
|
21963
|
+
const result = { removedBranches: [], removedRemoteBranches: [], removedTrackingRefs: [], removedWorktreeDirs: [], refused: [], failed: [], pruned: false };
|
|
21964
|
+
const beforeWorktrees = parseWorktreePorcelain(
|
|
21965
|
+
(await execFileP2("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout
|
|
21966
|
+
);
|
|
21967
|
+
const repoRoot2 = (await execFileP2("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim() || process.cwd();
|
|
21968
|
+
const worktreeGitRoot = await currentRepoWorktreeGitRoot(repoRoot2);
|
|
21969
|
+
const primaryRepoRoot = worktreeGitRoot ? (0, import_node_path26.dirname)((0, import_node_path26.dirname)(worktreeGitRoot)) : repoRoot2;
|
|
21970
|
+
const gcActor = describeActor({ env: process.env, surface: detectSurface(process.env), cwd: process.cwd() });
|
|
21971
|
+
const owners = readWorktreeOwners(primaryRepoRoot);
|
|
21972
|
+
const removalNow = Date.now();
|
|
21973
|
+
const refusesRemoval = (path2) => {
|
|
21974
|
+
if (!path2) return false;
|
|
21975
|
+
const owner = findWorktreeOwner(owners, path2);
|
|
21976
|
+
const verdict = decideWorktreeRemoval({ path: path2, owner, actor: gcActor, now: removalNow, force: opts.force });
|
|
21977
|
+
if (verdict.action !== "refuse") return false;
|
|
21978
|
+
result.refused.push(verdict.message);
|
|
21979
|
+
appendWorktreeEvent(primaryRepoRoot, {
|
|
21980
|
+
action: "refused",
|
|
21981
|
+
command: "worktree gc",
|
|
21982
|
+
target: path2,
|
|
21983
|
+
branch: owner?.branch,
|
|
21984
|
+
actor: gcActor,
|
|
21985
|
+
owner: owner ? { createdAt: owner.createdAt, lastSeenAt: owner.lastSeenAt, actor: owner.actor } : void 0,
|
|
21986
|
+
reason: verdict.message
|
|
21992
21987
|
});
|
|
21988
|
+
return true;
|
|
21989
|
+
};
|
|
21990
|
+
const branchesToClean = plan.branches.filter((b) => !refusesRemoval(b.worktreePath));
|
|
21991
|
+
const worktreeDirsToRemove = plan.worktreeDirs.filter((d) => !refusesRemoval(d.path));
|
|
21992
|
+
const deferredStore = await createDeferredWorktreeStore();
|
|
21993
|
+
const branchTracking = await applyBranchAndTrackingCleanup({ ...plan, branches: branchesToClean }, {
|
|
21994
|
+
localBranchHeads,
|
|
21995
|
+
cleanupBranch: async (branch, expectedHeadOid) => {
|
|
21996
|
+
const wtDeps = worktreeRemoveDeps(async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout);
|
|
21997
|
+
const cleanup = await cleanupPrMergeLocalBranch(branch.branch, {
|
|
21998
|
+
beforeWorktrees,
|
|
21999
|
+
startingPath: branch.worktreePath,
|
|
22000
|
+
pathExists: (p) => (0, import_node_fs27.existsSync)(p),
|
|
22001
|
+
execGit: async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout,
|
|
22002
|
+
teardownWorktreeStage,
|
|
22003
|
+
deferredStore,
|
|
22004
|
+
expectedHeadOid,
|
|
22005
|
+
detachReparsePoints: wtDeps.detachReparsePoints,
|
|
22006
|
+
// #3064: junction-safe teardown
|
|
22007
|
+
removeWorktreeDir: wtDeps.removeWorktreeDir
|
|
22008
|
+
});
|
|
22009
|
+
if (cleanup.worktree?.status === "removed") {
|
|
22010
|
+
recordGcWorktreeRemoval(primaryRepoRoot, gcActor, owners, cleanup.worktree.path, branch.branch);
|
|
22011
|
+
}
|
|
22012
|
+
return cleanup;
|
|
22013
|
+
},
|
|
22014
|
+
cleanupRemoteBranch: (branch, expectedHeadOid) => deleteReviewedRemoteBranch(remote, branch.branch, expectedHeadOid),
|
|
22015
|
+
cleanupTrackingRefs: (trackingRefs, unsafeBranches) => applyTrackingRefCleanup(trackingRefs, remote, unsafeBranches, {
|
|
22016
|
+
execGit: async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout
|
|
22017
|
+
})
|
|
22018
|
+
});
|
|
22019
|
+
result.removedBranches.push(...branchTracking.removedBranches);
|
|
22020
|
+
result.removedRemoteBranches.push(...branchTracking.removedRemoteBranches);
|
|
22021
|
+
result.removedTrackingRefs.push(...branchTracking.removedTrackingRefs);
|
|
22022
|
+
result.failed.push(...branchTracking.failed);
|
|
22023
|
+
const removeDeps = worktreeRemoveDeps(async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout);
|
|
22024
|
+
const siblingRoot = opts.root ? resolveExplicitScanRoot(opts.root, primaryRepoRoot) : siblingMmiWorktreesRoot(primaryRepoRoot);
|
|
22025
|
+
for (const wt of worktreeDirsToRemove) {
|
|
22026
|
+
try {
|
|
22027
|
+
const cleanupTarget = resolveSafeSiblingWorktreeCleanupTarget(wt.path, siblingRoot, {
|
|
22028
|
+
realpath: (path2) => (0, import_node_fs27.realpathSync)(path2)
|
|
22029
|
+
});
|
|
22030
|
+
if (!cleanupTarget.ok) {
|
|
22031
|
+
result.failed.push(`${wt.path}: ${cleanupTarget.reason}`);
|
|
22032
|
+
continue;
|
|
22033
|
+
}
|
|
22034
|
+
const stillValid = validateDeadWorktreeDirCleanup(wt, inspectSiblingWorktreeDir(wt.path, worktreeGitRoot));
|
|
22035
|
+
if (!stillValid.ok) {
|
|
22036
|
+
result.failed.push(`${wt.path}: skipped dead-dir removal (${stillValid.reason})`);
|
|
22037
|
+
continue;
|
|
22038
|
+
}
|
|
22039
|
+
const contentSafe = validateDeadWorktreeDirContent(wt, inspectDeadWorktreeDirContent(wt.path));
|
|
22040
|
+
if (!contentSafe.ok) {
|
|
22041
|
+
result.failed.push(`${wt.path}: skipped dead-dir removal (${contentSafe.reason})`);
|
|
22042
|
+
continue;
|
|
22043
|
+
}
|
|
22044
|
+
await removeDeps.removeWorktreeDir(cleanupTarget.path);
|
|
22045
|
+
result.removedWorktreeDirs.push(wt.path);
|
|
22046
|
+
recordGcWorktreeRemoval(primaryRepoRoot, gcActor, owners, wt.path, findWorktreeOwner(owners, wt.path)?.branch);
|
|
22047
|
+
} catch (e) {
|
|
22048
|
+
result.failed.push(`${wt.path}: ${e.message.split("\n")[0]}`);
|
|
22049
|
+
}
|
|
21993
22050
|
}
|
|
21994
|
-
|
|
21995
|
-
|
|
21996
|
-
|
|
21997
|
-
|
|
21998
|
-
|
|
21999
|
-
if (worktreeBranches2.has(branch)) continue;
|
|
22000
|
-
leaks.push({
|
|
22001
|
-
kind: "stale-branch",
|
|
22002
|
-
ref: branch,
|
|
22003
|
-
detail: `merged/closed branch ${branch} has no worktree`,
|
|
22004
|
-
remediation: "mmi-cli worktree gc --apply"
|
|
22005
|
-
});
|
|
22051
|
+
try {
|
|
22052
|
+
await execFileP2("git", ["worktree", "prune"], { timeout: GIT_TIMEOUT_MS });
|
|
22053
|
+
result.pruned = true;
|
|
22054
|
+
} catch (e) {
|
|
22055
|
+
result.failed.push(`worktree prune: ${e.message.split("\n")[0]}`);
|
|
22006
22056
|
}
|
|
22007
|
-
|
|
22008
|
-
|
|
22009
|
-
|
|
22010
|
-
|
|
22011
|
-
|
|
22012
|
-
|
|
22013
|
-
|
|
22014
|
-
|
|
22015
|
-
|
|
22016
|
-
|
|
22017
|
-
|
|
22018
|
-
|
|
22019
|
-
});
|
|
22057
|
+
return result;
|
|
22058
|
+
}
|
|
22059
|
+
async function pollGhPrChecks(prNumber, repoArgs) {
|
|
22060
|
+
let stdout = "";
|
|
22061
|
+
let stderr = "";
|
|
22062
|
+
try {
|
|
22063
|
+
({ stdout, stderr } = await execFileP2("gh", ["pr", "checks", prNumber, ...repoArgs], { timeout: GC_GH_TIMEOUT_MS2 }));
|
|
22064
|
+
} catch (e) {
|
|
22065
|
+
const err = e;
|
|
22066
|
+
if (err.killed || err.stdout == null && err.stderr == null) throw e;
|
|
22067
|
+
stdout = err.stdout ?? "";
|
|
22068
|
+
stderr = err.stderr ?? "";
|
|
22020
22069
|
}
|
|
22021
|
-
const
|
|
22022
|
-
|
|
22023
|
-
|
|
22024
|
-
leaks.push({
|
|
22025
|
-
kind: "orphan-dir",
|
|
22026
|
-
ref: dir.path,
|
|
22027
|
-
// Say only what the inspection proved. `orphaned-folder` proves the `.git` entry is gone while this
|
|
22028
|
-
// repo's worktree metadata still points at the directory — NOT that the directory is empty; gc
|
|
22029
|
-
// checks contents separately, at apply time.
|
|
22030
|
-
detail: dir.reason === "dangling-gitdir" ? "this repo's worktree directory whose git metadata is gone" : "this repo's worktree metadata points here, but the .git entry is gone",
|
|
22031
|
-
// gc --apply re-proves ownership before deleting anything; a raw `rm -rf` does not, so it is never
|
|
22032
|
-
// offered here — following it on a mis-classified dir would destroy another repo's live worktree.
|
|
22033
|
-
remediation: "mmi-cli worktree gc --apply"
|
|
22034
|
-
});
|
|
22070
|
+
const state = parseGhPrChecksResult(stdout, stderr);
|
|
22071
|
+
if (state === "error") {
|
|
22072
|
+
throw new Error(`gh pr checks ${prNumber}: ${(stderr || stdout).trim() || "unknown error"}`);
|
|
22035
22073
|
}
|
|
22036
|
-
return
|
|
22074
|
+
return state;
|
|
22037
22075
|
}
|
|
22038
|
-
|
|
22039
|
-
|
|
22040
|
-
|
|
22041
|
-
|
|
22042
|
-
|
|
22043
|
-
|
|
22044
|
-
|
|
22045
|
-
},
|
|
22046
|
-
inspect: inspectSiblingWorktreeDir
|
|
22047
|
-
};
|
|
22048
|
-
function scanOrphanDirs(worktreesRoot, worktreeGitRoot, deps = defaultOrphanDirScanDeps) {
|
|
22049
|
-
const candidates = [];
|
|
22050
|
-
for (const dir of deps.listDirs(worktreesRoot)) {
|
|
22051
|
-
const { cleanup } = classifySiblingWorktreeDir(deps.inspect(dir, worktreeGitRoot));
|
|
22052
|
-
if (cleanup) candidates.push({ path: dir, reason: cleanup.reason });
|
|
22053
|
-
}
|
|
22054
|
-
return candidates;
|
|
22055
|
-
}
|
|
22056
|
-
function formatStaleLeaks(leaks) {
|
|
22057
|
-
if (!leaks.length) return "worktree list --stale: no leaks found";
|
|
22058
|
-
const lines = [`worktree list --stale: ${leaks.length} leak(s)`];
|
|
22059
|
-
for (const leak of leaks) {
|
|
22060
|
-
lines.push(` [${leak.kind}] ${leak.ref} \u2014 ${leak.detail}`);
|
|
22061
|
-
lines.push(` fix: ${leak.remediation}`);
|
|
22062
|
-
}
|
|
22063
|
-
return lines.join("\n");
|
|
22064
|
-
}
|
|
22065
|
-
async function repoRootOf() {
|
|
22066
|
-
return (await execFileP2("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim() || process.cwd();
|
|
22067
|
-
}
|
|
22068
|
-
function classifyLandBranchMergeState(prs) {
|
|
22069
|
-
if (!prs) return { state: "unknown", numbers: [] };
|
|
22070
|
-
if (!prs.length) return { state: "no-pr", numbers: [] };
|
|
22071
|
-
const pick = (s) => prs.filter((pr2) => pr2.state.toUpperCase() === s).map((pr2) => pr2.number);
|
|
22072
|
-
const open2 = pick("OPEN");
|
|
22073
|
-
if (open2.length) return { state: "open", numbers: open2 };
|
|
22074
|
-
const known = /* @__PURE__ */ new Set(["OPEN", "CLOSED", "MERGED"]);
|
|
22075
|
-
const unrecognised = prs.filter((pr2) => !known.has(pr2.state.toUpperCase()));
|
|
22076
|
-
if (unrecognised.length) return { state: "unknown", numbers: prs.map((pr2) => pr2.number) };
|
|
22077
|
-
const merged = pick("MERGED");
|
|
22078
|
-
if (!merged.length) return { state: "closed-unmerged", numbers: prs.map((pr2) => pr2.number) };
|
|
22079
|
-
if (merged.length < prs.length) return { state: "mixed", numbers: prs.map((pr2) => pr2.number) };
|
|
22080
|
-
return { state: "merged", numbers: merged };
|
|
22081
|
-
}
|
|
22082
|
-
function describeLandMergeState(v) {
|
|
22083
|
-
const refs = v.numbers.map((n) => `#${n}`).join(", ");
|
|
22084
|
-
switch (v.state) {
|
|
22085
|
-
case "merged":
|
|
22086
|
-
return `PR ${refs} merged`;
|
|
22087
|
-
case "closed-unmerged":
|
|
22088
|
-
return `PR ${refs} CLOSED \u2014 NOT merged`;
|
|
22089
|
-
case "mixed":
|
|
22090
|
-
return `PR ${refs} \u2014 some merged, some CLOSED unmerged; which one owns the branch head is unproven`;
|
|
22091
|
-
case "open":
|
|
22092
|
-
return `PR ${refs} still OPEN \u2014 NOT merged`;
|
|
22093
|
-
case "no-pr":
|
|
22094
|
-
return "no pull request for this branch";
|
|
22095
|
-
case "unknown":
|
|
22096
|
-
return refs ? `PR ${refs} reported a pull-request state this CLI does not recognise \u2014 merge NOT confirmed` : "pull-request state could not be read \u2014 merge NOT confirmed";
|
|
22097
|
-
}
|
|
22076
|
+
function ghFailureReason(e) {
|
|
22077
|
+
const err = e;
|
|
22078
|
+
const head = String(err?.message ?? "").split("\n")[0]?.trim() ?? "";
|
|
22079
|
+
const detail = `${err?.stderr ?? ""}
|
|
22080
|
+
${err?.stdout ?? ""}`.split("\n").map((line) => line.trim()).find((line) => line.length > 0);
|
|
22081
|
+
if (detail && head) return `${head} \u2014 ${detail}`;
|
|
22082
|
+
return detail || head || "unknown error";
|
|
22098
22083
|
}
|
|
22099
|
-
|
|
22100
|
-
|
|
22101
|
-
|
|
22102
|
-
|
|
22103
|
-
|
|
22104
|
-
|
|
22105
|
-
|
|
22106
|
-
|
|
22107
|
-
|
|
22108
|
-
|
|
22109
|
-
|
|
22110
|
-
|
|
22111
|
-
|
|
22112
|
-
|
|
22113
|
-
|
|
22114
|
-
|
|
22115
|
-
|
|
22116
|
-
|
|
22117
|
-
|
|
22118
|
-
|
|
22119
|
-
|
|
22120
|
-
|
|
22121
|
-
|
|
22122
|
-
|
|
22123
|
-
|
|
22124
|
-
|
|
22125
|
-
|
|
22126
|
-
};
|
|
22127
|
-
|
|
22128
|
-
|
|
22084
|
+
var defaultGhMergeAutoIo = {
|
|
22085
|
+
gh: async (args, timeoutMs) => (await execFileP2("gh", args, { timeout: timeoutMs })).stdout
|
|
22086
|
+
};
|
|
22087
|
+
async function ghMergeAutoEnqueue(prNumber, repo, method, io = defaultGhMergeAutoIo) {
|
|
22088
|
+
const args = repo ? ["--repo", repo] : [];
|
|
22089
|
+
const headRef = (await io.gh(["pr", "view", prNumber, ...args, "--json", "headRefName", "--jq", ".headRefName"], GC_GH_TIMEOUT_MS2).catch(() => "")).trim();
|
|
22090
|
+
const deleteBranch = !isProtectedBranch(headRef);
|
|
22091
|
+
const readMergeState = () => readGhPrStateWithRetry(
|
|
22092
|
+
() => io.gh(["pr", "view", prNumber, ...args, "--json", "state", "--jq", ".state"], GC_GH_TIMEOUT_MS2)
|
|
22093
|
+
);
|
|
22094
|
+
const confirmEnqueueOutcome = async () => {
|
|
22095
|
+
const stateRead = await readMergeState();
|
|
22096
|
+
if (!stateRead.ok) {
|
|
22097
|
+
return { mergeStatus: "failed", error: `could not read PR state after merge: ${stateRead.error}` };
|
|
22098
|
+
}
|
|
22099
|
+
if (stateRead.state === "MERGED") return { mergeStatus: "merged" };
|
|
22100
|
+
const confirmation = await confirmAutoMergeEnqueued({
|
|
22101
|
+
readAutoMergeRequest: async () => io.gh(["pr", "view", prNumber, ...args, "--json", "autoMergeRequest", "--jq", '.autoMergeRequest // ""'], GC_GH_TIMEOUT_MS2),
|
|
22102
|
+
readMerged: async () => {
|
|
22103
|
+
const s = await readMergeState();
|
|
22104
|
+
return s.ok && s.state === "MERGED";
|
|
22105
|
+
},
|
|
22106
|
+
reEnqueue: async () => {
|
|
22107
|
+
await io.gh(buildPrMergeArgs({ number: prNumber, repoArgs: args, method, auto: true, deleteBranch }), GH_MUTATION_TIMEOUT_MS);
|
|
22108
|
+
}
|
|
22109
|
+
});
|
|
22110
|
+
if (confirmation === "merged") return { mergeStatus: "merged" };
|
|
22111
|
+
if (confirmation === "enqueued") return { mergeStatus: "auto-merge-enqueued" };
|
|
22112
|
+
return { mergeStatus: "failed", error: "auto-merge did not stick \u2014 GitHub reported no autoMergeRequest after enqueue; retry once the PR has a pending check" };
|
|
22113
|
+
};
|
|
22114
|
+
try {
|
|
22115
|
+
await io.gh(buildPrMergeArgs({ number: prNumber, repoArgs: args, method, auto: true, deleteBranch }), GH_MUTATION_TIMEOUT_MS);
|
|
22116
|
+
} catch (e) {
|
|
22117
|
+
const message = String(e.message || "");
|
|
22118
|
+
if (/already been merged/i.test(message)) return { mergeStatus: "merged" };
|
|
22119
|
+
const note = timeoutKillNote(e, GH_MUTATION_TIMEOUT_MS);
|
|
22120
|
+
if (note) return { mergeStatus: "failed", error: note };
|
|
22121
|
+
if (mergeAutoRejectedPrAlreadyClean(message)) {
|
|
22122
|
+
try {
|
|
22123
|
+
await io.gh(buildPrMergeArgs({ number: prNumber, repoArgs: args, method, auto: false, deleteBranch }), GH_MUTATION_TIMEOUT_MS);
|
|
22124
|
+
} catch (e2) {
|
|
22125
|
+
const m2 = String(e2.message || "");
|
|
22126
|
+
if (/already been merged/i.test(m2)) return { mergeStatus: "merged" };
|
|
22127
|
+
if (!ghPrMergeLocalBranchDeleteWarning(m2)) {
|
|
22128
|
+
const afterDirect = await readMergeState();
|
|
22129
|
+
if (afterDirect.ok && afterDirect.state === "MERGED") return { mergeStatus: "merged" };
|
|
22130
|
+
if (!afterDirect.ok) {
|
|
22131
|
+
return { mergeStatus: "failed", error: `could not confirm PR state after clean-status fallback: ${afterDirect.error}` };
|
|
22132
|
+
}
|
|
22133
|
+
try {
|
|
22134
|
+
await io.gh(buildPrMergeArgs({ number: prNumber, repoArgs: args, method, auto: true, deleteBranch }), GH_MUTATION_TIMEOUT_MS);
|
|
22135
|
+
} catch (e3) {
|
|
22136
|
+
const m3 = String(e3.message || "");
|
|
22137
|
+
if (/already been merged/i.test(m3)) return { mergeStatus: "merged" };
|
|
22138
|
+
const afterRetry = await readMergeState();
|
|
22139
|
+
if (afterRetry.ok && afterRetry.state === "MERGED") return { mergeStatus: "merged" };
|
|
22140
|
+
const stuck = (await io.gh(["pr", "view", prNumber, ...args, "--json", "autoMergeRequest", "--jq", '.autoMergeRequest // ""'], GC_GH_TIMEOUT_MS2).catch(() => "")).trim();
|
|
22141
|
+
if (stuck) return { mergeStatus: "auto-merge-enqueued" };
|
|
22142
|
+
return { mergeStatus: "failed", error: `${ghFailureReason(e2)} (re-enqueue also failed: ${ghFailureReason(e3)})` };
|
|
22143
|
+
}
|
|
22144
|
+
return confirmEnqueueOutcome();
|
|
22145
|
+
}
|
|
22146
|
+
}
|
|
22147
|
+
const stateRead = await readMergeState();
|
|
22148
|
+
if (stateRead.ok && stateRead.state === "MERGED") return { mergeStatus: "merged" };
|
|
22149
|
+
return {
|
|
22150
|
+
mergeStatus: "failed",
|
|
22151
|
+
error: stateRead.ok ? "direct merge did not land after clean-status fallback" : `could not confirm PR state after clean-status fallback: ${stateRead.error}`
|
|
22152
|
+
};
|
|
22153
|
+
}
|
|
22154
|
+
if (ghPrMergeLocalBranchDeleteWarning(message)) {
|
|
22155
|
+
const stateRead = await readMergeState();
|
|
22156
|
+
if (stateRead.ok && stateRead.state === "MERGED") return { mergeStatus: "merged" };
|
|
22157
|
+
return {
|
|
22158
|
+
mergeStatus: "failed",
|
|
22159
|
+
error: stateRead.ok ? message.split("\n")[0] : `could not confirm PR state after local branch cleanup warning: ${stateRead.error}`
|
|
22160
|
+
};
|
|
22161
|
+
}
|
|
22162
|
+
if (!basePolicyBlocksImmediateMerge(message)) {
|
|
22163
|
+
return { mergeStatus: "failed", error: ghFailureReason(e) };
|
|
22164
|
+
}
|
|
22129
22165
|
return {
|
|
22130
|
-
|
|
22131
|
-
|
|
22132
|
-
message: `'${input.branch}' has a pull request that never merged (${refs}). The remote branch is the only thing still holding that work. Re-run with --keep-remote for a local-only clean`
|
|
22166
|
+
mergeStatus: "failed",
|
|
22167
|
+
error: `${ghFailureReason(e)} \u2014 auto-merge could not be armed yet (the PR's required checks have not registered); re-run the same --auto merge once they appear`
|
|
22133
22168
|
};
|
|
22134
22169
|
}
|
|
22135
|
-
return
|
|
22170
|
+
return confirmEnqueueOutcome();
|
|
22136
22171
|
}
|
|
22137
|
-
async function
|
|
22172
|
+
async function remoteBranchExists2(branch, options = {}) {
|
|
22173
|
+
return checkRemoteBranchExists(branch, {
|
|
22174
|
+
execGit: async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout
|
|
22175
|
+
}, options);
|
|
22176
|
+
}
|
|
22177
|
+
var COMPOSE_TIMEOUT_MS = 12e4;
|
|
22178
|
+
function spawnDeferredGcSweep() {
|
|
22179
|
+
spawnDetachedSelf(["worktree", "gc", "sweep-deferred", "--quiet"], { spawn: import_node_child_process11.spawn, execPath: process.execPath, scriptPath: process.argv[1] });
|
|
22180
|
+
}
|
|
22181
|
+
async function createDeferredWorktreeStore() {
|
|
22138
22182
|
try {
|
|
22139
|
-
const { stdout } = await execFileP2("
|
|
22140
|
-
|
|
22141
|
-
"list",
|
|
22142
|
-
"--head",
|
|
22143
|
-
branch,
|
|
22144
|
-
"--state",
|
|
22145
|
-
"all",
|
|
22146
|
-
"--limit",
|
|
22147
|
-
"20",
|
|
22148
|
-
...repo ? ["--repo", repo] : [],
|
|
22149
|
-
"--json",
|
|
22150
|
-
"number,state"
|
|
22151
|
-
], { timeout: GH_TIMEOUT_MS });
|
|
22152
|
-
const parsed = JSON.parse(stdout.trim() || "null");
|
|
22153
|
-
if (!Array.isArray(parsed)) return void 0;
|
|
22154
|
-
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 }));
|
|
22183
|
+
const { stdout } = await execFileP2("git", ["rev-parse", "--git-common-dir"], { timeout: GIT_TIMEOUT_MS });
|
|
22184
|
+
return makeDeferredWorktreeStore(deferredWorktreesRegistryPath(stdout.trim()));
|
|
22155
22185
|
} catch {
|
|
22156
22186
|
return void 0;
|
|
22157
22187
|
}
|
|
22158
22188
|
}
|
|
22159
|
-
|
|
22189
|
+
var realWorktreeDirRemover = {
|
|
22190
|
+
probe: (p) => {
|
|
22191
|
+
let st;
|
|
22192
|
+
try {
|
|
22193
|
+
st = (0, import_node_fs27.lstatSync)(p);
|
|
22194
|
+
} catch {
|
|
22195
|
+
return null;
|
|
22196
|
+
}
|
|
22197
|
+
if (st.isSymbolicLink()) return "link";
|
|
22198
|
+
try {
|
|
22199
|
+
(0, import_node_fs27.readlinkSync)(p);
|
|
22200
|
+
return "link";
|
|
22201
|
+
} catch {
|
|
22202
|
+
}
|
|
22203
|
+
return st.isDirectory() ? "dir" : "file";
|
|
22204
|
+
},
|
|
22205
|
+
readdir: (p) => {
|
|
22206
|
+
try {
|
|
22207
|
+
return (0, import_node_fs27.readdirSync)(p);
|
|
22208
|
+
} catch {
|
|
22209
|
+
return [];
|
|
22210
|
+
}
|
|
22211
|
+
},
|
|
22212
|
+
// A directory reparse point (junction / dir-symlink) is detached with rmdir (unlinks the mount point,
|
|
22213
|
+
// leaving the target); a file symlink with unlink. rmdir first, fall back to unlink.
|
|
22214
|
+
detachLink: (p) => {
|
|
22215
|
+
try {
|
|
22216
|
+
(0, import_node_fs27.rmdirSync)(p);
|
|
22217
|
+
} catch {
|
|
22218
|
+
(0, import_node_fs27.unlinkSync)(p);
|
|
22219
|
+
}
|
|
22220
|
+
},
|
|
22221
|
+
removeTree: (p) => (0, import_promises6.rm)(p, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
|
22222
|
+
};
|
|
22223
|
+
async function resolvePrimaryCheckout(execGit) {
|
|
22160
22224
|
try {
|
|
22161
|
-
|
|
22162
|
-
const title = stdout.trim();
|
|
22163
|
-
return title || void 0;
|
|
22225
|
+
return parseWorktreePorcelain(await execGit(["worktree", "list", "--porcelain"]))[0]?.path;
|
|
22164
22226
|
} catch {
|
|
22165
22227
|
return void 0;
|
|
22166
22228
|
}
|
|
22167
22229
|
}
|
|
22168
|
-
|
|
22169
|
-
|
|
22170
|
-
|
|
22171
|
-
|
|
22230
|
+
function worktreeRemoveDeps(execGit) {
|
|
22231
|
+
return {
|
|
22232
|
+
git: execGit,
|
|
22233
|
+
sleep: (ms) => new Promise((resolve5) => setTimeout(resolve5, ms)),
|
|
22234
|
+
// #3064: unlink any reparse point (esp. a `node_modules` junction to base) before the git-native
|
|
22235
|
+
// `worktree remove --force`, which would otherwise recurse through it and empty the base checkout.
|
|
22236
|
+
detachReparsePoints: (worktreePath) => detachReparsePoints(worktreePath, realWorktreeDirRemover),
|
|
22237
|
+
removeWorktreeDir: async (worktreePath) => removeWorktreeTree(worktreePath, await resolvePrimaryCheckout(execGit), realWorktreeDirRemover)
|
|
22238
|
+
};
|
|
22172
22239
|
}
|
|
22173
|
-
function
|
|
22174
|
-
|
|
22175
|
-
|
|
22176
|
-
|
|
22177
|
-
|
|
22178
|
-
|
|
22179
|
-
|
|
22180
|
-
|
|
22181
|
-
|
|
22182
|
-
|
|
22183
|
-
|
|
22184
|
-
|
|
22185
|
-
|
|
22186
|
-
|
|
22187
|
-
|
|
22188
|
-
|
|
22189
|
-
|
|
22190
|
-
|
|
22191
|
-
|
|
22192
|
-
|
|
22193
|
-
|
|
22194
|
-
|
|
22195
|
-
|
|
22196
|
-
|
|
22197
|
-
|
|
22198
|
-
const
|
|
22199
|
-
|
|
22200
|
-
|
|
22201
|
-
|
|
22202
|
-
|
|
22203
|
-
remote: o.remote,
|
|
22204
|
-
hasStage,
|
|
22205
|
-
steps
|
|
22206
|
-
};
|
|
22207
|
-
if (!apply) {
|
|
22208
|
-
const preview = { dryRun: true, ...plan, ...o.keepRemote ? { keepRemote: true } : {} };
|
|
22209
|
-
if (o.json) return console.log(JSON.stringify(preview, null, 2));
|
|
22210
|
-
const lines = [`worktree land: dry-run (pass --apply to execute)`, ` branch: ${branch}`, ` worktree: ${wtPath}`];
|
|
22211
|
-
if (hasStage) lines.push(` stage: port ${stageSummary.port} (will be stopped)`);
|
|
22212
|
-
if (o.keepRemote) lines.push(` remote branch: kept (--keep-remote)`);
|
|
22213
|
-
for (const s of steps) lines.push(` - ${s}`);
|
|
22214
|
-
return console.log(lines.join("\n"));
|
|
22215
|
-
}
|
|
22216
|
-
const landDirty = ((await execFileP2("git", ["-C", wtPath, "status", "--porcelain"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "unreadable" }))).stdout || "").trim().length > 0;
|
|
22217
|
-
if (landDirty) {
|
|
22218
|
-
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 });
|
|
22219
|
-
}
|
|
22220
|
-
const landPrs = await fetchLandBranchPrs(branch);
|
|
22221
|
-
const mergeVerdict = classifyLandBranchMergeState(landPrs);
|
|
22222
|
-
const landRefs = decideWorktreeLandRefCleanup({
|
|
22223
|
-
branch,
|
|
22224
|
-
prs: landPrs,
|
|
22225
|
-
keepRemote: Boolean(o.keepRemote)
|
|
22226
|
-
});
|
|
22227
|
-
if (landRefs.action === "refuse") {
|
|
22228
|
-
return fail(`worktree land: ${landRefs.message}`, { code: ERROR_CODES.ERR_BAD_ENUM });
|
|
22229
|
-
}
|
|
22230
|
-
const keepRemoteBranch = Boolean(o.keepRemote) || landRefs.action === "keep-remote";
|
|
22231
|
-
if (landRefs.action === "keep-remote") console.warn(`worktree land: ${landRefs.message}.`);
|
|
22232
|
-
const report = [];
|
|
22233
|
-
if (hasStage) {
|
|
22234
|
-
try {
|
|
22235
|
-
await stopStage({ cwd: wtPath, requiredIdentityCwd: wtPath });
|
|
22236
|
-
report.push({ step: "stop stage", status: "stopped" });
|
|
22237
|
-
} catch (e) {
|
|
22238
|
-
report.push({ step: "stop stage", status: `failed: ${e.message.split("\n")[0]}` });
|
|
22239
|
-
}
|
|
22240
|
-
}
|
|
22241
|
-
releaseCwdIfUnderWorktree(wtPath, primaryCheckout);
|
|
22242
|
-
const removeOutcome = await removeWorktreeWithRecovery(
|
|
22243
|
-
wtPath,
|
|
22244
|
-
worktreeRemoveDeps(async (args) => (await execFileP2("git", ["-C", primaryCheckout, ...args], { timeout: GIT_TIMEOUT_MS })).stdout)
|
|
22245
|
-
);
|
|
22246
|
-
report.push({
|
|
22247
|
-
step: "remove worktree",
|
|
22248
|
-
status: removeOutcome.status === "removed" ? removeOutcome.recovery ? `done (${removeOutcome.recovery})` : "done" : `failed: ${removeOutcome.error ?? "lock held"} \u2014 run: git -C "${primaryCheckout}" worktree remove --force "${wtPath}"`
|
|
22249
|
-
});
|
|
22250
|
-
report.push(await bestEffortGit(["branch", "-D", branch], primaryCheckout, `delete local branch ${branch}`));
|
|
22251
|
-
if (!keepRemoteBranch) {
|
|
22252
|
-
report.push(await bestEffortGit(["push", o.remote, "--delete", branch], void 0, `delete remote branch ${branch}`, GH_MUTATION_TIMEOUT_MS));
|
|
22253
|
-
} else if (!o.keepRemote) {
|
|
22254
|
-
report.push({ step: `delete remote branch ${branch}`, status: `skipped: ${landRefs.action === "keep-remote" ? landRefs.reason : "kept"}` });
|
|
22255
|
-
}
|
|
22256
|
-
report.push(await bestEffortGit(["worktree", "prune"], primaryCheckout, "prune worktree metadata"));
|
|
22257
|
-
const result = {
|
|
22258
|
-
dryRun: false,
|
|
22259
|
-
...plan,
|
|
22260
|
-
...o.keepRemote ? { keepRemote: true } : {},
|
|
22261
|
-
// #3566: the merge outcome, machine-readable, so an orchestrator never has to infer it from the
|
|
22262
|
-
// absence of an error. `merged` is the ONLY value that means the work reached the base branch.
|
|
22263
|
-
mergeState: mergeVerdict.state,
|
|
22264
|
-
prNumbers: mergeVerdict.numbers,
|
|
22265
|
-
report
|
|
22266
|
-
};
|
|
22267
|
-
if (o.json) return console.log(JSON.stringify(result, null, 2));
|
|
22268
|
-
console.log(`worktree land: cleaned up branch ${branch} (${describeLandMergeState(mergeVerdict)})`);
|
|
22269
|
-
for (const r of report) console.log(` ${r.step}: ${r.status}`);
|
|
22270
|
-
if (mergeVerdict.state !== "merged") {
|
|
22271
|
-
console.warn(`worktree land: '${branch}' was cleaned up but NOT merged \u2014 ${describeLandMergeState(mergeVerdict)}. Do not report this work as shipped.`);
|
|
22272
|
-
}
|
|
22273
|
-
if (report.some((r) => r.status.startsWith("failed"))) process.exitCode = 1;
|
|
22274
|
-
} catch (e) {
|
|
22275
|
-
return failGraceful(`worktree land: ${e.message}`);
|
|
22240
|
+
async function worktreeHasStageState(worktreePath) {
|
|
22241
|
+
if (stageStateFileBelongsToWorktree(stageStatePath(worktreePath), worktreePath)) return true;
|
|
22242
|
+
try {
|
|
22243
|
+
const { stdout } = await execFileP2("git", ["-C", worktreePath, "rev-parse", "--path-format=absolute", "--git-common-dir"], { timeout: GIT_TIMEOUT_MS });
|
|
22244
|
+
const globalPath = stageGlobalStatePath(worktreePath, stdout.trim());
|
|
22245
|
+
return stageStateFileBelongsToWorktree(globalPath, worktreePath);
|
|
22246
|
+
} catch {
|
|
22247
|
+
return false;
|
|
22248
|
+
}
|
|
22249
|
+
}
|
|
22250
|
+
function stageStateFileBelongsToWorktree(statePath, worktreePath) {
|
|
22251
|
+
if (!(0, import_node_fs27.existsSync)(statePath)) return false;
|
|
22252
|
+
try {
|
|
22253
|
+
const state = JSON.parse((0, import_node_fs27.readFileSync)(statePath, "utf8"));
|
|
22254
|
+
const recordedCwd = typeof state.identity?.cwd === "string" ? state.identity.cwd : typeof state.cwd === "string" ? state.cwd : "";
|
|
22255
|
+
return Boolean(recordedCwd && isPathUnderDirectory(recordedCwd, worktreePath));
|
|
22256
|
+
} catch {
|
|
22257
|
+
return false;
|
|
22258
|
+
}
|
|
22259
|
+
}
|
|
22260
|
+
function teardownWorktreeStage(worktreePath) {
|
|
22261
|
+
return runWorktreeStageTeardown(worktreePath, {
|
|
22262
|
+
hasStageState: worktreeHasStageState,
|
|
22263
|
+
stopRecordedStage: async (wt) => (await stopStage({ cwd: wt, requiredIdentityCwd: wt })).pid,
|
|
22264
|
+
listComposeProjects: async () => {
|
|
22265
|
+
const { stdout } = await execFileP2("docker", ["compose", "ls", "--all", "--format", "json"], { timeout: GC_GH_TIMEOUT_MS2 });
|
|
22266
|
+
return parseComposeLs(stdout);
|
|
22267
|
+
},
|
|
22268
|
+
composeDown: async (project2) => {
|
|
22269
|
+
await execFileP2("docker", ["compose", "-p", project2, "down", "-v"], { timeout: COMPOSE_TIMEOUT_MS });
|
|
22276
22270
|
}
|
|
22277
22271
|
});
|
|
22278
|
-
|
|
22279
|
-
|
|
22280
|
-
|
|
22281
|
-
|
|
22282
|
-
|
|
22283
|
-
|
|
22284
|
-
|
|
22285
|
-
|
|
22286
|
-
|
|
22287
|
-
|
|
22288
|
-
|
|
22289
|
-
|
|
22290
|
-
|
|
22291
|
-
|
|
22292
|
-
|
|
22293
|
-
|
|
22294
|
-
|
|
22295
|
-
|
|
22272
|
+
}
|
|
22273
|
+
|
|
22274
|
+
// src/pr-checks-rest.ts
|
|
22275
|
+
var REST_GH_TIMEOUT_MS = 2e4;
|
|
22276
|
+
async function defaultGhApi(args) {
|
|
22277
|
+
const { stdout } = await execFileP2("gh", ["api", ...args], { timeout: REST_GH_TIMEOUT_MS });
|
|
22278
|
+
return stdout;
|
|
22279
|
+
}
|
|
22280
|
+
function classifyCheckRun(run) {
|
|
22281
|
+
if (run.status !== "completed") return "pending";
|
|
22282
|
+
switch (run.conclusion) {
|
|
22283
|
+
case "success":
|
|
22284
|
+
case "neutral":
|
|
22285
|
+
case "skipped":
|
|
22286
|
+
return "pass";
|
|
22287
|
+
case "failure":
|
|
22288
|
+
case "timed_out":
|
|
22289
|
+
case "cancelled":
|
|
22290
|
+
case "action_required":
|
|
22291
|
+
case "startup_failure":
|
|
22292
|
+
case "stale":
|
|
22293
|
+
return "fail";
|
|
22294
|
+
default:
|
|
22295
|
+
return "pending";
|
|
22296
|
+
}
|
|
22297
|
+
}
|
|
22298
|
+
function classifyCommitStatus(state) {
|
|
22299
|
+
if (state === "success") return "pass";
|
|
22300
|
+
if (state === "failure" || state === "error") return "fail";
|
|
22301
|
+
return "pending";
|
|
22302
|
+
}
|
|
22303
|
+
function dedupeLatestCheckRuns(runs) {
|
|
22304
|
+
const byIdentity = /* @__PURE__ */ new Map();
|
|
22305
|
+
const unkeyed = [];
|
|
22306
|
+
for (const run of runs) {
|
|
22307
|
+
if (!run.name || typeof run.id !== "number") {
|
|
22308
|
+
unkeyed.push(run);
|
|
22309
|
+
continue;
|
|
22296
22310
|
}
|
|
22297
|
-
|
|
22311
|
+
const key = `${run.app_id ?? "unknown-app"}:${run.name}`;
|
|
22312
|
+
const prior = byIdentity.get(key);
|
|
22313
|
+
if (!prior || run.id >= (prior.id ?? 0)) byIdentity.set(key, run);
|
|
22314
|
+
}
|
|
22315
|
+
return [...byIdentity.values(), ...unkeyed];
|
|
22298
22316
|
}
|
|
22299
|
-
|
|
22300
|
-
|
|
22301
|
-
const
|
|
22302
|
-
const
|
|
22303
|
-
|
|
22304
|
-
|
|
22305
|
-
|
|
22306
|
-
|
|
22307
|
-
|
|
22308
|
-
|
|
22309
|
-
|
|
22317
|
+
function derivePollState(buckets) {
|
|
22318
|
+
if (!buckets.length) return "no-checks-reported";
|
|
22319
|
+
const anyFailure = buckets.includes("fail");
|
|
22320
|
+
const anyPending = buckets.includes("pending");
|
|
22321
|
+
if (anyFailure) return anyPending ? "failing" : "failure";
|
|
22322
|
+
return anyPending ? "pending" : "success";
|
|
22323
|
+
}
|
|
22324
|
+
function parseNdjsonLines(stdout) {
|
|
22325
|
+
return stdout.split(/\r?\n/).filter((line) => line.trim()).map((line) => JSON.parse(line));
|
|
22326
|
+
}
|
|
22327
|
+
function parseRestPrSnapshot(json) {
|
|
22328
|
+
const pr2 = json ?? {};
|
|
22329
|
+
const headSha = pr2.head?.sha ?? "";
|
|
22330
|
+
if (!headSha) throw new Error("pulls endpoint returned no head SHA");
|
|
22331
|
+
const headRepo = pr2.head?.repo?.full_name;
|
|
22332
|
+
const baseRepo = pr2.base?.repo?.full_name;
|
|
22333
|
+
return {
|
|
22334
|
+
headSha,
|
|
22335
|
+
headRef: pr2.head?.ref ?? "",
|
|
22336
|
+
// Only call it a fork when BOTH names are known and differ — an absent name (private/deleted fork)
|
|
22337
|
+
// must not read as same-repo and send a git-ref probe at a branch the base repo does not have.
|
|
22338
|
+
headIsFork: Boolean(headRepo && baseRepo && headRepo !== baseRepo),
|
|
22339
|
+
mergeable: pr2.mergeable === true ? "MERGEABLE" : pr2.mergeable === false ? "CONFLICTING" : "UNKNOWN",
|
|
22340
|
+
baseRef: pr2.base?.ref ?? "development",
|
|
22341
|
+
merged: pr2.merged === true,
|
|
22342
|
+
state: pr2.state ?? ""
|
|
22343
|
+
};
|
|
22344
|
+
}
|
|
22345
|
+
async function fetchBranchTipSha(repo, ref, gh = defaultGhApi) {
|
|
22346
|
+
if (!ref) return null;
|
|
22310
22347
|
try {
|
|
22311
|
-
const
|
|
22312
|
-
const
|
|
22313
|
-
|
|
22314
|
-
for (const pr2 of prs) {
|
|
22315
|
-
const arr = byBranch.get(pr2.headRefName) ?? [];
|
|
22316
|
-
arr.push(pr2.state);
|
|
22317
|
-
byBranch.set(pr2.headRefName, arr);
|
|
22318
|
-
}
|
|
22319
|
-
for (const [br, states] of byBranch) {
|
|
22320
|
-
if (states.some((s) => s === "OPEN")) openPrBranches.add(br);
|
|
22321
|
-
else if (states.some((s) => s === "MERGED" || s === "CLOSED")) closedBranches.add(br);
|
|
22322
|
-
}
|
|
22348
|
+
const parsed = JSON.parse(await gh([`repos/${repo}/git/ref/heads/${ref}`]));
|
|
22349
|
+
const sha = parsed.object?.sha;
|
|
22350
|
+
return typeof sha === "string" && sha ? sha : null;
|
|
22323
22351
|
} catch {
|
|
22352
|
+
return null;
|
|
22324
22353
|
}
|
|
22325
|
-
const stages = [];
|
|
22326
|
-
for (const wt of worktrees) {
|
|
22327
|
-
const s = readStageSummary(wt.path);
|
|
22328
|
-
if (s) stages.push({ path: wt.path, port: s.port });
|
|
22329
|
-
}
|
|
22330
|
-
const worktreeGitRoot = await currentRepoWorktreeGitRoot(repoRoot2);
|
|
22331
|
-
const primaryRepoRoot = worktreeGitRoot ? (0, import_node_path25.dirname)((0, import_node_path25.dirname)(worktreeGitRoot)) : repoRoot2;
|
|
22332
|
-
const wtRoot = siblingMmiWorktreesRoot(primaryRepoRoot);
|
|
22333
|
-
let orphanDirs = [];
|
|
22334
|
-
if ((0, import_node_fs26.existsSync)(wtRoot)) {
|
|
22335
|
-
orphanDirs = scanOrphanDirs(wtRoot, worktreeGitRoot, {
|
|
22336
|
-
...defaultOrphanDirScanDeps,
|
|
22337
|
-
listDirs: (root) => worktreeScanDirs(root, primaryRepoRoot, defaultOrphanDirScanDeps.listDirs, isRepoCheckoutDir)
|
|
22338
|
-
});
|
|
22339
|
-
}
|
|
22340
|
-
return { worktrees, localBranches, currentBranch: currentBranch2, openPrBranches, closedBranches, stages, orphanDirs };
|
|
22341
22354
|
}
|
|
22342
|
-
async function
|
|
22355
|
+
async function fetchRestPrSnapshot(prNumber, repo, gh = defaultGhApi) {
|
|
22356
|
+
return parseRestPrSnapshot(JSON.parse(await gh([`repos/${repo}/pulls/${prNumber}`])));
|
|
22357
|
+
}
|
|
22358
|
+
async function fetchHeadCheckRuns(headSha, repo, gh) {
|
|
22359
|
+
const runsOut = await gh(["--paginate", `repos/${repo}/commits/${headSha}/check-runs?per_page=100`, "--jq", ".check_runs[] | {id, name, status, conclusion, app_id: .app.id}"]);
|
|
22360
|
+
return dedupeLatestCheckRuns(parseNdjsonLines(runsOut));
|
|
22361
|
+
}
|
|
22362
|
+
async function fetchHeadCheckBuckets(headSha, repo, gh) {
|
|
22363
|
+
const [runs, statusesOut] = await Promise.all([
|
|
22364
|
+
fetchHeadCheckRuns(headSha, repo, gh),
|
|
22365
|
+
gh(["--paginate", `repos/${repo}/commits/${headSha}/status?per_page=100`, "--jq", ".statuses[] | {context, state}"])
|
|
22366
|
+
]);
|
|
22367
|
+
const statuses = parseNdjsonLines(statusesOut);
|
|
22368
|
+
return [...runs.map(classifyCheckRun), ...statuses.map((s) => classifyCommitStatus(s.state))];
|
|
22369
|
+
}
|
|
22370
|
+
async function pollRestPrChecks(prNumber, repo, gh = defaultGhApi) {
|
|
22371
|
+
const snapshot = await fetchRestPrSnapshot(prNumber, repo, gh);
|
|
22372
|
+
return derivePollState(await fetchHeadCheckBuckets(snapshot.headSha, repo, gh));
|
|
22373
|
+
}
|
|
22374
|
+
async function pollRestPrMergeable(prNumber, repo, gh = defaultGhApi) {
|
|
22343
22375
|
try {
|
|
22344
|
-
|
|
22345
|
-
|
|
22346
|
-
return
|
|
22347
|
-
} catch (e) {
|
|
22348
|
-
const msg = e.message.split("\n")[0];
|
|
22349
|
-
if (/not found|already|no such|does not exist|not a valid|couldn't|unable to resolve/i.test(msg)) {
|
|
22350
|
-
return { step, status: "already gone" };
|
|
22351
|
-
}
|
|
22352
|
-
return { step, status: `failed: ${msg}` };
|
|
22376
|
+
return (await fetchRestPrSnapshot(prNumber, repo, gh)).mergeable;
|
|
22377
|
+
} catch {
|
|
22378
|
+
return "UNKNOWN";
|
|
22353
22379
|
}
|
|
22354
22380
|
}
|
|
22355
|
-
|
|
22356
|
-
|
|
22357
|
-
|
|
22358
|
-
|
|
22359
|
-
|
|
22360
|
-
async function editIssue(client, options, deps = {}) {
|
|
22361
|
-
const parsed = parseIssueRef(options.ref);
|
|
22362
|
-
const repo = parsed.repo ?? options.defaultRepo;
|
|
22363
|
-
if (!repo) throw new Error("could not resolve repo \u2014 pass --repo owner/repo");
|
|
22364
|
-
const url = `https://github.com/${repo}/issues/${parsed.number}`;
|
|
22365
|
-
const patch = {};
|
|
22366
|
-
let bodyChanged = false;
|
|
22367
|
-
if (options.title !== void 0) patch.title = options.title;
|
|
22368
|
-
if (options.body !== void 0 || options.bodyFile !== void 0) {
|
|
22369
|
-
let body;
|
|
22370
|
-
if (options.bodyFile) {
|
|
22371
|
-
const td = deps.textDeps ?? { readFile: (p, e) => Promise.resolve((0, import_node_fs27.readFileSync)(p, e)), readStdin: () => Promise.resolve("") };
|
|
22372
|
-
body = await resolveIssueBody({ body: options.body, bodyFile: options.bodyFile }, td);
|
|
22373
|
-
} else {
|
|
22374
|
-
body = options.body ?? "";
|
|
22375
|
-
}
|
|
22376
|
-
patch.body = body;
|
|
22377
|
-
bodyChanged = true;
|
|
22378
|
-
}
|
|
22379
|
-
if (patch.title !== void 0 || patch.body !== void 0) {
|
|
22380
|
-
await client.rest("PATCH", `repos/${repo}/issues/${parsed.number}`, { body: patch });
|
|
22381
|
-
}
|
|
22382
|
-
const labelsAdded = [];
|
|
22383
|
-
const labelsRemoved = [];
|
|
22384
|
-
if (options.addLabel?.length) {
|
|
22385
|
-
await client.rest("POST", `repos/${repo}/issues/${parsed.number}/labels`, { body: { labels: options.addLabel } });
|
|
22386
|
-
labelsAdded.push(...options.addLabel);
|
|
22381
|
+
async function pollRestPrMerged(prNumber, repo, gh = defaultGhApi) {
|
|
22382
|
+
try {
|
|
22383
|
+
return (await fetchRestPrSnapshot(prNumber, repo, gh)).merged;
|
|
22384
|
+
} catch {
|
|
22385
|
+
return false;
|
|
22387
22386
|
}
|
|
22388
|
-
|
|
22389
|
-
|
|
22390
|
-
|
|
22391
|
-
|
|
22392
|
-
|
|
22393
|
-
|
|
22394
|
-
|
|
22395
|
-
|
|
22396
|
-
|
|
22397
|
-
|
|
22398
|
-
|
|
22387
|
+
}
|
|
22388
|
+
var CI_BUDGET_ANNOTATION_TITLE = "CI budget exceeded";
|
|
22389
|
+
var CI_DISK_ANNOTATION_TITLE = "CI runner disk";
|
|
22390
|
+
var CI_TOOLCHAIN_ANNOTATION_TITLE = "CI runner toolchain";
|
|
22391
|
+
var RUNNER_INFRA_ANNOTATION_TITLES = /* @__PURE__ */ new Set([
|
|
22392
|
+
CI_BUDGET_ANNOTATION_TITLE,
|
|
22393
|
+
CI_DISK_ANNOTATION_TITLE,
|
|
22394
|
+
CI_TOOLCHAIN_ANNOTATION_TITLE
|
|
22395
|
+
]);
|
|
22396
|
+
function isErrorAnnotation(a) {
|
|
22397
|
+
const level = a.annotation_level?.toLowerCase();
|
|
22398
|
+
return level === "failure" || level === "error";
|
|
22399
|
+
}
|
|
22400
|
+
function classifyFailedChecks(failing) {
|
|
22401
|
+
const infraFailures = [];
|
|
22402
|
+
const otherFailures = [];
|
|
22403
|
+
for (const check of failing) {
|
|
22404
|
+
const errors = check.annotations.filter(isErrorAnnotation);
|
|
22405
|
+
const allInfra = errors.length > 0 && errors.every((a) => RUNNER_INFRA_ANNOTATION_TITLES.has(a.title?.trim() ?? ""));
|
|
22406
|
+
(allInfra ? infraFailures : otherFailures).push(check.name);
|
|
22399
22407
|
}
|
|
22400
|
-
|
|
22401
|
-
|
|
22402
|
-
|
|
22403
|
-
|
|
22408
|
+
if (infraFailures.length && !otherFailures.length) {
|
|
22409
|
+
return {
|
|
22410
|
+
cause: "runner-infra",
|
|
22411
|
+
infraFailures,
|
|
22412
|
+
otherFailures,
|
|
22413
|
+
reason: `${infraFailures.length} check(s) failed on RUNNER INFRASTRUCTURE, not your diff: ${infraFailures.join(", ")}. No test failed \u2014 the shared runner hit a wall-clock budget kill, ran out of disk, or was missing a toolchain (the check annotation names which). Re-run once the runner drains/heals (gh run rerun --failed); do not debug the diff.`
|
|
22414
|
+
};
|
|
22404
22415
|
}
|
|
22405
22416
|
return {
|
|
22406
|
-
|
|
22407
|
-
|
|
22408
|
-
|
|
22409
|
-
|
|
22410
|
-
bodyChanged,
|
|
22411
|
-
labelsAdded,
|
|
22412
|
-
labelsRemoved,
|
|
22413
|
-
...parentResult ? { parent: parentResult } : {}
|
|
22417
|
+
cause: "checks-failure",
|
|
22418
|
+
infraFailures,
|
|
22419
|
+
otherFailures,
|
|
22420
|
+
reason: infraFailures.length ? `checks failed: ${otherFailures.join(", ")}; separately, ${infraFailures.join(", ")} failed on runner infrastructure, not a test.` : `checks failed: ${otherFailures.join(", ") || "unknown"}.`
|
|
22414
22421
|
};
|
|
22415
22422
|
}
|
|
22416
|
-
async function
|
|
22417
|
-
const parsed = parseIssueRef(options.ref);
|
|
22418
|
-
const repo = parsed.repo ?? options.defaultRepo;
|
|
22419
|
-
if (!repo) throw new Error("could not resolve repo \u2014 pass --repo owner/repo");
|
|
22420
|
-
const url = `https://github.com/${repo}/issues/${parsed.number}`;
|
|
22421
|
-
const reason = options.reason ?? "completed";
|
|
22422
|
-
const stateReason = reason === "duplicate-of" ? "not_planned" : reason === "not-planned" ? "not_planned" : "completed";
|
|
22423
|
-
await client.rest("PATCH", `repos/${repo}/issues/${parsed.number}`, {
|
|
22424
|
-
body: { state: "closed", state_reason: stateReason }
|
|
22425
|
-
});
|
|
22426
|
-
if (reason === "duplicate-of" && options.duplicateOf) {
|
|
22427
|
-
await client.rest("POST", `repos/${repo}/issues/${parsed.number}/comments`, {
|
|
22428
|
-
body: { body: `Duplicate of #${options.duplicateOf}` }
|
|
22429
|
-
});
|
|
22430
|
-
}
|
|
22431
|
-
if (options.comment) {
|
|
22432
|
-
await client.rest("POST", `repos/${repo}/issues/${parsed.number}/comments`, {
|
|
22433
|
-
body: { body: options.comment }
|
|
22434
|
-
});
|
|
22435
|
-
}
|
|
22436
|
-
let board;
|
|
22423
|
+
async function diagnoseFailedRestChecks(prNumber, repo, gh = defaultGhApi) {
|
|
22437
22424
|
try {
|
|
22438
|
-
const
|
|
22439
|
-
if (
|
|
22440
|
-
const
|
|
22441
|
-
|
|
22425
|
+
const snapshot = await fetchRestPrSnapshot(prNumber, repo, gh);
|
|
22426
|
+
if (!snapshot.headIsFork) {
|
|
22427
|
+
const branchTip = await fetchBranchTipSha(repo, snapshot.headRef, gh);
|
|
22428
|
+
if (branchTip && branchTip !== snapshot.headSha) {
|
|
22429
|
+
return {
|
|
22430
|
+
cause: "stale-head",
|
|
22431
|
+
infraFailures: [],
|
|
22432
|
+
otherFailures: [],
|
|
22433
|
+
reason: `the PR head (${snapshot.headSha.slice(0, 7)}) is BEHIND the branch tip (${branchTip.slice(0, 7)}) \u2014 GitHub likely dropped a \`synchronize\` event, so the reported red is a superseded commit and NO run exists for your current push. This is not your diff failing. Re-push (an empty commit is enough) or close and reopen the PR to re-sync the head and trigger a fresh run.`
|
|
22434
|
+
};
|
|
22435
|
+
}
|
|
22442
22436
|
}
|
|
22437
|
+
const failing = (await fetchHeadCheckRuns(snapshot.headSha, repo, gh)).filter((run) => classifyCheckRun(run) === "fail" && typeof run.id === "number");
|
|
22438
|
+
if (!failing.length) return null;
|
|
22439
|
+
const annotated = await Promise.all(failing.map(async (run) => ({
|
|
22440
|
+
name: run.name ?? `check-run ${run.id}`,
|
|
22441
|
+
annotations: JSON.parse(await gh([`repos/${repo}/check-runs/${run.id}/annotations`]))
|
|
22442
|
+
})));
|
|
22443
|
+
return classifyFailedChecks(annotated);
|
|
22443
22444
|
} catch {
|
|
22445
|
+
return null;
|
|
22444
22446
|
}
|
|
22445
|
-
return { number: parsed.number, repo, url, state: "closed", stateReason, ...board ? { board } : {} };
|
|
22446
22447
|
}
|
|
22447
|
-
async function
|
|
22448
|
-
const parsed = parseIssueRef(ref);
|
|
22449
|
-
const repo = parsed.repo ?? defaultRepo;
|
|
22450
|
-
if (!repo) throw new Error("could not resolve repo \u2014 pass --repo owner/repo");
|
|
22451
|
-
const url = `https://github.com/${repo}/issues/${parsed.number}`;
|
|
22452
|
-
await client.rest("PATCH", `repos/${repo}/issues/${parsed.number}`, { body: { state: "open" } });
|
|
22453
|
-
let board;
|
|
22448
|
+
async function fetchRestCorePool(gh = defaultGhApi) {
|
|
22454
22449
|
try {
|
|
22455
|
-
const
|
|
22456
|
-
|
|
22457
|
-
|
|
22458
|
-
|
|
22459
|
-
}
|
|
22450
|
+
const parsed = JSON.parse(await gh(["rate_limit"]));
|
|
22451
|
+
const core = parsed.resources?.core;
|
|
22452
|
+
if (typeof core?.remaining !== "number" || typeof core?.reset !== "number") return null;
|
|
22453
|
+
return { remaining: core.remaining, reset: core.reset };
|
|
22460
22454
|
} catch {
|
|
22455
|
+
return null;
|
|
22461
22456
|
}
|
|
22462
|
-
return { number: parsed.number, repo, url, state: "open", ...board ? { board } : {} };
|
|
22463
22457
|
}
|
|
22464
|
-
|
|
22465
|
-
|
|
22466
|
-
|
|
22467
|
-
|
|
22468
|
-
|
|
22469
|
-
|
|
22458
|
+
|
|
22459
|
+
// src/worktree-lifecycle-commands.ts
|
|
22460
|
+
var import_node_fs28 = require("node:fs");
|
|
22461
|
+
var import_node_path27 = require("node:path");
|
|
22462
|
+
var GH_TIMEOUT_MS = 2e4;
|
|
22463
|
+
var DEFAULT_BASE = "origin/development";
|
|
22464
|
+
var DEFAULT_REMOTE = "origin";
|
|
22465
|
+
var PROTECTED_BRANCHES2 = /* @__PURE__ */ new Set(["development", "main", "master", "rc"]);
|
|
22466
|
+
function slugifyIssueTitle(title) {
|
|
22467
|
+
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
22468
|
+
return slug.slice(0, 50);
|
|
22470
22469
|
}
|
|
22471
|
-
|
|
22472
|
-
const
|
|
22473
|
-
|
|
22474
|
-
if (!repo) throw new Error("could not resolve repo \u2014 pass --repo owner/repo");
|
|
22475
|
-
await client.rest("DELETE", `repos/${repo}/issues/${parsed.number}/assignees`, { body: { assignees: [login] } });
|
|
22476
|
-
return { number: parsed.number, repo, url: `https://github.com/${repo}/issues/${parsed.number}`, login, action: "unassigned" };
|
|
22470
|
+
function buildNewBranchName(issueNumber, slug) {
|
|
22471
|
+
const clean4 = slug.replace(/^[-]+|[-]+$/g, "");
|
|
22472
|
+
return clean4 ? `${issueNumber}-${clean4}` : String(issueNumber);
|
|
22477
22473
|
}
|
|
22478
|
-
|
|
22479
|
-
|
|
22480
|
-
|
|
22481
|
-
|
|
22482
|
-
|
|
22483
|
-
|
|
22484
|
-
|
|
22485
|
-
|
|
22486
|
-
|
|
22474
|
+
function isIssueRef(target) {
|
|
22475
|
+
return /^#?\d+$/.test(target) || /^[\w.-]+\/[\w.-]+#\d+$/.test(target);
|
|
22476
|
+
}
|
|
22477
|
+
function worktreeCreatePlan(opts, args) {
|
|
22478
|
+
const target = String(args[0] ?? "");
|
|
22479
|
+
return {
|
|
22480
|
+
command: "worktree create",
|
|
22481
|
+
target,
|
|
22482
|
+
issueForm: isIssueRef(target),
|
|
22483
|
+
claim: Boolean(opts.claim),
|
|
22484
|
+
slug: opts.slug || void 0,
|
|
22485
|
+
from: opts.base || opts.from || DEFAULT_BASE,
|
|
22486
|
+
remote: opts.remote || DEFAULT_REMOTE,
|
|
22487
|
+
repo: opts.repo || void 0,
|
|
22488
|
+
assignee: opts.for || void 0
|
|
22489
|
+
};
|
|
22490
|
+
}
|
|
22491
|
+
function landSteps(branch, hasWorktree, hasStage) {
|
|
22492
|
+
const steps = [];
|
|
22493
|
+
if (hasStage) steps.push("stop spawned dev stage");
|
|
22494
|
+
if (hasWorktree) steps.push(`remove worktree (branch ${branch})`);
|
|
22495
|
+
steps.push(`delete local branch ${branch}`);
|
|
22496
|
+
steps.push(`delete remote branch ${branch}`);
|
|
22497
|
+
steps.push("prune worktree metadata");
|
|
22498
|
+
return steps;
|
|
22499
|
+
}
|
|
22500
|
+
function classifyStaleLeaks(input) {
|
|
22501
|
+
const protectedBranches = input.protectedBranches ?? PROTECTED_BRANCHES2;
|
|
22502
|
+
const leaks = [];
|
|
22503
|
+
const worktreeBranches2 = new Set(input.worktrees.filter((w) => !w.primary).map((w) => w.branch));
|
|
22504
|
+
const stageByPath = new Map(input.stages.map((s) => [s.path, s.port]));
|
|
22505
|
+
for (const wt of input.worktrees) {
|
|
22506
|
+
if (wt.primary) continue;
|
|
22507
|
+
if (protectedBranches.has(wt.branch)) continue;
|
|
22508
|
+
if (input.openPrBranches.has(wt.branch)) continue;
|
|
22509
|
+
if (!input.closedBranches.has(wt.branch)) continue;
|
|
22510
|
+
leaks.push({
|
|
22511
|
+
kind: "stale-worktree",
|
|
22512
|
+
ref: wt.branch,
|
|
22513
|
+
detail: `merged/closed branch ${wt.branch} still checked out at ${wt.path}`,
|
|
22514
|
+
remediation: `cd "${wt.path}" && mmi-cli worktree land --apply (or: mmi-cli worktree gc --apply)`
|
|
22515
|
+
});
|
|
22487
22516
|
}
|
|
22488
|
-
|
|
22489
|
-
|
|
22490
|
-
|
|
22491
|
-
|
|
22492
|
-
|
|
22517
|
+
for (const branch of input.localBranches) {
|
|
22518
|
+
if (protectedBranches.has(branch)) continue;
|
|
22519
|
+
if (branch === input.currentBranch) continue;
|
|
22520
|
+
if (input.openPrBranches.has(branch)) continue;
|
|
22521
|
+
if (!input.closedBranches.has(branch)) continue;
|
|
22522
|
+
if (worktreeBranches2.has(branch)) continue;
|
|
22523
|
+
leaks.push({
|
|
22524
|
+
kind: "stale-branch",
|
|
22525
|
+
ref: branch,
|
|
22526
|
+
detail: `merged/closed branch ${branch} has no worktree`,
|
|
22527
|
+
remediation: "mmi-cli worktree gc --apply"
|
|
22528
|
+
});
|
|
22493
22529
|
}
|
|
22494
|
-
|
|
22495
|
-
|
|
22496
|
-
|
|
22497
|
-
|
|
22498
|
-
|
|
22499
|
-
|
|
22500
|
-
|
|
22501
|
-
|
|
22502
|
-
|
|
22503
|
-
|
|
22504
|
-
|
|
22505
|
-
|
|
22506
|
-
|
|
22507
|
-
priority = lookup.item?.priority;
|
|
22508
|
-
}
|
|
22509
|
-
} catch {
|
|
22530
|
+
for (const wt of input.worktrees) {
|
|
22531
|
+
if (wt.primary) continue;
|
|
22532
|
+
const port = stageByPath.get(wt.path);
|
|
22533
|
+
if (port == null) continue;
|
|
22534
|
+
if (input.openPrBranches.has(wt.branch)) continue;
|
|
22535
|
+
if (protectedBranches.has(wt.branch) && !input.closedBranches.has(wt.branch)) continue;
|
|
22536
|
+
if (!input.closedBranches.has(wt.branch)) continue;
|
|
22537
|
+
leaks.push({
|
|
22538
|
+
kind: "stale-stage",
|
|
22539
|
+
ref: wt.branch,
|
|
22540
|
+
detail: `dev stage on port ${port} still running in ${wt.path} (branch ${wt.branch} merged/closed)`,
|
|
22541
|
+
remediation: `cd "${wt.path}" && mmi-cli stage stop --apply`
|
|
22542
|
+
});
|
|
22510
22543
|
}
|
|
22511
|
-
const
|
|
22512
|
-
const
|
|
22513
|
-
|
|
22514
|
-
|
|
22515
|
-
|
|
22516
|
-
|
|
22517
|
-
|
|
22518
|
-
|
|
22519
|
-
|
|
22520
|
-
|
|
22521
|
-
|
|
22522
|
-
|
|
22523
|
-
|
|
22544
|
+
const knownPaths = new Set(input.worktrees.map((w) => w.path));
|
|
22545
|
+
for (const dir of input.orphanDirs) {
|
|
22546
|
+
if (knownPaths.has(dir.path)) continue;
|
|
22547
|
+
leaks.push({
|
|
22548
|
+
kind: "orphan-dir",
|
|
22549
|
+
ref: dir.path,
|
|
22550
|
+
// Say only what the inspection proved. `orphaned-folder` proves the `.git` entry is gone while this
|
|
22551
|
+
// repo's worktree metadata still points at the directory — NOT that the directory is empty; gc
|
|
22552
|
+
// checks contents separately, at apply time.
|
|
22553
|
+
detail: dir.reason === "dangling-gitdir" ? "this repo's worktree directory whose git metadata is gone" : "this repo's worktree metadata points here, but the .git entry is gone",
|
|
22554
|
+
// gc --apply re-proves ownership before deleting anything; a raw `rm -rf` does not, so it is never
|
|
22555
|
+
// offered here — following it on a mis-classified dir would destroy another repo's live worktree.
|
|
22556
|
+
remediation: "mmi-cli worktree gc --apply"
|
|
22557
|
+
});
|
|
22524
22558
|
}
|
|
22525
|
-
|
|
22526
|
-
|
|
22527
|
-
|
|
22528
|
-
|
|
22529
|
-
let swept = false;
|
|
22530
|
-
if (sourceItemId) {
|
|
22559
|
+
return leaks;
|
|
22560
|
+
}
|
|
22561
|
+
var defaultOrphanDirScanDeps = {
|
|
22562
|
+
listDirs: (root) => {
|
|
22531
22563
|
try {
|
|
22532
|
-
|
|
22533
|
-
if (sourceCfg.projectId) {
|
|
22534
|
-
const resolved = resolveBoardConfig(sourceCfg);
|
|
22535
|
-
const DELETE_MUTATION = `mutation($projectId: ID!, $itemId: ID!) { deleteProjectV2Item(input: {projectId: $projectId, itemId: $itemId}) { deletedItemId } }`;
|
|
22536
|
-
await client.graphql(DELETE_MUTATION, { projectId: resolved.projectId, itemId: sourceItemId });
|
|
22537
|
-
swept = true;
|
|
22538
|
-
}
|
|
22564
|
+
return (0, import_node_fs28.readdirSync)(root, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => (0, import_node_path27.join)(root, e.name));
|
|
22539
22565
|
} catch {
|
|
22566
|
+
return [];
|
|
22540
22567
|
}
|
|
22568
|
+
},
|
|
22569
|
+
inspect: inspectSiblingWorktreeDir
|
|
22570
|
+
};
|
|
22571
|
+
function scanOrphanDirs(worktreesRoot, worktreeGitRoot, deps = defaultOrphanDirScanDeps) {
|
|
22572
|
+
const candidates = [];
|
|
22573
|
+
for (const dir of deps.listDirs(worktreesRoot)) {
|
|
22574
|
+
const { cleanup } = classifySiblingWorktreeDir(deps.inspect(dir, worktreeGitRoot));
|
|
22575
|
+
if (cleanup) candidates.push({ path: dir, reason: cleanup.reason });
|
|
22541
22576
|
}
|
|
22542
|
-
|
|
22543
|
-
|
|
22544
|
-
|
|
22545
|
-
|
|
22546
|
-
|
|
22547
|
-
|
|
22548
|
-
|
|
22549
|
-
|
|
22550
|
-
);
|
|
22551
|
-
const newItemId = addResult?.addProjectV2ItemById?.item?.id;
|
|
22552
|
-
if (newItemId && priority) {
|
|
22553
|
-
try {
|
|
22554
|
-
const p = priority.toLowerCase();
|
|
22555
|
-
if (CLI_PRIORITIES.includes(p)) {
|
|
22556
|
-
await setBoardItemPriority(client, destCfg, newItemId, p);
|
|
22557
|
-
carriedPriority = priority;
|
|
22558
|
-
}
|
|
22559
|
-
} catch {
|
|
22560
|
-
}
|
|
22561
|
-
}
|
|
22562
|
-
added = true;
|
|
22563
|
-
}
|
|
22564
|
-
} catch {
|
|
22577
|
+
return candidates;
|
|
22578
|
+
}
|
|
22579
|
+
function formatStaleLeaks(leaks) {
|
|
22580
|
+
if (!leaks.length) return "worktree list --stale: no leaks found";
|
|
22581
|
+
const lines = [`worktree list --stale: ${leaks.length} leak(s)`];
|
|
22582
|
+
for (const leak of leaks) {
|
|
22583
|
+
lines.push(` [${leak.kind}] ${leak.ref} \u2014 ${leak.detail}`);
|
|
22584
|
+
lines.push(` fix: ${leak.remediation}`);
|
|
22565
22585
|
}
|
|
22566
|
-
return
|
|
22567
|
-
number: newNumber,
|
|
22568
|
-
repo: newRepo,
|
|
22569
|
-
url: newUrl,
|
|
22570
|
-
previousRepo: sourceRepo,
|
|
22571
|
-
previousNumber: parsed.number,
|
|
22572
|
-
board: { swept, added, ...carriedPriority ? { priority: carriedPriority } : {} }
|
|
22573
|
-
};
|
|
22586
|
+
return lines.join("\n");
|
|
22574
22587
|
}
|
|
22575
|
-
function
|
|
22576
|
-
return
|
|
22588
|
+
async function repoRootOf() {
|
|
22589
|
+
return (await execFileP2("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim() || process.cwd();
|
|
22577
22590
|
}
|
|
22578
|
-
function
|
|
22579
|
-
|
|
22580
|
-
if (
|
|
22581
|
-
|
|
22582
|
-
|
|
22583
|
-
|
|
22584
|
-
|
|
22591
|
+
function classifyLandBranchMergeState(prs) {
|
|
22592
|
+
if (!prs) return { state: "unknown", numbers: [] };
|
|
22593
|
+
if (!prs.length) return { state: "no-pr", numbers: [] };
|
|
22594
|
+
const pick = (s) => prs.filter((pr2) => pr2.state.toUpperCase() === s).map((pr2) => pr2.number);
|
|
22595
|
+
const open2 = pick("OPEN");
|
|
22596
|
+
if (open2.length) return { state: "open", numbers: open2 };
|
|
22597
|
+
const known = /* @__PURE__ */ new Set(["OPEN", "CLOSED", "MERGED"]);
|
|
22598
|
+
const unrecognised = prs.filter((pr2) => !known.has(pr2.state.toUpperCase()));
|
|
22599
|
+
if (unrecognised.length) return { state: "unknown", numbers: prs.map((pr2) => pr2.number) };
|
|
22600
|
+
const merged = pick("MERGED");
|
|
22601
|
+
if (!merged.length) return { state: "closed-unmerged", numbers: prs.map((pr2) => pr2.number) };
|
|
22602
|
+
if (merged.length < prs.length) return { state: "mixed", numbers: prs.map((pr2) => pr2.number) };
|
|
22603
|
+
return { state: "merged", numbers: merged };
|
|
22585
22604
|
}
|
|
22586
|
-
function
|
|
22587
|
-
const
|
|
22588
|
-
|
|
22589
|
-
|
|
22590
|
-
|
|
22591
|
-
|
|
22605
|
+
function describeLandMergeState(v) {
|
|
22606
|
+
const refs = v.numbers.map((n) => `#${n}`).join(", ");
|
|
22607
|
+
switch (v.state) {
|
|
22608
|
+
case "merged":
|
|
22609
|
+
return `PR ${refs} merged`;
|
|
22610
|
+
case "closed-unmerged":
|
|
22611
|
+
return `PR ${refs} CLOSED \u2014 NOT merged`;
|
|
22612
|
+
case "mixed":
|
|
22613
|
+
return `PR ${refs} \u2014 some merged, some CLOSED unmerged; which one owns the branch head is unproven`;
|
|
22614
|
+
case "open":
|
|
22615
|
+
return `PR ${refs} still OPEN \u2014 NOT merged`;
|
|
22616
|
+
case "no-pr":
|
|
22617
|
+
return "no pull request for this branch";
|
|
22618
|
+
case "unknown":
|
|
22619
|
+
return refs ? `PR ${refs} reported a pull-request state this CLI does not recognise \u2014 merge NOT confirmed` : "pull-request state could not be read \u2014 merge NOT confirmed";
|
|
22620
|
+
}
|
|
22592
22621
|
}
|
|
22593
|
-
|
|
22594
|
-
|
|
22595
|
-
const
|
|
22596
|
-
const
|
|
22597
|
-
|
|
22598
|
-
|
|
22599
|
-
|
|
22600
|
-
|
|
22601
|
-
|
|
22602
|
-
|
|
22603
|
-
|
|
22604
|
-
|
|
22605
|
-
|
|
22606
|
-
|
|
22607
|
-
|
|
22608
|
-
|
|
22609
|
-
|
|
22610
|
-
|
|
22611
|
-
|
|
22612
|
-
|
|
22613
|
-
}
|
|
22614
|
-
|
|
22615
|
-
|
|
22616
|
-
|
|
22617
|
-
|
|
22618
|
-
|
|
22619
|
-
|
|
22620
|
-
|
|
22621
|
-
}
|
|
22622
|
-
let priority;
|
|
22623
|
-
try {
|
|
22624
|
-
priority = spec.priority ? normalizePriority(spec.priority) : "medium";
|
|
22625
|
-
} catch (e) {
|
|
22626
|
-
errors.push({ row, error: e.message });
|
|
22627
|
-
continue;
|
|
22628
|
-
}
|
|
22629
|
-
validated.push({ row, spec, priority, type: spec.type });
|
|
22622
|
+
function decideWorktreeLandRefCleanup(input) {
|
|
22623
|
+
if (input.keepRemote) return { action: "proceed" };
|
|
22624
|
+
const verdict = classifyLandBranchMergeState(input.prs);
|
|
22625
|
+
const refs = verdict.numbers.map((n) => `#${n}`).join(", ");
|
|
22626
|
+
if (verdict.state === "unknown") {
|
|
22627
|
+
return verdict.numbers.length ? {
|
|
22628
|
+
action: "keep-remote",
|
|
22629
|
+
reason: "pr-state-unrecognised",
|
|
22630
|
+
message: `pull request ${refs} for '${input.branch}' reported a state this CLI does not recognise \u2014 keeping the remote branch rather than deleting a ref whose merge is unproven`
|
|
22631
|
+
} : {
|
|
22632
|
+
action: "keep-remote",
|
|
22633
|
+
reason: "pr-state-unreadable",
|
|
22634
|
+
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`
|
|
22635
|
+
};
|
|
22636
|
+
}
|
|
22637
|
+
if (verdict.state === "open") {
|
|
22638
|
+
return {
|
|
22639
|
+
action: "refuse",
|
|
22640
|
+
reason: "open-pr",
|
|
22641
|
+
message: `'${input.branch}' still has an OPEN pull request (${refs}). 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`
|
|
22642
|
+
};
|
|
22643
|
+
}
|
|
22644
|
+
if (verdict.state === "mixed") {
|
|
22645
|
+
return {
|
|
22646
|
+
action: "keep-remote",
|
|
22647
|
+
reason: "mixed-pr-state",
|
|
22648
|
+
message: `'${input.branch}' has both a merged and a closed-unmerged pull request (${refs}) and nothing here proves which owns the current head \u2014 keeping the remote branch rather than deleting a ref that may hold unmerged work`
|
|
22649
|
+
};
|
|
22630
22650
|
}
|
|
22631
|
-
|
|
22632
|
-
|
|
22633
|
-
|
|
22634
|
-
|
|
22635
|
-
|
|
22636
|
-
|
|
22637
|
-
const validation = validateBatchSpecs(specs);
|
|
22638
|
-
if (!validation.ok) {
|
|
22639
|
-
const lines = validation.errors.map((e) => ` row ${e.row}: ${e.error}`).join("\n");
|
|
22640
|
-
throw new Error(`batch validation failed (${validation.errors.length} error(s)):
|
|
22641
|
-
${lines}`);
|
|
22651
|
+
if (verdict.state === "closed-unmerged") {
|
|
22652
|
+
return {
|
|
22653
|
+
action: "refuse",
|
|
22654
|
+
reason: "unmerged-pr",
|
|
22655
|
+
message: `'${input.branch}' has a pull request that never merged (${refs}). The remote branch is the only thing still holding that work. Re-run with --keep-remote for a local-only clean`
|
|
22656
|
+
};
|
|
22642
22657
|
}
|
|
22643
|
-
|
|
22644
|
-
|
|
22645
|
-
|
|
22658
|
+
return { action: "proceed" };
|
|
22659
|
+
}
|
|
22660
|
+
async function fetchLandBranchPrs(branch, repo) {
|
|
22661
|
+
try {
|
|
22662
|
+
const { stdout } = await execFileP2("gh", [
|
|
22663
|
+
"pr",
|
|
22664
|
+
"list",
|
|
22665
|
+
"--head",
|
|
22666
|
+
branch,
|
|
22667
|
+
"--state",
|
|
22668
|
+
"all",
|
|
22669
|
+
"--limit",
|
|
22670
|
+
"20",
|
|
22671
|
+
...repo ? ["--repo", repo] : [],
|
|
22672
|
+
"--json",
|
|
22673
|
+
"number,state"
|
|
22674
|
+
], { timeout: GH_TIMEOUT_MS });
|
|
22675
|
+
const parsed = JSON.parse(stdout.trim() || "null");
|
|
22676
|
+
if (!Array.isArray(parsed)) return void 0;
|
|
22677
|
+
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 }));
|
|
22678
|
+
} catch {
|
|
22679
|
+
return void 0;
|
|
22646
22680
|
}
|
|
22647
|
-
|
|
22648
|
-
|
|
22649
|
-
|
|
22650
|
-
|
|
22651
|
-
const
|
|
22652
|
-
|
|
22653
|
-
|
|
22681
|
+
}
|
|
22682
|
+
async function fetchIssueTitle(repo, number) {
|
|
22683
|
+
try {
|
|
22684
|
+
const { stdout } = await execFileP2("gh", ["issue", "view", String(number), "--repo", repo, "--json", "title", "--jq", ".title"], { timeout: GH_TIMEOUT_MS });
|
|
22685
|
+
const title = stdout.trim();
|
|
22686
|
+
return title || void 0;
|
|
22687
|
+
} catch {
|
|
22688
|
+
return void 0;
|
|
22654
22689
|
}
|
|
22655
|
-
|
|
22656
|
-
|
|
22657
|
-
const
|
|
22658
|
-
|
|
22659
|
-
|
|
22690
|
+
}
|
|
22691
|
+
async function selfShell(args, timeoutMs) {
|
|
22692
|
+
const scriptPath = process.argv[1];
|
|
22693
|
+
const { stdout } = await execFileP2(process.execPath, [scriptPath, ...args], { timeout: timeoutMs });
|
|
22694
|
+
return stdout;
|
|
22695
|
+
}
|
|
22696
|
+
function registerWorktreeCommands(program3) {
|
|
22697
|
+
const worktree2 = program3.commands.find((c) => c.name() === "worktree");
|
|
22698
|
+
if (!worktree2) return;
|
|
22699
|
+
const has = (name) => worktree2.commands.some((c) => c.name() === name);
|
|
22700
|
+
if (has("land") && has("list")) return;
|
|
22701
|
+
if (!has("land")) worktree2.command("land").description("post-merge clean: remove the current worktree, delete its local + origin branch, and stop its dev stage (dry-run by default)").option("--apply", "execute the cleanup (default: dry-run preview)").option("--dry-run", "show what would be removed without doing it (default)").option("--remote <name>", "remote whose branch is deleted", DEFAULT_REMOTE).option("--keep-remote", "do not delete the remote (origin) branch").option("--json", "machine-readable output").action(async (o) => {
|
|
22702
|
+
if (o.apply && o.dryRun) return fail("worktree land: choose either --dry-run or --apply");
|
|
22703
|
+
const apply = Boolean(o.apply);
|
|
22660
22704
|
try {
|
|
22661
|
-
const
|
|
22662
|
-
|
|
22663
|
-
|
|
22664
|
-
|
|
22665
|
-
|
|
22666
|
-
continue;
|
|
22667
|
-
}
|
|
22705
|
+
const wtPath = (await execFileP2("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS })).stdout.trim();
|
|
22706
|
+
const branch = (await execFileP2("git", ["rev-parse", "--abbrev-ref", "HEAD"], { timeout: GIT_TIMEOUT_MS })).stdout.trim();
|
|
22707
|
+
if (!wtPath || !branch) return fail("worktree land: not inside a git worktree");
|
|
22708
|
+
if (PROTECTED_BRANCHES2.has(branch)) {
|
|
22709
|
+
return fail(`worktree land: refusing to land protected branch '${branch}'`, { code: ERROR_CODES.ERR_BAD_ENUM });
|
|
22668
22710
|
}
|
|
22669
|
-
|
|
22670
|
-
|
|
22671
|
-
|
|
22711
|
+
const gitFile = (0, import_node_path27.join)(wtPath, ".git");
|
|
22712
|
+
const isLinked = (0, import_node_fs28.existsSync)(gitFile) && (0, import_node_fs28.statSync)(gitFile).isFile();
|
|
22713
|
+
if (apply && !isLinked) {
|
|
22714
|
+
return fail("worktree land: run from inside the linked worktree you want to land (this is the primary checkout)");
|
|
22672
22715
|
}
|
|
22673
|
-
const
|
|
22674
|
-
const
|
|
22675
|
-
const
|
|
22676
|
-
|
|
22677
|
-
|
|
22678
|
-
|
|
22679
|
-
|
|
22680
|
-
|
|
22681
|
-
|
|
22682
|
-
|
|
22683
|
-
|
|
22716
|
+
const commonDir = (await execFileP2("git", ["rev-parse", "--git-common-dir"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim();
|
|
22717
|
+
const primaryCheckout = commonDir ? (0, import_node_path27.dirname)(commonDir) : wtPath;
|
|
22718
|
+
const stageSummary = readStageSummary(wtPath);
|
|
22719
|
+
const hasStage = stageSummary != null;
|
|
22720
|
+
const steps = landSteps(branch, true, hasStage);
|
|
22721
|
+
const plan = {
|
|
22722
|
+
command: "worktree land",
|
|
22723
|
+
branch,
|
|
22724
|
+
worktreePath: wtPath,
|
|
22725
|
+
primaryCheckout,
|
|
22726
|
+
remote: o.remote,
|
|
22727
|
+
hasStage,
|
|
22728
|
+
steps
|
|
22729
|
+
};
|
|
22730
|
+
if (!apply) {
|
|
22731
|
+
const preview = { dryRun: true, ...plan, ...o.keepRemote ? { keepRemote: true } : {} };
|
|
22732
|
+
if (o.json) return console.log(JSON.stringify(preview, null, 2));
|
|
22733
|
+
const lines = [`worktree land: dry-run (pass --apply to execute)`, ` branch: ${branch}`, ` worktree: ${wtPath}`];
|
|
22734
|
+
if (hasStage) lines.push(` stage: port ${stageSummary.port} (will be stopped)`);
|
|
22735
|
+
if (o.keepRemote) lines.push(` remote branch: kept (--keep-remote)`);
|
|
22736
|
+
for (const s of steps) lines.push(` - ${s}`);
|
|
22737
|
+
return console.log(lines.join("\n"));
|
|
22684
22738
|
}
|
|
22685
|
-
|
|
22686
|
-
|
|
22687
|
-
|
|
22688
|
-
if (attached.projectItemId) row.projectItemId = attached.projectItemId;
|
|
22739
|
+
const landDirty = ((await execFileP2("git", ["-C", wtPath, "status", "--porcelain"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "unreadable" }))).stdout || "").trim().length > 0;
|
|
22740
|
+
if (landDirty) {
|
|
22741
|
+
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 });
|
|
22689
22742
|
}
|
|
22690
|
-
const
|
|
22691
|
-
|
|
22743
|
+
const landPrs = await fetchLandBranchPrs(branch);
|
|
22744
|
+
const mergeVerdict = classifyLandBranchMergeState(landPrs);
|
|
22745
|
+
const landRefs = decideWorktreeLandRefCleanup({
|
|
22746
|
+
branch,
|
|
22747
|
+
prs: landPrs,
|
|
22748
|
+
keepRemote: Boolean(o.keepRemote)
|
|
22749
|
+
});
|
|
22750
|
+
if (landRefs.action === "refuse") {
|
|
22751
|
+
return fail(`worktree land: ${landRefs.message}`, { code: ERROR_CODES.ERR_BAD_ENUM });
|
|
22752
|
+
}
|
|
22753
|
+
const keepRemoteBranch = Boolean(o.keepRemote) || landRefs.action === "keep-remote";
|
|
22754
|
+
if (landRefs.action === "keep-remote") console.warn(`worktree land: ${landRefs.message}.`);
|
|
22755
|
+
const landOwner = lookupWorktreeOwner(primaryCheckout, wtPath);
|
|
22756
|
+
const report = [];
|
|
22757
|
+
if (hasStage) {
|
|
22692
22758
|
try {
|
|
22693
|
-
|
|
22694
|
-
|
|
22759
|
+
await stopStage({ cwd: wtPath, requiredIdentityCwd: wtPath });
|
|
22760
|
+
report.push({ step: "stop stage", status: "stopped" });
|
|
22695
22761
|
} catch (e) {
|
|
22696
|
-
|
|
22697
|
-
row.parentLinkError = (err.stderr || err.message || String(e)).trim();
|
|
22698
|
-
process.stderr.write(`warning: issue #${row.number} created but NOT linked under ${parentRef}: ${row.parentLinkError}
|
|
22699
|
-
`);
|
|
22762
|
+
report.push({ step: "stop stage", status: `failed: ${e.message.split("\n")[0]}` });
|
|
22700
22763
|
}
|
|
22701
22764
|
}
|
|
22702
|
-
|
|
22703
|
-
const
|
|
22704
|
-
|
|
22705
|
-
|
|
22706
|
-
|
|
22707
|
-
|
|
22708
|
-
|
|
22709
|
-
}
|
|
22710
|
-
async function findExistingByIdempotencyKey(client, key, repo) {
|
|
22711
|
-
const marker = idempotencyMarker(key);
|
|
22712
|
-
try {
|
|
22713
|
-
const result = await client.rest(
|
|
22714
|
-
"GET",
|
|
22715
|
-
`search/issues?per_page=5&q=${encodeURIComponent(`repo:${repo} "${marker}" in:body`)}`
|
|
22716
|
-
);
|
|
22717
|
-
const open2 = result?.items?.find((item) => item.state === "open" && item.number);
|
|
22718
|
-
if (!open2?.number) return void 0;
|
|
22719
|
-
return { number: open2.number, url: open2.html_url ?? `https://github.com/${repo}/issues/${open2.number}` };
|
|
22720
|
-
} catch {
|
|
22721
|
-
return void 0;
|
|
22722
|
-
}
|
|
22723
|
-
}
|
|
22724
|
-
function parseCloseReason(raw) {
|
|
22725
|
-
if (raw === void 0) return {};
|
|
22726
|
-
const trimmed = raw.trim().toLowerCase().replace(/[\s_-]+/g, "");
|
|
22727
|
-
if (trimmed === "completed" || trimmed === "complete") return { reason: "completed" };
|
|
22728
|
-
if (trimmed === "notplanned" || trimmed === "notplanned") return { reason: "not-planned" };
|
|
22729
|
-
if (trimmed === "duplicateof" || trimmed === "duplicate") return { reason: "duplicate-of" };
|
|
22730
|
-
throw new Error(`unknown close reason "${raw}" \u2014 expected completed, not-planned, or duplicate-of`);
|
|
22731
|
-
}
|
|
22732
|
-
function registerIssueLifecycleCommands(program3, deps = {}) {
|
|
22733
|
-
const issue2 = program3.commands.find((c) => c.name() === "issue");
|
|
22734
|
-
if (!issue2) return;
|
|
22735
|
-
mutating(
|
|
22736
|
-
issue2.command("edit <ref>").description("edit an issue title/body/labels or reparent it (--parent) \u2014 reparenting is impossible via raw gh").option("--title <title>", "new title").option("--body <body>", "new body (markdown)").option("--body-file <path|->", "read new body from a UTF-8 file, or stdin with -").option("--add-label <label...>", "label(s) to add (repeatable)").option("--remove-label <label...>", "label(s) to remove (repeatable)").option("--parent <ref>", "reparent: link under this parent (#123, owner/repo#123, or URL)").option("--repo <owner/repo>", "repo for a bare ref (defaults to the current repo)"),
|
|
22737
|
-
(opts, args) => ({ command: "issue edit", ref: args[0], fields: Object.keys(opts).filter((k) => k !== "repo" && opts[k] !== void 0) })
|
|
22738
|
-
).action(async (ref, o) => {
|
|
22739
|
-
try {
|
|
22740
|
-
const defaultRepo = await resolveRepo(o.repo);
|
|
22741
|
-
const result = await editIssue(defaultGitHubClient(), {
|
|
22742
|
-
ref,
|
|
22743
|
-
defaultRepo,
|
|
22744
|
-
title: o.title,
|
|
22745
|
-
body: o.body,
|
|
22746
|
-
bodyFile: o.bodyFile,
|
|
22747
|
-
addLabel: o.addLabel,
|
|
22748
|
-
removeLabel: o.removeLabel,
|
|
22749
|
-
parent: o.parent
|
|
22765
|
+
releaseCwdIfUnderWorktree(wtPath, primaryCheckout);
|
|
22766
|
+
const removeOutcome = await removeWorktreeWithRecovery(
|
|
22767
|
+
wtPath,
|
|
22768
|
+
worktreeRemoveDeps(async (args) => (await execFileP2("git", ["-C", primaryCheckout, ...args], { timeout: GIT_TIMEOUT_MS })).stdout)
|
|
22769
|
+
);
|
|
22770
|
+
report.push({
|
|
22771
|
+
step: "remove worktree",
|
|
22772
|
+
status: removeOutcome.status === "removed" ? removeOutcome.recovery ? `done (${removeOutcome.recovery})` : "done" : `failed: ${removeOutcome.error ?? "lock held"} \u2014 run: git -C "${primaryCheckout}" worktree remove --force "${wtPath}"`
|
|
22750
22773
|
});
|
|
22751
|
-
|
|
22752
|
-
|
|
22753
|
-
|
|
22754
|
-
|
|
22755
|
-
|
|
22756
|
-
|
|
22757
|
-
|
|
22758
|
-
|
|
22759
|
-
|
|
22760
|
-
|
|
22761
|
-
|
|
22762
|
-
|
|
22763
|
-
|
|
22764
|
-
|
|
22765
|
-
|
|
22766
|
-
|
|
22767
|
-
});
|
|
22774
|
+
const landActor = describeActor({ env: process.env, surface: detectSurface(process.env), cwd: wtPath });
|
|
22775
|
+
appendWorktreeEvent(primaryCheckout, {
|
|
22776
|
+
action: removeOutcome.status === "removed" ? "removed" : "failed",
|
|
22777
|
+
command: "worktree land",
|
|
22778
|
+
// NATIVE separators (#3338): `land` reads its path from `git rev-parse`, which emits forward
|
|
22779
|
+
// slashes even on Windows, while `create` and `gc` record the native form. Logging both spellings
|
|
22780
|
+
// puts one worktree under two names in the file an operator greps to find what took theirs.
|
|
22781
|
+
target: toNativePath(wtPath),
|
|
22782
|
+
branch,
|
|
22783
|
+
actor: landActor,
|
|
22784
|
+
owner: landOwner ? { createdAt: landOwner.createdAt, lastSeenAt: landOwner.lastSeenAt, actor: landOwner.actor } : void 0,
|
|
22785
|
+
reason: describeLandMergeState(mergeVerdict)
|
|
22786
|
+
});
|
|
22787
|
+
if (removeOutcome.status === "removed") dropWorktreeOwner(primaryCheckout, wtPath);
|
|
22788
|
+
report.push(await bestEffortGit(["branch", "-D", branch], primaryCheckout, `delete local branch ${branch}`));
|
|
22789
|
+
if (!keepRemoteBranch) {
|
|
22790
|
+
report.push(await bestEffortGit(["push", o.remote, "--delete", branch], void 0, `delete remote branch ${branch}`, GH_MUTATION_TIMEOUT_MS));
|
|
22791
|
+
} else if (!o.keepRemote) {
|
|
22792
|
+
report.push({ step: `delete remote branch ${branch}`, status: `skipped: ${landRefs.action === "keep-remote" ? landRefs.reason : "kept"}` });
|
|
22768
22793
|
}
|
|
22769
|
-
|
|
22770
|
-
|
|
22771
|
-
|
|
22772
|
-
|
|
22773
|
-
}
|
|
22794
|
+
report.push(await bestEffortGit(["worktree", "prune"], primaryCheckout, "prune worktree metadata"));
|
|
22795
|
+
const result = {
|
|
22796
|
+
dryRun: false,
|
|
22797
|
+
...plan,
|
|
22798
|
+
...o.keepRemote ? { keepRemote: true } : {},
|
|
22799
|
+
// #3566: the merge outcome, machine-readable, so an orchestrator never has to infer it from the
|
|
22800
|
+
// absence of an error. `merged` is the ONLY value that means the work reached the base branch.
|
|
22801
|
+
mergeState: mergeVerdict.state,
|
|
22802
|
+
prNumbers: mergeVerdict.numbers,
|
|
22803
|
+
report
|
|
22804
|
+
};
|
|
22805
|
+
if (o.json) return console.log(JSON.stringify(result, null, 2));
|
|
22806
|
+
console.log(`worktree land: cleaned up branch ${branch} (${describeLandMergeState(mergeVerdict)})`);
|
|
22807
|
+
for (const r of report) console.log(` ${r.step}: ${r.status}`);
|
|
22808
|
+
if (mergeVerdict.state !== "merged") {
|
|
22809
|
+
console.warn(`worktree land: '${branch}' was cleaned up but NOT merged \u2014 ${describeLandMergeState(mergeVerdict)}. Do not report this work as shipped.`);
|
|
22774
22810
|
}
|
|
22775
|
-
|
|
22776
|
-
}
|
|
22777
|
-
).action(async (ref, o) => {
|
|
22778
|
-
const parsed = parseCloseReason(o.reason);
|
|
22779
|
-
try {
|
|
22780
|
-
const defaultRepo = await resolveRepo(o.repo);
|
|
22781
|
-
const result = await closeIssue(defaultGitHubClient(), {
|
|
22782
|
-
ref,
|
|
22783
|
-
defaultRepo,
|
|
22784
|
-
reason: parsed.reason,
|
|
22785
|
-
duplicateOf: o.duplicateOf ? Number(o.duplicateOf) : void 0,
|
|
22786
|
-
comment: o.comment
|
|
22787
|
-
});
|
|
22788
|
-
console.log(JSON.stringify(result));
|
|
22789
|
-
} catch (e) {
|
|
22790
|
-
return failGraceful(`issue close failed: ${e.message}`);
|
|
22791
|
-
}
|
|
22792
|
-
});
|
|
22793
|
-
mutating(
|
|
22794
|
-
issue2.command("reopen <ref>").description("reopen a closed issue and move its board item back to Todo").option("--repo <owner/repo>", "repo for a bare ref (defaults to the current repo)"),
|
|
22795
|
-
(_opts, args) => ({ command: "issue reopen", ref: args[0] })
|
|
22796
|
-
).action(async (ref, o) => {
|
|
22797
|
-
try {
|
|
22798
|
-
const defaultRepo = await resolveRepo(o.repo);
|
|
22799
|
-
const result = await reopenIssue(defaultGitHubClient(), ref, defaultRepo);
|
|
22800
|
-
console.log(JSON.stringify(result));
|
|
22801
|
-
} catch (e) {
|
|
22802
|
-
return failGraceful(`issue reopen failed: ${e.message}`);
|
|
22803
|
-
}
|
|
22804
|
-
});
|
|
22805
|
-
mutating(
|
|
22806
|
-
issue2.command("assign <ref> <login>").description("assign a user to an issue \u2014 pure ownership, no board Status change (unlike board claim)").option("--repo <owner/repo>", "repo for a bare ref (defaults to the current repo)"),
|
|
22807
|
-
(_opts, args) => ({ command: "issue assign", ref: args[0], login: args[1] })
|
|
22808
|
-
).action(async (ref, login, o) => {
|
|
22809
|
-
try {
|
|
22810
|
-
const defaultRepo = await resolveRepo(o.repo);
|
|
22811
|
-
const result = await assignIssue(defaultGitHubClient(), ref, login, defaultRepo);
|
|
22812
|
-
console.log(JSON.stringify(result));
|
|
22813
|
-
} catch (e) {
|
|
22814
|
-
return failGraceful(`issue assign failed: ${e.message}`);
|
|
22815
|
-
}
|
|
22816
|
-
});
|
|
22817
|
-
mutating(
|
|
22818
|
-
issue2.command("unassign <ref> <login>").description("remove a user from an issue \u2014 pure ownership, no board Status change").option("--repo <owner/repo>", "repo for a bare ref (defaults to the current repo)"),
|
|
22819
|
-
(_opts, args) => ({ command: "issue unassign", ref: args[0], login: args[1] })
|
|
22820
|
-
).action(async (ref, login, o) => {
|
|
22821
|
-
try {
|
|
22822
|
-
const defaultRepo = await resolveRepo(o.repo);
|
|
22823
|
-
const result = await unassignIssue(defaultGitHubClient(), ref, login, defaultRepo);
|
|
22824
|
-
console.log(JSON.stringify(result));
|
|
22825
|
-
} catch (e) {
|
|
22826
|
-
return failGraceful(`issue unassign failed: ${e.message}`);
|
|
22827
|
-
}
|
|
22828
|
-
});
|
|
22829
|
-
mutating(
|
|
22830
|
-
issue2.command("relocate <ref>").description("transfer an issue to another repo, sweep the ghost board item, add to the destination board, and carry priority").requiredOption("--to <owner/repo>", "destination repository").option("--repo <owner/repo>", "source repo for a bare ref (defaults to the current repo)"),
|
|
22831
|
-
(opts, args) => ({ command: "issue relocate", ref: args[0], to: opts.to })
|
|
22832
|
-
).action(async (ref, o) => {
|
|
22833
|
-
try {
|
|
22834
|
-
const defaultRepo = await resolveRepo(o.repo);
|
|
22835
|
-
const result = await relocateIssue(defaultGitHubClient(), ref, o.to, defaultRepo);
|
|
22836
|
-
console.log(JSON.stringify(result));
|
|
22811
|
+
if (report.some((r) => r.status.startsWith("failed"))) process.exitCode = 1;
|
|
22837
22812
|
} catch (e) {
|
|
22838
|
-
return failGraceful(`
|
|
22813
|
+
return failGraceful(`worktree land: ${e.message}`);
|
|
22839
22814
|
}
|
|
22840
22815
|
});
|
|
22841
|
-
|
|
22842
|
-
issue2.command("unlink-child <parent> <child>").description("unlink a child issue from its parent (inverse of link-child) and print {parentNumber,subIssueNumber,totalCount} JSON").option("--repo <owner/repo>", "repo for bare refs on either side (defaults to the current repo)"),
|
|
22843
|
-
(_opts, args) => ({ command: "issue unlink-child", parent: args[0], child: args[1] })
|
|
22844
|
-
).action(async (parentRef, childRef, o) => {
|
|
22845
|
-
const defaultRepo = await resolveRepo(o.repo);
|
|
22816
|
+
if (!has("list")) worktree2.command("list").description("list worktrees; --stale surfaces orphaned worktrees/branches/running dev servers with remediation").option("--stale", "show only leaks (merged/closed branches, orphan dirs, stale dev servers)").option("--json", "machine-readable output").action(async (o) => {
|
|
22846
22817
|
try {
|
|
22847
|
-
const
|
|
22848
|
-
|
|
22818
|
+
const ctx = await gatherWorktreeContext();
|
|
22819
|
+
if (o.stale) {
|
|
22820
|
+
const leaks = classifyStaleLeaks(ctx);
|
|
22821
|
+
if (o.json) return console.log(JSON.stringify({ stale: leaks, count: leaks.length }, null, 2));
|
|
22822
|
+
return console.log(formatStaleLeaks(leaks));
|
|
22823
|
+
}
|
|
22824
|
+
if (o.json) return console.log(JSON.stringify({ worktrees: ctx.worktrees }, null, 2));
|
|
22825
|
+
if (!ctx.worktrees.length) return console.log("worktree list: no worktrees");
|
|
22826
|
+
const lines = ["worktree list:"];
|
|
22827
|
+
for (const wt of ctx.worktrees) {
|
|
22828
|
+
const tag = wt.primary ? "primary" : "worktree";
|
|
22829
|
+
lines.push(` ${wt.branch} @ ${wt.path} (${tag})`);
|
|
22830
|
+
}
|
|
22831
|
+
console.log(lines.join("\n"));
|
|
22849
22832
|
} catch (e) {
|
|
22850
|
-
|
|
22851
|
-
const note = timeoutKillNote(e, GH_MUTATION_TIMEOUT_MS);
|
|
22852
|
-
return failGraceful(`issue unlink-child: ${(err.stderr || err.message || String(e)).trim()}${note ? ` (${note})` : ""}`);
|
|
22833
|
+
return failGraceful(`worktree list: ${e.message}`);
|
|
22853
22834
|
}
|
|
22854
22835
|
});
|
|
22855
|
-
extendCreateCommand(issue2, deps.attach);
|
|
22856
22836
|
}
|
|
22857
|
-
function
|
|
22858
|
-
const
|
|
22859
|
-
|
|
22860
|
-
|
|
22861
|
-
const
|
|
22862
|
-
const
|
|
22863
|
-
|
|
22864
|
-
|
|
22865
|
-
|
|
22866
|
-
|
|
22867
|
-
|
|
22868
|
-
|
|
22869
|
-
|
|
22870
|
-
|
|
22871
|
-
|
|
22872
|
-
|
|
22873
|
-
|
|
22874
|
-
|
|
22875
|
-
|
|
22876
|
-
const lines = validation.errors.map((e) => ` row ${e.row}: ${e.error}`).join("\n");
|
|
22877
|
-
return fail(`issue create --batch: validation failed (${validation.errors.length} error(s)):
|
|
22878
|
-
${lines}`);
|
|
22879
|
-
}
|
|
22880
|
-
const batchParent = opts.parent;
|
|
22881
|
-
const batchPriority = opts.priority ? normalizePriority(opts.priority) : void 0;
|
|
22882
|
-
if (opts.dryRun || opts.validateOnly) {
|
|
22883
|
-
const planned = validation.validated.map((v) => ({
|
|
22884
|
-
row: v.row,
|
|
22885
|
-
type: v.type,
|
|
22886
|
-
title: v.spec.title,
|
|
22887
|
-
priority: v.spec.priority ? v.priority : batchPriority ?? v.priority,
|
|
22888
|
-
repo: v.spec.repo,
|
|
22889
|
-
...v.spec.parent ?? batchParent ? { parent: v.spec.parent ?? batchParent } : {}
|
|
22890
|
-
}));
|
|
22891
|
-
console.log(JSON.stringify(opts.validateOnly ? { ok: true, planned } : { dry_run: true, planned }));
|
|
22892
|
-
return;
|
|
22893
|
-
}
|
|
22894
|
-
try {
|
|
22895
|
-
const result = await createIssuesBatch(specs, {
|
|
22896
|
-
repo: opts.repo,
|
|
22897
|
-
idempotencyKey: opts.idempotencyKey,
|
|
22898
|
-
parent: batchParent,
|
|
22899
|
-
priority: batchPriority
|
|
22900
|
-
}, { attach: batchAttach });
|
|
22901
|
-
console.log(JSON.stringify(result));
|
|
22902
|
-
if (result.failures.length) process.exitCode = 1;
|
|
22903
|
-
} catch (e) {
|
|
22904
|
-
return failGraceful(`issue create --batch failed: ${e.message}`);
|
|
22905
|
-
}
|
|
22906
|
-
return;
|
|
22837
|
+
async function gatherWorktreeContext() {
|
|
22838
|
+
const repoRoot2 = await repoRootOf();
|
|
22839
|
+
const porcelain = (await execFileP2("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout;
|
|
22840
|
+
const parsed = parseWorktreePorcelain(porcelain);
|
|
22841
|
+
const primaryPath = parsed[0]?.path ?? repoRoot2;
|
|
22842
|
+
const worktrees = parsed.map((w) => ({ path: toNativePath(w.path), branch: w.branch, primary: w.path === primaryPath }));
|
|
22843
|
+
const branchOut = (await execFileP2("git", ["branch", "--format=%(refname:short)"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout;
|
|
22844
|
+
const localBranches = branchOut.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
22845
|
+
const currentBranch2 = (await execFileP2("git", ["rev-parse", "--abbrev-ref", "HEAD"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim() || void 0;
|
|
22846
|
+
const openPrBranches = /* @__PURE__ */ new Set();
|
|
22847
|
+
const closedBranches = /* @__PURE__ */ new Set();
|
|
22848
|
+
try {
|
|
22849
|
+
const { stdout } = await execFileP2("gh", ["pr", "list", "--state", "all", "--limit", "200", "--json", "headRefName,state"], { timeout: GH_TIMEOUT_MS });
|
|
22850
|
+
const prs = JSON.parse(stdout || "[]");
|
|
22851
|
+
const byBranch = /* @__PURE__ */ new Map();
|
|
22852
|
+
for (const pr2 of prs) {
|
|
22853
|
+
const arr = byBranch.get(pr2.headRefName) ?? [];
|
|
22854
|
+
arr.push(pr2.state);
|
|
22855
|
+
byBranch.set(pr2.headRefName, arr);
|
|
22907
22856
|
}
|
|
22908
|
-
|
|
22909
|
-
|
|
22910
|
-
|
|
22911
|
-
if (repo) {
|
|
22912
|
-
const existing = await findExistingByIdempotencyKey(defaultGitHubClient(), opts.idempotencyKey, repo);
|
|
22913
|
-
if (existing) {
|
|
22914
|
-
console.log(JSON.stringify({ ...existing, idempotent: true }));
|
|
22915
|
-
return;
|
|
22916
|
-
}
|
|
22917
|
-
}
|
|
22918
|
-
} catch {
|
|
22919
|
-
}
|
|
22857
|
+
for (const [br, states] of byBranch) {
|
|
22858
|
+
if (states.some((s) => s === "OPEN")) openPrBranches.add(br);
|
|
22859
|
+
else if (states.some((s) => s === "MERGED" || s === "CLOSED")) closedBranches.add(br);
|
|
22920
22860
|
}
|
|
22921
|
-
|
|
22922
|
-
};
|
|
22923
|
-
}
|
|
22924
|
-
|
|
22925
|
-
// src/train-commands.ts
|
|
22926
|
-
var import_node_fs29 = require("node:fs");
|
|
22927
|
-
var import_node_path27 = require("node:path");
|
|
22928
|
-
|
|
22929
|
-
// src/plugin-guard-io.ts
|
|
22930
|
-
var import_node_fs28 = require("node:fs");
|
|
22931
|
-
var import_node_path26 = require("node:path");
|
|
22932
|
-
var import_node_os6 = require("node:os");
|
|
22933
|
-
|
|
22934
|
-
// src/plugin-guard.ts
|
|
22935
|
-
function buildPluginGuardDecision(i) {
|
|
22936
|
-
if (!i.isOrgRepo) return { state: "not-org" };
|
|
22937
|
-
if (!i.installRecordPresent) return { state: "no-install" };
|
|
22938
|
-
if (!i.marketplaceClonePresent || !i.pluginCachePresent) return { state: "unresolved" };
|
|
22939
|
-
return { state: "healthy" };
|
|
22940
|
-
}
|
|
22941
|
-
function buildPluginGuardLine(state, opts = {}) {
|
|
22942
|
-
if (state === "healthy" || state === "not-org") return { exitCode: 0 };
|
|
22943
|
-
const recovery = opts.recovery ?? "mmi-cli plugin heal";
|
|
22944
|
-
const restartHint = opts.restartHint ?? "restart your agent host / reload plugins";
|
|
22945
|
-
const reason = state === "no-install" ? "MMI plugin is not installed for this user/session" : "MMI plugin is installed but its marketplace/cache is unresolved";
|
|
22946
|
-
return {
|
|
22947
|
-
line: `[mmi-guard] ${reason}; run ${recovery} and ${restartHint}.`,
|
|
22948
|
-
exitCode: 1
|
|
22949
|
-
};
|
|
22950
|
-
}
|
|
22951
|
-
|
|
22952
|
-
// src/plugin-guard-io.ts
|
|
22953
|
-
var isWin = process.platform === "win32";
|
|
22954
|
-
var MMI_PLUGIN_ID = "mmi@mutmutco";
|
|
22955
|
-
var LEGACY_MMI_MARKETPLACE = "mmi";
|
|
22956
|
-
function detectSurface(env) {
|
|
22957
|
-
const has = (k) => Boolean(env[k]?.trim());
|
|
22958
|
-
if (env.MMI_AGENT_SURFACE === "codex" || has("CODEX_HOME") || (env.CLAUDE_PLUGIN_ROOT ?? "").includes(".codex")) {
|
|
22959
|
-
return "codex";
|
|
22861
|
+
} catch {
|
|
22960
22862
|
}
|
|
22961
|
-
|
|
22962
|
-
|
|
22863
|
+
const stages = [];
|
|
22864
|
+
for (const wt of worktrees) {
|
|
22865
|
+
const s = readStageSummary(wt.path);
|
|
22866
|
+
if (s) stages.push({ path: wt.path, port: s.port });
|
|
22963
22867
|
}
|
|
22964
|
-
|
|
22965
|
-
const
|
|
22966
|
-
const
|
|
22967
|
-
|
|
22968
|
-
if (
|
|
22969
|
-
|
|
22970
|
-
|
|
22971
|
-
|
|
22972
|
-
|
|
22973
|
-
case "claude-cli":
|
|
22974
|
-
case "claude-vscode":
|
|
22975
|
-
return "claude";
|
|
22976
|
-
case "codex":
|
|
22977
|
-
return "codex";
|
|
22978
|
-
case "cursor":
|
|
22979
|
-
return "cursor";
|
|
22980
|
-
case "opencode":
|
|
22981
|
-
return "opencode";
|
|
22982
|
-
case "shell":
|
|
22983
|
-
default:
|
|
22984
|
-
return null;
|
|
22868
|
+
const worktreeGitRoot = await currentRepoWorktreeGitRoot(repoRoot2);
|
|
22869
|
+
const primaryRepoRoot = worktreeGitRoot ? (0, import_node_path27.dirname)((0, import_node_path27.dirname)(worktreeGitRoot)) : repoRoot2;
|
|
22870
|
+
const wtRoot = siblingMmiWorktreesRoot(primaryRepoRoot);
|
|
22871
|
+
let orphanDirs = [];
|
|
22872
|
+
if ((0, import_node_fs28.existsSync)(wtRoot)) {
|
|
22873
|
+
orphanDirs = scanOrphanDirs(wtRoot, worktreeGitRoot, {
|
|
22874
|
+
...defaultOrphanDirScanDeps,
|
|
22875
|
+
listDirs: (root) => worktreeScanDirs(root, primaryRepoRoot, defaultOrphanDirScanDeps.listDirs, isRepoCheckoutDir)
|
|
22876
|
+
});
|
|
22985
22877
|
}
|
|
22878
|
+
return { worktrees, localBranches, currentBranch: currentBranch2, openPrBranches, closedBranches, stages, orphanDirs };
|
|
22986
22879
|
}
|
|
22987
|
-
function
|
|
22988
|
-
|
|
22989
|
-
|
|
22990
|
-
|
|
22991
|
-
|
|
22992
|
-
|
|
22993
|
-
|
|
22994
|
-
|
|
22995
|
-
|
|
22996
|
-
|
|
22997
|
-
|
|
22998
|
-
case "shell":
|
|
22999
|
-
default:
|
|
23000
|
-
return "restart Claude Code (or run /reload-plugins)";
|
|
22880
|
+
async function bestEffortGit(args, cwd, step, timeoutMs = GIT_TIMEOUT_MS) {
|
|
22881
|
+
try {
|
|
22882
|
+
const fullArgs = cwd ? ["-C", cwd, ...args] : args;
|
|
22883
|
+
await execFileP2("git", fullArgs, { timeout: timeoutMs });
|
|
22884
|
+
return { step, status: "done" };
|
|
22885
|
+
} catch (e) {
|
|
22886
|
+
const msg = e.message.split("\n")[0];
|
|
22887
|
+
if (/not found|already|no such|does not exist|not a valid|couldn't|unable to resolve/i.test(msg)) {
|
|
22888
|
+
return { step, status: "already gone" };
|
|
22889
|
+
}
|
|
22890
|
+
return { step, status: `failed: ${msg}` };
|
|
23001
22891
|
}
|
|
23002
22892
|
}
|
|
23003
|
-
|
|
23004
|
-
|
|
23005
|
-
|
|
23006
|
-
|
|
23007
|
-
|
|
23008
|
-
|
|
23009
|
-
|
|
23010
|
-
|
|
23011
|
-
|
|
23012
|
-
|
|
23013
|
-
|
|
23014
|
-
|
|
23015
|
-
|
|
23016
|
-
|
|
22893
|
+
|
|
22894
|
+
// src/issue-commands.ts
|
|
22895
|
+
var import_node_fs29 = require("node:fs");
|
|
22896
|
+
var import_node_crypto5 = require("node:crypto");
|
|
22897
|
+
var ghRunner = async (args, timeoutMs) => (await execFileP2("gh", args, { timeout: timeoutMs })).stdout;
|
|
22898
|
+
async function editIssue(client, options, deps = {}) {
|
|
22899
|
+
const parsed = parseIssueRef(options.ref);
|
|
22900
|
+
const repo = parsed.repo ?? options.defaultRepo;
|
|
22901
|
+
if (!repo) throw new Error("could not resolve repo \u2014 pass --repo owner/repo");
|
|
22902
|
+
const url = `https://github.com/${repo}/issues/${parsed.number}`;
|
|
22903
|
+
const patch = {};
|
|
22904
|
+
let bodyChanged = false;
|
|
22905
|
+
if (options.title !== void 0) patch.title = options.title;
|
|
22906
|
+
if (options.body !== void 0 || options.bodyFile !== void 0) {
|
|
22907
|
+
let body;
|
|
22908
|
+
if (options.bodyFile) {
|
|
22909
|
+
const td = deps.textDeps ?? { readFile: (p, e) => Promise.resolve((0, import_node_fs29.readFileSync)(p, e)), readStdin: () => Promise.resolve("") };
|
|
22910
|
+
body = await resolveIssueBody({ body: options.body, bodyFile: options.bodyFile }, td);
|
|
22911
|
+
} else {
|
|
22912
|
+
body = options.body ?? "";
|
|
22913
|
+
}
|
|
22914
|
+
patch.body = body;
|
|
22915
|
+
bodyChanged = true;
|
|
23017
22916
|
}
|
|
23018
|
-
|
|
23019
|
-
|
|
23020
|
-
if (surface === "codex") {
|
|
23021
|
-
return "Codex ships an MMI plugin (#3563). Install or repair it with:\n codex plugin marketplace add mutmutco/MMI-Hub && codex plugin add mmi@mutmutco\n Then run /hooks and TRUST the MMI hooks \u2014 bundled hooks are skipped until reviewed.\n Update the CLI too: npm i -g @mutmutco/cli";
|
|
22917
|
+
if (patch.title !== void 0 || patch.body !== void 0) {
|
|
22918
|
+
await client.rest("PATCH", `repos/${repo}/issues/${parsed.number}`, { body: patch });
|
|
23022
22919
|
}
|
|
23023
|
-
|
|
23024
|
-
|
|
23025
|
-
|
|
23026
|
-
|
|
23027
|
-
|
|
23028
|
-
|
|
23029
|
-
|
|
23030
|
-
|
|
22920
|
+
const labelsAdded = [];
|
|
22921
|
+
const labelsRemoved = [];
|
|
22922
|
+
if (options.addLabel?.length) {
|
|
22923
|
+
await client.rest("POST", `repos/${repo}/issues/${parsed.number}/labels`, { body: { labels: options.addLabel } });
|
|
22924
|
+
labelsAdded.push(...options.addLabel);
|
|
22925
|
+
}
|
|
22926
|
+
for (const label of options.removeLabel ?? []) {
|
|
22927
|
+
try {
|
|
22928
|
+
await client.rest("DELETE", `repos/${repo}/issues/${parsed.number}/labels/${encodeURIComponent(label)}`);
|
|
22929
|
+
labelsRemoved.push(label);
|
|
22930
|
+
} catch (e) {
|
|
22931
|
+
if (e instanceof GitHubApiError && e.status === 404) {
|
|
22932
|
+
labelsRemoved.push(label);
|
|
22933
|
+
} else {
|
|
22934
|
+
throw e;
|
|
22935
|
+
}
|
|
22936
|
+
}
|
|
22937
|
+
}
|
|
22938
|
+
let parentResult;
|
|
22939
|
+
if (options.parent) {
|
|
22940
|
+
const run = deps.runGh ?? ghRunner;
|
|
22941
|
+
parentResult = await linkSubIssue(run, options.parent, options.ref, repo);
|
|
22942
|
+
}
|
|
22943
|
+
return {
|
|
22944
|
+
number: parsed.number,
|
|
22945
|
+
repo,
|
|
22946
|
+
url,
|
|
22947
|
+
...patch.title !== void 0 ? { title: patch.title } : {},
|
|
22948
|
+
bodyChanged,
|
|
22949
|
+
labelsAdded,
|
|
22950
|
+
labelsRemoved,
|
|
22951
|
+
...parentResult ? { parent: parentResult } : {}
|
|
22952
|
+
};
|
|
23031
22953
|
}
|
|
23032
|
-
function
|
|
23033
|
-
|
|
23034
|
-
|
|
23035
|
-
|
|
23036
|
-
|
|
23037
|
-
|
|
23038
|
-
|
|
23039
|
-
|
|
23040
|
-
|
|
22954
|
+
async function closeIssue(client, options, deps = {}) {
|
|
22955
|
+
const parsed = parseIssueRef(options.ref);
|
|
22956
|
+
const repo = parsed.repo ?? options.defaultRepo;
|
|
22957
|
+
if (!repo) throw new Error("could not resolve repo \u2014 pass --repo owner/repo");
|
|
22958
|
+
const url = `https://github.com/${repo}/issues/${parsed.number}`;
|
|
22959
|
+
const reason = options.reason ?? "completed";
|
|
22960
|
+
const stateReason = reason === "duplicate-of" ? "not_planned" : reason === "not-planned" ? "not_planned" : "completed";
|
|
22961
|
+
await client.rest("PATCH", `repos/${repo}/issues/${parsed.number}`, {
|
|
22962
|
+
body: { state: "closed", state_reason: stateReason }
|
|
23041
22963
|
});
|
|
23042
|
-
|
|
23043
|
-
}
|
|
23044
|
-
|
|
23045
|
-
|
|
23046
|
-
}
|
|
23047
|
-
function hasProjectInstallRecord(file, pluginId, projectPath) {
|
|
23048
|
-
const records = file?.plugins?.[pluginId];
|
|
23049
|
-
if (!Array.isArray(records)) return false;
|
|
23050
|
-
return records.some((r) => r.scope === "project" && r.projectPath === projectPath);
|
|
23051
|
-
}
|
|
23052
|
-
function hasUserInstallRecord(file, pluginId) {
|
|
23053
|
-
const records = file?.plugins?.[pluginId];
|
|
23054
|
-
if (!Array.isArray(records)) return false;
|
|
23055
|
-
return records.some((r) => r.scope === "user");
|
|
23056
|
-
}
|
|
23057
|
-
var CLAUDE_PLUGIN_TIMEOUT_MS = 12e4;
|
|
23058
|
-
var NPM_VIEW_TIMEOUT_MS = 15e3;
|
|
23059
|
-
function runHostBin(bin, args, opts) {
|
|
23060
|
-
return isWin ? execFileP2("cmd.exe", ["/c", bin, ...args], opts) : execFileP2(bin, args, opts);
|
|
23061
|
-
}
|
|
23062
|
-
var installedPluginsPath2 = (surface = detectSurface(process.env)) => {
|
|
23063
|
-
const homeDir = surface === "codex" ? ".codex" : ".claude";
|
|
23064
|
-
return (0, import_node_path26.join)((0, import_node_os6.homedir)(), homeDir, "plugins", "installed_plugins.json");
|
|
23065
|
-
};
|
|
23066
|
-
function readInstalledPlugins(surface = detectSurface(process.env)) {
|
|
23067
|
-
try {
|
|
23068
|
-
return JSON.parse((0, import_node_fs28.readFileSync)(installedPluginsPath2(surface), "utf8"));
|
|
23069
|
-
} catch {
|
|
23070
|
-
return null;
|
|
22964
|
+
if (reason === "duplicate-of" && options.duplicateOf) {
|
|
22965
|
+
await client.rest("POST", `repos/${repo}/issues/${parsed.number}/comments`, {
|
|
22966
|
+
body: { body: `Duplicate of #${options.duplicateOf}` }
|
|
22967
|
+
});
|
|
23071
22968
|
}
|
|
23072
|
-
|
|
23073
|
-
|
|
23074
|
-
|
|
23075
|
-
|
|
23076
|
-
(0, import_node_path26.join)(home, ".codex", ".tmp", "marketplaces", "mutmutco"),
|
|
23077
|
-
(0, import_node_path26.join)(home, ".codex", "plugins", "marketplaces", "mutmutco")
|
|
23078
|
-
];
|
|
22969
|
+
if (options.comment) {
|
|
22970
|
+
await client.rest("POST", `repos/${repo}/issues/${parsed.number}/comments`, {
|
|
22971
|
+
body: { body: options.comment }
|
|
22972
|
+
});
|
|
23079
22973
|
}
|
|
23080
|
-
|
|
23081
|
-
}
|
|
23082
|
-
function marketplaceClonePresent(surface, home, exists = import_node_fs28.existsSync) {
|
|
23083
|
-
return marketplaceCloneCandidates(surface, home).some(exists);
|
|
23084
|
-
}
|
|
23085
|
-
async function fetchNpmReleasedVersion() {
|
|
22974
|
+
let board;
|
|
23086
22975
|
try {
|
|
23087
|
-
const
|
|
23088
|
-
|
|
22976
|
+
const cfg = await loadConfigForBoardSelector(options.ref, options.defaultRepo);
|
|
22977
|
+
if (cfg.projectId) {
|
|
22978
|
+
const result = await moveBoardItem({ config: cfg, selector: options.ref, status: "Done", repo, allowPartial: true }, deps);
|
|
22979
|
+
board = { status: result.status, partial: result.partial };
|
|
22980
|
+
}
|
|
23089
22981
|
} catch {
|
|
23090
|
-
return void 0;
|
|
23091
22982
|
}
|
|
22983
|
+
return { number: parsed.number, repo, url, state: "closed", stateReason, ...board ? { board } : {} };
|
|
23092
22984
|
}
|
|
23093
|
-
|
|
23094
|
-
|
|
23095
|
-
const
|
|
22985
|
+
async function reopenIssue(client, ref, defaultRepo, deps = {}) {
|
|
22986
|
+
const parsed = parseIssueRef(ref);
|
|
22987
|
+
const repo = parsed.repo ?? defaultRepo;
|
|
22988
|
+
if (!repo) throw new Error("could not resolve repo \u2014 pass --repo owner/repo");
|
|
22989
|
+
const url = `https://github.com/${repo}/issues/${parsed.number}`;
|
|
22990
|
+
await client.rest("PATCH", `repos/${repo}/issues/${parsed.number}`, { body: { state: "open" } });
|
|
22991
|
+
let board;
|
|
23096
22992
|
try {
|
|
23097
|
-
|
|
23098
|
-
|
|
23099
|
-
|
|
23100
|
-
|
|
22993
|
+
const cfg = await loadConfigForBoardSelector(ref, defaultRepo);
|
|
22994
|
+
if (cfg.projectId) {
|
|
22995
|
+
const result = await moveBoardItem({ config: cfg, selector: ref, status: "Todo", repo, allowPartial: true }, deps);
|
|
22996
|
+
board = { status: result.status, partial: result.partial };
|
|
22997
|
+
}
|
|
22998
|
+
} catch {
|
|
23101
22999
|
}
|
|
23000
|
+
return { number: parsed.number, repo, url, state: "open", ...board ? { board } : {} };
|
|
23102
23001
|
}
|
|
23103
|
-
function
|
|
23104
|
-
const
|
|
23105
|
-
const
|
|
23106
|
-
|
|
23107
|
-
|
|
23108
|
-
|
|
23109
|
-
marketplaceClonePresent: marketplaceClonePresent(surface, (0, import_node_os6.homedir)()),
|
|
23110
|
-
pluginCachePresent: (0, import_node_fs28.existsSync)((0, import_node_path26.join)((0, import_node_os6.homedir)(), homeDir, "plugins", "cache", "mutmutco", "mmi"))
|
|
23111
|
-
};
|
|
23002
|
+
async function assignIssue(client, ref, login, defaultRepo) {
|
|
23003
|
+
const parsed = parseIssueRef(ref);
|
|
23004
|
+
const repo = parsed.repo ?? defaultRepo;
|
|
23005
|
+
if (!repo) throw new Error("could not resolve repo \u2014 pass --repo owner/repo");
|
|
23006
|
+
await client.rest("POST", `repos/${repo}/issues/${parsed.number}/assignees`, { body: { assignees: [login] } });
|
|
23007
|
+
return { number: parsed.number, repo, url: `https://github.com/${repo}/issues/${parsed.number}`, login, action: "assigned" };
|
|
23112
23008
|
}
|
|
23113
|
-
function
|
|
23114
|
-
|
|
23009
|
+
async function unassignIssue(client, ref, login, defaultRepo) {
|
|
23010
|
+
const parsed = parseIssueRef(ref);
|
|
23011
|
+
const repo = parsed.repo ?? defaultRepo;
|
|
23012
|
+
if (!repo) throw new Error("could not resolve repo \u2014 pass --repo owner/repo");
|
|
23013
|
+
await client.rest("DELETE", `repos/${repo}/issues/${parsed.number}/assignees`, { body: { assignees: [login] } });
|
|
23014
|
+
return { number: parsed.number, repo, url: `https://github.com/${repo}/issues/${parsed.number}`, login, action: "unassigned" };
|
|
23115
23015
|
}
|
|
23116
|
-
|
|
23016
|
+
var TRANSFER_ISSUE_MUTATION = `
|
|
23017
|
+
mutation($issueId: ID!, $repositoryId: ID!) {
|
|
23018
|
+
transferIssue(input: {issueId: $issueId, repositoryId: $repositoryId}) {
|
|
23019
|
+
issue {
|
|
23020
|
+
id
|
|
23021
|
+
number
|
|
23022
|
+
url
|
|
23023
|
+
repository { nameWithOwner }
|
|
23024
|
+
}
|
|
23025
|
+
}
|
|
23026
|
+
}`;
|
|
23027
|
+
var ADD_PROJECT_ITEM_MUTATION = `
|
|
23028
|
+
mutation($projectId: ID!, $contentId: ID!) {
|
|
23029
|
+
addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
|
|
23030
|
+
item { id }
|
|
23031
|
+
}
|
|
23032
|
+
}`;
|
|
23033
|
+
async function relocateIssue(client, ref, toRepo, defaultRepo, deps = {}) {
|
|
23034
|
+
const parsed = parseIssueRef(ref);
|
|
23035
|
+
const sourceRepo = parsed.repo ?? defaultRepo;
|
|
23036
|
+
if (!sourceRepo) throw new Error("could not resolve the source repo \u2014 pass owner/repo#123 or use --repo");
|
|
23037
|
+
let sourceItemId;
|
|
23038
|
+
let priority;
|
|
23117
23039
|
try {
|
|
23118
|
-
await
|
|
23119
|
-
|
|
23040
|
+
const sourceCfg = await loadConfigForBoardSelector(ref, defaultRepo);
|
|
23041
|
+
if (sourceCfg.projectId) {
|
|
23042
|
+
const resolved = resolveBoardConfig(sourceCfg);
|
|
23043
|
+
const lookup = await fetchIssueProjectItem(client, resolved, { repo: sourceRepo, number: parsed.number });
|
|
23044
|
+
sourceItemId = lookup.item?.itemId;
|
|
23045
|
+
priority = lookup.item?.priority;
|
|
23046
|
+
}
|
|
23120
23047
|
} catch {
|
|
23121
|
-
return false;
|
|
23122
23048
|
}
|
|
23123
|
-
}
|
|
23124
|
-
|
|
23049
|
+
const sourceIssue = await client.rest("GET", `repos/${sourceRepo}/issues/${parsed.number}`);
|
|
23050
|
+
const issueNodeId = sourceIssue?.node_id;
|
|
23051
|
+
if (!issueNodeId) throw new Error(`could not resolve node id for ${sourceRepo}#${parsed.number}`);
|
|
23052
|
+
const targetRepoData = await client.rest("GET", `repos/${toRepo}`);
|
|
23053
|
+
const targetRepoNodeId = targetRepoData?.node_id;
|
|
23054
|
+
if (!targetRepoNodeId) throw new Error(`could not resolve node id for repo ${toRepo}`);
|
|
23055
|
+
const transferred = await client.graphql(
|
|
23056
|
+
TRANSFER_ISSUE_MUTATION,
|
|
23057
|
+
{ issueId: issueNodeId, repositoryId: targetRepoNodeId }
|
|
23058
|
+
);
|
|
23059
|
+
const newIssue = transferred?.transferIssue?.issue;
|
|
23060
|
+
if (!newIssue?.id || !newIssue.number || !newIssue.url) {
|
|
23061
|
+
throw new Error("transferIssue returned an unexpected response (no issue id/number/url)");
|
|
23062
|
+
}
|
|
23063
|
+
const newRepo = newIssue.repository?.nameWithOwner ?? toRepo;
|
|
23064
|
+
const newNumber = newIssue.number;
|
|
23065
|
+
const newUrl = newIssue.url;
|
|
23066
|
+
const newNodeId = newIssue.id;
|
|
23067
|
+
let swept = false;
|
|
23068
|
+
if (sourceItemId) {
|
|
23069
|
+
try {
|
|
23070
|
+
const sourceCfg = await loadConfigForBoardSelector(ref, defaultRepo);
|
|
23071
|
+
if (sourceCfg.projectId) {
|
|
23072
|
+
const resolved = resolveBoardConfig(sourceCfg);
|
|
23073
|
+
const DELETE_MUTATION = `mutation($projectId: ID!, $itemId: ID!) { deleteProjectV2Item(input: {projectId: $projectId, itemId: $itemId}) { deletedItemId } }`;
|
|
23074
|
+
await client.graphql(DELETE_MUTATION, { projectId: resolved.projectId, itemId: sourceItemId });
|
|
23075
|
+
swept = true;
|
|
23076
|
+
}
|
|
23077
|
+
} catch {
|
|
23078
|
+
}
|
|
23079
|
+
}
|
|
23080
|
+
let added = false;
|
|
23081
|
+
let carriedPriority;
|
|
23125
23082
|
try {
|
|
23126
|
-
const
|
|
23127
|
-
|
|
23128
|
-
|
|
23129
|
-
|
|
23130
|
-
|
|
23083
|
+
const destCfg = await loadConfigForBoardSelector(`${newRepo}#${newNumber}`, newRepo);
|
|
23084
|
+
if (destCfg.projectId) {
|
|
23085
|
+
const addResult = await client.graphql(
|
|
23086
|
+
ADD_PROJECT_ITEM_MUTATION,
|
|
23087
|
+
{ projectId: destCfg.projectId, contentId: newNodeId }
|
|
23088
|
+
);
|
|
23089
|
+
const newItemId = addResult?.addProjectV2ItemById?.item?.id;
|
|
23090
|
+
if (newItemId && priority) {
|
|
23091
|
+
try {
|
|
23092
|
+
const p = priority.toLowerCase();
|
|
23093
|
+
if (CLI_PRIORITIES.includes(p)) {
|
|
23094
|
+
await setBoardItemPriority(client, destCfg, newItemId, p);
|
|
23095
|
+
carriedPriority = priority;
|
|
23096
|
+
}
|
|
23097
|
+
} catch {
|
|
23098
|
+
}
|
|
23099
|
+
}
|
|
23100
|
+
added = true;
|
|
23101
|
+
}
|
|
23131
23102
|
} catch {
|
|
23132
|
-
return false;
|
|
23133
23103
|
}
|
|
23104
|
+
return {
|
|
23105
|
+
number: newNumber,
|
|
23106
|
+
repo: newRepo,
|
|
23107
|
+
url: newUrl,
|
|
23108
|
+
previousRepo: sourceRepo,
|
|
23109
|
+
previousNumber: parsed.number,
|
|
23110
|
+
board: { swept, added, ...carriedPriority ? { priority: carriedPriority } : {} }
|
|
23111
|
+
};
|
|
23134
23112
|
}
|
|
23135
|
-
function
|
|
23136
|
-
|
|
23137
|
-
return refSupported ? ` \u21BB reinstalling the MMI plugin via \`${bin} plugin\` (marketplace remove \u2192 add --ref main \u2192 ${installVerb})\u2026` : ` \u21BB reinstalling the MMI plugin via \`${bin} plugin\` (marketplace remove \u2192 add \u2192 ${installVerb}; release ref pinned by the marketplace manifest)\u2026`;
|
|
23113
|
+
function idempotencyMarker(key) {
|
|
23114
|
+
return `mmi-idempotency:${key}`;
|
|
23138
23115
|
}
|
|
23139
|
-
function
|
|
23140
|
-
|
|
23141
|
-
|
|
23116
|
+
function appendIdempotencyMarker(body, key) {
|
|
23117
|
+
const marker = `<!-- ${idempotencyMarker(key)} -->`;
|
|
23118
|
+
if (body.includes(marker)) return body;
|
|
23119
|
+
return `${body.replace(/\s+$/, "")}
|
|
23120
|
+
|
|
23121
|
+
${marker}
|
|
23122
|
+
`;
|
|
23142
23123
|
}
|
|
23143
|
-
|
|
23144
|
-
|
|
23145
|
-
|
|
23146
|
-
|
|
23147
|
-
|
|
23124
|
+
function rowIdempotencyKey(batchKey, spec) {
|
|
23125
|
+
const identity = `${spec.type}
|
|
23126
|
+
${spec.title.trim()}
|
|
23127
|
+
${spec.body ?? ""}`;
|
|
23128
|
+
const hash = (0, import_node_crypto5.createHash)("sha256").update(identity).digest("hex").slice(0, 16);
|
|
23129
|
+
return `${batchKey}:${hash}`;
|
|
23148
23130
|
}
|
|
23149
|
-
|
|
23150
|
-
|
|
23151
|
-
const
|
|
23152
|
-
|
|
23153
|
-
const
|
|
23154
|
-
|
|
23155
|
-
|
|
23156
|
-
|
|
23157
|
-
|
|
23158
|
-
|
|
23159
|
-
|
|
23131
|
+
var BATCH_SPEC_KEYS = /* @__PURE__ */ new Set(["type", "title", "body", "priority", "labels", "parent", "repo"]);
|
|
23132
|
+
function validateBatchSpecs(specs) {
|
|
23133
|
+
const errors = [];
|
|
23134
|
+
const validated = [];
|
|
23135
|
+
const validTypes = ["bug", "feature", "task"];
|
|
23136
|
+
for (let i = 0; i < specs.length; i += 1) {
|
|
23137
|
+
const spec = specs[i];
|
|
23138
|
+
const row = i + 1;
|
|
23139
|
+
if (!spec || typeof spec !== "object") {
|
|
23140
|
+
errors.push({ row, error: "not an object" });
|
|
23141
|
+
continue;
|
|
23142
|
+
}
|
|
23143
|
+
const unknown = Object.keys(spec).filter((k) => !BATCH_SPEC_KEYS.has(k));
|
|
23144
|
+
if (unknown.length) {
|
|
23145
|
+
errors.push({ row, error: `unknown key(s): ${unknown.join(", ")} \u2014 expected only: ${[...BATCH_SPEC_KEYS].join(", ")}` });
|
|
23146
|
+
continue;
|
|
23147
|
+
}
|
|
23148
|
+
if (spec.repo !== void 0 && !/^[\w.-]+\/[\w.-]+$/.test(spec.repo)) {
|
|
23149
|
+
errors.push({ row, error: `bad repo "${spec.repo}" \u2014 expected owner/repo` });
|
|
23150
|
+
continue;
|
|
23151
|
+
}
|
|
23152
|
+
if (!validTypes.includes(spec.type)) {
|
|
23153
|
+
errors.push({ row, error: `unknown type "${spec.type}" \u2014 expected one of: ${validTypes.join(", ")}` });
|
|
23154
|
+
continue;
|
|
23155
|
+
}
|
|
23156
|
+
if (!spec.title || !spec.title.trim()) {
|
|
23157
|
+
errors.push({ row, error: "missing or empty title" });
|
|
23158
|
+
continue;
|
|
23159
|
+
}
|
|
23160
|
+
let priority;
|
|
23161
|
+
try {
|
|
23162
|
+
priority = spec.priority ? normalizePriority(spec.priority) : "medium";
|
|
23163
|
+
} catch (e) {
|
|
23164
|
+
errors.push({ row, error: e.message });
|
|
23165
|
+
continue;
|
|
23166
|
+
}
|
|
23167
|
+
validated.push({ row, spec, priority, type: spec.type });
|
|
23160
23168
|
}
|
|
23161
|
-
|
|
23162
|
-
if (restored) log(` ${restored}`);
|
|
23163
|
-
return true;
|
|
23169
|
+
return { ok: errors.length === 0, errors, validated };
|
|
23164
23170
|
}
|
|
23165
|
-
async function
|
|
23166
|
-
|
|
23167
|
-
|
|
23171
|
+
async function createIssuesBatch(specs, options, deps = {}) {
|
|
23172
|
+
const ensureLabels = deps.ensureLabels ?? ensureLabelsExist;
|
|
23173
|
+
const client = deps.client ?? defaultGitHubClient();
|
|
23174
|
+
const runCreate = deps.runCreate ?? (async (args) => (await execFileP2("gh", args, { timeout: GH_MUTATION_TIMEOUT_MS })).stdout);
|
|
23175
|
+
const validation = validateBatchSpecs(specs);
|
|
23176
|
+
if (!validation.ok) {
|
|
23177
|
+
const lines = validation.errors.map((e) => ` row ${e.row}: ${e.error}`).join("\n");
|
|
23178
|
+
throw new Error(`batch validation failed (${validation.errors.length} error(s)):
|
|
23179
|
+
${lines}`);
|
|
23168
23180
|
}
|
|
23169
|
-
const
|
|
23170
|
-
|
|
23171
|
-
|
|
23172
|
-
|
|
23173
|
-
|
|
23174
|
-
|
|
23175
|
-
}
|
|
23176
|
-
|
|
23177
|
-
|
|
23178
|
-
|
|
23179
|
-
|
|
23180
|
-
}
|
|
23181
|
-
|
|
23181
|
+
const defaultRepo = await resolveRepo(options.repo);
|
|
23182
|
+
if (!defaultRepo && validation.validated.some((v) => !v.spec.repo)) {
|
|
23183
|
+
throw new Error("could not resolve repo \u2014 pass --repo owner/repo or set repo per row");
|
|
23184
|
+
}
|
|
23185
|
+
const rowRepo = (spec) => spec.repo ?? defaultRepo;
|
|
23186
|
+
const labelsByRepo = /* @__PURE__ */ new Map();
|
|
23187
|
+
for (const { spec } of validation.validated) {
|
|
23188
|
+
if (!spec.labels?.length) continue;
|
|
23189
|
+
const bucket = labelsByRepo.get(rowRepo(spec)) ?? /* @__PURE__ */ new Set();
|
|
23190
|
+
for (const label of spec.labels) bucket.add(label);
|
|
23191
|
+
labelsByRepo.set(rowRepo(spec), bucket);
|
|
23192
|
+
}
|
|
23193
|
+
for (const [repo, labels] of labelsByRepo) await ensureLabels([...labels], repo);
|
|
23194
|
+
const created = [];
|
|
23195
|
+
const failures = [];
|
|
23196
|
+
for (const entry of validation.validated) {
|
|
23197
|
+
const { spec, priority, type } = entry;
|
|
23198
|
+
try {
|
|
23199
|
+
const rowKey = options.idempotencyKey ? rowIdempotencyKey(options.idempotencyKey, spec) : void 0;
|
|
23200
|
+
if (rowKey) {
|
|
23201
|
+
const existing = await findExistingByIdempotencyKey(client, rowKey, rowRepo(spec));
|
|
23202
|
+
if (existing) {
|
|
23203
|
+
created.push({ row: entry.row, ...existing, idempotent: true });
|
|
23204
|
+
continue;
|
|
23205
|
+
}
|
|
23206
|
+
}
|
|
23207
|
+
let body = spec.body ?? "";
|
|
23208
|
+
if (rowKey) {
|
|
23209
|
+
body = appendIdempotencyMarker(body, rowKey);
|
|
23210
|
+
}
|
|
23211
|
+
const rowPriority = spec.priority ? priority : options.priority ?? priority;
|
|
23212
|
+
const args = buildIssueArgs({ type, title: spec.title, body, priority: rowPriority, repo: rowRepo(spec), labels: spec.labels });
|
|
23213
|
+
const swapped = await bodyArgsViaFile(args);
|
|
23214
|
+
let row;
|
|
23215
|
+
try {
|
|
23216
|
+
const stdout = await runCreate(swapped.args);
|
|
23217
|
+
const result = parseCreatedUrl(stdout);
|
|
23218
|
+
row = { row: entry.row, number: result.number, url: result.url };
|
|
23219
|
+
created.push(row);
|
|
23220
|
+
} finally {
|
|
23221
|
+
await swapped.cleanup();
|
|
23222
|
+
}
|
|
23223
|
+
if (deps.attach) {
|
|
23224
|
+
const attached = await deps.attach(row.number, rowRepo(spec), rowPriority);
|
|
23225
|
+
row.onBoard = attached.onBoard;
|
|
23226
|
+
if (attached.projectItemId) row.projectItemId = attached.projectItemId;
|
|
23227
|
+
}
|
|
23228
|
+
const parentRef = spec.parent ?? options.parent;
|
|
23229
|
+
if (parentRef) {
|
|
23230
|
+
try {
|
|
23231
|
+
const run = deps.runGh ?? ghRunner;
|
|
23232
|
+
row.parent = await linkSubIssue(run, parentRef, row.url, rowRepo(spec));
|
|
23233
|
+
} catch (e) {
|
|
23234
|
+
const err = e;
|
|
23235
|
+
row.parentLinkError = (err.stderr || err.message || String(e)).trim();
|
|
23236
|
+
process.stderr.write(`warning: issue #${row.number} created but NOT linked under ${parentRef}: ${row.parentLinkError}
|
|
23237
|
+
`);
|
|
23238
|
+
}
|
|
23239
|
+
}
|
|
23240
|
+
} catch (e) {
|
|
23241
|
+
const err = e;
|
|
23242
|
+
const note = timeoutKillNote(e, GH_MUTATION_TIMEOUT_MS);
|
|
23243
|
+
failures.push({ row: entry.row, error: (err.stderr || err.message || String(e)).trim() + (note ? ` (${note})` : "") });
|
|
23244
|
+
}
|
|
23182
23245
|
}
|
|
23246
|
+
return { created, failures };
|
|
23183
23247
|
}
|
|
23184
|
-
function
|
|
23185
|
-
|
|
23186
|
-
const after = readKnownMarketplacesFile(path2);
|
|
23187
|
-
const next = restoreMarketplacePins(after, pins);
|
|
23188
|
-
if (next === null) return void 0;
|
|
23248
|
+
async function findExistingByIdempotencyKey(client, key, repo) {
|
|
23249
|
+
const marker = idempotencyMarker(key);
|
|
23189
23250
|
try {
|
|
23190
|
-
|
|
23251
|
+
const result = await client.rest(
|
|
23252
|
+
"GET",
|
|
23253
|
+
`search/issues?per_page=5&q=${encodeURIComponent(`repo:${repo} "${marker}" in:body`)}`
|
|
23254
|
+
);
|
|
23255
|
+
const open2 = result?.items?.find((item) => item.state === "open" && item.number);
|
|
23256
|
+
if (!open2?.number) return void 0;
|
|
23257
|
+
return { number: open2.number, url: open2.html_url ?? `https://github.com/${repo}/issues/${open2.number}` };
|
|
23191
23258
|
} catch {
|
|
23192
|
-
return
|
|
23259
|
+
return void 0;
|
|
23193
23260
|
}
|
|
23194
|
-
|
|
23195
|
-
|
|
23196
|
-
|
|
23197
|
-
|
|
23261
|
+
}
|
|
23262
|
+
function parseCloseReason(raw) {
|
|
23263
|
+
if (raw === void 0) return {};
|
|
23264
|
+
const trimmed = raw.trim().toLowerCase().replace(/[\s_-]+/g, "");
|
|
23265
|
+
if (trimmed === "completed" || trimmed === "complete") return { reason: "completed" };
|
|
23266
|
+
if (trimmed === "notplanned" || trimmed === "notplanned") return { reason: "not-planned" };
|
|
23267
|
+
if (trimmed === "duplicateof" || trimmed === "duplicate") return { reason: "duplicate-of" };
|
|
23268
|
+
throw new Error(`unknown close reason "${raw}" \u2014 expected completed, not-planned, or duplicate-of`);
|
|
23269
|
+
}
|
|
23270
|
+
function registerIssueLifecycleCommands(program3, deps = {}) {
|
|
23271
|
+
const issue2 = program3.commands.find((c) => c.name() === "issue");
|
|
23272
|
+
if (!issue2) return;
|
|
23273
|
+
mutating(
|
|
23274
|
+
issue2.command("edit <ref>").description("edit an issue title/body/labels or reparent it (--parent) \u2014 reparenting is impossible via raw gh").option("--title <title>", "new title").option("--body <body>", "new body (markdown)").option("--body-file <path|->", "read new body from a UTF-8 file, or stdin with -").option("--add-label <label...>", "label(s) to add (repeatable)").option("--remove-label <label...>", "label(s) to remove (repeatable)").option("--parent <ref>", "reparent: link under this parent (#123, owner/repo#123, or URL)").option("--repo <owner/repo>", "repo for a bare ref (defaults to the current repo)"),
|
|
23275
|
+
(opts, args) => ({ command: "issue edit", ref: args[0], fields: Object.keys(opts).filter((k) => k !== "repo" && opts[k] !== void 0) })
|
|
23276
|
+
).action(async (ref, o) => {
|
|
23277
|
+
try {
|
|
23278
|
+
const defaultRepo = await resolveRepo(o.repo);
|
|
23279
|
+
const result = await editIssue(defaultGitHubClient(), {
|
|
23280
|
+
ref,
|
|
23281
|
+
defaultRepo,
|
|
23282
|
+
title: o.title,
|
|
23283
|
+
body: o.body,
|
|
23284
|
+
bodyFile: o.bodyFile,
|
|
23285
|
+
addLabel: o.addLabel,
|
|
23286
|
+
removeLabel: o.removeLabel,
|
|
23287
|
+
parent: o.parent
|
|
23288
|
+
});
|
|
23289
|
+
console.log(JSON.stringify(result));
|
|
23290
|
+
} catch (e) {
|
|
23291
|
+
return failGraceful(`issue edit failed: ${e.message}`);
|
|
23292
|
+
}
|
|
23198
23293
|
});
|
|
23199
|
-
|
|
23294
|
+
mutating(
|
|
23295
|
+
issue2.command("close <ref>").description("close an issue and move its board item to Done (--reason completed|not-planned|duplicate-of --duplicate-of <n>)").option("--reason <reason>", "completed | not-planned | duplicate-of (defaults to completed)").option("--duplicate-of <number>", "the issue number this duplicates (use with --reason duplicate-of)").option("--comment <text>", "a closing comment to post").option("--repo <owner/repo>", "repo for a bare ref (defaults to the current repo)"),
|
|
23296
|
+
(opts, args) => {
|
|
23297
|
+
let reason;
|
|
23298
|
+
try {
|
|
23299
|
+
reason = parseCloseReason(opts.reason).reason;
|
|
23300
|
+
} catch (e) {
|
|
23301
|
+
fail(`issue close: ${e.message}`, {
|
|
23302
|
+
code: ERROR_CODES.ERR_BAD_ENUM,
|
|
23303
|
+
offending_flag: "--reason",
|
|
23304
|
+
expected: ["completed", "not-planned", "duplicate-of"]
|
|
23305
|
+
});
|
|
23306
|
+
}
|
|
23307
|
+
if (reason === "duplicate-of" && !opts.duplicateOf) {
|
|
23308
|
+
fail("issue close: --reason duplicate-of requires --duplicate-of <number>", {
|
|
23309
|
+
code: ERROR_CODES.ERR_MISSING_FLAG,
|
|
23310
|
+
offending_flag: "--duplicate-of"
|
|
23311
|
+
});
|
|
23312
|
+
}
|
|
23313
|
+
return { command: "issue close", ref: args[0], reason: reason ?? "completed", duplicateOf: opts.duplicateOf };
|
|
23314
|
+
}
|
|
23315
|
+
).action(async (ref, o) => {
|
|
23316
|
+
const parsed = parseCloseReason(o.reason);
|
|
23317
|
+
try {
|
|
23318
|
+
const defaultRepo = await resolveRepo(o.repo);
|
|
23319
|
+
const result = await closeIssue(defaultGitHubClient(), {
|
|
23320
|
+
ref,
|
|
23321
|
+
defaultRepo,
|
|
23322
|
+
reason: parsed.reason,
|
|
23323
|
+
duplicateOf: o.duplicateOf ? Number(o.duplicateOf) : void 0,
|
|
23324
|
+
comment: o.comment
|
|
23325
|
+
});
|
|
23326
|
+
console.log(JSON.stringify(result));
|
|
23327
|
+
} catch (e) {
|
|
23328
|
+
return failGraceful(`issue close failed: ${e.message}`);
|
|
23329
|
+
}
|
|
23330
|
+
});
|
|
23331
|
+
mutating(
|
|
23332
|
+
issue2.command("reopen <ref>").description("reopen a closed issue and move its board item back to Todo").option("--repo <owner/repo>", "repo for a bare ref (defaults to the current repo)"),
|
|
23333
|
+
(_opts, args) => ({ command: "issue reopen", ref: args[0] })
|
|
23334
|
+
).action(async (ref, o) => {
|
|
23335
|
+
try {
|
|
23336
|
+
const defaultRepo = await resolveRepo(o.repo);
|
|
23337
|
+
const result = await reopenIssue(defaultGitHubClient(), ref, defaultRepo);
|
|
23338
|
+
console.log(JSON.stringify(result));
|
|
23339
|
+
} catch (e) {
|
|
23340
|
+
return failGraceful(`issue reopen failed: ${e.message}`);
|
|
23341
|
+
}
|
|
23342
|
+
});
|
|
23343
|
+
mutating(
|
|
23344
|
+
issue2.command("assign <ref> <login>").description("assign a user to an issue \u2014 pure ownership, no board Status change (unlike board claim)").option("--repo <owner/repo>", "repo for a bare ref (defaults to the current repo)"),
|
|
23345
|
+
(_opts, args) => ({ command: "issue assign", ref: args[0], login: args[1] })
|
|
23346
|
+
).action(async (ref, login, o) => {
|
|
23347
|
+
try {
|
|
23348
|
+
const defaultRepo = await resolveRepo(o.repo);
|
|
23349
|
+
const result = await assignIssue(defaultGitHubClient(), ref, login, defaultRepo);
|
|
23350
|
+
console.log(JSON.stringify(result));
|
|
23351
|
+
} catch (e) {
|
|
23352
|
+
return failGraceful(`issue assign failed: ${e.message}`);
|
|
23353
|
+
}
|
|
23354
|
+
});
|
|
23355
|
+
mutating(
|
|
23356
|
+
issue2.command("unassign <ref> <login>").description("remove a user from an issue \u2014 pure ownership, no board Status change").option("--repo <owner/repo>", "repo for a bare ref (defaults to the current repo)"),
|
|
23357
|
+
(_opts, args) => ({ command: "issue unassign", ref: args[0], login: args[1] })
|
|
23358
|
+
).action(async (ref, login, o) => {
|
|
23359
|
+
try {
|
|
23360
|
+
const defaultRepo = await resolveRepo(o.repo);
|
|
23361
|
+
const result = await unassignIssue(defaultGitHubClient(), ref, login, defaultRepo);
|
|
23362
|
+
console.log(JSON.stringify(result));
|
|
23363
|
+
} catch (e) {
|
|
23364
|
+
return failGraceful(`issue unassign failed: ${e.message}`);
|
|
23365
|
+
}
|
|
23366
|
+
});
|
|
23367
|
+
mutating(
|
|
23368
|
+
issue2.command("relocate <ref>").description("transfer an issue to another repo, sweep the ghost board item, add to the destination board, and carry priority").requiredOption("--to <owner/repo>", "destination repository").option("--repo <owner/repo>", "source repo for a bare ref (defaults to the current repo)"),
|
|
23369
|
+
(opts, args) => ({ command: "issue relocate", ref: args[0], to: opts.to })
|
|
23370
|
+
).action(async (ref, o) => {
|
|
23371
|
+
try {
|
|
23372
|
+
const defaultRepo = await resolveRepo(o.repo);
|
|
23373
|
+
const result = await relocateIssue(defaultGitHubClient(), ref, o.to, defaultRepo);
|
|
23374
|
+
console.log(JSON.stringify(result));
|
|
23375
|
+
} catch (e) {
|
|
23376
|
+
return failGraceful(`issue relocate failed: ${e.message}`);
|
|
23377
|
+
}
|
|
23378
|
+
});
|
|
23379
|
+
mutating(
|
|
23380
|
+
issue2.command("unlink-child <parent> <child>").description("unlink a child issue from its parent (inverse of link-child) and print {parentNumber,subIssueNumber,totalCount} JSON").option("--repo <owner/repo>", "repo for bare refs on either side (defaults to the current repo)"),
|
|
23381
|
+
(_opts, args) => ({ command: "issue unlink-child", parent: args[0], child: args[1] })
|
|
23382
|
+
).action(async (parentRef, childRef, o) => {
|
|
23383
|
+
const defaultRepo = await resolveRepo(o.repo);
|
|
23384
|
+
try {
|
|
23385
|
+
const result = await unlinkSubIssue(ghRunner, parentRef, childRef, defaultRepo);
|
|
23386
|
+
console.log(JSON.stringify(result));
|
|
23387
|
+
} catch (e) {
|
|
23388
|
+
const err = e;
|
|
23389
|
+
const note = timeoutKillNote(e, GH_MUTATION_TIMEOUT_MS);
|
|
23390
|
+
return failGraceful(`issue unlink-child: ${(err.stderr || err.message || String(e)).trim()}${note ? ` (${note})` : ""}`);
|
|
23391
|
+
}
|
|
23392
|
+
});
|
|
23393
|
+
extendCreateCommand(issue2, deps.attach);
|
|
23200
23394
|
}
|
|
23201
|
-
|
|
23202
|
-
|
|
23203
|
-
|
|
23204
|
-
|
|
23205
|
-
|
|
23395
|
+
function extendCreateCommand(issue2, batchAttach) {
|
|
23396
|
+
const createCmd = issue2.commands.find((c) => c.name() === "create");
|
|
23397
|
+
if (!createCmd) return;
|
|
23398
|
+
createCmd.option("--batch <file.json>", "create multiple issues from a JSON array file (pre-validates all rows before creating any)").option("--idempotency-key <key>", "create-if-not-exists: skip creation when an issue with this key already exists (retried loops must not duplicate)");
|
|
23399
|
+
const internal = createCmd;
|
|
23400
|
+
const originalListener = internal._actionHandler;
|
|
23401
|
+
internal._actionHandler = async (args) => {
|
|
23402
|
+
const opts = createCmd.opts();
|
|
23403
|
+
if (opts.batch) {
|
|
23404
|
+
let specs;
|
|
23405
|
+
try {
|
|
23406
|
+
const raw = (0, import_node_fs29.readFileSync)(opts.batch, "utf8");
|
|
23407
|
+
specs = JSON.parse(raw);
|
|
23408
|
+
if (!Array.isArray(specs)) throw new Error("batch file must contain a JSON array");
|
|
23409
|
+
} catch (e) {
|
|
23410
|
+
return fail(`issue create --batch: cannot read/parse ${opts.batch}: ${e.message}`);
|
|
23411
|
+
}
|
|
23412
|
+
const validation = validateBatchSpecs(specs);
|
|
23413
|
+
if (!validation.ok) {
|
|
23414
|
+
const lines = validation.errors.map((e) => ` row ${e.row}: ${e.error}`).join("\n");
|
|
23415
|
+
return fail(`issue create --batch: validation failed (${validation.errors.length} error(s)):
|
|
23416
|
+
${lines}`);
|
|
23417
|
+
}
|
|
23418
|
+
const batchParent = opts.parent;
|
|
23419
|
+
const batchPriority = opts.priority ? normalizePriority(opts.priority) : void 0;
|
|
23420
|
+
if (opts.dryRun || opts.validateOnly) {
|
|
23421
|
+
const planned = validation.validated.map((v) => ({
|
|
23422
|
+
row: v.row,
|
|
23423
|
+
type: v.type,
|
|
23424
|
+
title: v.spec.title,
|
|
23425
|
+
priority: v.spec.priority ? v.priority : batchPriority ?? v.priority,
|
|
23426
|
+
repo: v.spec.repo,
|
|
23427
|
+
...v.spec.parent ?? batchParent ? { parent: v.spec.parent ?? batchParent } : {}
|
|
23428
|
+
}));
|
|
23429
|
+
console.log(JSON.stringify(opts.validateOnly ? { ok: true, planned } : { dry_run: true, planned }));
|
|
23430
|
+
return;
|
|
23431
|
+
}
|
|
23432
|
+
try {
|
|
23433
|
+
const result = await createIssuesBatch(specs, {
|
|
23434
|
+
repo: opts.repo,
|
|
23435
|
+
idempotencyKey: opts.idempotencyKey,
|
|
23436
|
+
parent: batchParent,
|
|
23437
|
+
priority: batchPriority
|
|
23438
|
+
}, { attach: batchAttach });
|
|
23439
|
+
console.log(JSON.stringify(result));
|
|
23440
|
+
if (result.failures.length) process.exitCode = 1;
|
|
23441
|
+
} catch (e) {
|
|
23442
|
+
return failGraceful(`issue create --batch failed: ${e.message}`);
|
|
23443
|
+
}
|
|
23206
23444
|
return;
|
|
23207
23445
|
}
|
|
23208
|
-
|
|
23209
|
-
|
|
23210
|
-
|
|
23211
|
-
|
|
23212
|
-
|
|
23213
|
-
|
|
23214
|
-
|
|
23215
|
-
|
|
23216
|
-
|
|
23217
|
-
}
|
|
23218
|
-
|
|
23219
|
-
|
|
23220
|
-
|
|
23221
|
-
return;
|
|
23222
|
-
}
|
|
23223
|
-
const descriptor = PLUGIN_SURFACE_HEAL.claude;
|
|
23224
|
-
const healed = await applyPluginHeal(surface, console.log, { force: true });
|
|
23225
|
-
if (healed) {
|
|
23226
|
-
console.log(` \u2713 MMI plugin reinstalled \u2014 ${reloadAction(surface)} to load MMI commands`);
|
|
23227
|
-
} else {
|
|
23228
|
-
const refSupported = await marketplaceAddRefSupported("claude");
|
|
23229
|
-
const recovery = refSupported ? descriptor.recovery : recoveryWithoutRef(descriptor.recovery);
|
|
23230
|
-
const note = refAbsenceNote("claude", refSupported);
|
|
23231
|
-
console.log(` \u2717 Auto-heal failed or was skipped. Run manually:
|
|
23232
|
-
${recovery}${note}${pluginReadGrantNote()}`);
|
|
23233
|
-
}
|
|
23446
|
+
if (opts.idempotencyKey && !opts.dryRun && !opts.validateOnly) {
|
|
23447
|
+
try {
|
|
23448
|
+
const repo = await resolveRepo(opts.repo);
|
|
23449
|
+
if (repo) {
|
|
23450
|
+
const existing = await findExistingByIdempotencyKey(defaultGitHubClient(), opts.idempotencyKey, repo);
|
|
23451
|
+
if (existing) {
|
|
23452
|
+
console.log(JSON.stringify({ ...existing, idempotent: true }));
|
|
23453
|
+
return;
|
|
23454
|
+
}
|
|
23455
|
+
}
|
|
23456
|
+
} catch {
|
|
23457
|
+
}
|
|
23458
|
+
}
|
|
23459
|
+
return originalListener?.(args);
|
|
23460
|
+
};
|
|
23234
23461
|
}
|
|
23235
23462
|
|
|
23463
|
+
// src/train-commands.ts
|
|
23464
|
+
var import_node_fs30 = require("node:fs");
|
|
23465
|
+
var import_node_path28 = require("node:path");
|
|
23466
|
+
|
|
23236
23467
|
// src/train-status.ts
|
|
23237
23468
|
function buildTrainStatusReport(input) {
|
|
23238
23469
|
const releaseTrack = resolveReleaseTrack(input.meta, void 0, input.repo);
|
|
@@ -23271,7 +23502,7 @@ function formatTrainStatus(r) {
|
|
|
23271
23502
|
// src/train-commands.ts
|
|
23272
23503
|
function readRepoVersion() {
|
|
23273
23504
|
try {
|
|
23274
|
-
return JSON.parse((0,
|
|
23505
|
+
return JSON.parse((0, import_node_fs30.readFileSync)((0, import_node_path28.join)(process.cwd(), ".claude-plugin", "plugin.json"), "utf8")).version || void 0;
|
|
23275
23506
|
} catch {
|
|
23276
23507
|
return void 0;
|
|
23277
23508
|
}
|
|
@@ -23417,9 +23648,9 @@ function registerDeployCommands(program3) {
|
|
|
23417
23648
|
}
|
|
23418
23649
|
|
|
23419
23650
|
// src/discovery-commands.ts
|
|
23420
|
-
var
|
|
23421
|
-
var
|
|
23422
|
-
var
|
|
23651
|
+
var import_node_fs31 = require("node:fs");
|
|
23652
|
+
var import_node_os8 = require("node:os");
|
|
23653
|
+
var import_node_path29 = require("node:path");
|
|
23423
23654
|
var GC_GH_TIMEOUT_MS3 = 2e4;
|
|
23424
23655
|
async function collectStatus() {
|
|
23425
23656
|
let branch = "";
|
|
@@ -23594,10 +23825,10 @@ async function collectOnboardStatus() {
|
|
|
23594
23825
|
else if (top) nextCommand = `mmi-cli board claim ${top.number} # ${top.title}`;
|
|
23595
23826
|
else nextCommand = "mmi-cli board read \u2014 no claimable items found";
|
|
23596
23827
|
}
|
|
23597
|
-
const home = (0,
|
|
23828
|
+
const home = (0, import_node_os8.homedir)();
|
|
23598
23829
|
const plugin = onboardPluginGate({
|
|
23599
|
-
readKnown: () => readFileSyncSafe((0,
|
|
23600
|
-
readSettings: () => readFileSyncSafe((0,
|
|
23830
|
+
readKnown: () => readFileSyncSafe((0, import_node_path29.join)(home, ...KNOWN_MARKETPLACES_RELATIVE), import_node_fs31.readFileSync),
|
|
23831
|
+
readSettings: () => readFileSyncSafe((0, import_node_path29.join)(home, ".claude", "settings.json"), import_node_fs31.readFileSync)
|
|
23601
23832
|
});
|
|
23602
23833
|
return { track, board, registry: registry2, secrets, plugin, nextCommand };
|
|
23603
23834
|
}
|
|
@@ -25017,9 +25248,9 @@ function ghAccountCaveat(announcedLogin, accounts) {
|
|
|
25017
25248
|
}
|
|
25018
25249
|
|
|
25019
25250
|
// src/doctor-io.ts
|
|
25020
|
-
var
|
|
25021
|
-
var
|
|
25022
|
-
var
|
|
25251
|
+
var import_node_fs32 = require("node:fs");
|
|
25252
|
+
var import_node_os9 = require("node:os");
|
|
25253
|
+
var import_node_path30 = require("node:path");
|
|
25023
25254
|
var import_node_child_process12 = require("node:child_process");
|
|
25024
25255
|
var import_node_util8 = require("node:util");
|
|
25025
25256
|
var execFileP6 = (0, import_node_util8.promisify)(import_node_child_process12.execFile);
|
|
@@ -25027,7 +25258,7 @@ var MMI_PLUGIN_ID2 = "mmi@mutmutco";
|
|
|
25027
25258
|
function installedClaudePluginVersion() {
|
|
25028
25259
|
try {
|
|
25029
25260
|
const file = JSON.parse(
|
|
25030
|
-
(0,
|
|
25261
|
+
(0, import_node_fs32.readFileSync)((0, import_node_path30.join)((0, import_node_os9.homedir)(), ".claude", "plugins", "installed_plugins.json"), "utf8")
|
|
25031
25262
|
);
|
|
25032
25263
|
const versions = (file.plugins?.[MMI_PLUGIN_ID2] ?? []).map((r) => r.version).filter((v) => Boolean(v));
|
|
25033
25264
|
if (versions.length === 0) return void 0;
|
|
@@ -25048,13 +25279,13 @@ function worktreeRootSync() {
|
|
|
25048
25279
|
}
|
|
25049
25280
|
var gitignorePath = () => {
|
|
25050
25281
|
const root = worktreeRootSync();
|
|
25051
|
-
return root === null ? null : (0,
|
|
25282
|
+
return root === null ? null : (0, import_node_path30.join)(root, ".gitignore");
|
|
25052
25283
|
};
|
|
25053
25284
|
function readGitignore() {
|
|
25054
25285
|
const path2 = gitignorePath();
|
|
25055
25286
|
if (path2 === null) return null;
|
|
25056
25287
|
try {
|
|
25057
|
-
return (0,
|
|
25288
|
+
return (0, import_node_fs32.readFileSync)(path2, "utf8");
|
|
25058
25289
|
} catch {
|
|
25059
25290
|
return null;
|
|
25060
25291
|
}
|
|
@@ -25063,7 +25294,7 @@ function writeGitignore(content) {
|
|
|
25063
25294
|
const path2 = gitignorePath();
|
|
25064
25295
|
if (path2 === null) return false;
|
|
25065
25296
|
try {
|
|
25066
|
-
(0,
|
|
25297
|
+
(0, import_node_fs32.writeFileSync)(path2, content, "utf8");
|
|
25067
25298
|
return true;
|
|
25068
25299
|
} catch {
|
|
25069
25300
|
return false;
|
|
@@ -25087,7 +25318,7 @@ async function repoRoot() {
|
|
|
25087
25318
|
}
|
|
25088
25319
|
function hasRepoLocalWorktrees() {
|
|
25089
25320
|
const root = worktreeRootSync();
|
|
25090
|
-
return root !== null && (0,
|
|
25321
|
+
return root !== null && (0, import_node_fs32.existsSync)((0, import_node_path30.join)(root, ".worktrees"));
|
|
25091
25322
|
}
|
|
25092
25323
|
|
|
25093
25324
|
// src/index.ts
|
|
@@ -25122,8 +25353,8 @@ ${r.stderr ?? ""}`).catch(() => "");
|
|
|
25122
25353
|
function ghMultiAccountCaveat(announcedLogin) {
|
|
25123
25354
|
try {
|
|
25124
25355
|
const hostsPath = ghHostsConfigPath(process.env, process.platform);
|
|
25125
|
-
if (!hostsPath || !(0,
|
|
25126
|
-
return ghAccountCaveat(announcedLogin, parseGhHostsAccounts((0,
|
|
25356
|
+
if (!hostsPath || !(0, import_node_fs33.existsSync)(hostsPath)) return void 0;
|
|
25357
|
+
return ghAccountCaveat(announcedLogin, parseGhHostsAccounts((0, import_node_fs33.readFileSync)(hostsPath, "utf8")));
|
|
25127
25358
|
} catch {
|
|
25128
25359
|
return void 0;
|
|
25129
25360
|
}
|
|
@@ -25131,12 +25362,12 @@ function ghMultiAccountCaveat(announcedLogin) {
|
|
|
25131
25362
|
var ENV_HEAL_LOCK_STALE_MS = 10 * 6e4;
|
|
25132
25363
|
var ENV_HEAL_LOCK_MAX_WAIT_MS = 2 * 6e4;
|
|
25133
25364
|
function envHealLockPath(home) {
|
|
25134
|
-
return (0,
|
|
25365
|
+
return (0, import_node_path31.join)(home, ".claude", "plugins", ".mmi-env-heal.lock");
|
|
25135
25366
|
}
|
|
25136
25367
|
async function withEnvHealLock(what, run) {
|
|
25137
25368
|
try {
|
|
25138
25369
|
return await withFileLock(
|
|
25139
|
-
envHealLockPath((0,
|
|
25370
|
+
envHealLockPath((0, import_node_os10.homedir)()),
|
|
25140
25371
|
{ staleMs: ENV_HEAL_LOCK_STALE_MS, maxWaitMs: ENV_HEAL_LOCK_MAX_WAIT_MS, label: "mmi env-heal lock" },
|
|
25141
25372
|
run
|
|
25142
25373
|
);
|
|
@@ -25231,9 +25462,9 @@ function mmiDoctorDeps(opts = {}) {
|
|
|
25231
25462
|
},
|
|
25232
25463
|
pluginCache: () => {
|
|
25233
25464
|
const plan = buildPluginCachePlan(
|
|
25234
|
-
(0,
|
|
25465
|
+
(0, import_node_os10.homedir)(),
|
|
25235
25466
|
runningPluginVersion(process.env, resolveClientVersion()),
|
|
25236
|
-
pluginCacheFsDeps((0,
|
|
25467
|
+
pluginCacheFsDeps((0, import_node_os10.homedir)(), () => 0)
|
|
25237
25468
|
);
|
|
25238
25469
|
return {
|
|
25239
25470
|
stale: plan.prune,
|
|
@@ -25279,11 +25510,11 @@ function mmiDoctorDeps(opts = {}) {
|
|
|
25279
25510
|
// which branch it would pick one up from. Two local file reads, no network, fail-soft to no rows.
|
|
25280
25511
|
marketplaceRows: () => {
|
|
25281
25512
|
try {
|
|
25282
|
-
const home = (0,
|
|
25513
|
+
const home = (0, import_node_os10.homedir)();
|
|
25283
25514
|
return marketplaceRows(
|
|
25284
25515
|
MMI_MARKETPLACE_NAME,
|
|
25285
|
-
readFileSyncSafe((0,
|
|
25286
|
-
readFileSyncSafe((0,
|
|
25516
|
+
readFileSyncSafe((0, import_node_path31.join)(home, ...KNOWN_MARKETPLACES_RELATIVE), import_node_fs33.readFileSync),
|
|
25517
|
+
readFileSyncSafe((0, import_node_path31.join)(home, ".claude", "settings.json"), import_node_fs33.readFileSync)
|
|
25287
25518
|
);
|
|
25288
25519
|
} catch {
|
|
25289
25520
|
return [];
|
|
@@ -25413,19 +25644,19 @@ program2.hook("preAction", async (_thisCommand, actionCommand) => {
|
|
|
25413
25644
|
});
|
|
25414
25645
|
var rules = program2.command("rules").description("org-managed .gitignore delivery");
|
|
25415
25646
|
rules.command("gitignore").option("--write", "upsert the managed block into .gitignore (default: check only, non-zero exit on drift)").option("--json", "machine-readable output").description("verify (or --write) this repo's org-managed .gitignore block matches the SSOT").action((opts) => {
|
|
25416
|
-
const path2 = (0,
|
|
25417
|
-
const current = (0,
|
|
25647
|
+
const path2 = (0, import_node_path31.join)(process.cwd(), ".gitignore");
|
|
25648
|
+
const current = (0, import_node_fs33.existsSync)(path2) ? (0, import_node_fs33.readFileSync)(path2, "utf8") : null;
|
|
25418
25649
|
const plan = planManagedGitignore(current);
|
|
25419
25650
|
const drift = [...plan.added.map((l) => `+${l}`), ...plan.removed.map((l) => `-${l}`)].join(", ") || "block normalize";
|
|
25420
25651
|
if (opts.json) {
|
|
25421
|
-
if (opts.write && plan.changed) (0,
|
|
25652
|
+
if (opts.write && plan.changed) (0, import_node_fs33.writeFileSync)(path2, plan.content, "utf8");
|
|
25422
25653
|
console.log(JSON.stringify(plan, null, 2));
|
|
25423
25654
|
if (!opts.write && plan.changed) process.exitCode = 1;
|
|
25424
25655
|
return;
|
|
25425
25656
|
}
|
|
25426
25657
|
if (opts.write) {
|
|
25427
25658
|
if (plan.changed) {
|
|
25428
|
-
(0,
|
|
25659
|
+
(0, import_node_fs33.writeFileSync)(path2, plan.content, "utf8");
|
|
25429
25660
|
console.log(`mmi-cli org rules gitignore: updated .gitignore (${drift})`);
|
|
25430
25661
|
} else {
|
|
25431
25662
|
console.log("mmi-cli org rules gitignore: up to date");
|
|
@@ -25535,7 +25766,7 @@ gcCmd.command("sweep-deferred").description("retry IDE-locked deferred worktree
|
|
|
25535
25766
|
process.exit(process.exitCode ?? 0);
|
|
25536
25767
|
});
|
|
25537
25768
|
});
|
|
25538
|
-
gcCmd.option("--dry-run", "show what would be deleted (default)").option("--apply", "delete only the listed clean merged/closed PR local+remote branches, linked worktrees, and stale tracking refs").option("--json", "machine-readable output").option("--scratch", "prune safe local scratch (#1864) instead of git branches/refs; local plans are surfaced advisory-only, never auto-pruned").option("--remote <name>", "remote name", "origin").option("--limit <n>", "PRs to inspect per state", "200").option("--root <path>", "sweep an explicitly named worktrees root instead of the authoritative ../mmi-worktrees (#3471) \u2014 descends into this repo container when present; ownership, dead-dir, and content guards still apply").action(async (o) => {
|
|
25769
|
+
gcCmd.option("--dry-run", "show what would be deleted (default)").option("--apply", "delete only the listed clean merged/closed PR local+remote branches, linked worktrees, and stale tracking refs").option("--json", "machine-readable output").option("--scratch", "prune safe local scratch (#1864) instead of git branches/refs; local plans are surfaced advisory-only, never auto-pruned").option("--remote <name>", "remote name", "origin").option("--limit <n>", "PRs to inspect per state", "200").option("--force", "remove worktrees even when the ownership registry shows another session created or worked in them recently (#3580)").option("--root <path>", "sweep an explicitly named worktrees root instead of the authoritative ../mmi-worktrees (#3471) \u2014 descends into this repo container when present; ownership, dead-dir, and content guards still apply").action(async (o) => {
|
|
25539
25770
|
if (o.apply && o.dryRun) return fail("worktree gc: choose either --dry-run or --apply");
|
|
25540
25771
|
if (o.scratch) {
|
|
25541
25772
|
try {
|
|
@@ -25551,8 +25782,8 @@ gcCmd.option("--dry-run", "show what would be deleted (default)").option("--appl
|
|
|
25551
25782
|
if (!Number.isFinite(limit) || limit < 1) return fail("worktree gc: --limit must be a positive integer");
|
|
25552
25783
|
let root;
|
|
25553
25784
|
if (o.root !== void 0) {
|
|
25554
|
-
root = (0,
|
|
25555
|
-
if (!(0,
|
|
25785
|
+
root = (0, import_node_path31.resolve)(o.root);
|
|
25786
|
+
if (!(0, import_node_fs33.existsSync)(root) || !(0, import_node_fs33.statSync)(root).isDirectory()) return fail(`worktree gc: --root ${o.root} is not a directory`);
|
|
25556
25787
|
const gcRepoRoot = (await execFileP2("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim() || process.cwd();
|
|
25557
25788
|
if (isPathUnderDirectory(gcRepoRoot, root)) {
|
|
25558
25789
|
return fail(`worktree gc: --root ${root} contains this checkout \u2014 name a worktrees root, not the repo or an ancestor of it`);
|
|
@@ -25572,7 +25803,7 @@ gcCmd.option("--dry-run", "show what would be deleted (default)").option("--appl
|
|
|
25572
25803
|
// git fsmonitor daemon from inheriting this sweep's stdio pipe and wedging the git call on Windows.
|
|
25573
25804
|
worktreeRemoveDeps(async (args) => (await execFileP2("git", ["-c", "core.fsmonitor=false", ...args], { timeout: GIT_TIMEOUT_MS })).stdout)
|
|
25574
25805
|
).catch(() => void 0);
|
|
25575
|
-
applyResult = await applyGcPlan(plan, o.remote, { root });
|
|
25806
|
+
applyResult = await applyGcPlan(plan, o.remote, { root, force: o.force });
|
|
25576
25807
|
}
|
|
25577
25808
|
if (o.json) {
|
|
25578
25809
|
console.log(JSON.stringify({ dryRun: !o.apply, remote: o.remote, ...root ? { root } : {}, plan, applyResult }, null, 2));
|
|
@@ -25616,6 +25847,18 @@ function runWorktreeInstall(command, cwd, quiet) {
|
|
|
25616
25847
|
async function primaryCheckoutRoot(from) {
|
|
25617
25848
|
return primaryCheckoutRootOf(async (args) => (await execFileP2("git", ["-C", from, ...args], { timeout: GIT_TIMEOUT_MS })).stdout);
|
|
25618
25849
|
}
|
|
25850
|
+
async function unprovenWorktreeReason(wtPath, repoRoot2) {
|
|
25851
|
+
if (!(0, import_node_fs33.existsSync)(wtPath)) return `${wtPath} does not exist on disk`;
|
|
25852
|
+
const porcelain = (await execFileP2("git", ["-C", repoRoot2, "worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout;
|
|
25853
|
+
const registered = parseWorktreePorcelainEntries(porcelain);
|
|
25854
|
+
if (!registered.length) {
|
|
25855
|
+
return `'git worktree list' could not be read from ${repoRoot2}, so ${wtPath} could not be proven registered`;
|
|
25856
|
+
}
|
|
25857
|
+
if (!registered.some((w) => samePath(w.path, wtPath))) {
|
|
25858
|
+
return `${wtPath} exists on disk but 'git worktree list' does not name it \u2014 its git metadata is gone, so it is not a usable worktree`;
|
|
25859
|
+
}
|
|
25860
|
+
return void 0;
|
|
25861
|
+
}
|
|
25619
25862
|
function makeProvisionDeps(worktreeRoot, quiet, log) {
|
|
25620
25863
|
return {
|
|
25621
25864
|
runInstall: (command, cwd) => runWorktreeInstall(command, cwd, quiet),
|
|
@@ -25626,26 +25869,26 @@ function makeProvisionDeps(worktreeRoot, quiet, log) {
|
|
|
25626
25869
|
function acquireWorktreeSetupLock(worktreeRoot) {
|
|
25627
25870
|
const lockPath = repoRuntimeStatePath(worktreeRoot, "worktree-setup.lock");
|
|
25628
25871
|
const take = () => {
|
|
25629
|
-
const fd = (0,
|
|
25872
|
+
const fd = (0, import_node_fs33.openSync)(lockPath, "wx");
|
|
25630
25873
|
try {
|
|
25631
|
-
(0,
|
|
25874
|
+
(0, import_node_fs33.writeSync)(fd, String(Date.now()));
|
|
25632
25875
|
} finally {
|
|
25633
|
-
(0,
|
|
25876
|
+
(0, import_node_fs33.closeSync)(fd);
|
|
25634
25877
|
}
|
|
25635
25878
|
return () => {
|
|
25636
25879
|
try {
|
|
25637
|
-
(0,
|
|
25880
|
+
(0, import_node_fs33.rmSync)(lockPath, { force: true });
|
|
25638
25881
|
} catch {
|
|
25639
25882
|
}
|
|
25640
25883
|
};
|
|
25641
25884
|
};
|
|
25642
25885
|
try {
|
|
25643
|
-
(0,
|
|
25886
|
+
(0, import_node_fs33.mkdirSync)((0, import_node_path31.dirname)(lockPath), { recursive: true });
|
|
25644
25887
|
return take();
|
|
25645
25888
|
} catch {
|
|
25646
25889
|
try {
|
|
25647
|
-
if (Date.now() - (0,
|
|
25648
|
-
(0,
|
|
25890
|
+
if (Date.now() - (0, import_node_fs33.statSync)(lockPath).mtimeMs > WORKTREE_SETUP_LOCK_TTL_MS) {
|
|
25891
|
+
(0, import_node_fs33.rmSync)(lockPath, { force: true });
|
|
25649
25892
|
return take();
|
|
25650
25893
|
}
|
|
25651
25894
|
} catch {
|
|
@@ -25658,6 +25901,7 @@ withExamples(mutating(
|
|
|
25658
25901
|
worktree.command("create <branch-or-issue>").description("create a worktree from a base ref and provision it (install deps + copy local-only config); an issue ref derives the <issue>-<slug> branch, and --claim claims its board item (#2996, formerly worktree new)").option("--from <ref>", "base ref to branch from", "origin/development").option("--base <ref>", "alias of --from (git's own word for the base ref)").option("--path <path>", "worktree path (default: ../mmi-worktrees/<RepoName>/<branch> \u2014 authoritative; the <RepoName> segment is what makes ownership provable from the path, #3471. gc/list read this root in BOTH the nested and the legacy flat shape)").option("--remote <name>", "remote to fetch when --from is a <remote>/<branch> ref", "origin").option("--slug <slug>", "issue-ref form: override the slug derived from the issue title").option("--claim", "issue-ref form: claim the issue's board item after provisioning").option("--for <login>", "with --claim: assign the board item to this login instead of @me"),
|
|
25659
25902
|
worktreeCreatePlan
|
|
25660
25903
|
).action(async (target, o, cmd) => {
|
|
25904
|
+
let step = "resolve the branch name";
|
|
25661
25905
|
try {
|
|
25662
25906
|
if (o.base !== void 0 && cmd.getOptionValueSource("from") === "cli" && o.base !== o.from) {
|
|
25663
25907
|
return fail(`worktree create: --from and --base are the same option (--base is an alias); got --from ${o.from} and --base ${o.base} \u2014 pass one`);
|
|
@@ -25681,10 +25925,12 @@ withExamples(mutating(
|
|
|
25681
25925
|
const repoRoot2 = await primaryCheckoutRoot(process.cwd()) ?? process.cwd();
|
|
25682
25926
|
const wtPath = o.path ?? defaultWorktreePath(repoRoot2, branch);
|
|
25683
25927
|
const { base, fetchBranch } = resolveWorktreeBase(fromRef, o.remote);
|
|
25928
|
+
step = `fetch the base ref ${fromRef}`;
|
|
25684
25929
|
if (fetchBranch) {
|
|
25685
25930
|
const fetchErr = await execFileP2("git", ["fetch", o.remote, fetchBranch], { timeout: GH_MUTATION_TIMEOUT_MS }).then(() => void 0).catch((e) => (e instanceof Error ? e.message : String(e)).split("\n")[0]);
|
|
25686
25931
|
if (fetchErr) console.error(` warning: could not fetch ${o.remote}/${fetchBranch} (${fetchErr}); base ${base} may be stale`);
|
|
25687
25932
|
}
|
|
25933
|
+
step = `git worktree add ${wtPath}`;
|
|
25688
25934
|
await addWorktreeRobust(wtPath, branch, base, {
|
|
25689
25935
|
git: async (args) => (await execFileP2("git", args, { timeout: GH_MUTATION_TIMEOUT_MS })).stdout,
|
|
25690
25936
|
revParse: async (ref) => {
|
|
@@ -25700,11 +25946,13 @@ withExamples(mutating(
|
|
|
25700
25946
|
if (!o.json) console.error(` ${m}`);
|
|
25701
25947
|
}
|
|
25702
25948
|
});
|
|
25949
|
+
step = "install deps + copy local-only config";
|
|
25703
25950
|
const report = await provisionWorktree(wtPath, makeProvisionDeps(wtPath, Boolean(o.json), (m) => {
|
|
25704
25951
|
if (!o.json) console.error(` ${m}`);
|
|
25705
25952
|
}));
|
|
25706
25953
|
let claim;
|
|
25707
25954
|
let claimError;
|
|
25955
|
+
step = "claim the board item";
|
|
25708
25956
|
if (issueForm && o.claim && selector) {
|
|
25709
25957
|
try {
|
|
25710
25958
|
const claimRaw = await selfShell(
|
|
@@ -25716,6 +25964,21 @@ withExamples(mutating(
|
|
|
25716
25964
|
claimError = e.message.split("\n")[0];
|
|
25717
25965
|
}
|
|
25718
25966
|
}
|
|
25967
|
+
const unproven = await unprovenWorktreeReason(wtPath, repoRoot2);
|
|
25968
|
+
if (unproven) {
|
|
25969
|
+
const detail = `worktree create: reported success but ${unproven}. The create either failed silently or the worktree was removed after it ran (MMI-Hub#3580). Re-run this command; if it recurs, check \`mmi-cli worktree events\` for what removed it.`;
|
|
25970
|
+
if (o.json) {
|
|
25971
|
+
console.log(JSON.stringify({ ok: false, branch, path: wtPath, base, error: detail }, null, 2));
|
|
25972
|
+
} else {
|
|
25973
|
+
console.error(detail);
|
|
25974
|
+
}
|
|
25975
|
+
process.exitCode = 1;
|
|
25976
|
+
return;
|
|
25977
|
+
}
|
|
25978
|
+
const createActor = describeActor({ env: process.env, surface: detectSurface(process.env), cwd: process.cwd() });
|
|
25979
|
+
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
25980
|
+
recordWorktreeOwner(repoRoot2, { path: wtPath, branch, createdAt, lastSeenAt: createdAt, actor: createActor });
|
|
25981
|
+
appendWorktreeEvent(repoRoot2, { action: "created", command: "worktree create", target: wtPath, branch, actor: createActor });
|
|
25719
25982
|
if (o.json) {
|
|
25720
25983
|
return console.log(JSON.stringify({
|
|
25721
25984
|
branch,
|
|
@@ -25734,7 +25997,7 @@ withExamples(mutating(
|
|
|
25734
25997
|
else console.log(` board item ${selector.repo}#${selector.number} claimed`);
|
|
25735
25998
|
}
|
|
25736
25999
|
} catch (e) {
|
|
25737
|
-
fail(`worktree create: ${e.message}`);
|
|
26000
|
+
fail(`worktree create: failed at step '${step}' \u2014 ${e.message}`);
|
|
25738
26001
|
}
|
|
25739
26002
|
}), [
|
|
25740
26003
|
"mmi-cli worktree create fix/gc-sweep-2687",
|
|
@@ -25765,6 +26028,14 @@ worktree.command("setup [path]").description("provision an existing worktree (in
|
|
|
25765
26028
|
release();
|
|
25766
26029
|
}
|
|
25767
26030
|
});
|
|
26031
|
+
worktree.command("events").description("who created and who removed this repo's worktrees \u2014 the append-only attribution log (#3580)").option("--limit <n>", "most recent events to show", "50").option("--json", "machine-readable output").action(async (o) => {
|
|
26032
|
+
const limit = Number.parseInt(o.limit, 10);
|
|
26033
|
+
if (!Number.isFinite(limit) || limit < 1) return fail("worktree events: --limit must be a positive integer");
|
|
26034
|
+
const primaryRoot = await primaryCheckoutRoot(process.cwd()) ?? process.cwd();
|
|
26035
|
+
const events = readWorktreeEvents(primaryRoot, limit);
|
|
26036
|
+
if (o.json) return console.log(JSON.stringify({ events, count: events.length }, null, 2));
|
|
26037
|
+
console.log(formatWorktreeEvents(events));
|
|
26038
|
+
});
|
|
25768
26039
|
async function ghJson(args, timeout = 1e4) {
|
|
25769
26040
|
const { stdout } = await execFileP2("gh", args, { timeout });
|
|
25770
26041
|
return JSON.parse(stdout);
|
|
@@ -26087,7 +26358,7 @@ project.command("set [owner/repo]").description("upsert project META (idempotent
|
|
|
26087
26358
|
if (dupe) return fail(`org project set: KEY "${dupe}" was passed to both --var and --set; --set is an alias of --var, so pass each KEY once`);
|
|
26088
26359
|
if (o.secretsFile) {
|
|
26089
26360
|
try {
|
|
26090
|
-
vars.push(`secrets=${(0,
|
|
26361
|
+
vars.push(`secrets=${(0, import_node_fs33.readFileSync)(o.secretsFile, "utf8")}`);
|
|
26091
26362
|
} catch (e) {
|
|
26092
26363
|
return fail(`org project set: cannot read --secrets-file ${o.secretsFile}: ${e.message}`);
|
|
26093
26364
|
}
|
|
@@ -26743,11 +27014,11 @@ pr.command("view <number>").description("read a PR as structured JSON (merged st
|
|
|
26743
27014
|
}
|
|
26744
27015
|
});
|
|
26745
27016
|
async function listCiWorkflowPaths(cwd = process.cwd()) {
|
|
26746
|
-
const wfDir = (0,
|
|
26747
|
-
if (!(0,
|
|
26748
|
-
return (0,
|
|
27017
|
+
const wfDir = (0, import_node_path31.join)(cwd, ".github", "workflows");
|
|
27018
|
+
if (!(0, import_node_fs33.existsSync)(wfDir)) return [];
|
|
27019
|
+
return (0, import_node_fs33.readdirSync)(wfDir).filter((name) => /\.(ya?ml)$/i.test(name)).filter((name) => {
|
|
26749
27020
|
try {
|
|
26750
|
-
return workflowReportsPrChecks((0,
|
|
27021
|
+
return workflowReportsPrChecks((0, import_node_fs33.readFileSync)((0, import_node_path31.join)(wfDir, name), "utf8"));
|
|
26751
27022
|
} catch {
|
|
26752
27023
|
return true;
|
|
26753
27024
|
}
|
|
@@ -26774,16 +27045,16 @@ function ciAuditDeps() {
|
|
|
26774
27045
|
// gate re-seed step is skipped gracefully rather than failing mid-run.
|
|
26775
27046
|
readSeedFile: (path2) => {
|
|
26776
27047
|
if (!root) return null;
|
|
26777
|
-
const fullPath = (0,
|
|
26778
|
-
return (0,
|
|
27048
|
+
const fullPath = (0, import_node_path31.join)(root, path2);
|
|
27049
|
+
return (0, import_node_fs33.existsSync)(fullPath) ? (0, import_node_fs33.readFileSync)(fullPath, "utf8") : null;
|
|
26779
27050
|
}
|
|
26780
27051
|
};
|
|
26781
27052
|
}
|
|
26782
27053
|
function hubRoot() {
|
|
26783
|
-
const fromPkg = (0,
|
|
27054
|
+
const fromPkg = (0, import_node_path31.join)(__dirname, "..", "..");
|
|
26784
27055
|
const marker = "skills/bootstrap/seeds/manifest.json";
|
|
26785
|
-
if ((0,
|
|
26786
|
-
if ((0,
|
|
27056
|
+
if ((0, import_node_fs33.existsSync)((0, import_node_path31.join)(fromPkg, marker))) return fromPkg;
|
|
27057
|
+
if ((0, import_node_fs33.existsSync)((0, import_node_path31.join)(process.cwd(), marker))) return process.cwd();
|
|
26787
27058
|
return null;
|
|
26788
27059
|
}
|
|
26789
27060
|
pr.command("ci-policy").description("report merge CI policy: wait-for-checks vs no-ci (for grind/build agents)").option("--json", "machine-readable output").option("--repo <owner/repo>", "target repo (defaults to the current checkout)").action(async (o) => {
|
|
@@ -27060,7 +27331,7 @@ jsonParity(pr.command("merge <number>").description("merge a PR (squash by defau
|
|
|
27060
27331
|
localCleanup = await cleanupPrMergeLocalBranch(headRef, {
|
|
27061
27332
|
beforeWorktrees,
|
|
27062
27333
|
startingPath,
|
|
27063
|
-
pathExists: (p) => (0,
|
|
27334
|
+
pathExists: (p) => (0, import_node_fs33.existsSync)(p),
|
|
27064
27335
|
execGit: async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout,
|
|
27065
27336
|
teardownWorktreeStage,
|
|
27066
27337
|
deferredStore,
|
|
@@ -27401,10 +27672,10 @@ access.command("audit").description("audit collaborator roles + train-branch pus
|
|
|
27401
27672
|
targets = resolution.targets;
|
|
27402
27673
|
}
|
|
27403
27674
|
const derivedMatrix = registryProjects ? accessMatrixFromProjects(registryProjects) : {};
|
|
27404
|
-
const fileMatrix = (0,
|
|
27675
|
+
const fileMatrix = (0, import_node_fs33.existsSync)("access-matrix.json") ? loadAccessMatrix((0, import_node_fs33.readFileSync)("access-matrix.json", "utf8")) : {};
|
|
27405
27676
|
const matrix = mergeAccessMatrix(fileMatrix, derivedMatrix);
|
|
27406
27677
|
const derivedContracts = registryProjects ? dataAccessContractsFromProjects(registryProjects) : { consumers: {} };
|
|
27407
|
-
const fileContracts = (0,
|
|
27678
|
+
const fileContracts = (0, import_node_fs33.existsSync)("data-access-contracts.json") ? loadDataAccessContracts((0, import_node_fs33.readFileSync)("data-access-contracts.json", "utf8")) : { consumers: {} };
|
|
27408
27679
|
const dataAccess = mergeDataAccessContracts(fileContracts, derivedContracts);
|
|
27409
27680
|
const report = await auditOrgAccess(targets, deps, matrix, dataAccess);
|
|
27410
27681
|
console.log(o.json ? JSON.stringify(report, null, 2) : renderAccessReport(report));
|
|
@@ -27438,16 +27709,16 @@ function directoryBytes(path2) {
|
|
|
27438
27709
|
let total = 0;
|
|
27439
27710
|
let entries;
|
|
27440
27711
|
try {
|
|
27441
|
-
entries = (0,
|
|
27712
|
+
entries = (0, import_node_fs33.readdirSync)(path2, { withFileTypes: true });
|
|
27442
27713
|
} catch {
|
|
27443
27714
|
return 0;
|
|
27444
27715
|
}
|
|
27445
27716
|
for (const entry of entries) {
|
|
27446
|
-
const child2 = (0,
|
|
27717
|
+
const child2 = (0, import_node_path31.join)(path2, entry.name);
|
|
27447
27718
|
if (entry.isDirectory()) total += directoryBytes(child2);
|
|
27448
27719
|
else {
|
|
27449
27720
|
try {
|
|
27450
|
-
total += (0,
|
|
27721
|
+
total += (0, import_node_fs33.statSync)(child2).size;
|
|
27451
27722
|
} catch {
|
|
27452
27723
|
}
|
|
27453
27724
|
}
|
|
@@ -27455,25 +27726,25 @@ function directoryBytes(path2) {
|
|
|
27455
27726
|
return total;
|
|
27456
27727
|
}
|
|
27457
27728
|
function listDirEntries(dir) {
|
|
27458
|
-
return (0,
|
|
27729
|
+
return (0, import_node_fs33.readdirSync)(dir, { withFileTypes: true }).map((d) => ({ name: d.name, isDirectory: d.isDirectory() }));
|
|
27459
27730
|
}
|
|
27460
27731
|
function readInstalledPluginRefs(home) {
|
|
27461
27732
|
const p = installedPluginsPath(home);
|
|
27462
|
-
if (!(0,
|
|
27733
|
+
if (!(0, import_node_fs33.existsSync)(p)) return [];
|
|
27463
27734
|
try {
|
|
27464
|
-
return installedPluginPaths((0,
|
|
27735
|
+
return installedPluginPaths((0, import_node_fs33.readFileSync)(p, "utf8"));
|
|
27465
27736
|
} catch {
|
|
27466
27737
|
return null;
|
|
27467
27738
|
}
|
|
27468
27739
|
}
|
|
27469
27740
|
function pluginCacheFsDeps(home, dirBytes) {
|
|
27470
27741
|
return {
|
|
27471
|
-
exists: (p) => (0,
|
|
27472
|
-
listVersionDirs: (root) => (0,
|
|
27742
|
+
exists: (p) => (0, import_node_fs33.existsSync)(p),
|
|
27743
|
+
listVersionDirs: (root) => (0, import_node_fs33.readdirSync)(root, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name),
|
|
27473
27744
|
dirBytes,
|
|
27474
|
-
listStagingDirs: (root) => (0,
|
|
27745
|
+
listStagingDirs: (root) => (0, import_node_fs33.readdirSync)(root, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => {
|
|
27475
27746
|
try {
|
|
27476
|
-
return { name: d.name, mtimeMs: newestMtimeMs((0,
|
|
27747
|
+
return { name: d.name, mtimeMs: newestMtimeMs((0, import_node_path31.join)(root, d.name), listDirEntries, (p) => (0, import_node_fs33.statSync)(p).mtimeMs) };
|
|
27477
27748
|
} catch {
|
|
27478
27749
|
return { name: d.name, mtimeMs: Date.now() };
|
|
27479
27750
|
}
|
|
@@ -27487,10 +27758,10 @@ function stagingApplyFsGuard(home) {
|
|
|
27487
27758
|
return {
|
|
27488
27759
|
referencedPaths: () => readInstalledPluginRefs(home),
|
|
27489
27760
|
mtimeMs: (name) => {
|
|
27490
|
-
const p = (0,
|
|
27491
|
-
if (!(0,
|
|
27761
|
+
const p = (0, import_node_path31.join)(stagingRoot, name);
|
|
27762
|
+
if (!(0, import_node_fs33.existsSync)(p)) return null;
|
|
27492
27763
|
try {
|
|
27493
|
-
return newestMtimeMs(p, listDirEntries, (q) => (0,
|
|
27764
|
+
return newestMtimeMs(p, listDirEntries, (q) => (0, import_node_fs33.statSync)(q).mtimeMs);
|
|
27494
27765
|
} catch {
|
|
27495
27766
|
return null;
|
|
27496
27767
|
}
|
|
@@ -27500,13 +27771,13 @@ function stagingApplyFsGuard(home) {
|
|
|
27500
27771
|
}
|
|
27501
27772
|
program2.command("plugin-prune").description(`prune stale cached MMI plugin versions (keeps running + newest, ${PLUGIN_CACHE_KEEP} total) and orphaned temp_git_* staging dirs; dry-run unless --apply (#2903, #2990)`).option("--apply", "actually delete the stale version dirs + orphaned staging dirs (default: report only)").option("--json", "machine-readable output").action((o) => {
|
|
27502
27773
|
const plan = buildPluginCachePlan(
|
|
27503
|
-
(0,
|
|
27774
|
+
(0, import_node_os10.homedir)(),
|
|
27504
27775
|
runningPluginVersion(process.env, resolveClientVersion()),
|
|
27505
|
-
pluginCacheFsDeps((0,
|
|
27776
|
+
pluginCacheFsDeps((0, import_node_os10.homedir)(), directoryBytes),
|
|
27506
27777
|
{ withBytes: true }
|
|
27507
27778
|
);
|
|
27508
27779
|
const anythingToDelete = plan.prune.length > 0 || plan.staging.length > 0;
|
|
27509
|
-
const result = o.apply && anythingToDelete ? applyPluginCachePlan(plan, (p) => (0,
|
|
27780
|
+
const result = o.apply && anythingToDelete ? applyPluginCachePlan(plan, (p) => (0, import_node_fs33.rmSync)(p, { recursive: true, force: true }), stagingApplyFsGuard((0, import_node_os10.homedir)())) : void 0;
|
|
27510
27781
|
const warnings = plan.prune.length > 0 ? [CONCURRENT_SESSION_WARNING] : [];
|
|
27511
27782
|
if (o.json) console.log(JSON.stringify({ ...plan, warnings, applied: result ?? null }));
|
|
27512
27783
|
else console.log(renderPluginCachePlan(plan, result));
|
|
@@ -27579,6 +27850,10 @@ program2.command("session-start").description("run the SessionStart verbs (whoam
|
|
|
27579
27850
|
spawnDetachedSelf(["worktree", "setup", "--quiet"], { spawn: import_node_child_process13.spawn, execPath: process.execPath, scriptPath: process.argv[1] });
|
|
27580
27851
|
bannerIo.log(worktreeBanner);
|
|
27581
27852
|
}
|
|
27853
|
+
if (isLinkedWorktree(process.cwd())) {
|
|
27854
|
+
const primaryRoot = await primaryCheckoutRoot(process.cwd());
|
|
27855
|
+
if (primaryRoot) touchWorktreeOwner(primaryRoot, process.cwd());
|
|
27856
|
+
}
|
|
27582
27857
|
const payloadChars = measured.chars();
|
|
27583
27858
|
recordSessionPayload(process.cwd(), payloadChars);
|
|
27584
27859
|
appendHookActivity(process.cwd(), { event: "SessionStart", script: "session-start", outcome: "ran", action: "session-start hook completed", payloadChars });
|