import { exec, execFile } from "child_process"; import { promisify } from "util"; import { resolve } from "path"; import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js"; import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js"; import { detectRepoInfo, isPaseoOwnedWorktreeCwd } from "./worktree.js"; import { requirePaseoWorktreeBaseRefName } from "./worktree-metadata.js"; const execAsync = promisify(exec); const execFileAsync = promisify(execFile); const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = { ...process.env, GIT_OPTIONAL_LOCKS: "0", }; export class NotGitRepoError extends Error { readonly cwd: string; readonly code = "NOT_GIT_REPO"; constructor(cwd: string) { super(`Not a git repository: ${cwd}`); this.name = "NotGitRepoError"; this.cwd = cwd; } } export class MergeConflictError extends Error { readonly baseRef: string; readonly currentBranch: string; readonly conflictFiles: string[]; constructor(options: { baseRef: string; currentBranch: string; conflictFiles: string[] }) { super(`Merge conflict while merging ${options.currentBranch} into ${options.baseRef}`); this.name = "MergeConflictError"; this.baseRef = options.baseRef; this.currentBranch = options.currentBranch; this.conflictFiles = options.conflictFiles; } } export class MergeFromBaseConflictError extends Error { readonly baseRef: string; readonly currentBranch: string; readonly conflictFiles: string[]; constructor(options: { baseRef: string; currentBranch: string; conflictFiles: string[] }) { super( `Merge conflict while merging ${options.baseRef} into ${options.currentBranch}. Please merge manually.` ); this.name = "MergeFromBaseConflictError"; this.baseRef = options.baseRef; this.currentBranch = options.currentBranch; this.conflictFiles = options.conflictFiles; } } export interface AheadBehind { ahead: number; behind: number; } export interface CheckoutStatus { isGit: false; } export type CheckoutStatusGitNonPaseo = { isGit: true; repoRoot: string; currentBranch: string | null; isDirty: boolean; baseRef: string | null; aheadBehind: AheadBehind | null; hasRemote: boolean; isPaseoOwnedWorktree: false; }; export type CheckoutStatusGitPaseo = { isGit: true; repoRoot: string; currentBranch: string | null; isDirty: boolean; baseRef: string; aheadBehind: AheadBehind | null; hasRemote: boolean; isPaseoOwnedWorktree: true; }; export type CheckoutStatusGit = CheckoutStatusGitNonPaseo | CheckoutStatusGitPaseo; export type CheckoutStatusResult = CheckoutStatus | CheckoutStatusGit; export interface CheckoutDiffResult { diff: string; structured?: ParsedDiffFile[]; } export interface CheckoutDiffCompare { mode: "uncommitted" | "base"; baseRef?: string; includeStructured?: boolean; } export interface MergeToBaseOptions { baseRef?: string; mode?: "merge" | "squash"; commitMessage?: string; } export interface MergeFromBaseOptions { baseRef?: string; requireCleanTarget?: boolean; } export type CheckoutContext = { paseoHome?: string; }; function isGitError(error: unknown): boolean { if (!(error instanceof Error)) { return false; } return /not a git repository/i.test(error.message) || /git repository/i.test(error.message); } async function requireRepoInfo(cwd: string) { try { return await detectRepoInfo(cwd); } catch (error) { if (isGitError(error)) { throw new NotGitRepoError(cwd); } if (error instanceof Error) { throw new NotGitRepoError(cwd); } throw error; } } async function getCurrentBranch(cwd: string): Promise { const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd, env: READ_ONLY_GIT_ENV, }); const branch = stdout.trim(); return branch.length > 0 ? branch : null; } async function getWorktreeRoot(cwd: string): Promise { try { const { stdout } = await execAsync( "git rev-parse --path-format=absolute --show-toplevel", { cwd, env: READ_ONLY_GIT_ENV } ); const root = stdout.trim(); return root.length > 0 ? root : null; } catch { return null; } } type GitWorktreeEntry = { path: string; branchRef?: string; }; function parseWorktreeList(output: string): GitWorktreeEntry[] { const entries: GitWorktreeEntry[] = []; let current: GitWorktreeEntry | null = null; for (const line of output.split("\n")) { const trimmed = line.trim(); if (!trimmed) { continue; } if (trimmed.startsWith("worktree ")) { if (current) { entries.push(current); } current = { path: trimmed.slice("worktree ".length).trim() }; continue; } if (current && trimmed.startsWith("branch ")) { current.branchRef = trimmed.slice("branch ".length).trim(); } } if (current) { entries.push(current); } return entries; } async function getWorktreePathForBranch( cwd: string, branchName: string ): Promise { try { const { stdout } = await execAsync("git worktree list --porcelain", { cwd, env: READ_ONLY_GIT_ENV, }); const entries = parseWorktreeList(stdout); const ref = branchName.startsWith("refs/heads/") ? branchName : `refs/heads/${branchName}`; return entries.find((entry) => entry.branchRef === ref)?.path ?? null; } catch { return null; } } export async function renameCurrentBranch( cwd: string, newName: string ): Promise<{ previousBranch: string | null; currentBranch: string | null }> { await requireRepoInfo(cwd); const previousBranch = await getCurrentBranch(cwd); if (!previousBranch || previousBranch === "HEAD") { throw new Error("Cannot rename branch in detached HEAD state"); } await execAsync(`git branch -m "${newName}"`, { cwd, }); const currentBranch = await getCurrentBranch(cwd); return { previousBranch, currentBranch }; } type ConfiguredBaseRefForCwd = | { baseRef: null; isPaseoOwnedWorktree: false } | { baseRef: string; isPaseoOwnedWorktree: true }; async function getConfiguredBaseRefForCwd( cwd: string, context?: CheckoutContext ): Promise { const ownership = await isPaseoOwnedWorktreeCwd(cwd, { paseoHome: context?.paseoHome }); if (!ownership.allowed) { return { baseRef: null, isPaseoOwnedWorktree: false }; } const worktreeRoot = (await getWorktreeRoot(cwd)) ?? cwd; return { baseRef: requirePaseoWorktreeBaseRefName(worktreeRoot), isPaseoOwnedWorktree: true, }; } async function isWorkingTreeDirty(cwd: string, repoType: "bare" | "normal"): Promise { if (repoType === "bare") { return false; } const { stdout } = await execAsync("git status --porcelain", { cwd, env: READ_ONLY_GIT_ENV, }); return stdout.trim().length > 0; } async function hasOriginRemote(cwd: string): Promise { try { const { stdout } = await execAsync("git config --get remote.origin.url", { cwd, env: READ_ONLY_GIT_ENV, }); return stdout.trim().length > 0; } catch { return false; } } async function resolveBaseRef(repoRoot: string): Promise { try { const { stdout } = await execAsync("git symbolic-ref --quiet refs/remotes/origin/HEAD", { cwd: repoRoot, env: READ_ONLY_GIT_ENV, }); const ref = stdout.trim(); if (ref) { // Prefer a local branch name (e.g. "main") over the remote-tracking ref (e.g. "origin/main") // so that status/diff/merge all operate against the same base ref. const remoteShort = ref.replace(/^refs\/remotes\//, ""); const localName = remoteShort.startsWith("origin/") ? remoteShort.slice("origin/".length) : remoteShort; try { await execAsync(`git show-ref --verify --quiet refs/heads/${localName}`, { cwd: repoRoot, env: READ_ONLY_GIT_ENV, }); return localName; } catch { return remoteShort; } } } catch { // ignore } const { stdout } = await execAsync("git branch --format='%(refname:short)'", { cwd: repoRoot, env: READ_ONLY_GIT_ENV, }); const branches = stdout .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0); if (branches.includes("main")) { return "main"; } if (branches.includes("master")) { return "master"; } return null; } function normalizeLocalBranchRefName(input: string): string { return input.startsWith("origin/") ? input.slice("origin/".length) : input; } async function doesGitRefExist(cwd: string, fullRef: string): Promise { try { await execAsync(`git show-ref --verify --quiet ${fullRef}`, { cwd, env: READ_ONLY_GIT_ENV, }); return true; } catch { return false; } } async function resolveBestBaseRefForMerge(cwd: string, normalizedBaseRef: string): Promise { const [hasLocal, hasOrigin] = await Promise.all([ doesGitRefExist(cwd, `refs/heads/${normalizedBaseRef}`), doesGitRefExist(cwd, `refs/remotes/origin/${normalizedBaseRef}`), ]); if (hasLocal && !hasOrigin) { return normalizedBaseRef; } if (!hasLocal && hasOrigin) { return `origin/${normalizedBaseRef}`; } if (!hasLocal && !hasOrigin) { throw new Error(`Base branch not found locally or on origin: ${normalizedBaseRef}`); } // Both exist: choose the ref with more unique commits compared to the other. try { const { stdout } = await execAsync( `git rev-list --left-right --count ${normalizedBaseRef}...origin/${normalizedBaseRef}`, { cwd, env: READ_ONLY_GIT_ENV } ); const [localOnlyRaw, originOnlyRaw] = stdout.trim().split(/\s+/); const localOnly = Number.parseInt(localOnlyRaw ?? "0", 10); const originOnly = Number.parseInt(originOnlyRaw ?? "0", 10); if (!Number.isNaN(localOnly) && !Number.isNaN(originOnly) && originOnly > localOnly) { return `origin/${normalizedBaseRef}`; } } catch { // ignore and fall back to local } return normalizedBaseRef; } async function getAheadBehind(cwd: string, baseRef: string, currentBranch: string): Promise { const normalizedBaseRef = normalizeLocalBranchRefName(baseRef); if (!normalizedBaseRef || !currentBranch || normalizedBaseRef === currentBranch) { return null; } const { stdout } = await execAsync( `git rev-list --left-right --count ${normalizedBaseRef}...${currentBranch}`, { cwd, env: READ_ONLY_GIT_ENV } ); const [behindRaw, aheadRaw] = stdout.trim().split(/\s+/); const behind = Number.parseInt(behindRaw ?? "0", 10); const ahead = Number.parseInt(aheadRaw ?? "0", 10); if (Number.isNaN(behind) || Number.isNaN(ahead)) { return null; } return { ahead, behind }; } async function getUntrackedDiff(cwd: string): Promise { let untrackedDiff = ""; try { const { stdout: untrackedFiles } = await execAsync( "git ls-files --others --exclude-standard", { cwd, env: READ_ONLY_GIT_ENV } ); const newFiles = untrackedFiles.trim().split("\n").filter(Boolean); for (const file of newFiles) { try { const { stdout: fileDiff } = await execAsync( `git diff --no-index /dev/null "${file}" || true`, { cwd, env: READ_ONLY_GIT_ENV } ); if (fileDiff) { untrackedDiff += fileDiff; } } catch { // Ignore errors for individual files } } } catch { // Ignore errors getting untracked files } return untrackedDiff; } export async function getCheckoutStatus( cwd: string, context?: CheckoutContext ): Promise { let repoInfo: Awaited>; try { repoInfo = await detectRepoInfo(cwd); } catch (error) { if (isGitError(error)) { return { isGit: false }; } throw error; } const currentBranch = await getCurrentBranch(cwd); const isDirty = await isWorkingTreeDirty(cwd, repoInfo.type); const hasRemote = await hasOriginRemote(repoInfo.path); const configured = await getConfiguredBaseRefForCwd(cwd, context); const baseRef = configured.baseRef ?? (await resolveBaseRef(repoInfo.path)); const aheadBehind = baseRef && currentBranch ? await getAheadBehind(cwd, baseRef, currentBranch) : null; if (configured.isPaseoOwnedWorktree) { return { isGit: true, repoRoot: repoInfo.path, currentBranch, isDirty, baseRef: configured.baseRef, aheadBehind, hasRemote, isPaseoOwnedWorktree: true, }; } return { isGit: true, repoRoot: repoInfo.path, currentBranch, isDirty, baseRef, aheadBehind, hasRemote, isPaseoOwnedWorktree: false, }; } export async function getCheckoutDiff( cwd: string, compare: CheckoutDiffCompare, context?: CheckoutContext ): Promise { await requireRepoInfo(cwd); let diff = ""; if (compare.mode === "uncommitted") { const { stdout: trackedDiff } = await execAsync("git diff HEAD", { cwd, env: READ_ONLY_GIT_ENV, }); const untrackedDiff = await getUntrackedDiff(cwd); diff = trackedDiff + untrackedDiff; } else { const configured = await getConfiguredBaseRefForCwd(cwd, context); const repoInfo = await detectRepoInfo(cwd); const baseRef = configured.baseRef ?? compare.baseRef ?? (await resolveBaseRef(repoInfo.path)); if (!baseRef) { diff = ""; } else if (configured.isPaseoOwnedWorktree && compare.baseRef && compare.baseRef !== baseRef) { throw new Error(`Base ref mismatch: expected ${baseRef}, got ${compare.baseRef}`); } else { const normalizedBaseRef = normalizeLocalBranchRefName(baseRef); // Diff base ref against working tree (includes uncommitted changes) const { stdout: trackedDiff } = await execAsync(`git diff ${normalizedBaseRef}`, { cwd, env: READ_ONLY_GIT_ENV, }); const untrackedDiff = await getUntrackedDiff(cwd); diff = trackedDiff + untrackedDiff; } } if (compare.includeStructured) { return { diff, structured: await parseAndHighlightDiff(diff, cwd) }; } return { diff }; } export async function commitChanges( cwd: string, options: { message: string; addAll?: boolean } ): Promise { await requireRepoInfo(cwd); if (options.addAll ?? true) { await execFileAsync("git", ["add", "-A"], { cwd }); } await execFileAsync("git", ["-c", "commit.gpgsign=false", "commit", "-m", options.message], { cwd, }); } export async function commitAll(cwd: string, message: string): Promise { await commitChanges(cwd, { message, addAll: true }); } export async function mergeToBase( cwd: string, options: MergeToBaseOptions = {}, context?: CheckoutContext ): Promise { const repoInfo = await requireRepoInfo(cwd); const currentBranch = await getCurrentBranch(cwd); const configured = await getConfiguredBaseRefForCwd(cwd, context); const baseRef = configured.baseRef ?? options.baseRef ?? (await resolveBaseRef(repoInfo.path)); if (!baseRef) { throw new Error("Unable to determine base branch for merge"); } if (configured.isPaseoOwnedWorktree && options.baseRef && options.baseRef !== baseRef) { throw new Error(`Base ref mismatch: expected ${baseRef}, got ${options.baseRef}`); } if (!currentBranch) { throw new Error("Unable to determine current branch for merge"); } let normalizedBaseRef = baseRef; normalizedBaseRef = normalizeLocalBranchRefName(normalizedBaseRef); if (normalizedBaseRef === currentBranch) { return; } const currentWorktreeRoot = (await getWorktreeRoot(cwd)) ?? cwd; const baseWorktree = await getWorktreePathForBranch(repoInfo.path, normalizedBaseRef); const operationCwd = baseWorktree ?? currentWorktreeRoot; const isSameCheckout = resolve(operationCwd) === resolve(currentWorktreeRoot); const originalBranch = await getCurrentBranch(operationCwd); const mode = options.mode ?? "merge"; try { await execAsync(`git checkout ${normalizedBaseRef}`, { cwd: operationCwd }); if (mode === "squash") { await execAsync(`git merge --squash ${currentBranch}`, { cwd: operationCwd }); const message = options.commitMessage ?? `Squash merge ${currentBranch} into ${normalizedBaseRef}`; await execFileAsync( "git", ["-c", "commit.gpgsign=false", "commit", "-m", message], { cwd: operationCwd } ); } else { await execAsync(`git merge ${currentBranch}`, { cwd: operationCwd }); } } catch (error) { const errorDetails = error instanceof Error ? `${error.message}\n${(error as any).stderr ?? ""}\n${(error as any).stdout ?? ""}` : String(error); try { const [unmergedOutput, lsFilesOutput, statusOutput] = await Promise.all([ execAsync("git diff --name-only --diff-filter=U", { cwd: operationCwd }), execAsync("git ls-files -u", { cwd: operationCwd }), execAsync("git status --porcelain", { cwd: operationCwd }), ]); const statusConflicts = statusOutput.stdout .split("\n") .map((line) => line.trim()) .filter(Boolean) .filter((line) => /^(UU|AA|DD|AU|UA|UD|DU)\s/.test(line)) .map((line) => line.slice(3).trim()); const conflicts = [ ...unmergedOutput.stdout .split("\n") .map((line) => line.trim()) .filter(Boolean), ...lsFilesOutput.stdout .split("\n") .map((line) => line.trim()) .filter(Boolean) .map((line) => line.split("\t").pop() as string), ...statusConflicts, ].filter(Boolean); const conflictDetected = conflicts.length > 0 || /CONFLICT|Automatic merge failed/i.test(errorDetails); if (conflictDetected) { try { await execAsync("git merge --abort", { cwd: operationCwd }); } catch { // ignore } throw new MergeConflictError({ baseRef: normalizedBaseRef, currentBranch, conflictFiles: conflicts.length > 0 ? conflicts : [], }); } } catch (innerError) { if (innerError instanceof MergeConflictError) { throw innerError; } // ignore detection failures } throw error; } finally { if (isSameCheckout && originalBranch && originalBranch !== normalizedBaseRef) { try { await execAsync(`git checkout ${originalBranch}`, { cwd: operationCwd }); } catch { // ignore } } } } export async function mergeFromBase( cwd: string, options: MergeFromBaseOptions = {}, context?: CheckoutContext ): Promise { const repoInfo = await requireRepoInfo(cwd); const currentBranch = await getCurrentBranch(cwd); if (!currentBranch || currentBranch === "HEAD") { throw new Error("Unable to determine current branch for merge"); } const configured = await getConfiguredBaseRefForCwd(cwd, context); const baseRef = configured.baseRef ?? options.baseRef ?? (await resolveBaseRef(repoInfo.path)); if (!baseRef) { throw new Error("Unable to determine base branch for merge"); } if (configured.isPaseoOwnedWorktree && options.baseRef && options.baseRef !== baseRef) { throw new Error(`Base ref mismatch: expected ${baseRef}, got ${options.baseRef}`); } const requireCleanTarget = options.requireCleanTarget ?? true; if (requireCleanTarget) { const { stdout } = await execAsync("git status --porcelain", { cwd, env: READ_ONLY_GIT_ENV, }); if (stdout.trim().length > 0) { throw new Error("Working directory has uncommitted changes."); } } const normalizedBaseRef = normalizeLocalBranchRefName(baseRef); const bestBaseRef = await resolveBestBaseRefForMerge(cwd, normalizedBaseRef); if (bestBaseRef === currentBranch) { return; } try { await execAsync(`git merge ${bestBaseRef}`, { cwd }); } catch (error) { const errorDetails = error instanceof Error ? `${error.message}\n${(error as any).stderr ?? ""}\n${(error as any).stdout ?? ""}` : String(error); try { const [unmergedOutput, lsFilesOutput, statusOutput] = await Promise.all([ execAsync("git diff --name-only --diff-filter=U", { cwd }), execAsync("git ls-files -u", { cwd }), execAsync("git status --porcelain", { cwd }), ]); const statusConflicts = statusOutput.stdout .split("\n") .map((line) => line.trim()) .filter(Boolean) .filter((line) => /^(UU|AA|DD|AU|UA|UD|DU)\s/.test(line)) .map((line) => line.slice(3).trim()); const conflicts = [ ...unmergedOutput.stdout .split("\n") .map((line) => line.trim()) .filter(Boolean), ...lsFilesOutput.stdout .split("\n") .map((line) => line.trim()) .filter(Boolean) .map((line) => line.split("\t").pop() as string), ...statusConflicts, ].filter(Boolean); const conflictDetected = conflicts.length > 0 || /CONFLICT|Automatic merge failed/i.test(errorDetails); if (conflictDetected) { try { await execAsync("git merge --abort", { cwd }); } catch { // ignore } throw new MergeFromBaseConflictError({ baseRef: bestBaseRef, currentBranch, conflictFiles: conflicts.length > 0 ? conflicts : [], }); } } catch (innerError) { if (innerError instanceof MergeFromBaseConflictError) { throw innerError; } // ignore detection failures } throw error; } } export async function pushCurrentBranch(cwd: string): Promise { await requireRepoInfo(cwd); const currentBranch = await getCurrentBranch(cwd); if (!currentBranch || currentBranch === "HEAD") { throw new Error("Unable to determine current branch for push"); } const hasRemote = await hasOriginRemote(cwd); if (!hasRemote) { throw new Error("Remote 'origin' is not configured."); } await execAsync(`git push -u origin ${currentBranch}`, { cwd }); } export interface CreatePullRequestOptions { title: string; body?: string; base?: string; head?: string; draft?: boolean; } export interface PullRequestStatus { url: string; title: string; state: string; baseRefName: string; headRefName: string; } async function ensureGhAvailable(cwd: string): Promise { try { await execAsync("gh --version", { cwd }); } catch { throw new Error("GitHub CLI (gh) is not available or not authenticated"); } } async function resolveGitHubRepo(cwd: string): Promise { try { const { stdout } = await execAsync("git config --get remote.origin.url", { cwd, env: READ_ONLY_GIT_ENV, }); const url = stdout.trim(); if (!url) { return null; } let cleaned = url; if (cleaned.startsWith("git@github.com:")) { cleaned = cleaned.slice("git@github.com:".length); } else if (cleaned.startsWith("https://github.com/")) { cleaned = cleaned.slice("https://github.com/".length); } else if (cleaned.startsWith("http://github.com/")) { cleaned = cleaned.slice("http://github.com/".length); } else { const marker = "github.com/"; const index = cleaned.indexOf(marker); if (index !== -1) { cleaned = cleaned.slice(index + marker.length); } else { return null; } } if (cleaned.endsWith(".git")) { cleaned = cleaned.slice(0, -".git".length); } if (!cleaned.includes("/")) { return null; } return cleaned; } catch { // ignore } return null; } export async function createPullRequest( cwd: string, options: CreatePullRequestOptions ): Promise<{ url: string; number: number }> { await requireRepoInfo(cwd); await ensureGhAvailable(cwd); const repo = await resolveGitHubRepo(cwd); if (!repo) { throw new Error("Unable to determine GitHub repo from git remote"); } const repoInfo = await detectRepoInfo(cwd); const head = options.head ?? (await getCurrentBranch(cwd)); const configured = await getConfiguredBaseRefForCwd(cwd); const base = configured.baseRef ?? options.base ?? (await resolveBaseRef(repoInfo.path)); if (!head) { throw new Error("Unable to determine head branch for PR"); } if (!base) { throw new Error("Unable to determine base branch for PR"); } const normalizedBase = normalizeLocalBranchRefName(base); if (configured.isPaseoOwnedWorktree && options.base && options.base !== base) { throw new Error(`Base ref mismatch: expected ${base}, got ${options.base}`); } await execAsync(`git push -u origin ${head}`, { cwd }); const args = ["api", "-X", "POST", `repos/${repo}/pulls`, "-f", `title=${options.title}`]; args.push("-f", `head=${head}`); args.push("-f", `base=${normalizedBase}`); if (options.body) { args.push("-f", `body=${options.body}`); } const { stdout } = await execFileAsync("gh", args, { cwd }); const parsed = JSON.parse(stdout.trim()); if (!parsed?.url || !parsed?.number) { throw new Error("GitHub CLI did not return PR url/number"); } return { url: parsed.url, number: parsed.number }; } export async function getPullRequestStatus(cwd: string): Promise { await requireRepoInfo(cwd); await ensureGhAvailable(cwd); const repo = await resolveGitHubRepo(cwd); const head = await getCurrentBranch(cwd); if (!repo || !head) { return null; } const owner = repo.split("/")[0]; const { stdout } = await execFileAsync( "gh", [ "api", `repos/${repo}/pulls`, "-X", "GET", "-F", `head=${owner}:${head}`, "-F", "state=open", ], { cwd } ); const parsed = JSON.parse(stdout.trim()); const current = Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : null; if (!current) { return null; } return { url: current.html_url ?? current.url, title: current.title, state: current.state, baseRefName: current.base?.ref ?? "", headRefName: current.head?.ref ?? head, }; }