import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; import type { IsoBackendKind, VcsGitRepo, VcsWorktreeEntry } from "@oh-my-pi/pi-natives"; import * as vcs from "@oh-my-pi/pi-natives/vcs"; import { getWorktreeDir, hashPath, isEnoent, logger, slugify } from "@oh-my-pi/pi-utils"; import { github } from "../utils/github"; import { formatIsolationBackend, parseIsolationBackend } from "../task/worktree"; import { withRepoLock } from "../utils/repo-lock"; import type { ToolSession } from "."; import type { GhPrCheckoutSummary, GhToolDetails } from "@oh-my-pi/pi-tui/tools/github"; import { appendRepoFlag, buildTextResult, formatAuthor, formatLabels, formatRepoRef, normalizeOptionalString, normalizePrIdentifierList, parsePullRequestUrl, parseRepoRef, requireCurrentGitBranch, requireNonEmpty, } from "./gh-common"; import { pushLine } from "@oh-my-pi/pi-tui/tools/gh-format"; import { formatShortSha } from "@oh-my-pi/pi-tui/tools/gh-format"; import type { GhPrViewData, GhRepoViewData, GithubInput } from "./gh-types"; import { invalidateAllForNumber } from "./github-cache"; import { throwIfAborted } from "./tool-errors"; import { ToolError } from "@oh-my-pi/pi-tui/tools/tool-errors"; import { cfgIsolationBackend, cfgWorktreeClone } from "../task/settings"; export const GH_REPO_CLONE_FIELDS = ["nameWithOwner", "sshUrl", "url"]; export const GH_PR_CREATE_FIELDS = [ "author", "baseRefName", "createdAt", "headRefName", "isDraft", "labels", "number", "state", "title", "url", ]; export const GH_PR_CHECKOUT_FIELDS = [ "baseRefName", "headRefName", "headRefOid", "headRepository", "headRepositoryOwner", "isCrossRepository", "maintainerCanModify", "number", "title", "url", ]; const PR_FETCH_TIMEOUT_MS = 30 * 60 * 1000; export function sanitizeRemoteName(value: string): string { const sanitized = slugify(value); return sanitized.length > 0 ? `fork-${sanitized}` : "fork"; } /** Maximum disambiguation suffixes we try before giving up on a worktree path. */ export const WORKTREE_PATH_MAX_SUFFIX = 100; export function toLocalBranchRef(value: string): string { return `refs/heads/${value}`; } export async function requireGitRepoRoot(cwd: string, signal?: AbortSignal): Promise { signal?.throwIfAborted(); const repoRoot = vcs.git(cwd)?.info().repoRoot; if (!repoRoot) { throw new ToolError("Current git repository is unavailable."); } return repoRoot; } export async function requirePrimaryGitRepoRoot(cwd: string, signal?: AbortSignal): Promise { signal?.throwIfAborted(); const primaryRepoRoot = vcs.git(cwd)?.primaryRoot(); if (!primaryRepoRoot) { throw new ToolError("Current git repository is unavailable."); } return primaryRepoRoot; } /** * Resolve a worktree path that is free of conflicts. * * Given a `basePath`, return either `basePath` itself or `${basePath}-2`, * `${basePath}-3`, … up to {@link WORKTREE_PATH_MAX_SUFFIX} — whichever is the * first variant that is **not** registered with git as another worktree and * **not** present on disk. The numeric tail salvages two rare cases that * would otherwise abort a checkout: stale leftover dirs from an interrupted * `git worktree add`, and the (vanishingly unlikely) `hashPath` collision * between two repos that happen to produce the same 7-hex digest. */ export async function resolveAvailableWorktreePath( basePath: string, existingWorktrees: VcsWorktreeEntry[], ): Promise { const registered = new Set(existingWorktrees.map(entry => path.resolve(entry.path))); for (let attempt = 0; attempt < WORKTREE_PATH_MAX_SUFFIX; attempt += 1) { const candidate = attempt === 0 ? basePath : `${basePath}-${attempt + 1}`; const normalized = path.resolve(candidate); if (registered.has(normalized)) continue; try { await fs.stat(normalized); } catch (error) { if (isEnoent(error)) { return candidate; } throw error; } } throw new ToolError( `could not find an unused worktree path under ${basePath} (tried ${WORKTREE_PATH_MAX_SUFFIX} suffixes)`, ); } export function selectPrCloneUrl(originUrl: string | undefined, repo: Pick): string { if (originUrl?.startsWith("http://") || originUrl?.startsWith("https://")) { return normalizeOptionalString(repo.url) ?? normalizeOptionalString(repo.sshUrl) ?? ""; } return normalizeOptionalString(repo.sshUrl) ?? normalizeOptionalString(repo.url) ?? ""; } export async function getRemoteUrls(repoRoot: string, signal?: AbortSignal): Promise> { const repo = vcs.requireGit(repoRoot); const remotes = await repo.remoteList(signal); const urls = new Map(); for (const remoteName of remotes) { const remoteUrl = await repo.remoteUrl(remoteName, signal); if (remoteUrl) urls.set(remoteName, remoteUrl); } return urls; } export async function ensurePrRemote( repoRoot: string, data: GhPrViewData, signal?: AbortSignal, ): Promise<{ name: string; url: string }> { return ensurePrRemoteWithRepo(repoRoot, data, vcs.requireGit(repoRoot), signal); } async function ensurePrRemoteWithRepo( repoRoot: string, data: GhPrViewData, repository: VcsGitRepo, signal?: AbortSignal, ): Promise<{ name: string; url: string }> { if (!data.isCrossRepository) { const originUrl = await repository.remoteUrl("origin", signal); if (!originUrl) { throw new ToolError("origin remote is unavailable for this repository."); } return { name: "origin", url: originUrl, }; } const headRepository = requireNonEmpty(data.headRepository?.nameWithOwner, "head repository"); const pullRepo = parsePullRequestUrl(data.url).repo; const pullHost = pullRepo ? parseRepoRef(pullRepo).host : undefined; const repoSummary = await github.json( repoRoot, ["repo", "view", formatRepoRef(pullHost, headRepository), "--json", GH_REPO_CLONE_FIELDS.join(",")], signal, { repoProvided: true }, ); const originUrl = await repository.remoteUrl("origin", signal); const remoteUrl = selectPrCloneUrl(originUrl ?? undefined, repoSummary); if (!remoteUrl) { throw new ToolError(`Could not determine a clone URL for ${headRepository}.`); } const remotes = new Map(); for (const remoteName of await repository.remoteList(signal)) { const url = await repository.remoteUrl(remoteName, signal); if (url) remotes.set(remoteName, url); } for (const [remoteName, url] of remotes) { if (url === remoteUrl) { return { name: remoteName, url }; } } const preferredRemoteName = sanitizeRemoteName( data.headRepositoryOwner?.login ?? headRepository.split("/")[0] ?? "fork", ); let remoteName = preferredRemoteName; let suffix = 2; while (remotes.has(remoteName)) { remoteName = `${preferredRemoteName}-${suffix}`; suffix += 1; } await repository.remoteAdd(remoteName, remoteUrl, signal); return { name: remoteName, url: remoteUrl, }; } export async function resolvePrBranchPushTarget( repoRoot: string, localBranch: string, signal?: AbortSignal, ): Promise<{ remoteName: string; remoteBranch: string; remoteUrl?: string; prUrl?: string; maintainerCanModify?: boolean; isCrossRepository: boolean; }> { const repository = vcs.requireGit(repoRoot); const configPrefix = `branch.${localBranch}.`; const [headRef, pushRemote, remote, prUrl, maintainerCanModifyValue, isCrossRepositoryValue] = await Promise.all([ repository.configGet(`${configPrefix}ompPrHeadRef`, signal), repository.configGet(`${configPrefix}pushRemote`, signal), repository.configGet(`${configPrefix}remote`, signal), repository.configGet(`${configPrefix}ompPrUrl`, signal), repository.configGet(`${configPrefix}ompPrMaintainerCanModify`, signal), repository.configGet(`${configPrefix}ompPrIsCrossRepository`, signal), ]); if (!headRef) { throw new ToolError(`branch ${localBranch} has no PR push metadata; check it out via op: pr_checkout first`); } const remoteName = pushRemote ?? remote; if (!remoteName) { throw new ToolError(`branch ${localBranch} has no configured push remote`); } return { remoteName, remoteBranch: headRef, remoteUrl: (await repository.remoteUrl(remoteName, signal)) ?? undefined, prUrl: prUrl ?? undefined, maintainerCanModify: maintainerCanModifyValue == null ? undefined : ["1", "true", "yes", "on"].includes(maintainerCanModifyValue.toLowerCase()), isCrossRepository: ["1", "true", "yes", "on"].includes((isCrossRepositoryValue ?? "").toLowerCase()), }; } export function formatPrCheckoutResult(options: { data: GhPrViewData; localBranch: string; worktreePath: string; remoteName: string; remoteUrl: string; reused: boolean; clonedWith?: IsoBackendKind; }): string { const { data, localBranch, worktreePath, remoteName, remoteUrl, reused, clonedWith } = options; const lines: string[] = [ reused ? `# Pull Request #${data.number ?? "?"} Worktree` : `# Checked Out Pull Request #${data.number ?? "?"}`, "", ]; pushLine(lines, "Title", data.title ?? undefined); pushLine(lines, "URL", data.url); pushLine(lines, "Base", data.baseRefName); pushLine(lines, "Head", data.headRefName); pushLine(lines, "Local branch", localBranch); pushLine( lines, "Worktree", clonedWith != null ? `${worktreePath} (cloned via ${formatIsolationBackend(clonedWith)})` : worktreePath, ); pushLine(lines, "Remote", remoteName); pushLine(lines, "Remote URL", remoteUrl); pushLine(lines, "Cross repository", data.isCrossRepository); pushLine(lines, "Maintainer can modify", data.maintainerCanModify); lines.push(""); lines.push( reused ? "Reused the existing PR worktree." : "Created a dedicated worktree for this PR and configured the local branch to push back to the PR head branch.", ); return lines.join("\n").trim(); } export function formatPrPushResult(options: { localBranch: string; remoteName: string; remoteBranch: string; remoteUrl?: string; prUrl?: string; forceWithLease: boolean; }): string { const lines: string[] = ["# Pushed Pull Request Branch", ""]; pushLine(lines, "Local branch", options.localBranch); pushLine(lines, "Remote", options.remoteName); pushLine(lines, "Remote branch", options.remoteBranch); pushLine(lines, "Remote URL", options.remoteUrl); pushLine(lines, "PR", options.prUrl); pushLine(lines, "Force with lease", options.forceWithLease); lines.push(""); lines.push(`Pushed ${options.localBranch} to ${options.remoteName}:${options.remoteBranch}.`); return lines.join("\n").trim(); } export function joinSections(sections: string[]): string[] { return sections.flatMap((section, idx) => (idx === 0 ? [section] : ["", "---", "", section])); } export async function executePrCheckout( session: ToolSession, params: GithubInput, signal: AbortSignal | undefined, ): Promise> { const repo = normalizeOptionalString(params.repo); const force = params.force ?? false; const prList = normalizePrIdentifierList(params.pr); const prRefs = prList.length > 0 ? prList : [undefined]; const isMulti = prRefs.length > 1; const settled = await Promise.allSettled( prRefs.map(prRef => checkoutPullRequest(session, signal, { prRef, repo, force })), ); const outcomes: PrCheckoutOutcome[] = []; const failures: Array<{ prRef: string | undefined; reason: unknown }> = []; for (let i = 0; i < settled.length; i++) { const entry = settled[i]; if (entry.status === "fulfilled") outcomes.push(entry.value); else failures.push({ prRef: prRefs[i], reason: entry.reason }); } if (failures.length > 0) { throwIfAborted(signal); const failureLines = failures.map( f => `- ${f.prRef ?? "(current branch)"}: ${f.reason instanceof Error ? f.reason.message : String(f.reason)}`, ); if (outcomes.length === 0) { if (failures.length === 1) throw failures[0].reason; throw new ToolError(`all ${failures.length} PR checkouts failed:\n${failureLines.join("\n")}`); } // Partial success: report the worktrees that did get created alongside // the failures so the agent does not lose track of them. const sections = outcomes.map(formatPrCheckoutResult); const header = `# ${outcomes.length}/${settled.length} Pull Request Worktrees checked out (${failures.length} failed)`; const text = [header, "", ...joinSections(sections), "", "## Failed", ...failureLines].join("\n").trim(); return buildTextResult(text, undefined, { repo, checkouts: outcomes.map(outcomeToSummary), }); } if (!isMulti) { const [outcome] = outcomes; return buildTextResult(formatPrCheckoutResult(outcome), outcome.data.url, { repo: repo ?? outcome.data.headRepository?.nameWithOwner, branch: outcome.localBranch, worktreePath: outcome.worktreePath, remote: outcome.remoteName, remoteBranch: outcome.headRefName, checkouts: [outcomeToSummary(outcome)], }); } const sections = outcomes.map(formatPrCheckoutResult); const reusedCount = outcomes.reduce((acc, o) => acc + (o.reused ? 1 : 0), 0); const newCount = outcomes.length - reusedCount; const headerParts: string[] = []; if (newCount > 0) headerParts.push(`${newCount} checked out`); if (reusedCount > 0) headerParts.push(`${reusedCount} reused`); const header = `# ${outcomes.length} Pull Request Worktrees (${headerParts.join(", ")})`; const text = [header, "", ...joinSections(sections)].join("\n").trim(); return buildTextResult(text, undefined, { repo, checkouts: outcomes.map(outcomeToSummary), }); } export interface PrCheckoutOptions { prRef: string | undefined; repo: string | undefined; force: boolean; } export interface PrCheckoutOutcome { data: GhPrViewData; localBranch: string; worktreePath: string; remoteName: string; remoteUrl: string; headRefName: string; reused: boolean; clonedWith?: IsoBackendKind; } export async function checkoutPullRequest( session: ToolSession, signal: AbortSignal | undefined, options: PrCheckoutOptions, ): Promise { const { prRef, repo, force } = options; if (prRef?.startsWith("-")) { throw new ToolError(`invalid PR identifier: ${prRef}. Pass a PR number, URL, or branch name.`); } const args = ["pr", "view"]; if (prRef) args.push(prRef); appendRepoFlag(args, repo, prRef); args.push("--json", GH_PR_CHECKOUT_FIELDS.join(",")); const data = await github.json(session.cwd, args, signal, { repoProvided: Boolean(repo), }); const prNumber = data.number; if (typeof prNumber !== "number") { throw new ToolError("GitHub CLI did not return a pull request number."); } const headRefName = requireNonEmpty(data.headRefName, "head branch"); const headRefOid = requireNonEmpty(data.headRefOid, "head commit"); const repoRoot = await requireGitRepoRoot(session.cwd, signal); const primaryRepoRoot = await requirePrimaryGitRepoRoot(repoRoot, signal); const localBranch = `pr-${prNumber}`; const worktreePath = getWorktreeDir(`${prNumber}-${hashPath(primaryRepoRoot)}`); // Every git mutation against `repoRoot` from here on must run under the // per-repo lock. Worktrees of the same primary repo share `.git/config`, // `commit-graph` chain, `packed-refs`, and worktree metadata files — git // uses O_EXCL lock files for each, with no waiter. Concurrent in-process // callers (e.g. parallel `pr_checkout` calls) would otherwise lose lock // races and surface "could not lock config file" / "Another git process // seems to be running" errors. The gh API call above stays outside the // lock so multiple checkouts can fetch PR metadata in parallel. return withRepoLock( repoRoot, async () => { const repository = vcs.requireGit(repoRoot); const existingWorktrees = await repository.worktrees(signal); const existingWorktree = existingWorktrees.find(entry => entry.branch === toLocalBranchRef(localBranch)); const remote = await ensurePrRemoteWithRepo(repoRoot, data, repository, signal); await repository.fetch( remote.name, `refs/heads/${headRefName}`, `refs/remotes/${remote.name}/${headRefName}`, PR_FETCH_TIMEOUT_MS, signal, ); if (!existingWorktree) { const localBranchRef = toLocalBranchRef(localBranch); const localBranchExists = await repository.refExists(localBranchRef, signal); if (localBranchExists) { const existingOid = await repository.resolveRef(localBranchRef, signal); if (existingOid !== headRefOid) { if (!force) { throw new ToolError( `local branch ${localBranch} already exists at ${formatShortSha(existingOid ?? undefined) ?? existingOid ?? "unknown commit"}; pass force=true to reset it`, ); } await repository.createBranch( localBranch, `refs/remotes/${remote.name}/${headRefName}`, true, signal, ); } } else { await repository.createBranch(localBranch, `refs/remotes/${remote.name}/${headRefName}`, false, signal); } } const configPrefix = `branch.${localBranch}.`; await repository.configSet(`${configPrefix}remote`, remote.name, signal); await repository.configSet(`${configPrefix}merge`, `refs/heads/${headRefName}`, signal); await repository.configSet(`${configPrefix}pushRemote`, remote.name, signal); await repository.configSet(`${configPrefix}ompPrHeadRef`, headRefName, signal); await repository.configSet(`${configPrefix}ompPrUrl`, data.url ?? "", signal); await repository.configSet( `${configPrefix}ompPrIsCrossRepository`, String(Boolean(data.isCrossRepository)), signal, ); await repository.configSet( `${configPrefix}ompPrMaintainerCanModify`, String(Boolean(data.maintainerCanModify)), signal, ); let finalWorktreePath = existingWorktree?.path ?? worktreePath; let clonedWith: IsoBackendKind | undefined; if (!existingWorktree) { finalWorktreePath = await resolveAvailableWorktreePath(worktreePath, existingWorktrees); await fs.mkdir(path.dirname(finalWorktreePath), { recursive: true }); const result = await repository.worktreeAdd( finalWorktreePath, localBranch, { detach: false, clone: cfgWorktreeClone.get(session.settings), backend: parseIsolationBackend(cfgIsolationBackend.get(session.settings)), }, signal, ); clonedWith = result.clonedWith ?? undefined; if (result.cloneError) { logger.warn("worktree clone fell back to plain checkout", { path: finalWorktreePath, error: result.cloneError, }); } } const resolvedWorktreePath = await fs.realpath(finalWorktreePath); return { data, localBranch, worktreePath: resolvedWorktreePath, remoteName: remote.name, remoteUrl: remote.url, headRefName, reused: Boolean(existingWorktree), clonedWith, }; }, signal, ); } export function outcomeToSummary(outcome: PrCheckoutOutcome): GhPrCheckoutSummary { return { prNumber: typeof outcome.data.number === "number" ? outcome.data.number : undefined, url: outcome.data.url ?? undefined, branch: outcome.localBranch, worktreePath: outcome.worktreePath, remote: outcome.remoteName, remoteBranch: outcome.headRefName, reused: outcome.reused, clonedWith: outcome.clonedWith, }; } export async function executePrPush( session: ToolSession, params: GithubInput, signal: AbortSignal | undefined, ): Promise> { const repoRoot = await requireGitRepoRoot(session.cwd, signal); const repository = vcs.requireGit(repoRoot); const localBranch = normalizeOptionalString(params.branch) ?? (await requireCurrentGitBranch(repoRoot, signal)); const refExists = await repository.refExists(toLocalBranchRef(localBranch), signal); if (!refExists) { throw new ToolError(`local branch ${localBranch} does not exist`); } const target = await resolvePrBranchPushTarget(repoRoot, localBranch, signal); const currentBranch = await repository.currentBranch(signal); const sourceRef = currentBranch === localBranch ? "HEAD" : toLocalBranchRef(localBranch); const refspec = `${sourceRef}:refs/heads/${target.remoteBranch}`; await repository.push( { forceWithLease: params.forceWithLease, refspec, remote: target.remoteName, }, signal, ); // A successful push changes what `pr://N` and `pr://N/diff` should show; // drop the cached rows so the canonical "push → re-read diff" flow sees // fresh data instead of a soft-TTL stale snapshot. const pushedPr = parsePullRequestUrl(target.prUrl); if (pushedPr.prNumber !== undefined) { invalidateAllForNumber(pushedPr.prNumber, pushedPr.repo); } return buildTextResult( formatPrPushResult({ localBranch, remoteName: target.remoteName, remoteBranch: target.remoteBranch, remoteUrl: target.remoteUrl, prUrl: target.prUrl, forceWithLease: params.forceWithLease ?? false, }), target.prUrl, { branch: localBranch, remote: target.remoteName, remoteBranch: target.remoteBranch, }, ); } export async function executePrCreate( session: ToolSession, params: GithubInput, signal: AbortSignal | undefined, ): Promise> { const repo = normalizeOptionalString(params.repo); const title = normalizeOptionalString(params.title); const body = params.body; const base = normalizeOptionalString(params.base); const head = normalizeOptionalString(params.head); const draft = params.draft ?? false; const fill = params.fill ?? false; const reviewers = normalizePrIdentifierList(params.reviewer); const assignees = normalizePrIdentifierList(params.assignee); const labels = normalizePrIdentifierList(params.label); if (!fill && !title) { throw new ToolError("title is required unless fill is true"); } if (fill && (title || body !== undefined)) { throw new ToolError("fill is mutually exclusive with title and body"); } const args = ["pr", "create"]; appendRepoFlag(args, repo); if (title) args.push("--title", title); if (base) args.push("--base", base); if (head) args.push("--head", head); if (draft) args.push("--draft"); if (fill) args.push("--fill"); for (const reviewer of reviewers) args.push("--reviewer", reviewer); for (const assignee of assignees) args.push("--assignee", assignee); for (const label of labels) args.push("--label", label); let bodyDir: string | undefined; try { if (!fill) { if (body !== undefined && body.length > 0) { // Route through a temp file so multi-KB bodies stay clear of any // argv-length limits and shell-quoting hazards on uncommon platforms. bodyDir = await fs.mkdtemp(path.join(os.tmpdir(), "gh-pr-body-")); const bodyFile = path.join(bodyDir, "body.md"); await Bun.write(bodyFile, body); args.push("--body-file", bodyFile); } else { // Avoid gh dropping into an interactive editor when no body is given. args.push("--body", ""); } } const output = await github.text(session.cwd, args, signal, { repoProvided: Boolean(repo), }); const url = output .split("\n") .map(line => line.trim()) .find(line => line.startsWith("https://")) ?? output.trim(); const parsed = parsePullRequestUrl(url); const resolvedRepo = repo ?? parsed.repo; let prView: GhPrViewData | undefined; if (resolvedRepo && parsed.prNumber !== undefined) { try { prView = await github.json( session.cwd, ["pr", "view", String(parsed.prNumber), "--repo", resolvedRepo, "--json", GH_PR_CREATE_FIELDS.join(",")], signal, { repoProvided: true }, ); } catch { // Best-effort summary; PR creation already succeeded. } } const text = formatPrCreateResult({ url, prNumber: parsed.prNumber, data: prView, title, base, head, draft, }); return buildTextResult(text, url || prView?.url); } finally { if (bodyDir) { await fs.rm(bodyDir, { recursive: true, force: true }).catch(() => {}); } } } export function formatPrCreateResult(options: { url: string; prNumber?: number; data?: GhPrViewData; title?: string; base?: string; head?: string; draft?: boolean; }): string { const number = options.prNumber ?? options.data?.number; const headerTitle = options.data?.title ?? options.title ?? "Untitled"; const header = number !== undefined ? `# Created Pull Request #${number}: ${headerTitle}` : `# Created Pull Request: ${headerTitle}`; const lines: string[] = [header, ""]; pushLine(lines, "URL", options.url || options.data?.url); pushLine(lines, "State", options.data?.state); pushLine(lines, "Draft", options.data?.isDraft ?? options.draft); pushLine(lines, "Base", options.data?.baseRefName ?? options.base); pushLine(lines, "Head", options.data?.headRefName ?? options.head); pushLine(lines, "Author", formatAuthor(options.data?.author)); pushLine(lines, "Created", options.data?.createdAt); pushLine(lines, "Labels", formatLabels(options.data?.labels)); return lines.join("\n").trim(); }