171 lines
8.5 KiB
TypeScript
171 lines
8.5 KiB
TypeScript
// 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<string, unknown> {
|
|
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<string, unknown> {
|
|
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<string, unknown> {
|
|
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<string, unknown> {
|
|
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<string, unknown> {
|
|
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<string, unknown>[] {
|
|
return nodes.map((node) => syncRequestedNode(repo, baseRef, node, dryRun));
|
|
}
|
|
|
|
function syncRequestedNode(repo: string, baseRef: string | null, node: string, dryRun: boolean): Record<string, unknown> {
|
|
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, "://<redacted>@");
|
|
}
|
|
|
|
function skipped(reason: string, details: Record<string, unknown> = {}): Record<string, unknown> {
|
|
return { ok: true, skipped: true, skippedReason: reason, ...details };
|
|
}
|
|
|
|
function failed(reason: string, result: CommandResult, details: Record<string, unknown> = {}): Record<string, unknown> {
|
|
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<string, unknown> {
|
|
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);
|
|
}
|