diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts
index 2838edcad7..5e4416d8b9 100644
--- a/apps/server/src/git/Layers/GitCore.test.ts
+++ b/apps/server/src/git/Layers/GitCore.test.ts
@@ -386,6 +386,36 @@ it.layer(TestLayer)("git integration", (it) => {
}),
);
+ it.effect("paginates branch results and returns paging metadata", () =>
+ Effect.gen(function* () {
+ const tmp = yield* makeTmpDir();
+ const { initialBranch } = yield* initRepoWithCommit(tmp);
+ yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-a" });
+ yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-b" });
+ yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-c" });
+
+ const firstPage = yield* (yield* GitCore).listBranches({ cwd: tmp, limit: 2 });
+ expect(firstPage.totalCount).toBe(4);
+ expect(firstPage.nextCursor).toBe(2);
+ expect(firstPage.branches.map((branch) => branch.name)).toEqual([
+ initialBranch,
+ "feature-a",
+ ]);
+
+ const secondPage = yield* (yield* GitCore).listBranches({
+ cwd: tmp,
+ cursor: firstPage.nextCursor ?? 0,
+ limit: 2,
+ });
+ expect(secondPage.totalCount).toBe(4);
+ expect(secondPage.nextCursor).toBeNull();
+ expect(secondPage.branches.map((branch) => branch.name)).toEqual([
+ "feature-b",
+ "feature-c",
+ ]);
+ }),
+ );
+
it.effect("parses separate branch names when column.ui is always enabled", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
@@ -531,6 +561,41 @@ it.layer(TestLayer)("git integration", (it) => {
expect(remoteBranch?.remoteName).toBe(remoteName);
}),
);
+
+ it.effect(
+ "filters branch queries before pagination and dedupes origin refs with local matches",
+ () =>
+ Effect.gen(function* () {
+ const remote = yield* makeTmpDir();
+ const tmp = yield* makeTmpDir();
+
+ yield* git(remote, ["init", "--bare"]);
+ const { initialBranch } = yield* initRepoWithCommit(tmp);
+ yield* git(tmp, ["remote", "add", "origin", remote]);
+ yield* git(tmp, ["push", "-u", "origin", initialBranch]);
+
+ yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/demo" });
+ yield* git(tmp, ["push", "-u", "origin", "feature/demo"]);
+
+ yield* git(tmp, ["checkout", "-b", "feature/remote-only"]);
+ yield* git(tmp, ["push", "-u", "origin", "feature/remote-only"]);
+ yield* git(tmp, ["checkout", initialBranch]);
+ yield* git(tmp, ["branch", "-D", "feature/remote-only"]);
+
+ const result = yield* (yield* GitCore).listBranches({
+ cwd: tmp,
+ query: "feature/",
+ limit: 10,
+ });
+
+ expect(result.totalCount).toBe(2);
+ expect(result.nextCursor).toBeNull();
+ expect(result.branches.map((branch) => branch.name)).toEqual([
+ "feature/demo",
+ "origin/feature/remote-only",
+ ]);
+ }),
+ );
});
// ── checkoutGitBranch ──
@@ -737,6 +802,9 @@ it.layer(TestLayer)("git integration", (it) => {
) {
return ok();
}
+ if (input.operation === "GitCore.statusDetails.defaultRef") {
+ return ok("refs/remotes/origin/main\n");
+ }
return Effect.fail(
new GitCommandError({
operation: input.operation,
@@ -800,6 +868,9 @@ it.layer(TestLayer)("git integration", (it) => {
) {
return ok();
}
+ if (input.operation === "GitCore.statusDetails.defaultRef") {
+ return ok("refs/remotes/origin/main\n");
+ }
return Effect.fail(
new GitCommandError({
operation: input.operation,
diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts
index 0a11abab5b..98b1400aba 100644
--- a/apps/server/src/git/Layers/GitCore.ts
+++ b/apps/server/src/git/Layers/GitCore.ts
@@ -18,7 +18,8 @@ import {
} from "effect";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
-import { GitCommandError } from "@t3tools/contracts";
+import { GitCommandError, type GitBranch } from "@t3tools/contracts";
+import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git";
import {
GitCore,
type ExecuteGitProgress,
@@ -49,6 +50,7 @@ const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5);
const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;
+const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100;
type TraceTailState = {
processedChars: number;
@@ -183,6 +185,40 @@ function parseBranchLine(line: string): { name: string; current: boolean } | nul
};
}
+function filterBranchesForListQuery(
+ branches: ReadonlyArray,
+ query?: string,
+): ReadonlyArray {
+ if (!query) {
+ return branches;
+ }
+
+ const normalizedQuery = query.toLowerCase();
+ return branches.filter((branch) => branch.name.toLowerCase().includes(normalizedQuery));
+}
+
+function paginateBranches(input: {
+ branches: ReadonlyArray;
+ cursor?: number | undefined;
+ limit?: number | undefined;
+}): {
+ branches: ReadonlyArray;
+ nextCursor: number | null;
+ totalCount: number;
+} {
+ const cursor = input.cursor ?? 0;
+ const limit = input.limit ?? GIT_LIST_BRANCHES_DEFAULT_LIMIT;
+ const totalCount = input.branches.length;
+ const branches = input.branches.slice(cursor, cursor + limit);
+ const nextCursor = cursor + branches.length < totalCount ? cursor + branches.length : null;
+
+ return {
+ branches,
+ nextCursor,
+ totalCount,
+ };
+}
+
function sanitizeRemoteName(value: string): string {
const sanitized = value
.trim()
@@ -1101,15 +1137,52 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) {
yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true }));
- const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all(
- [
- runGitStdout("GitCore.statusDetails.status", cwd, ["status", "--porcelain=2", "--branch"]),
- runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]),
- runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, ["diff", "--cached", "--numstat"]),
- ],
- { concurrency: "unbounded" },
+ const statusResult = yield* executeGit(
+ "GitCore.statusDetails.status",
+ cwd,
+ ["status", "--porcelain=2", "--branch"],
+ {
+ allowNonZeroExit: true,
+ },
);
+ if (statusResult.code !== 0) {
+ const stderr = statusResult.stderr.trim();
+ return yield* createGitCommandError(
+ "GitCore.statusDetails.status",
+ cwd,
+ ["status", "--porcelain=2", "--branch"],
+ stderr || "git status failed",
+ );
+ }
+
+ const [unstagedNumstatStdout, stagedNumstatStdout, defaultRefResult, hasOriginRemote] =
+ yield* Effect.all(
+ [
+ runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]),
+ runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, [
+ "diff",
+ "--cached",
+ "--numstat",
+ ]),
+ executeGit(
+ "GitCore.statusDetails.defaultRef",
+ cwd,
+ ["symbolic-ref", "refs/remotes/origin/HEAD"],
+ {
+ allowNonZeroExit: true,
+ },
+ ),
+ originRemoteExists(cwd).pipe(Effect.catch(() => Effect.succeed(false))),
+ ],
+ { concurrency: "unbounded" },
+ );
+ const statusStdout = statusResult.stdout;
+ const defaultBranch =
+ defaultRefResult.code === 0
+ ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
+ : null;
+
let branch: string | null = null;
let upstreamRef: string | null = null;
let aheadCount = 0;
@@ -1176,6 +1249,12 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
files.sort((a, b) => a.path.localeCompare(b.path));
return {
+ isRepo: true,
+ hasOriginRemote,
+ isDefaultBranch:
+ branch !== null &&
+ (branch === defaultBranch ||
+ (defaultBranch === null && (branch === "main" || branch === "master"))),
branch,
upstreamRef,
hasWorkingTreeChanges,
@@ -1193,6 +1272,9 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
const status: GitCoreShape["status"] = (input) =>
statusDetails(input.cwd).pipe(
Effect.map((details) => ({
+ isRepo: details.isRepo,
+ hasOriginRemote: details.hasOriginRemote,
+ isDefaultBranch: details.isDefaultBranch,
branch: details.branch,
hasWorkingTreeChanges: details.hasWorkingTreeChanges,
workingTree: details.workingTree,
@@ -1571,7 +1653,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
if (localBranchResult.code !== 0) {
const stderr = localBranchResult.stderr.trim();
if (stderr.toLowerCase().includes("not a git repository")) {
- return { branches: [], isRepo: false, hasOriginRemote: false };
+ return {
+ branches: [],
+ isRepo: false,
+ hasOriginRemote: false,
+ nextCursor: null,
+ totalCount: 0,
+ };
}
return yield* createGitCommandError(
"GitCore.listBranches",
@@ -1735,9 +1823,22 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
})
: [];
- const branches = [...localBranches, ...remoteBranches];
+ const branches = paginateBranches({
+ branches: filterBranchesForListQuery(
+ dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]),
+ input.query,
+ ),
+ cursor: input.cursor,
+ limit: input.limit,
+ });
- return { branches, isRepo: true, hasOriginRemote: remoteNames.includes("origin") };
+ return {
+ branches: [...branches.branches],
+ isRepo: true,
+ hasOriginRemote: remoteNames.includes("origin"),
+ nextCursor: branches.nextCursor,
+ totalCount: branches.totalCount,
+ };
});
const createWorktree: GitCoreShape["createWorktree"] = Effect.fn("createWorktree")(
diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts
index 6fd55030fd..9ee2ed97ad 100644
--- a/apps/server/src/git/Layers/GitManager.test.ts
+++ b/apps/server/src/git/Layers/GitManager.test.ts
@@ -660,6 +660,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
});
const status = yield* manager.status({ cwd: repoDir });
+ expect(status.isRepo).toBe(true);
+ expect(status.hasOriginRemote).toBe(true);
+ expect(status.isDefaultBranch).toBe(false);
expect(status.branch).toBe("feature/status-open-pr");
expect(status.pr).toEqual({
number: 13,
@@ -672,6 +675,32 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
}),
);
+ it.effect("status returns an explicit non-repo result for non-git directories", () =>
+ Effect.gen(function* () {
+ const cwd = yield* makeTempDir("t3code-git-manager-non-repo-");
+ const { manager } = yield* makeManager();
+
+ const status = yield* manager.status({ cwd });
+
+ expect(status).toEqual({
+ isRepo: false,
+ hasOriginRemote: false,
+ isDefaultBranch: false,
+ branch: null,
+ hasWorkingTreeChanges: false,
+ workingTree: {
+ files: [],
+ insertions: 0,
+ deletions: 0,
+ },
+ hasUpstream: false,
+ aheadCount: 0,
+ behindCount: 0,
+ pr: null,
+ });
+ }),
+ );
+
it.effect("status briefly caches repeated lookups for the same cwd", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts
index ca9d562c03..363e6f7de9 100644
--- a/apps/server/src/git/Layers/GitManager.ts
+++ b/apps/server/src/git/Layers/GitManager.ts
@@ -5,6 +5,7 @@ import { Cache, Duration, Effect, Exit, FileSystem, Layer, Option, Path, Ref } f
import {
GitActionProgressEvent,
GitActionProgressPhase,
+ GitCommandError,
GitRunStackedActionResult,
GitStackedAction,
ModelSelection,
@@ -22,7 +23,7 @@ import {
type GitManagerShape,
type GitRunStackedActionOptions,
} from "../Services/GitManager.ts";
-import { GitCore } from "../Services/GitCore.ts";
+import { GitCore, GitStatusDetails } from "../Services/GitCore.ts";
import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts";
import { TextGeneration } from "../Services/TextGeneration.ts";
import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts";
@@ -38,6 +39,10 @@ const STATUS_RESULT_CACHE_CAPACITY = 2_048;
type StripProgressContext = T extends any ? Omit : never;
type GitActionProgressPayload = StripProgressContext;
+function isNotGitRepositoryError(error: GitCommandError): boolean {
+ return error.message.toLowerCase().includes("not a git repository");
+}
+
interface OpenPrInfo {
number: number;
title: string;
@@ -688,10 +693,25 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp";
const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd);
const readStatus = Effect.fn("readStatus")(function* (cwd: string) {
- const details = yield* gitCore.statusDetails(cwd);
+ const details = yield* gitCore.statusDetails(cwd).pipe(
+ Effect.catchIf(isNotGitRepositoryError, () =>
+ Effect.succeed({
+ isRepo: false,
+ hasOriginRemote: false,
+ isDefaultBranch: false,
+ branch: null,
+ upstreamRef: null,
+ hasWorkingTreeChanges: false,
+ workingTree: { files: [], insertions: 0, deletions: 0 },
+ hasUpstream: false,
+ aheadCount: 0,
+ behindCount: 0,
+ } satisfies GitStatusDetails),
+ ),
+ );
const pr =
- details.branch !== null
+ details.isRepo && details.branch !== null
? yield* findLatestPr(cwd, {
branch: details.branch,
upstreamRef: details.upstreamRef,
@@ -702,6 +722,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
: null;
return {
+ isRepo: details.isRepo,
+ hasOriginRemote: details.hasOriginRemote,
+ isDefaultBranch: details.isDefaultBranch,
branch: details.branch,
hasWorkingTreeChanges: details.hasWorkingTreeChanges,
workingTree: details.workingTree,
@@ -908,12 +931,6 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
return parsed[0] ?? null;
});
- const isDefaultBranch = Effect.fn("isDefaultBranch")(function* (cwd: string, branch: string) {
- const branches = yield* gitCore.listBranches({ cwd });
- const currentBranch = branches.branches.find((candidate) => candidate.name === branch);
- return currentBranch?.isDefault ?? (branch === "main" || branch === "master");
- });
-
const buildCompletionToast = Effect.fn("buildCompletionToast")(function* (
cwd: string,
result: Pick,
@@ -935,11 +952,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
upstreamRef: finalStatus.upstreamRef,
hasUpstream: finalStatus.hasUpstream,
};
- currentBranchIsDefault = yield* isDefaultBranch(cwd, finalStatus.branch).pipe(
- Effect.catch(() =>
- Effect.succeed(finalStatus.branch === "main" || finalStatus.branch === "master"),
- ),
- );
+ currentBranchIsDefault = finalStatus.isDefaultBranch;
}
}
diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts
index 53829f37b8..7bf7585d11 100644
--- a/apps/server/src/server.test.ts
+++ b/apps/server/src/server.test.ts
@@ -822,6 +822,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
gitManager: {
status: () =>
Effect.succeed({
+ isRepo: true,
+ hasOriginRemote: true,
+ isDefaultBranch: true,
branch: "main",
hasWorkingTreeChanges: false,
workingTree: { files: [], insertions: 0, deletions: 0 },
@@ -922,6 +925,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
],
isRepo: true,
hasOriginRemote: true,
+ nextCursor: null,
+ totalCount: 1,
}),
createWorktree: () =>
Effect.succeed({
diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts
index 99928441c7..c9e336bf48 100644
--- a/apps/web/src/components/BranchToolbar.logic.ts
+++ b/apps/web/src/components/BranchToolbar.logic.ts
@@ -1,5 +1,9 @@
import type { GitBranch } from "@t3tools/contracts";
import { Schema } from "effect";
+export {
+ dedupeRemoteBranchesWithLocalMatches,
+ deriveLocalBranchNameFromRemoteRef,
+} from "@t3tools/shared/git";
export const EnvMode = Schema.Literals(["local", "worktree"]);
export type EnvMode = typeof EnvMode.Type;
@@ -43,58 +47,6 @@ export function resolveBranchToolbarValue(input: {
return currentGitBranch ?? activeThreadBranch;
}
-export function deriveLocalBranchNameFromRemoteRef(branchName: string): string {
- const firstSeparatorIndex = branchName.indexOf("/");
- if (firstSeparatorIndex <= 0 || firstSeparatorIndex === branchName.length - 1) {
- return branchName;
- }
- return branchName.slice(firstSeparatorIndex + 1);
-}
-
-function deriveLocalBranchNameCandidatesFromRemoteRef(
- branchName: string,
- remoteName?: string,
-): ReadonlyArray {
- const candidates = new Set();
- const firstSlashCandidate = deriveLocalBranchNameFromRemoteRef(branchName);
- if (firstSlashCandidate.length > 0) {
- candidates.add(firstSlashCandidate);
- }
-
- if (remoteName) {
- const remotePrefix = `${remoteName}/`;
- if (branchName.startsWith(remotePrefix) && branchName.length > remotePrefix.length) {
- candidates.add(branchName.slice(remotePrefix.length));
- }
- }
-
- return [...candidates];
-}
-
-export function dedupeRemoteBranchesWithLocalMatches(
- branches: ReadonlyArray,
-): ReadonlyArray {
- const localBranchNames = new Set(
- branches.filter((branch) => !branch.isRemote).map((branch) => branch.name),
- );
-
- return branches.filter((branch) => {
- if (!branch.isRemote) {
- return true;
- }
-
- if (branch.remoteName !== "origin") {
- return true;
- }
-
- const localBranchCandidates = deriveLocalBranchNameCandidatesFromRemoteRef(
- branch.name,
- branch.remoteName,
- );
- return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate));
- });
-}
-
export function resolveBranchSelectionTarget(input: {
activeProjectCwd: string;
activeWorktreePath: string | null;
diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx
index c1a43adc90..e1dbb8756c 100644
--- a/apps/web/src/components/BranchToolbarBranchSelector.tsx
+++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx
@@ -1,5 +1,5 @@
import type { GitBranch } from "@t3tools/contracts";
-import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual";
import { ChevronDownIcon } from "lucide-react";
import {
@@ -15,7 +15,7 @@ import {
} from "react";
import {
- gitBranchesQueryOptions,
+ gitBranchSearchInfiniteQueryOptions,
gitQueryKeys,
gitStatusQueryOptions,
invalidateGitQueries,
@@ -23,7 +23,6 @@ import {
import { readNativeApi } from "../nativeApi";
import { parsePullRequestReference } from "../pullRequestReference";
import {
- dedupeRemoteBranchesWithLocalMatches,
deriveLocalBranchNameFromRemoteRef,
EnvMode,
resolveBranchSelectionTarget,
@@ -38,6 +37,7 @@ import {
ComboboxItem,
ComboboxList,
ComboboxPopup,
+ ComboboxStatus,
ComboboxTrigger,
} from "./ui/combobox";
import { toastManager } from "./ui/toast";
@@ -89,11 +89,33 @@ export function BranchToolbarBranchSelector({
const [branchQuery, setBranchQuery] = useState("");
const deferredBranchQuery = useDeferredValue(branchQuery);
- const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd));
const branchStatusQuery = useQuery(gitStatusQueryOptions(branchCwd));
+ const trimmedBranchQuery = branchQuery.trim();
+ const deferredTrimmedBranchQuery = deferredBranchQuery.trim();
+
+ useEffect(() => {
+ if (!branchCwd) return;
+ void queryClient.prefetchInfiniteQuery(
+ gitBranchSearchInfiniteQueryOptions({ cwd: branchCwd, query: "" }),
+ );
+ }, [branchCwd, queryClient]);
+
+ const {
+ data: branchesSearchData,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ isPending: isBranchesSearchPending,
+ } = useInfiniteQuery(
+ gitBranchSearchInfiniteQueryOptions({
+ cwd: branchCwd,
+ query: deferredTrimmedBranchQuery,
+ enabled: isBranchMenuOpen,
+ }),
+ );
const branches = useMemo(
- () => dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []),
- [branchesQuery.data?.branches],
+ () => branchesSearchData?.pages.flatMap((page) => page.branches) ?? [],
+ [branchesSearchData?.pages],
);
const currentGitBranch =
branchStatusQuery.data?.branch ?? branches.find((branch) => branch.current)?.name ?? null;
@@ -108,8 +130,6 @@ export function BranchToolbarBranchSelector({
() => new Map(branches.map((branch) => [branch.name, branch] as const)),
[branches],
);
- const trimmedBranchQuery = branchQuery.trim();
- const deferredTrimmedBranchQuery = deferredBranchQuery.trim();
const normalizedDeferredBranchQuery = deferredTrimmedBranchQuery.toLowerCase();
const prReference = parsePullRequestReference(trimmedBranchQuery);
const isSelectingWorktreeBase =
@@ -156,6 +176,14 @@ export function BranchToolbarBranchSelector({
);
const [isBranchActionPending, startBranchActionTransition] = useTransition();
const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40;
+ const totalBranchCount = branchesSearchData?.pages[0]?.totalCount ?? 0;
+ const branchStatusText = isBranchesSearchPending
+ ? "Loading branches..."
+ : isFetchingNextPage
+ ? "Loading more branches..."
+ : hasNextPage
+ ? `Showing ${branches.length} of ${totalBranchCount} branches`
+ : null;
const runBranchAction = (action: () => Promise) => {
startBranchActionTransition(async () => {
@@ -213,7 +241,7 @@ export function BranchToolbarBranchSelector({
let nextBranchName = selectedBranchName;
if (branch.isRemote) {
- const status = await api.git.status({ cwd: branchCwd }).catch(() => null);
+ const status = await api.git.status({ cwd: selectionTarget.checkoutCwd }).catch(() => null);
if (status?.branch) {
nextBranchName = status.branch;
}
@@ -295,6 +323,24 @@ export function BranchToolbarBranchSelector({
);
const branchListScrollElementRef = useRef(null);
+ const maybeFetchNextBranchPage = useCallback(() => {
+ if (!isBranchMenuOpen || !hasNextPage || isFetchingNextPage) {
+ return;
+ }
+
+ const scrollElement = branchListScrollElementRef.current;
+ if (!scrollElement) {
+ return;
+ }
+
+ const distanceFromBottom =
+ scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight;
+ if (distanceFromBottom > 96) {
+ return;
+ }
+
+ void fetchNextPage().catch(() => undefined);
+ }, [fetchNextPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]);
const branchListVirtualizer = useVirtualizer({
count: filteredBranchPickerItems.length,
estimateSize: (index) =>
@@ -331,6 +377,35 @@ export function BranchToolbarBranchSelector({
shouldVirtualizeBranchList,
]);
+ useEffect(() => {
+ if (!isBranchMenuOpen) {
+ return;
+ }
+
+ branchListScrollElementRef.current?.scrollTo({ top: 0 });
+ }, [deferredTrimmedBranchQuery, isBranchMenuOpen]);
+
+ useEffect(() => {
+ const scrollElement = branchListScrollElementRef.current;
+ if (!scrollElement || !isBranchMenuOpen) {
+ return;
+ }
+
+ const handleScroll = () => {
+ maybeFetchNextBranchPage();
+ };
+
+ scrollElement.addEventListener("scroll", handleScroll, { passive: true });
+ handleScroll();
+ return () => {
+ scrollElement.removeEventListener("scroll", handleScroll);
+ };
+ }, [isBranchMenuOpen, maybeFetchNextBranchPage]);
+
+ useEffect(() => {
+ maybeFetchNextBranchPage();
+ }, [branches.length, maybeFetchNextBranchPage]);
+
const triggerLabel = getBranchTriggerLabel({
activeWorktreePath,
effectiveEnvMode,
@@ -425,7 +500,7 @@ export function BranchToolbarBranchSelector({
}
className="text-muted-foreground/70 hover:text-foreground/80"
- disabled={(branchesQuery.isLoading && branches.length === 0) || isBranchActionPending}
+ disabled={(isBranchesSearchPending && branches.length === 0) || isBranchActionPending}
>
{triggerLabel}
@@ -468,6 +543,7 @@ export function BranchToolbarBranchSelector({
filteredBranchPickerItems.map((itemValue, index) => renderPickerItem(itemValue, index))
)}
+ {branchStatusText ? {branchStatusText} : null}
);
diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx
index 0e5f573d54..b0263cbf40 100644
--- a/apps/web/src/components/ChatView.browser.tsx
+++ b/apps/web/src/components/ChatView.browser.tsx
@@ -632,6 +632,8 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown {
return {
isRepo: true,
hasOriginRemote: true,
+ nextCursor: null,
+ totalCount: 1,
branches: [
{
name: "main",
@@ -644,6 +646,9 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown {
}
if (tag === WS_METHODS.gitStatus) {
return {
+ isRepo: true,
+ hasOriginRemote: true,
+ isDefaultBranch: true,
branch: "main",
hasWorkingTreeChanges: false,
workingTree: {
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 76133712d4..07561e8e64 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -26,7 +26,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useDebouncedValue } from "@tanstack/react-pacer";
import { useNavigate, useSearch } from "@tanstack/react-router";
-import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
+import { gitCreateWorktreeMutationOptions, gitStatusQueryOptions } from "~/lib/gitReactQuery";
import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
import { isElectron } from "../env";
import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
@@ -1201,7 +1201,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
(debouncerState) => ({ isPending: debouncerState.isPending }),
);
const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
- const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd));
+ const gitStatusQuery = useQuery(gitStatusQueryOptions(gitCwd));
const keybindings = useServerKeybindings();
const availableEditors = useServerAvailableEditors();
const modelOptionsByProvider = useMemo(
@@ -1338,7 +1338,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
});
}, [activeProjectCwd, activeThreadWorktreePath]);
// Default true while loading to avoid toolbar flicker.
- const isGitRepo = branchesQuery.data?.isRepo ?? true;
+ const isGitRepo = gitStatusQuery.data?.isRepo ?? true;
const terminalShortcutLabelOptions = useMemo(
() => ({
context: {
diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx
index fadb8cb69d..dc376a5b3d 100644
--- a/apps/web/src/components/DiffPanel.tsx
+++ b/apps/web/src/components/DiffPanel.tsx
@@ -19,7 +19,7 @@ import {
useState,
} from "react";
import { openInPreferredEditor } from "../editorPreferences";
-import { gitBranchesQueryOptions } from "~/lib/gitReactQuery";
+import { gitStatusQueryOptions } from "~/lib/gitReactQuery";
import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
import { cn } from "~/lib/utils";
import { readNativeApi } from "../nativeApi";
@@ -189,8 +189,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined,
);
const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd;
- const gitBranchesQuery = useQuery(gitBranchesQueryOptions(activeCwd ?? null));
- const isGitRepo = gitBranchesQuery.data?.isRepo ?? true;
+ const gitStatusQuery = useQuery(gitStatusQueryOptions(activeCwd ?? null));
+ const isGitRepo = gitStatusQuery.data?.isRepo ?? true;
const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
useTurnDiffSummaries(activeThread);
const orderedTurnDiffSummaries = useMemo(
diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts
index f21c924c79..6ed3033085 100644
--- a/apps/web/src/components/GitActionsControl.logic.test.ts
+++ b/apps/web/src/components/GitActionsControl.logic.test.ts
@@ -13,6 +13,9 @@ import {
function status(overrides: Partial = {}): GitStatusResult {
return {
+ isRepo: true,
+ hasOriginRemote: true,
+ isDefaultBranch: false,
branch: "feature/test",
hasWorkingTreeChanges: false,
workingTree: {
diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx
index 6e811e6f4b..e56f77a60b 100644
--- a/apps/web/src/components/GitActionsControl.tsx
+++ b/apps/web/src/components/GitActionsControl.tsx
@@ -41,14 +41,12 @@ import { Textarea } from "~/components/ui/textarea";
import { toastManager } from "~/components/ui/toast";
import { openInPreferredEditor } from "~/editorPreferences";
import {
- gitBranchesQueryOptions,
gitInitMutationOptions,
gitMutationKeys,
gitPullMutationOptions,
gitRunStackedActionMutationOptions,
gitStatusQueryOptions,
invalidateGitStatusQuery,
- invalidateGitQueries,
} from "~/lib/gitReactQuery";
import { newCommandId, randomUUID } from "~/lib/utils";
import { resolvePathLinkTarget } from "~/terminal-links";
@@ -276,21 +274,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
);
const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd));
-
- const { data: branchList = null } = useQuery(gitBranchesQueryOptions(gitCwd));
// Default to true while loading so we don't flash init controls.
- const isRepo = branchList?.isRepo ?? true;
- const hasOriginRemote = branchList?.hasOriginRemote ?? false;
- const currentBranch = branchList?.branches.find((branch) => branch.current)?.name ?? null;
- const isGitStatusOutOfSync =
- !!gitStatus?.branch && !!currentBranch && gitStatus.branch !== currentBranch;
-
- useEffect(() => {
- if (!isGitStatusOutOfSync) return;
- void invalidateGitQueries(queryClient, { cwd: gitCwd });
- }, [gitCwd, isGitStatusOutOfSync, queryClient]);
-
- const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus;
+ const isRepo = gitStatus?.isRepo ?? true;
+ const hasOriginRemote = gitStatus?.hasOriginRemote ?? false;
+ const gitStatusForActions = gitStatus;
const allFiles = gitStatusForActions?.workingTree.files ?? [];
const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path));
@@ -334,11 +321,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
]);
const isDefaultBranch = useMemo(() => {
- const branchName = gitStatusForActions?.branch;
- if (!branchName) return false;
- const current = branchList?.branches.find((branch) => branch.name === branchName);
- return current?.isDefault ?? (branchName === "main" || branchName === "master");
- }, [branchList?.branches, gitStatusForActions?.branch]);
+ return gitStatusForActions?.isDefaultBranch ?? false;
+ }, [gitStatusForActions?.isDefaultBranch]);
const gitActionMenuItems = useMemo(
() => buildMenuItems(gitStatusForActions, isGitActionRunning, hasOriginRemote),
@@ -879,11 +863,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
Behind upstream. Pull/rebase first.
)}
- {isGitStatusOutOfSync && (
-
- Refreshing git status...
-
- )}
{gitStatusError && (
{gitStatusError.message}
)}
diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx
index 9581e72cd2..b8398e7b8f 100644
--- a/apps/web/src/components/KeybindingsToast.browser.tsx
+++ b/apps/web/src/components/KeybindingsToast.browser.tsx
@@ -157,11 +157,16 @@ function resolveWsRpc(tag: string): unknown {
return {
isRepo: true,
hasOriginRemote: true,
+ nextCursor: null,
+ totalCount: 1,
branches: [{ name: "main", current: true, isDefault: true, worktreePath: null }],
};
}
if (tag === WS_METHODS.gitStatus) {
return {
+ isRepo: true,
+ hasOriginRemote: true,
+ isDefaultBranch: true,
branch: "main",
hasWorkingTreeChanges: false,
workingTree: { files: [], insertions: 0, deletions: 0 },
diff --git a/apps/web/src/lib/gitReactQuery.test.ts b/apps/web/src/lib/gitReactQuery.test.ts
index be644d8ff6..d260c2aee8 100644
--- a/apps/web/src/lib/gitReactQuery.test.ts
+++ b/apps/web/src/lib/gitReactQuery.test.ts
@@ -9,8 +9,11 @@ vi.mock("../wsRpcClient", () => ({
getWsRpcClient: vi.fn(),
}));
+import type { InfiniteData } from "@tanstack/react-query";
+import type { GitListBranchesResult } from "@t3tools/contracts";
+
import {
- gitBranchesQueryOptions,
+ gitBranchSearchInfiniteQueryOptions,
gitMutationKeys,
gitQueryKeys,
gitPreparePullRequestThreadMutationOptions,
@@ -21,6 +24,19 @@ import {
invalidateGitQueries,
} from "./gitReactQuery";
+const BRANCH_QUERY_RESULT: GitListBranchesResult = {
+ branches: [],
+ isRepo: true,
+ hasOriginRemote: true,
+ nextCursor: null,
+ totalCount: 0,
+};
+
+const BRANCH_SEARCH_RESULT: InfiniteData = {
+ pages: [BRANCH_QUERY_RESULT],
+ pageParams: [0],
+};
+
describe("gitMutationKeys", () => {
it("scopes stacked action keys by cwd", () => {
expect(gitMutationKeys.runStackedAction("/repo/a")).not.toEqual(
@@ -69,9 +85,21 @@ describe("invalidateGitQueries", () => {
const queryClient = new QueryClient();
queryClient.setQueryData(gitQueryKeys.status("/repo/a"), { ok: "a" });
- queryClient.setQueryData(gitQueryKeys.branches("/repo/a"), { ok: "a-branches" });
+ queryClient.setQueryData(
+ gitBranchSearchInfiniteQueryOptions({
+ cwd: "/repo/a",
+ query: "feature",
+ }).queryKey,
+ BRANCH_SEARCH_RESULT,
+ );
queryClient.setQueryData(gitQueryKeys.status("/repo/b"), { ok: "b" });
- queryClient.setQueryData(gitQueryKeys.branches("/repo/b"), { ok: "b-branches" });
+ queryClient.setQueryData(
+ gitBranchSearchInfiniteQueryOptions({
+ cwd: "/repo/b",
+ query: "feature",
+ }).queryKey,
+ BRANCH_SEARCH_RESULT,
+ );
await invalidateGitQueries(queryClient, { cwd: "/repo/a" });
@@ -79,13 +107,23 @@ describe("invalidateGitQueries", () => {
queryClient.getQueryState(gitStatusQueryOptions("/repo/a").queryKey)?.isInvalidated,
).toBe(true);
expect(
- queryClient.getQueryState(gitBranchesQueryOptions("/repo/a").queryKey)?.isInvalidated,
+ queryClient.getQueryState(
+ gitBranchSearchInfiniteQueryOptions({
+ cwd: "/repo/a",
+ query: "feature",
+ }).queryKey,
+ )?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(gitStatusQueryOptions("/repo/b").queryKey)?.isInvalidated,
).toBe(false);
expect(
- queryClient.getQueryState(gitBranchesQueryOptions("/repo/b").queryKey)?.isInvalidated,
+ queryClient.getQueryState(
+ gitBranchSearchInfiniteQueryOptions({
+ cwd: "/repo/b",
+ query: "feature",
+ }).queryKey,
+ )?.isInvalidated,
).toBe(false);
});
});
@@ -95,7 +133,6 @@ describe("invalidateGitStatusQuery", () => {
const queryClient = new QueryClient();
queryClient.setQueryData(gitQueryKeys.status("/repo/a"), { ok: "a" });
- queryClient.setQueryData(gitQueryKeys.branches("/repo/a"), { ok: "a-branches" });
queryClient.setQueryData(gitQueryKeys.status("/repo/b"), { ok: "b" });
await invalidateGitStatusQuery(queryClient, "/repo/a");
@@ -103,9 +140,6 @@ describe("invalidateGitStatusQuery", () => {
expect(
queryClient.getQueryState(gitStatusQueryOptions("/repo/a").queryKey)?.isInvalidated,
).toBe(true);
- expect(
- queryClient.getQueryState(gitBranchesQueryOptions("/repo/a").queryKey)?.isInvalidated,
- ).toBe(false);
expect(
queryClient.getQueryState(gitStatusQueryOptions("/repo/b").queryKey)?.isInvalidated,
).toBe(false);
diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts
index 25411db7bd..53f04848c7 100644
--- a/apps/web/src/lib/gitReactQuery.ts
+++ b/apps/web/src/lib/gitReactQuery.ts
@@ -1,5 +1,10 @@
import { type GitActionProgressEvent, type GitStackedAction } from "@t3tools/contracts";
-import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query";
+import {
+ infiniteQueryOptions,
+ mutationOptions,
+ queryOptions,
+ type QueryClient,
+} from "@tanstack/react-query";
import { ensureNativeApi } from "../nativeApi";
import { getWsRpcClient } from "../wsRpcClient";
@@ -7,11 +12,14 @@ const GIT_STATUS_STALE_TIME_MS = 5_000;
const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000;
const GIT_BRANCHES_STALE_TIME_MS = 15_000;
const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000;
+const GIT_BRANCHES_PAGE_SIZE = 100;
export const gitQueryKeys = {
all: ["git"] as const,
status: (cwd: string | null) => ["git", "status", cwd] as const,
branches: (cwd: string | null) => ["git", "branches", cwd] as const,
+ branchSearch: (cwd: string | null, query: string) =>
+ ["git", "branches", cwd, "search", query] as const,
};
export const gitMutationKeys = {
@@ -59,15 +67,28 @@ export function gitStatusQueryOptions(cwd: string | null) {
});
}
-export function gitBranchesQueryOptions(cwd: string | null) {
- return queryOptions({
- queryKey: gitQueryKeys.branches(cwd),
- queryFn: async () => {
+export function gitBranchSearchInfiniteQueryOptions(input: {
+ cwd: string | null;
+ query: string;
+ enabled?: boolean;
+}) {
+ const normalizedQuery = input.query.trim();
+
+ return infiniteQueryOptions({
+ queryKey: gitQueryKeys.branchSearch(input.cwd, normalizedQuery),
+ initialPageParam: 0,
+ queryFn: async ({ pageParam }) => {
const api = ensureNativeApi();
- if (!cwd) throw new Error("Git branches are unavailable.");
- return api.git.listBranches({ cwd });
+ if (!input.cwd) throw new Error("Git branches are unavailable.");
+ return api.git.listBranches({
+ cwd: input.cwd,
+ ...(normalizedQuery.length > 0 ? { query: normalizedQuery } : {}),
+ cursor: pageParam,
+ limit: GIT_BRANCHES_PAGE_SIZE,
+ });
},
- enabled: cwd !== null,
+ getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
+ enabled: input.cwd !== null && (input.enabled ?? true),
staleTime: GIT_BRANCHES_STALE_TIME_MS,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts
index 03fc050d4a..525ae7ee0d 100644
--- a/packages/contracts/src/git.ts
+++ b/packages/contracts/src/git.ts
@@ -2,6 +2,7 @@ import { Schema } from "effect";
import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas";
const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
+const GIT_LIST_BRANCHES_MAX_LIMIT = 200;
// Domain Types
@@ -120,6 +121,11 @@ export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;
export const GitListBranchesInput = Schema.Struct({
cwd: TrimmedNonEmptyStringSchema,
+ query: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(256))),
+ cursor: Schema.optional(NonNegativeInt),
+ limit: Schema.optional(
+ PositiveInt.check(Schema.isLessThanOrEqualTo(GIT_LIST_BRANCHES_MAX_LIMIT)),
+ ),
});
export type GitListBranchesInput = typeof GitListBranchesInput.Type;
@@ -180,7 +186,10 @@ const GitStatusPr = Schema.Struct({
});
export const GitStatusResult = Schema.Struct({
- branch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr),
+ isRepo: Schema.Boolean,
+ hasOriginRemote: Schema.Boolean,
+ isDefaultBranch: Schema.Boolean,
+ branch: Schema.NullOr(TrimmedNonEmptyStringSchema),
hasWorkingTreeChanges: Schema.Boolean,
workingTree: Schema.Struct({
files: Schema.Array(
@@ -204,6 +213,8 @@ export const GitListBranchesResult = Schema.Struct({
branches: Schema.Array(GitBranch),
isRepo: Schema.Boolean,
hasOriginRemote: Schema.Boolean,
+ nextCursor: NonNegativeInt.pipe(Schema.NullOr),
+ totalCount: NonNegativeInt,
});
export type GitListBranchesResult = typeof GitListBranchesResult.Type;
diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts
index bbd290393f..90bc655a76 100644
--- a/packages/shared/src/git.ts
+++ b/packages/shared/src/git.ts
@@ -1,3 +1,5 @@
+import type { GitBranch } from "@t3tools/contracts";
+
/**
* Sanitize an arbitrary string into a valid, lowercase git branch fragment.
* Strips quotes, collapses separators, limits to 64 chars.
@@ -59,3 +61,61 @@ export function resolveAutoFeatureBranchName(
return `${resolvedBase}-${suffix}`;
}
+
+/**
+ * Strip the remote prefix from a remote ref such as `origin/feature/demo`.
+ */
+export function deriveLocalBranchNameFromRemoteRef(branchName: string): string {
+ const firstSeparatorIndex = branchName.indexOf("/");
+ if (firstSeparatorIndex <= 0 || firstSeparatorIndex === branchName.length - 1) {
+ return branchName;
+ }
+ return branchName.slice(firstSeparatorIndex + 1);
+}
+
+function deriveLocalBranchNameCandidatesFromRemoteRef(
+ branchName: string,
+ remoteName?: string,
+): ReadonlyArray {
+ const candidates = new Set();
+ const firstSlashCandidate = deriveLocalBranchNameFromRemoteRef(branchName);
+ if (firstSlashCandidate.length > 0) {
+ candidates.add(firstSlashCandidate);
+ }
+
+ if (remoteName) {
+ const remotePrefix = `${remoteName}/`;
+ if (branchName.startsWith(remotePrefix) && branchName.length > remotePrefix.length) {
+ candidates.add(branchName.slice(remotePrefix.length));
+ }
+ }
+
+ return [...candidates];
+}
+
+/**
+ * Hide `origin/*` remote refs when a matching local branch already exists.
+ */
+export function dedupeRemoteBranchesWithLocalMatches(
+ branches: ReadonlyArray,
+): ReadonlyArray {
+ const localBranchNames = new Set(
+ branches.filter((branch) => !branch.isRemote).map((branch) => branch.name),
+ );
+
+ return branches.filter((branch) => {
+ if (!branch.isRemote) {
+ return true;
+ }
+
+ if (branch.remoteName !== "origin") {
+ return true;
+ }
+
+ const localBranchCandidates = deriveLocalBranchNameCandidatesFromRemoteRef(
+ branch.name,
+ branch.remoteName,
+ );
+ return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate));
+ });
+}
diff --git a/scripts/package.json b/scripts/package.json
index d9db897ea2..157763b398 100644
--- a/scripts/package.json
+++ b/scripts/package.json
@@ -4,8 +4,6 @@
"type": "module",
"scripts": {
"prepare": "effect-language-service patch",
- "claude-fast-mode-probe": "bun run claude-fast-mode-probe.ts",
- "claude-haiku-thinking-probe": "bun run claude-haiku-thinking-probe.ts",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},