From ed1e178b60cc1e60c6addea381b28fb88934714a Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 07:34:25 +0000 Subject: [PATCH] fix(worktree): clean error when restoring/promoting onto an occupied branch createWorktreeForExistingBranch only checked that the branch existed and that the worktree name/path was free -- never whether the branch was already checked out in another worktree. When it was, git failed with a raw "git worktree add exited with code 128" error that escaped through the restore and promote paths into error tracking. The workspace creation path already caught the "is already used by worktree" message and surfaced a friendly error, but the restore (suspension/archive) and promote paths did not. Detect the occupied branch up front in createWorktreeForExistingBranch and throw a clean, actionable message instead. The wording mirrors git's own so existing consumers that key on the "is already used by worktree" substring keep recognising the case. Fixing it at this shared chokepoint covers all three callers (create, restore, promote). Generated-By: PostHog Code Task-Id: c5eefa8e-bed0-4490-bb76-948ba7260998 --- packages/git/src/worktree.test.ts | 24 ++++++++++++++++++++ packages/git/src/worktree.ts | 37 +++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) 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