import { ConfigService } from "@nestjs/config"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { WorktreeManagerService } from "./worktree-manager.service"; import { GitOperationsService } from "./git-operations.service"; import { WorktreeError } from "./types"; import * as path from "path"; // Mock simple-git const mockGit = { raw: vi.fn(), }; vi.mock("simple-git", () => ({ simpleGit: vi.fn(() => mockGit), })); describe("WorktreeManagerService", () => { let service: WorktreeManagerService; let mockConfigService: ConfigService; let mockGitOperationsService: GitOperationsService; beforeEach(() => { // Reset all mocks vi.clearAllMocks(); // Create mock config service mockConfigService = { get: vi.fn((key: string) => { if (key === "orchestrator.git.userName") return "Test User"; if (key === "orchestrator.git.userEmail") return "test@example.com"; return undefined; }), } as unknown as ConfigService; // Create mock git operations service mockGitOperationsService = new GitOperationsService(mockConfigService); // Create service with mocks service = new WorktreeManagerService(mockGitOperationsService); }); describe("createWorktree", () => { it("should create worktree with correct naming convention", async () => { const repoPath = "/tmp/test-repo"; const agentId = "agent-123"; const taskId = "task-456"; const expectedPath = path.join("/tmp", "test-repo_worktrees", `agent-${agentId}-${taskId}`); const branchName = `agent-${agentId}-${taskId}`; mockGit.raw.mockResolvedValue( `worktree ${expectedPath}\nHEAD abc123\nbranch refs/heads/${branchName}` ); const result = await service.createWorktree(repoPath, agentId, taskId); expect(result).toBeDefined(); expect(result.path).toBe(expectedPath); expect(result.branch).toBe(branchName); expect(mockGit.raw).toHaveBeenCalledWith([ "worktree", "add", expectedPath, "-b", branchName, "develop", ]); }); it("should create worktree with custom base branch", async () => { const repoPath = "/tmp/test-repo"; const agentId = "agent-123"; const taskId = "task-456"; const baseBranch = "main"; const expectedPath = path.join("/tmp", "test-repo_worktrees", `agent-${agentId}-${taskId}`); const branchName = `agent-${agentId}-${taskId}`; mockGit.raw.mockResolvedValue( `worktree ${expectedPath}\nHEAD abc123\nbranch refs/heads/${branchName}` ); await service.createWorktree(repoPath, agentId, taskId, baseBranch); expect(mockGit.raw).toHaveBeenCalledWith([ "worktree", "add", expectedPath, "-b", branchName, baseBranch, ]); }); it("should throw WorktreeError if worktree already exists", async () => { const error = new Error("fatal: 'agent-123-task-456' already exists"); mockGit.raw.mockRejectedValue(error); await expect( service.createWorktree("/tmp/test-repo", "agent-123", "task-456") ).rejects.toThrow(WorktreeError); try { await service.createWorktree("/tmp/test-repo", "agent-123", "task-456"); } catch (e) { expect(e).toBeInstanceOf(WorktreeError); expect((e as WorktreeError).operation).toBe("createWorktree"); expect((e as WorktreeError).cause).toBe(error); } }); it("should throw WorktreeError on git command failure", async () => { const error = new Error("git command failed"); mockGit.raw.mockRejectedValue(error); await expect( service.createWorktree("/tmp/test-repo", "agent-123", "task-456") ).rejects.toThrow(WorktreeError); }); it("should validate agentId is not empty", async () => { await expect(service.createWorktree("/tmp/test-repo", "", "task-456")).rejects.toThrow( "agentId is required" ); }); it("should validate taskId is not empty", async () => { await expect(service.createWorktree("/tmp/test-repo", "agent-123", "")).rejects.toThrow( "taskId is required" ); }); it("should validate repoPath is not empty", async () => { await expect(service.createWorktree("", "agent-123", "task-456")).rejects.toThrow( "repoPath is required" ); }); }); describe("removeWorktree", () => { it("should remove worktree successfully", async () => { const worktreePath = "/tmp/test-repo_worktrees/agent-123-task-456"; mockGit.raw.mockResolvedValue(""); await service.removeWorktree(worktreePath); expect(mockGit.raw).toHaveBeenCalledWith(["worktree", "remove", worktreePath, "--force"]); }); it("should handle non-existent worktree gracefully", async () => { const worktreePath = "/tmp/test-repo_worktrees/non-existent"; const error = new Error("fatal: 'non-existent' is not a working tree"); mockGit.raw.mockRejectedValue(error); // Should not throw, just log warning await expect(service.removeWorktree(worktreePath)).resolves.not.toThrow(); }); it("should throw WorktreeError on removal failure", async () => { const worktreePath = "/tmp/test-repo_worktrees/agent-123-task-456"; const error = new Error("permission denied"); mockGit.raw.mockRejectedValue(error); // Should throw for non-worktree-not-found errors await expect(service.removeWorktree(worktreePath)).rejects.toThrow(); }); it("should validate worktreePath is not empty", async () => { await expect(service.removeWorktree("")).rejects.toThrow("worktreePath is required"); }); }); describe("listWorktrees", () => { it("should return empty array when no worktrees exist", async () => { const repoPath = "/tmp/test-repo"; mockGit.raw.mockResolvedValue(`/tmp/test-repo abc123 [develop]`); const result = await service.listWorktrees(repoPath); expect(result).toEqual([]); }); it("should list all active worktrees", async () => { const repoPath = "/tmp/test-repo"; const output = `/tmp/test-repo abc123 [develop] /tmp/test-repo_worktrees/agent-123-task-456 def456 [agent-123-task-456] /tmp/test-repo_worktrees/agent-789-task-012 abc789 [agent-789-task-012]`; mockGit.raw.mockResolvedValue(output); const result = await service.listWorktrees(repoPath); expect(result).toHaveLength(2); expect(result[0].path).toBe("/tmp/test-repo_worktrees/agent-123-task-456"); expect(result[0].commit).toBe("def456"); expect(result[0].branch).toBe("agent-123-task-456"); expect(result[1].path).toBe("/tmp/test-repo_worktrees/agent-789-task-012"); expect(result[1].commit).toBe("abc789"); expect(result[1].branch).toBe("agent-789-task-012"); }); it("should parse worktree info correctly", async () => { const repoPath = "/tmp/test-repo"; const output = `/tmp/test-repo abc123 [develop] /tmp/test-repo_worktrees/agent-123-task-456 def456 [agent-123-task-456]`; mockGit.raw.mockResolvedValue(output); const result = await service.listWorktrees(repoPath); expect(result[0]).toEqual({ path: "/tmp/test-repo_worktrees/agent-123-task-456", commit: "def456", branch: "agent-123-task-456", }); }); it("should throw WorktreeError on git command failure", async () => { const error = new Error("git command failed"); mockGit.raw.mockRejectedValue(error); await expect(service.listWorktrees("/tmp/test-repo")).rejects.toThrow(WorktreeError); }); it("should validate repoPath is not empty", async () => { await expect(service.listWorktrees("")).rejects.toThrow("repoPath is required"); }); }); describe("cleanupWorktree", () => { it("should remove worktree on agent completion and return success", async () => { const repoPath = "/tmp/test-repo"; const agentId = "agent-123"; const taskId = "task-456"; const worktreePath = path.join("/tmp", "test-repo_worktrees", `agent-${agentId}-${taskId}`); mockGit.raw.mockResolvedValue(""); const result = await service.cleanupWorktree(repoPath, agentId, taskId); expect(result).toEqual({ success: true }); expect(mockGit.raw).toHaveBeenCalledWith(["worktree", "remove", worktreePath, "--force"]); }); it("should return failure result on cleanup errors", async () => { const error = new Error("worktree not found"); mockGit.raw.mockRejectedValue(error); const result = await service.cleanupWorktree("/tmp/test-repo", "agent-123", "task-456"); expect(result.success).toBe(false); expect(result.error).toContain("Failed to remove worktree"); }); it("should handle non-Error objects in cleanup errors", async () => { mockGit.raw.mockRejectedValue("string error"); const result = await service.cleanupWorktree("/tmp/test-repo", "agent-123", "task-456"); expect(result.success).toBe(false); expect(result.error).toContain("Failed to remove worktree"); }); it("should validate agentId is not empty", async () => { await expect(service.cleanupWorktree("/tmp/test-repo", "", "task-456")).rejects.toThrow( "agentId is required" ); }); it("should validate taskId is not empty", async () => { await expect(service.cleanupWorktree("/tmp/test-repo", "agent-123", "")).rejects.toThrow( "taskId is required" ); }); it("should validate repoPath is not empty", async () => { await expect(service.cleanupWorktree("", "agent-123", "task-456")).rejects.toThrow( "repoPath is required" ); }); }); describe("getWorktreePath", () => { it("should generate correct worktree path", () => { const repoPath = "/tmp/test-repo"; const agentId = "agent-123"; const taskId = "task-456"; const expectedPath = path.join("/tmp", "test-repo_worktrees", `agent-${agentId}-${taskId}`); const result = service.getWorktreePath(repoPath, agentId, taskId); expect(result).toBe(expectedPath); }); it("should handle repo paths with trailing slashes", () => { const repoPath = "/tmp/test-repo/"; const agentId = "agent-123"; const taskId = "task-456"; const expectedPath = path.join("/tmp", "test-repo_worktrees", `agent-${agentId}-${taskId}`); const result = service.getWorktreePath(repoPath, agentId, taskId); expect(result).toBe(expectedPath); }); }); describe("getBranchName", () => { it("should generate correct branch name", () => { const agentId = "agent-123"; const taskId = "task-456"; const expectedBranch = `agent-${agentId}-${taskId}`; const result = service.getBranchName(agentId, taskId); expect(result).toBe(expectedBranch); }); }); });