Skip to content

Commit 89ac28b

Browse files
refactor: target repo status cache updates only to affected batch entries
1 parent 6118868 commit 89ac28b

4 files changed

Lines changed: 144 additions & 40 deletions

File tree

frontend/src/components/source-control/BranchesTab.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useState } from 'react'
22
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
33
import { listBranches, switchBranch, GitAuthError, getRepo } from '@/api/repos'
4-
import { useGitStatus } from '@/api/git'
4+
import { fetchGitStatus, useGitStatus } from '@/api/git'
55
import { Button } from '@/components/ui/button'
66
import { Input } from '@/components/ui/input'
77
import { Loader2, GitBranch, GitBranchPlus, Check, Plus, AlertCircle, ArrowUp, ArrowDown, Globe } from 'lucide-react'
@@ -10,7 +10,7 @@ import { showToast } from '@/lib/toast'
1010
import { useGit } from '@/hooks/useGit'
1111
import { GIT_UI_COLORS } from '@/lib/git-status-styles'
1212
import { CreateWorktreeDialog } from '@/components/repo/CreateWorktreeDialog'
13-
import { invalidateRepoGitCaches } from '@/lib/queryInvalidation'
13+
import { invalidateRepoGitCaches, setRepoGitStatusCaches } from '@/lib/queryInvalidation'
1414

