import { Injectable, Logger } from "@nestjs/common"; import { simpleGit, SimpleGit } from "simple-git"; import * as path from "path"; import { GitOperationsService } from "./git-operations.service"; import { WorktreeInfo, WorktreeError } from "./types"; import { validateBranchName } from "./git-validation.util"; /** * Result of worktree cleanup operation */ export interface WorktreeCleanupResult { /** Whether the cleanup succeeded */ success: boolean; /** Error message if the cleanup failed */ error?: string; } /** * Service for managing git worktrees for agent isolation */ @Injectable() export class WorktreeManagerService { private readonly logger = new Logger(WorktreeManagerService.name); constructor(private readonly gitOperationsService: GitOperationsService) {} /** * Get a simple-git instance for a local path */ private getGit(localPath: string): SimpleGit { return simpleGit(localPath); } /** * Generate worktree path for an agent */ public getWorktreePath(repoPath: string, agentId: string, taskId: string): string { // Remove trailing slash if present const cleanRepoPath = repoPath.replace(/\/$/, ""); const repoDir = path.dirname(cleanRepoPath); const repoName = path.basename(cleanRepoPath); const worktreeName = `agent-${agentId}-${taskId}`; return path.join(repoDir, `${repoName}_worktrees`, worktreeName); } /** * Generate branch name for an agent */ public getBranchName(agentId: string, taskId: string): string { return `agent-${agentId}-${taskId}`; } /** * Create a worktree for an agent */ async createWorktree( repoPath: string, agentId: string, taskId: string, baseBranch = "develop" ): Promise { // Validate inputs if (!repoPath) { throw new Error("repoPath is required"); } if (!agentId) { throw new Error("agentId is required"); } if (!taskId) { throw new Error("taskId is required"); } // Validate baseBranch to prevent command injection // This is defense-in-depth - DTO validation should catch this first validateBranchName(baseBranch); const worktreePath = this.getWorktreePath(repoPath, agentId, taskId); const branchName = this.getBranchName(agentId, taskId); try { this.logger.log(`Creating worktree for agent ${agentId}, task ${taskId} at ${worktreePath}`); const git = this.getGit(repoPath); // Create worktree with new branch // baseBranch is validated above to prevent command injection await git.raw(["worktree", "add", worktreePath, "-b", branchName, baseBranch]); this.logger.log(`Successfully created worktree at ${worktreePath}`); // Return worktree info return { path: worktreePath, branch: branchName, commit: "HEAD", // Will be updated after first commit }; } catch (error) { this.logger.error(`Failed to create worktree: ${String(error)}`); throw new WorktreeError( `Failed to create worktree for agent ${agentId}, task ${taskId}`, "createWorktree", error as Error ); } } /** * Remove a worktree */ async removeWorktree(worktreePath: string): Promise { // Validate input if (!worktreePath) { throw new Error("worktreePath is required"); } try { this.logger.log(`Removing worktree at ${worktreePath}`); // Get the parent repo path by going up from worktree const worktreeParent = path.dirname(worktreePath); const repoName = path.basename(worktreeParent).replace("_worktrees", ""); const repoPath = path.join(path.dirname(worktreeParent), repoName); const git = this.getGit(repoPath); // Remove worktree await git.raw(["worktree", "remove", worktreePath, "--force"]); this.logger.log(`Successfully removed worktree at ${worktreePath}`); } catch (error) { const errorMessage = (error as Error).message || String(error); // If worktree doesn't exist, log warning but don't throw if ( errorMessage.includes("is not a working tree") || errorMessage.includes("does not exist") ) { this.logger.warn(`Worktree ${worktreePath} does not exist, skipping removal`); return; } // For other errors, throw this.logger.error(`Failed to remove worktree: ${String(error)}`); throw new WorktreeError( `Failed to remove worktree at ${worktreePath}`, "removeWorktree", error as Error ); } } /** * List all worktrees for a repository */ async listWorktrees(repoPath: string): Promise { // Validate input if (!repoPath) { throw new Error("repoPath is required"); } try { this.logger.log(`Listing worktrees for repository at ${repoPath}`); const git = this.getGit(repoPath); // Get worktree list const output = await git.raw(["worktree", "list"]); // Parse output const worktrees: WorktreeInfo[] = []; const lines = output.trim().split("\n"); for (const line of lines) { // Format: /path/to/worktree commit [branch] const match = /^(.+?)\s+([a-f0-9]+)\s+\[(.+?)\]$/.exec(line); if (!match) continue; const [, worktreePath, commit, branch] = match; // Only include agent worktrees (not the main repo) if (worktreePath.includes("_worktrees")) { worktrees.push({ path: worktreePath, commit, branch, }); } } this.logger.log(`Found ${worktrees.length.toString()} active worktrees`); return worktrees; } catch (error) { this.logger.error(`Failed to list worktrees: ${String(error)}`); throw new WorktreeError( `Failed to list worktrees for repository at ${repoPath}`, "listWorktrees", error as Error ); } } /** * Cleanup worktree for a specific agent * * Returns structured result indicating success/failure. * Does not throw - cleanup is best-effort. */ async cleanupWorktree( repoPath: string, agentId: string, taskId: string ): Promise { // Validate inputs if (!repoPath) { throw new Error("repoPath is required"); } if (!agentId) { throw new Error("agentId is required"); } if (!taskId) { throw new Error("taskId is required"); } const worktreePath = this.getWorktreePath(repoPath, agentId, taskId); try { this.logger.log(`Cleaning up worktree for agent ${agentId}, task ${taskId}`); await this.removeWorktree(worktreePath); this.logger.log(`Successfully cleaned up worktree for agent ${agentId}, task ${taskId}`); return { success: true }; } catch (error) { // Log error but don't throw - cleanup should be best-effort const errorMessage = error instanceof Error ? error.message : String(error); this.logger.warn( `Failed to cleanup worktree for agent ${agentId}, task ${taskId}: ${errorMessage}` ); return { success: false, error: errorMessage }; } } }