From 2a77407aaa5a82e69df03d4eff6ab94c394f7308 Mon Sep 17 00:00:00 2001 From: Shirone Date: Tue, 27 Jan 2026 14:48:55 +0100 Subject: [PATCH] feat(01-02): extract WorktreeResolver from AutoModeService - Create WorktreeResolver class for git worktree discovery - Extract getCurrentBranch, findWorktreeForBranch, listWorktrees methods - Add WorktreeInfo interface for worktree metadata - Always resolve paths to absolute for cross-platform compatibility - Add 20 unit tests covering all worktree operations --- apps/server/src/services/worktree-resolver.ts | 170 ++++++++++ .../unit/services/worktree-resolver.test.ts | 310 ++++++++++++++++++ 2 files changed, 480 insertions(+) create mode 100644 apps/server/src/services/worktree-resolver.ts create mode 100644 apps/server/tests/unit/services/worktree-resolver.test.ts diff --git a/apps/server/src/services/worktree-resolver.ts b/apps/server/src/services/worktree-resolver.ts new file mode 100644 index 00000000..48ae405d --- /dev/null +++ b/apps/server/src/services/worktree-resolver.ts @@ -0,0 +1,170 @@ +/** + * WorktreeResolver - Git worktree discovery and resolution + * + * Extracted from AutoModeService to provide a standalone service for: + * - Finding existing worktrees for a given branch + * - Getting the current branch of a repository + * - Listing all worktrees with their metadata + * + * Key behaviors: + * - Parses `git worktree list --porcelain` output + * - Always resolves paths to absolute (cross-platform compatibility) + * - Handles detached HEAD and bare worktrees gracefully + */ + +import { exec } from 'child_process'; +import { promisify } from 'util'; +import path from 'path'; + +const execAsync = promisify(exec); + +/** + * Information about a git worktree + */ +export interface WorktreeInfo { + /** Absolute path to the worktree directory */ + path: string; + /** Branch name (without refs/heads/ prefix), or null if detached HEAD */ + branch: string | null; + /** Whether this is the main worktree (first in git worktree list) */ + isMain: boolean; +} + +/** + * WorktreeResolver handles git worktree discovery and path resolution. + * + * This service is responsible for: + * 1. Finding existing worktrees by branch name + * 2. Getting the current branch of a repository + * 3. Listing all worktrees with normalized paths + */ +export class WorktreeResolver { + /** + * Get the current branch name for a git repository + * + * @param projectPath - Path to the git repository + * @returns The current branch name, or null if not in a git repo or on detached HEAD + */ + async getCurrentBranch(projectPath: string): Promise { + try { + const { stdout } = await execAsync('git branch --show-current', { cwd: projectPath }); + const branch = stdout.trim(); + return branch || null; + } catch { + return null; + } + } + + /** + * Find an existing worktree for a given branch name + * + * @param projectPath - Path to the git repository (main worktree) + * @param branchName - Branch name to find worktree for + * @returns Absolute path to the worktree, or null if not found + */ + async findWorktreeForBranch(projectPath: string, branchName: string): Promise { + try { + const { stdout } = await execAsync('git worktree list --porcelain', { + cwd: projectPath, + }); + + const lines = stdout.split('\n'); + let currentPath: string | null = null; + let currentBranch: string | null = null; + + for (const line of lines) { + if (line.startsWith('worktree ')) { + currentPath = line.slice(9); + } else if (line.startsWith('branch ')) { + currentBranch = line.slice(7).replace('refs/heads/', ''); + } else if (line === '' && currentPath && currentBranch) { + // End of a worktree entry + if (currentBranch === branchName) { + // Resolve to absolute path - git may return relative paths + // On Windows, this is critical for cwd to work correctly + // On all platforms, absolute paths ensure consistent behavior + return this.resolvePath(projectPath, currentPath); + } + currentPath = null; + currentBranch = null; + } + } + + // Check the last entry (if file doesn't end with newline) + if (currentPath && currentBranch && currentBranch === branchName) { + return this.resolvePath(projectPath, currentPath); + } + + return null; + } catch { + return null; + } + } + + /** + * List all worktrees for a repository + * + * @param projectPath - Path to the git repository + * @returns Array of WorktreeInfo objects with normalized paths + */ + async listWorktrees(projectPath: string): Promise { + try { + const { stdout } = await execAsync('git worktree list --porcelain', { + cwd: projectPath, + }); + + const worktrees: WorktreeInfo[] = []; + const lines = stdout.split('\n'); + let currentPath: string | null = null; + let currentBranch: string | null = null; + let isFirstWorktree = true; + + for (const line of lines) { + if (line.startsWith('worktree ')) { + currentPath = line.slice(9); + } else if (line.startsWith('branch ')) { + currentBranch = line.slice(7).replace('refs/heads/', ''); + } else if (line.startsWith('detached')) { + // Detached HEAD - branch is null + currentBranch = null; + } else if (line === '' && currentPath) { + // End of a worktree entry + worktrees.push({ + path: this.resolvePath(projectPath, currentPath), + branch: currentBranch, + isMain: isFirstWorktree, + }); + currentPath = null; + currentBranch = null; + isFirstWorktree = false; + } + } + + // Handle last entry if file doesn't end with newline + if (currentPath) { + worktrees.push({ + path: this.resolvePath(projectPath, currentPath), + branch: currentBranch, + isMain: isFirstWorktree, + }); + } + + return worktrees; + } catch { + return []; + } + } + + /** + * Resolve a path to absolute, handling both relative and absolute inputs + * + * @param projectPath - Base path for relative resolution + * @param worktreePath - Path from git worktree list output + * @returns Absolute path + */ + private resolvePath(projectPath: string, worktreePath: string): string { + return path.isAbsolute(worktreePath) + ? path.resolve(worktreePath) + : path.resolve(projectPath, worktreePath); + } +} diff --git a/apps/server/tests/unit/services/worktree-resolver.test.ts b/apps/server/tests/unit/services/worktree-resolver.test.ts new file mode 100644 index 00000000..75bec402 --- /dev/null +++ b/apps/server/tests/unit/services/worktree-resolver.test.ts @@ -0,0 +1,310 @@ +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { WorktreeResolver, type WorktreeInfo } from '@/services/worktree-resolver.js'; +import { exec } from 'child_process'; + +// Mock child_process +vi.mock('child_process', () => ({ + exec: vi.fn(), +})); + +// Create promisified mock helper +const mockExecAsync = ( + impl: (cmd: string, options?: { cwd?: string }) => Promise<{ stdout: string; stderr: string }> +) => { + (exec as unknown as Mock).mockImplementation( + ( + cmd: string, + options: { cwd?: string } | undefined, + callback: (error: Error | null, result: { stdout: string; stderr: string }) => void + ) => { + impl(cmd, options) + .then((result) => callback(null, result)) + .catch((error) => callback(error, { stdout: '', stderr: '' })); + } + ); +}; + +describe('WorktreeResolver', () => { + let resolver: WorktreeResolver; + + beforeEach(() => { + vi.clearAllMocks(); + resolver = new WorktreeResolver(); + }); + + describe('getCurrentBranch', () => { + it('should return branch name when on a branch', async () => { + mockExecAsync(async () => ({ stdout: 'main\n', stderr: '' })); + + const branch = await resolver.getCurrentBranch('/test/project'); + + expect(branch).toBe('main'); + }); + + it('should return null on detached HEAD (empty output)', async () => { + mockExecAsync(async () => ({ stdout: '', stderr: '' })); + + const branch = await resolver.getCurrentBranch('/test/project'); + + expect(branch).toBeNull(); + }); + + it('should return null when git command fails', async () => { + mockExecAsync(async () => { + throw new Error('Not a git repository'); + }); + + const branch = await resolver.getCurrentBranch('/not/a/git/repo'); + + expect(branch).toBeNull(); + }); + + it('should trim whitespace from branch name', async () => { + mockExecAsync(async () => ({ stdout: ' feature-branch \n', stderr: '' })); + + const branch = await resolver.getCurrentBranch('/test/project'); + + expect(branch).toBe('feature-branch'); + }); + + it('should use provided projectPath as cwd', async () => { + let capturedCwd: string | undefined; + mockExecAsync(async (cmd, options) => { + capturedCwd = options?.cwd; + return { stdout: 'main\n', stderr: '' }; + }); + + await resolver.getCurrentBranch('/custom/path'); + + expect(capturedCwd).toBe('/custom/path'); + }); + }); + + describe('findWorktreeForBranch', () => { + const porcelainOutput = `worktree /Users/dev/project +branch refs/heads/main + +worktree /Users/dev/project/.worktrees/feature-x +branch refs/heads/feature-x + +worktree /Users/dev/project/.worktrees/feature-y +branch refs/heads/feature-y +`; + + it('should find worktree by branch name', async () => { + mockExecAsync(async () => ({ stdout: porcelainOutput, stderr: '' })); + + const path = await resolver.findWorktreeForBranch('/Users/dev/project', 'feature-x'); + + expect(path).toBe('/Users/dev/project/.worktrees/feature-x'); + }); + + it('should return null when branch not found', async () => { + mockExecAsync(async () => ({ stdout: porcelainOutput, stderr: '' })); + + const path = await resolver.findWorktreeForBranch('/Users/dev/project', 'non-existent'); + + expect(path).toBeNull(); + }); + + it('should return null when git command fails', async () => { + mockExecAsync(async () => { + throw new Error('Not a git repository'); + }); + + const path = await resolver.findWorktreeForBranch('/not/a/repo', 'main'); + + expect(path).toBeNull(); + }); + + it('should find main worktree', async () => { + mockExecAsync(async () => ({ stdout: porcelainOutput, stderr: '' })); + + const path = await resolver.findWorktreeForBranch('/Users/dev/project', 'main'); + + expect(path).toBe('/Users/dev/project'); + }); + + it('should handle porcelain output without trailing newline', async () => { + const noTrailingNewline = `worktree /Users/dev/project +branch refs/heads/main + +worktree /Users/dev/project/.worktrees/feature-x +branch refs/heads/feature-x`; + + mockExecAsync(async () => ({ stdout: noTrailingNewline, stderr: '' })); + + const path = await resolver.findWorktreeForBranch('/Users/dev/project', 'feature-x'); + + expect(path).toBe('/Users/dev/project/.worktrees/feature-x'); + }); + + it('should resolve relative paths to absolute', async () => { + const relativePathOutput = `worktree /Users/dev/project +branch refs/heads/main + +worktree .worktrees/feature-relative +branch refs/heads/feature-relative +`; + + mockExecAsync(async () => ({ stdout: relativePathOutput, stderr: '' })); + + const result = await resolver.findWorktreeForBranch('/Users/dev/project', 'feature-relative'); + + // Should resolve to absolute path + expect(result).toBe('/Users/dev/project/.worktrees/feature-relative'); + }); + + it('should use projectPath as cwd for git command', async () => { + let capturedCwd: string | undefined; + mockExecAsync(async (cmd, options) => { + capturedCwd = options?.cwd; + return { stdout: porcelainOutput, stderr: '' }; + }); + + await resolver.findWorktreeForBranch('/custom/project', 'main'); + + expect(capturedCwd).toBe('/custom/project'); + }); + }); + + describe('listWorktrees', () => { + it('should list all worktrees with metadata', async () => { + const porcelainOutput = `worktree /Users/dev/project +branch refs/heads/main + +worktree /Users/dev/project/.worktrees/feature-x +branch refs/heads/feature-x + +worktree /Users/dev/project/.worktrees/feature-y +branch refs/heads/feature-y +`; + + mockExecAsync(async () => ({ stdout: porcelainOutput, stderr: '' })); + + const worktrees = await resolver.listWorktrees('/Users/dev/project'); + + expect(worktrees).toHaveLength(3); + expect(worktrees[0]).toEqual({ + path: '/Users/dev/project', + branch: 'main', + isMain: true, + }); + expect(worktrees[1]).toEqual({ + path: '/Users/dev/project/.worktrees/feature-x', + branch: 'feature-x', + isMain: false, + }); + expect(worktrees[2]).toEqual({ + path: '/Users/dev/project/.worktrees/feature-y', + branch: 'feature-y', + isMain: false, + }); + }); + + it('should return empty array when git command fails', async () => { + mockExecAsync(async () => { + throw new Error('Not a git repository'); + }); + + const worktrees = await resolver.listWorktrees('/not/a/repo'); + + expect(worktrees).toEqual([]); + }); + + it('should handle detached HEAD worktrees', async () => { + const porcelainWithDetached = `worktree /Users/dev/project +branch refs/heads/main + +worktree /Users/dev/project/.worktrees/detached-wt +detached +`; + + mockExecAsync(async () => ({ stdout: porcelainWithDetached, stderr: '' })); + + const worktrees = await resolver.listWorktrees('/Users/dev/project'); + + expect(worktrees).toHaveLength(2); + expect(worktrees[1]).toEqual({ + path: '/Users/dev/project/.worktrees/detached-wt', + branch: null, // Detached HEAD has no branch + isMain: false, + }); + }); + + it('should mark only first worktree as main', async () => { + const multipleWorktrees = `worktree /Users/dev/project +branch refs/heads/main + +worktree /Users/dev/project/.worktrees/wt1 +branch refs/heads/branch1 + +worktree /Users/dev/project/.worktrees/wt2 +branch refs/heads/branch2 +`; + + mockExecAsync(async () => ({ stdout: multipleWorktrees, stderr: '' })); + + const worktrees = await resolver.listWorktrees('/Users/dev/project'); + + expect(worktrees[0].isMain).toBe(true); + expect(worktrees[1].isMain).toBe(false); + expect(worktrees[2].isMain).toBe(false); + }); + + it('should resolve relative paths to absolute', async () => { + const relativePathOutput = `worktree /Users/dev/project +branch refs/heads/main + +worktree .worktrees/relative-wt +branch refs/heads/relative-branch +`; + + mockExecAsync(async () => ({ stdout: relativePathOutput, stderr: '' })); + + const worktrees = await resolver.listWorktrees('/Users/dev/project'); + + expect(worktrees[1].path).toBe('/Users/dev/project/.worktrees/relative-wt'); + }); + + it('should handle single worktree (main only)', async () => { + const singleWorktree = `worktree /Users/dev/project +branch refs/heads/main +`; + + mockExecAsync(async () => ({ stdout: singleWorktree, stderr: '' })); + + const worktrees = await resolver.listWorktrees('/Users/dev/project'); + + expect(worktrees).toHaveLength(1); + expect(worktrees[0]).toEqual({ + path: '/Users/dev/project', + branch: 'main', + isMain: true, + }); + }); + + it('should handle empty git worktree list output', async () => { + mockExecAsync(async () => ({ stdout: '', stderr: '' })); + + const worktrees = await resolver.listWorktrees('/Users/dev/project'); + + expect(worktrees).toEqual([]); + }); + + it('should handle output without trailing newline', async () => { + const noTrailingNewline = `worktree /Users/dev/project +branch refs/heads/main + +worktree /Users/dev/project/.worktrees/feature-x +branch refs/heads/feature-x`; + + mockExecAsync(async () => ({ stdout: noTrailingNewline, stderr: '' })); + + const worktrees = await resolver.listWorktrees('/Users/dev/project'); + + expect(worktrees).toHaveLength(2); + expect(worktrees[1].branch).toBe('feature-x'); + }); + }); +});