From e7a63e0b6e5f0e51bf5856a3ab99d1c33e25066b Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Sun, 5 Apr 2026 19:10:53 +0200 Subject: [PATCH] Add git sync settings to control background polling and suppress SSH prompts Background git fetches every 15s trigger repeated 1Password/SSH agent popups when credentials aren't cached (fixes #1467). Adds a new "Git Sync" settings section with a polling mode selector (All sessions / Active session only / Disabled) and a quiet background fetches toggle that suppresses credential prompts via GIT_TERMINAL_PROMPT=0, SSH_ASKPASS="", etc. All defaults preserve existing behaviour. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Layers/CheckpointStore.test.ts | 2 + apps/server/src/git/Layers/GitCore.test.ts | 2 + apps/server/src/git/Layers/GitCore.ts | 52 ++++++++---- apps/server/src/git/Layers/GitManager.test.ts | 2 + .../Layers/CheckpointReactor.test.ts | 2 + .../workspace/Layers/WorkspaceEntries.test.ts | 2 + .../Layers/WorkspaceFileSystem.test.ts | 2 + .../BranchToolbarBranchSelector.tsx | 16 +++- apps/web/src/components/ChatView.tsx | 5 +- apps/web/src/components/DiffPanel.tsx | 6 +- apps/web/src/components/GitActionsControl.tsx | 6 +- apps/web/src/components/Sidebar.tsx | 5 +- .../components/settings/SettingsPanels.tsx | 80 +++++++++++++++++++ apps/web/src/lib/gitReactQuery.ts | 20 +++-- packages/contracts/src/settings.ts | 11 +++ 15 files changed, 184 insertions(+), 29 deletions(-) diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts index 6e7b18277c..b6f254ed98 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts @@ -12,6 +12,7 @@ import { GitCoreLive } from "../../git/Layers/GitCore.ts"; import { GitCore } from "../../git/Services/GitCore.ts"; import { GitCommandError } from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { ThreadId } from "@t3tools/contracts"; const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { @@ -19,6 +20,7 @@ const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { }); const GitCoreTestLayer = GitCoreLive.pipe( Layer.provide(ServerConfigLayer), + Layer.provide(ServerSettingsService.layerTest()), Layer.provide(NodeServices.layer), ); const CheckpointStoreTestLayer = CheckpointStoreLive.pipe( diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 5e4416d8b9..7c18f4c045 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -11,12 +11,14 @@ import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; import { GitCommandError } from "@t3tools/contracts"; import { type ProcessRunResult, runProcess } from "../../processRunner.ts"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; // ── Helpers ── const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-core-test-" }); const GitCoreTestLayer = GitCoreLive.pipe( Layer.provide(ServerConfigLayer), + Layer.provide(ServerSettingsService.layerTest()), Layer.provide(NodeServices.layer), ); const TestLayer = Layer.mergeAll(NodeServices.layer, GitCoreTestLayer); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 1178a4b67e..051a07cbc0 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -36,6 +36,7 @@ import { parseRemoteRefWithRemoteNames, } from "../remoteRefs.ts"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -74,6 +75,7 @@ interface ExecuteGitOptions { maxOutputBytes?: number | undefined; truncateOutputAtMaxBytes?: boolean | undefined; progress?: ExecuteGitProgress | undefined; + env?: Record | undefined; } function parseBranchAb(value: string): { ahead: number; behind: number } { @@ -632,6 +634,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const { worktreesDir } = yield* ServerConfig; + const serverSettings = yield* ServerSettingsService; let executeRaw: GitCoreShape["execute"]; @@ -770,6 +773,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { cwd, args, ...(options.stdin !== undefined ? { stdin: options.stdin } : {}), + ...(options.env !== undefined ? { env: options.env } : {}), allowNonZeroExit: true, ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), @@ -893,20 +897,35 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const fetchUpstreamRefForStatus = ( gitCommonDir: string, upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, - ): Effect.Effect => { - const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; - const fetchCwd = - path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; - return executeGit( - "GitCore.fetchUpstreamRefForStatus", - fetchCwd, - ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], - { - allowNonZeroExit: true, - timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), - }, - ).pipe(Effect.asVoid); - }; + ): Effect.Effect => + Effect.gen(function* () { + const settings = yield* serverSettings.getSettings.pipe( + Effect.catch(() => Effect.succeed(null)), + ); + const quietEnv: Record = + settings?.gitQuietBackgroundChecks + ? { + GIT_TERMINAL_PROMPT: "0", + SSH_ASKPASS: "", + GIT_ASKPASS: "", + GCM_INTERACTIVE: "never", + } + : {}; + + const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; + const fetchCwd = + path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; + yield* executeGit( + "GitCore.fetchUpstreamRefForStatus", + fetchCwd, + ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], + { + allowNonZeroExit: true, + timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), + env: quietEnv, + }, + ); + }).pipe(Effect.asVoid); const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) { const gitCommonDir = yield* runGitStdout("GitCore.resolveGitCommonDir", cwd, [ @@ -940,6 +959,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const refreshStatusUpstreamIfStale = Effect.fn("refreshStatusUpstreamIfStale")(function* ( cwd: string, ) { + const settings = yield* serverSettings.getSettings.pipe( + Effect.catch(() => Effect.succeed(null)), + ); + if (settings?.gitPollingMode === "disabled") return; + const upstream = yield* resolveCurrentUpstream(cwd); if (!upstream) return; const gitCommonDir = yield* resolveGitCommonDir(cwd); diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 005bdb5bc6..2beb300100 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -624,6 +624,7 @@ function makeManager(input?: { const gitCoreLayer = GitCoreLive.pipe( Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(serverSettingsLayer), ); const managerLayer = Layer.mergeAll( @@ -649,6 +650,7 @@ const asThreadId = (threadId: string) => threadId as ThreadId; const GitManagerTestLayer = GitCoreLive.pipe( Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })), + Layer.provide(ServerSettingsService.layerTest()), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 72adb175f9..154eb960ba 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -20,6 +20,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; import { GitCoreLive } from "../../git/Layers/GitCore.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { CheckpointReactorLive } from "./CheckpointReactor.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; @@ -271,6 +272,7 @@ describe("CheckpointReactor", () => { Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(WorkspacePathsLive), Layer.provideMerge(GitCoreLive), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts index 960cb69bf1..d4fe48cc7a 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts @@ -6,6 +6,7 @@ import { Effect, FileSystem, Layer, Path, PlatformError } from "effect"; import { ServerConfig } from "../../config.ts"; import { GitCoreLive } from "../../git/Layers/GitCore.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { GitCore } from "../../git/Services/GitCore.ts"; import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; @@ -15,6 +16,7 @@ const TestLayer = Layer.empty.pipe( Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(WorkspacePathsLive), Layer.provideMerge(GitCoreLive), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-entries-test-", diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts index fcfd13c912..88fd578c77 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts @@ -4,6 +4,7 @@ import { Effect, FileSystem, Layer, Path } from "effect"; import { ServerConfig } from "../../config.ts"; import { GitCoreLive } from "../../git/Layers/GitCore.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "../Services/WorkspaceFileSystem.ts"; import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; @@ -20,6 +21,7 @@ const TestLayer = Layer.empty.pipe( Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(WorkspacePathsLive), Layer.provideMerge(GitCoreLive), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-files-test-", diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index e1dbb8756c..bbd5be0503 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -20,6 +20,7 @@ import { gitStatusQueryOptions, invalidateGitQueries, } from "../lib/gitReactQuery"; +import { useSettings } from "../hooks/useSettings"; import { readNativeApi } from "../nativeApi"; import { parsePullRequestReference } from "../pullRequestReference"; import { @@ -89,16 +90,24 @@ export function BranchToolbarBranchSelector({ const [branchQuery, setBranchQuery] = useState(""); const deferredBranchQuery = useDeferredValue(branchQuery); - const branchStatusQuery = useQuery(gitStatusQueryOptions(branchCwd)); + const { gitPollingMode } = useSettings(); + const gitPollingEnabled = gitPollingMode !== "disabled"; + const branchStatusQuery = useQuery( + gitStatusQueryOptions(branchCwd, { pollingEnabled: gitPollingEnabled }), + ); const trimmedBranchQuery = branchQuery.trim(); const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); useEffect(() => { if (!branchCwd) return; void queryClient.prefetchInfiniteQuery( - gitBranchSearchInfiniteQueryOptions({ cwd: branchCwd, query: "" }), + gitBranchSearchInfiniteQueryOptions({ + cwd: branchCwd, + query: "", + pollingEnabled: gitPollingEnabled, + }), ); - }, [branchCwd, queryClient]); + }, [branchCwd, queryClient, gitPollingEnabled]); const { data: branchesSearchData, @@ -111,6 +120,7 @@ export function BranchToolbarBranchSelector({ cwd: branchCwd, query: deferredTrimmedBranchQuery, enabled: isBranchMenuOpen, + pollingEnabled: gitPollingEnabled, }), ); const branches = useMemo( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f995bb4ce7..24e08fac84 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1399,7 +1399,10 @@ export default function ChatView({ threadId }: ChatViewProps) { (debouncerState) => ({ isPending: debouncerState.isPending }), ); const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; - const gitStatusQuery = useQuery(gitStatusQueryOptions(gitCwd)); + const gitPollingEnabled = settings.gitPollingMode !== "disabled"; + const gitStatusQuery = useQuery( + gitStatusQueryOptions(gitCwd, { pollingEnabled: gitPollingEnabled }), + ); const keybindings = useServerKeybindings(); const availableEditors = useServerAvailableEditors(); const modelOptionsByProvider = useMemo( diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index dc376a5b3d..0aec8768f1 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -189,7 +189,11 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, ); const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; - const gitStatusQuery = useQuery(gitStatusQueryOptions(activeCwd ?? null)); + const gitStatusQuery = useQuery( + gitStatusQueryOptions(activeCwd ?? null, { + pollingEnabled: settings.gitPollingMode !== "disabled", + }), + ); const isGitRepo = gitStatusQuery.data?.isRepo ?? true; const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 42882d000d..90e37b56dd 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -51,6 +51,7 @@ import { import { newCommandId, randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; import { readNativeApi } from "~/nativeApi"; +import { useSettings } from "~/hooks/useSettings"; import { useStore } from "~/store"; interface GitActionsControlProps { @@ -275,7 +276,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions [persistThreadBranchSync], ); - const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); + const { gitPollingMode } = useSettings(); + const { data: gitStatus = null, error: gitStatusError } = useQuery( + gitStatusQueryOptions(gitCwd, { pollingEnabled: gitPollingMode !== "disabled" }), + ); // Default to true while loading so we don't flash init controls. const isRepo = gitStatus?.isRepo ?? true; const hasOriginRemote = gitStatus?.hasOriginRemote ?? false; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 89ced46454..b9e04dc22b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -782,11 +782,12 @@ export default function Sidebar() { ], [threadGitTargets], ); + const sidebarGitPollingEnabled = appSettings.gitPollingMode === "all"; const threadGitStatusQueries = useQueries({ queries: threadGitStatusCwds.map((cwd) => ({ - ...gitStatusQueryOptions(cwd), + ...gitStatusQueryOptions(cwd, { pollingEnabled: sidebarGitPollingEnabled }), staleTime: 30_000, - refetchInterval: 60_000, + refetchInterval: sidebarGitPollingEnabled ? 60_000 : false, })), }); const prByThreadId = useMemo(() => { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d534eefaa4..33939f6c7e 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -86,6 +86,12 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const GIT_POLLING_MODE_LABELS = { + all: "All sessions", + "active-session": "Active session only", + disabled: "Disabled", +} as const; + type InstallProviderSettings = { provider: ProviderKind; title: string; @@ -1411,6 +1417,80 @@ export function GeneralSettingsPanel() { })} + + + updateSettings({ + gitPollingMode: DEFAULT_UNIFIED_SETTINGS.gitPollingMode, + }) + } + /> + ) : null + } + control={ + + } + /> + + + updateSettings({ + gitQuietBackgroundChecks: DEFAULT_UNIFIED_SETTINGS.gitQuietBackgroundChecks, + }) + } + /> + ) : null + } + control={ + + updateSettings({ gitQuietBackgroundChecks: Boolean(checked) }) + } + aria-label="Suppress credential prompts during background fetches" + /> + } + /> + + { @@ -65,9 +69,9 @@ export function gitStatusQueryOptions(cwd: string | null) { }, enabled: cwd !== null, staleTime: GIT_STATUS_STALE_TIME_MS, - refetchOnWindowFocus: "always", - refetchOnReconnect: "always", - refetchInterval: GIT_STATUS_REFETCH_INTERVAL_MS, + refetchOnWindowFocus: polling ? "always" : false, + refetchOnReconnect: polling ? "always" : false, + refetchInterval: polling ? GIT_STATUS_REFETCH_INTERVAL_MS : false, }); } @@ -75,8 +79,10 @@ export function gitBranchSearchInfiniteQueryOptions(input: { cwd: string | null; query: string; enabled?: boolean; + pollingEnabled?: boolean; }) { const normalizedQuery = input.query.trim(); + const polling = input.pollingEnabled ?? true; return infiniteQueryOptions({ queryKey: gitQueryKeys.branchSearch(input.cwd, normalizedQuery), @@ -94,9 +100,9 @@ export function gitBranchSearchInfiniteQueryOptions(input: { getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, enabled: input.cwd !== null && (input.enabled ?? true), staleTime: GIT_BRANCHES_STALE_TIME_MS, - refetchOnWindowFocus: true, - refetchOnReconnect: true, - refetchInterval: GIT_BRANCHES_REFETCH_INTERVAL_MS, + refetchOnWindowFocus: polling, + refetchOnReconnect: polling, + refetchInterval: polling ? GIT_BRANCHES_REFETCH_INTERVAL_MS : false, }); } diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6633ce42a6..6428a59291 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -44,6 +44,9 @@ export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientS export const ThreadEnvMode = Schema.Literals(["local", "worktree"]); export type ThreadEnvMode = typeof ThreadEnvMode.Type; +export const GitPollingMode = Schema.Literals(["all", "active-session", "disabled"]); +export type GitPollingMode = typeof GitPollingMode.Type; + const makeBinaryPathSetting = (fallback: string) => TrimmedString.pipe( Schema.decodeTo( @@ -95,6 +98,12 @@ export const ServerSettings = Schema.Struct({ claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(() => ({}))), }).pipe(Schema.withDecodingDefault(() => ({}))), observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(() => ({}))), + + // Git sync settings + gitPollingMode: GitPollingMode.pipe( + Schema.withDecodingDefault(() => "all" as const satisfies GitPollingMode), + ), + gitQuietBackgroundChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), }); export type ServerSettings = typeof ServerSettings.Type; @@ -177,5 +186,7 @@ export const ServerSettingsPatch = Schema.Struct({ claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), }), ), + gitPollingMode: Schema.optionalKey(GitPollingMode), + gitQuietBackgroundChecks: Schema.optionalKey(Schema.Boolean), }); export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;