Files
Codewalkers/apps/server/git/manager.ts
Lukas May 40900a5641 fix: self-healing stale worktree recovery in SimpleGitWorktreeManager
When git worktree add fails with "branch already used by worktree at
<path>", parse the stale path, force-remove it, and retry once. Fixes
blocked task retries where the old agent-workdirs directory still exists
on disk and git worktree prune alone can't clear the reference.
2026-03-07 00:13:24 +01:00

459 lines
14 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']);
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 <branch> <path> <base-branch>
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<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 stale worktree path from "already used by worktree at '<path>'" 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<void> {
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<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;
}
}