authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-09 12:03:24-07:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-09 22:50:45-07:00
log9bb9023009b0c45c66a609c386cd233855043caf
tree376bff1c6a8a5d9882012fbb79ae35c6207122bc
parentf70cb5de4517e55f62ed2c71e49291d97a7dc927

Handle branch selection across main and secondary worktrees

- add `resolveBranchSelectionTarget` to unify checkout cwd/worktree decisions - reuse existing worktrees when available, including switching back to main repo - update branch selector to use shared logic and add targeted unit tests

3 files changed, 111 insertions(+), 5 deletions(-)

apps/web/src/components/BranchToolbar.logic.test.ts+71
......@@ -4,6 +4,7 @@ import {
44 filterBranchPickerItems,
55 dedupeRemoteBranchesWithLocalMatches,
66 deriveLocalBranchNameFromRemoteRef,
7 resolveBranchSelectionTarget,
78 resolveDraftEnvModeAfterBranchChange,
89 resolveBranchToolbarValue,
910} from "./BranchToolbar.logic";
......@@ -223,3 +224,73 @@ describe("filterBranchPickerItems", () => {
223224 ).toEqual(["feature/488", "__create_new_branch__:48"]);
224225 });
225226});
227
228describe("resolveBranchSelectionTarget", () => {
229 it("reuses an existing secondary worktree for the selected branch", () => {
230 expect(
231 resolveBranchSelectionTarget({
232 activeProjectCwd: "/repo",
233 activeWorktreePath: "/repo/.t3/worktrees/feature-a",
234 branch: {
235 isDefault: false,
236 worktreePath: "/repo/.t3/worktrees/feature-b",
237 },
238 }),
239 ).toEqual({
240 checkoutCwd: "/repo/.t3/worktrees/feature-b",
241 nextWorktreePath: "/repo/.t3/worktrees/feature-b",
242 reuseExistingWorktree: true,
243 });
244 });
245
246 it("switches back to the main repo when the branch already lives there", () => {
247 expect(
248 resolveBranchSelectionTarget({
249 activeProjectCwd: "/repo",
250 activeWorktreePath: "/repo/.t3/worktrees/feature-a",
251 branch: {
252 isDefault: true,
253 worktreePath: "/repo",
254 },
255 }),
256 ).toEqual({
257 checkoutCwd: "/repo",
258 nextWorktreePath: null,
259 reuseExistingWorktree: true,
260 });
261 });
262
263 it("checks out the default branch in the main repo when leaving a secondary worktree", () => {
264 expect(
265 resolveBranchSelectionTarget({
266 activeProjectCwd: "/repo",
267 activeWorktreePath: "/repo/.t3/worktrees/feature-a",
268 branch: {
269 isDefault: true,
270 worktreePath: null,
271 },
272 }),
273 ).toEqual({
274 checkoutCwd: "/repo",
275 nextWorktreePath: null,
276 reuseExistingWorktree: false,
277 });
278 });
279
280 it("keeps checkout in the current worktree for non-default branches", () => {
281 expect(
282 resolveBranchSelectionTarget({
283 activeProjectCwd: "/repo",
284 activeWorktreePath: "/repo/.t3/worktrees/feature-a",
285 branch: {
286 isDefault: false,
287 worktreePath: null,
288 },
289 }),
290 ).toEqual({
291 checkoutCwd: "/repo/.t3/worktrees/feature-a",
292 nextWorktreePath: "/repo/.t3/worktrees/feature-a",
293 reuseExistingWorktree: false,
294 });
295 });
296});
apps/web/src/components/BranchToolbar.logic.ts+29
......@@ -112,3 +112,32 @@ export function filterBranchPickerItems(input: {
112112 return itemValue.toLowerCase().includes(normalizedQuery);
113113 });
114114}
115
116export function resolveBranchSelectionTarget(input: {
117 activeProjectCwd: string;
118 activeWorktreePath: string | null;
119 branch: Pick<GitBranch, "isDefault" | "worktreePath">;
120}): {
121 checkoutCwd: string;
122 nextWorktreePath: string | null;
123 reuseExistingWorktree: boolean;
124} {
125 const { activeProjectCwd, activeWorktreePath, branch } = input;
126
127 if (branch.worktreePath) {
128 return {
129 checkoutCwd: branch.worktreePath,
130 nextWorktreePath: branch.worktreePath === activeProjectCwd ? null : branch.worktreePath,
131 reuseExistingWorktree: true,
132 };
133 }
134
135 const nextWorktreePath =
136 activeWorktreePath !== null && branch.isDefault ? null : activeWorktreePath;
137
138 return {
139 checkoutCwd: nextWorktreePath ?? activeProjectCwd,
140 nextWorktreePath,
141 reuseExistingWorktree: false,
142 };
143}
apps/web/src/components/BranchToolbarBranchSelector.tsx+11-5
......@@ -27,6 +27,7 @@ import {
2727 deriveLocalBranchNameFromRemoteRef,
2828 EnvMode,
2929 filterBranchPickerItems,
30 resolveBranchSelectionTarget,
3031 resolveBranchToolbarValue,
3132} from "./BranchToolbar.logic";
3233import { Button } from "./ui/button";
......@@ -175,10 +176,15 @@ export function BranchToolbarBranchSelector({
175176 return;
176177 }
177178
179 const selectionTarget = resolveBranchSelectionTarget({
180 activeProjectCwd,
181 activeWorktreePath,
182 branch,
183 });
184
178185 // If the branch already lives in a worktree, point the thread there.
179 if (branch.worktreePath) {
180 const isMainWorktree = branch.worktreePath === activeProjectCwd;
181 onSetThreadBranch(branch.name, isMainWorktree ? null : branch.worktreePath);
186 if (selectionTarget.reuseExistingWorktree) {
187 onSetThreadBranch(branch.name, selectionTarget.nextWorktreePath);
182188 setIsBranchMenuOpen(false);
183189 onComposerFocusRequest?.();
184190 return;
......@@ -194,7 +200,7 @@ export function BranchToolbarBranchSelector({
194200 runBranchAction(async () => {
195201 setOptimisticBranch(selectedBranchName);
196202 try {
197 await api.git.checkout({ cwd: branchCwd, branch: branch.name });
203 await api.git.checkout({ cwd: selectionTarget.checkoutCwd, branch: branch.name });
198204 await invalidateGitQueries(queryClient);
199205 } catch (error) {
200206 toastManager.add({
......@@ -214,7 +220,7 @@ export function BranchToolbarBranchSelector({
214220 }
215221
216222 setOptimisticBranch(nextBranchName);
217 onSetThreadBranch(nextBranchName, activeWorktreePath);
223 onSetThreadBranch(nextBranchName, selectionTarget.nextWorktreePath);
218224 });
219225 };
220226