diff --git a/src/node/runtime/CoderSSHRuntime.ts b/src/node/runtime/CoderSSHRuntime.ts index ebaa2fa5e2..461778c34f 100644 --- a/src/node/runtime/CoderSSHRuntime.ts +++ b/src/node/runtime/CoderSSHRuntime.ts @@ -83,11 +83,11 @@ export class CoderSSHRuntime extends SSHRuntime { /** * Flags for WorkspaceService to customize create flow: - * - deferredHost: skip srcBaseDir resolution (Coder host doesn't exist yet) + * - deferredRuntimeAccess: skip srcBaseDir resolution (Coder host doesn't exist yet) * - configLevelCollisionDetection: use config-based collision check (can't reach host) */ readonly createFlags: RuntimeCreateFlags = { - deferredHost: true, + deferredRuntimeAccess: true, configLevelCollisionDetection: true, }; diff --git a/src/node/runtime/DevcontainerRuntime.ts b/src/node/runtime/DevcontainerRuntime.ts new file mode 100644 index 0000000000..3081320542 --- /dev/null +++ b/src/node/runtime/DevcontainerRuntime.ts @@ -0,0 +1,75 @@ +import type { + RuntimeCreateFlags, + WorkspaceCreationParams, + WorkspaceCreationResult, + WorkspaceForkParams, + WorkspaceForkResult, + WorkspaceInitParams, + WorkspaceInitResult, +} from "./Runtime"; +import { LocalBaseRuntime } from "./LocalBaseRuntime"; +import { WorktreeManager } from "@/node/worktree/WorktreeManager"; + +export interface DevcontainerRuntimeOptions { + srcBaseDir: string; + configPath: string; +} + +export class DevcontainerRuntime extends LocalBaseRuntime { + private readonly worktreeManager: WorktreeManager; + private readonly configPath: string; + + readonly createFlags: RuntimeCreateFlags = { + deferredRuntimeAccess: true, + }; + + constructor(options: DevcontainerRuntimeOptions) { + super(); + this.worktreeManager = new WorktreeManager(options.srcBaseDir); + this.configPath = options.configPath; + } + + getWorkspacePath(projectPath: string, workspaceName: string): string { + return this.worktreeManager.getWorkspacePath(projectPath, workspaceName); + } + + async createWorkspace(params: WorkspaceCreationParams): Promise { + return this.worktreeManager.createWorkspace({ + projectPath: params.projectPath, + branchName: params.branchName, + trunkBranch: params.trunkBranch, + initLogger: params.initLogger, + }); + } + + // eslint-disable-next-line @typescript-eslint/require-await -- stub for Phase 1; will have real async logic + async initWorkspace(_params: WorkspaceInitParams): Promise { + return { success: true }; + } + + async renameWorkspace( + projectPath: string, + oldName: string, + newName: string, + _abortSignal?: AbortSignal + ): Promise< + { success: true; oldPath: string; newPath: string } | { success: false; error: string } + > { + // Note: _abortSignal ignored for local operations (fast, no need for cancellation) + return this.worktreeManager.renameWorkspace(projectPath, oldName, newName); + } + + async deleteWorkspace( + projectPath: string, + workspaceName: string, + force: boolean, + _abortSignal?: AbortSignal + ): Promise<{ success: true; deletedPath: string } | { success: false; error: string }> { + // Note: _abortSignal ignored for local operations (fast, no need for cancellation) + return this.worktreeManager.deleteWorkspace(projectPath, workspaceName, force); + } + + async forkWorkspace(params: WorkspaceForkParams): Promise { + return this.worktreeManager.forkWorkspace(params); + } +} diff --git a/src/node/runtime/LocalBaseRuntime.test.ts b/src/node/runtime/LocalBaseRuntime.test.ts new file mode 100644 index 0000000000..826c5b9da4 --- /dev/null +++ b/src/node/runtime/LocalBaseRuntime.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "bun:test"; +import * as os from "os"; +import * as path from "path"; +import { LocalBaseRuntime } from "./LocalBaseRuntime"; +import type { + WorkspaceCreationParams, + WorkspaceCreationResult, + WorkspaceInitParams, + WorkspaceInitResult, + WorkspaceForkParams, + WorkspaceForkResult, +} from "./Runtime"; + +class TestLocalRuntime extends LocalBaseRuntime { + getWorkspacePath(_projectPath: string, _workspaceName: string): string { + return "/tmp/workspace"; + } + + createWorkspace(_params: WorkspaceCreationParams): Promise { + return Promise.resolve({ success: true, workspacePath: "/tmp/workspace" }); + } + + initWorkspace(_params: WorkspaceInitParams): Promise { + return Promise.resolve({ success: true }); + } + + renameWorkspace( + _projectPath: string, + _oldName: string, + _newName: string + ): Promise< + { success: true; oldPath: string; newPath: string } | { success: false; error: string } + > { + return Promise.resolve({ success: true, oldPath: "/tmp/workspace", newPath: "/tmp/workspace" }); + } + + deleteWorkspace( + _projectPath: string, + _workspaceName: string, + _force: boolean + ): Promise<{ success: true; deletedPath: string } | { success: false; error: string }> { + return Promise.resolve({ success: true, deletedPath: "/tmp/workspace" }); + } + + forkWorkspace(_params: WorkspaceForkParams): Promise { + return Promise.resolve({ + success: true, + workspacePath: "/tmp/workspace", + sourceBranch: "main", + }); + } +} + +describe("LocalBaseRuntime.resolvePath", () => { + it("should expand tilde to home directory", async () => { + const runtime = new TestLocalRuntime(); + const resolved = await runtime.resolvePath("~"); + expect(resolved).toBe(os.homedir()); + }); + + it("should expand tilde with path", async () => { + const runtime = new TestLocalRuntime(); + const resolved = await runtime.resolvePath("~/.."); + const expected = path.dirname(os.homedir()); + expect(resolved).toBe(expected); + }); + + it("should resolve absolute paths", async () => { + const runtime = new TestLocalRuntime(); + const resolved = await runtime.resolvePath("/tmp"); + expect(resolved).toBe("/tmp"); + }); + + it("should resolve non-existent paths without checking existence", async () => { + const runtime = new TestLocalRuntime(); + const resolved = await runtime.resolvePath("/this/path/does/not/exist/12345"); + // Should resolve to absolute path without checking if it exists + expect(resolved).toBe("/this/path/does/not/exist/12345"); + }); + + it("should resolve relative paths from cwd", async () => { + const runtime = new TestLocalRuntime(); + const resolved = await runtime.resolvePath("."); + // Should resolve to absolute path + expect(path.isAbsolute(resolved)).toBe(true); + }); +}); diff --git a/src/node/runtime/Runtime.ts b/src/node/runtime/Runtime.ts index 91b5e4dd77..a07dc2a4d3 100644 --- a/src/node/runtime/Runtime.ts +++ b/src/node/runtime/Runtime.ts @@ -261,9 +261,9 @@ export interface WorkspaceForkResult { export interface RuntimeCreateFlags { /** * Skip srcBaseDir resolution before createWorkspace. - * Use when host doesn't exist until postCreateSetup (e.g., Coder). + * Use when runtime access doesn't exist until postCreateSetup (e.g., Coder). */ - deferredHost?: boolean; + deferredRuntimeAccess?: boolean; /** * Use config-level collision detection instead of runtime.createWorkspace. diff --git a/src/node/runtime/WorktreeRuntime.ts b/src/node/runtime/WorktreeRuntime.ts index 529ce04bfb..2e725759b4 100644 --- a/src/node/runtime/WorktreeRuntime.ts +++ b/src/node/runtime/WorktreeRuntime.ts @@ -1,5 +1,3 @@ -import * as fsPromises from "fs/promises"; -import * as path from "path"; import type { WorkspaceCreationParams, WorkspaceCreationResult, @@ -7,18 +5,11 @@ import type { WorkspaceInitResult, WorkspaceForkParams, WorkspaceForkResult, - InitLogger, } from "./Runtime"; -import { listLocalBranches, cleanStaleLock, getCurrentBranch } from "@/node/git"; import { checkInitHookExists, getMuxEnv } from "./initHook"; -import { execAsync } from "@/node/utils/disposableExec"; -import { getBashPath } from "@/node/utils/main/bashPath"; -import { getProjectName } from "@/node/utils/runtime/helpers"; -import { getErrorMessage } from "@/common/utils/errors"; -import { expandTilde } from "./tildeExpansion"; import { LocalBaseRuntime } from "./LocalBaseRuntime"; -import { toPosixPath } from "@/node/utils/paths"; -import { log } from "@/node/services/log"; +import { getErrorMessage } from "@/common/utils/errors"; +import { WorktreeManager } from "@/node/worktree/WorktreeManager"; /** * Worktree runtime implementation that executes commands and file operations @@ -29,177 +20,24 @@ import { log } from "@/node/services/log"; * - Each workspace is a git worktree with its own branch */ export class WorktreeRuntime extends LocalBaseRuntime { - private readonly srcBaseDir: string; + private readonly worktreeManager: WorktreeManager; constructor(srcBaseDir: string) { super(); - // Expand tilde to actual home directory path for local file system operations - this.srcBaseDir = expandTilde(srcBaseDir); + this.worktreeManager = new WorktreeManager(srcBaseDir); } getWorkspacePath(projectPath: string, workspaceName: string): string { - const projectName = getProjectName(projectPath); - return path.join(this.srcBaseDir, projectName, workspaceName); + return this.worktreeManager.getWorkspacePath(projectPath, workspaceName); } async createWorkspace(params: WorkspaceCreationParams): Promise { - const { projectPath, branchName, trunkBranch, initLogger } = params; - - // Clean up stale lock before git operations on main repo - cleanStaleLock(projectPath); - - try { - // Compute workspace path using the canonical method - const workspacePath = this.getWorkspacePath(projectPath, branchName); - initLogger.logStep("Creating git worktree..."); - - // Create parent directory if needed - const parentDir = path.dirname(workspacePath); - try { - await fsPromises.access(parentDir); - } catch { - await fsPromises.mkdir(parentDir, { recursive: true }); - } - - // Check if workspace already exists - try { - await fsPromises.access(workspacePath); - return { - success: false, - error: `Workspace already exists at ${workspacePath}`, - }; - } catch { - // Workspace doesn't exist, proceed with creation - } - - // Check if branch exists locally - const localBranches = await listLocalBranches(projectPath); - const branchExists = localBranches.includes(branchName); - - // Fetch origin before creating worktree (best-effort) - // This ensures new branches start from the latest origin state - const fetchedOrigin = await this.fetchOriginTrunk(projectPath, trunkBranch, initLogger); - - // Determine best base for new branches: use origin if local can fast-forward to it, - // otherwise preserve local state (user may have unpushed work) - const shouldUseOrigin = - fetchedOrigin && (await this.canFastForwardToOrigin(projectPath, trunkBranch, initLogger)); - - // Create worktree (git worktree is typically fast) - if (branchExists) { - // Branch exists, just add worktree pointing to it - using proc = execAsync( - `git -C "${projectPath}" worktree add "${workspacePath}" "${branchName}"` - ); - await proc.result; - } else { - // Branch doesn't exist, create from the best available base: - // - origin/ if local is behind/equal (ensures fresh starting point) - // - local if local is ahead/diverged (preserves user's work) - const newBranchBase = shouldUseOrigin ? `origin/${trunkBranch}` : trunkBranch; - using proc = execAsync( - `git -C "${projectPath}" worktree add -b "${branchName}" "${workspacePath}" "${newBranchBase}"` - ); - await proc.result; - } - - initLogger.logStep("Worktree created successfully"); - - // For existing branches, fast-forward to latest origin (best-effort) - // Only if local can fast-forward (preserves unpushed work) - if (shouldUseOrigin && branchExists) { - await this.fastForwardToOrigin(workspacePath, trunkBranch, initLogger); - } - - return { success: true, workspacePath }; - } catch (error) { - return { - success: false, - error: getErrorMessage(error), - }; - } - } - - /** - * Fetch trunk branch from origin before worktree creation. - * Returns true if fetch succeeded (origin is available for branching). - */ - private async fetchOriginTrunk( - projectPath: string, - trunkBranch: string, - initLogger: InitLogger - ): Promise { - try { - initLogger.logStep(`Fetching latest from origin/${trunkBranch}...`); - - using fetchProc = execAsync(`git -C "${projectPath}" fetch origin "${trunkBranch}"`); - await fetchProc.result; - - initLogger.logStep("Fetched latest from origin"); - return true; - } catch (error) { - const errorMsg = getErrorMessage(error); - // Branch doesn't exist on origin (common for subagent local-only branches) - if (errorMsg.includes("couldn't find remote ref")) { - initLogger.logStep(`Branch "${trunkBranch}" not found on origin; using local state.`); - } else { - initLogger.logStderr( - `Note: Could not fetch from origin (${errorMsg}), using local branch state` - ); - } - return false; - } - } - - /** - * Check if local trunk can fast-forward to origin/. - * Returns true if local is behind or equal to origin (safe to use origin). - * Returns false if local is ahead or diverged (preserve local state). - */ - private async canFastForwardToOrigin( - projectPath: string, - trunkBranch: string, - initLogger: InitLogger - ): Promise { - try { - // Check if local trunk is an ancestor of origin/trunk - // Exit code 0 = local is ancestor (can fast-forward), non-zero = cannot - using proc = execAsync( - `git -C "${projectPath}" merge-base --is-ancestor "${trunkBranch}" "origin/${trunkBranch}"` - ); - await proc.result; - return true; // Local is behind or equal to origin - } catch { - // Local is ahead or diverged - preserve local state - initLogger.logStderr( - `Note: Local ${trunkBranch} is ahead of or diverged from origin, using local state` - ); - return false; - } - } - - /** - * Fast-forward merge to latest origin/ after checkout. - * Best-effort operation for existing branches that may be behind origin. - */ - private async fastForwardToOrigin( - workspacePath: string, - trunkBranch: string, - initLogger: InitLogger - ): Promise { - try { - initLogger.logStep("Fast-forward merging..."); - - using mergeProc = execAsync( - `git -C "${workspacePath}" merge --ff-only "origin/${trunkBranch}"` - ); - await mergeProc.result; - initLogger.logStep("Fast-forwarded to latest origin successfully"); - } catch (mergeError) { - // Fast-forward not possible (diverged branches) - just warn - const errorMsg = getErrorMessage(mergeError); - initLogger.logStderr(`Note: Fast-forward failed (${errorMsg}), using local branch state`); - } + return this.worktreeManager.createWorkspace({ + projectPath: params.projectPath, + branchName: params.branchName, + trunkBranch: params.trunkBranch, + initLogger: params.initLogger, + }); } async initWorkspace(params: WorkspaceInitParams): Promise { @@ -237,34 +75,7 @@ export class WorktreeRuntime extends LocalBaseRuntime { { success: true; oldPath: string; newPath: string } | { success: false; error: string } > { // Note: _abortSignal ignored for local operations (fast, no need for cancellation) - // Clean up stale lock before git operations on main repo - cleanStaleLock(projectPath); - - // Compute workspace paths using canonical method - const oldPath = this.getWorkspacePath(projectPath, oldName); - const newPath = this.getWorkspacePath(projectPath, newName); - - try { - // Move the worktree directory (updates git's internal worktree metadata) - using moveProc = execAsync(`git -C "${projectPath}" worktree move "${oldPath}" "${newPath}"`); - await moveProc.result; - - // Rename the git branch to match the new workspace name - // In mux, branch name and workspace name are always kept in sync. - // Run from the new worktree path since that's where the branch is checked out. - // Best-effort: ignore errors (e.g., branch might have a different name in test scenarios). - try { - using branchProc = execAsync(`git -C "${newPath}" branch -m "${oldName}" "${newName}"`); - await branchProc.result; - } catch { - // Branch rename failed - this is fine, the directory was still moved - // This can happen if the branch name doesn't match the old directory name - } - - return { success: true, oldPath, newPath }; - } catch (error) { - return { success: false, error: `Failed to rename workspace: ${getErrorMessage(error)}` }; - } + return this.worktreeManager.renameWorkspace(projectPath, oldName, newName); } async deleteWorkspace( @@ -274,269 +85,10 @@ export class WorktreeRuntime extends LocalBaseRuntime { _abortSignal?: AbortSignal ): Promise<{ success: true; deletedPath: string } | { success: false; error: string }> { // Note: _abortSignal ignored for local operations (fast, no need for cancellation) - // Clean up stale lock before git operations on main repo - cleanStaleLock(projectPath); - - // In-place workspaces are identified by projectPath === workspaceName - // These are direct workspace directories (e.g., CLI/benchmark sessions), not git worktrees - const isInPlace = projectPath === workspaceName; - - // For git worktree workspaces, workspaceName is the branch name. - // Now that archiving exists, deleting a workspace should also delete its local branch by default. - const shouldDeleteBranch = !isInPlace; - - const tryDeleteBranch = async () => { - if (!shouldDeleteBranch) return; - - const branchToDelete = workspaceName.trim(); - if (!branchToDelete) { - log.debug("Skipping git branch deletion: empty workspace name", { - projectPath, - workspaceName, - }); - return; - } - - let localBranches: string[]; - try { - localBranches = await listLocalBranches(projectPath); - } catch (error) { - log.debug("Failed to list local branches; skipping branch deletion", { - projectPath, - workspaceName: branchToDelete, - error: getErrorMessage(error), - }); - return; - } - - if (!localBranches.includes(branchToDelete)) { - log.debug("Skipping git branch deletion: branch does not exist locally", { - projectPath, - workspaceName: branchToDelete, - }); - return; - } - - // Never delete protected/trunk branches. - const protectedBranches = new Set(["main", "master", "trunk", "develop", "default"]); - - // If there's only one local branch, treat it as protected (likely trunk). - if (localBranches.length === 1) { - protectedBranches.add(localBranches[0]); - } - - const currentBranch = await getCurrentBranch(projectPath); - if (currentBranch) { - protectedBranches.add(currentBranch); - } - - // If origin/HEAD points at a local branch, also treat it as protected. - try { - using originHeadProc = execAsync( - `git -C "${projectPath}" symbolic-ref refs/remotes/origin/HEAD` - ); - const { stdout } = await originHeadProc.result; - const ref = stdout.trim(); - const prefix = "refs/remotes/origin/"; - if (ref.startsWith(prefix)) { - protectedBranches.add(ref.slice(prefix.length)); - } - } catch { - // No origin/HEAD (or not a git repo) - ignore - } - - if (protectedBranches.has(branchToDelete)) { - log.debug("Skipping git branch deletion: protected branch", { - projectPath, - workspaceName: branchToDelete, - }); - return; - } - - // Extra safety: don't delete a branch still checked out by any worktree. - try { - using worktreeProc = execAsync(`git -C "${projectPath}" worktree list --porcelain`); - const { stdout } = await worktreeProc.result; - const needle = `branch refs/heads/${branchToDelete}`; - const isCheckedOut = stdout.split("\n").some((line) => line.trim() === needle); - if (isCheckedOut) { - log.debug("Skipping git branch deletion: branch still checked out by a worktree", { - projectPath, - workspaceName: branchToDelete, - }); - return; - } - } catch (error) { - // If the worktree list fails, proceed anyway - git itself will refuse to delete a checked-out branch. - log.debug("Failed to check worktree list before branch deletion; proceeding", { - projectPath, - workspaceName: branchToDelete, - error: getErrorMessage(error), - }); - } - - const deleteFlag = force ? "-D" : "-d"; - try { - using deleteProc = execAsync( - `git -C "${projectPath}" branch ${deleteFlag} "${branchToDelete}"` - ); - await deleteProc.result; - } catch (error) { - // Best-effort: workspace deletion should not fail just because branch cleanup failed. - log.debug("Failed to delete git branch after removing worktree", { - projectPath, - workspaceName: branchToDelete, - error: getErrorMessage(error), - }); - } - }; - - // Compute workspace path using the canonical method - const deletedPath = this.getWorkspacePath(projectPath, workspaceName); - - // Check if directory exists - if not, operation is idempotent - try { - await fsPromises.access(deletedPath); - } catch { - // Directory doesn't exist - operation is idempotent - // For standard worktrees, prune stale git records (best effort) - if (!isInPlace) { - try { - using pruneProc = execAsync(`git -C "${projectPath}" worktree prune`); - await pruneProc.result; - } catch { - // Ignore prune errors - directory is already deleted, which is the goal - } - } - - // Best-effort: also delete the local branch. - await tryDeleteBranch(); - return { success: true, deletedPath }; - } - - // For in-place workspaces, there's no worktree to remove - // Just return success - the workspace directory itself should not be deleted - // as it may contain the user's actual project files - if (isInPlace) { - return { success: true, deletedPath }; - } - - try { - // Use git worktree remove to delete the worktree - // This updates git's internal worktree metadata correctly - // Only use --force if explicitly requested by the caller - const forceFlag = force ? " --force" : ""; - using proc = execAsync( - `git -C "${projectPath}" worktree remove${forceFlag} "${deletedPath}"` - ); - await proc.result; - - // Best-effort: also delete the local branch. - await tryDeleteBranch(); - return { success: true, deletedPath }; - } catch (error) { - const message = getErrorMessage(error); - - // Check if the error is due to missing/stale worktree - const normalizedError = message.toLowerCase(); - const looksLikeMissingWorktree = - normalizedError.includes("not a working tree") || - normalizedError.includes("does not exist") || - normalizedError.includes("no such file"); - - if (looksLikeMissingWorktree) { - // Worktree records are stale - prune them - try { - using pruneProc = execAsync(`git -C "${projectPath}" worktree prune`); - await pruneProc.result; - } catch { - // Ignore prune errors - } - // Treat as success - workspace is gone (idempotent) - await tryDeleteBranch(); - return { success: true, deletedPath }; - } - - // If force is enabled and git worktree remove failed, fall back to rm -rf - // This handles edge cases like submodules where git refuses to delete - if (force) { - try { - // Prune git's worktree records first (best effort) - try { - using pruneProc = execAsync(`git -C "${projectPath}" worktree prune`); - await pruneProc.result; - } catch { - // Ignore prune errors - we'll still try rm -rf - } - - // Force delete the directory (use bash shell for rm -rf on Windows) - // Convert to POSIX path for Git Bash compatibility on Windows - using rmProc = execAsync(`rm -rf "${toPosixPath(deletedPath)}"`, { - shell: getBashPath(), - }); - await rmProc.result; - - // Best-effort: also delete the local branch. - await tryDeleteBranch(); - return { success: true, deletedPath }; - } catch (rmError) { - return { - success: false, - error: `Failed to remove worktree via git and rm: ${getErrorMessage(rmError)}`, - }; - } - } - - // force=false - return the git error without attempting rm -rf - return { success: false, error: `Failed to remove worktree: ${message}` }; - } + return this.worktreeManager.deleteWorkspace(projectPath, workspaceName, force); } async forkWorkspace(params: WorkspaceForkParams): Promise { - const { projectPath, sourceWorkspaceName, newWorkspaceName, initLogger } = params; - - // Get source workspace path - const sourceWorkspacePath = this.getWorkspacePath(projectPath, sourceWorkspaceName); - - // Get current branch from source workspace - try { - using proc = execAsync(`git -C "${sourceWorkspacePath}" branch --show-current`); - const { stdout } = await proc.result; - const sourceBranch = stdout.trim(); - - if (!sourceBranch) { - return { - success: false, - error: "Failed to detect branch in source workspace", - }; - } - - // Use createWorkspace with sourceBranch as trunk to fork from source branch - const createResult = await this.createWorkspace({ - projectPath, - branchName: newWorkspaceName, - trunkBranch: sourceBranch, // Fork from source branch instead of main/master - directoryName: newWorkspaceName, - initLogger, - }); - - if (!createResult.success || !createResult.workspacePath) { - return { - success: false, - error: createResult.error ?? "Failed to create workspace", - }; - } - - return { - success: true, - workspacePath: createResult.workspacePath, - sourceBranch, - }; - } catch (error) { - return { - success: false, - error: getErrorMessage(error), - }; - } + return this.worktreeManager.forkWorkspace(params); } } diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 5801f7a904..ca1c9e160c 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -580,9 +580,9 @@ export class WorkspaceService extends EventEmitter { runtime = createRuntime(finalRuntimeConfig, { projectPath }); // Resolve srcBaseDir path if the config has one. - // Skip if runtime has deferredHost flag (host doesn't exist yet, e.g., Coder). + // Skip if runtime has deferredRuntimeAccess flag (runtime doesn't exist yet, e.g., Coder). const srcBaseDir = getSrcBaseDir(finalRuntimeConfig); - if (srcBaseDir && !runtime.createFlags?.deferredHost) { + if (srcBaseDir && !runtime.createFlags?.deferredRuntimeAccess) { const resolvedSrcBaseDir = await runtime.resolvePath(srcBaseDir); if (resolvedSrcBaseDir !== srcBaseDir && hasSrcBaseDir(finalRuntimeConfig)) { finalRuntimeConfig = { diff --git a/src/node/runtime/WorktreeRuntime.test.ts b/src/node/worktree/WorktreeManager.test.ts similarity index 72% rename from src/node/runtime/WorktreeRuntime.test.ts rename to src/node/worktree/WorktreeManager.test.ts index 4225d12081..9cec5dda1f 100644 --- a/src/node/runtime/WorktreeRuntime.test.ts +++ b/src/node/worktree/WorktreeManager.test.ts @@ -3,8 +3,8 @@ import * as os from "os"; import * as path from "path"; import * as fsPromises from "fs/promises"; import { execSync } from "node:child_process"; -import { WorktreeRuntime } from "./WorktreeRuntime"; -import type { InitLogger } from "./Runtime"; +import type { InitLogger } from "@/node/runtime/Runtime"; +import { WorktreeManager } from "./WorktreeManager"; function initGitRepo(projectPath: string): void { execSync("git init -b main", { cwd: projectPath, stdio: "ignore" }); @@ -26,10 +26,10 @@ function createNullInitLogger(): InitLogger { }; } -describe("WorktreeRuntime constructor", () => { +describe("WorktreeManager constructor", () => { it("should expand tilde in srcBaseDir", () => { - const runtime = new WorktreeRuntime("~/workspace"); - const workspacePath = runtime.getWorkspacePath("/home/user/project", "branch"); + const manager = new WorktreeManager("~/workspace"); + const workspacePath = manager.getWorkspacePath("/home/user/project", "branch"); // The workspace path should use the expanded home directory const expected = path.join(os.homedir(), "workspace", "project", "branch"); @@ -37,62 +37,26 @@ describe("WorktreeRuntime constructor", () => { }); it("should handle absolute paths without expansion", () => { - const runtime = new WorktreeRuntime("/absolute/path"); - const workspacePath = runtime.getWorkspacePath("/home/user/project", "branch"); + const manager = new WorktreeManager("/absolute/path"); + const workspacePath = manager.getWorkspacePath("/home/user/project", "branch"); const expected = path.join("/absolute/path", "project", "branch"); expect(workspacePath).toBe(expected); }); it("should handle bare tilde", () => { - const runtime = new WorktreeRuntime("~"); - const workspacePath = runtime.getWorkspacePath("/home/user/project", "branch"); + const manager = new WorktreeManager("~"); + const workspacePath = manager.getWorkspacePath("/home/user/project", "branch"); const expected = path.join(os.homedir(), "project", "branch"); expect(workspacePath).toBe(expected); }); }); -describe("WorktreeRuntime.resolvePath", () => { - it("should expand tilde to home directory", async () => { - const runtime = new WorktreeRuntime("/tmp"); - const resolved = await runtime.resolvePath("~"); - expect(resolved).toBe(os.homedir()); - }); - - it("should expand tilde with path", async () => { - const runtime = new WorktreeRuntime("/tmp"); - // Use a path that likely exists (or use /tmp if ~ doesn't have subdirs) - const resolved = await runtime.resolvePath("~/.."); - const expected = path.dirname(os.homedir()); - expect(resolved).toBe(expected); - }); - - it("should resolve absolute paths", async () => { - const runtime = new WorktreeRuntime("/tmp"); - const resolved = await runtime.resolvePath("/tmp"); - expect(resolved).toBe("/tmp"); - }); - - it("should resolve non-existent paths without checking existence", async () => { - const runtime = new WorktreeRuntime("/tmp"); - const resolved = await runtime.resolvePath("/this/path/does/not/exist/12345"); - // Should resolve to absolute path without checking if it exists - expect(resolved).toBe("/this/path/does/not/exist/12345"); - }); - - it("should resolve relative paths from cwd", async () => { - const runtime = new WorktreeRuntime("/tmp"); - const resolved = await runtime.resolvePath("."); - // Should resolve to absolute path - expect(path.isAbsolute(resolved)).toBe(true); - }); -}); - -describe("WorktreeRuntime.deleteWorkspace", () => { +describe("WorktreeManager.deleteWorkspace", () => { it("deletes non-agent branches when removing worktrees (force)", async () => { const rootDir = await fsPromises.realpath( - await fsPromises.mkdtemp(path.join(os.tmpdir(), "worktree-runtime-delete-")) + await fsPromises.mkdtemp(path.join(os.tmpdir(), "worktree-manager-delete-")) ); try { @@ -103,15 +67,14 @@ describe("WorktreeRuntime.deleteWorkspace", () => { const srcBaseDir = path.join(rootDir, "src"); await fsPromises.mkdir(srcBaseDir, { recursive: true }); - const runtime = new WorktreeRuntime(srcBaseDir); + const manager = new WorktreeManager(srcBaseDir); const initLogger = createNullInitLogger(); const branchName = "feature_aaaaaaaaaa"; - const createResult = await runtime.createWorkspace({ + const createResult = await manager.createWorkspace({ projectPath, branchName, trunkBranch: "main", - directoryName: branchName, initLogger, }); expect(createResult.success).toBe(true); @@ -129,7 +92,7 @@ describe("WorktreeRuntime.deleteWorkspace", () => { execSync("git add README.md", { cwd: workspacePath, stdio: "ignore" }); execSync('git commit -m "change"', { cwd: workspacePath, stdio: "ignore" }); - const deleteResult = await runtime.deleteWorkspace(projectPath, branchName, true); + const deleteResult = await manager.deleteWorkspace(projectPath, branchName, true); expect(deleteResult.success).toBe(true); const after = execSync(`git branch --list "${branchName}"`, { @@ -146,7 +109,7 @@ describe("WorktreeRuntime.deleteWorkspace", () => { it("deletes merged branches when removing worktrees (safe delete)", async () => { const rootDir = await fsPromises.realpath( - await fsPromises.mkdtemp(path.join(os.tmpdir(), "worktree-runtime-delete-")) + await fsPromises.mkdtemp(path.join(os.tmpdir(), "worktree-manager-delete-")) ); try { @@ -157,15 +120,14 @@ describe("WorktreeRuntime.deleteWorkspace", () => { const srcBaseDir = path.join(rootDir, "src"); await fsPromises.mkdir(srcBaseDir, { recursive: true }); - const runtime = new WorktreeRuntime(srcBaseDir); + const manager = new WorktreeManager(srcBaseDir); const initLogger = createNullInitLogger(); const branchName = "feature_merge_aaaaaaaaaa"; - const createResult = await runtime.createWorkspace({ + const createResult = await manager.createWorkspace({ projectPath, branchName, trunkBranch: "main", - directoryName: branchName, initLogger, }); expect(createResult.success).toBe(true); @@ -189,7 +151,7 @@ describe("WorktreeRuntime.deleteWorkspace", () => { // Merge into main so `git branch -d` succeeds. execSync(`git merge "${branchName}"`, { cwd: projectPath, stdio: "ignore" }); - const deleteResult = await runtime.deleteWorkspace(projectPath, branchName, false); + const deleteResult = await manager.deleteWorkspace(projectPath, branchName, false); expect(deleteResult.success).toBe(true); const after = execSync(`git branch --list "${branchName}"`, { @@ -206,7 +168,7 @@ describe("WorktreeRuntime.deleteWorkspace", () => { it("does not delete protected branches", async () => { const rootDir = await fsPromises.realpath( - await fsPromises.mkdtemp(path.join(os.tmpdir(), "worktree-runtime-delete-")) + await fsPromises.mkdtemp(path.join(os.tmpdir(), "worktree-manager-delete-")) ); try { @@ -220,15 +182,14 @@ describe("WorktreeRuntime.deleteWorkspace", () => { const srcBaseDir = path.join(rootDir, "src"); await fsPromises.mkdir(srcBaseDir, { recursive: true }); - const runtime = new WorktreeRuntime(srcBaseDir); + const manager = new WorktreeManager(srcBaseDir); const initLogger = createNullInitLogger(); const branchName = "main"; - const createResult = await runtime.createWorkspace({ + const createResult = await manager.createWorkspace({ projectPath, branchName, trunkBranch: "main", - directoryName: branchName, initLogger, }); expect(createResult.success).toBe(true); @@ -238,7 +199,7 @@ describe("WorktreeRuntime.deleteWorkspace", () => { } const workspacePath = createResult.workspacePath; - const deleteResult = await runtime.deleteWorkspace(projectPath, branchName, true); + const deleteResult = await manager.deleteWorkspace(projectPath, branchName, true); expect(deleteResult.success).toBe(true); // The worktree directory should be removed. diff --git a/src/node/worktree/WorktreeManager.ts b/src/node/worktree/WorktreeManager.ts new file mode 100644 index 0000000000..bc06317523 --- /dev/null +++ b/src/node/worktree/WorktreeManager.ts @@ -0,0 +1,502 @@ +import * as fsPromises from "fs/promises"; +import * as path from "path"; +import type { + WorkspaceCreationResult, + WorkspaceForkParams, + WorkspaceForkResult, + InitLogger, +} from "@/node/runtime/Runtime"; +import { listLocalBranches, cleanStaleLock, getCurrentBranch } from "@/node/git"; +import { execAsync } from "@/node/utils/disposableExec"; +import { getBashPath } from "@/node/utils/main/bashPath"; +import { getProjectName } from "@/node/utils/runtime/helpers"; +import { getErrorMessage } from "@/common/utils/errors"; +import { expandTilde } from "@/node/runtime/tildeExpansion"; +import { toPosixPath } from "@/node/utils/paths"; +import { log } from "@/node/services/log"; + +export class WorktreeManager { + private readonly srcBaseDir: string; + + constructor(srcBaseDir: string) { + // Expand tilde to actual home directory path for local file system operations + this.srcBaseDir = expandTilde(srcBaseDir); + } + + getWorkspacePath(projectPath: string, workspaceName: string): string { + const projectName = getProjectName(projectPath); + return path.join(this.srcBaseDir, projectName, workspaceName); + } + + async createWorkspace(params: { + projectPath: string; + branchName: string; + trunkBranch: string; + initLogger: InitLogger; + }): Promise { + const { projectPath, branchName, trunkBranch, initLogger } = params; + + // Clean up stale lock before git operations on main repo + cleanStaleLock(projectPath); + + try { + // Compute workspace path using the canonical method + const workspacePath = this.getWorkspacePath(projectPath, branchName); + initLogger.logStep("Creating git worktree..."); + + // Create parent directory if needed + const parentDir = path.dirname(workspacePath); + try { + await fsPromises.access(parentDir); + } catch { + await fsPromises.mkdir(parentDir, { recursive: true }); + } + + // Check if workspace already exists + try { + await fsPromises.access(workspacePath); + return { + success: false, + error: `Workspace already exists at ${workspacePath}`, + }; + } catch { + // Workspace doesn't exist, proceed with creation + } + + // Check if branch exists locally + const localBranches = await listLocalBranches(projectPath); + const branchExists = localBranches.includes(branchName); + + // Fetch origin before creating worktree (best-effort) + // This ensures new branches start from the latest origin state + const fetchedOrigin = await this.fetchOriginTrunk(projectPath, trunkBranch, initLogger); + + // Determine best base for new branches: use origin if local can fast-forward to it, + // otherwise preserve local state (user may have unpushed work) + const shouldUseOrigin = + fetchedOrigin && (await this.canFastForwardToOrigin(projectPath, trunkBranch, initLogger)); + + // Create worktree (git worktree is typically fast) + if (branchExists) { + // Branch exists, just add worktree pointing to it + using proc = execAsync( + `git -C "${projectPath}" worktree add "${workspacePath}" "${branchName}"` + ); + await proc.result; + } else { + // Branch doesn't exist, create from the best available base: + // - origin/ if local is behind/equal (ensures fresh starting point) + // - local if local is ahead/diverged (preserves user's work) + const newBranchBase = shouldUseOrigin ? `origin/${trunkBranch}` : trunkBranch; + using proc = execAsync( + `git -C "${projectPath}" worktree add -b "${branchName}" "${workspacePath}" "${newBranchBase}"` + ); + await proc.result; + } + + initLogger.logStep("Worktree created successfully"); + + // For existing branches, fast-forward to latest origin (best-effort) + // Only if local can fast-forward (preserves unpushed work) + if (shouldUseOrigin && branchExists) { + await this.fastForwardToOrigin(workspacePath, trunkBranch, initLogger); + } + + return { success: true, workspacePath }; + } catch (error) { + return { + success: false, + error: getErrorMessage(error), + }; + } + } + + /** + * Fetch trunk branch from origin before worktree creation. + * Returns true if fetch succeeded (origin is available for branching). + */ + private async fetchOriginTrunk( + projectPath: string, + trunkBranch: string, + initLogger: InitLogger + ): Promise { + try { + initLogger.logStep(`Fetching latest from origin/${trunkBranch}...`); + + using fetchProc = execAsync(`git -C "${projectPath}" fetch origin "${trunkBranch}"`); + await fetchProc.result; + + initLogger.logStep("Fetched latest from origin"); + return true; + } catch (error) { + const errorMsg = getErrorMessage(error); + // Branch doesn't exist on origin (common for subagent local-only branches) + if (errorMsg.includes("couldn't find remote ref")) { + initLogger.logStep(`Branch "${trunkBranch}" not found on origin; using local state.`); + } else { + initLogger.logStderr( + `Note: Could not fetch from origin (${errorMsg}), using local branch state` + ); + } + return false; + } + } + + /** + * Check if local trunk can fast-forward to origin/. + * Returns true if local is behind or equal to origin (safe to use origin). + * Returns false if local is ahead or diverged (preserve local state). + */ + private async canFastForwardToOrigin( + projectPath: string, + trunkBranch: string, + initLogger: InitLogger + ): Promise { + try { + // Check if local trunk is an ancestor of origin/trunk + // Exit code 0 = local is ancestor (can fast-forward), non-zero = cannot + using proc = execAsync( + `git -C "${projectPath}" merge-base --is-ancestor "${trunkBranch}" "origin/${trunkBranch}"` + ); + await proc.result; + return true; // Local is behind or equal to origin + } catch { + // Local is ahead or diverged - preserve local state + initLogger.logStderr( + `Note: Local ${trunkBranch} is ahead of or diverged from origin, using local state` + ); + return false; + } + } + + /** + * Fast-forward merge to latest origin/ after checkout. + * Best-effort operation for existing branches that may be behind origin. + */ + private async fastForwardToOrigin( + workspacePath: string, + trunkBranch: string, + initLogger: InitLogger + ): Promise { + try { + initLogger.logStep("Fast-forward merging..."); + + using mergeProc = execAsync( + `git -C "${workspacePath}" merge --ff-only "origin/${trunkBranch}"` + ); + await mergeProc.result; + initLogger.logStep("Fast-forwarded to latest origin successfully"); + } catch (mergeError) { + // Fast-forward not possible (diverged branches) - just warn + const errorMsg = getErrorMessage(mergeError); + initLogger.logStderr(`Note: Fast-forward failed (${errorMsg}), using local branch state`); + } + } + + async renameWorkspace( + projectPath: string, + oldName: string, + newName: string + ): Promise< + { success: true; oldPath: string; newPath: string } | { success: false; error: string } + > { + // Clean up stale lock before git operations on main repo + cleanStaleLock(projectPath); + + // Compute workspace paths using canonical method + const oldPath = this.getWorkspacePath(projectPath, oldName); + const newPath = this.getWorkspacePath(projectPath, newName); + + try { + // Move the worktree directory (updates git's internal worktree metadata) + using moveProc = execAsync(`git -C "${projectPath}" worktree move "${oldPath}" "${newPath}"`); + await moveProc.result; + + // Rename the git branch to match the new workspace name + // In mux, branch name and workspace name are always kept in sync. + // Run from the new worktree path since that's where the branch is checked out. + // Best-effort: ignore errors (e.g., branch might have a different name in test scenarios). + try { + using branchProc = execAsync(`git -C "${newPath}" branch -m "${oldName}" "${newName}"`); + await branchProc.result; + } catch { + // Branch rename failed - this is fine, the directory was still moved + // This can happen if the branch name doesn't match the old directory name + } + + return { success: true, oldPath, newPath }; + } catch (error) { + return { success: false, error: `Failed to rename workspace: ${getErrorMessage(error)}` }; + } + } + + async deleteWorkspace( + projectPath: string, + workspaceName: string, + force: boolean + ): Promise<{ success: true; deletedPath: string } | { success: false; error: string }> { + // Clean up stale lock before git operations on main repo + cleanStaleLock(projectPath); + + // In-place workspaces are identified by projectPath === workspaceName + // These are direct workspace directories (e.g., CLI/benchmark sessions), not git worktrees + const isInPlace = projectPath === workspaceName; + + // For git worktree workspaces, workspaceName is the branch name. + // Now that archiving exists, deleting a workspace should also delete its local branch by default. + const shouldDeleteBranch = !isInPlace; + + const tryDeleteBranch = async () => { + if (!shouldDeleteBranch) return; + + const branchToDelete = workspaceName.trim(); + if (!branchToDelete) { + log.debug("Skipping git branch deletion: empty workspace name", { + projectPath, + workspaceName, + }); + return; + } + + let localBranches: string[]; + try { + localBranches = await listLocalBranches(projectPath); + } catch (error) { + log.debug("Failed to list local branches; skipping branch deletion", { + projectPath, + workspaceName: branchToDelete, + error: getErrorMessage(error), + }); + return; + } + + if (!localBranches.includes(branchToDelete)) { + log.debug("Skipping git branch deletion: branch does not exist locally", { + projectPath, + workspaceName: branchToDelete, + }); + return; + } + + // Never delete protected/trunk branches. + const protectedBranches = new Set(["main", "master", "trunk", "develop", "default"]); + + // If there's only one local branch, treat it as protected (likely trunk). + if (localBranches.length === 1) { + protectedBranches.add(localBranches[0]); + } + + const currentBranch = await getCurrentBranch(projectPath); + if (currentBranch) { + protectedBranches.add(currentBranch); + } + + // If origin/HEAD points at a local branch, also treat it as protected. + try { + using originHeadProc = execAsync( + `git -C "${projectPath}" symbolic-ref refs/remotes/origin/HEAD` + ); + const { stdout } = await originHeadProc.result; + const ref = stdout.trim(); + const prefix = "refs/remotes/origin/"; + if (ref.startsWith(prefix)) { + protectedBranches.add(ref.slice(prefix.length)); + } + } catch { + // No origin/HEAD (or not a git repo) - ignore + } + + if (protectedBranches.has(branchToDelete)) { + log.debug("Skipping git branch deletion: protected branch", { + projectPath, + workspaceName: branchToDelete, + }); + return; + } + + // Extra safety: don't delete a branch still checked out by any worktree. + try { + using worktreeProc = execAsync(`git -C "${projectPath}" worktree list --porcelain`); + const { stdout } = await worktreeProc.result; + const needle = `branch refs/heads/${branchToDelete}`; + const isCheckedOut = stdout.split("\n").some((line) => line.trim() === needle); + if (isCheckedOut) { + log.debug("Skipping git branch deletion: branch still checked out by a worktree", { + projectPath, + workspaceName: branchToDelete, + }); + return; + } + } catch (error) { + // If the worktree list fails, proceed anyway - git itself will refuse to delete a checked-out branch. + log.debug("Failed to check worktree list before branch deletion; proceeding", { + projectPath, + workspaceName: branchToDelete, + error: getErrorMessage(error), + }); + } + + const deleteFlag = force ? "-D" : "-d"; + try { + using deleteProc = execAsync( + `git -C "${projectPath}" branch ${deleteFlag} "${branchToDelete}"` + ); + await deleteProc.result; + } catch (error) { + // Best-effort: workspace deletion should not fail just because branch cleanup failed. + log.debug("Failed to delete git branch after removing worktree", { + projectPath, + workspaceName: branchToDelete, + error: getErrorMessage(error), + }); + } + }; + + // Compute workspace path using the canonical method + const deletedPath = this.getWorkspacePath(projectPath, workspaceName); + + // Check if directory exists - if not, operation is idempotent + try { + await fsPromises.access(deletedPath); + } catch { + // Directory doesn't exist - operation is idempotent + // For standard worktrees, prune stale git records (best effort) + if (!isInPlace) { + try { + using pruneProc = execAsync(`git -C "${projectPath}" worktree prune`); + await pruneProc.result; + } catch { + // Ignore prune errors - directory is already deleted, which is the goal + } + } + + // Best-effort: also delete the local branch. + await tryDeleteBranch(); + return { success: true, deletedPath }; + } + + // For in-place workspaces, there's no worktree to remove + // Just return success - the workspace directory itself should not be deleted + // as it may contain the user's actual project files + if (isInPlace) { + return { success: true, deletedPath }; + } + + try { + // Use git worktree remove to delete the worktree + // This updates git's internal worktree metadata correctly + // Only use --force if explicitly requested by the caller + const forceFlag = force ? " --force" : ""; + using proc = execAsync( + `git -C "${projectPath}" worktree remove${forceFlag} "${deletedPath}"` + ); + await proc.result; + + // Best-effort: also delete the local branch. + await tryDeleteBranch(); + return { success: true, deletedPath }; + } catch (error) { + const message = getErrorMessage(error); + + // Check if the error is due to missing/stale worktree + const normalizedError = message.toLowerCase(); + const looksLikeMissingWorktree = + normalizedError.includes("not a working tree") || + normalizedError.includes("does not exist") || + normalizedError.includes("no such file"); + + if (looksLikeMissingWorktree) { + // Worktree records are stale - prune them + try { + using pruneProc = execAsync(`git -C "${projectPath}" worktree prune`); + await pruneProc.result; + } catch { + // Ignore prune errors + } + // Treat as success - workspace is gone (idempotent) + await tryDeleteBranch(); + return { success: true, deletedPath }; + } + + // If force is enabled and git worktree remove failed, fall back to rm -rf + // This handles edge cases like submodules where git refuses to delete + if (force) { + try { + // Prune git's worktree records first (best effort) + try { + using pruneProc = execAsync(`git -C "${projectPath}" worktree prune`); + await pruneProc.result; + } catch { + // Ignore prune errors - we'll still try rm -rf + } + + // Force delete the directory (use bash shell for rm -rf on Windows) + // Convert to POSIX path for Git Bash compatibility on Windows + using rmProc = execAsync(`rm -rf "${toPosixPath(deletedPath)}"`, { + shell: getBashPath(), + }); + await rmProc.result; + + // Best-effort: also delete the local branch. + await tryDeleteBranch(); + return { success: true, deletedPath }; + } catch (rmError) { + return { + success: false, + error: `Failed to remove worktree via git and rm: ${getErrorMessage(rmError)}`, + }; + } + } + + // force=false - return the git error without attempting rm -rf + return { success: false, error: `Failed to remove worktree: ${message}` }; + } + } + + async forkWorkspace(params: WorkspaceForkParams): Promise { + const { projectPath, sourceWorkspaceName, newWorkspaceName, initLogger } = params; + + // Get source workspace path + const sourceWorkspacePath = this.getWorkspacePath(projectPath, sourceWorkspaceName); + + // Get current branch from source workspace + try { + using proc = execAsync(`git -C "${sourceWorkspacePath}" branch --show-current`); + const { stdout } = await proc.result; + const sourceBranch = stdout.trim(); + + if (!sourceBranch) { + return { + success: false, + error: "Failed to detect branch in source workspace", + }; + } + + // Use createWorkspace with sourceBranch as trunk to fork from source branch + const createResult = await this.createWorkspace({ + projectPath, + branchName: newWorkspaceName, + trunkBranch: sourceBranch, // Fork from source branch instead of main/master + initLogger, + }); + + if (!createResult.success || !createResult.workspacePath) { + return { + success: false, + error: createResult.error ?? "Failed to create workspace", + }; + } + + return { + success: true, + workspacePath: createResult.workspacePath, + sourceBranch, + }; + } catch (error) { + return { + success: false, + error: getErrorMessage(error), + }; + } + } +} diff --git a/tests/runtime/runtime.test.ts b/tests/runtime/runtime.test.ts index f08048dbe9..474bbc49b4 100644 --- a/tests/runtime/runtime.test.ts +++ b/tests/runtime/runtime.test.ts @@ -8,7 +8,7 @@ * * Note: Workspace management tests (renameWorkspace, deleteWorkspace) are colocated * with their runtime implementations: - * - WorktreeRuntime: src/node/runtime/WorktreeRuntime.test.ts + * - WorktreeManager: src/node/worktree/WorktreeManager.test.ts * - SSHRuntime: src/node/runtime/SSHRuntime.test.ts */