// SPEC: PJ2026-01060703 GitHub PR merge closeout. // Responsibility: guarded local/node worktree closeout after a successful PR merge. import { relative, resolve, sep } from "node:path"; import { repoRoot } from "../config"; import { runCommand, type CommandResult } from "../command"; import type { GitHubOptions, GitHubPullRequest } from "./types"; interface WorktreeEntry { path: string; branch: string | null; head: string | null; } export function runPrMergeCloseout(repo: string, pr: GitHubPullRequest, options: GitHubOptions): Record { const headRef = pr.head?.ref ?? null; const baseRef = pr.base?.ref ?? null; const localEnabled = options.localCloseout; return { ok: true, valuesPrinted: false, headRef, baseRef, localWorktree: localEnabled ? cleanupHeadWorktree(headRef, options.dryRun) : skipped("local-closeout-disabled"), mainWorktree: localEnabled ? syncLocalMainWorktree(repo, baseRef, options.dryRun) : skipped("local-closeout-disabled"), nodeSyncs: syncRequestedNodes(repo, baseRef, options.syncNodes, options.dryRun), }; } function cleanupHeadWorktree(headRef: string | null, dryRun: boolean): Record { if (headRef === null || headRef.length === 0) return skipped("head-ref-missing"); const list = git(["worktree", "list", "--porcelain"], 10_000); if (list.exitCode !== 0) return failed("worktree-list-failed", list); const entries = parseWorktreeList(list.stdout); const matches = entries.filter((entry) => entry.branch === `refs/heads/${headRef}` && isManagedTaskWorktree(entry.path)); if (matches.length === 0) return skipped("local-head-worktree-not-found", { headRef }); const removals = matches.map((entry) => removeWorktree(entry.path, dryRun)); const branchDelete = deleteLocalBranch(headRef, dryRun); return { ok: removals.every((item) => item.ok === true || item.planned === true) && (branchDelete.ok === true || branchDelete.planned === true || branchDelete.skipped === true), headRef, worktrees: removals, branchDelete, }; } function removeWorktree(path: string, dryRun: boolean): Record { const status = git(["-C", path, "status", "--porcelain"], 10_000); if (status.exitCode !== 0) return failed("worktree-status-failed", status, { path }); if (status.stdout.trim().length > 0) return skipped("worktree-dirty", { path, porcelainLines: status.stdout.trim().split(/\r?\n/u).slice(0, 20) }); if (dryRun) return { planned: true, action: "git-worktree-remove", path }; const removed = git(["worktree", "remove", path], 30_000); return removed.exitCode === 0 ? { ok: true, action: "git-worktree-remove", path } : failed("worktree-remove-failed", removed, { path }); } function deleteLocalBranch(headRef: string, dryRun: boolean): Record { const list = git(["branch", "--list", headRef], 10_000); if (list.exitCode !== 0) return failed("branch-list-failed", list, { headRef }); if (list.stdout.trim().length === 0) return skipped("local-branch-not-found", { headRef }); if (dryRun) return { planned: true, action: "git-branch-delete", branch: headRef }; const deleted = git(["branch", "-d", headRef], 20_000); return deleted.exitCode === 0 ? { ok: true, action: "git-branch-delete", branch: headRef } : failed("branch-delete-failed", deleted, { branch: headRef }); } function syncLocalMainWorktree(repo: string, baseRef: string | null, dryRun: boolean): Record { if (baseRef === null || baseRef.length === 0) return skipped("base-ref-missing"); const remote = git(["remote", "get-url", "origin"], 10_000); if (remote.exitCode !== 0) return failed("remote-read-failed", remote); if (!remoteMatchesRepo(remote.stdout.trim(), repo)) return skipped("local-repo-mismatch", { repo, origin: redactRemote(remote.stdout.trim()) }); const current = git(["rev-parse", "--abbrev-ref", "HEAD"], 10_000); if (current.exitCode !== 0) return failed("current-branch-read-failed", current); const currentBranch = current.stdout.trim(); if (currentBranch !== baseRef) return skipped("main-worktree-on-different-branch", { currentBranch, baseRef }); if (dryRun) return { planned: true, action: "git-fetch-merge-ff-only", path: repoRoot, branch: baseRef }; const status = git(["status", "--porcelain"], 10_000); if (status.exitCode !== 0) return failed("main-worktree-status-failed", status); const hadDirty = status.stdout.trim().length > 0; const stash = hadDirty ? git(["stash", "push", "-u", "-m", `unidesk-gh-pr-merge-closeout ${repo} ${baseRef}`], 30_000) : null; if (stash !== null && stash.exitCode !== 0) return failed("main-worktree-stash-failed", stash, { branch: baseRef }); const fetched = git(["fetch", "origin", baseRef], 60_000); const merged = fetched.exitCode === 0 ? git(["merge", "--ff-only", `origin/${baseRef}`], 60_000) : null; const applied = stash !== null ? git(["stash", "apply"], 60_000) : null; const ok = fetched.exitCode === 0 && merged?.exitCode === 0 && (applied === null || applied.exitCode === 0); return { ok, action: "git-fetch-merge-ff-only", path: repoRoot, branch: baseRef, dirtyPreservedByStash: hadDirty, fetch: commandSummary(fetched), merge: merged === null ? null : commandSummary(merged), stashApply: applied === null ? null : commandSummary(applied), }; } function syncRequestedNodes(repo: string, baseRef: string | null, nodes: string[], dryRun: boolean): Record[] { return nodes.map((node) => syncRequestedNode(repo, baseRef, node, dryRun)); } function syncRequestedNode(repo: string, baseRef: string | null, node: string, dryRun: boolean): Record { const command = nodeSyncCommand(repo, baseRef, node); if (command === null) return skipped("node-sync-unsupported", { repo, baseRef, node }); if (dryRun) return { planned: true, node, command: command.join(" ") }; const result = runCommand(command, repoRoot, { timeoutMs: 120_000 }); return result.exitCode === 0 ? { ok: true, node, command: command.join(" ") } : failed("node-sync-failed", result, { node, command: command.join(" ") }); } function nodeSyncCommand(repo: string, baseRef: string | null, node: string): string[] | null { if (repo.toLowerCase() === "pikastech/hwlab" && baseRef === "v0.3") { return ["bun", "scripts/cli.ts", "hwlab", "nodes", "control-plane", "source-workspace", "sync", "--node", node.toUpperCase(), "--lane", "v03", "--confirm"]; } return null; } function parseWorktreeList(text: string): WorktreeEntry[] { const entries: WorktreeEntry[] = []; let current: WorktreeEntry | null = null; for (const line of text.split(/\r?\n/u)) { if (line.startsWith("worktree ")) { if (current !== null) entries.push(current); current = { path: line.slice("worktree ".length), branch: null, head: null }; } else if (line.startsWith("branch ") && current !== null) { current.branch = line.slice("branch ".length); } else if (line.startsWith("HEAD ") && current !== null) { current.head = line.slice("HEAD ".length); } } if (current !== null) entries.push(current); return entries; } function isManagedTaskWorktree(path: string): boolean { const rel = relative(repoRoot, resolve(path)); return rel === ".worktree" || rel.startsWith(`.worktree${sep}`); } function git(args: string[], timeoutMs: number): CommandResult { return runCommand(["git", ...args], repoRoot, { timeoutMs }); } function remoteMatchesRepo(remote: string, repo: string): boolean { const normalized = remote .replace(/^git@github\.com:/u, "") .replace(/^https:\/\/github\.com\//u, "") .replace(/\.git$/u, "") .toLowerCase(); return normalized === repo.toLowerCase(); } function redactRemote(remote: string): string { return remote.replace(/:\/\/[^/@]+@/u, "://@"); } function skipped(reason: string, details: Record = {}): Record { return { ok: true, skipped: true, skippedReason: reason, ...details }; } function failed(reason: string, result: CommandResult, details: Record = {}): Record { return { ok: false, degradedReason: reason, ...details, command: result.command.join(" "), exitCode: result.exitCode, timedOut: result.timedOut, stderrTail: tail(result.stderr), stdoutTail: tail(result.stdout) }; } function commandSummary(result: CommandResult): Record { return { exitCode: result.exitCode, timedOut: result.timedOut, stderrTail: tail(result.stderr), stdoutTail: tail(result.stdout) }; } function tail(text: string): string { return text.trim().split(/\r?\n/u).slice(-8).join("\n").slice(-1000); }