diff --git a/README.md b/README.md index 4e1a4af323..3bdce5f26e 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,10 @@ Here are some specific use cases we enable: ## Features -- **Isolated workspaces** with central view on git divergence - - **Local**: git worktrees on your local machine ([docs](https://cmux.io/local.html)) - - **SSH**: regular git clones on a remote server ([docs](https://cmux.io/ssh.html)) +- **Isolated workspaces** with central view on git divergence ([docs](https://cmux.io/runtime.html)) + - **[Local](https://cmux.io/runtime/local.html)**: run directly in your project directory + - **[Worktree](https://cmux.io/runtime/worktree.html)**: git worktrees on your local machine + - **[SSH](https://cmux.io/runtime/ssh.html)**: remote execution on a server over SSH - **Multi-model** (`sonnet-4-*`, `grok-*`, `gpt-5-*`, `opus-4-*`) - Ollama supported for local LLMs ([docs](https://cmux.io/models.html#ollama-local)) - OpenRouter supported for long-tail of LLMs ([docs](https://cmux.io/models.html#openrouter-cloud)) diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 7a7677d317..e7166f0ead 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -9,8 +9,10 @@ # Features - [Workspaces](./workspaces.md) - - [Local](./local.md) - - [SSH](./ssh.md) + - [Runtimes](./runtime.md) + - [Local](./runtime/local.md) + - [Worktree](./runtime/worktree.md) + - [SSH](./runtime/ssh.md) - [Forking](./fork.md) - [Init Hooks](./init-hooks.md) - [VS Code Extension](./vscode-extension.md) diff --git a/docs/runtime.md b/docs/runtime.md new file mode 100644 index 0000000000..8efafa570d --- /dev/null +++ b/docs/runtime.md @@ -0,0 +1,23 @@ +# Runtimes + +Runtimes determine where and how mux executes agent workspaces. + +| Runtime | Isolation | Best For | +| ------------------------------------- | ------------------------------------------ | ---------------------------------------- | +| **[Local](./runtime/local.md)** | All workspaces share the project directory | Quick edits to your working copy | +| **[Worktree](./runtime/worktree.md)** | Each workspace gets its own directory | Running multiple agents in parallel | +| **[SSH](./runtime/ssh.md)** | Remote execution over SSH | Security, performance, heavy parallelism | + +## Choosing a Runtime + +When creating a workspace, select the runtime from the dropdown in the workspace creation UI. + +## Init Hooks + +[Init hooks](./init-hooks.md) can detect the runtime type via the `MUX_RUNTIME` environment variable: + +- `local` — Local runtime +- `worktree` — Worktree runtime +- `ssh` — SSH runtime + +This lets your init hook adapt behavior, e.g., skip worktree-specific setup when running in local mode. diff --git a/docs/runtime/local.md b/docs/runtime/local.md new file mode 100644 index 0000000000..24275b6239 --- /dev/null +++ b/docs/runtime/local.md @@ -0,0 +1,19 @@ +# Local Runtime + +Local runtime runs the agent directly in your project directory—the same directory you use for development. There's no worktree isolation; the agent works in your actual working copy. + +## When to Use + +- Quick one-off tasks in your current working copy +- Reviewing agent work alongside your own uncommitted changes +- Projects where worktrees don't work well (e.g., some monorepos) + +## Caveats + +⚠️ **No isolation**: Multiple local workspaces for the same project see and modify the same files. Running them simultaneously can cause conflicts. mux shows a warning when another local workspace is actively streaming. + +⚠️ **Affects your working copy**: Agent changes happen in your actual project directory. + +## Filesystem + +The workspace path is your project directory itself. No additional directories are created. diff --git a/docs/ssh.md b/docs/runtime/ssh.md similarity index 90% rename from docs/ssh.md rename to docs/runtime/ssh.md index 4cada96daf..908ae1357c 100644 --- a/docs/ssh.md +++ b/docs/runtime/ssh.md @@ -1,17 +1,17 @@ -# SSH Workspaces +# SSH Runtime mux supports using remote hosts over SSH for workspaces. When configured, all tool operations will execute over SSH and the agent is securely isolated from your local machine. -Our security architecture considers the remote machine potentially hostile. No keys or credentials are implicitly transferred there—just the git archive and [Project Secrets](./project-secrets.md). +Our security architecture considers the remote machine potentially hostile. No keys or credentials are implicitly transferred there—just the git archive and [Project Secrets](../project-secrets.md). We highly recommend using SSH workspaces for an optimal experience: - **Security**: Prompt injection risk is contained to the credentials / files on the remote machine. - - SSH remotes pair nicely with [agentic git identities](./agentic-git-identity.md) + - SSH remotes pair nicely with [agentic git identities](../agentic-git-identity.md) - **Performance**: Run many, many agents in parallel while maintaining good battery life and UI performance -![ssh workspaces](./img/new-workspace-ssh.webp) +![ssh workspaces](../img/new-workspace-ssh.webp) The Host can be: diff --git a/docs/local.md b/docs/runtime/worktree.md similarity index 69% rename from docs/local.md rename to docs/runtime/worktree.md index a3e1c0008c..04187c0d8f 100644 --- a/docs/local.md +++ b/docs/runtime/worktree.md @@ -1,6 +1,6 @@ -# Local Workspaces +# Worktree Runtime -Local workspaces use [git worktrees](https://git-scm.com/docs/git-worktree) on your local machine. Worktrees share the `.git` directory with your main repository while maintaining independent working changes and checkout state. +Worktree runtime uses [git worktrees](https://git-scm.com/docs/git-worktree) on your local machine. Worktrees share the `.git` directory with your main repository while maintaining independent working changes and checkout state. ## How Worktrees Work @@ -10,7 +10,7 @@ It's important to note that a **worktree is not locked to a branch**. The agent ## Filesystem Layout -Local workspaces are stored in `~/.mux/src//`. +Worktree workspaces are stored in `~/.mux/src//`. Example layout: diff --git a/docs/vscode-extension.md b/docs/vscode-extension.md index dbf0cb70ed..1be6b1d3a6 100644 --- a/docs/vscode-extension.md +++ b/docs/vscode-extension.md @@ -85,5 +85,5 @@ repository. ## Related - [Workspaces Overview](./workspaces.md) -- [SSH Workspaces](./ssh.md) +- [SSH Runtime](./runtime/ssh.md) - [VS Code Remote-SSH Documentation](https://code.visualstudio.com/docs/remote/ssh) diff --git a/docs/workspaces.md b/docs/workspaces.md index 93b62e76f7..877791d16b 100644 --- a/docs/workspaces.md +++ b/docs/workspaces.md @@ -2,20 +2,23 @@ Workspaces in mux provide isolated development environments for parallel agent work. Each workspace maintains its own Git state, allowing you to explore different approaches, run multiple tasks simultaneously, or test changes without affecting your main repository. -## Workspace Types +## Runtimes -mux supports two workspace backends: +mux supports three [runtime types](./runtime.md): -- **[Local Workspaces](./local.md)**: Use [git worktrees](https://git-scm.com/docs/git-worktree) on your local machine. Worktrees share the `.git` directory with your main repository while maintaining independent working changes. +- **[Local](./runtime/local.md)**: Run directly in your project directory. No isolation—best for quick edits to your working copy. -- **[SSH Workspaces](./ssh.md)**: Regular git clones on a remote server accessed via SSH. These are completely independent repositories stored on the remote machine. +- **[Worktree](./runtime/worktree.md)**: Isolated directories using [git worktrees](https://git-scm.com/docs/git-worktree). Worktrees share `.git` with your main repository while maintaining independent working changes. -## Choosing a Backend +- **[SSH](./runtime/ssh.md)**: Remote execution over SSH. Ideal for heavy workloads, security isolation, or leveraging remote infrastructure. -The workspace backend is selected when you create a workspace: +## Choosing a Runtime -- **Local**: Best for fast iteration, local testing, and when you want to leverage your local machine's resources -- **SSH**: Ideal for heavy workloads, long-running tasks, or when you need access to remote infrastructure +The runtime is selected when you create a workspace: + +- **Local**: Quick tasks in your current working copy +- **Worktree**: Best for parallel agent work with isolation +- **SSH**: Heavy workloads, security, or remote infrastructure ## Key Concepts diff --git a/src/browser/App.tsx b/src/browser/App.tsx index b3833917d9..2b6842cdaa 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -559,26 +559,31 @@ function AppInner() {
{selectedWorkspace ? ( - - - + (() => { + const currentMetadata = workspaceMetadata.get(selectedWorkspace.workspaceId); + // Use metadata.name for workspace name (works for both worktree and local runtimes) + // Fallback to path-based derivation for legacy compatibility + const workspaceName = + currentMetadata?.name ?? + selectedWorkspace.namedWorkspacePath?.split("/").pop() ?? + selectedWorkspace.workspaceId; + return ( + + + + ); + })() ) : creationProjectPath ? ( (() => { const projectPath = creationProjectPath; diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 840cc002fe..2dd1d36515 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -36,6 +36,7 @@ import { useAIViewKeybinds } from "@/browser/hooks/useAIViewKeybinds"; import { evictModelFromLRU } from "@/browser/hooks/useModelLRU"; import { QueuedMessage } from "./Messages/QueuedMessage"; import { CompactionWarning } from "./CompactionWarning"; +import { ConcurrentLocalWarning } from "./ConcurrentLocalWarning"; import { checkAutoCompaction } from "@/browser/utils/compaction/autoCompactionCheck"; import { executeCompaction } from "@/browser/utils/chatCommands"; import { useProviderOptions } from "@/browser/hooks/useProviderOptions"; @@ -44,6 +45,7 @@ import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; interface AIViewProps { workspaceId: string; + projectPath: string; projectName: string; branch: string; namedWorkspacePath: string; // User-friendly path for display and terminal @@ -55,6 +57,7 @@ interface AIViewProps { const AIViewInner: React.FC = ({ workspaceId, + projectPath, projectName, branch, namedWorkspacePath, @@ -561,6 +564,11 @@ const AIViewInner: React.FC = ({ onEdit={() => void handleEditQueuedMessage()} /> )} +
{!autoScroll && ( diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index e728a1e1d7..34dcb0a50a 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -15,14 +15,18 @@ interface CreationControlsProps { /** * Additional controls shown only during workspace creation - * - Trunk branch selector (which branch to fork from) - * - Runtime mode (local vs SSH) + * - Trunk branch selector (which branch to fork from) - hidden for Local runtime + * - Runtime mode (Local, Worktree, SSH) */ export function CreationControls(props: CreationControlsProps) { + // Local runtime doesn't need a trunk branch selector (uses project dir as-is) + const showTrunkBranchSelector = + props.branches.length > 0 && props.runtimeMode !== RUNTIME_MODE.LOCAL; + return (
- {/* Trunk Branch Selector */} - {props.branches.length > 0 && ( + {/* Trunk Branch Selector - hidden for Local runtime */} + {showTrunkBranchSelector && (
{ const mode = newMode as RuntimeMode; - props.onRuntimeChange(mode, mode === RUNTIME_MODE.LOCAL ? "" : props.sshHost); + // Clear SSH host when switching away from SSH + props.onRuntimeChange(mode, mode === RUNTIME_MODE.SSH ? props.sshHost : ""); }} disabled={props.disabled} aria-label="Runtime mode" @@ -77,8 +83,10 @@ export function CreationControls(props: CreationControlsProps) { Runtime:
- • Local: git worktree in ~/.mux/src -
• SSH: remote clone in ~/mux on SSH host + • Local: work directly in project directory (no isolation) +
+ • Worktree: git worktree in ~/.mux/src (isolated) +
• SSH: remote clone on SSH host
diff --git a/src/browser/components/ConcurrentLocalWarning.tsx b/src/browser/components/ConcurrentLocalWarning.tsx new file mode 100644 index 0000000000..3d3251804f --- /dev/null +++ b/src/browser/components/ConcurrentLocalWarning.tsx @@ -0,0 +1,72 @@ +import React, { useMemo } from "react"; +import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; +import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; +import { isLocalProjectRuntime } from "@/common/types/runtime"; +import type { RuntimeConfig } from "@/common/types/runtime"; +import { useSyncExternalStore } from "react"; + +/** + * Subtle indicator shown when a local project-dir workspace has another workspace + * for the same project that is currently streaming. + */ +export const ConcurrentLocalWarning: React.FC<{ + workspaceId: string; + projectPath: string; + runtimeConfig?: RuntimeConfig; +}> = (props) => { + // Only show for local project-dir runtimes (not worktree or SSH) + const isLocalProject = isLocalProjectRuntime(props.runtimeConfig); + + const { workspaceMetadata } = useWorkspaceContext(); + const store = useWorkspaceStoreRaw(); + + // Find other local project-dir workspaces for the same project + const otherLocalWorkspaceIds = useMemo(() => { + if (!isLocalProject) return []; + + const result: string[] = []; + for (const [id, meta] of workspaceMetadata) { + // Skip current workspace + if (id === props.workspaceId) continue; + // Must be same project + if (meta.projectPath !== props.projectPath) continue; + // Must also be local project-dir runtime + if (!isLocalProjectRuntime(meta.runtimeConfig)) continue; + result.push(id); + } + return result; + }, [isLocalProject, workspaceMetadata, props.workspaceId, props.projectPath]); + + // Subscribe to streaming state of other local workspaces + const streamingWorkspaceName = useSyncExternalStore( + (listener) => { + const unsubscribers = otherLocalWorkspaceIds.map((id) => store.subscribeKey(id, listener)); + return () => unsubscribers.forEach((unsub) => unsub()); + }, + () => { + for (const id of otherLocalWorkspaceIds) { + try { + const state = store.getWorkspaceSidebarState(id); + if (state.canInterrupt) { + const meta = workspaceMetadata.get(id); + return meta?.name ?? id; + } + } catch { + // Workspace may not be registered yet, skip + } + } + return null; + } + ); + + if (!isLocalProject || !streamingWorkspaceName) { + return null; + } + + return ( +
+ ⚠ {streamingWorkspaceName} is also running in this + project directory — agents may interfere +
+ ); +}; diff --git a/src/browser/components/RuntimeBadge.tsx b/src/browser/components/RuntimeBadge.tsx index e0b399d520..b5f175bda2 100644 --- a/src/browser/components/RuntimeBadge.tsx +++ b/src/browser/components/RuntimeBadge.tsx @@ -1,7 +1,7 @@ import React from "react"; import { cn } from "@/common/lib/utils"; import type { RuntimeConfig } from "@/common/types/runtime"; -import { isSSHRuntime } from "@/common/types/runtime"; +import { isSSHRuntime, isWorktreeRuntime, isLocalProjectRuntime } from "@/common/types/runtime"; import { extractSshHostname } from "@/browser/utils/ui/runtimeBadge"; import { TooltipWrapper, Tooltip } from "./Tooltip"; @@ -10,47 +10,122 @@ interface RuntimeBadgeProps { className?: string; } +/** Server rack icon for SSH runtime */ +function SSHIcon() { + return ( + + + + + + + ); +} + +/** Git branch icon for worktree runtime */ +function WorktreeIcon() { + return ( + + {/* Simplified git branch: vertical line with branch off */} + + + + + + + ); +} + +/** Folder icon for local project-dir runtime (reserved for future use) */ +function _LocalIcon() { + return ( + + {/* Folder icon */} + + + ); +} + /** - * Badge to display SSH runtime information. - * Shows icon-only badge for SSH runtimes with hostname in tooltip. + * Badge to display runtime type information. + * Shows icon-only badge with tooltip describing the runtime type. + * - SSH: server icon with hostname + * - Worktree: git branch icon (isolated worktree) + * - Local: folder icon (project directory, no badge shown by default) */ export function RuntimeBadge({ runtimeConfig, className }: RuntimeBadgeProps) { - const hostname = extractSshHostname(runtimeConfig); - - if (!hostname) { - return null; + // SSH runtime: show server icon with hostname + if (isSSHRuntime(runtimeConfig)) { + const hostname = extractSshHostname(runtimeConfig); + return ( + + + + + SSH: {hostname ?? runtimeConfig.host} + + ); } - return ( - - - + - {/* Server rack icon */} - - - - - - - - SSH: {isSSHRuntime(runtimeConfig) ? runtimeConfig.host : hostname} - - - ); + + + Worktree: isolated git worktree + + ); + } + + // Local project-dir runtime: don't show badge (it's the simplest/default) + // Could optionally show LocalIcon if we want visibility + if (isLocalProjectRuntime(runtimeConfig)) { + return null; // No badge for simple local runtimes + } + + return null; } diff --git a/src/browser/utils/chatCommands.test.ts b/src/browser/utils/chatCommands.test.ts index d3d20093f0..8ec837abf2 100644 --- a/src/browser/utils/chatCommands.test.ts +++ b/src/browser/utils/chatCommands.test.ts @@ -13,14 +13,21 @@ beforeEach(() => { describe("parseRuntimeString", () => { const workspaceName = "test-workspace"; - test("returns undefined for undefined runtime (default to local)", () => { + test("returns undefined for undefined runtime (default to worktree)", () => { expect(parseRuntimeString(undefined, workspaceName)).toBeUndefined(); }); - test("returns undefined for explicit 'local' runtime", () => { - expect(parseRuntimeString("local", workspaceName)).toBeUndefined(); - expect(parseRuntimeString("LOCAL", workspaceName)).toBeUndefined(); - expect(parseRuntimeString(" local ", workspaceName)).toBeUndefined(); + test("returns undefined for explicit 'worktree' runtime", () => { + expect(parseRuntimeString("worktree", workspaceName)).toBeUndefined(); + expect(parseRuntimeString("WORKTREE", workspaceName)).toBeUndefined(); + expect(parseRuntimeString(" worktree ", workspaceName)).toBeUndefined(); + }); + + test("returns local config for explicit 'local' runtime", () => { + // "local" now returns project-dir runtime config (no srcBaseDir) + expect(parseRuntimeString("local", workspaceName)).toEqual({ type: "local" }); + expect(parseRuntimeString("LOCAL", workspaceName)).toEqual({ type: "local" }); + expect(parseRuntimeString(" local ", workspaceName)).toEqual({ type: "local" }); }); test("parses valid SSH runtime", () => { @@ -87,10 +94,10 @@ describe("parseRuntimeString", () => { test("throws error for unknown runtime type", () => { expect(() => parseRuntimeString("docker", workspaceName)).toThrow( - "Unknown runtime type: 'docker'" + "Unknown runtime type: 'docker'. Use 'ssh ', 'worktree', or 'local'" ); expect(() => parseRuntimeString("remote", workspaceName)).toThrow( - "Unknown runtime type: 'remote'" + "Unknown runtime type: 'remote'. Use 'ssh ', 'worktree', or 'local'" ); }); }); diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index d9ad6897d3..3ae071dcfb 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -404,22 +404,30 @@ async function handleForkCommand( * Parse runtime string from -r flag into RuntimeConfig for backend * Supports formats: * - "ssh " or "ssh " -> SSH runtime - * - "local" -> Local runtime (explicit) - * - undefined -> Local runtime (default) + * - "worktree" -> Worktree runtime (git worktrees) + * - "local" -> Local runtime (project-dir, no isolation) + * - undefined -> Worktree runtime (default) */ export function parseRuntimeString( runtime: string | undefined, _workspaceName: string ): RuntimeConfig | undefined { if (!runtime) { - return undefined; // Default to local (backend decides) + return undefined; // Default to worktree (backend decides) } const trimmed = runtime.trim(); const lowerTrimmed = trimmed.toLowerCase(); + // Worktree runtime (explicit or default) + if (lowerTrimmed === RUNTIME_MODE.WORKTREE) { + return undefined; // Explicit worktree - let backend use default + } + + // Local runtime (project-dir, no isolation) if (lowerTrimmed === RUNTIME_MODE.LOCAL) { - return undefined; // Explicit local - let backend use default + // Return "local" type without srcBaseDir to indicate project-dir runtime + return { type: RUNTIME_MODE.LOCAL }; } // Parse "ssh " or "ssh " format @@ -439,7 +447,7 @@ export function parseRuntimeString( }; } - throw new Error(`Unknown runtime type: '${runtime}'. Use 'ssh ' or 'local'`); + throw new Error(`Unknown runtime type: '${runtime}'. Use 'ssh ', 'worktree', or 'local'`); } export interface CreateWorkspaceOptions { diff --git a/src/common/constants/workspace.ts b/src/common/constants/workspace.ts index 04f6e3e341..2e8c07b011 100644 --- a/src/common/constants/workspace.ts +++ b/src/common/constants/workspace.ts @@ -1,10 +1,11 @@ import type { RuntimeConfig } from "@/common/types/runtime"; /** - * Default runtime configuration for local workspaces - * Used when no runtime config is specified + * Default runtime configuration for worktree workspaces. + * Uses git worktrees for workspace isolation. + * Used when no runtime config is specified. */ export const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = { - type: "local", + type: "worktree", srcBaseDir: "~/.mux/src", } as const; diff --git a/src/common/types/runtime.test.ts b/src/common/types/runtime.test.ts index 8b5690bf7b..9d3c8a6258 100644 --- a/src/common/types/runtime.test.ts +++ b/src/common/types/runtime.test.ts @@ -23,16 +23,16 @@ describe("parseRuntimeModeAndHost", () => { }); }); - it("defaults to local for undefined", () => { + it("defaults to worktree for undefined", () => { expect(parseRuntimeModeAndHost(undefined)).toEqual({ - mode: "local", + mode: "worktree", host: "", }); }); - it("defaults to local for null", () => { + it("defaults to worktree for null", () => { expect(parseRuntimeModeAndHost(null)).toEqual({ - mode: "local", + mode: "worktree", host: "", }); }); @@ -47,8 +47,12 @@ describe("buildRuntimeString", () => { expect(buildRuntimeString("ssh", "")).toBe("ssh"); }); - it("returns undefined for local mode", () => { - expect(buildRuntimeString("local", "")).toBeUndefined(); + it("returns 'local' for local mode", () => { + expect(buildRuntimeString("local", "")).toBe("local"); + }); + + it("returns undefined for worktree mode (default)", () => { + expect(buildRuntimeString("worktree", "")).toBeUndefined(); }); it("trims whitespace from host", () => { diff --git a/src/common/types/runtime.ts b/src/common/types/runtime.ts index 085b702b9d..f7e69d16cf 100644 --- a/src/common/types/runtime.ts +++ b/src/common/types/runtime.ts @@ -3,20 +3,41 @@ */ /** Runtime mode type - used in UI and runtime string parsing */ -export type RuntimeMode = "local" | "ssh"; +export type RuntimeMode = "local" | "worktree" | "ssh"; /** Runtime mode constants */ export const RUNTIME_MODE = { LOCAL: "local" as const, + WORKTREE: "worktree" as const, SSH: "ssh" as const, } as const; /** Runtime string prefix for SSH mode (e.g., "ssh hostname") */ export const SSH_RUNTIME_PREFIX = "ssh "; +/** + * Runtime configuration union type. + * + * COMPATIBILITY NOTE: + * - `type: "local"` with `srcBaseDir` = legacy worktree config (for backward compat) + * - `type: "local"` without `srcBaseDir` = new project-dir runtime + * - `type: "worktree"` = explicit worktree runtime (new workspaces) + * + * This allows two-way compatibility: users can upgrade/downgrade without breaking workspaces. + */ export type RuntimeConfig = | { type: "local"; + /** Base directory where all workspaces are stored (legacy worktree config) */ + srcBaseDir: string; + } + | { + type: "local"; + /** No srcBaseDir = project-dir runtime (uses project path directly) */ + srcBaseDir?: never; + } + | { + type: "worktree"; /** Base directory where all workspaces are stored (e.g., ~/.mux/src) */ srcBaseDir: string; } @@ -36,6 +57,7 @@ export type RuntimeConfig = * Parse runtime string from localStorage or UI input into mode and host * Format: "ssh " -> { mode: "ssh", host: "" } * "ssh" -> { mode: "ssh", host: "" } + * "worktree" -> { mode: "worktree", host: "" } * "local" or undefined -> { mode: "local", host: "" } * * Use this for UI state management (localStorage, form inputs) @@ -45,7 +67,7 @@ export function parseRuntimeModeAndHost(runtime: string | null | undefined): { host: string; } { if (!runtime) { - return { mode: RUNTIME_MODE.LOCAL, host: "" }; + return { mode: RUNTIME_MODE.WORKTREE, host: "" }; } const trimmed = runtime.trim(); @@ -55,19 +77,23 @@ export function parseRuntimeModeAndHost(runtime: string | null | undefined): { return { mode: RUNTIME_MODE.LOCAL, host: "" }; } + if (lowerTrimmed === RUNTIME_MODE.WORKTREE) { + return { mode: RUNTIME_MODE.WORKTREE, host: "" }; + } + // Handle both "ssh" and "ssh " if (lowerTrimmed === RUNTIME_MODE.SSH || lowerTrimmed.startsWith(SSH_RUNTIME_PREFIX)) { const host = trimmed.substring(SSH_RUNTIME_PREFIX.length).trim(); return { mode: RUNTIME_MODE.SSH, host }; } - // Default to local for unrecognized strings - return { mode: RUNTIME_MODE.LOCAL, host: "" }; + // Default to worktree for unrecognized strings + return { mode: RUNTIME_MODE.WORKTREE, host: "" }; } /** * Build runtime string for storage/IPC from mode and host - * Returns: "ssh " for SSH with host, "ssh" for SSH without host, undefined for local + * Returns: "ssh " for SSH, "local" for local, undefined for worktree (default) */ export function buildRuntimeString(mode: RuntimeMode, host: string): string | undefined { if (mode === RUNTIME_MODE.SSH) { @@ -75,6 +101,10 @@ export function buildRuntimeString(mode: RuntimeMode, host: string): string | un // Persist SSH mode even without a host so UI remains in SSH state return trimmedHost ? `${SSH_RUNTIME_PREFIX}${trimmedHost}` : "ssh"; } + if (mode === RUNTIME_MODE.LOCAL) { + return "local"; + } + // Worktree is default, no string needed return undefined; } @@ -86,3 +116,30 @@ export function isSSHRuntime( ): config is Extract { return config?.type === "ssh"; } + +/** + * Type guard to check if a runtime config uses worktree semantics. + * This includes both explicit "worktree" type AND legacy "local" with srcBaseDir. + */ +export function isWorktreeRuntime( + config: RuntimeConfig | undefined +): config is + | Extract + | Extract { + if (!config) return false; + if (config.type === "worktree") return true; + // Legacy: "local" with srcBaseDir is treated as worktree + if (config.type === "local" && "srcBaseDir" in config && config.srcBaseDir) return true; + return false; +} + +/** + * Type guard to check if a runtime config is project-dir local (no isolation) + */ +export function isLocalProjectRuntime( + config: RuntimeConfig | undefined +): config is Extract { + if (!config) return false; + // "local" without srcBaseDir is project-dir runtime + return config.type === "local" && !("srcBaseDir" in config && config.srcBaseDir); +} diff --git a/src/common/utils/runtimeCompatibility.ts b/src/common/utils/runtimeCompatibility.ts index 1ea3bcf4f5..f40214cffc 100644 --- a/src/common/utils/runtimeCompatibility.ts +++ b/src/common/utils/runtimeCompatibility.ts @@ -11,20 +11,23 @@ import type { RuntimeConfig } from "@/common/types/runtime"; * Check if a runtime config is from a newer version and incompatible. * * This handles downgrade compatibility: if a user upgrades to a version - * with new runtime types (e.g., "local" without srcBaseDir for project-dir mode), - * then downgrades, those workspaces should show a clear error rather than crashing. + * with new runtime types, then downgrades, those workspaces should show + * a clear error rather than crashing. + * + * Currently supported types: + * - "local" without srcBaseDir: Project-dir runtime (uses project path directly) + * - "local" with srcBaseDir: Legacy worktree config (for backward compat) + * - "worktree": Explicit worktree runtime + * - "ssh": Remote SSH runtime */ export function isIncompatibleRuntimeConfig(config: RuntimeConfig | undefined): boolean { if (!config) { return false; } - // Future versions may add "local" without srcBaseDir (project-dir mode) - // or new types like "worktree". Detect these as incompatible. - if (config.type === "local" && !("srcBaseDir" in config && config.srcBaseDir)) { - return true; - } - // Unknown types from future versions - if (config.type !== "local" && config.type !== "ssh") { + // All known types are compatible + const knownTypes = ["local", "worktree", "ssh"]; + if (!knownTypes.includes(config.type)) { + // Unknown type from a future version return true; } return false; diff --git a/src/node/git.ts b/src/node/git.ts index 105a603105..6d15b6563e 100644 --- a/src/node/git.ts +++ b/src/node/git.ts @@ -84,7 +84,8 @@ export async function createWorktree( const dirName = options.directoryName ?? branchName; // Compute workspace path using Runtime (single source of truth) const runtime = createRuntime( - options.runtimeConfig ?? { type: "local", srcBaseDir: config.srcDir } + options.runtimeConfig ?? { type: "local", srcBaseDir: config.srcDir }, + { projectPath } ); const workspacePath = runtime.getWorkspacePath(projectPath, dirName); const { trunkBranch } = options; diff --git a/src/node/runtime/LocalBaseRuntime.ts b/src/node/runtime/LocalBaseRuntime.ts new file mode 100644 index 0000000000..bc3dce26c6 --- /dev/null +++ b/src/node/runtime/LocalBaseRuntime.ts @@ -0,0 +1,402 @@ +import { spawn } from "child_process"; +import * as fs from "fs"; +import * as fsPromises from "fs/promises"; +import * as path from "path"; +import { Readable, Writable } from "stream"; +import type { + Runtime, + ExecOptions, + ExecStream, + FileStat, + WorkspaceCreationParams, + WorkspaceCreationResult, + WorkspaceInitParams, + WorkspaceInitResult, + WorkspaceForkParams, + WorkspaceForkResult, + InitLogger, +} from "./Runtime"; +import { RuntimeError as RuntimeErrorClass } from "./Runtime"; +import { NON_INTERACTIVE_ENV_VARS } from "@/common/constants/env"; +import { getBashPath } from "@/node/utils/main/bashPath"; +import { EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT } from "@/common/constants/exitCodes"; +import { DisposableProcess } from "@/node/utils/disposableExec"; +import { expandTilde } from "./tildeExpansion"; +import { + checkInitHookExists, + getInitHookPath, + createLineBufferedLoggers, + getInitHookEnv, +} from "./initHook"; + +/** + * Abstract base class for local runtimes (both WorktreeRuntime and LocalRuntime). + * + * Provides shared implementation for: + * - exec() - Command execution with streaming I/O + * - readFile() - File reading with streaming + * - writeFile() - Atomic file writes with streaming + * - stat() - File statistics + * - resolvePath() - Path resolution with tilde expansion + * - normalizePath() - Path normalization + * + * Subclasses must implement workspace-specific methods: + * - getWorkspacePath() + * - createWorkspace() + * - initWorkspace() + * - deleteWorkspace() + * - renameWorkspace() + * - forkWorkspace() + */ +export abstract class LocalBaseRuntime implements Runtime { + async exec(command: string, options: ExecOptions): Promise { + const startTime = performance.now(); + + // Use the specified working directory (must be a specific workspace path) + const cwd = options.cwd; + + // Check if working directory exists before spawning + // This prevents confusing ENOENT errors from spawn() + try { + await fsPromises.access(cwd); + } catch (err) { + throw new RuntimeErrorClass( + `Working directory does not exist: ${cwd}`, + "exec", + err instanceof Error ? err : undefined + ); + } + + // If niceness is specified on Unix/Linux, spawn nice directly to avoid escaping issues + // Windows doesn't have nice command, so just spawn bash directly + const isWindows = process.platform === "win32"; + const bashPath = getBashPath(); + const spawnCommand = options.niceness !== undefined && !isWindows ? "nice" : bashPath; + const spawnArgs = + options.niceness !== undefined && !isWindows + ? ["-n", options.niceness.toString(), bashPath, "-c", command] + : ["-c", command]; + + const childProcess = spawn(spawnCommand, spawnArgs, { + cwd, + env: { + ...process.env, + ...(options.env ?? {}), + ...NON_INTERACTIVE_ENV_VARS, + }, + stdio: ["pipe", "pipe", "pipe"], + // CRITICAL: Spawn as detached process group leader to enable cleanup of background processes. + // When a bash script spawns background processes (e.g., `sleep 100 &`), we need to kill + // the entire process group (including all backgrounded children) via process.kill(-pid). + // NOTE: detached:true does NOT cause bash to wait for background jobs when using 'exit' event + // instead of 'close' event. The 'exit' event fires when bash exits, ignoring background children. + detached: true, + }); + + // Wrap in DisposableProcess for automatic cleanup + const disposable = new DisposableProcess(childProcess); + + // Convert Node.js streams to Web Streams + const stdout = Readable.toWeb(childProcess.stdout) as unknown as ReadableStream; + const stderr = Readable.toWeb(childProcess.stderr) as unknown as ReadableStream; + const stdin = Writable.toWeb(childProcess.stdin) as unknown as WritableStream; + + // No stream cleanup in DisposableProcess - streams close naturally when process exits + // bash.ts handles cleanup after waiting for exitCode + + // Track if we killed the process due to timeout or abort + let timedOut = false; + let aborted = false; + + // Create promises for exit code and duration + // Uses special exit codes (EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT) for expected error conditions + const exitCode = new Promise((resolve, reject) => { + // Use 'exit' event instead of 'close' to handle background processes correctly. + // The 'close' event waits for ALL child processes (including background ones) to exit, + // which causes hangs when users spawn background processes like servers. + // The 'exit' event fires when the main bash process exits, which is what we want. + childProcess.on("exit", (code) => { + // Clean up any background processes (process group cleanup) + // This prevents zombie processes when scripts spawn background tasks + if (childProcess.pid !== undefined) { + try { + // Kill entire process group with SIGKILL - cannot be caught/ignored + // Use negative PID to signal the entire process group + process.kill(-childProcess.pid, "SIGKILL"); + } catch { + // Process group already dead or doesn't exist - ignore + } + } + + // Check abort first (highest priority) + if (aborted || options.abortSignal?.aborted) { + resolve(EXIT_CODE_ABORTED); + return; + } + // Check if we killed the process due to timeout + if (timedOut) { + resolve(EXIT_CODE_TIMEOUT); + return; + } + resolve(code ?? 0); + // Cleanup runs automatically via DisposableProcess + }); + + childProcess.on("error", (err) => { + reject(new RuntimeErrorClass(`Failed to execute command: ${err.message}`, "exec", err)); + }); + }); + + const duration = exitCode.then(() => performance.now() - startTime); + + // Register process group cleanup with DisposableProcess + // This ensures ALL background children are killed when process exits + disposable.addCleanup(() => { + if (childProcess.pid === undefined) return; + + try { + // Kill entire process group with SIGKILL - cannot be caught/ignored + process.kill(-childProcess.pid, "SIGKILL"); + } catch { + // Process group already dead or doesn't exist - ignore + } + }); + + // Handle abort signal + if (options.abortSignal) { + options.abortSignal.addEventListener("abort", () => { + aborted = true; + disposable[Symbol.dispose](); // Kill process and run cleanup + }); + } + + // Handle timeout + if (options.timeout !== undefined) { + const timeoutHandle = setTimeout(() => { + timedOut = true; + disposable[Symbol.dispose](); // Kill process and run cleanup + }, options.timeout * 1000); + + // Clear timeout if process exits naturally + void exitCode.finally(() => clearTimeout(timeoutHandle)); + } + + return { stdout, stderr, stdin, exitCode, duration }; + } + + readFile(filePath: string, _abortSignal?: AbortSignal): ReadableStream { + // Note: _abortSignal ignored for local operations (fast, no need for cancellation) + const nodeStream = fs.createReadStream(filePath); + + // Handle errors by wrapping in a transform + const webStream = Readable.toWeb(nodeStream) as unknown as ReadableStream; + + return new ReadableStream({ + async start(controller: ReadableStreamDefaultController) { + try { + const reader = webStream.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + controller.enqueue(value); + } + controller.close(); + } catch (err) { + controller.error( + new RuntimeErrorClass( + `Failed to read file ${filePath}: ${err instanceof Error ? err.message : String(err)}`, + "file_io", + err instanceof Error ? err : undefined + ) + ); + } + }, + }); + } + + writeFile(filePath: string, _abortSignal?: AbortSignal): WritableStream { + // Note: _abortSignal ignored for local operations (fast, no need for cancellation) + let tempPath: string; + let writer: WritableStreamDefaultWriter; + let resolvedPath: string; + let originalMode: number | undefined; + + return new WritableStream({ + async start() { + // Resolve symlinks to write through them (preserves the symlink) + try { + resolvedPath = await fsPromises.realpath(filePath); + // Save original permissions to restore after write + const stat = await fsPromises.stat(resolvedPath); + originalMode = stat.mode; + } catch { + // If file doesn't exist, use the original path and default permissions + resolvedPath = filePath; + originalMode = undefined; + } + + // Create parent directories if they don't exist + const parentDir = path.dirname(resolvedPath); + await fsPromises.mkdir(parentDir, { recursive: true }); + + // Create temp file for atomic write + tempPath = `${resolvedPath}.tmp.${Date.now()}`; + const nodeStream = fs.createWriteStream(tempPath); + const webStream = Writable.toWeb(nodeStream) as WritableStream; + writer = webStream.getWriter(); + }, + async write(chunk: Uint8Array) { + await writer.write(chunk); + }, + async close() { + // Close the writer and rename to final location + await writer.close(); + try { + // If we have original permissions, apply them to temp file before rename + if (originalMode !== undefined) { + await fsPromises.chmod(tempPath, originalMode); + } + await fsPromises.rename(tempPath, resolvedPath); + } catch (err) { + throw new RuntimeErrorClass( + `Failed to write file ${filePath}: ${err instanceof Error ? err.message : String(err)}`, + "file_io", + err instanceof Error ? err : undefined + ); + } + }, + async abort(reason?: unknown) { + // Clean up temp file on abort + await writer.abort(); + try { + await fsPromises.unlink(tempPath); + } catch { + // Ignore errors cleaning up temp file + } + throw new RuntimeErrorClass( + `Failed to write file ${filePath}: ${String(reason)}`, + "file_io" + ); + }, + }); + } + + async stat(filePath: string, _abortSignal?: AbortSignal): Promise { + // Note: _abortSignal ignored for local operations (fast, no need for cancellation) + try { + const stats = await fsPromises.stat(filePath); + return { + size: stats.size, + modifiedTime: stats.mtime, + isDirectory: stats.isDirectory(), + }; + } catch (err) { + throw new RuntimeErrorClass( + `Failed to stat ${filePath}: ${err instanceof Error ? err.message : String(err)}`, + "file_io", + err instanceof Error ? err : undefined + ); + } + } + + resolvePath(filePath: string): Promise { + // Expand tilde to actual home directory path + const expanded = expandTilde(filePath); + + // Resolve to absolute path (handles relative paths like "./foo") + return Promise.resolve(path.resolve(expanded)); + } + + normalizePath(targetPath: string, basePath: string): string { + // For local runtime, use Node.js path resolution + // Handle special case: current directory + const target = targetPath.trim(); + if (target === ".") { + return path.resolve(basePath); + } + return path.resolve(basePath, target); + } + + // Abstract methods that subclasses must implement + abstract getWorkspacePath(projectPath: string, workspaceName: string): string; + + abstract createWorkspace(params: WorkspaceCreationParams): Promise; + + abstract initWorkspace(params: WorkspaceInitParams): Promise; + + abstract renameWorkspace( + projectPath: string, + oldName: string, + newName: string, + abortSignal?: AbortSignal + ): Promise< + { success: true; oldPath: string; newPath: string } | { success: false; error: string } + >; + + abstract deleteWorkspace( + projectPath: string, + workspaceName: string, + force: boolean, + abortSignal?: AbortSignal + ): Promise<{ success: true; deletedPath: string } | { success: false; error: string }>; + + abstract forkWorkspace(params: WorkspaceForkParams): Promise; + + /** + * Helper to run .mux/init hook if it exists and is executable. + * Shared between WorktreeRuntime and LocalRuntime. + */ + protected async runInitHook( + projectPath: string, + workspacePath: string, + initLogger: InitLogger, + runtimeType: "local" | "worktree" + ): Promise { + // Check if hook exists and is executable + const hookExists = await checkInitHookExists(projectPath); + if (!hookExists) { + return; + } + + const hookPath = getInitHookPath(projectPath); + initLogger.logStep(`Running init hook: ${hookPath}`); + + // Create line-buffered loggers + const loggers = createLineBufferedLoggers(initLogger); + + return new Promise((resolve) => { + const bashPath = getBashPath(); + const proc = spawn(bashPath, ["-c", `"${hookPath}"`], { + cwd: workspacePath, + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + ...getInitHookEnv(projectPath, runtimeType), + }, + }); + + proc.stdout.on("data", (data: Buffer) => { + loggers.stdout.append(data.toString()); + }); + + proc.stderr.on("data", (data: Buffer) => { + loggers.stderr.append(data.toString()); + }); + + proc.on("close", (code) => { + // Flush any remaining buffered output + loggers.stdout.flush(); + loggers.stderr.flush(); + + initLogger.logComplete(code ?? 0); + resolve(); + }); + + proc.on("error", (err) => { + initLogger.logStderr(`Error running init hook: ${err.message}`); + initLogger.logComplete(-1); + resolve(); + }); + }); + } +} diff --git a/src/node/runtime/LocalRuntime.test.ts b/src/node/runtime/LocalRuntime.test.ts index a3cbdd0534..c802db53e6 100644 --- a/src/node/runtime/LocalRuntime.test.ts +++ b/src/node/runtime/LocalRuntime.test.ts @@ -1,67 +1,212 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, beforeAll, afterAll } from "bun:test"; import * as os from "os"; import * as path from "path"; +import * as fs from "fs/promises"; import { LocalRuntime } from "./LocalRuntime"; +import type { InitLogger } from "./Runtime"; -describe("LocalRuntime constructor", () => { - it("should expand tilde in srcBaseDir", () => { - const runtime = new LocalRuntime("~/workspace"); - const workspacePath = runtime.getWorkspacePath("/home/user/project", "branch"); +// Minimal mock logger - matches pattern in initHook.test.ts +function createMockLogger(): InitLogger & { steps: string[] } { + const steps: string[] = []; + return { + steps, + logStep: (msg: string) => steps.push(msg), + logStdout: () => { + /* no-op for test */ + }, + logStderr: () => { + /* no-op for test */ + }, + logComplete: () => { + /* no-op for test */ + }, + }; +} - // The workspace path should use the expanded home directory - const expected = path.join(os.homedir(), "workspace", "project", "branch"); - expect(workspacePath).toBe(expected); - }); +describe("LocalRuntime", () => { + // Use a temp directory for tests + let testDir: string; - it("should handle absolute paths without expansion", () => { - const runtime = new LocalRuntime("/absolute/path"); - const workspacePath = runtime.getWorkspacePath("/home/user/project", "branch"); + beforeAll(async () => { + testDir = await fs.mkdtemp(path.join(os.tmpdir(), "localruntime-test-")); + }); - const expected = path.join("/absolute/path", "project", "branch"); - expect(workspacePath).toBe(expected); + afterAll(async () => { + await fs.rm(testDir, { recursive: true, force: true }); }); - it("should handle bare tilde", () => { - const runtime = new LocalRuntime("~"); - const workspacePath = runtime.getWorkspacePath("/home/user/project", "branch"); + describe("constructor and getWorkspacePath", () => { + it("stores projectPath and returns it regardless of arguments", () => { + const runtime = new LocalRuntime("/home/user/my-project"); + // Both arguments are ignored - always returns the project path + expect(runtime.getWorkspacePath("/other/path", "some-branch")).toBe("/home/user/my-project"); + expect(runtime.getWorkspacePath("", "")).toBe("/home/user/my-project"); + }); - const expected = path.join(os.homedir(), "project", "branch"); - expect(workspacePath).toBe(expected); + it("does not expand tilde (unlike WorktreeRuntime)", () => { + // LocalRuntime stores the path as-is; callers must pass expanded paths + const runtime = new LocalRuntime("~/my-project"); + expect(runtime.getWorkspacePath("", "")).toBe("~/my-project"); + }); }); -}); -describe("LocalRuntime.resolvePath", () => { - it("should expand tilde to home directory", async () => { - const runtime = new LocalRuntime("/tmp"); - const resolved = await runtime.resolvePath("~"); - expect(resolved).toBe(os.homedir()); + describe("createWorkspace", () => { + it("succeeds when directory exists", async () => { + const runtime = new LocalRuntime(testDir); + const logger = createMockLogger(); + + const result = await runtime.createWorkspace({ + projectPath: testDir, + branchName: "main", + trunkBranch: "main", + directoryName: "main", + initLogger: logger, + }); + + expect(result.success).toBe(true); + expect(result.workspacePath).toBe(testDir); + expect(logger.steps.length).toBeGreaterThan(0); + expect(logger.steps.some((s) => s.includes("project directory"))).toBe(true); + }); + + it("fails when directory does not exist", async () => { + const nonExistentPath = path.join(testDir, "does-not-exist"); + const runtime = new LocalRuntime(nonExistentPath); + const logger = createMockLogger(); + + const result = await runtime.createWorkspace({ + projectPath: nonExistentPath, + branchName: "main", + trunkBranch: "main", + directoryName: "main", + initLogger: logger, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("does not exist"); + }); }); - it("should expand tilde with path", async () => { - const runtime = new LocalRuntime("/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); + describe("deleteWorkspace", () => { + it("returns success without deleting anything", async () => { + const runtime = new LocalRuntime(testDir); + + // Create a test file to verify it isn't deleted + const testFile = path.join(testDir, "delete-test.txt"); + await fs.writeFile(testFile, "should not be deleted"); + + const result = await runtime.deleteWorkspace(testDir, "main", false); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.deletedPath).toBe(testDir); + } + + // Verify file still exists + const fileStillExists = await fs.access(testFile).then( + () => true, + () => false + ); + expect(fileStillExists).toBe(true); + + // Cleanup + await fs.unlink(testFile); + }); + + it("returns success even with force=true (still no-op)", async () => { + const runtime = new LocalRuntime(testDir); + + const result = await runtime.deleteWorkspace(testDir, "main", true); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.deletedPath).toBe(testDir); + } + // Directory should still exist + const dirExists = await fs.access(testDir).then( + () => true, + () => false + ); + expect(dirExists).toBe(true); + }); }); - it("should resolve absolute paths", async () => { - const runtime = new LocalRuntime("/tmp"); - const resolved = await runtime.resolvePath("/tmp"); - expect(resolved).toBe("/tmp"); + describe("renameWorkspace", () => { + it("is a no-op that returns success with same path", async () => { + const runtime = new LocalRuntime(testDir); + + const result = await runtime.renameWorkspace(testDir, "old", "new"); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.oldPath).toBe(testDir); + expect(result.newPath).toBe(testDir); + } + }); }); - it("should resolve non-existent paths without checking existence", async () => { - const runtime = new LocalRuntime("/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"); + describe("forkWorkspace", () => { + it("returns error - operation not supported", async () => { + const runtime = new LocalRuntime(testDir); + const logger = createMockLogger(); + + const result = await runtime.forkWorkspace({ + projectPath: testDir, + sourceWorkspaceName: "main", + newWorkspaceName: "feature", + initLogger: logger, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Cannot fork"); + expect(result.error).toContain("project-dir"); + }); }); - it("should resolve relative paths from cwd", async () => { - const runtime = new LocalRuntime("/tmp"); - const resolved = await runtime.resolvePath("."); - // Should resolve to absolute path - expect(path.isAbsolute(resolved)).toBe(true); + describe("inherited LocalBaseRuntime methods", () => { + it("exec runs commands in projectPath", async () => { + const runtime = new LocalRuntime(testDir); + + const stream = await runtime.exec("pwd", { + cwd: testDir, + timeout: 10, + }); + + const reader = stream.stdout.getReader(); + let output = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + output += new TextDecoder().decode(value); + } + + const exitCode = await stream.exitCode; + expect(exitCode).toBe(0); + expect(output.trim()).toBe(testDir); + }); + + it("stat works on projectPath", async () => { + const runtime = new LocalRuntime(testDir); + + const stat = await runtime.stat(testDir); + + expect(stat.isDirectory).toBe(true); + }); + + it("resolvePath expands tilde", async () => { + const runtime = new LocalRuntime(testDir); + + const resolved = await runtime.resolvePath("~"); + + expect(resolved).toBe(os.homedir()); + }); + + it("normalizePath resolves relative paths", () => { + const runtime = new LocalRuntime(testDir); + + const result = runtime.normalizePath(".", testDir); + + expect(result).toBe(testDir); + }); }); }); diff --git a/src/node/runtime/LocalRuntime.ts b/src/node/runtime/LocalRuntime.ts index 8ea9eff5a9..95a22b92ab 100644 --- a/src/node/runtime/LocalRuntime.ts +++ b/src/node/runtime/LocalRuntime.ts @@ -1,374 +1,66 @@ -import { spawn } from "child_process"; -import * as fs from "fs"; -import * as fsPromises from "fs/promises"; -import * as path from "path"; -import { Readable, Writable } from "stream"; import type { - Runtime, - ExecOptions, - ExecStream, - FileStat, WorkspaceCreationParams, WorkspaceCreationResult, WorkspaceInitParams, WorkspaceInitResult, WorkspaceForkParams, WorkspaceForkResult, - InitLogger, } from "./Runtime"; -import { RuntimeError as RuntimeErrorClass } from "./Runtime"; -import { NON_INTERACTIVE_ENV_VARS } from "@/common/constants/env"; -import { getBashPath } from "@/node/utils/main/bashPath"; -import { EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT } from "@/common/constants/exitCodes"; -import { listLocalBranches } from "@/node/git"; -import { - checkInitHookExists, - getInitHookPath, - createLineBufferedLoggers, - getInitHookEnv, -} from "./initHook"; -import { execAsync, DisposableProcess } from "@/node/utils/disposableExec"; -import { getProjectName } from "@/node/utils/runtime/helpers"; +import { checkInitHookExists } from "./initHook"; import { getErrorMessage } from "@/common/utils/errors"; -import { expandTilde } from "./tildeExpansion"; +import { LocalBaseRuntime } from "./LocalBaseRuntime"; /** - * Local runtime implementation that executes commands and file operations - * directly on the host machine using Node.js APIs. + * Local runtime implementation that uses the project directory directly. + * + * Unlike WorktreeRuntime, this runtime: + * - Does NOT create git worktrees or isolate workspaces + * - Uses the project directory as the workspace path + * - Cannot delete the project directory (deleteWorkspace is a no-op) + * - Cannot rename or fork workspaces + * + * This is useful for users who want to work directly in their project + * without the overhead of worktree management. */ -export class LocalRuntime implements Runtime { - private readonly srcBaseDir: string; +export class LocalRuntime extends LocalBaseRuntime { + private readonly projectPath: string; - constructor(srcBaseDir: string) { - // Expand tilde to actual home directory path for local file system operations - this.srcBaseDir = expandTilde(srcBaseDir); + constructor(projectPath: string) { + super(); + this.projectPath = projectPath; } - async exec(command: string, options: ExecOptions): Promise { - const startTime = performance.now(); - - // Use the specified working directory (must be a specific workspace path) - const cwd = options.cwd; - - // Check if working directory exists before spawning - // This prevents confusing ENOENT errors from spawn() - try { - await fsPromises.access(cwd); - } catch (err) { - throw new RuntimeErrorClass( - `Working directory does not exist: ${cwd}`, - "exec", - err instanceof Error ? err : undefined - ); - } - - // If niceness is specified on Unix/Linux, spawn nice directly to avoid escaping issues - // Windows doesn't have nice command, so just spawn bash directly - const isWindows = process.platform === "win32"; - const bashPath = getBashPath(); - const spawnCommand = options.niceness !== undefined && !isWindows ? "nice" : bashPath; - const spawnArgs = - options.niceness !== undefined && !isWindows - ? ["-n", options.niceness.toString(), bashPath, "-c", command] - : ["-c", command]; - - const childProcess = spawn(spawnCommand, spawnArgs, { - cwd, - env: { - ...process.env, - ...(options.env ?? {}), - ...NON_INTERACTIVE_ENV_VARS, - }, - stdio: ["pipe", "pipe", "pipe"], - // CRITICAL: Spawn as detached process group leader to enable cleanup of background processes. - // When a bash script spawns background processes (e.g., `sleep 100 &`), we need to kill - // the entire process group (including all backgrounded children) via process.kill(-pid). - // NOTE: detached:true does NOT cause bash to wait for background jobs when using 'exit' event - // instead of 'close' event. The 'exit' event fires when bash exits, ignoring background children. - detached: true, - }); - - // Wrap in DisposableProcess for automatic cleanup - const disposable = new DisposableProcess(childProcess); - - // Convert Node.js streams to Web Streams - const stdout = Readable.toWeb(childProcess.stdout) as unknown as ReadableStream; - const stderr = Readable.toWeb(childProcess.stderr) as unknown as ReadableStream; - const stdin = Writable.toWeb(childProcess.stdin) as unknown as WritableStream; - - // No stream cleanup in DisposableProcess - streams close naturally when process exits - // bash.ts handles cleanup after waiting for exitCode - - // Track if we killed the process due to timeout or abort - let timedOut = false; - let aborted = false; - - // Create promises for exit code and duration - // Uses special exit codes (EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT) for expected error conditions - const exitCode = new Promise((resolve, reject) => { - // Use 'exit' event instead of 'close' to handle background processes correctly. - // The 'close' event waits for ALL child processes (including background ones) to exit, - // which causes hangs when users spawn background processes like servers. - // The 'exit' event fires when the main bash process exits, which is what we want. - childProcess.on("exit", (code) => { - // Clean up any background processes (process group cleanup) - // This prevents zombie processes when scripts spawn background tasks - if (childProcess.pid !== undefined) { - try { - // Kill entire process group with SIGKILL - cannot be caught/ignored - // Use negative PID to signal the entire process group - process.kill(-childProcess.pid, "SIGKILL"); - } catch { - // Process group already dead or doesn't exist - ignore - } - } - - // Check abort first (highest priority) - if (aborted || options.abortSignal?.aborted) { - resolve(EXIT_CODE_ABORTED); - return; - } - // Check if we killed the process due to timeout - if (timedOut) { - resolve(EXIT_CODE_TIMEOUT); - return; - } - resolve(code ?? 0); - // Cleanup runs automatically via DisposableProcess - }); - - childProcess.on("error", (err) => { - reject(new RuntimeErrorClass(`Failed to execute command: ${err.message}`, "exec", err)); - }); - }); - - const duration = exitCode.then(() => performance.now() - startTime); - - // Register process group cleanup with DisposableProcess - // This ensures ALL background children are killed when process exits - disposable.addCleanup(() => { - if (childProcess.pid === undefined) return; - - try { - // Kill entire process group with SIGKILL - cannot be caught/ignored - process.kill(-childProcess.pid, "SIGKILL"); - } catch { - // Process group already dead or doesn't exist - ignore - } - }); - - // Handle abort signal - if (options.abortSignal) { - options.abortSignal.addEventListener("abort", () => { - aborted = true; - disposable[Symbol.dispose](); // Kill process and run cleanup - }); - } - - // Handle timeout - if (options.timeout !== undefined) { - const timeoutHandle = setTimeout(() => { - timedOut = true; - disposable[Symbol.dispose](); // Kill process and run cleanup - }, options.timeout * 1000); - - // Clear timeout if process exits naturally - void exitCode.finally(() => clearTimeout(timeoutHandle)); - } - - return { stdout, stderr, stdin, exitCode, duration }; - } - - readFile(filePath: string, _abortSignal?: AbortSignal): ReadableStream { - // Note: _abortSignal ignored for local operations (fast, no need for cancellation) - const nodeStream = fs.createReadStream(filePath); - - // Handle errors by wrapping in a transform - const webStream = Readable.toWeb(nodeStream) as unknown as ReadableStream; - - return new ReadableStream({ - async start(controller: ReadableStreamDefaultController) { - try { - const reader = webStream.getReader(); - while (true) { - const { done, value } = await reader.read(); - if (done) break; - controller.enqueue(value); - } - controller.close(); - } catch (err) { - controller.error( - new RuntimeErrorClass( - `Failed to read file ${filePath}: ${err instanceof Error ? err.message : String(err)}`, - "file_io", - err instanceof Error ? err : undefined - ) - ); - } - }, - }); - } - - writeFile(filePath: string, _abortSignal?: AbortSignal): WritableStream { - // Note: _abortSignal ignored for local operations (fast, no need for cancellation) - let tempPath: string; - let writer: WritableStreamDefaultWriter; - let resolvedPath: string; - let originalMode: number | undefined; - - return new WritableStream({ - async start() { - // Resolve symlinks to write through them (preserves the symlink) - try { - resolvedPath = await fsPromises.realpath(filePath); - // Save original permissions to restore after write - const stat = await fsPromises.stat(resolvedPath); - originalMode = stat.mode; - } catch { - // If file doesn't exist, use the original path and default permissions - resolvedPath = filePath; - originalMode = undefined; - } - - // Create parent directories if they don't exist - const parentDir = path.dirname(resolvedPath); - await fsPromises.mkdir(parentDir, { recursive: true }); - - // Create temp file for atomic write - tempPath = `${resolvedPath}.tmp.${Date.now()}`; - const nodeStream = fs.createWriteStream(tempPath); - const webStream = Writable.toWeb(nodeStream) as WritableStream; - writer = webStream.getWriter(); - }, - async write(chunk: Uint8Array) { - await writer.write(chunk); - }, - async close() { - // Close the writer and rename to final location - await writer.close(); - try { - // If we have original permissions, apply them to temp file before rename - if (originalMode !== undefined) { - await fsPromises.chmod(tempPath, originalMode); - } - await fsPromises.rename(tempPath, resolvedPath); - } catch (err) { - throw new RuntimeErrorClass( - `Failed to write file ${filePath}: ${err instanceof Error ? err.message : String(err)}`, - "file_io", - err instanceof Error ? err : undefined - ); - } - }, - async abort(reason?: unknown) { - // Clean up temp file on abort - await writer.abort(); - try { - await fsPromises.unlink(tempPath); - } catch { - // Ignore errors cleaning up temp file - } - throw new RuntimeErrorClass( - `Failed to write file ${filePath}: ${String(reason)}`, - "file_io" - ); - }, - }); - } - - async stat(filePath: string, _abortSignal?: AbortSignal): Promise { - // Note: _abortSignal ignored for local operations (fast, no need for cancellation) - try { - const stats = await fsPromises.stat(filePath); - return { - size: stats.size, - modifiedTime: stats.mtime, - isDirectory: stats.isDirectory(), - }; - } catch (err) { - throw new RuntimeErrorClass( - `Failed to stat ${filePath}: ${err instanceof Error ? err.message : String(err)}`, - "file_io", - err instanceof Error ? err : undefined - ); - } - } - - resolvePath(filePath: string): Promise { - // Expand tilde to actual home directory path - const expanded = expandTilde(filePath); - - // Resolve to absolute path (handles relative paths like "./foo") - return Promise.resolve(path.resolve(expanded)); - } - - normalizePath(targetPath: string, basePath: string): string { - // For local runtime, use Node.js path resolution - // Handle special case: current directory - const target = targetPath.trim(); - if (target === ".") { - return path.resolve(basePath); - } - return path.resolve(basePath, target); - } - - getWorkspacePath(projectPath: string, workspaceName: string): string { - const projectName = getProjectName(projectPath); - return path.join(this.srcBaseDir, projectName, workspaceName); + /** + * For LocalRuntime, the workspace path is always the project path itself. + * The workspaceName parameter is ignored since there's only one workspace per project. + */ + getWorkspacePath(_projectPath: string, _workspaceName: string): string { + return this.projectPath; } + /** + * Creating a workspace is a no-op for LocalRuntime since we use the project directory directly. + * We just verify the directory exists. + */ async createWorkspace(params: WorkspaceCreationParams): Promise { - const { projectPath, branchName, trunkBranch, initLogger } = params; + const { initLogger } = params; try { - // Compute workspace path using the canonical method - const workspacePath = this.getWorkspacePath(projectPath, branchName); - initLogger.logStep("Creating git worktree..."); + initLogger.logStep("Using project directory directly (no worktree isolation)"); - // Create parent directory if needed - const parentDir = path.dirname(workspacePath); + // Verify the project directory exists try { - await fsPromises.access(parentDir); + await this.stat(this.projectPath); } 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}`, + error: `Project directory does not exist: ${this.projectPath}`, }; - } catch { - // Workspace doesn't exist, proceed with creation } - // Check if branch exists locally - const localBranches = await listLocalBranches(projectPath); - const branchExists = localBranches.includes(branchName); - - // 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 it from trunk - using proc = execAsync( - `git -C "${projectPath}" worktree add -b "${branchName}" "${workspacePath}" "${trunkBranch}"` - ); - await proc.result; - } - - initLogger.logStep("Worktree created successfully"); - - // Pull latest from origin (best-effort, non-blocking on failure) - await this.pullLatestFromOrigin(workspacePath, trunkBranch, initLogger); + initLogger.logStep("Project directory verified"); - return { success: true, workspacePath }; + return { success: true, workspacePath: this.projectPath }; } catch (error) { return { success: false, @@ -377,54 +69,14 @@ export class LocalRuntime implements Runtime { } } - /** - * Fetch and rebase on latest origin/ - * Best-effort operation - logs status but doesn't fail workspace creation - */ - private async pullLatestFromOrigin( - workspacePath: string, - trunkBranch: string, - initLogger: InitLogger - ): Promise { - try { - initLogger.logStep(`Fetching latest from origin/${trunkBranch}...`); - - // Fetch the trunk branch from origin - using fetchProc = execAsync(`git -C "${workspacePath}" fetch origin "${trunkBranch}"`); - await fetchProc.result; - - initLogger.logStep("Fast-forward merging..."); - - // Attempt fast-forward merge from origin/ - try { - 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 skipped (${errorMsg}), using local branch state`); - } - } catch (error) { - // Fetch failed - log and continue (common for repos without remote) - const errorMsg = getErrorMessage(error); - initLogger.logStderr( - `Note: Could not fetch from origin (${errorMsg}), using local branch state` - ); - } - } - async initWorkspace(params: WorkspaceInitParams): Promise { const { projectPath, workspacePath, initLogger } = params; try { // Run .mux/init hook if it exists - // Note: runInitHook calls logComplete() internally if hook exists const hookExists = await checkInitHookExists(projectPath); if (hookExists) { - await this.runInitHook(projectPath, workspacePath, initLogger); + await this.runInitHook(projectPath, workspacePath, initLogger, "local"); } else { // No hook - signal completion immediately initLogger.logComplete(0); @@ -442,233 +94,46 @@ export class LocalRuntime implements Runtime { } /** - * Run .mux/init hook if it exists and is executable + * Renaming is a no-op for LocalRuntime - the workspace path is always the project directory. + * Returns success so the metadata (workspace name) can be updated in config. */ - private async runInitHook( - projectPath: string, - workspacePath: string, - initLogger: InitLogger - ): Promise { - // Check if hook exists and is executable - const hookExists = await checkInitHookExists(projectPath); - if (!hookExists) { - return; - } - - const hookPath = getInitHookPath(projectPath); - initLogger.logStep(`Running init hook: ${hookPath}`); - - // Create line-buffered loggers - const loggers = createLineBufferedLoggers(initLogger); - - return new Promise((resolve) => { - const bashPath = getBashPath(); - const proc = spawn(bashPath, ["-c", `"${hookPath}"`], { - cwd: workspacePath, - stdio: ["ignore", "pipe", "pipe"], - env: { - ...process.env, - ...getInitHookEnv(projectPath, "local"), - }, - }); - - proc.stdout.on("data", (data: Buffer) => { - loggers.stdout.append(data.toString()); - }); - - proc.stderr.on("data", (data: Buffer) => { - loggers.stderr.append(data.toString()); - }); - - proc.on("close", (code) => { - // Flush any remaining buffered output - loggers.stdout.flush(); - loggers.stderr.flush(); - - initLogger.logComplete(code ?? 0); - resolve(); - }); - - proc.on("error", (err) => { - initLogger.logStderr(`Error running init hook: ${err.message}`); - initLogger.logComplete(-1); - resolve(); - }); - }); - } - + // eslint-disable-next-line @typescript-eslint/require-await async renameWorkspace( - projectPath: string, - oldName: string, - newName: string, + _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) - // Compute workspace paths using canonical method - const oldPath = this.getWorkspacePath(projectPath, oldName); - const newPath = this.getWorkspacePath(projectPath, newName); - - try { - // Use git worktree move to rename the worktree directory - // This updates git's internal worktree metadata correctly - using proc = execAsync(`git -C "${projectPath}" worktree move "${oldPath}" "${newPath}"`); - await proc.result; - - return { success: true, oldPath, newPath }; - } catch (error) { - return { success: false, error: `Failed to move worktree: ${getErrorMessage(error)}` }; - } + // No filesystem operation needed - path stays the same + return { success: true, oldPath: this.projectPath, newPath: this.projectPath }; } + /** + * Deleting is a no-op for LocalRuntime - we never delete the user's project directory. + * Returns success so the workspace entry can be removed from config. + */ + // eslint-disable-next-line @typescript-eslint/require-await async deleteWorkspace( - projectPath: string, - workspaceName: string, - force: boolean, + _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) - - // 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; - - // 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 - } - } - 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; - - 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) - 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 - using rmProc = execAsync(`rm -rf "${deletedPath}"`); - await rmProc.result; - - 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 success but don't actually delete anything + // The project directory should never be deleted + return { success: true, deletedPath: this.projectPath }; } - 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), - }; - } + /** + * Forking is not supported for LocalRuntime since there's no worktree to fork. + */ + // eslint-disable-next-line @typescript-eslint/require-await + async forkWorkspace(_params: WorkspaceForkParams): Promise { + return { + success: false, + error: "Cannot fork a local project-dir workspace. Use worktree runtime for branching.", + }; } } diff --git a/src/node/runtime/WorktreeRuntime.test.ts b/src/node/runtime/WorktreeRuntime.test.ts new file mode 100644 index 0000000000..949b309d59 --- /dev/null +++ b/src/node/runtime/WorktreeRuntime.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "bun:test"; +import * as os from "os"; +import * as path from "path"; +import { WorktreeRuntime } from "./WorktreeRuntime"; + +describe("WorktreeRuntime constructor", () => { + it("should expand tilde in srcBaseDir", () => { + const runtime = new WorktreeRuntime("~/workspace"); + const workspacePath = runtime.getWorkspacePath("/home/user/project", "branch"); + + // The workspace path should use the expanded home directory + const expected = path.join(os.homedir(), "workspace", "project", "branch"); + expect(workspacePath).toBe(expected); + }); + + it("should handle absolute paths without expansion", () => { + const runtime = new WorktreeRuntime("/absolute/path"); + const workspacePath = runtime.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 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); + }); +}); diff --git a/src/node/runtime/WorktreeRuntime.ts b/src/node/runtime/WorktreeRuntime.ts new file mode 100644 index 0000000000..35d5495d7d --- /dev/null +++ b/src/node/runtime/WorktreeRuntime.ts @@ -0,0 +1,340 @@ +import * as fsPromises from "fs/promises"; +import * as path from "path"; +import type { + WorkspaceCreationParams, + WorkspaceCreationResult, + WorkspaceInitParams, + WorkspaceInitResult, + WorkspaceForkParams, + WorkspaceForkResult, + InitLogger, +} from "./Runtime"; +import { listLocalBranches } from "@/node/git"; +import { checkInitHookExists } from "./initHook"; +import { execAsync } from "@/node/utils/disposableExec"; +import { getProjectName } from "@/node/utils/runtime/helpers"; +import { getErrorMessage } from "@/common/utils/errors"; +import { expandTilde } from "./tildeExpansion"; +import { LocalBaseRuntime } from "./LocalBaseRuntime"; + +/** + * Worktree runtime implementation that executes commands and file operations + * directly on the host machine using Node.js APIs. + * + * This runtime uses git worktrees for workspace isolation: + * - Workspaces are created in {srcBaseDir}/{projectName}/{workspaceName} + * - Each workspace is a git worktree with its own branch + */ +export class WorktreeRuntime extends LocalBaseRuntime { + private readonly srcBaseDir: string; + + constructor(srcBaseDir: string) { + super(); + // 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: WorkspaceCreationParams): Promise { + const { projectPath, branchName, trunkBranch, initLogger } = params; + + 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); + + // 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 it from trunk + using proc = execAsync( + `git -C "${projectPath}" worktree add -b "${branchName}" "${workspacePath}" "${trunkBranch}"` + ); + await proc.result; + } + + initLogger.logStep("Worktree created successfully"); + + // Pull latest from origin (best-effort, non-blocking on failure) + await this.pullLatestFromOrigin(workspacePath, trunkBranch, initLogger); + + return { success: true, workspacePath }; + } catch (error) { + return { + success: false, + error: getErrorMessage(error), + }; + } + } + + /** + * Fetch and rebase on latest origin/ + * Best-effort operation - logs status but doesn't fail workspace creation + */ + private async pullLatestFromOrigin( + workspacePath: string, + trunkBranch: string, + initLogger: InitLogger + ): Promise { + try { + initLogger.logStep(`Fetching latest from origin/${trunkBranch}...`); + + // Fetch the trunk branch from origin + using fetchProc = execAsync(`git -C "${workspacePath}" fetch origin "${trunkBranch}"`); + await fetchProc.result; + + initLogger.logStep("Fast-forward merging..."); + + // Attempt fast-forward merge from origin/ + try { + 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 skipped (${errorMsg}), using local branch state`); + } + } catch (error) { + // Fetch failed - log and continue (common for repos without remote) + const errorMsg = getErrorMessage(error); + initLogger.logStderr( + `Note: Could not fetch from origin (${errorMsg}), using local branch state` + ); + } + } + + async initWorkspace(params: WorkspaceInitParams): Promise { + const { projectPath, workspacePath, initLogger } = params; + + try { + // Run .mux/init hook if it exists + // Note: runInitHook calls logComplete() internally if hook exists + const hookExists = await checkInitHookExists(projectPath); + if (hookExists) { + await this.runInitHook(projectPath, workspacePath, initLogger, "worktree"); + } else { + // No hook - signal completion immediately + initLogger.logComplete(0); + } + return { success: true }; + } catch (error) { + const errorMsg = getErrorMessage(error); + initLogger.logStderr(`Initialization failed: ${errorMsg}`); + initLogger.logComplete(-1); + return { + success: false, + error: errorMsg, + }; + } + } + + 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) + // Compute workspace paths using canonical method + const oldPath = this.getWorkspacePath(projectPath, oldName); + const newPath = this.getWorkspacePath(projectPath, newName); + + try { + // Use git worktree move to rename the worktree directory + // This updates git's internal worktree metadata correctly + using proc = execAsync(`git -C "${projectPath}" worktree move "${oldPath}" "${newPath}"`); + await proc.result; + + return { success: true, oldPath, newPath }; + } catch (error) { + return { success: false, error: `Failed to move worktree: ${getErrorMessage(error)}` }; + } + } + + 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) + + // 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; + + // 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 + } + } + 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; + + 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) + 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 + using rmProc = execAsync(`rm -rf "${deletedPath}"`); + await rmProc.result; + + 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 + 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), + }; + } + } +} diff --git a/src/node/runtime/initHook.ts b/src/node/runtime/initHook.ts index 66cd671372..e93addb889 100644 --- a/src/node/runtime/initHook.ts +++ b/src/node/runtime/initHook.ts @@ -30,11 +30,11 @@ export function getInitHookPath(projectPath: string): string { * Get environment variables for init hook execution * Centralizes env var injection to avoid duplication across runtimes * @param projectPath - Path to project root (local path for LocalRuntime, remote path for SSHRuntime) - * @param runtime - Runtime type: "local" or "ssh" + * @param runtime - Runtime type: "local", "worktree", or "ssh" */ export function getInitHookEnv( projectPath: string, - runtime: "local" | "ssh" + runtime: "local" | "worktree" | "ssh" ): Record { return { MUX_PROJECT_PATH: projectPath, diff --git a/src/node/runtime/runtimeFactory.test.ts b/src/node/runtime/runtimeFactory.test.ts index bb89add4e3..24cfd635b5 100644 --- a/src/node/runtime/runtimeFactory.test.ts +++ b/src/node/runtime/runtimeFactory.test.ts @@ -2,13 +2,15 @@ import { describe, expect, it } from "bun:test"; import { isIncompatibleRuntimeConfig } from "@/common/utils/runtimeCompatibility"; import { createRuntime, IncompatibleRuntimeError } from "./runtimeFactory"; import type { RuntimeConfig } from "@/common/types/runtime"; +import { LocalRuntime } from "./LocalRuntime"; +import { WorktreeRuntime } from "./WorktreeRuntime"; describe("isIncompatibleRuntimeConfig", () => { it("returns false for undefined config", () => { expect(isIncompatibleRuntimeConfig(undefined)).toBe(false); }); - it("returns false for valid local config with srcBaseDir", () => { + it("returns false for local config with srcBaseDir (legacy worktree)", () => { const config: RuntimeConfig = { type: "local", srcBaseDir: "~/.mux/src", @@ -16,7 +18,21 @@ describe("isIncompatibleRuntimeConfig", () => { expect(isIncompatibleRuntimeConfig(config)).toBe(false); }); - it("returns false for valid SSH config", () => { + it("returns false for local config without srcBaseDir (project-dir mode)", () => { + // Local without srcBaseDir is now supported as project-dir mode + const config: RuntimeConfig = { type: "local" }; + expect(isIncompatibleRuntimeConfig(config)).toBe(false); + }); + + it("returns false for worktree config", () => { + const config: RuntimeConfig = { + type: "worktree", + srcBaseDir: "~/.mux/src", + }; + expect(isIncompatibleRuntimeConfig(config)).toBe(false); + }); + + it("returns false for SSH config", () => { const config: RuntimeConfig = { type: "ssh", host: "example.com", @@ -25,48 +41,45 @@ describe("isIncompatibleRuntimeConfig", () => { expect(isIncompatibleRuntimeConfig(config)).toBe(false); }); - it("returns true for local config without srcBaseDir (future project-dir mode)", () => { - // Simulate a config from a future version that has type: "local" without srcBaseDir - // This bypasses TypeScript checks to simulate runtime data from newer versions - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const config = { type: "local" } as RuntimeConfig; - expect(isIncompatibleRuntimeConfig(config)).toBe(true); - }); - - it("returns true for local config with empty srcBaseDir", () => { - // Simulate a malformed config - empty srcBaseDir shouldn't be valid - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const config = { type: "local", srcBaseDir: "" } as RuntimeConfig; - expect(isIncompatibleRuntimeConfig(config)).toBe(true); - }); - - it("returns true for unknown runtime type (future types like worktree)", () => { + it("returns true for unknown runtime type from future versions", () => { // Simulate a config from a future version with new type - - const config = { type: "worktree", srcBaseDir: "~/.mux/src" } as unknown as RuntimeConfig; + const config = { type: "future-runtime" } as unknown as RuntimeConfig; expect(isIncompatibleRuntimeConfig(config)).toBe(true); }); }); describe("createRuntime", () => { - it("creates LocalRuntime for valid local config", () => { + it("creates WorktreeRuntime for local config with srcBaseDir (legacy)", () => { const config: RuntimeConfig = { type: "local", srcBaseDir: "/tmp/test-src", }; const runtime = createRuntime(config); - expect(runtime).toBeDefined(); + expect(runtime).toBeInstanceOf(WorktreeRuntime); }); - it("throws IncompatibleRuntimeError for local config without srcBaseDir", () => { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const config = { type: "local" } as RuntimeConfig; - expect(() => createRuntime(config)).toThrow(IncompatibleRuntimeError); - expect(() => createRuntime(config)).toThrow(/newer version of mux/); + it("creates LocalRuntime for local config without srcBaseDir (project-dir)", () => { + const config: RuntimeConfig = { type: "local" }; + const runtime = createRuntime(config, { projectPath: "/tmp/my-project" }); + expect(runtime).toBeInstanceOf(LocalRuntime); + }); + + it("creates WorktreeRuntime for explicit worktree config", () => { + const config: RuntimeConfig = { + type: "worktree", + srcBaseDir: "/tmp/test-src", + }; + const runtime = createRuntime(config); + expect(runtime).toBeInstanceOf(WorktreeRuntime); + }); + + it("throws error for local project-dir without projectPath option", () => { + const config: RuntimeConfig = { type: "local" }; + expect(() => createRuntime(config)).toThrow(/projectPath/); }); it("throws IncompatibleRuntimeError for unknown runtime type", () => { - const config = { type: "worktree", srcBaseDir: "~/.mux/src" } as unknown as RuntimeConfig; + const config = { type: "future-runtime" } as unknown as RuntimeConfig; expect(() => createRuntime(config)).toThrow(IncompatibleRuntimeError); expect(() => createRuntime(config)).toThrow(/newer version of mux/); }); diff --git a/src/node/runtime/runtimeFactory.ts b/src/node/runtime/runtimeFactory.ts index 1686d85fd3..8ea84918d0 100644 --- a/src/node/runtime/runtimeFactory.ts +++ b/src/node/runtime/runtimeFactory.ts @@ -1,7 +1,9 @@ import type { Runtime } from "./Runtime"; import { LocalRuntime } from "./LocalRuntime"; +import { WorktreeRuntime } from "./WorktreeRuntime"; import { SSHRuntime } from "./SSHRuntime"; import type { RuntimeConfig } from "@/common/types/runtime"; +import { isLocalProjectRuntime } from "@/common/types/runtime"; import { isIncompatibleRuntimeConfig } from "@/common/utils/runtimeCompatibility"; // Re-export for backward compatibility with existing imports @@ -19,9 +21,26 @@ export class IncompatibleRuntimeError extends Error { } /** - * Create a Runtime instance based on the configuration + * Options for creating a runtime. */ -export function createRuntime(config: RuntimeConfig): Runtime { +export interface CreateRuntimeOptions { + /** + * Project path - required for project-dir local runtimes (type: "local" without srcBaseDir). + * For other runtime types, this is optional and used only for getWorkspacePath calculations. + */ + projectPath?: string; +} + +/** + * Create a Runtime instance based on the configuration. + * + * Handles three runtime types: + * - "local" without srcBaseDir: Project-dir runtime (no isolation) - requires projectPath in options + * - "local" with srcBaseDir: Legacy worktree config (backward compat) + * - "worktree": Explicit worktree runtime + * - "ssh": Remote SSH runtime + */ +export function createRuntime(config: RuntimeConfig, options?: CreateRuntimeOptions): Runtime { // Check for incompatible configs from newer versions if (isIncompatibleRuntimeConfig(config)) { throw new IncompatibleRuntimeError( @@ -32,7 +51,22 @@ export function createRuntime(config: RuntimeConfig): Runtime { switch (config.type) { case "local": - return new LocalRuntime(config.srcBaseDir); + // Check if this is legacy "local" with srcBaseDir (= worktree semantics) + // or new "local" without srcBaseDir (= project-dir semantics) + if (isLocalProjectRuntime(config)) { + // Project-dir: uses project path directly, no isolation + if (!options?.projectPath) { + throw new Error( + "LocalRuntime requires projectPath in options for project-dir config (type: 'local' without srcBaseDir)" + ); + } + return new LocalRuntime(options.projectPath); + } + // Legacy: "local" with srcBaseDir is treated as worktree + return new WorktreeRuntime(config.srcBaseDir); + + case "worktree": + return new WorktreeRuntime(config.srcBaseDir); case "ssh": return new SSHRuntime({ @@ -48,3 +82,10 @@ export function createRuntime(config: RuntimeConfig): Runtime { } } } + +/** + * Helper to check if a runtime config requires projectPath for createRuntime. + */ +export function runtimeRequiresProjectPath(config: RuntimeConfig): boolean { + return isLocalProjectRuntime(config); +} diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index cffc11196d..942d5105f6 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -199,7 +199,8 @@ export class AgentSession { ? metadata.projectPath : (() => { const runtime = createRuntime( - metadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir } + metadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir }, + { projectPath: metadata.projectPath } ); return runtime.getWorkspacePath(metadata.projectPath, metadata.name); })(); diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 5113d9941c..8d54397193 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -950,7 +950,8 @@ export class AIService extends EventEmitter { // Get workspace path - handle both worktree and in-place modes const runtime = createRuntime( - metadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir } + metadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir }, + { projectPath: metadata.projectPath } ); // In-place workspaces (CLI/benchmarks) have projectPath === name // Use path directly instead of reconstructing via getWorkspacePath diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts index a54671b279..7de28a0d7e 100644 --- a/src/node/services/ipcMain.ts +++ b/src/node/services/ipcMain.ts @@ -338,26 +338,40 @@ export class IpcMain { options.trunkBranch ?? (await detectDefaultTrunkBranch(projectPath, branches)) ?? "main"; // 3. Create workspace - const finalRuntimeConfig: RuntimeConfig = options.runtimeConfig ?? { - type: "local", + // Default to worktree runtime for new workspaces + let finalRuntimeConfig: RuntimeConfig = options.runtimeConfig ?? { + type: "worktree", srcBaseDir: this.config.srcDir, }; const workspaceId = this.config.generateStableId(); let runtime; - let resolvedSrcBaseDir: string; try { - runtime = createRuntime(finalRuntimeConfig); - resolvedSrcBaseDir = await runtime.resolvePath(finalRuntimeConfig.srcBaseDir); + // Handle different runtime types + const isLocalProjectDir = + finalRuntimeConfig.type === "local" && + !("srcBaseDir" in finalRuntimeConfig && finalRuntimeConfig.srcBaseDir); + + if (isLocalProjectDir) { + // Local project-dir runtime: use projectPath directly + runtime = createRuntime(finalRuntimeConfig, { projectPath }); + } else { + // Worktree, legacy local with srcBaseDir, or SSH runtime + runtime = createRuntime(finalRuntimeConfig); - if (resolvedSrcBaseDir !== finalRuntimeConfig.srcBaseDir) { - const resolvedRuntimeConfig: RuntimeConfig = { - ...finalRuntimeConfig, - srcBaseDir: resolvedSrcBaseDir, - }; - runtime = createRuntime(resolvedRuntimeConfig); - finalRuntimeConfig.srcBaseDir = resolvedSrcBaseDir; + // Resolve srcBaseDir for worktree/SSH runtimes + if ("srcBaseDir" in finalRuntimeConfig && finalRuntimeConfig.srcBaseDir) { + const resolvedSrcBaseDir = await runtime.resolvePath(finalRuntimeConfig.srcBaseDir); + if (resolvedSrcBaseDir !== finalRuntimeConfig.srcBaseDir) { + const resolvedConfig: RuntimeConfig = { + ...finalRuntimeConfig, + srcBaseDir: resolvedSrcBaseDir, + }; + finalRuntimeConfig = resolvedConfig; + runtime = createRuntime(finalRuntimeConfig); + } + } } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); @@ -643,31 +657,39 @@ export class IpcMain { // Generate stable workspace ID (stored in config, not used for directory name) const workspaceId = this.config.generateStableId(); - // Create runtime for workspace creation (defaults to local with srcDir as base) - const finalRuntimeConfig: RuntimeConfig = runtimeConfig ?? { - type: "local", + // Create runtime for workspace creation (defaults to worktree with srcDir as base) + let finalRuntimeConfig: RuntimeConfig = runtimeConfig ?? { + type: "worktree", srcBaseDir: this.config.srcDir, }; - // Create temporary runtime to resolve srcBaseDir path - // This allows tilde paths to work for both local and SSH runtimes + // Create runtime instance based on config type let runtime; - let resolvedSrcBaseDir: string; try { - runtime = createRuntime(finalRuntimeConfig); - - // Resolve srcBaseDir to absolute path (expanding tildes, etc.) - resolvedSrcBaseDir = await runtime.resolvePath(finalRuntimeConfig.srcBaseDir); - - // If path was resolved to something different, recreate runtime with resolved path - if (resolvedSrcBaseDir !== finalRuntimeConfig.srcBaseDir) { - const resolvedRuntimeConfig: RuntimeConfig = { - ...finalRuntimeConfig, - srcBaseDir: resolvedSrcBaseDir, - }; - runtime = createRuntime(resolvedRuntimeConfig); - // Update finalRuntimeConfig to store resolved path in config - finalRuntimeConfig.srcBaseDir = resolvedSrcBaseDir; + // Handle different runtime types + const isLocalProjectDir = + finalRuntimeConfig.type === "local" && + !("srcBaseDir" in finalRuntimeConfig && finalRuntimeConfig.srcBaseDir); + + if (isLocalProjectDir) { + // Local project-dir runtime: use projectPath directly + runtime = createRuntime(finalRuntimeConfig, { projectPath }); + } else { + // Worktree, legacy local with srcBaseDir, or SSH runtime + runtime = createRuntime(finalRuntimeConfig); + + // Resolve srcBaseDir for worktree/SSH runtimes + if ("srcBaseDir" in finalRuntimeConfig && finalRuntimeConfig.srcBaseDir) { + const resolvedSrcBaseDir = await runtime.resolvePath(finalRuntimeConfig.srcBaseDir); + if (resolvedSrcBaseDir !== finalRuntimeConfig.srcBaseDir) { + const resolvedConfig: RuntimeConfig = { + ...finalRuntimeConfig, + srcBaseDir: resolvedSrcBaseDir, + }; + finalRuntimeConfig = resolvedConfig; + runtime = createRuntime(finalRuntimeConfig); + } + } } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); @@ -841,7 +863,8 @@ export class IpcMain { // Create runtime instance for this workspace // For local runtimes, workdir should be srcDir, not the individual workspace path const runtime = createRuntime( - oldMetadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir } + oldMetadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir }, + { projectPath } ); // Delegate rename to runtime (handles both local and SSH) @@ -930,7 +953,7 @@ export class IpcMain { type: "local", srcBaseDir: this.config.srcDir, }; - const runtime = createRuntime(sourceRuntimeConfig); + const runtime = createRuntime(sourceRuntimeConfig, { projectPath: foundProjectPath }); // Generate stable workspace ID for the new workspace const newWorkspaceId = this.config.generateStableId(); @@ -1381,7 +1404,7 @@ export class IpcMain { type: "local" as const, srcBaseDir: this.config.srcDir, }; - const runtime = createRuntime(runtimeConfig); + const runtime = createRuntime(runtimeConfig, { projectPath: metadata.projectPath }); const workspacePath = runtime.getWorkspacePath(metadata.projectPath, metadata.name); // Create bash tool with workspace's cwd and secrets @@ -1489,12 +1512,13 @@ export class IpcMain { log.info(`Workspace ${workspaceId} metadata exists but not found in config`); return { success: true }; // Consider it already removed } - const { projectPath, workspacePath } = workspace; + const { projectPath, workspacePath: _workspacePath } = workspace; // Create runtime instance for this workspace // For local runtimes, workdir should be srcDir, not the individual workspace path const runtime = createRuntime( - metadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir } + metadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir }, + { projectPath } ); // Delegate deletion to runtime - it handles all path computation, existence checks, and pruning @@ -1514,12 +1538,14 @@ export class IpcMain { // Delete workspace metadata (fire and forget) void this.extensionMetadata.deleteWorkspace(workspaceId); - // Update config to remove the workspace from all projects + // Update config to remove the workspace by ID + // NOTE: Filter by ID, not path. For local project-dir runtimes, multiple workspaces + // share the same path (the project directory), so filtering by path would delete them all. const projectsConfig = this.config.loadConfigOrDefault(); let configUpdated = false; for (const [_projectPath, projectConfig] of projectsConfig.projects.entries()) { const initialCount = projectConfig.workspaces.length; - projectConfig.workspaces = projectConfig.workspaces.filter((w) => w.path !== workspacePath); + projectConfig.workspaces = projectConfig.workspaces.filter((w) => w.id !== workspaceId); if (projectConfig.workspaces.length < initialCount) { configUpdated = true; } @@ -1862,7 +1888,8 @@ export class IpcMain { // Create runtime for this workspace (default to local if not specified) const runtime = createRuntime( - workspaceMetadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir } + workspaceMetadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir }, + { projectPath: workspaceMetadata.projectPath } ); // Compute workspace path diff --git a/tests/ipcMain/removeWorkspace.test.ts b/tests/ipcMain/removeWorkspace.test.ts index b27e651e4d..89b0e8272b 100644 --- a/tests/ipcMain/removeWorkspace.test.ts +++ b/tests/ipcMain/removeWorkspace.test.ts @@ -472,6 +472,132 @@ describeIntegration("Workspace deletion integration tests", () => { } ); + // Local project-dir runtime specific tests + // These test the new LocalRuntime that uses project directory directly (no worktree isolation) + describe("Local project-dir runtime tests", () => { + const getLocalProjectDirConfig = (): RuntimeConfig => { + // Local project-dir: type "local" without srcBaseDir + return { type: "local" }; + }; + + test.concurrent( + "should delete only the specified workspace, not all workspaces with same path", + async () => { + const env = await createTestEnvironment(); + const tempGitRepo = await createTempGitRepo(); + + try { + const runtimeConfig = getLocalProjectDirConfig(); + + // Create multiple local workspaces for the same project + // All will have the same workspacePath (the project directory) + const { workspaceId: ws1Id } = await createWorkspaceWithInit( + env, + tempGitRepo, + "local-ws-1", + runtimeConfig, + true, // waitForInit + false // not SSH + ); + + const { workspaceId: ws2Id } = await createWorkspaceWithInit( + env, + tempGitRepo, + "local-ws-2", + runtimeConfig, + true, + false + ); + + const { workspaceId: ws3Id } = await createWorkspaceWithInit( + env, + tempGitRepo, + "local-ws-3", + runtimeConfig, + true, + false + ); + + // Verify all three workspaces exist in config + let config = env.config.loadConfigOrDefault(); + let project = config.projects.get(tempGitRepo); + expect(project?.workspaces.length).toBe(3); + expect(project?.workspaces.some((w) => w.id === ws1Id)).toBe(true); + expect(project?.workspaces.some((w) => w.id === ws2Id)).toBe(true); + expect(project?.workspaces.some((w) => w.id === ws3Id)).toBe(true); + + // Delete workspace 2 + const deleteResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_REMOVE, + ws2Id + ); + expect(deleteResult.success).toBe(true); + + // Verify ONLY workspace 2 was removed, workspaces 1 and 3 still exist + config = env.config.loadConfigOrDefault(); + project = config.projects.get(tempGitRepo); + + // BUG: Currently all 3 get deleted because they share the same workspacePath + // After fix: Only ws2 should be deleted + expect(project?.workspaces.length).toBe(2); + expect(project?.workspaces.some((w) => w.id === ws1Id)).toBe(true); + expect(project?.workspaces.some((w) => w.id === ws2Id)).toBe(false); // deleted + expect(project?.workspaces.some((w) => w.id === ws3Id)).toBe(true); + + // Cleanup remaining workspaces + await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, ws1Id); + await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, ws3Id); + } finally { + await cleanupTestEnvironment(env); + await cleanupTempGitRepo(tempGitRepo); + } + }, + TEST_TIMEOUT_LOCAL_MS + ); + + test.concurrent( + "should not delete project directory when deleting local workspace", + async () => { + const env = await createTestEnvironment(); + const tempGitRepo = await createTempGitRepo(); + + try { + const runtimeConfig = getLocalProjectDirConfig(); + const { workspaceId } = await createWorkspaceWithInit( + env, + tempGitRepo, + "local-ws-test", + runtimeConfig, + true, + false + ); + + // Verify workspace exists + const existsBefore = await workspaceExists(env, workspaceId); + expect(existsBefore).toBe(true); + + // Delete workspace + const deleteResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_REMOVE, + workspaceId + ); + expect(deleteResult.success).toBe(true); + + // Project directory should still exist (LocalRuntime.deleteWorkspace is a no-op) + const projectDirExists = await fs + .access(tempGitRepo) + .then(() => true) + .catch(() => false); + expect(projectDirExists).toBe(true); + } finally { + await cleanupTestEnvironment(env); + await cleanupTempGitRepo(tempGitRepo); + } + }, + TEST_TIMEOUT_LOCAL_MS + ); + }); + // SSH-specific tests (unpushed refs only matter for SSH, not local worktrees which share .git) describe("SSH-only tests", () => { const getRuntimeConfig = (branchName: string): RuntimeConfig | undefined => { diff --git a/tests/runtime/test-helpers.ts b/tests/runtime/test-helpers.ts index c7eea30034..9b73c5085f 100644 --- a/tests/runtime/test-helpers.ts +++ b/tests/runtime/test-helpers.ts @@ -7,12 +7,13 @@ import { realpathSync } from "fs"; import * as os from "os"; import * as path from "path"; import type { Runtime } from "@/node/runtime/Runtime"; -import { LocalRuntime } from "@/node/runtime/LocalRuntime"; +import { WorktreeRuntime } from "@/node/runtime/WorktreeRuntime"; import { SSHRuntime } from "@/node/runtime/SSHRuntime"; import type { SSHServerConfig } from "./ssh-fixture"; /** * Runtime type for test matrix + * Note: "local" here means worktree runtime (isolated git worktrees), not project-dir runtime */ export type RuntimeType = "local" | "ssh"; @@ -27,8 +28,9 @@ export function createTestRuntime( switch (type) { case "local": // Resolve symlinks (e.g., /tmp -> /private/tmp on macOS) to match git worktree paths + // Note: "local" in tests means WorktreeRuntime (isolated git worktrees) const resolvedWorkdir = realpathSync(workdir); - return new LocalRuntime(resolvedWorkdir); + return new WorktreeRuntime(resolvedWorkdir); case "ssh": if (!sshConfig) { throw new Error("SSH config required for SSH runtime"); diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 9fbb0e31e6..1e1983a33d 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -3,6 +3,27 @@ import { getAllWorkspaces, WorkspaceWithContext } from "./muxConfig"; import { openWorkspace } from "./workspaceOpener"; import { formatRelativeTime } from "mux/browser/utils/ui/dateTime"; +/** + * Get the icon for a runtime type + * - local (project-dir): $(folder) - simple folder, uses project directly + * - worktree: $(git-branch) - git worktree isolation + * - legacy local with srcBaseDir: $(git-branch) - treated as worktree + * - ssh: $(remote) - remote execution + */ +function getRuntimeIcon(runtimeConfig: WorkspaceWithContext["runtimeConfig"]): string { + if (runtimeConfig.type === "ssh") { + return "$(remote)"; + } + if (runtimeConfig.type === "worktree") { + return "$(git-branch)"; + } + // type === "local": check if it has srcBaseDir (legacy worktree) or not (project-dir) + if ("srcBaseDir" in runtimeConfig && runtimeConfig.srcBaseDir) { + return "$(git-branch)"; // Legacy worktree + } + return "$(folder)"; // Project-dir local +} + /** * Format workspace for display in QuickPick */ @@ -10,9 +31,7 @@ function formatWorkspaceLabel(workspace: WorkspaceWithContext): string { // Choose icon based on streaming status and runtime type const icon = workspace.extensionMetadata?.streaming ? "$(sync~spin)" // Spinning icon for active streaming - : workspace.runtimeConfig.type === "ssh" - ? "$(remote)" - : "$(folder)"; + : getRuntimeIcon(workspace.runtimeConfig); const baseName = `${icon} [${workspace.projectName}] ${workspace.name}`; diff --git a/vscode/src/muxConfig.ts b/vscode/src/muxConfig.ts index 7fed9a5ad1..60899f7c94 100644 --- a/vscode/src/muxConfig.ts +++ b/vscode/src/muxConfig.ts @@ -58,6 +58,6 @@ export async function getAllWorkspaces(): Promise { * Uses Runtime to compute path using main app's logic */ export function getWorkspacePath(workspace: WorkspaceWithContext): string { - const runtime = createRuntime(workspace.runtimeConfig); + const runtime = createRuntime(workspace.runtimeConfig, { projectPath: workspace.projectPath }); return runtime.getWorkspacePath(workspace.projectPath, workspace.name); } diff --git a/vscode/src/workspaceOpener.ts b/vscode/src/workspaceOpener.ts index 9ba115b6be..20f06b3ef4 100644 --- a/vscode/src/workspaceOpener.ts +++ b/vscode/src/workspaceOpener.ts @@ -16,7 +16,8 @@ function isRemoteSshInstalled(): boolean { * Open an SSH workspace in a new VS Code window */ export async function openWorkspace(workspace: WorkspaceWithContext) { - if (workspace.runtimeConfig.type === "local") { + // Handle local runtimes: "local", "worktree", or legacy "local" with srcBaseDir + if (workspace.runtimeConfig.type === "local" || workspace.runtimeConfig.type === "worktree") { const workspacePath = getWorkspacePath(workspace); const uri = vscode.Uri.file(workspacePath); @@ -44,8 +45,10 @@ export async function openWorkspace(workspace: WorkspaceWithContext) { return; } + // At this point, it must be SSH (we handled local/worktree above) if (workspace.runtimeConfig.type !== "ssh") { - vscode.window.showErrorMessage("mux: Workspace is not configured for SSH."); + // This should never happen given the early return above + vscode.window.showErrorMessage("mux: Unknown workspace runtime type."); return; }