diff --git a/packages/git/src/worktree.test.ts b/packages/git/src/worktree.test.ts index ea4f70697d..fac9c62122 100644 --- a/packages/git/src/worktree.test.ts +++ b/packages/git/src/worktree.test.ts @@ -229,6 +229,30 @@ describe("WorktreeManager lifecycle (add / exists / list / remove / prune)", () expect(await dirExists(info.worktreePath)).toBe(false); expect(await manager.listWorktrees()).toEqual([]); }); + + it("throws a clean occupied-branch error when the branch is already checked out", async () => { + await createGitClient(localDir).branch(["feature"]); + + const manager = new WorktreeManager({ + mainRepoPath: localDir, + worktreeBasePath: worktreeBaseDir, + }); + + // First checkout succeeds and claims the branch. + const first = await manager.createWorktreeForExistingBranch("feature"); + expect(await dirExists(first.worktreePath)).toBe(true); + + // Second checkout must fail with a clean, actionable message rather than the + // raw "git worktree add exited with code 128" error. The message keeps the + // "is already used by worktree" substring the workspace and restore paths + // match on. + await expect( + manager.createWorktreeForExistingBranch("feature"), + ).rejects.toThrow(/is already used by worktree/); + await expect( + manager.createWorktreeForExistingBranch("feature"), + ).rejects.not.toThrow(/exited with code 128/); + }); }); describe("WorktreeManager worktree link/include processing", () => { diff --git a/packages/git/src/worktree.ts b/packages/git/src/worktree.ts index 31ccc259e1..3d5ec47bfa 100644 --- a/packages/git/src/worktree.ts +++ b/packages/git/src/worktree.ts @@ -250,6 +250,19 @@ export class WorktreeManager { throw new Error(`Branch '${branch}' does not exist`); } + // Git refuses to check a branch out in a second worktree, failing with an + // opaque "git worktree add exited with code 128" error. Detect that up + // front and throw a clean, actionable message. The wording mirrors git's + // own ("is already used by worktree at ...") so callers that key on that + // substring — the workspace creation path and the restore toast — keep + // recognising the occupied-branch case. + const occupyingWorktreePath = await this.findWorktreePathForBranch(branch); + if (occupyingWorktreePath) { + throw new Error( + `Branch '${branch}' is already used by worktree at '${occupyingWorktreePath}'`, + ); + } + const worktreeName = await this.resolveAvailableWorktreeName(preferredName); const { worktreePath, targetPath } = await this.prepareWorktreePath(worktreeName); @@ -345,6 +358,30 @@ export class WorktreeManager { }; } + /** + * Returns the path of an existing worktree that already has `branch` checked + * out (including the main checkout), or null when the branch is free. Uses + * git's own worktree list, so it covers worktrees anywhere on disk, not just + * managed ones under the base path. + */ + private async findWorktreePathForBranch( + branch: string, + ): Promise { + try { + const worktrees = await listWorktreesRaw(this.mainRepoPath); + const match = worktrees.find((wt) => wt.branch === branch); + return match ? match.path : null; + } catch (error) { + // Degrade to the prior behaviour: if the check can't run, let the git + // command proceed rather than blocking creation on a listing failure. + this.log.warn("Failed to check for existing worktree on branch", { + branch, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + } + /** * Resolves a worktree name that does not collide with an existing worktree, * falling back to a freshly generated unique name when the preferred (or