diff --git a/package-lock.json b/package-lock.json index becf6bf..4e39c12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,8 @@ "commander": "^12.1.0", "drizzle-orm": "^0.45.1", "execa": "^9.5.2", + "nanoid": "^5.1.6", + "simple-git": "^3.30.0", "zod": "^4.3.6" }, "bin": { @@ -945,6 +947,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + } + }, + "node_modules/@kwsites/promise-deferred": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", @@ -1639,7 +1656,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2710,14 +2726,12 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", "funding": [ { "type": "github", @@ -2726,10 +2740,10 @@ ], "license": "MIT", "bin": { - "nanoid": "bin/nanoid.cjs" + "nanoid": "bin/nanoid.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^18 || >=20" } }, "node_modules/napi-build-utils": { @@ -2899,6 +2913,25 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -3171,6 +3204,21 @@ "simple-concat": "^1.0.0" } }, + "node_modules/simple-git": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.30.0.tgz", + "integrity": "sha512-q6lxyDsCmEal/MEGhP1aVyQ3oxnagGlBDOVSIB4XUVLl1iZh0Pah6ebC9V4xBap/RfgP2WlI8EKs0WS0rMEJHg==", + "license": "MIT", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "debug": "^4.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/git-js?sponsor=1" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/package.json b/package.json index cffa4ba..0d76a6e 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ "commander": "^12.1.0", "drizzle-orm": "^0.45.1", "execa": "^9.5.2", + "nanoid": "^5.1.6", + "simple-git": "^3.30.0", "zod": "^4.3.6" }, "devDependencies": { diff --git a/src/git/index.ts b/src/git/index.ts index c53c6ed..8645213 100644 --- a/src/git/index.ts +++ b/src/git/index.ts @@ -1,12 +1,12 @@ /** * Git Module - Public API * - * Exports the WorktreeManager port interface and types. + * Exports the WorktreeManager port interface, types, and adapters. * All modules should import from this index file. * * Pattern follows EventBus module: * - Port interface (WorktreeManager) is what consumers depend on - * - Adapter implementations will be added in future plans + * - Adapter implementation (SimpleGitWorktreeManager) uses simple-git */ // Port interface (what consumers depend on) @@ -14,3 +14,6 @@ export type { WorktreeManager } from './types.js'; // Domain types export type { Worktree, WorktreeDiff, MergeResult } from './types.js'; + +// Adapters +export { SimpleGitWorktreeManager } from './manager.js'; diff --git a/src/git/manager.ts b/src/git/manager.ts new file mode 100644 index 0000000..ce10ca7 --- /dev/null +++ b/src/git/manager.ts @@ -0,0 +1,372 @@ +/** + * 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'; + +/** + * 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 + */ + constructor(repoPath: string, eventBus?: EventBus) { + this.repoPath = repoPath; + this.git = simpleGit(repoPath); + this.worktreesDir = 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); + + // Create worktree with new branch + // git worktree add -b + await this.git.raw([ + 'worktree', + 'add', + '-b', + branch, + worktreePath, + baseBranch, + ]); + + 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; + + // 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 worktrees = await this.list(); + return worktrees.find((wt) => wt.path.endsWith(id)) ?? 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}`); + } + + // 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; + + // 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 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; + } + + /** + * 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; + } +}