1515
interface BranchesTabProps {
1616
repoId: number
@@ -42,10 +42,15 @@ export function BranchesTab({ repoId, currentBranch }: BranchesTabProps) {
4242
const isRepoWorktree = repo?.isWorktree ?? false
4343

4444
const switchBranchMutation = useMutation({
45-
mutationFn: (branch: string) => switchBranch(repoId, branch),
46-
onSuccess: (updatedRepo) => {
45+
mutationFn: async (branch: string) => {
46+
const updatedRepo = await switchBranch(repoId, branch)
47+
const status = await fetchGitStatus(repoId)
48+
return { updatedRepo, status }
49+
},
50+
onSuccess: ({ updatedRepo, status }) => {
4751
queryClient.setQueryData(['repo', repoId], updatedRepo)
48-
invalidateRepoGitCaches(queryClient, repoId)
52+
setRepoGitStatusCaches(queryClient, repoId, status)
53+
invalidateRepoGitCaches(queryClient, repoId, { invalidateStatus: false })
4954
refetch()
5055
showToast.success(`Switched to branch: ${updatedRepo.currentBranch}`)
5156
},

frontend/src/hooks/useGit.test.tsx

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useGit } from './useGit'
44
import * as gitApi from '../api/git'
55
import * as toast from '../lib/toast'
66
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
7+
import type { GitStatusResponse } from '../types/git'
78

89
vi.mock('../api/git', () => ({
910
gitFetch: vi.fn(),
@@ -14,6 +15,7 @@ vi.mock('../api/git', () => ({
1415
gitUnstageFiles: vi.fn(),
1516
gitDiscardFiles: vi.fn(),
1617
gitReset: vi.fn(),
18+
fetchGitStatus: vi.fn(),
1719
fetchGitLog: vi.fn(),
1820
fetchGitDiff: vi.fn(),
1921
createBranch: vi.fn(),
@@ -34,6 +36,15 @@ vi.mock('../lib/toast', () => ({
3436

3537
const mockInvalidateQueries = vi.fn()
3638
const mockSetQueryData = vi.fn()
39+
const mockSetQueriesData = vi.fn()
40+
41+
const mockGitStatus: GitStatusResponse = {
42+
branch: 'main',
43+
ahead: 0,
44+
behind: 0,
45+
files: [],
46+
hasChanges: false,
47+
}
3748

3849
vi.mock('@tanstack/react-query', async () => {
3950
const actual = await vi.importActual('@tanstack/react-query')
@@ -42,6 +53,7 @@ vi.mock('@tanstack/react-query', async () => {
4253
useQueryClient: vi.fn(() => ({
4354
invalidateQueries: mockInvalidateQueries,
4455
setQueryData: mockSetQueryData,
56+
setQueriesData: mockSetQueriesData,
4557
}))
4658
}
4759
})
@@ -62,8 +74,40 @@ describe('useGit', () => {
6274
beforeEach(() => {
6375
vi.clearAllMocks()
6476
mockInvalidateQueries.mockClear()
77+
vi.mocked(gitApi.gitFetch).mockResolvedValue(mockGitStatus)
78+
vi.mocked(gitApi.gitPull).mockResolvedValue(mockGitStatus)
79+
vi.mocked(gitApi.gitPush).mockResolvedValue(mockGitStatus)
80+
vi.mocked(gitApi.gitCommit).mockResolvedValue(mockGitStatus)
81+
vi.mocked(gitApi.gitStageFiles).mockResolvedValue(mockGitStatus)
82+
vi.mocked(gitApi.gitUnstageFiles).mockResolvedValue(mockGitStatus)
83+
vi.mocked(gitApi.gitDiscardFiles).mockResolvedValue(mockGitStatus)
84+
vi.mocked(gitApi.gitReset).mockResolvedValue(mockGitStatus)
85+
vi.mocked(gitApi.fetchGitStatus).mockResolvedValue(mockGitStatus)
6586
})
6687

88+
const expectTargetedStatusCacheUpdate = () => {
89+
expect(mockSetQueryData).toHaveBeenCalledWith(['gitStatus', 1], mockGitStatus)
90+
expect(mockSetQueriesData).toHaveBeenCalledWith(
91+
expect.objectContaining({
92+
queryKey: ['reposGitStatus'],
93+
predicate: expect.any(Function),
94+
}),
95+
expect.any(Function),
96+
)
97+
98+
const [filters, updater] = mockSetQueriesData.mock.calls[0]
99+
expect(filters.predicate({ queryKey: ['reposGitStatus', [1, 2]] })).toBe(true)
100+
expect(filters.predicate({ queryKey: ['reposGitStatus', [2, 3]] })).toBe(false)
101+
expect(filters.predicate({ queryKey: ['other', [1]] })).toBe(false)
102+
103+
const otherStatus = { ...mockGitStatus, branch: 'dev' }
104+
const oldData = new Map<number, GitStatusResponse>([[1, otherStatus], [2, otherStatus]])
105+
const updated = updater(oldData)
106+
expect(updated).not.toBe(oldData)
107+
expect(updated.get(1)).toBe(mockGitStatus)
108+
expect(updated.get(2)).toBe(otherStatus)
109+
}
110+
67111
it('returns all mutations', () => {
68112
const { result } = renderHook(() => useGit(1), { wrapper: createWrapper() })
69113

@@ -86,7 +130,9 @@ describe('useGit', () => {
86130
})
87131

88132
expect(gitApi.gitFetch).toHaveBeenCalledWith(1)
89-
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['gitStatus', 1] })
133+
expectTargetedStatusCacheUpdate()
134+
expect(mockInvalidateQueries).not.toHaveBeenCalledWith({ queryKey: ['reposGitStatus'] })
135+
expect(mockInvalidateQueries).not.toHaveBeenCalledWith({ queryKey: ['gitStatus', 1] })
90136
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['fileDiff', 1] })
91137
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['gitLog', 1] })
92138
})
@@ -113,7 +159,9 @@ describe('useGit', () => {
113159
})
114160

115161
expect(gitApi.gitPull).toHaveBeenCalledWith(1)
116-
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['gitStatus', 1] })
162+
expectTargetedStatusCacheUpdate()
163+
expect(mockInvalidateQueries).not.toHaveBeenCalledWith({ queryKey: ['reposGitStatus'] })
164+
expect(mockInvalidateQueries).not.toHaveBeenCalledWith({ queryKey: ['gitStatus', 1] })
117165
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['fileDiff', 1] })
118166
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['gitLog', 1] })
119167
})
@@ -136,10 +184,12 @@ describe('useGit', () => {
136184
const { result } = renderHook(() => useGit(1), { wrapper: createWrapper() })
137185

138186
await waitFor(() => {
139-
result.current.push.mutateAsync()
187+
result.current.push.mutateAsync(undefined)
140188
})
141189

142190
expect(gitApi.gitPush).toHaveBeenCalledWith(1, false)
191+
expectTargetedStatusCacheUpdate()
192+
expect(mockInvalidateQueries).not.toHaveBeenCalledWith({ queryKey: ['reposGitStatus'] })
143193
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['fileDiff', 1] })
144194
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['gitLog', 1] })
145195
})
@@ -150,7 +200,7 @@ describe('useGit', () => {
150200
const { result } = renderHook(() => useGit(1), { wrapper: createWrapper() })
151201

152202
await waitFor(() => {
153-
result.current.push.mutateAsync().catch(() => {})
203+
result.current.push.mutateAsync(undefined).catch(() => {})
154204
})
155205

156206
expect(toast.showToast.error).toHaveBeenCalledWith('Push failed')
@@ -166,7 +216,9 @@ describe('useGit', () => {
166216
})
167217

168218
expect(gitApi.gitCommit).toHaveBeenCalledWith(1, 'test commit', undefined)
169-
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['gitStatus', 1] })
219+
expectTargetedStatusCacheUpdate()
220+
expect(mockInvalidateQueries).not.toHaveBeenCalledWith({ queryKey: ['reposGitStatus'] })
221+
expect(mockInvalidateQueries).not.toHaveBeenCalledWith({ queryKey: ['gitStatus', 1] })
170222
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['fileDiff', 1] })
171223
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['gitLog', 1] })
172224
})
@@ -193,7 +245,9 @@ describe('useGit', () => {
193245
})
194246

