diff --git a/apps/server/git/manager.ts b/apps/server/git/manager.ts index 1bd9b46..0331707 100644 --- a/apps/server/git/manager.ts +++ b/apps/server/git/manager.ts @@ -85,10 +85,32 @@ export class SimpleGitWorktreeManager implements WorktreeManager { } // Prune stale worktree references before adding new one await this.git.raw(['worktree', 'prune']); - await this.git.raw(['worktree', 'add', worktreePath, branch]); + 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 - await this.git.raw(['worktree', 'add', '-b', branch, worktreePath, baseBranch]); + 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 = { @@ -300,6 +322,25 @@ export class SimpleGitWorktreeManager implements WorktreeManager { } } + /** Parse stale worktree path from "already used by worktree at ''" 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 { + 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. */