Skip to content

Commit dbcebd4

Browse files
committed
fix(git): hide worktree branches in picker
1 parent bc2cbd9 commit dbcebd4

8 files changed

Lines changed: 206 additions & 14 deletions

File tree

packages/core/src/domain/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ export interface GitBranch {
124124
isRemote: boolean; // Whether it's a remote branch
125125
isCurrent: boolean; // Whether it's the current branch
126126
remote?: string; // Remote name (e.g., "origin")
127+
linkedWorktreePath?: string; // Path of another worktree using this branch, if any
127128
}
128129

129130
export interface WorktreeInfo {

packages/server/src/__tests__/git/cli.test.ts

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
getGitHistory,
1515
getGitStatus,
1616
runGit,
17+
runGitCheckout,
1718
runGitFetch,
1819
runGitPull,
1920
runGitPush,
@@ -609,7 +610,7 @@ describe("runGitListBranches", () => {
609610
await rm(testDir, { recursive: true });
610611
});
611612

612-
it("strips linked worktree markers from local branch names", async () => {
613+
it("omits local branches that are checked out in another worktree", async () => {
613614
await writeFile(join(testDir, "README.md"), "test");
614615
await execFileAsync("git", ["add", "."], { cwd: testDir });
615616
await execFileAsync("git", ["commit", "-m", "initial"], { cwd: testDir });
@@ -624,11 +625,11 @@ describe("runGitListBranches", () => {
624625
const result = await runGitListBranches(testDir);
625626

626627
expect(result.current).toBe(defaultBranch);
627-
expect(result.branches).toContainEqual({
628-
name: "feature/worktree-branch",
629-
isRemote: false,
630-
isCurrent: false,
631-
});
628+
expect(result.branches).not.toContainEqual(
629+
expect.objectContaining({
630+
name: "feature/worktree-branch",
631+
})
632+
);
632633
expect(result.branches).not.toContainEqual(
633634
expect.objectContaining({
634635
name: expect.stringMatching(/^\+\s/),
@@ -716,6 +717,42 @@ describe("getGitStatus", () => {
716717
});
717718
});
718719

720+
describe("runGitCheckout", () => {
721+
it("switches to an existing local branch when selecting a remote branch with the same short name", async () => {
722+
const testDir = await mkdtemp(join(tmpdir(), "git-checkout-remote-existing-local-"));
723+
const remoteDir = await mkdtemp(join(tmpdir(), "git-checkout-remote-existing-local-remote-"));
724+
725+
await execFileAsync("git", ["init"], { cwd: testDir });
726+
await execFileAsync("git", ["config", "user.name", "Test"], { cwd: testDir });
727+
await execFileAsync("git", ["config", "user.email", "test@example.com"], { cwd: testDir });
728+
await writeFile(join(testDir, "README.md"), "test");
729+
await execFileAsync("git", ["add", "."], { cwd: testDir });
730+
await execFileAsync("git", ["commit", "-m", "initial"], { cwd: testDir });
731+
732+
await execFileAsync("git", ["init", "--bare"], { cwd: remoteDir });
733+
await execFileAsync("git", ["remote", "add", "origin", remoteDir], { cwd: testDir });
734+
735+
await execFileAsync("git", ["checkout", "-b", "feature/login"], { cwd: testDir });
736+
await execFileAsync("git", ["push", "-u", "origin", "feature/login"], { cwd: testDir });
737+
await execFileAsync("git", ["checkout", "master"], { cwd: testDir });
738+
739+
const result = await runGitCheckout(testDir, "origin/feature/login");
740+
741+
expect(result).toMatchObject({
742+
success: true,
743+
branch: "feature/login",
744+
});
745+
await expect(
746+
execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: testDir })
747+
).resolves.toMatchObject({
748+
stdout: "feature/login\n",
749+
});
750+
751+
await rm(testDir, { recursive: true });
752+
await rm(remoteDir, { recursive: true });
753+
});
754+
});
755+
719756
describe("getGitHistory", () => {
720757
it("returns recent commits in reverse chronological order with author and timestamp", async () => {
721758
const testDir = await mkdtemp(join(tmpdir(), "git-history-"));

packages/server/src/git/cli.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -568,7 +568,15 @@ export async function runGitCheckout(
568568
if (isRemoteRef && !options?.createBranch) {
569569
const remoteSeparatorIndex = ref.indexOf("/");
570570
const branchName = remoteSeparatorIndex >= 0 ? ref.slice(remoteSeparatorIndex + 1) : ref;
571-
args.push("-b", branchName, ref);
571+
572+
try {
573+
await runGit(cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`]);
574+
const { stdout, stderr } = await runGit(cwd, ["checkout", branchName]);
575+
const message = stdout || stderr || `Checkout to ${branchName} completed`;
576+
return { success: true, message, branch: branchName };
577+
} catch {
578+
args.push("-b", branchName, ref);
579+
}
572580

573581
try {
574582
const { stdout, stderr } = await runGit(cwd, args);
@@ -644,12 +652,29 @@ export async function runGitListBranches(cwd: string): Promise<{
644652
}> {
645653
// Get local branches
646654
const { stdout: localOutput } = await runGit(cwd, ["branch", "--list"]);
655+
const { stdout: localVerboseOutput } = await runGit(cwd, ["branch", "--list", "-vv"]);
647656

648657
// Get remote branches
649658
const { stdout: remoteOutput } = await runGit(cwd, ["branch", "-r"]);
650659

651660
const branches: GitBranch[] = [];
652661
let current = "";
662+
const linkedWorktreePathsByBranch = new Map<string, string>();
663+
664+
const localVerboseLines = localVerboseOutput.split("\n").filter((line) => line.trim());
665+
for (const line of localVerboseLines) {
666+
const normalizedLine = line.replace(/^[*+ ]\s+/, "");
667+
const branchMatch = normalizedLine.match(/^([^\s]+)\s+/);
668+
const worktreeMatch = line.match(/\((.+?)\)\s/);
669+
if (!branchMatch?.[1] || !worktreeMatch?.[1]) {
670+
continue;
671+
}
672+
673+
const worktreePath = worktreeMatch[1];
674+
if (worktreePath.startsWith("/") || worktreePath.startsWith("~")) {
675+
linkedWorktreePathsByBranch.set(branchMatch[1], worktreePath);
676+
}
677+
}
653678

654679
// Parse local branches
655680
const localLines = localOutput.split("\n").filter((line) => line.trim());
@@ -665,10 +690,15 @@ export async function runGitListBranches(cwd: string): Promise<{
665690
continue; // Don't add to branches array
666691
}
667692

693+
if (linkedWorktreePathsByBranch.has(name) && !isCurrent) {
694+
continue;
695+
}
696+
668697
branches.push({
669698
name,
670699
isRemote: false,
671700
isCurrent,
701+
linkedWorktreePath: linkedWorktreePathsByBranch.get(name),
672702
});
673703
if (isCurrent) {
674704
current = name;

packages/web/src/features/workspace/actions/use-git-actions.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -930,6 +930,7 @@ export function useBranchQuickPickActions() {
930930
const quickPickState = useAtomValue(branchQuickPickAtom);
931931
const setQuickPick = useSetAtom(branchQuickPickAtom);
932932
const dispatch = useAtomValue(dispatchCommandAtom);
933+
const pushToast = useSetAtom(pushToastAtom);
933934
const workspaceId = quickPickState.workspaceId;
934935
const branchList = useAtomValue(gitBranchListAtomFamily(workspaceId ?? ""));
935936
const setBranchList = useSetAtom(gitBranchListAtomFamily(workspaceId ?? ""));
@@ -1011,12 +1012,19 @@ export function useBranchQuickPickActions() {
10111012

10121013
const filteredBranches = useMemo(
10131014
() =>
1014-
branchList.branches.filter((branch) =>
1015-
branch.name.toLowerCase().includes(inputValue.toLowerCase())
1015+
branchList.branches.filter(
1016+
(branch) =>
1017+
(branch.isCurrent || !branch.linkedWorktreePath) &&
1018+
branch.name.toLowerCase().includes(inputValue.toLowerCase())
10161019
),
10171020
[branchList.branches, inputValue]
10181021
);
10191022

1023+
const isBranchSelectable = useCallback(
1024+
(branch: GitBranch) => branch.isCurrent || !branch.linkedWorktreePath,
1025+
[]
1026+
);
1027+
10201028
const exactMatch = filteredBranches.find(
10211029
(branch) => branch.name.toLowerCase() === inputValue.toLowerCase()
10221030
);
@@ -1109,14 +1117,20 @@ export function useBranchQuickPickActions() {
11091117
});
11101118

11111119
if (!result.ok || !result.data?.success) {
1112-
console.error("Failed to checkout branch:", result.error?.message ?? result.data?.message);
1120+
const errorMessage = result.error?.message ?? result.data?.message;
1121+
console.error("Failed to checkout branch:", errorMessage);
1122+
pushToast({
1123+
kind: "error",
1124+
title: t("git.quick_pick.checkout_failed_title"),
1125+
body: errorMessage || t("git.quick_pick.checkout_failed_body", { name: branchName }),
1126+
});
11131127
return;
11141128
}
11151129

11161130
await refreshBranchState();
11171131
handleClose();
11181132
},
1119-
[dispatch, handleClose, refreshBranchState, workspaceId]
1133+
[dispatch, handleClose, pushToast, refreshBranchState, t, workspaceId]
11201134
);
11211135

11221136
const handleBranchCreate = useCallback(
@@ -1132,14 +1146,20 @@ export function useBranchQuickPickActions() {
11321146
});
11331147

11341148
if (!result.ok || !result.data?.success) {
1135-
console.error("Failed to create branch:", result.error?.message ?? result.data?.message);
1149+
const errorMessage = result.error?.message ?? result.data?.message;
1150+
console.error("Failed to create branch:", errorMessage);
1151+
pushToast({
1152+
kind: "error",
1153+
title: t("git.quick_pick.create_failed_title"),
1154+
body: errorMessage || t("git.quick_pick.create_failed_body", { name: branchName }),
1155+
});
11361156
return;
11371157
}
11381158

11391159
await refreshBranchState();
11401160
handleClose();
11411161
},
1142-
[dispatch, handleClose, refreshBranchState, workspaceId]
1162+
[dispatch, handleClose, pushToast, refreshBranchState, t, workspaceId]
11431163
);
11441164

11451165
return {
@@ -1151,6 +1171,7 @@ export function useBranchQuickPickActions() {
11511171
handleClose,
11521172
handleRequestBranchCreate,
11531173
inputValue,
1174+
isBranchSelectable,
11541175
pendingCreateBranchName,
11551176
quickPickState,
11561177
selectedIndex,

packages/web/src/features/workspace/views/shared/branch-quick-pick.test.tsx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createStore, Provider, useSetAtom } from "jotai";
55
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
66
import { localeAtom } from "../../../../atoms/app-ui";
77
import { wsClientAtom } from "../../../../atoms/connection";
8+
import { toastsAtom } from "../../../notifications/atoms";
89
import { branchQuickPickAtom, gitBranchListAtomFamily } from "../../atoms";
910
import { BranchQuickPick, DesktopBranchQuickPickPopover } from "./branch-quick-pick";
1011

@@ -198,6 +199,28 @@ describe("BranchQuickPick", () => {
198199
expect(screen.getByText("Current Branch")).toBeInTheDocument();
199200
});
200201

202+
it("hides branches that are already checked out in another worktree on mobile", () => {
203+
viewportMocks.viewport = "mobile";
204+
store.set(gitBranchListAtomFamily("ws-test"), {
205+
current: "develop",
206+
branches: [
207+
{
208+
name: "chore/e2e-specs-reorg",
209+
isCurrent: false,
210+
isRemote: false,
211+
linkedWorktreePath: "/tmp/e2e-specs-reorg",
212+
},
213+
{ name: "develop", isCurrent: true, isRemote: false },
214+
],
215+
loading: false,
216+
});
217+
218+
renderQuickPick();
219+
220+
expect(screen.queryByRole("button", { name: "chore/e2e-specs-reorg" })).not.toBeInTheDocument();
221+
expect(screen.getByRole("button", { name: "develop" })).toBeInTheDocument();
222+
});
223+
201224
it("does not keep the current branch selected when mobile focus moves to create branch", async () => {
202225
viewportMocks.viewport = "mobile";
203226
store.set(branchQuickPickAtom, {
@@ -307,6 +330,50 @@ describe("BranchQuickPick", () => {
307330
});
308331
});
309332

333+
it("shows an error toast and keeps the picker open when checkout fails", async () => {
334+
viewportMocks.viewport = "mobile";
335+
sendCommandMock.mockImplementation(async (op: string) => {
336+
if (op === "git.checkout") {
337+
return {
338+
success: false,
339+
message: "Branch is already used by another worktree",
340+
};
341+
}
342+
343+
if (op === "git.branches") {
344+
return {
345+
current: "main",
346+
branches: [
347+
{ name: "main", isCurrent: true, isRemote: false },
348+
{ name: "feature/auth", isCurrent: false, isRemote: false },
349+
],
350+
};
351+
}
352+
353+
if (op === "git.status") {
354+
return gitStatus;
355+
}
356+
357+
return undefined;
358+
});
359+
360+
renderQuickPick();
361+
362+
fireEvent.click(screen.getByRole("button", { name: "feature/auth" }));
363+
364+
await waitFor(() => {
365+
expect(store.get(toastsAtom)).toEqual(
366+
expect.arrayContaining([
367+
expect.objectContaining({
368+
kind: "error",
369+
body: "Branch is already used by another worktree",
370+
}),
371+
])
372+
);
373+
});
374+
expect(store.get(branchQuickPickAtom).visible).toBe(true);
375+
});
376+
310377
it("requires confirmation before creating a new branch on Enter", async () => {
311378
renderQuickPick();
312379

0 commit comments

Comments
 (0)