195247
expect(gitApi.gitStageFiles).toHaveBeenCalledWith(1, ['file.txt'])
196-
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['gitStatus', 1] })
248+
expectTargetedStatusCacheUpdate()
249+
expect(mockInvalidateQueries).not.toHaveBeenCalledWith({ queryKey: ['reposGitStatus'] })
250+
expect(mockInvalidateQueries).not.toHaveBeenCalledWith({ queryKey: ['gitStatus', 1] })
197251
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['fileDiff', 1] })
198252
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['gitLog', 1] })
199253
})
@@ -220,7 +274,9 @@ describe('useGit', () => {
220274
})
221275

222276
expect(gitApi.gitUnstageFiles).toHaveBeenCalledWith(1, ['file.txt'])
223-
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['gitStatus', 1] })
277+
expectTargetedStatusCacheUpdate()
278+
expect(mockInvalidateQueries).not.toHaveBeenCalledWith({ queryKey: ['reposGitStatus'] })
279+
expect(mockInvalidateQueries).not.toHaveBeenCalledWith({ queryKey: ['gitStatus', 1] })
224280
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['fileDiff', 1] })
225281
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['gitLog', 1] })
226282
})

frontend/src/hooks/useGit.ts

Lines changed: 37 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { useMutation, useQueryClient } from '@tanstack/react-query'
2-
import { gitFetch, gitPull, gitPush, gitCommit, gitStageFiles, gitUnstageFiles, gitDiscardFiles, fetchGitLog, fetchGitDiff, gitReset, getApiErrorMessage } from '@/api/git'
2+
import { gitFetch, gitPull, gitPush, gitCommit, gitStageFiles, gitUnstageFiles, gitDiscardFiles, fetchGitLog, fetchGitDiff, gitReset, getApiErrorMessage, fetchGitStatus } from '@/api/git'
33
import { createBranch, switchBranch } from '@/api/repos'
44
import { showToast } from '@/lib/toast'
5-
import { invalidateRepoGitCaches } from '@/lib/queryInvalidation'
5+
import { invalidateRepoGitCaches, setRepoGitStatusCaches } from '@/lib/queryInvalidation'
66

