Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,32 @@ export async function getMainWorktreeFromWorktree(worktreePath: string): Promise
return null;
}
}

export async function removeWorktree(
projectPath: string,
workspacePath: string,
options: { force: boolean } = { force: false }
): Promise<WorktreeResult> {
try {
// Remove the worktree (from the main repository context)
using proc = execAsync(
`git -C "${projectPath}" worktree remove "${workspacePath}" ${options.force ? "--force" : ""}`
);
await proc.result;
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { success: false, error: message };
}
}

export async function pruneWorktrees(projectPath: string): Promise<WorktreeResult> {
try {
using proc = execAsync(`git -C "${projectPath}" worktree prune`);
await proc.result;
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { success: false, error: message };
}
}
84 changes: 84 additions & 0 deletions src/runtime/LocalRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import type {
WorkspaceCreationResult,
WorkspaceInitParams,
WorkspaceInitResult,
WorkspaceForkParams,
WorkspaceForkResult,
InitLogger,
} from "./Runtime";
import { RuntimeError as RuntimeErrorClass } from "./Runtime";
Expand Down Expand Up @@ -488,6 +490,21 @@ export class LocalRuntime implements Runtime {
// 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
// Prune stale git records (best effort)
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 };
}

try {
// Use git worktree remove to delete the worktree
// This updates git's internal worktree metadata correctly
Expand All @@ -502,6 +519,25 @@ export class LocalRuntime implements Runtime {
} 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) {
Expand Down Expand Up @@ -531,4 +567,52 @@ export class LocalRuntime implements Runtime {
return { success: false, error: `Failed to remove worktree: ${message}` };
}
}

async forkWorkspace(params: WorkspaceForkParams): Promise<WorkspaceForkResult> {
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),
};
}
}
}
45 changes: 45 additions & 0 deletions src/runtime/Runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,40 @@ export interface WorkspaceInitResult {
error?: string;
}

/**
* Runtime interface - minimal, low-level abstraction for tool execution environments.
*
* All methods return streaming primitives for memory efficiency.
* Use helpers in utils/runtime/ for convenience wrappers (e.g., readFileString, execBuffered).

/**
* Parameters for forking an existing workspace
*/
export interface WorkspaceForkParams {
/** Project root path (local path) */
projectPath: string;
/** Name of the source workspace to fork from */
sourceWorkspaceName: string;
/** Name for the new workspace */
newWorkspaceName: string;
/** Logger for streaming initialization events */
initLogger: InitLogger;
}

/**
* Result of forking a workspace
*/
export interface WorkspaceForkResult {
/** Whether the fork operation succeeded */
success: boolean;
/** Path to the new workspace (if successful) */
workspacePath?: string;
/** Branch that was forked from */
sourceBranch?: string;
/** Error message (if failed) */
error?: string;
}

/**
* Runtime interface - minimal, low-level abstraction for tool execution environments.
*
Expand Down Expand Up @@ -306,6 +340,17 @@ export interface Runtime {
workspaceName: string,
force: boolean
): Promise<{ success: true; deletedPath: string } | { success: false; error: string }>;

/**
* Fork an existing workspace to create a new one
* Creates a new workspace branching from the source workspace's current branch
* - LocalRuntime: Detects source branch via git, creates new worktree from that branch
* - SSHRuntime: Currently unimplemented (returns static error)
*
* @param params Fork parameters (source workspace name, new workspace name, etc.)
* @returns Result with new workspace path and source branch, or error
*/
forkWorkspace(params: WorkspaceForkParams): Promise<WorkspaceForkResult>;
}

/**
Expand Down
14 changes: 14 additions & 0 deletions src/runtime/SSHRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import type {
WorkspaceCreationResult,
WorkspaceInitParams,
WorkspaceInitResult,
WorkspaceForkParams,
WorkspaceForkResult,
InitLogger,
} from "./Runtime";
import { RuntimeError as RuntimeErrorClass } from "./Runtime";
Expand Down Expand Up @@ -971,6 +973,18 @@ export class SSHRuntime implements Runtime {
return { success: false, error: `Failed to delete directory: ${getErrorMessage(error)}` };
}
}

forkWorkspace(_params: WorkspaceForkParams): Promise<WorkspaceForkResult> {
// SSH forking is not yet implemented due to unresolved complexities:
// - Users expect the new workspace's filesystem state to match the remote workspace,
// not the local project (which may be out of sync or on a different commit)
// - This requires: detecting the branch, copying remote state, handling uncommitted changes
// - For now, users should create a new workspace from the desired branch instead
return Promise.resolve({
success: false,
error: "Forking SSH workspaces is not yet implemented. Create a new workspace instead.",
});
}
}

/**
Expand Down
Loading