feat(03-02): install simple-git and create WorktreeManager adapter with CRUD

- Install simple-git dependency for git operations
- Create SimpleGitWorktreeManager class implementing WorktreeManager port
- Implement create(), remove(), list(), get() methods
- EventBus optional dependency injection for emitting worktree events
- Worktrees stored in .cw-worktrees directory
This commit is contained in:
Lukas May
2026-01-30 19:27:35 +01:00
parent b70c07caf2
commit 0cf2849993
4 changed files with 435 additions and 10 deletions

64
package-lock.json generated
View File

@@ -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",

View File

@@ -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": {

View File

@@ -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';

372
src/git/manager.ts Normal file
View File

@@ -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<Worktree> {
const worktreePath = path.join(this.worktreesDir, id);
// Create worktree with new branch
// git worktree add -b <branch> <path> <base-branch>
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<void> {
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 <path> --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<Worktree[]> {
// 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<Worktree | null> {
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<WorktreeDiff> {
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<MergeResult> {
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<Worktree> = {};
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<string>();
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;
}
}