import { execSync } from "node:child_process"; import { mkdtemp, writeFile, rm, mkdir, realpath } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; const TEMP_CLEANUP_RETRIES = 5; const TEMP_CLEANUP_RETRY_DELAY_MS = 100; interface TempRepo { path: string; branchHeads: Record; cleanup: () => Promise; } export interface TempDirectory { path: string; cleanup: () => Promise; } /** * The temp root for E2E fixtures. On macOS we resolve symlinks (/tmp → * /private/tmp) so fixture paths match the daemon's resolved paths; on Windows * `/tmp` doesn't exist, so fall back to the OS temp dir. */ export async function resolveTempRoot(): Promise { return process.platform === "win32" ? tmpdir() : await realpath("/tmp"); } async function configureRemote(input: { repoPath: string; withRemote: boolean; originUrl: string | undefined; }): Promise { const { repoPath, withRemote, originUrl } = input; if (withRemote) { // Deterministic local remote to avoid relying on external auth/network in e2e. const remoteDir = path.join(repoPath, "remote.git"); await mkdir(remoteDir, { recursive: true }); execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" }); execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" }); execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" }); if (originUrl) { // Relabel origin to a display URL after the local tracking remote is set // up, so project grouping shows the remote's owner/repo while branch // tracking refs still resolve locally (no fetch from the synthetic URL). execSync(`git remote set-url origin ${JSON.stringify(originUrl)}`, { cwd: repoPath, stdio: "ignore", }); } return; } if (originUrl) { // Daemon reads origin for project grouping; no fetch occurs, so a synthetic URL is fine. execSync(`git remote add origin ${JSON.stringify(originUrl)}`, { cwd: repoPath, stdio: "ignore", }); } } export const createTempGitRepo = async ( prefix = "paseo-e2e-", options?: { withRemote?: boolean; originUrl?: string; paseoConfig?: Record; files?: Array<{ path: string; content: string }>; branches?: string[]; }, ): Promise => { // Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping. const repoPath = await mkdtemp(path.join(await resolveTempRoot(), prefix)); const withRemote = options?.withRemote ?? false; execSync("git init -b main", { cwd: repoPath, stdio: "ignore" }); execSync('git config user.email "e2e@paseo.test"', { cwd: repoPath, stdio: "ignore" }); execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" }); execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" }); await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n"); if (options?.paseoConfig) { await writeFile( path.join(repoPath, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2), ); } for (const file of options?.files ?? []) { const filePath = path.join(repoPath, file.path); await mkdir(path.dirname(filePath), { recursive: true }); await writeFile(filePath, file.content); } execSync("git add README.md", { cwd: repoPath, stdio: "ignore" }); if (options?.paseoConfig) { execSync("git add paseo.json", { cwd: repoPath, stdio: "ignore" }); } for (const file of options?.files ?? []) { execSync(`git add ${JSON.stringify(file.path)}`, { cwd: repoPath, stdio: "ignore" }); } execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" }); const branchHeads: Record = {}; const branches = Array.from(new Set(options?.branches ?? [])); for (const branch of branches) { if (branch !== "main") { execSync(`git checkout -b ${JSON.stringify(branch)} main`, { cwd: repoPath, stdio: "ignore", }); } const markerPath = `.paseo-e2e-${branch.replace(/[^a-zA-Z0-9._-]/g, "-")}.txt`; await writeFile(path.join(repoPath, markerPath), `branch ${branch}\n`); execSync(`git add ${JSON.stringify(markerPath)}`, { cwd: repoPath, stdio: "ignore" }); execSync(`git commit -m ${JSON.stringify(`Add ${branch} marker`)}`, { cwd: repoPath, stdio: "ignore", }); branchHeads[branch] = execSync(`git rev-parse ${JSON.stringify(branch)}`, { cwd: repoPath, stdio: "pipe", }) .toString() .trim(); execSync("git checkout main", { cwd: repoPath, stdio: "ignore" }); } await configureRemote({ repoPath, withRemote, originUrl: options?.originUrl }); return { path: repoPath, branchHeads, cleanup: async () => { await rm(repoPath, { recursive: true, force: true, maxRetries: TEMP_CLEANUP_RETRIES, retryDelay: TEMP_CLEANUP_RETRY_DELAY_MS, }); }, }; }; /** * A plain (non-git) directory opened as a project. The daemon shows its * basename as the project name, since there's no remote to group under. */ export async function createTempDirectory(prefix = "paseo-e2e-dir-"): Promise { const dirPath = await mkdtemp(path.join(await resolveTempRoot(), prefix)); await writeFile(path.join(dirPath, "README.md"), "# Temp Directory\n"); return { path: dirPath, cleanup: async () => { await rm(dirPath, { recursive: true, force: true, maxRetries: TEMP_CLEANUP_RETRIES, retryDelay: TEMP_CLEANUP_RETRY_DELAY_MS, }); }, }; } export async function readWorktreeBranchInfo({ worktreePath }: { worktreePath: string }): Promise<{ currentBranch: string; hasAncestor: (ref: string) => boolean; }> { const currentBranch = execSync("git branch --show-current", { cwd: worktreePath, stdio: "pipe", }) .toString() .trim(); return { currentBranch, hasAncestor: (ref: string) => { try { execSync(`git merge-base --is-ancestor ${JSON.stringify(ref)} HEAD`, { cwd: worktreePath, stdio: "ignore", }); return true; } catch { return false; } }, }; }