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.
459 lines
14 KiB
TypeScript
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;
|
|
}
|
|
}
|