import { existsSync, mkdirSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; import type { WorkspaceMode, WorkspaceProbe } from "agent-relay-sdk"; import { errMessage } from "agent-relay-sdk"; import { git, requireGit } from "../git"; import { withRepoLock } from "../repo-lock"; import { provisionWorkspaceDeps, provisionWorkspaceSymlinks } from "./deps"; import { availableBranch, branchName, terminalBaseRef, workspaceId, workspacesRoot, repoSlug } from "./names"; import { parseWorktrees, shortBranch } from "./parse"; import type { WorkspaceResolution, WorkspaceResolutionInput } from "./types"; export async function probeWorkspace(requestedPath: string): Promise { const path = resolve(requestedPath); try { const stat = statSync(path); if (!stat.isDirectory()) return { path, isGitRepo: false, error: `Not a directory: ${path}` }; } catch (error) { return { path, isGitRepo: false, error: errMessage(error) }; } const root = await git(["rev-parse", "--show-toplevel"], path); if (!root.ok || !root.stdout) return { path, isGitRepo: false }; const repoRoot = root.stdout; const [branchRef, head, status, worktrees] = await Promise.all([ git(["symbolic-ref", "--quiet", "--short", "HEAD"], repoRoot), git(["rev-parse", "HEAD"], repoRoot), git(["status", "--porcelain"], repoRoot), git(["worktree", "list", "--porcelain"], repoRoot), ]); const branch = shortBranch(branchRef.stdout); return { path, isGitRepo: true, repoRoot, ...(branch ? { branch } : {}), ...(head.ok && head.stdout ? { headSha: head.stdout } : {}), dirty: status.ok ? status.stdout.length > 0 : undefined, detached: !branch, ...(worktrees.ok ? { worktrees: parseWorktrees(worktrees.stdout) } : {}), }; } export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Promise { const requestedMode = input.workspaceMode ?? "inherit"; const sourceCwd = resolve(input.cwd); const probe = await probeWorkspace(sourceCwd); // #328 backstop — an EXPLICIT `isolated` request needs a git repo to branch off. Without one, // the old code silently downgraded to `shared` (no branch, no worktree, no signal), so a worker // asked for an isolated branch instead edited a live tree. Fail loud and actionable instead of // fabricating a different mode than the caller asked for. (`inherit`→isolated, from a policy or // automation in a non-repo dir, keeps its legitimate shared fallback below.) if (requestedMode === "isolated" && !probe.isGitRepo) { throw new Error(`workspaceMode "isolated" requires a git repo, but ${sourceCwd} is not one. Pass an explicit cwd under a repo, or omit cwd to inherit the caller's.`); } const inheritedMode = input.policyName || input.automationRunId ? "isolated" : "shared"; const mode: WorkspaceMode = requestedMode === "inherit" ? inheritedMode : requestedMode; if (mode !== "isolated" || !probe.isGitRepo || !probe.repoRoot) { return { cwd: sourceCwd, workspace: { mode: "shared", requestedMode, sourceCwd, probe }, }; } // #635 — attach to an existing worktree instead of creating a fresh one. if (input.resumeWorkspace?.mode === "attach") { const resume = input.resumeWorkspace; const wtp = resume.worktreePath; if (!wtp) throw new Error("resumeWorkspace attach: worktreePath is required"); if (!existsSync(wtp)) throw new Error(`resumeWorkspace attach: worktree does not exist: ${wtp}`); return { cwd: wtp, workspace: { ...(resume.workspaceId ? { id: resume.workspaceId } : { id: workspaceId(input) }), mode: "isolated", requestedMode, repoRoot: probe.repoRoot, sourceCwd, worktreePath: wtp, branch: resume.branch, ...(resume.baseRef ? { baseRef: resume.baseRef } : {}), ...(resume.baseSha ? { baseSha: resume.baseSha } : {}), status: "active", probe, }, }; } // #635 — create a fresh worktree off an existing branch's HEAD instead of main's HEAD. if (input.resumeWorkspace?.mode === "branch-from") { const resume = input.resumeWorkspace; const repoRoot = probe.repoRoot; const startSha = await requireGit(["rev-parse", resume.branch], repoRoot); const baseRef = resume.baseRef ?? await terminalBaseRef(repoRoot, resume.branch); const baseSha = resume.baseSha ?? (baseRef ? await requireGit(["rev-parse", baseRef], repoRoot) : undefined); const id = workspaceId(input); const workspaceRoot = input.workspaceRoot ? resolve(input.workspaceRoot) : workspacesRoot(homedir()); const worktreePath = join(workspaceRoot, repoSlug(repoRoot), id); const existing = await existingRegisteredWorktree(repoRoot, worktreePath); if (existing) { return existingWorkspaceResolution({ id, repoRoot, sourceCwd, worktreePath, branch: existing.branch, baseRef, baseSha, requestedMode, probe, }); } // #1020 (B2) — serialize the branch resolve + `git worktree add` against a concurrent // land/cleanup on the same repoRoot (both take this lock), so worktree-admin ops can't race // `.git/worktrees/` metadata. Deps/symlinks provisioning below touches only the new worktree // dir, so it stays outside the lock to keep the hold time short. const branch = await withRepoLock(repoRoot, async () => { const resolved = await availableBranch(repoRoot, branchName(input, id)); mkdirSync(join(worktreePath, ".."), { recursive: true }); await requireGit(["worktree", "add", "-b", resolved, worktreePath, startSha], repoRoot); return resolved; }); const deps = await provisionWorkspaceDeps(repoRoot, worktreePath); const symlinks = provisionWorkspaceSymlinks(repoRoot, worktreePath, input.workspaceSymlinks ?? []); return { cwd: worktreePath, workspace: { id, mode: "isolated", requestedMode, repoRoot, sourceCwd, worktreePath, branch, ...(baseRef ? { baseRef } : {}), ...(baseSha ? { baseSha } : {}), status: "active", deps, ...(symlinks.linked.length || symlinks.errors ? { symlinks } : {}), probe, }, }; } const id = workspaceId(input); const repoRoot = probe.repoRoot; const baseRef = input.baseRef ?? await terminalBaseRef(repoRoot, probe.branch); const baseSha = input.baseSha ?? probe.headSha ?? await requireGit(["rev-parse", "HEAD"], repoRoot); const workspaceRoot = input.workspaceRoot ? resolve(input.workspaceRoot) : workspacesRoot(homedir()); const worktreePath = join(workspaceRoot, repoSlug(repoRoot), id); const existing = await existingRegisteredWorktree(repoRoot, worktreePath); if (existing) { return existingWorkspaceResolution({ id, repoRoot, sourceCwd, worktreePath, branch: existing.branch, baseRef, baseSha, requestedMode, probe, }); } // #1020 (B2) — serialize the branch resolve + `git worktree add` against a concurrent // land/cleanup on the same repoRoot (both take this lock); provisioning below is worktree-local // and stays outside the lock. const branch = await withRepoLock(repoRoot, async () => { const resolved = await availableBranch(repoRoot, branchName(input, id)); mkdirSync(join(worktreePath, ".."), { recursive: true }); await requireGit(["worktree", "add", "-b", resolved, worktreePath, baseSha], repoRoot); return resolved; }); // A fresh worktree has no node_modules (git worktrees don't share // gitignored/untracked files). Provision deps before handing cwd to the // runner so the agent can typecheck/test/build without manual setup (#159). const deps = await provisionWorkspaceDeps(repoRoot, worktreePath); // Symlink configured untracked paths (AGENTS.md, .claude-rig, …) from main. Like // node_modules, these are gitignored so the fresh worktree lacks them (#159 follow-up). const symlinks = provisionWorkspaceSymlinks(repoRoot, worktreePath, input.workspaceSymlinks ?? []); return { cwd: worktreePath, workspace: { id, mode: "isolated", requestedMode, repoRoot, sourceCwd, worktreePath, branch, baseRef, baseSha, status: "active", deps, ...(symlinks.linked.length || symlinks.errors ? { symlinks } : {}), probe, }, }; } async function existingRegisteredWorktree(repoRoot: string, worktreePath: string): Promise<{ branch?: string } | undefined> { const resolvedPath = resolve(worktreePath); if (!existsSync(resolvedPath)) return undefined; const listed = await git(["worktree", "list", "--porcelain"], repoRoot); if (!listed.ok) return undefined; const match = parseWorktrees(listed.stdout).find((worktree) => resolve(worktree.path) === resolvedPath); if (!match) return undefined; const topLevel = await git(["rev-parse", "--show-toplevel"], resolvedPath); if (!topLevel.ok || resolve(topLevel.stdout) !== resolvedPath) return undefined; return { branch: match.branch }; } function existingWorkspaceResolution(input: { id: string; repoRoot: string; sourceCwd: string; worktreePath: string; branch?: string; baseRef?: string; baseSha?: string; requestedMode: WorkspaceMode | "inherit"; probe: WorkspaceProbe; }): WorkspaceResolution { return { cwd: input.worktreePath, reusedExisting: true, workspace: { id: input.id, mode: "isolated", requestedMode: input.requestedMode, repoRoot: input.repoRoot, sourceCwd: input.sourceCwd, worktreePath: input.worktreePath, ...(input.branch ? { branch: input.branch } : {}), ...(input.baseRef ? { baseRef: input.baseRef } : {}), ...(input.baseSha ? { baseSha: input.baseSha } : {}), status: "active", probe: input.probe, }, }; }