From 96c6ed51a7b69c4d1211a502f23550e9eec804b1 Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Tue, 19 May 2026 17:12:32 +0200 Subject: [PATCH 1/2] fix(git): cancel superseded git status queries and debounce file-watcher invalidations Forward AbortSignal through the tRPC read procedures and GitService methods into the underlying queries package, so TanStack Query refetches and unmounts actually kill the spawned `git status --untracked-files=all` child process (streamGitStatus already wires SIGTERM on abort, but never received the signal). Debounce git working-tree invalidations from useFileWatcher at 500ms so a burst of file events (worktree setup, node_modules churn, large merges) collapses into one refetch instead of stacking dozens of concurrent git processes. Generated-By: PostHog Code Task-Id: 8b1baf87-a02c-4bbc-bf41-c6267dac1846 --- apps/code/src/main/services/git/service.ts | 55 +++++++++++++++---- apps/code/src/main/trpc/routers/git.ts | 52 +++++++++++++----- .../code/src/renderer/hooks/useFileWatcher.ts | 33 +++++++++-- 3 files changed, 109 insertions(+), 31 deletions(-) diff --git a/apps/code/src/main/services/git/service.ts b/apps/code/src/main/services/git/service.ts index 68084b985..a45d2eb9d 100644 --- a/apps/code/src/main/services/git/service.ts +++ b/apps/code/src/main/services/git/service.ts @@ -297,20 +297,29 @@ export class GitService extends TypedEventEmitter { return getRemoteUrl(directoryPath); } - public async getCurrentBranch(directoryPath: string): Promise { - return getCurrentBranch(directoryPath); + public async getCurrentBranch( + directoryPath: string, + signal?: AbortSignal, + ): Promise { + return getCurrentBranch(directoryPath, { abortSignal: signal }); } public async getDefaultBranch(directoryPath: string): Promise { return getDefaultBranch(directoryPath); } - public async getAllBranches(directoryPath: string): Promise { - return getAllBranches(directoryPath); + public async getAllBranches( + directoryPath: string, + signal?: AbortSignal, + ): Promise { + return getAllBranches(directoryPath, { abortSignal: signal }); } - public async getGitBusyState(directoryPath: string): Promise { - return getGitBusyState(directoryPath); + public async getGitBusyState( + directoryPath: string, + signal?: AbortSignal, + ): Promise { + return getGitBusyState(directoryPath, { abortSignal: signal }); } public async createBranch( @@ -334,9 +343,11 @@ export class GitService extends TypedEventEmitter { public async getChangedFilesHead( directoryPath: string, + signal?: AbortSignal, ): Promise { const files = await getChangedFilesDetailed(directoryPath, { excludePatterns: [".claude", "CLAUDE.local.md"], + abortSignal: signal, }); type HeadChangedFile = Omit; const filteredFiles: Array = await Promise.all( @@ -371,29 +382,42 @@ export class GitService extends TypedEventEmitter { public async getFileAtHead( directoryPath: string, filePath: string, + signal?: AbortSignal, ): Promise { - return getFileAtHead(directoryPath, filePath); + return getFileAtHead(directoryPath, filePath, { abortSignal: signal }); } public async getDiffHead( directoryPath: string, ignoreWhitespace?: boolean, + signal?: AbortSignal, ): Promise { - return getDiffHead(directoryPath, { ignoreWhitespace }); + return getDiffHead(directoryPath, { + ignoreWhitespace, + abortSignal: signal, + }); } public async getDiffCached( directoryPath: string, ignoreWhitespace?: boolean, + signal?: AbortSignal, ): Promise { - return getStagedDiff(directoryPath, { ignoreWhitespace }); + return getStagedDiff(directoryPath, { + ignoreWhitespace, + abortSignal: signal, + }); } public async getDiffUnstaged( directoryPath: string, ignoreWhitespace?: boolean, + signal?: AbortSignal, ): Promise { - return getUnstagedDiff(directoryPath, { ignoreWhitespace }); + return getUnstagedDiff(directoryPath, { + ignoreWhitespace, + abortSignal: signal, + }); } public async stageFiles( @@ -412,9 +436,13 @@ export class GitService extends TypedEventEmitter { return this.getStateSnapshot(directoryPath); } - public async getDiffStats(directoryPath: string): Promise { + public async getDiffStats( + directoryPath: string, + signal?: AbortSignal, + ): Promise { const stats = await getDiffStats(directoryPath, { excludePatterns: [".claude", "CLAUDE.local.md"], + abortSignal: signal, }); return { filesChanged: stats.filesChanged, @@ -455,8 +483,11 @@ export class GitService extends TypedEventEmitter { public async getLatestCommit( directoryPath: string, + signal?: AbortSignal, ): Promise { - const commit = await getLatestCommit(directoryPath); + const commit = await getLatestCommit(directoryPath, { + abortSignal: signal, + }); if (!commit) return null; return { sha: commit.sha, diff --git a/apps/code/src/main/trpc/routers/git.ts b/apps/code/src/main/trpc/routers/git.ts index 5137ed435..b364d7aad 100644 --- a/apps/code/src/main/trpc/routers/git.ts +++ b/apps/code/src/main/trpc/routers/git.ts @@ -125,17 +125,23 @@ export const gitRouter = router({ getCurrentBranch: publicProcedure .input(getCurrentBranchInput) .output(getCurrentBranchOutput) - .query(({ input }) => getService().getCurrentBranch(input.directoryPath)), + .query(({ input, signal }) => + getService().getCurrentBranch(input.directoryPath, signal), + ), getAllBranches: publicProcedure .input(getAllBranchesInput) .output(getAllBranchesOutput) - .query(({ input }) => getService().getAllBranches(input.directoryPath)), + .query(({ input, signal }) => + getService().getAllBranches(input.directoryPath, signal), + ), getGitBusyState: publicProcedure .input(getGitBusyStateInput) .output(getGitBusyStateOutput) - .query(({ input }) => getService().getGitBusyState(input.directoryPath)), + .query(({ input, signal }) => + getService().getGitBusyState(input.directoryPath, signal), + ), createBranch: publicProcedure .input(createBranchInput) @@ -154,42 +160,56 @@ export const gitRouter = router({ getChangedFilesHead: publicProcedure .input(getChangedFilesHeadInput) .output(getChangedFilesHeadOutput) - .query(({ input }) => - getService().getChangedFilesHead(input.directoryPath), + .query(({ input, signal }) => + getService().getChangedFilesHead(input.directoryPath, signal), ), getFileAtHead: publicProcedure .input(getFileAtHeadInput) .output(getFileAtHeadOutput) - .query(({ input }) => - getService().getFileAtHead(input.directoryPath, input.filePath), + .query(({ input, signal }) => + getService().getFileAtHead(input.directoryPath, input.filePath, signal), ), getDiffHead: publicProcedure .input(diffInput) .output(diffOutput) - .query(({ input }) => - getService().getDiffHead(input.directoryPath, input.ignoreWhitespace), + .query(({ input, signal }) => + getService().getDiffHead( + input.directoryPath, + input.ignoreWhitespace, + signal, + ), ), getDiffCached: publicProcedure .input(diffInput) .output(diffOutput) - .query(({ input }) => - getService().getDiffCached(input.directoryPath, input.ignoreWhitespace), + .query(({ input, signal }) => + getService().getDiffCached( + input.directoryPath, + input.ignoreWhitespace, + signal, + ), ), getDiffUnstaged: publicProcedure .input(diffInput) .output(diffOutput) - .query(({ input }) => - getService().getDiffUnstaged(input.directoryPath, input.ignoreWhitespace), + .query(({ input, signal }) => + getService().getDiffUnstaged( + input.directoryPath, + input.ignoreWhitespace, + signal, + ), ), getDiffStats: publicProcedure .input(getDiffStatsInput) .output(getDiffStatsOutput) - .query(({ input }) => getService().getDiffStats(input.directoryPath)), + .query(({ input, signal }) => + getService().getDiffStats(input.directoryPath, signal), + ), stageFiles: publicProcedure .input(stageFilesInput) @@ -233,7 +253,9 @@ export const gitRouter = router({ getLatestCommit: publicProcedure .input(getLatestCommitInput) .output(getLatestCommitOutput) - .query(({ input }) => getService().getLatestCommit(input.directoryPath)), + .query(({ input, signal }) => + getService().getLatestCommit(input.directoryPath, signal), + ), getGitRepoInfo: publicProcedure .input(getGitRepoInfoInput) diff --git a/apps/code/src/renderer/hooks/useFileWatcher.ts b/apps/code/src/renderer/hooks/useFileWatcher.ts index 44604f15e..adabe969f 100644 --- a/apps/code/src/renderer/hooks/useFileWatcher.ts +++ b/apps/code/src/renderer/hooks/useFileWatcher.ts @@ -8,15 +8,40 @@ import { useQueryClient } from "@tanstack/react-query"; import { useSubscription } from "@trpc/tanstack-react-query"; import { logger } from "@utils/logger"; import { toRelativePath } from "@utils/path"; -import { useEffect } from "react"; +import { useCallback, useEffect, useRef } from "react"; const log = logger.scope("file-watcher"); +const GIT_INVALIDATION_DEBOUNCE_MS = 500; + export function useFileWatcher(repoPath: string | null, taskId?: string) { const trpc = useTRPC(); const queryClient = useQueryClient(); const closeTabsForFile = usePanelLayoutStore((s) => s.closeTabsForFile); + const gitInvalidateTimerRef = useRef | null>( + null, + ); + + const scheduleGitWorkingTreeInvalidation = useCallback((rp: string) => { + if (gitInvalidateTimerRef.current) { + clearTimeout(gitInvalidateTimerRef.current); + } + gitInvalidateTimerRef.current = setTimeout(() => { + gitInvalidateTimerRef.current = null; + invalidateGitWorkingTreeQueries(rp); + }, GIT_INVALIDATION_DEBOUNCE_MS); + }, []); + + useEffect(() => { + return () => { + if (gitInvalidateTimerRef.current) { + clearTimeout(gitInvalidateTimerRef.current); + gitInvalidateTimerRef.current = null; + } + }; + }, []); + useEffect(() => { if (!repoPath) return; @@ -47,7 +72,7 @@ export function useFileWatcher(repoPath: string | null, taskId?: string) { filePath: relativePath, }), ); - invalidateGitWorkingTreeQueries(repoPath); + scheduleGitWorkingTreeInvalidation(repoPath); }, }), ); @@ -57,7 +82,7 @@ export function useFileWatcher(repoPath: string | null, taskId?: string) { enabled: !!repoPath, onData: ({ repoPath: rp, filePath }) => { if (rp !== repoPath) return; - invalidateGitWorkingTreeQueries(repoPath); + scheduleGitWorkingTreeInvalidation(repoPath); if (!taskId) return; const relativePath = toRelativePath(filePath, repoPath); closeTabsForFile(taskId, relativePath); @@ -71,7 +96,7 @@ export function useFileWatcher(repoPath: string | null, taskId?: string) { onData: ({ repoPath: rp }) => { if (rp !== repoPath) return; invalidateGitBranchQueries(repoPath); - invalidateGitWorkingTreeQueries(repoPath); + scheduleGitWorkingTreeInvalidation(repoPath); }, }), ); From 00fce122cb82ab0065922b754e8b4a650e8e940d Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Tue, 19 May 2026 17:17:43 +0200 Subject: [PATCH 2/2] refactor(file-watcher): coalesce working-tree invalidation into one event Move the debounce from the renderer into the file-watcher service. The service already debounces events at 500ms; emit a single WorkingTreeChanged event per flush so the renderer invalidates git queries once per burst instead of once per file event. Generated-By: PostHog Code Task-Id: 8b1baf87-a02c-4bbc-bf41-c6267dac1846 --- .../src/main/services/file-watcher/schemas.ts | 6 +++ .../src/main/services/file-watcher/service.ts | 6 ++- .../src/main/trpc/routers/file-watcher.ts | 1 + .../code/src/renderer/hooks/useFileWatcher.ts | 40 +++++-------------- 4 files changed, 22 insertions(+), 31 deletions(-) diff --git a/apps/code/src/main/services/file-watcher/schemas.ts b/apps/code/src/main/services/file-watcher/schemas.ts index 3b11e9522..f4072853a 100644 --- a/apps/code/src/main/services/file-watcher/schemas.ts +++ b/apps/code/src/main/services/file-watcher/schemas.ts @@ -25,6 +25,7 @@ export const FileWatcherEvent = { FileChanged: "file-changed", FileDeleted: "file-deleted", GitStateChanged: "git-state-changed", + WorkingTreeChanged: "working-tree-changed", } as const; export type DirectoryChangedPayload = { @@ -46,9 +47,14 @@ export type GitStateChangedPayload = { repoPath: string; }; +export type WorkingTreeChangedPayload = { + repoPath: string; +}; + export interface FileWatcherEvents { [FileWatcherEvent.DirectoryChanged]: DirectoryChangedPayload; [FileWatcherEvent.FileChanged]: FileChangedPayload; [FileWatcherEvent.FileDeleted]: FileDeletedPayload; [FileWatcherEvent.GitStateChanged]: GitStateChangedPayload; + [FileWatcherEvent.WorkingTreeChanged]: WorkingTreeChangedPayload; } diff --git a/apps/code/src/main/services/file-watcher/service.ts b/apps/code/src/main/services/file-watcher/service.ts index 3a54ccdb2..e86e5b486 100644 --- a/apps/code/src/main/services/file-watcher/service.ts +++ b/apps/code/src/main/services/file-watcher/service.ts @@ -171,9 +171,11 @@ export class FileWatcherService extends TypedEventEmitter { const totalChanges = pending.files.size + pending.deletes.size; - // For bulk changes, emit a single event instead of per-file events + if (totalChanges > 0) { + this.emit(FileWatcherEvent.WorkingTreeChanged, { repoPath }); + } + if (totalChanges > BULK_THRESHOLD) { - this.emit(FileWatcherEvent.GitStateChanged, { repoPath }); pending.dirs.clear(); pending.files.clear(); pending.deletes.clear(); diff --git a/apps/code/src/main/trpc/routers/file-watcher.ts b/apps/code/src/main/trpc/routers/file-watcher.ts index 92d90ecdb..c442556d1 100644 --- a/apps/code/src/main/trpc/routers/file-watcher.ts +++ b/apps/code/src/main/trpc/routers/file-watcher.ts @@ -41,4 +41,5 @@ export const fileWatcherRouter = router({ onFileChanged: subscribe(FileWatcherEvent.FileChanged), onFileDeleted: subscribe(FileWatcherEvent.FileDeleted), onGitStateChanged: subscribe(FileWatcherEvent.GitStateChanged), + onWorkingTreeChanged: subscribe(FileWatcherEvent.WorkingTreeChanged), }); diff --git a/apps/code/src/renderer/hooks/useFileWatcher.ts b/apps/code/src/renderer/hooks/useFileWatcher.ts index adabe969f..c8134f171 100644 --- a/apps/code/src/renderer/hooks/useFileWatcher.ts +++ b/apps/code/src/renderer/hooks/useFileWatcher.ts @@ -8,40 +8,15 @@ import { useQueryClient } from "@tanstack/react-query"; import { useSubscription } from "@trpc/tanstack-react-query"; import { logger } from "@utils/logger"; import { toRelativePath } from "@utils/path"; -import { useCallback, useEffect, useRef } from "react"; +import { useEffect } from "react"; const log = logger.scope("file-watcher"); -const GIT_INVALIDATION_DEBOUNCE_MS = 500; - export function useFileWatcher(repoPath: string | null, taskId?: string) { const trpc = useTRPC(); const queryClient = useQueryClient(); const closeTabsForFile = usePanelLayoutStore((s) => s.closeTabsForFile); - const gitInvalidateTimerRef = useRef | null>( - null, - ); - - const scheduleGitWorkingTreeInvalidation = useCallback((rp: string) => { - if (gitInvalidateTimerRef.current) { - clearTimeout(gitInvalidateTimerRef.current); - } - gitInvalidateTimerRef.current = setTimeout(() => { - gitInvalidateTimerRef.current = null; - invalidateGitWorkingTreeQueries(rp); - }, GIT_INVALIDATION_DEBOUNCE_MS); - }, []); - - useEffect(() => { - return () => { - if (gitInvalidateTimerRef.current) { - clearTimeout(gitInvalidateTimerRef.current); - gitInvalidateTimerRef.current = null; - } - }; - }, []); - useEffect(() => { if (!repoPath) return; @@ -72,7 +47,6 @@ export function useFileWatcher(repoPath: string | null, taskId?: string) { filePath: relativePath, }), ); - scheduleGitWorkingTreeInvalidation(repoPath); }, }), ); @@ -82,7 +56,6 @@ export function useFileWatcher(repoPath: string | null, taskId?: string) { enabled: !!repoPath, onData: ({ repoPath: rp, filePath }) => { if (rp !== repoPath) return; - scheduleGitWorkingTreeInvalidation(repoPath); if (!taskId) return; const relativePath = toRelativePath(filePath, repoPath); closeTabsForFile(taskId, relativePath); @@ -96,7 +69,16 @@ export function useFileWatcher(repoPath: string | null, taskId?: string) { onData: ({ repoPath: rp }) => { if (rp !== repoPath) return; invalidateGitBranchQueries(repoPath); - scheduleGitWorkingTreeInvalidation(repoPath); + }, + }), + ); + + useSubscription( + trpc.fileWatcher.onWorkingTreeChanged.subscriptionOptions(undefined, { + enabled: !!repoPath, + onData: ({ repoPath: rp }) => { + if (rp !== repoPath) return; + invalidateGitWorkingTreeQueries(repoPath); }, }), );