From 4a7105eb8ff5b644f767564a6c4abc5be5a8a3ab Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 14:52:28 +0100 Subject: [PATCH] fix: Worktree get() matches wrong agent due to ambiguous endsWith lookup The `get(id)` method on SimpleGitWorktreeManager used `path.endsWith(id)` to find worktrees. Since all agents working on the same project create worktrees with the same project name suffix (e.g., "codewalk-district"), cleanup for one agent could match and delete another agent's worktree. Fix: match on `basename(worktreesDir)/id` so each manager's lookups are scoped to its own worktree base directory. --- apps/server/git/manager.test.ts | 52 +++++++++++++++++++++++++++++++++ apps/server/git/manager.ts | 9 +++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/apps/server/git/manager.test.ts b/apps/server/git/manager.test.ts index 41006dc..017daeb 100644 --- a/apps/server/git/manager.test.ts +++ b/apps/server/git/manager.test.ts @@ -453,6 +453,58 @@ describe('SimpleGitWorktreeManager', () => { }); }); + // ========================================================================== + // Cross-Agent Isolation + // ========================================================================== + + describe('cross-agent isolation', () => { + it('get() only matches worktrees in its own worktreesDir', async () => { + // Simulate two agents with separate worktree base dirs but same repo + const agentADir = path.join(repoPath, 'workdirs', 'agent-a'); + const agentBDir = path.join(repoPath, 'workdirs', 'agent-b'); + await mkdir(agentADir, { recursive: true }); + await mkdir(agentBDir, { recursive: true }); + + const managerA = new SimpleGitWorktreeManager(repoPath, undefined, agentADir); + const managerB = new SimpleGitWorktreeManager(repoPath, undefined, agentBDir); + + // Both create worktrees with the same id (project name) + await managerA.create('my-project', 'agent/agent-a'); + await managerB.create('my-project', 'agent/agent-b'); + + // Each manager should only see its own worktree + const wtA = await managerA.get('my-project'); + const wtB = await managerB.get('my-project'); + + expect(wtA).not.toBeNull(); + expect(wtB).not.toBeNull(); + expect(wtA!.path).toContain('agent-a'); + expect(wtB!.path).toContain('agent-b'); + expect(wtA!.path).not.toBe(wtB!.path); + }); + + it('remove() only removes worktrees in its own worktreesDir', async () => { + const agentADir = path.join(repoPath, 'workdirs', 'agent-a'); + const agentBDir = path.join(repoPath, 'workdirs', 'agent-b'); + await mkdir(agentADir, { recursive: true }); + await mkdir(agentBDir, { recursive: true }); + + const managerA = new SimpleGitWorktreeManager(repoPath, undefined, agentADir); + const managerB = new SimpleGitWorktreeManager(repoPath, undefined, agentBDir); + + await managerA.create('my-project', 'agent/agent-a'); + await managerB.create('my-project', 'agent/agent-b'); + + // Remove agent A's worktree + await managerA.remove('my-project'); + + // Agent B's worktree should still exist + const wtB = await managerB.get('my-project'); + expect(wtB).not.toBeNull(); + expect(wtB!.path).toContain('agent-b'); + }); + }); + // ========================================================================== // Edge Cases // ========================================================================== diff --git a/apps/server/git/manager.ts b/apps/server/git/manager.ts index 95539af..36cec19 100644 --- a/apps/server/git/manager.ts +++ b/apps/server/git/manager.ts @@ -9,6 +9,7 @@ */ import path from 'node:path'; +import { realpathSync } from 'node:fs'; import { simpleGit, type SimpleGit } from 'simple-git'; import type { EventBus } from '../events/types.js'; import type { @@ -159,8 +160,14 @@ export class SimpleGitWorktreeManager implements WorktreeManager { * Finds worktree by matching path ending with id. */ async get(id: string): Promise { + const expectedSuffix = path.join(path.basename(this.worktreesDir), id); const worktrees = await this.list(); - return worktrees.find((wt) => wt.path.endsWith(id)) ?? null; + // 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; } /**