| author | |
| committer | |
| log | c626892adf57042e0a69a6178853cc4e6c8fb6ed |
| tree | 0dc792a7c326a97f9bc07abaffa07e0a47969ba1 |
| parent | 3c66cdf2fe2541b96077a4c6cc48bf60617baf0b |
- Deduplicate branch list by removing remote refs that match an existing local branch
- Move remote-ref local-name derivation into shared BranchToolbar logic
- Add logic tests for remote-ref parsing and deduplication behavior3 files changed, 102 insertions(+), 11 deletions(-)
apps/web/src/components/BranchToolbar.logic.test.ts+71-1| ... | @@ -1,6 +1,10 @@ | ... | @@ -1,6 +1,10 @@ |
| 1 | import { ThreadId, type GitBranch } from "@t3tools/contracts"; | 1 | import { ThreadId, type GitBranch } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vitest"; | 2 | import { describe, expect, it } from "vitest"; |
| 3 | import { deriveSyncedLocalBranch } from "./BranchToolbar.logic"; | 3 | import { |
| 4 | dedupeRemoteBranchesWithLocalMatches, | ||
| 5 | deriveLocalBranchNameFromRemoteRef, | ||
| 6 | deriveSyncedLocalBranch, | ||
| 7 | } from "./BranchToolbar.logic"; | ||
| 4 | 8 | ||
| 5 | const branches: GitBranch[] = [ | 9 | const branches: GitBranch[] = [ |
| 6 | { | 10 | { |
| ... | @@ -57,3 +61,69 @@ describe("deriveSyncedLocalBranch", () => { | ... | @@ -57,3 +61,69 @@ describe("deriveSyncedLocalBranch", () => { |
| 57 | expect(result).toBeNull(); | 61 | expect(result).toBeNull(); |
| 58 | }); | 62 | }); |
| 59 | }); | 63 | }); |
| 64 | |||
| 65 | describe("deriveLocalBranchNameFromRemoteRef", () => { | ||
| 66 | it("strips the remote prefix from a remote ref", () => { | ||
| 67 | expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo"); | ||
| 68 | }); | ||
| 69 | |||
| 70 | it("returns the original name when ref is malformed", () => { | ||
| 71 | expect(deriveLocalBranchNameFromRemoteRef("origin/")).toBe("origin/"); | ||
| 72 | expect(deriveLocalBranchNameFromRemoteRef("/feature/demo")).toBe("/feature/demo"); | ||
| 73 | }); | ||
| 74 | }); | ||
| 75 | |||
| 76 | describe("dedupeRemoteBranchesWithLocalMatches", () => { | ||
| 77 | it("hides remote refs when the matching local branch exists", () => { | ||
| 78 | const input: GitBranch[] = [ | ||
| 79 | { | ||
| 80 | name: "feature/demo", | ||
| 81 | current: false, | ||
| 82 | isDefault: false, | ||
| 83 | worktreePath: null, | ||
| 84 | }, | ||
| 85 | { | ||
| 86 | name: "origin/feature/demo", | ||
| 87 | isRemote: true, | ||
| 88 | current: false, | ||
| 89 | isDefault: false, | ||
| 90 | worktreePath: null, | ||
| 91 | }, | ||
| 92 | { | ||
| 93 | name: "origin/feature/remote-only", | ||
| 94 | isRemote: true, | ||
| 95 | current: false, | ||
| 96 | isDefault: false, | ||
| 97 | worktreePath: null, | ||
| 98 | }, | ||
| 99 | ]; | ||
| 100 | |||
| 101 | expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([ | ||
| 102 | "feature/demo", | ||
| 103 | "origin/feature/remote-only", | ||
| 104 | ]); | ||
| 105 | }); | ||
| 106 | |||
| 107 | it("keeps all entries when no local match exists for a remote ref", () => { | ||
| 108 | const input: GitBranch[] = [ | ||
| 109 | { | ||
| 110 | name: "feature/local", | ||
| 111 | current: false, | ||
| 112 | isDefault: false, | ||
| 113 | worktreePath: null, | ||
| 114 | }, | ||
| 115 | { | ||
| 116 | name: "origin/feature/remote-only", | ||
| 117 | isRemote: true, | ||
| 118 | current: false, | ||
| 119 | isDefault: false, | ||
| 120 | worktreePath: null, | ||
| 121 | }, | ||
| 122 | ]; | ||
| 123 | |||
| 124 | expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([ | ||
| 125 | "feature/local", | ||
| 126 | "origin/feature/remote-only", | ||
| 127 | ]); | ||
| 128 | }); | ||
| 129 | }); |
apps/web/src/components/BranchToolbar.logic.ts+25| ... | @@ -8,6 +8,31 @@ interface DeriveSyncedLocalBranchInput { | ... | @@ -8,6 +8,31 @@ interface DeriveSyncedLocalBranchInput { |
| 8 | queryBranches: ReadonlyArray<GitBranch> | undefined; | 8 | queryBranches: ReadonlyArray<GitBranch> | undefined; |
| 9 | } | 9 | } |
| 10 | 10 | ||
| 11 | export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { | ||
| 12 | const separatorIndex = branchName.indexOf("/"); | ||
| 13 | if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) { | ||
| 14 | return branchName; | ||
| 15 | } | ||
| 16 | return branchName.slice(separatorIndex + 1); | ||
| 17 | } | ||
| 18 | |||
| 19 | export function dedupeRemoteBranchesWithLocalMatches( | ||
| 20 | branches: ReadonlyArray<GitBranch>, | ||
| 21 | ): ReadonlyArray<GitBranch> { | ||
| 22 | const localBranchNames = new Set( | ||
| 23 | branches.filter((branch) => !branch.isRemote).map((branch) => branch.name), | ||
| 24 | ); | ||
| 25 | |||
| 26 | return branches.filter((branch) => { | ||
| 27 | if (!branch.isRemote) { | ||
| 28 | return true; | ||
| 29 | } | ||
| 30 | |||
| 31 | const localBranchName = deriveLocalBranchNameFromRemoteRef(branch.name); | ||
| 32 | return !localBranchNames.has(localBranchName); | ||
| 33 | }); | ||
| 34 | } | ||
| 35 | |||
| 11 | export function deriveSyncedLocalBranch({ | 36 | export function deriveSyncedLocalBranch({ |
| 12 | activeThreadId, | 37 | activeThreadId, |
| 13 | activeWorktreePath, | 38 | activeWorktreePath, |
apps/web/src/components/BranchToolbar.tsx+6-10| ... | @@ -10,7 +10,11 @@ import { | ... | @@ -10,7 +10,11 @@ import { |
| 10 | } from "../lib/gitReactQuery"; | 10 | } from "../lib/gitReactQuery"; |
| 11 | import { readNativeApi } from "../nativeApi"; | 11 | import { readNativeApi } from "../nativeApi"; |
| 12 | import { useStore } from "../store"; | 12 | import { useStore } from "../store"; |
| 13 | import { deriveSyncedLocalBranch } from "./BranchToolbar.logic"; | 13 | import { |
| 14 | dedupeRemoteBranchesWithLocalMatches, | ||
| 15 | deriveLocalBranchNameFromRemoteRef, | ||
| 16 | deriveSyncedLocalBranch, | ||
| 17 | } from "./BranchToolbar.logic"; | ||
| 14 | import { Button } from "./ui/button"; | 18 | import { Button } from "./ui/button"; |
| 15 | import { | 19 | import { |
| 16 | Combobox, | 20 | Combobox, |
| ... | @@ -31,14 +35,6 @@ interface BranchToolbarProps { | ... | @@ -31,14 +35,6 @@ interface BranchToolbarProps { |
| 31 | onComposerFocusRequest?: () => void; | 35 | onComposerFocusRequest?: () => void; |
| 32 | } | 36 | } |
| 33 | 37 | ||
| 34 | function deriveLocalBranchNameFromRemoteRef(branchName: string): string { | ||
| 35 | const separatorIndex = branchName.indexOf("/"); | ||
| 36 | if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) { | ||
| 37 | return branchName; | ||
| 38 | } | ||
| 39 | return branchName.slice(separatorIndex + 1); | ||
| 40 | } | ||
| 41 | |||
| 42 | export default function BranchToolbar({ | 38 | export default function BranchToolbar({ |
| 43 | threadId, | 39 | threadId, |
| 44 | envMode, | 40 | envMode, |
| ... | @@ -63,7 +59,7 @@ export default function BranchToolbar({ | ... | @@ -63,7 +59,7 @@ export default function BranchToolbar({ |
| 63 | 59 | ||
| 64 | const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd)); | 60 | const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd)); |
| 65 | 61 | ||
| 66 | const branches = branchesQuery.data?.branches ?? []; | 62 | const branches = dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []); |
| 67 | const branchNames = branches.map((branch) => branch.name); | 63 | const branchNames = branches.map((branch) => branch.name); |
| 68 | const branchByName = new Map(branches.map((branch) => [branch.name, branch])); | 64 | const branchByName = new Map(branches.map((branch) => [branch.name, branch])); |
| 69 | const trimmedBranchQuery = branchQuery.trim(); | 65 | const trimmedBranchQuery = branchQuery.trim(); |