| author | |
| committer | |
| log | 9bb9023009b0c45c66a609c386cd233855043caf |
| tree | 376bff1c6a8a5d9882012fbb79ae35c6207122bc |
| parent | f70cb5de4517e55f62ed2c71e49291d97a7dc927 |
- 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 tests3 files changed, 111 insertions(+), 5 deletions(-)
apps/web/src/components/BranchToolbar.logic.test.ts+71| ... | ... | @@ -4,6 +4,7 @@ import { |
| 4 | 4 | filterBranchPickerItems, |
| 5 | 5 | dedupeRemoteBranchesWithLocalMatches, |
| 6 | 6 | deriveLocalBranchNameFromRemoteRef, |
| 7 | resolveBranchSelectionTarget, | |
| 7 | 8 | resolveDraftEnvModeAfterBranchChange, |
| 8 | 9 | resolveBranchToolbarValue, |
| 9 | 10 | } from "./BranchToolbar.logic"; |
| ... | ... | @@ -223,3 +224,73 @@ describe("filterBranchPickerItems", () => { |
| 223 | 224 | ).toEqual(["feature/488", "__create_new_branch__:48"]); |
| 224 | 225 | }); |
| 225 | 226 | }); |
| 227 | ||
| 228 | describe("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: { |
| 112 | 112 | return itemValue.toLowerCase().includes(normalizedQuery); |
| 113 | 113 | }); |
| 114 | 114 | } |
| 115 | ||
| 116 | export 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 { |
| 27 | 27 | deriveLocalBranchNameFromRemoteRef, |
| 28 | 28 | EnvMode, |
| 29 | 29 | filterBranchPickerItems, |
| 30 | resolveBranchSelectionTarget, | |
| 30 | 31 | resolveBranchToolbarValue, |
| 31 | 32 | } from "./BranchToolbar.logic"; |
| 32 | 33 | import { Button } from "./ui/button"; |
| ... | ... | @@ -175,10 +176,15 @@ export function BranchToolbarBranchSelector({ |
| 175 | 176 | return; |
| 176 | 177 | } |
| 177 | 178 | |
| 179 | const selectionTarget = resolveBranchSelectionTarget({ | |
| 180 | activeProjectCwd, | |
| 181 | activeWorktreePath, | |
| 182 | branch, | |
| 183 | }); | |
| 184 | ||
| 178 | 185 | // 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); | |
| 182 | 188 | setIsBranchMenuOpen(false); |
| 183 | 189 | onComposerFocusRequest?.(); |
| 184 | 190 | return; |
| ... | ... | @@ -194,7 +200,7 @@ export function BranchToolbarBranchSelector({ |
| 194 | 200 | runBranchAction(async () => { |
| 195 | 201 | setOptimisticBranch(selectedBranchName); |
| 196 | 202 | try { |
| 197 | await api.git.checkout({ cwd: branchCwd, branch: branch.name }); | |
| 203 | await api.git.checkout({ cwd: selectionTarget.checkoutCwd, branch: branch.name }); | |
| 198 | 204 | await invalidateGitQueries(queryClient); |
| 199 | 205 | } catch (error) { |
| 200 | 206 | toastManager.add({ |
| ... | ... | @@ -214,7 +220,7 @@ export function BranchToolbarBranchSelector({ |
| 214 | 220 | } |
| 215 | 221 | |
| 216 | 222 | setOptimisticBranch(nextBranchName); |
| 217 | onSetThreadBranch(nextBranchName, activeWorktreePath); | |
| 223 | onSetThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); | |
| 218 | 224 | }); |
| 219 | 225 | }; |
| 220 | 226 |