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) => {...@@ -386,6 +386,36 @@ it.layer(TestLayer)("git integration", (it) => {
386 }),386 }),
387 );387 );
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
389 it.effect("parses separate branch names when column.ui is always enabled", () =>419 it.effect("parses separate branch names when column.ui is always enabled", () =>
390 Effect.gen(function* () {420 Effect.gen(function* () {
391 const tmp = yield* makeTmpDir();421 const tmp = yield* makeTmpDir();
...@@ -531,6 +561,41 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -531,6 +561,41 @@ it.layer(TestLayer)("git integration", (it) => {
531 expect(remoteBranch?.remoteName).toBe(remoteName);561 expect(remoteBranch?.remoteName).toBe(remoteName);
532 }),562 }),
533 );563 );
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 );
534 });599 });
535600
536 // ── checkoutGitBranch ──601 // ── checkoutGitBranch ──
...@@ -737,6 +802,9 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -737,6 +802,9 @@ it.layer(TestLayer)("git integration", (it) => {
737 ) {802 ) {
738 return ok();803 return ok();
739 }804 }
805 if (input.operation === "GitCore.statusDetails.defaultRef") {
806 return ok("refs/remotes/origin/main\n");
807 }
740 return Effect.fail(808 return Effect.fail(
741 new GitCommandError({809 new GitCommandError({
742 operation: input.operation,810 operation: input.operation,
...@@ -800,6 +868,9 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -800,6 +868,9 @@ it.layer(TestLayer)("git integration", (it) => {
800 ) {868 ) {
801 return ok();869 return ok();
802 }870 }
871 if (input.operation === "GitCore.statusDetails.defaultRef") {
872 return ok("refs/remotes/origin/main\n");
873 }
803 return Effect.fail(874 return Effect.fail(
804 new GitCommandError({875 new GitCommandError({
805 operation: input.operation,876 operation: input.operation,
apps/server/src/git/Layers/GitCore.ts+112-11
...@@ -18,7 +18,8 @@ import {...@@ -18,7 +18,8 @@ import {
18} from "effect";18} from "effect";
19import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";19import { 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";
22import {23import {
23 GitCore,24 GitCore,
24 type ExecuteGitProgress,25 type ExecuteGitProgress,
...@@ -49,6 +50,7 @@ const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);...@@ -49,6 +50,7 @@ const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
49const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5);50const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5);
50const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;51const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
51const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;52const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;
53const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100;
5254
53type TraceTailState = {55type TraceTailState = {
54 processedChars: number;56 processedChars: number;
...@@ -183,6 +185,40 @@ function parseBranchLine(line: string): { name: string; current: boolean } | nul...@@ -183,6 +185,40 @@ function parseBranchLine(line: string): { name: string; current: boolean } | nul
183 };185 };
184}186}
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
186function sanitizeRemoteName(value: string): string {222function sanitizeRemoteName(value: string): string {
187 const sanitized = value223 const sanitized = value
188 .trim()224 .trim()
...@@ -1101,15 +1137,52 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {...@@ -1101,15 +1137,52 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
1101 const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) {1137 const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) {
1102 yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true }));1138 yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true }));
11031139
1104 const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all(1140 const statusResult = yield* executeGit(
1105 [1141 "GitCore.statusDetails.status",
1106 runGitStdout("GitCore.statusDetails.status", cwd, ["status", "--porcelain=2", "--branch"]),1142 cwd,
1107 runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]),1143 ["status", "--porcelain=2", "--branch"],
1108 runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, ["diff", "--cached", "--numstat"]),1144 {
1109 ],1145 allowNonZeroExit: true,
1110 { concurrency: "unbounded" },1146 },
1111 );1147 );
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
1113 let branch: string | null = null;1186 let branch: string | null = null;
1114 let upstreamRef: string | null = null;1187 let upstreamRef: string | null = null;
1115 let aheadCount = 0;1188 let aheadCount = 0;
...@@ -1176,6 +1249,12 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {...@@ -1176,6 +1249,12 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
1176 files.sort((a, b) => a.path.localeCompare(b.path));1249 files.sort((a, b) => a.path.localeCompare(b.path));
11771250
1178 return {1251 return {
1252 isRepo: true,
1253 hasOriginRemote,
1254 isDefaultBranch:
1255 branch !== null &&
1256 (branch === defaultBranch ||
1257 (defaultBranch === null && (branch === "main" || branch === "master"))),
1179 branch,1258 branch,
1180 upstreamRef,1259 upstreamRef,
1181 hasWorkingTreeChanges,1260 hasWorkingTreeChanges,
...@@ -1193,6 +1272,9 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {...@@ -1193,6 +1272,9 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
1193 const status: GitCoreShape["status"] = (input) =>1272 const status: GitCoreShape["status"] = (input) =>
1194 statusDetails(input.cwd).pipe(1273 statusDetails(input.cwd).pipe(
1195 Effect.map((details) => ({1274 Effect.map((details) => ({
1275 isRepo: details.isRepo,
1276 hasOriginRemote: details.hasOriginRemote,
1277 isDefaultBranch: details.isDefaultBranch,
1196 branch: details.branch,1278 branch: details.branch,
1197 hasWorkingTreeChanges: details.hasWorkingTreeChanges,1279 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
1198 workingTree: details.workingTree,1280 workingTree: details.workingTree,
...@@ -1571,7 +1653,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {...@@ -1571,7 +1653,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
1571 if (localBranchResult.code !== 0) {1653 if (localBranchResult.code !== 0) {
1572 const stderr = localBranchResult.stderr.trim();1654 const stderr = localBranchResult.stderr.trim();
1573 if (stderr.toLowerCase().includes("not a git repository")) {1655 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 };
1575 }1663 }
1576 return yield* createGitCommandError(1664 return yield* createGitCommandError(
1577 "GitCore.listBranches",1665 "GitCore.listBranches",
...@@ -1735,9 +1823,22 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {...@@ -1735,9 +1823,22 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
1735 })1823 })
1736 : [];1824 : [];
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 };
1741 });1842 });
17421843
1743 const createWorktree: GitCoreShape["createWorktree"] = Effect.fn("createWorktree")(1844 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) => {...@@ -660,6 +660,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
660 });660 });
661661
662 const status = yield* manager.status({ cwd: repoDir });662 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);
663 expect(status.branch).toBe("feature/status-open-pr");666 expect(status.branch).toBe("feature/status-open-pr");
664 expect(status.pr).toEqual({667 expect(status.pr).toEqual({
665 number: 13,668 number: 13,
...@@ -672,6 +675,32 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -672,6 +675,32 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
672 }),675 }),
673 );676 );
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
675 it.effect("status briefly caches repeated lookups for the same cwd", () =>704 it.effect("status briefly caches repeated lookups for the same cwd", () =>
676 Effect.gen(function* () {705 Effect.gen(function* () {
677 const repoDir = yield* makeTempDir("t3code-git-manager-");706 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...@@ -5,6 +5,7 @@ import { Cache, Duration, Effect, Exit, FileSystem, Layer, Option, Path, Ref } f
5import {5import {
6 GitActionProgressEvent,6 GitActionProgressEvent,
7 GitActionProgressPhase,7 GitActionProgressPhase,
8 GitCommandError,
8 GitRunStackedActionResult,9 GitRunStackedActionResult,
9 GitStackedAction,10 GitStackedAction,
10 ModelSelection,11 ModelSelection,
...@@ -22,7 +23,7 @@ import {...@@ -22,7 +23,7 @@ import {
22 type GitManagerShape,23 type GitManagerShape,
23 type GitRunStackedActionOptions,24 type GitRunStackedActionOptions,
24} from "../Services/GitManager.ts";25} from "../Services/GitManager.ts";
25import { GitCore } from "../Services/GitCore.ts";26import { GitCore, GitStatusDetails } from "../Services/GitCore.ts";
26import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts";27import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts";
27import { TextGeneration } from "../Services/TextGeneration.ts";28import { TextGeneration } from "../Services/TextGeneration.ts";
28import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts";29import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts";
...@@ -39,6 +40,10 @@ type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "act...@@ -39,6 +40,10 @@ type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "act
39type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>;40type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>;
40type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect<void, never>;41type 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
42interface OpenPrInfo {47interface OpenPrInfo {
43 number: number;48 number: number;
44 title: string;49 title: string;
...@@ -689,10 +694,25 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -689,10 +694,25 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
689 const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp";694 const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp";
690 const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd);695 const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd);
691 const readStatus = Effect.fn("readStatus")(function* (cwd: string) {696 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
694 const pr =714 const pr =
695 details.branch !== null715 details.isRepo && details.branch !== null
696 ? yield* findLatestPr(cwd, {716 ? yield* findLatestPr(cwd, {
697 branch: details.branch,717 branch: details.branch,
698 upstreamRef: details.upstreamRef,718 upstreamRef: details.upstreamRef,
...@@ -703,6 +723,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -703,6 +723,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
703 : null;723 : null;
704724
705 return {725 return {
726 isRepo: details.isRepo,
727 hasOriginRemote: details.hasOriginRemote,
728 isDefaultBranch: details.isDefaultBranch,
706 branch: details.branch,729 branch: details.branch,
707 hasWorkingTreeChanges: details.hasWorkingTreeChanges,730 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
708 workingTree: details.workingTree,731 workingTree: details.workingTree,
...@@ -909,12 +932,6 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -909,12 +932,6 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
909 return parsed[0] ?? null;932 return parsed[0] ?? null;
910 });933 });
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
918 const buildCompletionToast = Effect.fn("buildCompletionToast")(function* (935 const buildCompletionToast = Effect.fn("buildCompletionToast")(function* (
919 cwd: string,936 cwd: string,
920 result: Pick<GitRunStackedActionResult, "action" | "branch" | "commit" | "push" | "pr">,937 result: Pick<GitRunStackedActionResult, "action" | "branch" | "commit" | "push" | "pr">,
...@@ -936,11 +953,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -936,11 +953,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
936 upstreamRef: finalStatus.upstreamRef,953 upstreamRef: finalStatus.upstreamRef,
937 hasUpstream: finalStatus.hasUpstream,954 hasUpstream: finalStatus.hasUpstream,
938 };955 };
939 currentBranchIsDefault = yield* isDefaultBranch(cwd, finalStatus.branch).pipe(956 currentBranchIsDefault = finalStatus.isDefaultBranch;
940 Effect.catch(() =>
941 Effect.succeed(finalStatus.branch === "main" || finalStatus.branch === "master"),
942 ),
943 );
944 }957 }
945 }958 }
946959
apps/server/src/server.test.ts+5
...@@ -822,6 +822,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -822,6 +822,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
822 gitManager: {822 gitManager: {
823 status: () =>823 status: () =>
824 Effect.succeed({824 Effect.succeed({
825 isRepo: true,
826 hasOriginRemote: true,
827 isDefaultBranch: true,
825 branch: "main",828 branch: "main",
826 hasWorkingTreeChanges: false,829 hasWorkingTreeChanges: false,
827 workingTree: { files: [], insertions: 0, deletions: 0 },830 workingTree: { files: [], insertions: 0, deletions: 0 },
...@@ -922,6 +925,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -922,6 +925,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
922 ],925 ],
923 isRepo: true,926 isRepo: true,
924 hasOriginRemote: true,927 hasOriginRemote: true,
928 nextCursor: null,
929 totalCount: 1,
925 }),930 }),
926 createWorktree: () =>931 createWorktree: () =>
927 Effect.succeed({932 Effect.succeed({
apps/web/src/components/BranchToolbar.logic.ts+4-52
...@@ -1,5 +1,9 @@...@@ -1,5 +1,9 @@
1import type { GitBranch } from "@t3tools/contracts";1import type { GitBranch } from "@t3tools/contracts";
2import { Schema } from "effect";2import { Schema } from "effect";
3export {
4 dedupeRemoteBranchesWithLocalMatches,
5 deriveLocalBranchNameFromRemoteRef,
6} from "@t3tools/shared/git";
37
4export const EnvMode = Schema.Literals(["local", "worktree"]);8export const EnvMode = Schema.Literals(["local", "worktree"]);
5export type EnvMode = typeof EnvMode.Type;9export type EnvMode = typeof EnvMode.Type;
...@@ -43,58 +47,6 @@ export function resolveBranchToolbarValue(input: {...@@ -43,58 +47,6 @@ export function resolveBranchToolbarValue(input: {
43 return currentGitBranch ?? activeThreadBranch;47 return currentGitBranch ?? activeThreadBranch;
44}48}
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
98export function resolveBranchSelectionTarget(input: {50export function resolveBranchSelectionTarget(input: {
99 activeProjectCwd: string;51 activeProjectCwd: string;
100 activeWorktreePath: string | null;52 activeWorktreePath: string | null;
apps/web/src/components/BranchToolbarBranchSelector.tsx+86-10
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import type { GitBranch } from "@t3tools/contracts";1import type { GitBranch } from "@t3tools/contracts";
2import { useQuery, useQueryClient } from "@tanstack/react-query";2import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
3import { useVirtualizer } from "@tanstack/react-virtual";3import { useVirtualizer } from "@tanstack/react-virtual";
4import { ChevronDownIcon } from "lucide-react";4import { ChevronDownIcon } from "lucide-react";
5import {5import {
...@@ -15,7 +15,7 @@ import {...@@ -15,7 +15,7 @@ import {
15} from "react";15} from "react";
1616
17import {17import {
18 gitBranchesQueryOptions,18 gitBranchSearchInfiniteQueryOptions,
19 gitQueryKeys,19 gitQueryKeys,
20 gitStatusQueryOptions,20 gitStatusQueryOptions,
21 invalidateGitQueries,21 invalidateGitQueries,
...@@ -23,7 +23,6 @@ import {...@@ -23,7 +23,6 @@ import {
23import { readNativeApi } from "../nativeApi";23import { readNativeApi } from "../nativeApi";
24import { parsePullRequestReference } from "../pullRequestReference";24import { parsePullRequestReference } from "../pullRequestReference";
25import {25import {
26 dedupeRemoteBranchesWithLocalMatches,
27 deriveLocalBranchNameFromRemoteRef,26 deriveLocalBranchNameFromRemoteRef,
28 EnvMode,27 EnvMode,
29 resolveBranchSelectionTarget,28 resolveBranchSelectionTarget,
...@@ -38,6 +37,7 @@ import {...@@ -38,6 +37,7 @@ import {
38 ComboboxItem,37 ComboboxItem,
39 ComboboxList,38 ComboboxList,
40 ComboboxPopup,39 ComboboxPopup,
40 ComboboxStatus,
41 ComboboxTrigger,41 ComboboxTrigger,
42} from "./ui/combobox";42} from "./ui/combobox";
43import { toastManager } from "./ui/toast";43import { toastManager } from "./ui/toast";
...@@ -89,11 +89,33 @@ export function BranchToolbarBranchSelector({...@@ -89,11 +89,33 @@ export function BranchToolbarBranchSelector({
89 const [branchQuery, setBranchQuery] = useState("");89 const [branchQuery, setBranchQuery] = useState("");
90 const deferredBranchQuery = useDeferredValue(branchQuery);90 const deferredBranchQuery = useDeferredValue(branchQuery);
9191
92 const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd));
93 const branchStatusQuery = useQuery(gitStatusQueryOptions(branchCwd));92 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 );
94 const branches = useMemo(116 const branches = useMemo(
95 () => dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []),117 () => branchesSearchData?.pages.flatMap((page) => page.branches) ?? [],
96 [branchesQuery.data?.branches],118 [branchesSearchData?.pages],
97 );119 );
98 const currentGitBranch =120 const currentGitBranch =
99 branchStatusQuery.data?.branch ?? branches.find((branch) => branch.current)?.name ?? null;121 branchStatusQuery.data?.branch ?? branches.find((branch) => branch.current)?.name ?? null;
...@@ -108,8 +130,6 @@ export function BranchToolbarBranchSelector({...@@ -108,8 +130,6 @@ export function BranchToolbarBranchSelector({
108 () => new Map(branches.map((branch) => [branch.name, branch] as const)),130 () => new Map(branches.map((branch) => [branch.name, branch] as const)),
109 [branches],131 [branches],
110 );132 );
111 const trimmedBranchQuery = branchQuery.trim();
112 const deferredTrimmedBranchQuery = deferredBranchQuery.trim();
113 const normalizedDeferredBranchQuery = deferredTrimmedBranchQuery.toLowerCase();133 const normalizedDeferredBranchQuery = deferredTrimmedBranchQuery.toLowerCase();
114 const prReference = parsePullRequestReference(trimmedBranchQuery);134 const prReference = parsePullRequestReference(trimmedBranchQuery);
115 const isSelectingWorktreeBase =135 const isSelectingWorktreeBase =
...@@ -156,6 +176,14 @@ export function BranchToolbarBranchSelector({...@@ -156,6 +176,14 @@ export function BranchToolbarBranchSelector({
156 );176 );
157 const [isBranchActionPending, startBranchActionTransition] = useTransition();177 const [isBranchActionPending, startBranchActionTransition] = useTransition();
158 const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40;178 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
160 const runBranchAction = (action: () => Promise<void>) => {188 const runBranchAction = (action: () => Promise<void>) => {
161 startBranchActionTransition(async () => {189 startBranchActionTransition(async () => {
...@@ -213,7 +241,7 @@ export function BranchToolbarBranchSelector({...@@ -213,7 +241,7 @@ export function BranchToolbarBranchSelector({
213241
214 let nextBranchName = selectedBranchName;242 let nextBranchName = selectedBranchName;
215 if (branch.isRemote) {243 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);
217 if (status?.branch) {245 if (status?.branch) {
218 nextBranchName = status.branch;246 nextBranchName = status.branch;
219 }247 }
...@@ -295,6 +323,24 @@ export function BranchToolbarBranchSelector({...@@ -295,6 +323,24 @@ export function BranchToolbarBranchSelector({
295 );323 );
296324
297 const branchListScrollElementRef = useRef<HTMLDivElement | null>(null);325 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]);
298 const branchListVirtualizer = useVirtualizer({344 const branchListVirtualizer = useVirtualizer({
299 count: filteredBranchPickerItems.length,345 count: filteredBranchPickerItems.length,
300 estimateSize: (index) =>346 estimateSize: (index) =>
...@@ -331,6 +377,35 @@ export function BranchToolbarBranchSelector({...@@ -331,6 +377,35 @@ export function BranchToolbarBranchSelector({
331 shouldVirtualizeBranchList,377 shouldVirtualizeBranchList,
332 ]);378 ]);
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
334 const triggerLabel = getBranchTriggerLabel({409 const triggerLabel = getBranchTriggerLabel({
335 activeWorktreePath,410 activeWorktreePath,
336 effectiveEnvMode,411 effectiveEnvMode,
...@@ -425,7 +500,7 @@ export function BranchToolbarBranchSelector({...@@ -425,7 +500,7 @@ export function BranchToolbarBranchSelector({
425 <ComboboxTrigger500 <ComboboxTrigger
426 render={<Button variant="ghost" size="xs" />}501 render={<Button variant="ghost" size="xs" />}
427 className="text-muted-foreground/70 hover:text-foreground/80"502 className="text-muted-foreground/70 hover:text-foreground/80"
428 disabled={(branchesQuery.isLoading && branches.length === 0) || isBranchActionPending}503 disabled={(isBranchesSearchPending && branches.length === 0) || isBranchActionPending}
429 >504 >
430 <span className="max-w-[240px] truncate">{triggerLabel}</span>505 <span className="max-w-[240px] truncate">{triggerLabel}</span>
431 <ChevronDownIcon />506 <ChevronDownIcon />
...@@ -468,6 +543,7 @@ export function BranchToolbarBranchSelector({...@@ -468,6 +543,7 @@ export function BranchToolbarBranchSelector({
468 filteredBranchPickerItems.map((itemValue, index) => renderPickerItem(itemValue, index))543 filteredBranchPickerItems.map((itemValue, index) => renderPickerItem(itemValue, index))
469 )}544 )}
470 </ComboboxList>545 </ComboboxList>
546 {branchStatusText ? <ComboboxStatus>{branchStatusText}</ComboboxStatus> : null}
471 </ComboboxPopup>547 </ComboboxPopup>
472 </Combobox>548 </Combobox>
473 );549 );
apps/web/src/components/ChatView.browser.tsx+5
...@@ -632,6 +632,8 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown {...@@ -632,6 +632,8 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown {
632 return {632 return {
633 isRepo: true,633 isRepo: true,
634 hasOriginRemote: true,634 hasOriginRemote: true,
635 nextCursor: null,
636 totalCount: 1,
635 branches: [637 branches: [
636 {638 {
637 name: "main",639 name: "main",
...@@ -644,6 +646,9 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown {...@@ -644,6 +646,9 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown {
644 }646 }
645 if (tag === WS_METHODS.gitStatus) {647 if (tag === WS_METHODS.gitStatus) {
646 return {648 return {
649 isRepo: true,
650 hasOriginRemote: true,
651 isDefaultBranch: true,
647 branch: "main",652 branch: "main",
648 hasWorkingTreeChanges: false,653 hasWorkingTreeChanges: false,
649 workingTree: {654 workingTree: {
apps/web/src/components/ChatView.tsx+3-3
...@@ -26,7 +26,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr...@@ -26,7 +26,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
26import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";26import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
27import { useDebouncedValue } from "@tanstack/react-pacer";27import { useDebouncedValue } from "@tanstack/react-pacer";
28import { useNavigate, useSearch } from "@tanstack/react-router";28import { useNavigate, useSearch } from "@tanstack/react-router";
29import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";29import { gitCreateWorktreeMutationOptions, gitStatusQueryOptions } from "~/lib/gitReactQuery";
30import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";30import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
31import { isElectron } from "../env";31import { isElectron } from "../env";
32import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";32import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
...@@ -1201,7 +1201,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1201,7 +1201,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1201 (debouncerState) => ({ isPending: debouncerState.isPending }),1201 (debouncerState) => ({ isPending: debouncerState.isPending }),
1202 );1202 );
1203 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";1203 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
1204 const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd));1204 const gitStatusQuery = useQuery(gitStatusQueryOptions(gitCwd));
1205 const keybindings = useServerKeybindings();1205 const keybindings = useServerKeybindings();
1206 const availableEditors = useServerAvailableEditors();1206 const availableEditors = useServerAvailableEditors();
1207 const modelOptionsByProvider = useMemo(1207 const modelOptionsByProvider = useMemo(
...@@ -1338,7 +1338,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1338,7 +1338,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1338 });1338 });
1339 }, [activeProjectCwd, activeThreadWorktreePath]);1339 }, [activeProjectCwd, activeThreadWorktreePath]);
1340 // Default true while loading to avoid toolbar flicker.1340 // Default true while loading to avoid toolbar flicker.
1341 const isGitRepo = branchesQuery.data?.isRepo ?? true;1341 const isGitRepo = gitStatusQuery.data?.isRepo ?? true;
1342 const terminalShortcutLabelOptions = useMemo(1342 const terminalShortcutLabelOptions = useMemo(
1343 () => ({1343 () => ({
1344 context: {1344 context: {
apps/web/src/components/DiffPanel.tsx+3-3
...@@ -19,7 +19,7 @@ import {...@@ -19,7 +19,7 @@ import {
19 useState,19 useState,
20} from "react";20} from "react";
21import { openInPreferredEditor } from "../editorPreferences";21import { openInPreferredEditor } from "../editorPreferences";
22import { gitBranchesQueryOptions } from "~/lib/gitReactQuery";22import { gitStatusQueryOptions } from "~/lib/gitReactQuery";
23import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";23import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
24import { cn } from "~/lib/utils";24import { cn } from "~/lib/utils";
25import { readNativeApi } from "../nativeApi";25import { readNativeApi } from "../nativeApi";
...@@ -189,8 +189,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -189,8 +189,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
189 activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined,189 activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined,
190 );190 );
191 const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd;191 const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd;
192 const gitBranchesQuery = useQuery(gitBranchesQueryOptions(activeCwd ?? null));192 const gitStatusQuery = useQuery(gitStatusQueryOptions(activeCwd ?? null));
193 const isGitRepo = gitBranchesQuery.data?.isRepo ?? true;193 const isGitRepo = gitStatusQuery.data?.isRepo ?? true;
194 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =194 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
195 useTurnDiffSummaries(activeThread);195 useTurnDiffSummaries(activeThread);
196 const orderedTurnDiffSummaries = useMemo(196 const orderedTurnDiffSummaries = useMemo(
apps/web/src/components/GitActionsControl.logic.test.ts+3
...@@ -13,6 +13,9 @@ import {...@@ -13,6 +13,9 @@ import {
1313
14function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult {14function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult {
15 return {15 return {
16 isRepo: true,
17 hasOriginRemote: true,
18 isDefaultBranch: false,
16 branch: "feature/test",19 branch: "feature/test",
17 hasWorkingTreeChanges: false,20 hasWorkingTreeChanges: false,
18 workingTree: {21 workingTree: {
apps/web/src/components/GitActionsControl.tsx+5-26
...@@ -41,14 +41,12 @@ import { Textarea } from "~/components/ui/textarea";...@@ -41,14 +41,12 @@ import { Textarea } from "~/components/ui/textarea";
41import { toastManager, type ThreadToastData } from "~/components/ui/toast";41import { toastManager, type ThreadToastData } from "~/components/ui/toast";
42import { openInPreferredEditor } from "~/editorPreferences";42import { openInPreferredEditor } from "~/editorPreferences";
43import {43import {
44 gitBranchesQueryOptions,
45 gitInitMutationOptions,44 gitInitMutationOptions,
46 gitMutationKeys,45 gitMutationKeys,
47 gitPullMutationOptions,46 gitPullMutationOptions,
48 gitRunStackedActionMutationOptions,47 gitRunStackedActionMutationOptions,
49 gitStatusQueryOptions,48 gitStatusQueryOptions,
50 invalidateGitStatusQuery,49 invalidateGitStatusQuery,
51 invalidateGitQueries,
52} from "~/lib/gitReactQuery";50} from "~/lib/gitReactQuery";
53import { newCommandId, randomUUID } from "~/lib/utils";51import { newCommandId, randomUUID } from "~/lib/utils";
54import { resolvePathLinkTarget } from "~/terminal-links";52import { resolvePathLinkTarget } from "~/terminal-links";
...@@ -278,21 +276,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -278,21 +276,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
278 );276 );
279277
280 const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd));278 const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd));
281
282 const { data: branchList = null } = useQuery(gitBranchesQueryOptions(gitCwd));
283 // Default to true while loading so we don't flash init controls.279 // Default to true while loading so we don't flash init controls.
284 const isRepo = branchList?.isRepo ?? true;280 const isRepo = gitStatus?.isRepo ?? true;
285 const hasOriginRemote = branchList?.hasOriginRemote ?? false;281 const hasOriginRemote = gitStatus?.hasOriginRemote ?? false;
286 const currentBranch = branchList?.branches.find((branch) => branch.current)?.name ?? null;282 const gitStatusForActions = gitStatus;
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;
296283
297 const allFiles = gitStatusForActions?.workingTree.files ?? [];284 const allFiles = gitStatusForActions?.workingTree.files ?? [];
298 const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path));285 const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path));
...@@ -336,11 +323,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -336,11 +323,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
336 ]);323 ]);
337324
338 const isDefaultBranch = useMemo(() => {325 const isDefaultBranch = useMemo(() => {
339 const branchName = gitStatusForActions?.branch;326 return gitStatusForActions?.isDefaultBranch ?? false;
340 if (!branchName) return false;327 }, [gitStatusForActions?.isDefaultBranch]);
341 const current = branchList?.branches.find((branch) => branch.name === branchName);
342 return current?.isDefault ?? (branchName === "main" || branchName === "master");
343 }, [branchList?.branches, gitStatusForActions?.branch]);
344328
345 const gitActionMenuItems = useMemo(329 const gitActionMenuItems = useMemo(
346 () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasOriginRemote),330 () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasOriginRemote),
...@@ -881,11 +865,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -881,11 +865,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
881 Behind upstream. Pull/rebase first.865 Behind upstream. Pull/rebase first.
882 </p>866 </p>
883 )}867 )}
884 {isGitStatusOutOfSync && (
885 <p className="px-2 py-1.5 text-xs text-muted-foreground">
886 Refreshing git status...
887 </p>
888 )}
889 {gitStatusError && (868 {gitStatusError && (
890 <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError.message}</p>869 <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError.message}</p>
891 )}870 )}
apps/web/src/components/KeybindingsToast.browser.tsx+5
...@@ -157,11 +157,16 @@ function resolveWsRpc(tag: string): unknown {...@@ -157,11 +157,16 @@ function resolveWsRpc(tag: string): unknown {
157 return {157 return {
158 isRepo: true,158 isRepo: true,
159 hasOriginRemote: true,159 hasOriginRemote: true,
160 nextCursor: null,
161 totalCount: 1,
160 branches: [{ name: "main", current: true, isDefault: true, worktreePath: null }],162 branches: [{ name: "main", current: true, isDefault: true, worktreePath: null }],
161 };163 };
162 }164 }
163 if (tag === WS_METHODS.gitStatus) {165 if (tag === WS_METHODS.gitStatus) {
164 return {166 return {
167 isRepo: true,
168 hasOriginRemote: true,
169 isDefaultBranch: true,
165 branch: "main",170 branch: "main",
166 hasWorkingTreeChanges: false,171 hasWorkingTreeChanges: false,
167 workingTree: { files: [], insertions: 0, deletions: 0 },172 workingTree: { files: [], insertions: 0, deletions: 0 },
apps/web/src/lib/gitReactQuery.test.ts+43-9
...@@ -9,8 +9,11 @@ vi.mock("../wsRpcClient", () => ({...@@ -9,8 +9,11 @@ vi.mock("../wsRpcClient", () => ({
9 getWsRpcClient: vi.fn(),9 getWsRpcClient: vi.fn(),
10}));10}));
1111
12import type { InfiniteData } from "@tanstack/react-query";
13import type { GitListBranchesResult } from "@t3tools/contracts";
14
12import {15import {
13 gitBranchesQueryOptions,16 gitBranchSearchInfiniteQueryOptions,
14 gitMutationKeys,17 gitMutationKeys,
15 gitQueryKeys,18 gitQueryKeys,
16 gitPreparePullRequestThreadMutationOptions,19 gitPreparePullRequestThreadMutationOptions,
...@@ -21,6 +24,19 @@ import {...@@ -21,6 +24,19 @@ import {
21 invalidateGitQueries,24 invalidateGitQueries,
22} from "./gitReactQuery";25} 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
24describe("gitMutationKeys", () => {40describe("gitMutationKeys", () => {
25 it("scopes stacked action keys by cwd", () => {41 it("scopes stacked action keys by cwd", () => {
26 expect(gitMutationKeys.runStackedAction("/repo/a")).not.toEqual(42 expect(gitMutationKeys.runStackedAction("/repo/a")).not.toEqual(
...@@ -69,9 +85,21 @@ describe("invalidateGitQueries", () => {...@@ -69,9 +85,21 @@ describe("invalidateGitQueries", () => {
69 const queryClient = new QueryClient();85 const queryClient = new QueryClient();
7086
71 queryClient.setQueryData(gitQueryKeys.status("/repo/a"), { ok: "a" });87 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 );
73 queryClient.setQueryData(gitQueryKeys.status("/repo/b"), { ok: "b" });95 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
76 await invalidateGitQueries(queryClient, { cwd: "/repo/a" });104 await invalidateGitQueries(queryClient, { cwd: "/repo/a" });
77105
...@@ -79,13 +107,23 @@ describe("invalidateGitQueries", () => {...@@ -79,13 +107,23 @@ describe("invalidateGitQueries", () => {
79 queryClient.getQueryState(gitStatusQueryOptions("/repo/a").queryKey)?.isInvalidated,107 queryClient.getQueryState(gitStatusQueryOptions("/repo/a").queryKey)?.isInvalidated,
80 ).toBe(true);108 ).toBe(true);
81 expect(109 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,
83 ).toBe(true);116 ).toBe(true);
84 expect(117 expect(
85 queryClient.getQueryState(gitStatusQueryOptions("/repo/b").queryKey)?.isInvalidated,118 queryClient.getQueryState(gitStatusQueryOptions("/repo/b").queryKey)?.isInvalidated,
86 ).toBe(false);119 ).toBe(false);
87 expect(120 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,
89 ).toBe(false);127 ).toBe(false);
90 });128 });
91});129});
...@@ -95,7 +133,6 @@ describe("invalidateGitStatusQuery", () => {...@@ -95,7 +133,6 @@ describe("invalidateGitStatusQuery", () => {
95 const queryClient = new QueryClient();133 const queryClient = new QueryClient();
96134
97 queryClient.setQueryData(gitQueryKeys.status("/repo/a"), { ok: "a" });135 queryClient.setQueryData(gitQueryKeys.status("/repo/a"), { ok: "a" });
98 queryClient.setQueryData(gitQueryKeys.branches("/repo/a"), { ok: "a-branches" });
99 queryClient.setQueryData(gitQueryKeys.status("/repo/b"), { ok: "b" });136 queryClient.setQueryData(gitQueryKeys.status("/repo/b"), { ok: "b" });
100137
101 await invalidateGitStatusQuery(queryClient, "/repo/a");138 await invalidateGitStatusQuery(queryClient, "/repo/a");
...@@ -103,9 +140,6 @@ describe("invalidateGitStatusQuery", () => {...@@ -103,9 +140,6 @@ describe("invalidateGitStatusQuery", () => {
103 expect(140 expect(
104 queryClient.getQueryState(gitStatusQueryOptions("/repo/a").queryKey)?.isInvalidated,141 queryClient.getQueryState(gitStatusQueryOptions("/repo/a").queryKey)?.isInvalidated,
105 ).toBe(true);142 ).toBe(true);
106 expect(
107 queryClient.getQueryState(gitBranchesQueryOptions("/repo/a").queryKey)?.isInvalidated,
108 ).toBe(false);
109 expect(143 expect(
110 queryClient.getQueryState(gitStatusQueryOptions("/repo/b").queryKey)?.isInvalidated,144 queryClient.getQueryState(gitStatusQueryOptions("/repo/b").queryKey)?.isInvalidated,
111 ).toBe(false);145 ).toBe(false);
apps/web/src/lib/gitReactQuery.ts+29-8
...@@ -1,5 +1,10 @@...@@ -1,5 +1,10 @@
1import { type GitActionProgressEvent, type GitStackedAction } from "@t3tools/contracts";1import { 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";
3import { ensureNativeApi } from "../nativeApi";8import { ensureNativeApi } from "../nativeApi";
4import { getWsRpcClient } from "../wsRpcClient";9import { getWsRpcClient } from "../wsRpcClient";
510
...@@ -7,11 +12,14 @@ const GIT_STATUS_STALE_TIME_MS = 5_000;...@@ -7,11 +12,14 @@ const GIT_STATUS_STALE_TIME_MS = 5_000;
7const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000;12const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000;
8const GIT_BRANCHES_STALE_TIME_MS = 15_000;13const GIT_BRANCHES_STALE_TIME_MS = 15_000;
9const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000;14const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000;
15const GIT_BRANCHES_PAGE_SIZE = 100;
1016
11export const gitQueryKeys = {17export const gitQueryKeys = {
12 all: ["git"] as const,18 all: ["git"] as const,
13 status: (cwd: string | null) => ["git", "status", cwd] as const,19 status: (cwd: string | null) => ["git", "status", cwd] as const,
14 branches: (cwd: string | null) => ["git", "branches", cwd] as const,20 branches: (cwd: string | null) => ["git", "branches", cwd] as const,
21 branchSearch: (cwd: string | null, query: string) =>
22 ["git", "branches", cwd, "search", query] as const,
15};23};
1624
17export const gitMutationKeys = {25export const gitMutationKeys = {
...@@ -59,15 +67,28 @@ export function gitStatusQueryOptions(cwd: string | null) {...@@ -59,15 +67,28 @@ export function gitStatusQueryOptions(cwd: string | null) {
59 });67 });
60}68}
6169
62export function gitBranchesQueryOptions(cwd: string | null) {70export function gitBranchSearchInfiniteQueryOptions(input: {
63 return queryOptions({71 cwd: string | null;
64 queryKey: gitQueryKeys.branches(cwd),72 query: string;
65 queryFn: async () => {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 }) => {
66 const api = ensureNativeApi();81 const api = ensureNativeApi();
67 if (!cwd) throw new Error("Git branches are unavailable.");82 if (!input.cwd) throw new Error("Git branches are unavailable.");
68 return api.git.listBranches({ cwd });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 });
69 },89 },
70 enabled: cwd !== null,90 getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
91 enabled: input.cwd !== null && (input.enabled ?? true),
71 staleTime: GIT_BRANCHES_STALE_TIME_MS,92 staleTime: GIT_BRANCHES_STALE_TIME_MS,
72 refetchOnWindowFocus: true,93 refetchOnWindowFocus: true,
73 refetchOnReconnect: true,94 refetchOnReconnect: true,
packages/contracts/src/git.ts+12-1
...@@ -2,6 +2,7 @@ import { Schema } from "effect";...@@ -2,6 +2,7 @@ import { Schema } from "effect";
2import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas";2import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas";
33
4const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;4const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
5const GIT_LIST_BRANCHES_MAX_LIMIT = 200;
56
6// Domain Types7// Domain Types
78
...@@ -120,6 +121,11 @@ export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;...@@ -120,6 +121,11 @@ export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;
120121
121export const GitListBranchesInput = Schema.Struct({122export const GitListBranchesInput = Schema.Struct({
122 cwd: TrimmedNonEmptyStringSchema,123 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 ),
123});129});
124export type GitListBranchesInput = typeof GitListBranchesInput.Type;130export type GitListBranchesInput = typeof GitListBranchesInput.Type;
125131
...@@ -180,7 +186,10 @@ const GitStatusPr = Schema.Struct({...@@ -180,7 +186,10 @@ const GitStatusPr = Schema.Struct({
180});186});
181187
182export const GitStatusResult = Schema.Struct({188export 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),
184 hasWorkingTreeChanges: Schema.Boolean,193 hasWorkingTreeChanges: Schema.Boolean,
185 workingTree: Schema.Struct({194 workingTree: Schema.Struct({
186 files: Schema.Array(195 files: Schema.Array(
...@@ -204,6 +213,8 @@ export const GitListBranchesResult = Schema.Struct({...@@ -204,6 +213,8 @@ export const GitListBranchesResult = Schema.Struct({
204 branches: Schema.Array(GitBranch),213 branches: Schema.Array(GitBranch),
205 isRepo: Schema.Boolean,214 isRepo: Schema.Boolean,
206 hasOriginRemote: Schema.Boolean,215 hasOriginRemote: Schema.Boolean,
216 nextCursor: NonNegativeInt.pipe(Schema.NullOr),
217 totalCount: NonNegativeInt,
207});218});
208export type GitListBranchesResult = typeof GitListBranchesResult.Type;219export type GitListBranchesResult = typeof GitListBranchesResult.Type;
209220
packages/shared/src/git.ts+60
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import type { GitBranch } from "@t3tools/contracts";
2
1/**3/**
2 * Sanitize an arbitrary string into a valid, lowercase git branch fragment.4 * Sanitize an arbitrary string into a valid, lowercase git branch fragment.
3 * Strips quotes, collapses separators, limits to 64 chars.5 * Strips quotes, collapses separators, limits to 64 chars.
...@@ -59,3 +61,61 @@ export function resolveAutoFeatureBranchName(...@@ -59,3 +61,61 @@ export function resolveAutoFeatureBranchName(
5961
60 return `${resolvedBase}-${suffix}`;62 return `${resolvedBase}-${suffix}`;
61}63}
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 @@...@@ -4,8 +4,6 @@
4 "type": "module",4 "type": "module",
5 "scripts": {5 "scripts": {
6 "prepare": "effect-language-service patch",6 "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",
9 "typecheck": "tsc --noEmit",7 "typecheck": "tsc --noEmit",
10 "test": "vitest run"8 "test": "vitest run"
11 },9 },