77
export function useGit(repoId: number | undefined, onError?: (error: unknown) => void) {
88
const queryClient = useQueryClient()
@@ -20,8 +20,9 @@ export function useGit(repoId: number | undefined, onError?: (error: unknown) =>
2020
if (!repoId) throw new Error('No repo ID')
2121
return gitFetch(repoId)
2222
},
23-
onSuccess: () => {
24-
invalidateRepoGitCaches(queryClient, repoId)
23+
onSuccess: (data) => {
24+
if (repoId) setRepoGitStatusCaches(queryClient, repoId, data)
25+
invalidateRepoGitCaches(queryClient, repoId, { invalidateStatus: false })
2526
showToast.success('Fetch completed')
2627
},
2728
onError: handleError,
@@ -32,8 +33,9 @@ export function useGit(repoId: number | undefined, onError?: (error: unknown) =>
3233
if (!repoId) throw new Error('No repo ID')
3334
return gitPull(repoId)
3435
},
35-
onSuccess: () => {
36-
invalidateRepoGitCaches(queryClient, repoId)
36+
onSuccess: (data) => {
37+
if (repoId) setRepoGitStatusCaches(queryClient, repoId, data)
38+
invalidateRepoGitCaches(queryClient, repoId, { invalidateStatus: false })
3739
showToast.success('Pull completed')
3840
},
3941
onError: handleError,
@@ -45,8 +47,8 @@ export function useGit(repoId: number | undefined, onError?: (error: unknown) =>
4547
return gitPush(repoId, options?.setUpstream ?? false)
4648
},
4749
onSuccess: (data) => {
48-
queryClient.setQueryData(['gitStatus', repoId], data)
49-
invalidateRepoGitCaches(queryClient, repoId)
50+
if (repoId) setRepoGitStatusCaches(queryClient, repoId, data)
51+
invalidateRepoGitCaches(queryClient, repoId, { invalidateStatus: false })
5052
showToast.success('Push completed')
5153
},
5254
onError: handleError,
@@ -57,8 +59,9 @@ export function useGit(repoId: number | undefined, onError?: (error: unknown) =>
5759
if (!repoId) throw new Error('No repo ID')
5860
return gitCommit(repoId, message, stagedPaths)
5961
},
60-
onSuccess: () => {
61-
invalidateRepoGitCaches(queryClient, repoId)
62+
onSuccess: (data) => {
63+
if (repoId) setRepoGitStatusCaches(queryClient, repoId, data)
64+
invalidateRepoGitCaches(queryClient, repoId, { invalidateStatus: false })
6265
showToast.success('Commit created')
6366
},
6467
onError: handleError,
@@ -69,8 +72,9 @@ export function useGit(repoId: number | undefined, onError?: (error: unknown) =>
6972
if (!repoId) throw new Error('No repo ID')
7073
return gitStageFiles(repoId, paths)
7174
},
72-
onSuccess: () => {
73-
invalidateRepoGitCaches(queryClient, repoId)
75+
onSuccess: (data) => {
76+
if (repoId) setRepoGitStatusCaches(queryClient, repoId, data)
77+
invalidateRepoGitCaches(queryClient, repoId, { invalidateStatus: false })
7478
showToast.success('Files staged')
7579
},
7680
onError: handleError,
@@ -81,8 +85,9 @@ export function useGit(repoId: number | undefined, onError?: (error: unknown) =>
8185
if (!repoId) throw new Error('No repo ID')
8286
return gitUnstageFiles(repoId, paths)
8387
},
84-
onSuccess: () => {
85-
invalidateRepoGitCaches(queryClient, repoId)
88+
onSuccess: (data) => {
89+
if (repoId) setRepoGitStatusCaches(queryClient, repoId, data)
90+
invalidateRepoGitCaches(queryClient, repoId, { invalidateStatus: false })
8691
showToast.success('Files unstaged')
8792
},
8893
onError: handleError,
@@ -93,8 +98,9 @@ export function useGit(repoId: number | undefined, onError?: (error: unknown) =>
9398
if (!repoId) throw new Error('No repo ID')
9499
return gitDiscardFiles(repoId, paths, staged)
95100
},
96-
onSuccess: () => {
97-
invalidateRepoGitCaches(queryClient, repoId)
101+
onSuccess: (data) => {
102+
if (repoId) setRepoGitStatusCaches(queryClient, repoId, data)
103+
invalidateRepoGitCaches(queryClient, repoId, { invalidateStatus: false })
98104
},
99105
onError: handleError,
100106
})
@@ -116,24 +122,28 @@ export function useGit(repoId: number | undefined, onError?: (error: unknown) =>
116122
})
117123

118124
const createBranchMutation = useMutation({
119-
mutationFn: (branchName: string) => {
125+
mutationFn: async (branchName: string) => {
120126
if (!repoId) throw new Error('No repo ID')
121-
return createBranch(repoId, branchName)
127+
await createBranch(repoId, branchName)
128+
return fetchGitStatus(repoId)
122129
},
123-
onSuccess: () => {
124-
invalidateRepoGitCaches(queryClient, repoId)
130+
onSuccess: (data) => {
131+
if (repoId) setRepoGitStatusCaches(queryClient, repoId, data)
132+
invalidateRepoGitCaches(queryClient, repoId, { invalidateStatus: false })
125133
showToast.success('Branch created')
126134
},
127135
onError: handleError,
128136
})
129137

130138
const switchBranchMutation = useMutation({
131-
mutationFn: (branchName: string) => {
139+
mutationFn: async (branchName: string) => {
132140
if (!repoId) throw new Error('No repo ID')
133-
return switchBranch(repoId, branchName)
141+
await switchBranch(repoId, branchName)
142+
return fetchGitStatus(repoId)
134143
},
135-
onSuccess: () => {
136-
invalidateRepoGitCaches(queryClient, repoId)
144+
onSuccess: (data) => {
145+
if (repoId) setRepoGitStatusCaches(queryClient, repoId, data)
146+
invalidateRepoGitCaches(queryClient, repoId, { invalidateStatus: false })
137147
showToast.success('Switched to branch')
138148
},
139149
onError: handleError,
@@ -144,8 +154,9 @@ export function useGit(repoId: number | undefined, onError?: (error: unknown) =>
144154
if (!repoId) throw new Error('No repo ID')
145155
return gitReset(repoId, commitHash)
146156
},
147-
onSuccess: () => {
148-
invalidateRepoGitCaches(queryClient, repoId)
157+
onSuccess: (data) => {
158+
if (repoId) setRepoGitStatusCaches(queryClient, repoId, data)
159+
invalidateRepoGitCaches(queryClient, repoId, { invalidateStatus: false })
149160
showToast.success('Reset to commit')
150161
},
151162
onError: handleError,

frontend/src/lib/queryInvalidation.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { QueryClient } from '@tanstack/react-query'
2+
import type { GitStatusResponse } from '@/types/git'
23

34
export function messagesQueryKey(
45
opcodeUrl: string | null | undefined,
@@ -47,7 +48,15 @@ export function invalidateRepoListCaches(queryClient: QueryClient) {
4748
queryClient.invalidateQueries({ queryKey: ['reposGitStatus'] })
4849
}
4950

50-
export function invalidateRepoGitCaches(queryClient: QueryClient, repoId?: number | null) {
51+
interface RepoGitInvalidationOptions {
52+
invalidateStatus?: boolean
53+
}
54+
55+
export function invalidateRepoGitCaches(
56+
queryClient: QueryClient,
57+
repoId?: number | null,
58+
options: RepoGitInvalidationOptions = {},
59+
) {
5160
if (!repoId) {
5261
invalidateRepoListCaches(queryClient)
5362
queryClient.invalidateQueries({ queryKey: ['repo'] })
@@ -61,11 +70,34 @@ export function invalidateRepoGitCaches(queryClient: QueryClient, repoId?: numbe
6170
queryClient.invalidateQueries({ queryKey: ['repos'] })
6271
queryClient.invalidateQueries({ queryKey: ['repo', repoId] })
6372
queryClient.invalidateQueries({ queryKey: ['branches', repoId] })
64-
queryClient.invalidateQueries({ queryKey: ['gitStatus', repoId] })
73+
if (options.invalidateStatus ?? true) {
74+
queryClient.invalidateQueries({ queryKey: ['gitStatus', repoId] })
75+
}
6576
queryClient.invalidateQueries({ queryKey: ['gitLog', repoId] })
6677
queryClient.invalidateQueries({ queryKey: ['fileDiff', repoId] })
6778
}
6879

80+
function reposGitStatusQueryIncludesRepo(queryKey: readonly unknown[], repoId: number) {
81+
const repoIds = queryKey[1]
82+
return queryKey[0] === 'reposGitStatus' && Array.isArray(repoIds) && repoIds.includes(repoId)
83+
}
84+
85+
export function setRepoGitStatusCaches(queryClient: QueryClient, repoId: number, data: GitStatusResponse) {
86+
queryClient.setQueryData(['gitStatus', repoId], data)
87+
queryClient.setQueriesData<Map<number, GitStatusResponse>>(
88+
{
89+
queryKey: ['reposGitStatus'],
90+
predicate: (query) => reposGitStatusQueryIncludesRepo(query.queryKey, repoId),
91+
},
92+
(oldData) => {
93+
if (!oldData) return oldData
94+
const updated = new Map(oldData)
95+
updated.set(repoId, data)
96+
return updated
97+
},
98+
)
99+
}
100+
69101
const repoGitInvalidationTimers = new WeakMap<QueryClient, Map<number, ReturnType<typeof setTimeout>>>()
70102

71103
export function invalidateRepoGitCachesDebounced(queryClient: QueryClient, repoId: number, delayMs = 200) {

0 commit comments

Comments
 (0)