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:
64
package-lock.json
generated
64
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
372
src/git/manager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user