import { execFile } from "node:child_process"; import { chmodSync, existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, resolve } from "node:path"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); const DEFAULT_GIT_VALIDATE_REMOTE_TIMEOUT_MS = 15_000; const DEFAULT_GIT_FETCH_TIMEOUT_MS = 2 * 60_000; const DEFAULT_GIT_CLONE_TIMEOUT_MS = 5 * 60_000; interface GitAuthOptions { githubToken?: string | null; } function resolveGithubToken(options?: GitAuthOptions): string | null { const token = options?.githubToken ?? process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? process.env.HF_GITHUB_TOKEN ?? process.env.HF_GH_TOKEN ?? null; if (!token) return null; const trimmed = token.trim(); return trimmed.length > 0 ? trimmed : null; } let cachedAskpassPath: string | null = null; function ensureAskpassScript(): string { if (cachedAskpassPath) { return cachedAskpassPath; } const dir = mkdtempSync(resolve(tmpdir(), "foundry-git-askpass-")); const path = resolve(dir, "askpass.sh"); // Git invokes $GIT_ASKPASS with the prompt string as argv[1]. Provide both username and password. // We avoid embedding the token in this file; it is read from env at runtime. const content = [ "#!/bin/sh", 'prompt="$1"', // Prefer GH_TOKEN/GITHUB_TOKEN but support HF_* aliases too. 'token="${GH_TOKEN:-${GITHUB_TOKEN:-${HF_GITHUB_TOKEN:-${HF_GH_TOKEN:-}}}}"', 'case "$prompt" in', ' *Username*) echo "x-access-token" ;;', ' *Password*) echo "$token" ;;', ' *) echo "" ;;', "esac", "", ].join("\n"); writeFileSync(path, content, "utf8"); chmodSync(path, 0o700); cachedAskpassPath = path; return path; } function gitEnv(options?: GitAuthOptions): Record { const env: Record = { ...(process.env as Record) }; env.GIT_TERMINAL_PROMPT = "0"; const token = resolveGithubToken(options); if (token) { env.GIT_ASKPASS = ensureAskpassScript(); // Some tooling expects these vars; keep them aligned. env.GITHUB_TOKEN = token; env.GH_TOKEN = token; } return env; } async function configureGithubAuth(repoPath: string, options?: GitAuthOptions): Promise { const token = resolveGithubToken(options); if (!token) { return; } const authHeader = Buffer.from(`x-access-token:${token}`, "utf8").toString("base64"); await execFileAsync("git", ["-C", repoPath, "config", "--local", "credential.helper", ""], { env: gitEnv(options), }); await execFileAsync("git", ["-C", repoPath, "config", "--local", "http.https://github.com/.extraheader", `AUTHORIZATION: basic ${authHeader}`], { env: gitEnv(options), }); } export interface BranchSnapshot { branchName: string; commitSha: string; } export async function fetch(repoPath: string, options?: GitAuthOptions): Promise { await execFileAsync("git", ["-C", repoPath, "fetch", "--prune", "--no-auto-gc"], { timeout: DEFAULT_GIT_FETCH_TIMEOUT_MS, env: gitEnv(options), }); } export async function revParse(repoPath: string, ref: string): Promise { const { stdout } = await execFileAsync("git", ["-C", repoPath, "rev-parse", ref], { env: gitEnv() }); return stdout.trim(); } export async function validateRemote(remoteUrl: string, options?: GitAuthOptions): Promise { const remote = remoteUrl.trim(); if (!remote) { throw new Error("remoteUrl is required"); } try { await execFileAsync("git", ["ls-remote", "--exit-code", remote, "HEAD"], { // This command does not need repo context. Running from a neutral directory // avoids inheriting broken worktree .git indirection inside dev containers. cwd: tmpdir(), maxBuffer: 1024 * 1024, timeout: DEFAULT_GIT_VALIDATE_REMOTE_TIMEOUT_MS, env: gitEnv(options), }); } catch (error) { const detail = error instanceof Error ? error.message : String(error); throw new Error(`git remote validation failed: ${detail}`); } } function isGitRepo(path: string): boolean { return existsSync(resolve(path, ".git")); } export async function ensureCloned(remoteUrl: string, targetPath: string, options?: GitAuthOptions): Promise { const remote = remoteUrl.trim(); if (!remote) { throw new Error("remoteUrl is required"); } if (existsSync(targetPath)) { if (!isGitRepo(targetPath)) { throw new Error(`targetPath exists but is not a git repo: ${targetPath}`); } // Keep origin aligned with the configured remote URL. await execFileAsync("git", ["-C", targetPath, "remote", "set-url", "origin", remote], { maxBuffer: 1024 * 1024, timeout: DEFAULT_GIT_FETCH_TIMEOUT_MS, env: gitEnv(options), }); await configureGithubAuth(targetPath, options); await fetch(targetPath, options); return; } mkdirSync(dirname(targetPath), { recursive: true }); await execFileAsync("git", ["clone", remote, targetPath], { maxBuffer: 1024 * 1024 * 8, timeout: DEFAULT_GIT_CLONE_TIMEOUT_MS, env: gitEnv(options), }); await configureGithubAuth(targetPath, options); await fetch(targetPath, options); await ensureLocalBaseBranch(targetPath); } async function hasLocalBranches(repoPath: string): Promise { try { const { stdout } = await execFileAsync("git", ["-C", repoPath, "for-each-ref", "--format=%(refname:short)", "refs/heads"], { env: gitEnv(), }); return stdout .split("\n") .map((line) => line.trim()) .some(Boolean); } catch { return false; } } async function ensureLocalBaseBranch(repoPath: string): Promise { if (await hasLocalBranches(repoPath)) { return; } const baseRef = await remoteDefaultBaseRef(repoPath); const localBranch = baseRef.replace(/^origin\//, ""); await execFileAsync("git", ["-C", repoPath, "checkout", "-B", localBranch, baseRef], { maxBuffer: 1024 * 1024, timeout: DEFAULT_GIT_FETCH_TIMEOUT_MS, env: gitEnv(), }); } export async function remoteDefaultBaseRef(repoPath: string): Promise { try { const { stdout } = await execFileAsync("git", ["-C", repoPath, "symbolic-ref", "refs/remotes/origin/HEAD"], { env: gitEnv() }); const ref = stdout.trim(); // refs/remotes/origin/main const match = ref.match(/^refs\/remotes\/(.+)$/); if (match?.[1]) { return match[1]; } } catch { // fall through } const candidates = ["origin/main", "origin/master", "main", "master"]; for (const ref of candidates) { try { await execFileAsync("git", ["-C", repoPath, "rev-parse", "--verify", ref], { env: gitEnv() }); return ref; } catch { continue; } } return "origin/main"; } export async function listRemoteBranches(repoPath: string, options?: GitAuthOptions): Promise { await fetch(repoPath, options); const { stdout } = await execFileAsync("git", ["-C", repoPath, "for-each-ref", "--format=%(refname:short) %(objectname)", "refs/remotes/origin"], { maxBuffer: 1024 * 1024, env: gitEnv(options), }); return stdout .trim() .split("\n") .filter((line) => line.trim().length > 0) .map((line) => { const [refName, commitSha] = line.trim().split(/\s+/, 2); const short = (refName ?? "").trim(); const branchName = short.replace(/^origin\//, ""); return { branchName, commitSha: commitSha ?? "" }; }) .filter((row) => row.branchName.length > 0 && row.branchName !== "HEAD" && row.branchName !== "origin" && row.commitSha.length > 0); } async function remoteBranchExists(repoPath: string, branchName: string): Promise { try { await execFileAsync("git", ["-C", repoPath, "show-ref", "--verify", `refs/remotes/origin/${branchName}`], { env: gitEnv() }); return true; } catch { return false; } } export async function ensureRemoteBranch(repoPath: string, branchName: string, options?: GitAuthOptions): Promise { await fetch(repoPath, options); await ensureLocalBaseBranch(repoPath); if (await remoteBranchExists(repoPath, branchName)) { return; } const baseRef = await remoteDefaultBaseRef(repoPath); await execFileAsync("git", ["-C", repoPath, "push", "origin", `${baseRef}:refs/heads/${branchName}`], { maxBuffer: 1024 * 1024 * 2, env: gitEnv(options), }); await fetch(repoPath, options); } export async function diffStatForBranch(repoPath: string, branchName: string): Promise { try { const baseRef = await remoteDefaultBaseRef(repoPath); const headRef = `origin/${branchName}`; const { stdout } = await execFileAsync("git", ["-C", repoPath, "diff", "--shortstat", `${baseRef}...${headRef}`], { maxBuffer: 1024 * 1024, env: gitEnv(), }); const trimmed = stdout.trim(); if (!trimmed) { return "+0/-0"; } const insertMatch = trimmed.match(/(\d+)\s+insertion/); const deleteMatch = trimmed.match(/(\d+)\s+deletion/); const insertions = insertMatch ? insertMatch[1] : "0"; const deletions = deleteMatch ? deleteMatch[1] : "0"; return `+${insertions}/-${deletions}`; } catch { return "+0/-0"; } } export async function conflictsWithMain(repoPath: string, branchName: string): Promise { try { const baseRef = await remoteDefaultBaseRef(repoPath); const headRef = `origin/${branchName}`; // Use merge-tree (git 2.38+) for a clean conflict check. try { await execFileAsync("git", ["-C", repoPath, "merge-tree", "--write-tree", "--no-messages", baseRef, headRef], { env: gitEnv() }); // If merge-tree exits 0, no conflicts. Non-zero exit means conflicts. return false; } catch { // merge-tree exits non-zero when there are conflicts return true; } } catch { return false; } } export async function getOriginOwner(repoPath: string): Promise { try { const { stdout } = await execFileAsync("git", ["-C", repoPath, "remote", "get-url", "origin"], { env: gitEnv() }); const url = stdout.trim(); // Handle SSH: git@github.com:owner/repo.git const sshMatch = url.match(/[:\/]([^\/]+)\/[^\/]+(?:\.git)?$/); if (sshMatch) { return sshMatch[1] ?? ""; } // Handle HTTPS: https://github.com/owner/repo.git const httpsMatch = url.match(/\/\/[^\/]+\/([^\/]+)\//); if (httpsMatch) { return httpsMatch[1] ?? ""; } return ""; } catch { return ""; } }