Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/git/src/worktree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
37 changes: 37 additions & 0 deletions packages/git/src/worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<string | null> {
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
Expand Down
Loading