authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-02 12:38:19-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-02 12:38:19-07:00
log915a0548c0c024d9a51da1041475120dde089c26
tree8fa26fbb0e0e083eedebfbb550d09e2008abc62c
parent5ce5472bc389e178132df40f316014d714f3275d
signature Signed by PGP key B5690EEEBB952194

Paginate git branch listing and search results (#1691)

Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>

18 files changed, 502 insertions(+), 139 deletions(-)

apps/server/src/git/Layers/GitCore.test.ts+71
......@@ -386,6 +386,36 @@ it.layer(TestLayer)("git integration", (it) => {
386386 }),
387387 );
388388
389 it.effect("paginates branch results and returns paging metadata", () =>
390 Effect.gen(function* () {
391 const tmp = yield* makeTmpDir();
392 const { initialBranch } = yield* initRepoWithCommit(tmp);
393 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-a" });
394 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-b" });
395 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-c" });
396
397 const firstPage = yield* (yield* GitCore).listBranches({ cwd: tmp, limit: 2 });
398 expect(firstPage.totalCount).toBe(4);
399 expect(firstPage.nextCursor).toBe(2);
400 expect(firstPage.branches.map((branch) => branch.name)).toEqual([
401 initialBranch,
402 "feature-a",
403 ]);
404
405 const secondPage = yield* (yield* GitCore).listBranches({
406 cwd: tmp,
407 cursor: firstPage.nextCursor ?? 0,
408 limit: 2,
409 });
410 expect(secondPage.totalCount).toBe(4);
411 expect(secondPage.nextCursor).toBeNull();
412 expect(secondPage.branches.map((branch) => branch.name)).toEqual([
413 "feature-b",
414 "feature-c",
415 ]);
416 }),
417 );
418
389419 it.effect("parses separate branch names when column.ui is always enabled", () =>
390420 Effect.gen(function* () {
391421 const tmp = yield* makeTmpDir();
......@@ -531,6 +561,41 @@ it.layer(TestLayer)("git integration", (it) => {
531561 expect(remoteBranch?.remoteName).toBe(remoteName);
532562 }),
533563 );
564
565 it.effect(
566 "filters branch queries before pagination and dedupes origin refs with local matches",
567 () =>
568 Effect.gen(function* () {
569 const remote = yield* makeTmpDir();
570 const tmp = yield* makeTmpDir();
571
572 yield* git(remote, ["init", "--bare"]);
573 const { initialBranch } = yield* initRepoWithCommit(tmp);
574 yield* git(tmp, ["remote", "add", "origin", remote]);
575 yield* git(tmp, ["push", "-u", "origin", initialBranch]);
576
577 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/demo" });
578 yield* git(tmp, ["push", "-u", "origin", "feature/demo"]);
579
580 yield* git(tmp, ["checkout", "-b", "feature/remote-only"]);
581 yield* git(tmp, ["push", "-u", "origin", "feature/remote-only"]);
582 yield* git(tmp, ["checkout", initialBranch]);
583 yield* git(tmp, ["branch", "-D", "feature/remote-only"]);
584
585 const result = yield* (yield* GitCore).listBranches({
586 cwd: tmp,
587 query: "feature/",
588 limit: 10,
589 });
590
591 expect(result.totalCount).toBe(2);
592 expect(result.nextCursor).toBeNull();
593 expect(result.branches.map((branch) => branch.name)).toEqual([
594 "feature/demo",
595 "origin/feature/remote-only",
596 ]);
597 }),
598 );
534599 });
535600
536601 // ── checkoutGitBranch ──
......@@ -737,6 +802,9 @@ it.layer(TestLayer)("git integration", (it) => {
737802 ) {
738803 return ok();
739804 }
805 if (input.operation === "GitCore.statusDetails.defaultRef") {
806 return ok("refs/remotes/origin/main\n");
807 }
740808 return Effect.fail(
741809 new GitCommandError({
742810 operation: input.operation,
......@@ -800,6 +868,9 @@ it.layer(TestLayer)("git integration", (it) => {
800868 ) {
801869 return ok();
802870 }
871 if (input.operation === "GitCore.statusDetails.defaultRef") {
872 return ok("refs/remotes/origin/main\n");
873 }
803874 return Effect.fail(
804875 new GitCommandError({
805876 operation: input.operation,
apps/server/src/git/Layers/GitCore.ts+112-11
......@@ -18,7 +18,8 @@ import {
1818} from "effect";
1919import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2020
21import { GitCommandError } from "@t3tools/contracts";
21import { GitCommandError, type GitBranch } from "@t3tools/contracts";
22import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git";
2223import {
2324 GitCore,
2425 type ExecuteGitProgress,
......@@ -49,6 +50,7 @@ const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
4950const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5);
5051const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
5152const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;
53const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100;
5254
5355type TraceTailState = {
5456 processedChars: number;
......@@ -183,6 +185,40 @@ function parseBranchLine(line: string): { name: string; current: boolean } | nul
183185 };
184186}
185187
188function filterBranchesForListQuery(
189 branches: ReadonlyArray<GitBranch>,
190 query?: string,
191): ReadonlyArray<GitBranch> {
192 if (!query) {
193 return branches;
194 }
195
196 const normalizedQuery = query.toLowerCase();
197 return branches.filter((branch) => branch.name.toLowerCase().includes(normalizedQuery));
198}
199
200function paginateBranches(input: {
201 branches: ReadonlyArray<GitBranch>;
202 cursor?: number | undefined;
203 limit?: number | undefined;
204}): {
205 branches: ReadonlyArray<GitBranch>;
206 nextCursor: number | null;
207 totalCount: number;
208} {
209 const cursor = input.cursor ?? 0;
210 const limit = input.limit ?? GIT_LIST_BRANCHES_DEFAULT_LIMIT;
211 const totalCount = input.branches.length;
212 const branches = input.branches.slice(cursor, cursor + limit);
213 const nextCursor = cursor + branches.length < totalCount ? cursor + branches.length : null;
214
215 return {
216 branches,
217 nextCursor,
218 totalCount,
219 };
220}
221
186222function sanitizeRemoteName(value: string): string {
187223 const sanitized = value
188224 .trim()
......@@ -1101,15 +1137,52 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
11011137 const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) {
11021138 yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true }));
11031139
1104 const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all(
1105 [
1106 runGitStdout("GitCore.statusDetails.status", cwd, ["status", "--porcelain=2", "--branch"]),
1107 runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]),
1108 runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, ["diff", "--cached", "--numstat"]),
1109 ],
1110 { concurrency: "unbounded" },
1140 const statusResult = yield* executeGit(
1141 "GitCore.statusDetails.status",
1142 cwd,
1143 ["status", "--porcelain=2", "--branch"],
1144 {
1145 allowNonZeroExit: true,
1146 },
11111147 );
11121148
1149 if (statusResult.code !== 0) {
1150 const stderr = statusResult.stderr.trim();
1151 return yield* createGitCommandError(
1152 "GitCore.statusDetails.status",
1153 cwd,
1154 ["status", "--porcelain=2", "--branch"],
1155 stderr || "git status failed",
1156 );
1157 }
1158
1159 const [unstagedNumstatStdout, stagedNumstatStdout, defaultRefResult, hasOriginRemote] =
1160 yield* Effect.all(
1161 [
1162 runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]),
1163 runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, [
1164 "diff",
1165 "--cached",
1166 "--numstat",
1167 ]),
1168 executeGit(
1169 "GitCore.statusDetails.defaultRef",
1170 cwd,
1171 ["symbolic-ref", "refs/remotes/origin/HEAD"],
1172 {
1173 allowNonZeroExit: true,
1174 },
1175 ),
1176 originRemoteExists(cwd).pipe(Effect.catch(() => Effect.succeed(false))),
1177 ],
1178 { concurrency: "unbounded" },
1179 );
1180 const statusStdout = statusResult.stdout;
1181 const defaultBranch =
1182 defaultRefResult.code === 0
1183 ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
1184 : null;
1185
11131186 let branch: string | null = null;
11141187 let upstreamRef: string | null = null;
11151188 let aheadCount = 0;
......@@ -1176,6 +1249,12 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
11761249 files.sort((a, b) => a.path.localeCompare(b.path));
11771250
11781251 return {
1252 isRepo: true,
1253 hasOriginRemote,
1254 isDefaultBranch:
1255 branch !== null &&
1256 (branch === defaultBranch ||
1257 (defaultBranch === null && (branch === "main" || branch === "master"))),
11791258 branch,
11801259 upstreamRef,
11811260 hasWorkingTreeChanges,
......@@ -1193,6 +1272,9 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
11931272 const status: GitCoreShape["status"] = (input) =>
11941273 statusDetails(input.cwd).pipe(
11951274 Effect.map((details) => ({
1275 isRepo: details.isRepo,
1276 hasOriginRemote: details.hasOriginRemote,
1277 isDefaultBranch: details.isDefaultBranch,
11961278 branch: details.branch,
11971279 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
11981280 workingTree: details.workingTree,
......@@ -1571,7 +1653,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
15711653 if (localBranchResult.code !== 0) {
15721654 const stderr = localBranchResult.stderr.trim();
15731655 if (stderr.toLowerCase().includes("not a git repository")) {
1574 return { branches: [], isRepo: false, hasOriginRemote: false };
1656 return {
1657 branches: [],
1658 isRepo: false,
1659 hasOriginRemote: false,
1660 nextCursor: null,
1661 totalCount: 0,
1662 };
15751663 }
15761664 return yield* createGitCommandError(
15771665 "GitCore.listBranches",
......@@ -1735,9 +1823,22 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
17351823 })
17361824 : [];
17371825
1738 const branches = [...localBranches, ...remoteBranches];
1826 const branches = paginateBranches({
1827 branches: filterBranchesForListQuery(
1828 dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]),
1829 input.query,
1830 ),
1831 cursor: input.cursor,
1832 limit: input.limit,
1833 });
17391834
1740 return { branches, isRepo: true, hasOriginRemote: remoteNames.includes("origin") };
1835 return {
1836 branches: [...branches.branches],
1837 isRepo: true,
1838 hasOriginRemote: remoteNames.includes("origin"),
1839 nextCursor: branches.nextCursor,
1840 totalCount: branches.totalCount,
1841 };
17411842 });
17421843
17431844 const createWorktree: GitCoreShape["createWorktree"] = Effect.fn("createWorktree")(
apps/server/src/git/Layers/GitManager.test.ts+29
......@@ -660,6 +660,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
660660 });
661661
662662 const status = yield* manager.status({ cwd: repoDir });
663 expect(status.isRepo).toBe(true);
664 expect(status.hasOriginRemote).toBe(true);
665 expect(status.isDefaultBranch).toBe(false);
663666 expect(status.branch).toBe("feature/status-open-pr");
664667 expect(status.pr).toEqual({
665668 number: 13,
......@@ -672,6 +675,32 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
672675 }),
673676 );
674677
678 it.effect("status returns an explicit non-repo result for non-git directories", () =>
679 Effect.gen(function* () {
680 const cwd = yield* makeTempDir("t3code-git-manager-non-repo-");
681 const { manager } = yield* makeManager();
682
683 const status = yield* manager.status({ cwd });
684
685 expect(status).toEqual({
686 isRepo: false,
687 hasOriginRemote: false,
688 isDefaultBranch: false,
689 branch: null,
690 hasWorkingTreeChanges: false,
691 workingTree: {
692 files: [],
693 insertions: 0,
694 deletions: 0,
695 },
696 hasUpstream: false,
697 aheadCount: 0,
698 behindCount: 0,
699 pr: null,
700 });
701 }),
702 );
703
675704 it.effect("status briefly caches repeated lookups for the same cwd", () =>
676705 Effect.gen(function* () {
677706 const repoDir = yield* makeTempDir("t3code-git-manager-");
apps/server/src/git/Layers/GitManager.ts+27-14
......@@ -5,6 +5,7 @@ import { Cache, Duration, Effect, Exit, FileSystem, Layer, Option, Path, Ref } f
55import {
66 GitActionProgressEvent,
77 GitActionProgressPhase,
8 GitCommandError,
89 GitRunStackedActionResult,
910 GitStackedAction,
1011 ModelSelection,
......@@ -22,7 +23,7 @@ import {
2223 type GitManagerShape,
2324 type GitRunStackedActionOptions,
2425} from "../Services/GitManager.ts";
25import { GitCore } from "../Services/GitCore.ts";
26import { GitCore, GitStatusDetails } from "../Services/GitCore.ts";
2627import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts";
2728import { TextGeneration } from "../Services/TextGeneration.ts";
2829import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts";
......@@ -39,6 +40,10 @@ type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "act
3940type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>;
4041type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect<void, never>;
4142
43function isNotGitRepositoryError(error: GitCommandError): boolean {
44 return error.message.toLowerCase().includes("not a git repository");
45}
46
4247interface OpenPrInfo {
4348 number: number;
4449 title: string;
......@@ -689,10 +694,25 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
689694 const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp";
690695 const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd);
691696 const readStatus = Effect.fn("readStatus")(function* (cwd: string) {
692 const details = yield* gitCore.statusDetails(cwd);
697 const details = yield* gitCore.statusDetails(cwd).pipe(
698 Effect.catchIf(isNotGitRepositoryError, () =>
699 Effect.succeed({
700 isRepo: false,
701 hasOriginRemote: false,
702 isDefaultBranch: false,
703 branch: null,
704 upstreamRef: null,
705 hasWorkingTreeChanges: false,
706 workingTree: { files: [], insertions: 0, deletions: 0 },
707 hasUpstream: false,
708 aheadCount: 0,
709 behindCount: 0,
710 } satisfies GitStatusDetails),
711 ),
712 );
693713
694714 const pr =
695 details.branch !== null
715 details.isRepo && details.branch !== null
696716 ? yield* findLatestPr(cwd, {
697717 branch: details.branch,
698718 upstreamRef: details.upstreamRef,
......@@ -703,6 +723,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
703723 : null;
704724
705725 return {
726 isRepo: details.isRepo,
727 hasOriginRemote: details.hasOriginRemote,
728 isDefaultBranch: details.isDefaultBranch,
706729 branch: details.branch,
707730 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
708731 workingTree: details.workingTree,
......@@ -909,12 +932,6 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
909932 return parsed[0] ?? null;
910933 });
911934
912 const isDefaultBranch = Effect.fn("isDefaultBranch")(function* (cwd: string, branch: string) {
913 const branches = yield* gitCore.listBranches({ cwd });
914 const currentBranch = branches.branches.find((candidate) => candidate.name === branch);
915 return currentBranch?.isDefault ?? (branch === "main" || branch === "master");
916 });
917
918935 const buildCompletionToast = Effect.fn("buildCompletionToast")(function* (
919936 cwd: string,
920937 result: Pick<GitRunStackedActionResult, "action" | "branch" | "commit" | "push" | "pr">,
......@@ -936,11 +953,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
936953 upstreamRef: finalStatus.upstreamRef,
937954 hasUpstream: finalStatus.hasUpstream,
938955 };
939 currentBranchIsDefault = yield* isDefaultBranch(cwd, finalStatus.branch).pipe(
940 Effect.catch(() =>
941 Effect.succeed(finalStatus.branch === "main" || finalStatus.branch === "master"),
942 ),
943 );
956 currentBranchIsDefault = finalStatus.isDefaultBranch;
944957 }
945958 }
946959
apps/server/src/server.test.ts+5
......@@ -822,6 +822,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
822822 gitManager: {
823823 status: () =>
824824 Effect.succeed({
825 isRepo: true,
826 hasOriginRemote: true,
827 isDefaultBranch: true,
825828 branch: "main",
826829 hasWorkingTreeChanges: false,
827830 workingTree: { files: [], insertions: 0, deletions: 0 },
......@@ -922,6 +925,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
922925 ],
923926 isRepo: true,
924927 hasOriginRemote: true,
928 nextCursor: null,
929 totalCount: 1,
925930 }),
926931 createWorktree: () =>
927932 Effect.succeed({
apps/web/src/components/BranchToolbar.logic.ts+4-52
......@@ -1,5 +1,9 @@
11import type { GitBranch } from "@t3tools/contracts";
22import { Schema } from "effect";
3export {
4 dedupeRemoteBranchesWithLocalMatches,
5 deriveLocalBranchNameFromRemoteRef,
6} from "@t3tools/shared/git";
37
48export const EnvMode = Schema.Literals(["local", "worktree"]);
59export type EnvMode = typeof EnvMode.Type;
......@@ -43,58 +47,6 @@ export function resolveBranchToolbarValue(input: {
4347 return currentGitBranch ?? activeThreadBranch;
4448}
4549
46export function deriveLocalBranchNameFromRemoteRef(branchName: string): string {
47 const firstSeparatorIndex = branchName.indexOf("/");
48 if (firstSeparatorIndex <= 0 || firstSeparatorIndex === branchName.length - 1) {
49 return branchName;
50 }
51 return branchName.slice(firstSeparatorIndex + 1);
52}
53
54function deriveLocalBranchNameCandidatesFromRemoteRef(
55 branchName: string,
56 remoteName?: string,
57): ReadonlyArray<string> {
58 const candidates = new Set<string>();
59 const firstSlashCandidate = deriveLocalBranchNameFromRemoteRef(branchName);
60 if (firstSlashCandidate.length > 0) {
61 candidates.add(firstSlashCandidate);
62 }
63
64 if (remoteName) {
65 const remotePrefix = `${remoteName}/`;
66 if (branchName.startsWith(remotePrefix) && branchName.length > remotePrefix.length) {
67 candidates.add(branchName.slice(remotePrefix.length));
68 }
69 }
70
71 return [...candidates];
72}
73
74export function dedupeRemoteBranchesWithLocalMatches(
75 branches: ReadonlyArray<GitBranch>,
76): ReadonlyArray<GitBranch> {
77 const localBranchNames = new Set(
78 branches.filter((branch) => !branch.isRemote).map((branch) => branch.name),
79 );
80
81 return branches.filter((branch) => {
82 if (!branch.isRemote) {
83 return true;
84 }
85
86 if (branch.remoteName !== "origin") {
87 return true;
88 }
89
90 const localBranchCandidates = deriveLocalBranchNameCandidatesFromRemoteRef(
91 branch.name,
92 branch.remoteName,
93 );
94 return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate));
95 });
96}
97
9850export function resolveBranchSelectionTarget(input: {
9951 activeProjectCwd: string;
10052 activeWorktreePath: string | null;
apps/web/src/components/BranchToolbarBranchSelector.tsx+86-10
......@@ -1,5 +1,5 @@
11import type { GitBranch } from "@t3tools/contracts";
2import { useQuery, useQueryClient } from "@tanstack/react-query";
2import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
33import { useVirtualizer } from "@tanstack/react-virtual";
44import { ChevronDownIcon } from "lucide-react";
55import {
......@@ -15,7 +15,7 @@ import {
1515} from "react";
1616
1717import {
18 gitBranchesQueryOptions,
18 gitBranchSearchInfiniteQueryOptions,
1919 gitQueryKeys,
2020 gitStatusQueryOptions,
2121 invalidateGitQueries,
......@@ -23,7 +23,6 @@ import {
2323import { readNativeApi } from "../nativeApi";
2424import { parsePullRequestReference } from "../pullRequestReference";
2525import {
26 dedupeRemoteBranchesWithLocalMatches,
2726 deriveLocalBranchNameFromRemoteRef,
2827 EnvMode,
2928 resolveBranchSelectionTarget,
......@@ -38,6 +37,7 @@ import {
3837 ComboboxItem,
3938 ComboboxList,
4039 ComboboxPopup,
40 ComboboxStatus,
4141 ComboboxTrigger,
4242} from "./ui/combobox";
4343import { toastManager } from "./ui/toast";
......@@ -89,11 +89,33 @@ export function BranchToolbarBranchSelector({
8989 const [branchQuery, setBranchQuery] = useState("");
9090 const deferredBranchQuery = useDeferredValue(branchQuery);
9191
92 const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd));
9392 const branchStatusQuery = useQuery(gitStatusQueryOptions(branchCwd));
93 const trimmedBranchQuery = branchQuery.trim();
94 const deferredTrimmedBranchQuery = deferredBranchQuery.trim();
95
96 useEffect(() => {
97 if (!branchCwd) return;
98 void queryClient.prefetchInfiniteQuery(
99 gitBranchSearchInfiniteQueryOptions({ cwd: branchCwd, query: "" }),
100 );
101 }, [branchCwd, queryClient]);
102
103 const {
104 data: branchesSearchData,
105 fetchNextPage,
106 hasNextPage,
107 isFetchingNextPage,
108 isPending: isBranchesSearchPending,
109 } = useInfiniteQuery(
110 gitBranchSearchInfiniteQueryOptions({
111 cwd: branchCwd,
112 query: deferredTrimmedBranchQuery,
113 enabled: isBranchMenuOpen,
114 }),
115 );
94116 const branches = useMemo(
95 () => dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []),
96 [branchesQuery.data?.branches],
117 () => branchesSearchData?.pages.flatMap((page) => page.branches) ?? [],
118 [branchesSearchData?.pages],
97119 );
98120 const currentGitBranch =
99121 branchStatusQuery.data?.branch ?? branches.find((branch) => branch.current)?.name ?? null;
......@@ -108,8 +130,6 @@ export function BranchToolbarBranchSelector({
108130 () => new Map(branches.map((branch) => [branch.name, branch] as const)),
109131 [branches],
110132 );
111 const trimmedBranchQuery = branchQuery.trim();
112 const deferredTrimmedBranchQuery = deferredBranchQuery.trim();
113133 const normalizedDeferredBranchQuery = deferredTrimmedBranchQuery.toLowerCase();
114134 const prReference = parsePullRequestReference(trimmedBranchQuery);
115135 const isSelectingWorktreeBase =
......@@ -156,6 +176,14 @@ export function BranchToolbarBranchSelector({
156176 );
157177 const [isBranchActionPending, startBranchActionTransition] = useTransition();
158178 const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40;
179 const totalBranchCount = branchesSearchData?.pages[0]?.totalCount ?? 0;
180 const branchStatusText = isBranchesSearchPending
181 ? "Loading branches..."
182 : isFetchingNextPage
183 ? "Loading more branches..."
184 : hasNextPage
185 ? `Showing ${branches.length} of ${totalBranchCount} branches`
186 : null;
159187
160188 const runBranchAction = (action: () => Promise<void>) => {
161189 startBranchActionTransition(async () => {
......@@ -213,7 +241,7 @@ export function BranchToolbarBranchSelector({
213241
214242 let nextBranchName = selectedBranchName;
215243 if (branch.isRemote) {
216 const status = await api.git.status({ cwd: branchCwd }).catch(() => null);
244 const status = await api.git.status({ cwd: selectionTarget.checkoutCwd }).catch(() => null);
217245 if (status?.branch) {
218246 nextBranchName = status.branch;
219247 }
......@@ -295,6 +323,24 @@ export function BranchToolbarBranchSelector({
295323 );
296324
297325 const branchListScrollElementRef = useRef<HTMLDivElement | null>(null);
326 const maybeFetchNextBranchPage = useCallback(() => {
327 if (!isBranchMenuOpen || !hasNextPage || isFetchingNextPage) {
328 return;
329 }
330
331 const scrollElement = branchListScrollElementRef.current;
332 if (!scrollElement) {
333 return;
334 }
335
336 const distanceFromBottom =
337 scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight;
338 if (distanceFromBottom > 96) {
339 return;
340 }
341
342 void fetchNextPage().catch(() => undefined);
343 }, [fetchNextPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]);
298344 const branchListVirtualizer = useVirtualizer({
299345 count: filteredBranchPickerItems.length,
300346 estimateSize: (index) =>
......@@ -331,6 +377,35 @@ export function BranchToolbarBranchSelector({
331377 shouldVirtualizeBranchList,
332378 ]);
333379
380 useEffect(() => {
381 if (!isBranchMenuOpen) {
382 return;
383 }
384
385 branchListScrollElementRef.current?.scrollTo({ top: 0 });
386 }, [deferredTrimmedBranchQuery, isBranchMenuOpen]);
387
388 useEffect(() => {
389 const scrollElement = branchListScrollElementRef.current;
390 if (!scrollElement || !isBranchMenuOpen) {
391 return;
392 }
393
394 const handleScroll = () => {
395 maybeFetchNextBranchPage();
396 };
397
398 scrollElement.addEventListener("scroll", handleScroll, { passive: true });
399 handleScroll();
400 return () => {
401 scrollElement.removeEventListener("scroll", handleScroll);
402 };
403 }, [isBranchMenuOpen, maybeFetchNextBranchPage]);
404
405 useEffect(() => {
406 maybeFetchNextBranchPage();
407 }, [branches.length, maybeFetchNextBranchPage]);
408
334409 const triggerLabel = getBranchTriggerLabel({
335410 activeWorktreePath,
336411 effectiveEnvMode,
......@@ -425,7 +500,7 @@ export function BranchToolbarBranchSelector({
425500 <ComboboxTrigger
426501 render={<Button variant="ghost" size="xs" />}
427502 className="text-muted-foreground/70 hover:text-foreground/80"
428 disabled={(branchesQuery.isLoading && branches.length === 0) || isBranchActionPending}
503 disabled={(isBranchesSearchPending && branches.length === 0) || isBranchActionPending}
429504 >
430505 <span className="max-w-[240px] truncate">{triggerLabel}</span>
431506 <ChevronDownIcon />
......@@ -468,6 +543,7 @@ export function BranchToolbarBranchSelector({
468543 filteredBranchPickerItems.map((itemValue, index) => renderPickerItem(itemValue, index))
469544 )}
470545 </ComboboxList>
546 {branchStatusText ? <ComboboxStatus>{branchStatusText}</ComboboxStatus> : null}
471547 </ComboboxPopup>
472548 </Combobox>
473549 );
apps/web/src/components/ChatView.browser.tsx+5
......@@ -632,6 +632,8 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown {
632632 return {
633633 isRepo: true,
634634 hasOriginRemote: true,
635 nextCursor: null,
636 totalCount: 1,
635637 branches: [
636638 {
637639 name: "main",
......@@ -644,6 +646,9 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown {
644646 }
645647 if (tag === WS_METHODS.gitStatus) {
646648 return {
649 isRepo: true,
650 hasOriginRemote: true,
651 isDefaultBranch: true,
647652 branch: "main",
648653 hasWorkingTreeChanges: false,
649654 workingTree: {
apps/web/src/components/ChatView.tsx+3-3
......@@ -26,7 +26,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
2626import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2727import { useDebouncedValue } from "@tanstack/react-pacer";
2828import { useNavigate, useSearch } from "@tanstack/react-router";
29import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
29import { gitCreateWorktreeMutationOptions, gitStatusQueryOptions } from "~/lib/gitReactQuery";
3030import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
3131import { isElectron } from "../env";
3232import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
......@@ -1201,7 +1201,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
12011201 (debouncerState) => ({ isPending: debouncerState.isPending }),
12021202 );
12031203 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
1204 const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd));
1204 const gitStatusQuery = useQuery(gitStatusQueryOptions(gitCwd));
12051205 const keybindings = useServerKeybindings();
12061206 const availableEditors = useServerAvailableEditors();
12071207 const modelOptionsByProvider = useMemo(
......@@ -1338,7 +1338,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
13381338 });
13391339 }, [activeProjectCwd, activeThreadWorktreePath]);
13401340 // Default true while loading to avoid toolbar flicker.
1341 const isGitRepo = branchesQuery.data?.isRepo ?? true;
1341 const isGitRepo = gitStatusQuery.data?.isRepo ?? true;
13421342 const terminalShortcutLabelOptions = useMemo(
13431343 () => ({
13441344 context: {
apps/web/src/components/DiffPanel.tsx+3-3
......@@ -19,7 +19,7 @@ import {
1919 useState,
2020} from "react";
2121import { openInPreferredEditor } from "../editorPreferences";
22import { gitBranchesQueryOptions } from "~/lib/gitReactQuery";
22import { gitStatusQueryOptions } from "~/lib/gitReactQuery";
2323import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
2424import { cn } from "~/lib/utils";
2525import { readNativeApi } from "../nativeApi";
......@@ -189,8 +189,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
189189 activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined,
190190 );
191191 const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd;
192 const gitBranchesQuery = useQuery(gitBranchesQueryOptions(activeCwd ?? null));
193 const isGitRepo = gitBranchesQuery.data?.isRepo ?? true;
192 const gitStatusQuery = useQuery(gitStatusQueryOptions(activeCwd ?? null));
193 const isGitRepo = gitStatusQuery.data?.isRepo ?? true;
194194 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
195195 useTurnDiffSummaries(activeThread);
196196 const orderedTurnDiffSummaries = useMemo(
apps/web/src/components/GitActionsControl.logic.test.ts+3
......@@ -13,6 +13,9 @@ import {
1313
1414function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult {
1515 return {
16 isRepo: true,
17 hasOriginRemote: true,
18 isDefaultBranch: false,
1619 branch: "feature/test",
1720 hasWorkingTreeChanges: false,
1821 workingTree: {
apps/web/src/components/GitActionsControl.tsx+5-26
......@@ -41,14 +41,12 @@ import { Textarea } from "~/components/ui/textarea";
4141import { toastManager, type ThreadToastData } from "~/components/ui/toast";
4242import { openInPreferredEditor } from "~/editorPreferences";
4343import {
44 gitBranchesQueryOptions,
4544 gitInitMutationOptions,
4645 gitMutationKeys,
4746 gitPullMutationOptions,
4847 gitRunStackedActionMutationOptions,
4948 gitStatusQueryOptions,
5049 invalidateGitStatusQuery,
51 invalidateGitQueries,
5250} from "~/lib/gitReactQuery";
5351import { newCommandId, randomUUID } from "~/lib/utils";
5452import { resolvePathLinkTarget } from "~/terminal-links";
......@@ -278,21 +276,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
278276 );
279277
280278 const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd));
281
282 const { data: branchList = null } = useQuery(gitBranchesQueryOptions(gitCwd));
283279 // Default to true while loading so we don't flash init controls.
284 const isRepo = branchList?.isRepo ?? true;
285 const hasOriginRemote = branchList?.hasOriginRemote ?? false;
286 const currentBranch = branchList?.branches.find((branch) => branch.current)?.name ?? null;
287 const isGitStatusOutOfSync =
288 !!gitStatus?.branch && !!currentBranch && gitStatus.branch !== currentBranch;
289
290 useEffect(() => {
291 if (!isGitStatusOutOfSync) return;
292 void invalidateGitQueries(queryClient, { cwd: gitCwd });
293 }, [gitCwd, isGitStatusOutOfSync, queryClient]);
294
295 const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus;
280 const isRepo = gitStatus?.isRepo ?? true;
281 const hasOriginRemote = gitStatus?.hasOriginRemote ?? false;
282 const gitStatusForActions = gitStatus;
296283
297284 const allFiles = gitStatusForActions?.workingTree.files ?? [];
298285 const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path));
......@@ -336,11 +323,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
336323 ]);
337324
338325 const isDefaultBranch = useMemo(() => {
339 const branchName = gitStatusForActions?.branch;
340 if (!branchName) return false;
341 const current = branchList?.branches.find((branch) => branch.name === branchName);
342 return current?.isDefault ?? (branchName === "main" || branchName === "master");
343 }, [branchList?.branches, gitStatusForActions?.branch]);
326 return gitStatusForActions?.isDefaultBranch ?? false;
327 }, [gitStatusForActions?.isDefaultBranch]);
344328
345329 const gitActionMenuItems = useMemo(
346330 () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasOriginRemote),
......@@ -881,11 +865,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
881865 Behind upstream. Pull/rebase first.
882866 </p>
883867 )}
884 {isGitStatusOutOfSync && (
885 <p className="px-2 py-1.5 text-xs text-muted-foreground">
886 Refreshing git status...
887 </p>
888 )}
889868 {gitStatusError && (
890869 <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError.message}</p>
891870 )}
apps/web/src/components/KeybindingsToast.browser.tsx+5
......@@ -157,11 +157,16 @@ function resolveWsRpc(tag: string): unknown {
157157 return {
158158 isRepo: true,
159159 hasOriginRemote: true,
160 nextCursor: null,
161 totalCount: 1,
160162 branches: [{ name: "main", current: true, isDefault: true, worktreePath: null }],
161163 };
162164 }
163165 if (tag === WS_METHODS.gitStatus) {
164166 return {
167 isRepo: true,
168 hasOriginRemote: true,
169 isDefaultBranch: true,
165170 branch: "main",
166171 hasWorkingTreeChanges: false,
167172 workingTree: { files: [], insertions: 0, deletions: 0 },
apps/web/src/lib/gitReactQuery.test.ts+43-9
......@@ -9,8 +9,11 @@ vi.mock("../wsRpcClient", () => ({
99 getWsRpcClient: vi.fn(),
1010}));
1111
12import type { InfiniteData } from "@tanstack/react-query";
13import type { GitListBranchesResult } from "@t3tools/contracts";
14
1215import {
13 gitBranchesQueryOptions,
16 gitBranchSearchInfiniteQueryOptions,
1417 gitMutationKeys,
1518 gitQueryKeys,
1619 gitPreparePullRequestThreadMutationOptions,
......@@ -21,6 +24,19 @@ import {
2124 invalidateGitQueries,
2225} from "./gitReactQuery";
2326
27const BRANCH_QUERY_RESULT: GitListBranchesResult = {
28 branches: [],
29 isRepo: true,
30 hasOriginRemote: true,
31 nextCursor: null,
32 totalCount: 0,
33};
34
35const BRANCH_SEARCH_RESULT: InfiniteData<GitListBranchesResult, number> = {
36 pages: [BRANCH_QUERY_RESULT],
37 pageParams: [0],
38};
39
2440describe("gitMutationKeys", () => {
2541 it("scopes stacked action keys by cwd", () => {
2642 expect(gitMutationKeys.runStackedAction("/repo/a")).not.toEqual(
......@@ -69,9 +85,21 @@ describe("invalidateGitQueries", () => {
6985 const queryClient = new QueryClient();
7086
7187 queryClient.setQueryData(gitQueryKeys.status("/repo/a"), { ok: "a" });
72 queryClient.setQueryData(gitQueryKeys.branches("/repo/a"), { ok: "a-branches" });
88 queryClient.setQueryData(
89 gitBranchSearchInfiniteQueryOptions({
90 cwd: "/repo/a",
91 query: "feature",
92 }).queryKey,
93 BRANCH_SEARCH_RESULT,
94 );
7395 queryClient.setQueryData(gitQueryKeys.status("/repo/b"), { ok: "b" });
74 queryClient.setQueryData(gitQueryKeys.branches("/repo/b"), { ok: "b-branches" });
96 queryClient.setQueryData(
97 gitBranchSearchInfiniteQueryOptions({
98 cwd: "/repo/b",
99 query: "feature",
100 }).queryKey,
101 BRANCH_SEARCH_RESULT,
102 );
75103
76104 await invalidateGitQueries(queryClient, { cwd: "/repo/a" });
77105
......@@ -79,13 +107,23 @@ describe("invalidateGitQueries", () => {
79107 queryClient.getQueryState(gitStatusQueryOptions("/repo/a").queryKey)?.isInvalidated,
80108 ).toBe(true);
81109 expect(
82 queryClient.getQueryState(gitBranchesQueryOptions("/repo/a").queryKey)?.isInvalidated,
110 queryClient.getQueryState(
111 gitBranchSearchInfiniteQueryOptions({
112 cwd: "/repo/a",
113 query: "feature",
114 }).queryKey,
115 )?.isInvalidated,
83116 ).toBe(true);
84117 expect(
85118 queryClient.getQueryState(gitStatusQueryOptions("/repo/b").queryKey)?.isInvalidated,
86119 ).toBe(false);
87120 expect(
88 queryClient.getQueryState(gitBranchesQueryOptions("/repo/b").queryKey)?.isInvalidated,
121 queryClient.getQueryState(
122 gitBranchSearchInfiniteQueryOptions({
123 cwd: "/repo/b",
124 query: "feature",
125 }).queryKey,
126 )?.isInvalidated,
89127 ).toBe(false);
90128 });
91129});
......@@ -95,7 +133,6 @@ describe("invalidateGitStatusQuery", () => {
95133 const queryClient = new QueryClient();
96134
97135 queryClient.setQueryData(gitQueryKeys.status("/repo/a"), { ok: "a" });
98 queryClient.setQueryData(gitQueryKeys.branches("/repo/a"), { ok: "a-branches" });
99136 queryClient.setQueryData(gitQueryKeys.status("/repo/b"), { ok: "b" });
100137
101138 await invalidateGitStatusQuery(queryClient, "/repo/a");
......@@ -103,9 +140,6 @@ describe("invalidateGitStatusQuery", () => {
103140 expect(
104141 queryClient.getQueryState(gitStatusQueryOptions("/repo/a").queryKey)?.isInvalidated,
105142 ).toBe(true);
106 expect(
107 queryClient.getQueryState(gitBranchesQueryOptions("/repo/a").queryKey)?.isInvalidated,
108 ).toBe(false);
109143 expect(
110144 queryClient.getQueryState(gitStatusQueryOptions("/repo/b").queryKey)?.isInvalidated,
111145 ).toBe(false);
apps/web/src/lib/gitReactQuery.ts+29-8
......@@ -1,5 +1,10 @@
11import { type GitActionProgressEvent, type GitStackedAction } from "@t3tools/contracts";
2import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query";
2import {
3 infiniteQueryOptions,
4 mutationOptions,
5 queryOptions,
6 type QueryClient,
7} from "@tanstack/react-query";
38import { ensureNativeApi } from "../nativeApi";
49import { getWsRpcClient } from "../wsRpcClient";
510
......@@ -7,11 +12,14 @@ const GIT_STATUS_STALE_TIME_MS = 5_000;
712const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000;
813const GIT_BRANCHES_STALE_TIME_MS = 15_000;
914const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000;
15const GIT_BRANCHES_PAGE_SIZE = 100;
1016
1117export const gitQueryKeys = {
1218 all: ["git"] as const,
1319 status: (cwd: string | null) => ["git", "status", cwd] as const,
1420 branches: (cwd: string | null) => ["git", "branches", cwd] as const,
21 branchSearch: (cwd: string | null, query: string) =>
22 ["git", "branches", cwd, "search", query] as const,
1523};
1624
1725export const gitMutationKeys = {
......@@ -59,15 +67,28 @@ export function gitStatusQueryOptions(cwd: string | null) {
5967 });
6068}
6169
62export function gitBranchesQueryOptions(cwd: string | null) {
63 return queryOptions({
64 queryKey: gitQueryKeys.branches(cwd),
65 queryFn: async () => {
70export function gitBranchSearchInfiniteQueryOptions(input: {
71 cwd: string | null;
72 query: string;
73 enabled?: boolean;
74}) {
75 const normalizedQuery = input.query.trim();
76
77 return infiniteQueryOptions({
78 queryKey: gitQueryKeys.branchSearch(input.cwd, normalizedQuery),
79 initialPageParam: 0,
80 queryFn: async ({ pageParam }) => {
6681 const api = ensureNativeApi();
67 if (!cwd) throw new Error("Git branches are unavailable.");
68 return api.git.listBranches({ cwd });
82 if (!input.cwd) throw new Error("Git branches are unavailable.");
83 return api.git.listBranches({
84 cwd: input.cwd,
85 ...(normalizedQuery.length > 0 ? { query: normalizedQuery } : {}),
86 cursor: pageParam,
87 limit: GIT_BRANCHES_PAGE_SIZE,
88 });
6989 },
70 enabled: cwd !== null,
90 getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
91 enabled: input.cwd !== null && (input.enabled ?? true),
7192 staleTime: GIT_BRANCHES_STALE_TIME_MS,
7293 refetchOnWindowFocus: true,
7394 refetchOnReconnect: true,
packages/contracts/src/git.ts+12-1
......@@ -2,6 +2,7 @@ import { Schema } from "effect";
22import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas";
33
44const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
5const GIT_LIST_BRANCHES_MAX_LIMIT = 200;
56
67// Domain Types
78
......@@ -120,6 +121,11 @@ export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;
120121
121122export const GitListBranchesInput = Schema.Struct({
122123 cwd: TrimmedNonEmptyStringSchema,
124 query: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(256))),
125 cursor: Schema.optional(NonNegativeInt),
126 limit: Schema.optional(
127 PositiveInt.check(Schema.isLessThanOrEqualTo(GIT_LIST_BRANCHES_MAX_LIMIT)),
128 ),
123129});
124130export type GitListBranchesInput = typeof GitListBranchesInput.Type;
125131
......@@ -180,7 +186,10 @@ const GitStatusPr = Schema.Struct({
180186});
181187
182188export const GitStatusResult = Schema.Struct({
183 branch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr),
189 isRepo: Schema.Boolean,
190 hasOriginRemote: Schema.Boolean,
191 isDefaultBranch: Schema.Boolean,
192 branch: Schema.NullOr(TrimmedNonEmptyStringSchema),
184193 hasWorkingTreeChanges: Schema.Boolean,
185194 workingTree: Schema.Struct({
186195 files: Schema.Array(
......@@ -204,6 +213,8 @@ export const GitListBranchesResult = Schema.Struct({
204213 branches: Schema.Array(GitBranch),
205214 isRepo: Schema.Boolean,
206215 hasOriginRemote: Schema.Boolean,
216 nextCursor: NonNegativeInt.pipe(Schema.NullOr),
217 totalCount: NonNegativeInt,
207218});
208219export type GitListBranchesResult = typeof GitListBranchesResult.Type;
209220
packages/shared/src/git.ts+60
......@@ -1,3 +1,5 @@
1import type { GitBranch } from "@t3tools/contracts";
2
13/**
24 * Sanitize an arbitrary string into a valid, lowercase git branch fragment.
35 * Strips quotes, collapses separators, limits to 64 chars.
......@@ -59,3 +61,61 @@ export function resolveAutoFeatureBranchName(
5961
6062 return `${resolvedBase}-${suffix}`;
6163}
64
65/**
66 * Strip the remote prefix from a remote ref such as `origin/feature/demo`.
67 */
68export function deriveLocalBranchNameFromRemoteRef(branchName: string): string {
69 const firstSeparatorIndex = branchName.indexOf("/");
70 if (firstSeparatorIndex <= 0 || firstSeparatorIndex === branchName.length - 1) {
71 return branchName;
72 }
73 return branchName.slice(firstSeparatorIndex + 1);
74}
75
76function deriveLocalBranchNameCandidatesFromRemoteRef(
77 branchName: string,
78 remoteName?: string,
79): ReadonlyArray<string> {
80 const candidates = new Set<string>();
81 const firstSlashCandidate = deriveLocalBranchNameFromRemoteRef(branchName);
82 if (firstSlashCandidate.length > 0) {
83 candidates.add(firstSlashCandidate);
84 }
85
86 if (remoteName) {
87 const remotePrefix = `${remoteName}/`;
88 if (branchName.startsWith(remotePrefix) && branchName.length > remotePrefix.length) {
89 candidates.add(branchName.slice(remotePrefix.length));
90 }
91 }
92
93 return [...candidates];
94}
95
96/**
97 * Hide `origin/*` remote refs when a matching local branch already exists.
98 */
99export function dedupeRemoteBranchesWithLocalMatches(
100 branches: ReadonlyArray<GitBranch>,
101): ReadonlyArray<GitBranch> {
102 const localBranchNames = new Set(
103 branches.filter((branch) => !branch.isRemote).map((branch) => branch.name),
104 );
105
106 return branches.filter((branch) => {
107 if (!branch.isRemote) {
108 return true;
109 }
110
111 if (branch.remoteName !== "origin") {
112 return true;
113 }
114
115 const localBranchCandidates = deriveLocalBranchNameCandidatesFromRemoteRef(
116 branch.name,
117 branch.remoteName,
118 );
119 return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate));
120 });
121}
scripts/package.json-2
......@@ -4,8 +4,6 @@
44 "type": "module",
55 "scripts": {
66 "prepare": "effect-language-service patch",
7 "claude-fast-mode-probe": "bun run claude-fast-mode-probe.ts",
8 "claude-haiku-thinking-probe": "bun run claude-haiku-thinking-probe.ts",
97 "typecheck": "tsc --noEmit",
108 "test": "vitest run"
119 },