Files
Codewalkers/apps/server/git/manager.ts
Lukas May 28521e1c20 chore: merge main into cw/small-change-flow
Integrates main branch changes (headquarters dashboard, task retry count,
agent prompt persistence, remote sync improvements) with the initiative's
errand agent feature. Both features coexist in the merged result.

Key resolutions:
- Schema: take main's errands table (nullable projectId, no conflictFiles,
  with errandsRelations); migrate to 0035_faulty_human_fly
- Router: keep both errandProcedures and headquartersProcedures
- Errand prompt: take main's simpler version (no question-asking flow)
- Manager: take main's status check (running|idle only, no waiting_for_input)
- Tests: update to match removed conflictFiles field and undefined vs null
2026-03-06 16:48:12 +01:00

418 lines
12 KiB
TypeScript

/**
* 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 <repoPath>/.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<Worktree> {
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']);
await this.git.raw(['worktree', 'add', worktreePath, branch]);
} else {
// 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;
log.info({ id, branch }, 'removing worktree');
// 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 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<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}`);
}
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 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;
}
/**
* Check if a local branch exists in the repository.
*/
private async branchExists(branch: string): Promise<boolean> {
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<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;
}
}