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