import { execFile, spawn } from "child_process"; import { promisify } from "util"; import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs"; import { rm, stat } from "fs/promises"; import { join, basename, dirname, resolve, sep } from "path"; import net from "node:net"; import { createHash } from "node:crypto"; import * as pty from "node-pty"; import stripAnsi from "strip-ansi"; import { buildStringCommandShellInvocation } from "./string-command-shell.js"; import { readPaseoConfigJson } from "./paseo-config-file.js"; export { PaseoConfigRawSchema, PaseoLifecycleCommandRawSchema, PaseoScriptEntryRawSchema, PaseoWorktreeConfigRawSchema, PaseoConfigSchema, type PaseoConfig, type PaseoConfigRaw, } from "./paseo-config-schema.js"; import { PaseoConfigSchema, type PaseoConfig } from "./paseo-config-schema.js"; import { normalizeBaseRefName, readPaseoWorktreeMetadata, readPaseoWorktreeRuntimePort, writePaseoWorktreeMetadata, writePaseoWorktreeRuntimeMetadata, } from "./worktree-metadata.js"; import { runGitCommand } from "./run-git-command.js"; import { resolvePaseoHome } from "../server/paseo-home.js"; import { ensureNodePtySpawnHelperExecutableForCurrentPlatform } from "../terminal/terminal.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; const execFileAsync = promisify(execFile); const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = { ...process.env, GIT_OPTIONAL_LOCKS: "0", }; export interface WorktreeConfig { branchName: string; worktreePath: string; } export interface WorktreeRuntimeEnv { [key: string]: string; PASEO_SOURCE_CHECKOUT_PATH: string; PASEO_ROOT_PATH: string; PASEO_WORKTREE_PATH: string; PASEO_BRANCH_NAME: string; PASEO_WORKTREE_PORT: string; } export interface WorktreeSetupCommandResult { command: string; cwd: string; stdout: string; stderr: string; exitCode: number | null; durationMs: number; } export type WorktreeSetupCommandProgressEvent = | { type: "command_started"; index: number; total: number; command: string; cwd: string; } | { type: "output"; index: number; total: number; command: string; cwd: string; stream: "stdout" | "stderr"; chunk: string; } | { type: "command_completed"; index: number; total: number; command: string; cwd: string; exitCode: number | null; durationMs: number; stdout: string; stderr: string; }; export interface WorktreeTerminalConfig { name?: string; command: string; } export interface PlainScriptConfig { type?: undefined; command: string; port?: undefined; } export interface ServiceScriptConfig { type: "service"; command: string; port?: number; // explicit port override, otherwise auto-assigned } export type ScriptConfig = PlainScriptConfig | ServiceScriptConfig; export function isServiceScript(config: ScriptConfig): config is ServiceScriptConfig { return "type" in config && config.type === "service"; } export class WorktreeSetupError extends Error { readonly results: WorktreeSetupCommandResult[]; constructor(message: string, results: WorktreeSetupCommandResult[]) { super(message); this.name = "WorktreeSetupError"; this.results = results; } } export type WorktreeTeardownCommandResult = WorktreeSetupCommandResult; export class WorktreeTeardownError extends Error { readonly results: WorktreeTeardownCommandResult[]; constructor(message: string, results: WorktreeTeardownCommandResult[]) { super(message); this.name = "WorktreeTeardownError"; this.results = results; } } export interface PaseoWorktreeInfo { path: string; createdAt: string; branchName?: string; head?: string; } export interface PaseoWorktreeOwnership { allowed: boolean; repoRoot?: string; worktreeRoot?: string; worktreePath?: string; } export type WorktreeSource = | { kind: "branch-off"; baseBranch: string; newBranchName: string } | { kind: "checkout-branch"; branchName: string } | { kind: "checkout-github-pr"; githubPrNumber: number; headRef: string; baseRefName: string; localBranchName?: string; pushRemoteUrl?: string; }; export interface CreateWorktreeOptions { cwd: string; worktreeSlug: string; source: WorktreeSource; runSetup: boolean; paseoHome?: string; } interface ResolveExistingWorktreeForSlugOptions { slug: string; repoRoot: string; paseoHome?: string; } export class BranchAlreadyCheckedOutError extends Error { readonly branchName: string; constructor(branchName: string) { super(`Branch already checked out: ${branchName}`); this.name = "BranchAlreadyCheckedOutError"; this.branchName = branchName; } } export class UnknownBranchError extends Error { readonly branchName: string; readonly cwd: string; constructor(params: { branchName: string; cwd: string }) { super(`Unknown branch: ${params.branchName}`); this.name = "UnknownBranchError"; this.branchName = params.branchName; this.cwd = params.cwd; } } export function readPaseoConfig(repoRoot: string): PaseoConfig | null { try { const json = readPaseoConfigJson(repoRoot); if (json === null) { return null; } return PaseoConfigSchema.parse(json); } catch { throw new Error(`Failed to parse paseo.json`); } } export function getWorktreeSetupCommands(repoRoot: string): string[] { const config = readPaseoConfig(repoRoot); return config?.worktree?.setup ?? []; } export function getWorktreeTeardownCommands(repoRoot: string): string[] { const config = readPaseoConfig(repoRoot); return config?.worktree?.teardown ?? []; } export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConfig[] { const config = readPaseoConfig(repoRoot); const terminals = config?.worktree?.terminals; if (!Array.isArray(terminals) || terminals.length === 0) { return []; } const specs: WorktreeTerminalConfig[] = []; for (const terminal of terminals) { if (!terminal || typeof terminal !== "object") { continue; } const rawCommand = terminal.command; if (typeof rawCommand !== "string") { continue; } const command = rawCommand.trim(); if (!command) { continue; } const rawName = terminal.name; const name = typeof rawName === "string" && rawName.trim().length > 0 ? rawName.trim() : undefined; specs.push({ ...(name ? { name } : {}), command, }); } return specs; } export function getScriptConfigs(repoRoot: string): Map { const config = readPaseoConfig(repoRoot); const scripts = config?.scripts; if (!scripts || typeof scripts !== "object") { return new Map(); } const result = new Map(); for (const [name, entry] of Object.entries(scripts)) { if (!entry || typeof entry !== "object") { continue; } const rawCommand = entry.command; if (typeof rawCommand !== "string") { continue; } const command = rawCommand.trim(); if (!command) { continue; } const scriptConfig: ScriptConfig = entry.type === "service" ? { type: "service", command, } : { command }; if ( isServiceScript(scriptConfig) && typeof entry.port === "number" && Number.isFinite(entry.port) ) { scriptConfig.port = entry.port; } result.set(name, scriptConfig); } return result; } export function processCarriageReturns(text: string): string { if (!text.includes("\r")) { return text; } const output: string[] = []; let line: string[] = []; let cursor = 0; const flushLine = () => { output.push(line.join("")); line = []; cursor = 0; }; for (let index = 0; index < text.length; index += 1) { const char = text[index]; if (char === "\r") { if (text[index + 1] === "\n") { flushLine(); output.push("\n"); index += 1; continue; } cursor = 0; continue; } if (char === "\n") { flushLine(); output.push("\n"); continue; } if (cursor < line.length) { line[cursor] = char; } else { line.push(char); } cursor += 1; } if (line.length > 0) { output.push(line.join("")); } return output.join(""); } async function execSetupCommand( command: string, options: { cwd: string; env: NodeJS.ProcessEnv }, ): Promise { const startedAt = Date.now(); const shellInvocation = buildStringCommandShellInvocation({ command }); try { const { stdout, stderr } = await execFileAsync(shellInvocation.shell, shellInvocation.args, { cwd: options.cwd, env: options.env, }); return { command, cwd: options.cwd, stdout: stdout ?? "", stderr: stderr ?? "", exitCode: 0, durationMs: Date.now() - startedAt, }; } catch (error) { const execErr = error as { stdout?: string; stderr?: string; code?: unknown } | undefined; return { command, cwd: options.cwd, stdout: execErr?.stdout ?? "", stderr: execErr?.stderr ?? (error instanceof Error ? error.message : String(error)), exitCode: typeof execErr?.code === "number" ? execErr.code : null, durationMs: Date.now() - startedAt, }; } } async function execSetupCommandStreamed(options: { command: string; cwd: string; env: NodeJS.ProcessEnv; index: number; total: number; onEvent?: (event: WorktreeSetupCommandProgressEvent) => void; }): Promise { return new Promise((resolvePromise) => { const startedAt = Date.now(); const stdoutChunks: string[] = []; const stderrChunks: string[] = []; let settled = false; const emitOutput = (stream: "stdout" | "stderr", chunk: string) => { const text = stripAnsi(chunk); if (!text) { return; } if (stream === "stdout") { stdoutChunks.push(text); } else { stderrChunks.push(text); } options.onEvent?.({ type: "output", index: options.index, total: options.total, command: options.command, cwd: options.cwd, stream, chunk: text, }); }; const finish = (exitCode: number | null) => { if (settled) { return; } settled = true; const result: WorktreeSetupCommandResult = { command: options.command, cwd: options.cwd, stdout: stdoutChunks.join(""), stderr: stderrChunks.join(""), exitCode, durationMs: Date.now() - startedAt, }; options.onEvent?.({ type: "command_completed", index: options.index, total: options.total, command: options.command, cwd: options.cwd, exitCode: result.exitCode, durationMs: result.durationMs, stdout: result.stdout, stderr: result.stderr, }); resolvePromise(result); }; options.onEvent?.({ type: "command_started", index: options.index, total: options.total, command: options.command, cwd: options.cwd, }); const spawnWithPipes = () => { const shellInvocation = buildStringCommandShellInvocation({ command: options.command }); const child = spawn(shellInvocation.shell, shellInvocation.args, { cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"], }); child.stdout?.on("data", (chunk: Buffer | string) => { emitOutput("stdout", chunk.toString()); }); child.stderr?.on("data", (chunk: Buffer | string) => { emitOutput("stderr", chunk.toString()); }); child.on("error", (error) => { emitOutput("stderr", error instanceof Error ? error.message : String(error)); finish(null); }); child.on("close", (code) => { finish(typeof code === "number" ? code : null); }); }; try { ensureNodePtySpawnHelperExecutableForCurrentPlatform(); const shellInvocation = buildStringCommandShellInvocation({ command: options.command }); const terminal = pty.spawn(shellInvocation.shell, shellInvocation.args, { cwd: options.cwd, env: options.env, name: "xterm-color", cols: 120, rows: 30, }); terminal.onData((data) => { emitOutput("stdout", data); }); terminal.onExit(({ exitCode }) => { finish(typeof exitCode === "number" ? exitCode : null); }); } catch (error) { emitOutput("stderr", error instanceof Error ? error.message : String(error)); spawnWithPipes(); } }); } async function getAvailablePort(): Promise { return new Promise((resolvePromise, reject) => { const server = net.createServer(); server.once("error", reject); server.listen(0, () => { const address = server.address(); if (!address || typeof address === "string") { server.close(() => reject(new Error("Failed to acquire available port"))); return; } server.close((error) => { if (error) { reject(error); return; } resolvePromise(address.port); }); }); }); } async function assertPortAvailable(port: number): Promise { await new Promise((resolvePromise, reject) => { const server = net.createServer(); server.once("error", (error: NodeJS.ErrnoException) => { let message: string; if (error?.code === "EADDRINUSE") { message = `Persisted worktree port ${port} is already in use`; } else if (error instanceof Error) { message = error.message; } else { message = String(error); } reject(new Error(message)); }); server.listen(port, () => { server.close((error) => { if (error) { reject(error); return; } resolvePromise(); }); }); }); } async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise { try { const commonDir = await getGitCommonDir(worktreePath); const normalizedCommonDir = normalizePathForOwnership(commonDir); // Normal repo/worktree: common dir is /.git if (basename(normalizedCommonDir) === ".git") { return dirname(normalizedCommonDir); } // Bare repo: common dir is the repo dir itself return normalizedCommonDir; } catch { // Fallback: best-effort resolve toplevel (will be the worktree root in typical cases) try { const { stdout } = await runGitCommand(["rev-parse", "--show-toplevel"], { cwd: worktreePath, env: READ_ONLY_GIT_ENV, }); const topLevel = parseGitRevParsePath(stdout); if (topLevel) { return normalizePathForOwnership(topLevel); } } catch { // ignore } return normalizePathForOwnership(worktreePath); } } export async function runWorktreeSetupCommands(options: { worktreePath: string; branchName: string; cleanupOnFailure: boolean; repoRootPath?: string; runtimeEnv?: WorktreeRuntimeEnv; onEvent?: (event: WorktreeSetupCommandProgressEvent) => void; }): Promise { // Read paseo.json from the worktree (it will have the same content as the source repo) const setupCommands = getWorktreeSetupCommands(options.worktreePath); if (setupCommands.length === 0) { return []; } const runtimeEnv = options.runtimeEnv ?? (await resolveWorktreeRuntimeEnv({ worktreePath: options.worktreePath, branchName: options.branchName, ...(options.repoRootPath ? { repoRootPath: options.repoRootPath } : {}), })); const setupEnv = { ...process.env, ...runtimeEnv, }; const results: WorktreeSetupCommandResult[] = []; for (const [index, cmd] of setupCommands.entries()) { const result = options.onEvent ? await execSetupCommandStreamed({ command: cmd, cwd: options.worktreePath, env: setupEnv, index: index + 1, total: setupCommands.length, onEvent: options.onEvent, }) : await execSetupCommand(cmd, { cwd: options.worktreePath, env: setupEnv, }); results.push(result); if (result.exitCode !== 0) { if (options.cleanupOnFailure) { try { await runGitCommand(["worktree", "remove", options.worktreePath, "--force"], { cwd: options.worktreePath, timeout: 120_000, }); } catch { rmSync(options.worktreePath, { recursive: true, force: true }); } } throw new WorktreeSetupError( `Worktree setup command failed: ${cmd}\n${result.stderr}`.trim(), results, ); } } return results; } async function resolveBranchNameForWorktreePath(worktreePath: string): Promise { try { const { stdout } = await runGitCommand(["branch", "--show-current"], { cwd: worktreePath, env: READ_ONLY_GIT_ENV, }); const branchName = stdout.trim(); if (branchName.length > 0) { return branchName; } } catch { // ignore } return basename(worktreePath); } export async function resolveWorktreeRuntimeEnv(options: { worktreePath: string; branchName?: string; repoRootPath?: string; }): Promise { const repoRootPath = options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath)); const branchName = options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath)); let worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath); if (worktreePort === null) { worktreePort = await getAvailablePort(); const metadata = readPaseoWorktreeMetadata(options.worktreePath); if (metadata) { writePaseoWorktreeRuntimeMetadata(options.worktreePath, { worktreePort }); } } else { await assertPortAvailable(worktreePort); } return { // Source checkout path is the original git repo root (shared across worktrees), not the // worktree itself. This allows setup scripts to copy local files (e.g. .env) from the // source checkout. PASEO_SOURCE_CHECKOUT_PATH: repoRootPath, // Backward-compatible alias. PASEO_ROOT_PATH: repoRootPath, PASEO_WORKTREE_PATH: options.worktreePath, PASEO_BRANCH_NAME: branchName, PASEO_WORKTREE_PORT: String(worktreePort), }; } export async function runWorktreeTeardownCommands(options: { worktreePath: string; branchName?: string; repoRootPath?: string; }): Promise { // Read paseo.json from the worktree (it will have the same content as the source repo) const teardownCommands = getWorktreeTeardownCommands(options.worktreePath); if (teardownCommands.length === 0) { return []; } const repoRootPath = options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath)); const branchName = options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath)); const worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath); const teardownEnv: NodeJS.ProcessEnv = { ...process.env, // Source checkout path is the original git repo root (shared across worktrees), not the // worktree itself. This allows lifecycle scripts to copy or clean resources using paths // from the source checkout. PASEO_SOURCE_CHECKOUT_PATH: repoRootPath, // Backward-compatible alias. PASEO_ROOT_PATH: repoRootPath, PASEO_WORKTREE_PATH: options.worktreePath, PASEO_BRANCH_NAME: branchName, ...(worktreePort !== null ? { PASEO_WORKTREE_PORT: String(worktreePort) } : {}), }; const results: WorktreeTeardownCommandResult[] = []; for (const cmd of teardownCommands) { const result = await execSetupCommand(cmd, { cwd: options.worktreePath, env: teardownEnv, }); results.push(result); if (result.exitCode !== 0) { throw new WorktreeTeardownError( `Worktree teardown command failed: ${cmd}\n${result.stderr}`.trim(), results, ); } } return results; } /** * Get the git common directory (shared across worktrees) for a given cwd. * This is where refs, objects, etc. are stored. */ export async function getGitCommonDir(cwd: string): Promise { const { stdout } = await runGitCommand(["rev-parse", "--git-common-dir"], { cwd, env: READ_ONLY_GIT_ENV, }); const commonDir = resolveGitRevParsePath(cwd, stdout); if (!commonDir) { throw new Error("Not in a git repository"); } return commonDir; } /** * Validate that a string is a valid git branch name slug * Must be lowercase, alphanumeric, hyphens only */ export function validateBranchSlug(slug: string): { valid: boolean; error?: string; } { if (!slug || slug.length === 0) { return { valid: false, error: "Branch name cannot be empty" }; } if (slug.length > 100) { return { valid: false, error: "Branch name too long (max 100 characters)" }; } // Check for valid characters: lowercase letters, numbers, hyphens, forward slashes const validPattern = /^[a-z0-9-/]+$/; if (!validPattern.test(slug)) { return { valid: false, error: "Branch name must contain only lowercase letters, numbers, hyphens, and forward slashes", }; } // Cannot start or end with hyphen if (slug.startsWith("-") || slug.endsWith("-")) { return { valid: false, error: "Branch name cannot start or end with a hyphen", }; } // Cannot have consecutive hyphens if (slug.includes("--")) { return { valid: false, error: "Branch name cannot have consecutive hyphens" }; } return { valid: true }; } const MAX_SLUG_LENGTH = 50; /** * Convert string to kebab-case for branch names */ export function slugify(input: string): string { const slug = input .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); if (slug.length <= MAX_SLUG_LENGTH) { return slug; } // Truncate at word boundary (hyphen) if possible const truncated = slug.slice(0, MAX_SLUG_LENGTH); const lastHyphen = truncated.lastIndexOf("-"); if (lastHyphen > MAX_SLUG_LENGTH / 2) { return truncated.slice(0, lastHyphen); } return truncated.replace(/-+$/, ""); } const WORKTREE_PROJECT_HASH_LENGTH = 8; function deriveShortAlphanumericHash(value: string): string { const digest = createHash("sha256").update(value).digest(); let hashValue = 0n; for (let index = 0; index < 8; index += 1) { hashValue = (hashValue << 8n) | BigInt(digest[index] ?? 0); } return hashValue.toString(36).padStart(13, "0").slice(0, WORKTREE_PROJECT_HASH_LENGTH); } export async function deriveWorktreeProjectHash(cwd: string): Promise { try { const commonDir = await getGitCommonDir(cwd); const normalizedCommonDir = normalizePathForOwnership(commonDir); const repoRoot = basename(normalizedCommonDir) === ".git" ? dirname(normalizedCommonDir) : normalizedCommonDir; return deriveShortAlphanumericHash(repoRoot); } catch { return deriveShortAlphanumericHash(normalizePathForOwnership(cwd)); } } export async function getPaseoWorktreesRoot(cwd: string, paseoHome?: string): Promise { const home = paseoHome ? resolve(paseoHome) : resolvePaseoHome(); const projectHash = await deriveWorktreeProjectHash(cwd); return join(home, "worktrees", projectHash); } export async function computeWorktreePath( cwd: string, slug: string, paseoHome?: string, ): Promise { const worktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome); return join(worktreesRoot, slug); } function normalizePathForOwnership(input: string): string { try { return realpathSync(input); } catch { return resolve(input); } } function resolveRepoRootFromGitCommonDir(commonDir: string): string { const normalizedCommonDir = normalizePathForOwnership(commonDir); return basename(normalizedCommonDir) === ".git" ? dirname(normalizedCommonDir) : normalizedCommonDir; } export async function isPaseoOwnedWorktreeCwd( cwd: string, options?: { paseoHome?: string }, ): Promise { const resolvedCwd = normalizePathForOwnership(cwd); // repoRoot is best-effort: git may be unreachable from the worktree (e.g. a // previous archive attempt removed the admin dir before the working tree // could be fully cleaned up). We still want to allow archiving in that case. let repoRoot: string | undefined; try { const gitCommonDir = await getGitCommonDir(cwd); repoRoot = resolveRepoRootFromGitCommonDir(gitCommonDir); } catch { // ignore } const paseoHome = options?.paseoHome ? resolve(options.paseoHome) : resolvePaseoHome(); const paseoWorktreesPrefix = normalizePathForOwnership(join(paseoHome, "worktrees")) + sep; // Ownership is defined by the path living under $PASEO_HOME/worktrees//[/...]. // The / prefix is Paseo-private — nothing else writes there — so the // path shape alone is sufficient proof of ownership, even when git has already // forgotten about the worktree. if (!resolvedCwd.startsWith(paseoWorktreesPrefix)) { return { allowed: false, ...(repoRoot !== undefined ? { repoRoot } : {}), worktreePath: resolvedCwd, }; } const relative = resolvedCwd.slice(paseoWorktreesPrefix.length); const parts = relative.split(sep).filter((part) => part.length > 0); if (parts.length < 2) { return { allowed: false, ...(repoRoot !== undefined ? { repoRoot } : {}), worktreePath: resolvedCwd, }; } const worktreesRoot = join(paseoHome, "worktrees", parts[0]!); return { allowed: true, ...(repoRoot !== undefined ? { repoRoot } : {}), worktreeRoot: worktreesRoot, worktreePath: resolvedCwd, }; } type ParsedPaseoWorktreeInfo = Omit; function parseWorktreeList(output: string): ParsedPaseoWorktreeInfo[] { const entries: ParsedPaseoWorktreeInfo[] = []; let current: ParsedPaseoWorktreeInfo | null = null; for (const line of output.split("\n")) { if (line.startsWith("worktree ")) { if (current?.path) { entries.push(current); } current = { path: line.slice("worktree ".length).trim() }; continue; } if (!current) { continue; } if (line.startsWith("branch ")) { const ref = line.slice("branch ".length).trim(); current.branchName = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref; } else if (line.startsWith("HEAD ")) { current.head = line.slice("HEAD ".length).trim(); } else if (line.trim().length === 0) { if (current.path) { entries.push(current); } current = null; } } if (current?.path) { entries.push(current); } return entries; } function resolveWorktreeCreatedAtIso(worktreePath: string): string { try { const stats = statSync(worktreePath); const birthtimeMs = stats.birthtimeMs; const createdAtMs = Number.isFinite(birthtimeMs) && birthtimeMs > 0 ? birthtimeMs : stats.ctimeMs; return new Date(createdAtMs).toISOString(); } catch { return new Date(0).toISOString(); } } export async function listPaseoWorktrees({ cwd, paseoHome, }: { cwd: string; paseoHome?: string; }): Promise { const worktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome); const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], { cwd, env: READ_ONLY_GIT_ENV, }); const rootPrefix = normalizePathForOwnership(worktreesRoot) + sep; return parseWorktreeList(stdout) .map((entry) => Object.assign({}, entry, { path: normalizePathForOwnership(entry.path) })) .filter((entry) => entry.path.startsWith(rootPrefix)) .map((entry) => Object.assign({}, entry, { createdAt: resolveWorktreeCreatedAtIso(entry.path) }), ); } export async function resolveExistingWorktreeForSlug({ slug, repoRoot, paseoHome, }: ResolveExistingWorktreeForSlugOptions): Promise { const worktrees = await listPaseoWorktrees({ cwd: repoRoot, paseoHome, }); const slugSuffix = `${sep}${slug}`; const existingWorktree = worktrees.find((worktree) => worktree.path.endsWith(slugSuffix)); if (!existingWorktree) { return null; } const { stdout } = await runGitCommand(["branch", "--show-current"], { cwd: existingWorktree.path, env: READ_ONLY_GIT_ENV, }); const branchName = stdout.trim(); if (!branchName) { throw new Error(`Unable to resolve branch for existing worktree: ${existingWorktree.path}`); } return { branchName, worktreePath: existingWorktree.path, }; } export async function resolvePaseoWorktreeRootForCwd( cwd: string, options?: { paseoHome?: string }, ): Promise<{ repoRoot: string; worktreeRoot: string; worktreePath: string } | null> { let gitCommonDir: string; try { gitCommonDir = await getGitCommonDir(cwd); } catch { return null; } const worktreesRoot = await getPaseoWorktreesRoot(cwd, options?.paseoHome); const resolvedRoot = normalizePathForOwnership(worktreesRoot) + sep; let worktreeRoot: string | null = null; try { const { stdout } = await runGitCommand(["rev-parse", "--show-toplevel"], { cwd, env: READ_ONLY_GIT_ENV, }); worktreeRoot = parseGitRevParsePath(stdout); } catch { worktreeRoot = null; } if (!worktreeRoot) { return null; } const resolvedWorktreeRoot = normalizePathForOwnership(worktreeRoot); if (!resolvedWorktreeRoot.startsWith(resolvedRoot)) { return null; } const knownWorktrees = await listPaseoWorktrees({ cwd, paseoHome: options?.paseoHome, }); const match = knownWorktrees.find((entry) => entry.path === resolvedWorktreeRoot); if (!match) { return null; } return { repoRoot: gitCommonDir, worktreeRoot: worktreesRoot, worktreePath: match.path, }; } export async function deletePaseoWorktree({ cwd, worktreePath, worktreeSlug, worktreesRoot, paseoHome, }: { cwd: string | null; worktreePath?: string; worktreeSlug?: string; worktreesRoot?: string; paseoHome?: string; }): Promise { if (!worktreePath && !worktreeSlug) { throw new Error("worktreePath or worktreeSlug is required"); } // Resolve the worktrees-root. With a repo cwd we hash it the normal way; if // git has forgotten about the worktree we expect the caller to hand us the // path-derived worktreesRoot from the ownership check. let resolvedWorktreesRoot: string; if (worktreesRoot) { resolvedWorktreesRoot = worktreesRoot; } else if (cwd) { resolvedWorktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome); } else { throw new Error("cwd or worktreesRoot is required to delete a Paseo worktree"); } const resolvedRoot = normalizePathForOwnership(resolvedWorktreesRoot) + sep; const requestedPath = worktreePath ?? join(resolvedWorktreesRoot, worktreeSlug!); const resolvedRequested = normalizePathForOwnership(requestedPath); const resolvedWorktree = (await resolvePaseoWorktreeRootForCwd(requestedPath, { paseoHome }))?.worktreePath ?? resolvedRequested; if (!resolvedWorktree.startsWith(resolvedRoot)) { throw new Error("Refusing to delete non-Paseo worktree"); } if (await pathExists(resolvedWorktree)) { await runWorktreeTeardownCommands({ worktreePath: resolvedWorktree, }); } if (cwd) { try { await runGitCommand(["worktree", "remove", resolvedWorktree, "--force"], { cwd, timeout: 120_000, }); } catch { // `git worktree remove` fails if the admin dir is already gone (e.g. a // prior archive attempt removed it before the working tree could be // fully cleaned up), or if the repo root has moved. Fall through to the // rm retry loop below so the operation stays idempotent. } } await removeDirectoryWithRetries(resolvedWorktree); if (cwd) { try { await runGitCommand(["worktree", "prune"], { cwd, timeout: 30_000 }); } catch { // not critical; git will prune lazily } } } async function pathExists(path: string): Promise { try { await stat(path); return true; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return false; } throw error; } } async function removeDirectoryWithRetries(path: string): Promise { if (!(await pathExists(path))) { return; } const delaysMs = [0, 100, 300, 700, 1500]; let lastError: unknown = null; for (const delay of delaysMs) { if (delay > 0) { await new Promise((resolvePromise) => setTimeout(resolvePromise, delay)); } try { await rm(path, { recursive: true, force: true }); if (!(await pathExists(path))) { return; } lastError = new Error(`Directory still present after rm: ${path}`); } catch (error) { lastError = error; } } if (await pathExists(path)) { throw lastError instanceof Error ? lastError : new Error(`Failed to remove worktree directory: ${path}`); } } /** * Create a git worktree with proper naming conventions */ export const createWorktree = async ({ cwd, source, worktreeSlug, runSetup, paseoHome, }: CreateWorktreeOptions): Promise => { const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug }); let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome), worktreeSlug); mkdirSync(dirname(worktreePath), { recursive: true }); // Also handle worktree path collision let finalWorktreePath = worktreePath; let pathSuffix = 1; while (existsSync(finalWorktreePath)) { finalWorktreePath = `${worktreePath}-${pathSuffix}`; pathSuffix++; } // Primitive owner for `git worktree add`; callers route through createWorktreeCore. await runGitCommand(["worktree", "add", finalWorktreePath, ...sourcePlan.addArguments], { cwd, timeout: 120_000, }); worktreePath = normalizePathForOwnership(finalWorktreePath); if (sourcePlan.pushRemote) { await configureWorktreePushRemote({ cwd, branchName: sourcePlan.branchName, remote: sourcePlan.pushRemote, }); } writePaseoWorktreeMetadata(worktreePath, { baseRefName: sourcePlan.metadataBaseRefName }); if (runSetup) { await runWorktreeSetupCommands({ worktreePath, branchName: sourcePlan.branchName, cleanupOnFailure: true, }); } return { branchName: sourcePlan.branchName, worktreePath, }; }; interface ResolveWorktreeSourcePlanOptions { cwd: string; source: WorktreeSource; desiredSlug: string; } interface WorktreeSourcePlan { branchName: string; metadataBaseRefName: string; addArguments: string[]; pushRemote?: { name: string; url: string; headRef: string; }; } async function resolveWorktreeSourcePlan({ cwd, source, desiredSlug, }: ResolveWorktreeSourcePlanOptions): Promise { switch (source.kind) { case "branch-off": { const branchName = source.newBranchName; validateWorktreeBranchName(branchName); const normalizedBaseBranch = normalizeRequiredBaseBranch(source.baseBranch); const resolvedBaseBranch = await resolveBaseBranchForWorktree(cwd, normalizedBaseBranch); const branchExists = await localBranchExists(cwd, branchName); const base = branchExists ? branchName : resolvedBaseBranch; const candidateBranch = branchExists ? desiredSlug : branchName; const newBranchName = await resolveUniqueLocalBranchName(cwd, candidateBranch); return { branchName: newBranchName, metadataBaseRefName: normalizedBaseBranch, addArguments: ["-b", newBranchName, base], }; } case "checkout-branch": { validateWorktreeBranchName(source.branchName); if (!(await localBranchExists(cwd, source.branchName))) { try { await runGitCommand(["fetch", "origin", `${source.branchName}:${source.branchName}`], { cwd, timeout: 120_000, }); } catch { throw new UnknownBranchError({ branchName: source.branchName, cwd }); } } if (await isBranchCheckedOut(cwd, source.branchName)) { throw new BranchAlreadyCheckedOutError(source.branchName); } return { branchName: source.branchName, metadataBaseRefName: source.branchName, addArguments: [source.branchName], }; } case "checkout-github-pr": { const localBranchCandidate = source.localBranchName ?? source.headRef; validateWorktreeBranchName(localBranchCandidate); const localBranchName = await resolveUniqueLocalBranchName(cwd, localBranchCandidate); const normalizedBaseRefName = normalizeRequiredBaseBranch(source.baseRefName); await runGitCommand( [ "fetch", "origin", `refs/pull/${source.githubPrNumber}/head:refs/heads/${localBranchName}`, "--force", ], { cwd, timeout: 120_000, }, ); return { branchName: localBranchName, metadataBaseRefName: normalizedBaseRefName, addArguments: [localBranchName], ...(source.pushRemoteUrl ? { pushRemote: { name: `paseo-pr-${source.githubPrNumber}`, url: source.pushRemoteUrl, headRef: source.headRef, }, } : {}), }; } } } async function configureWorktreePushRemote(options: { cwd: string; branchName: string; remote: { name: string; url: string; headRef: string; }; }): Promise { await runGitCommand(["config", `remote.${options.remote.name}.url`, options.remote.url], { cwd: options.cwd, }); await runGitCommand( ["config", `remote.${options.remote.name}.push`, `HEAD:refs/heads/${options.remote.headRef}`], { cwd: options.cwd }, ); await runGitCommand(["config", `branch.${options.branchName}.remote`, options.remote.name], { cwd: options.cwd, }); await runGitCommand( ["config", `branch.${options.branchName}.merge`, `refs/heads/${options.remote.headRef}`], { cwd: options.cwd }, ); } function validateWorktreeBranchName(branchName: string): void { const validation = validateBranchSlug(branchName); if (!validation.valid) { throw new Error(`Invalid branch name: ${validation.error}`); } } function normalizeRequiredBaseBranch(baseBranch: string): string { const normalizedBaseBranch = normalizeBaseRefName(baseBranch); if (!normalizedBaseBranch) { throw new Error("Base branch is required when creating a Paseo worktree"); } if (normalizedBaseBranch === "HEAD") { throw new Error("Base branch cannot be HEAD when creating a Paseo worktree"); } return normalizedBaseBranch; } async function resolveBaseBranchForWorktree( cwd: string, normalizedBaseBranch: string, ): Promise { try { await runGitCommand(["rev-parse", "--verify", `origin/${normalizedBaseBranch}`], { cwd }); return `origin/${normalizedBaseBranch}`; } catch { try { await runGitCommand(["rev-parse", "--verify", normalizedBaseBranch], { cwd }); return normalizedBaseBranch; } catch { throw new Error(`Base branch not found: ${normalizedBaseBranch}`); } } } async function localBranchExists(cwd: string, branchName: string): Promise { try { await runGitCommand(["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], { cwd, }); return true; } catch { return false; } } async function resolveUniqueLocalBranchName(cwd: string, candidateBranch: string): Promise { let newBranchName = candidateBranch; let suffix = 1; while (await localBranchExists(cwd, newBranchName)) { newBranchName = `${candidateBranch}-${suffix}`; suffix++; } return newBranchName; } async function isBranchCheckedOut(cwd: string, branchName: string): Promise { const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], { cwd, env: READ_ONLY_GIT_ENV, }); return parseWorktreeList(stdout).some((entry) => entry.branchName === branchName); }