/** * SimpleGit WorktreeManager Adapter * * Implementation of WorktreeManager port interface using simple-git. * This is the ADAPTER for the WorktreeManager PORT. * * Manages git worktrees for isolated agent workspaces. * Each agent gets its own worktree to avoid file conflicts. */ import path from 'node:path'; import { simpleGit, type SimpleGit } from 'simple-git'; import type { EventBus } from '../events/types.js'; import type { WorktreeManager, Worktree, WorktreeDiff, MergeResult, } from './types.js'; import { createModuleLogger } from '../logger/index.js'; const log = createModuleLogger('git'); /** * SimpleGit-based implementation of the WorktreeManager interface. * * Wraps simple-git to provide git worktree operations * that conform to the WorktreeManager port interface. */ export class SimpleGitWorktreeManager implements WorktreeManager { private git: SimpleGit; private repoPath: string; private worktreesDir: string; private eventBus?: EventBus; /** * Create a new SimpleGitWorktreeManager. * * @param repoPath - Absolute path to the git repository * @param eventBus - Optional EventBus for emitting git events * @param worktreesBaseDir - Optional custom base directory for worktrees (defaults to /.cw-worktrees) */ constructor(repoPath: string, eventBus?: EventBus, worktreesBaseDir?: string) { this.repoPath = repoPath; this.git = simpleGit(repoPath); this.worktreesDir = worktreesBaseDir ?? path.join(repoPath, '.cw-worktrees'); this.eventBus = eventBus; } /** * Create a new worktree for isolated agent work. * * Creates a new branch and worktree directory. * The worktree will be ready for the agent to start working. */ async create( id: string, branch: string, baseBranch: string = 'main' ): Promise { const worktreePath = path.join(this.worktreesDir, id); log.info({ id, branch, baseBranch }, 'creating worktree'); // Safety: never force-reset a branch to its own base — this would nuke // shared branches like the initiative branch if passed as both branch and baseBranch. if (branch === baseBranch) { throw new Error(`Worktree branch and baseBranch are the same (${branch}). Use a unique branch name.`); } // Create worktree — reuse existing branch or create new one const branchExists = await this.branchExists(branch); if (branchExists) { // Branch exists from a previous run. Check if it has commits beyond baseBranch // before resetting — a previous agent may have done real work on this branch. try { const aheadCount = await this.git.raw(['rev-list', '--count', `${baseBranch}..${branch}`]); if (parseInt(aheadCount.trim(), 10) > 0) { log.warn({ branch, baseBranch, aheadBy: aheadCount.trim() }, 'branch has commits beyond base, preserving'); } else { await this.git.raw(['branch', '-f', branch, baseBranch]); } } catch { // If rev-list fails (e.g. baseBranch doesn't exist yet), fall back to reset await this.git.raw(['branch', '-f', branch, baseBranch]); } // Prune stale worktree references before adding new one await this.git.raw(['worktree', 'prune']); try { await this.git.raw(['worktree', 'add', worktreePath, branch]); } catch (err) { const stalePath = this.parseStaleWorktreePath(err); if (stalePath) { log.warn({ branch, stalePath }, 'branch locked to stale worktree, force-removing and retrying'); await this.forceRemoveWorktree(stalePath); await this.git.raw(['worktree', 'add', worktreePath, branch]); } else { throw err; } } } else { // git worktree add -b try { await this.git.raw(['worktree', 'add', '-b', branch, worktreePath, baseBranch]); } catch (err) { const stalePath = this.parseStaleWorktreePath(err); if (stalePath) { log.warn({ branch, stalePath }, 'branch locked to stale worktree, force-removing and retrying'); await this.forceRemoveWorktree(stalePath); await this.git.raw(['worktree', 'add', '-b', branch, worktreePath, baseBranch]); } else { throw err; } } } const worktree: Worktree = { id, branch, path: worktreePath, isMainWorktree: false, }; // Emit event if eventBus provided this.eventBus?.emit({ type: 'worktree:created', timestamp: new Date(), payload: { worktreeId: id, branch, path: worktreePath, }, }); return worktree; } /** * Remove a worktree and optionally its branch. * * Cleans up the worktree directory and removes it from git's tracking. */ async remove(id: string): Promise { const worktree = await this.get(id); if (!worktree) { throw new Error(`Worktree not found: ${id}`); } const branch = worktree.branch; log.info({ id, branch }, 'removing worktree'); // Remove worktree with force to handle any uncommitted changes // git worktree remove --force await this.git.raw(['worktree', 'remove', worktree.path, '--force']); // Emit event if eventBus provided this.eventBus?.emit({ type: 'worktree:removed', timestamp: new Date(), payload: { worktreeId: id, branch, }, }); } /** * List all worktrees in the repository. * * Returns all worktrees including the main one. */ async list(): Promise { // git worktree list --porcelain const output = await this.git.raw(['worktree', 'list', '--porcelain']); return this.parseWorktreeList(output); } /** * Get a specific worktree by ID. * * Finds worktree by matching path ending with id. */ async get(id: string): Promise { const expectedSuffix = path.join(path.basename(this.worktreesDir), id); const worktrees = await this.list(); // Match on the worktreesDir + id suffix to avoid cross-agent collisions. // Multiple agents may have worktrees ending with the same project name // (e.g., ".../agent-A/codewalk-district" vs ".../agent-B/codewalk-district"). // We match on basename(worktreesDir)/id to handle symlink differences // (e.g., macOS /var → /private/var) while still being unambiguous. return worktrees.find((wt) => wt.path.endsWith(expectedSuffix)) ?? null; } /** * Get the diff/changes in a worktree. * * Shows what files have changed compared to HEAD. */ async diff(id: string): Promise { const worktree = await this.get(id); if (!worktree) { throw new Error(`Worktree not found: ${id}`); } // Create git instance for the worktree directory const worktreeGit = simpleGit(worktree.path); // Get name-status diff against HEAD // git diff --name-status HEAD let diffOutput: string; try { diffOutput = await worktreeGit.raw(['diff', '--name-status', 'HEAD']); } catch { // If HEAD doesn't exist or other issues, return empty diff diffOutput = ''; } // Also get staged changes let stagedOutput: string; try { stagedOutput = await worktreeGit.raw([ 'diff', '--name-status', '--cached', ]); } catch { stagedOutput = ''; } // Combine and parse outputs const combined = diffOutput + stagedOutput; const files = this.parseDiffNameStatus(combined); // Get summary const fileCount = files.length; const summary = fileCount === 0 ? 'No changes' : `${fileCount} file(s) changed`; return { files, summary }; } /** * Merge worktree changes into target branch. * * Attempts to merge the worktree's branch into the target branch. * Returns conflict information if merge cannot be completed cleanly. */ async merge(id: string, targetBranch: string): Promise { const worktree = await this.get(id); if (!worktree) { throw new Error(`Worktree not found: ${id}`); } log.info({ id, targetBranch }, 'merging worktree'); // Store current branch to restore later const currentBranch = await this.git.revparse(['--abbrev-ref', 'HEAD']); try { // Checkout target branch in main repo await this.git.checkout(targetBranch); // Attempt merge with no-edit (no interactive editor) await this.git.merge([worktree.branch, '--no-edit']); // Emit success event this.eventBus?.emit({ type: 'worktree:merged', timestamp: new Date(), payload: { worktreeId: id, sourceBranch: worktree.branch, targetBranch, }, }); return { success: true, message: 'Merged successfully', }; } catch (error) { // Check if it's a merge conflict const status = await this.git.status(); if (status.conflicted.length > 0) { const conflicts = status.conflicted; log.warn({ id, targetBranch, conflictCount: conflicts.length }, 'merge conflicts detected'); // Emit conflict event this.eventBus?.emit({ type: 'worktree:conflict', timestamp: new Date(), payload: { worktreeId: id, sourceBranch: worktree.branch, targetBranch, conflictingFiles: conflicts, }, }); // Abort merge to clean up await this.git.merge(['--abort']); return { success: false, conflicts, message: 'Merge conflicts detected', }; } // Some other error occurred, rethrow throw error; } finally { // Restore original branch if different from target try { const nowBranch = await this.git.revparse(['--abbrev-ref', 'HEAD']); if (nowBranch.trim() !== currentBranch.trim()) { await this.git.checkout(currentBranch.trim()); } } catch { // Ignore errors restoring branch } } } /** Parse stale worktree path from "already used by worktree at ''" git error. */ private parseStaleWorktreePath(err: unknown): string | null { const msg = err instanceof Error ? err.message : String(err); const match = msg.match(/already used by worktree at '([^']+)'/); return match ? match[1] : null; } /** Force-remove a stale worktree (git ref + directory). */ private async forceRemoveWorktree(worktreePath: string): Promise { try { await this.git.raw(['worktree', 'remove', '--force', worktreePath]); } catch { // Corrupted state fallback: delete directory and prune const { rm } = await import('node:fs/promises'); await rm(worktreePath, { recursive: true, force: true }); await this.git.raw(['worktree', 'prune']); } } /** * Parse the porcelain output of git worktree list. */ private parseWorktreeList(output: string): Worktree[] { const worktrees: Worktree[] = []; const lines = output.trim().split('\n'); let currentWorktree: Partial = {}; let isFirst = true; for (const line of lines) { if (line.startsWith('worktree ')) { // Start of a new worktree entry if (currentWorktree.path) { // Derive ID from path const id = isFirst ? 'main' : path.basename(currentWorktree.path); worktrees.push({ id, branch: currentWorktree.branch || '', path: currentWorktree.path, isMainWorktree: isFirst, }); isFirst = false; } currentWorktree = { path: line.substring('worktree '.length) }; } else if (line.startsWith('branch ')) { // Branch reference (e.g., "branch refs/heads/main") const branchRef = line.substring('branch '.length); currentWorktree.branch = branchRef.replace('refs/heads/', ''); } else if (line.startsWith('HEAD ')) { // Detached HEAD, skip } else if (line === 'bare') { // Bare worktree, skip } else if (line === '') { // Empty line between worktrees } } // Don't forget the last worktree if (currentWorktree.path) { const id = isFirst ? 'main' : path.basename(currentWorktree.path); worktrees.push({ id, branch: currentWorktree.branch || '', path: currentWorktree.path, isMainWorktree: isFirst, }); } return worktrees; } /** * Check if a local branch exists in the repository. */ private async branchExists(branch: string): Promise { try { await this.git.raw(['rev-parse', '--verify', `refs/heads/${branch}`]); return true; } catch { return false; } } /** * Parse the output of git diff --name-status. */ private parseDiffNameStatus( output: string ): Array<{ path: string; status: 'added' | 'modified' | 'deleted' }> { if (!output.trim()) { return []; } const lines = output.trim().split('\n'); const files: Array<{ path: string; status: 'added' | 'modified' | 'deleted'; }> = []; const seen = new Set(); for (const line of lines) { if (!line.trim()) continue; // Format: "M\tpath/to/file" or "A\tpath/to/file" or "D\tpath/to/file" const match = line.match(/^([AMD])\t(.+)$/); if (match) { const [, statusLetter, filePath] = match; // Skip duplicates (can happen when combining diff + cached) if (seen.has(filePath)) continue; seen.add(filePath); let status: 'added' | 'modified' | 'deleted'; switch (statusLetter) { case 'A': status = 'added'; break; case 'M': status = 'modified'; break; case 'D': status = 'deleted'; break; default: continue; } files.push({ path: filePath, status }); } } return files; } }