Files
orca/src/shared/git-status-line-stats-cache.test.ts
T
Jinjing f1d2fe5c65 perf(git-status): duty-cycle, cancel, and cache status polling to cut idle git load (#8922)
* feat(git-status): batch, cancel, and cache git status polling to cut idl

- Add a single duty-cycled refresh scheduler (activity debounce + 60s
  safety timer) replacing multiple overlapping intervals, so status
  polling no longer runs near-continuously on large repos (#7983).
- Let safety refreshes reuse cached numstat line counts instead of
  re-running diff --numstat every cycle, invalidated by head change,
  known mutations, and a bounded TTL.
- Thread AbortSignal/request-token cancellation through IPC, RPC, and
  relay layers so a superseded or backgrounded git:status call is
  killed instead of finishing wastefully.
- Fix automatic upstream/status apply ordering so a slow, older
  refresh can no longer clobber a newer result, and so an earlier
  refresh still applies when a later one fails.

* Fix aborted git status scans being mistaken for completed empty results

- An aborted scan/numstat pass now always rejects instead of silently
  resolving, so a cancelled request can't look like a valid empty status.
- Stop clearing the line-stats cache key on abort, since an aborted pass
  never wrote to it — clearing was evicting a concurrent scan's healthy
  snapshot and forcing a redundant numstat recompute.

* Fix aborted git status scans resolving as completed results

Cancelled scans could still resolve with partial or stale data instead of
rejecting, letting callers treat an aborted refresh as a valid status. Also
stop counting aborted scan duration toward catch-up refresh pacing, which
was stretching the next refresh interval by the full length of a cancelled
(often slow) scan.

* Add cancellable, generation-aware git status polling to cut stale scans

- Route git.status through an abortable subscription per requestToken so
  cancelStatus can actually abort the remote scan instead of being a
  no-op, preventing wasted work and stale responses overwriting fresher
  state.
- Bump the git status polling generation on push-target changes so an
  in-flight refresh against the old remote/branch can't apply stale
  upstream data to the new one.
- Guard the stale-conflict poller against writes after unmount.
- Retire pre-purge line-stat scans in the cache so an older in-flight
  scan can't repopulate a key after a token-scoped purge.
2026-07-15 19:09:14 -07:00

203 lines
6.5 KiB
TypeScript

import { beforeEach, describe, expect, it } from 'vitest'
import {
applyCachedGitStatusLineStats,
beginGitStatusLineStatsCacheWrite,
clearGitStatusLineStatsCache,
clearGitStatusLineStatsCacheKey,
GIT_STATUS_LINE_STATS_CACHE_MAX_AGE_MS,
reuseOrRecomputeGitStatusLineStats,
storeGitStatusLineStats
} from './git-status-line-stats-cache'
const cachedEntries = [
{
path: 'src/a.ts',
status: 'modified',
area: 'unstaged',
added: 3,
removed: 2
}
]
describe('git status line stats cache', () => {
beforeEach(() => {
clearGitStatusLineStatsCache()
})
it('reuses counts only for an exact head and status-entry identity', () => {
storeGitStatusLineStats({ cacheKey: 'native\0/repo', head: 'head-1', entries: cachedEntries })
const matchingEntries = [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }]
expect(
applyCachedGitStatusLineStats({
cacheKey: 'native\0/repo',
head: 'head-1',
entries: matchingEntries
})
).toBe(true)
expect(matchingEntries).toEqual(cachedEntries)
expect(
applyCachedGitStatusLineStats({
cacheKey: 'native\0/repo',
head: 'head-2',
entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }]
})
).toBe(false)
})
it('invalidates a matching status identity at the bounded age', () => {
storeGitStatusLineStats({
cacheKey: 'native\0/repo',
head: 'head-1',
entries: cachedEntries,
now: 1_000
})
expect(
applyCachedGitStatusLineStats({
cacheKey: 'native\0/repo',
head: 'head-1',
entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }],
now: 1_000 + GIT_STATUS_LINE_STATS_CACHE_MAX_AGE_MS
})
).toBe(false)
})
it('isolates execution-host keys and supports mutation invalidation', () => {
storeGitStatusLineStats({
cacheKey: 'wsl:ubuntu\0/repo',
head: 'head-1',
entries: cachedEntries
})
expect(
applyCachedGitStatusLineStats({
cacheKey: 'wsl:debian\0/repo',
head: 'head-1',
entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }]
})
).toBe(false)
clearGitStatusLineStatsCacheKey('wsl:ubuntu\0/repo')
expect(
applyCachedGitStatusLineStats({
cacheKey: 'wsl:ubuntu\0/repo',
head: 'head-1',
entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }]
})
).toBe(false)
})
it('lets an earlier recompute store after a later reuse-only read began', () => {
const recomputeWrite = beginGitStatusLineStatsCacheWrite('native\0/repo')
// A hinted safety read begins later, reuses, and never stores; it must not
// starve the older recompute's store.
beginGitStatusLineStatsCacheWrite('native\0/repo')
storeGitStatusLineStats({
cacheKey: 'native\0/repo',
head: 'head-1',
entries: cachedEntries,
writeToken: recomputeWrite
})
const entries: { path: string; status: string; area: string; added?: number }[] = [
{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }
]
expect(
applyCachedGitStatusLineStats({ cacheKey: 'native\0/repo', head: 'head-1', entries })
).toBe(true)
expect(entries[0]?.added).toBe(3)
})
it('keeps a prior good snapshot reusable when an overlapping recompute is aborted', async () => {
// A completed scan stored healthy counts.
storeGitStatusLineStats({ cacheKey: 'native\0/repo', head: 'head-1', entries: cachedEntries })
// A later scan begins and recomputes but is aborted before it can store.
const abortedWrite = beginGitStatusLineStatsCacheWrite('native\0/repo')
let aborted = false
await expect(
reuseOrRecomputeGitStatusLineStats({
cacheKey: 'native\0/repo',
head: 'head-1',
entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }],
writeToken: abortedWrite,
reuse: false,
isAborted: () => aborted,
recompute: async () => {
aborted = true
return true
}
})
).rejects.toMatchObject({ name: 'AbortError' })
// The aborted pass persisted nothing, so it must not evict the healthy
// snapshot — a following safety read still reuses the stored counts.
const entries: { path: string; status: string; area: string; added?: number }[] = [
{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }
]
expect(
applyCachedGitStatusLineStats({ cacheKey: 'native\0/repo', head: 'head-1', entries })
).toBe(true)
expect(entries[0]?.added).toBe(3)
})
it('rejects a reuse-only read that is already aborted', async () => {
storeGitStatusLineStats({ cacheKey: 'native\0/repo', head: 'head-1', entries: cachedEntries })
const writeToken = beginGitStatusLineStatsCacheWrite('native\0/repo')
await expect(
reuseOrRecomputeGitStatusLineStats({
cacheKey: 'native\0/repo',
head: 'head-1',
entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }],
writeToken,
reuse: true,
isAborted: () => true,
recompute: async () => true
})
).rejects.toMatchObject({ name: 'AbortError' })
})
it('rejects stale cache writes and writes started before invalidation', () => {
const olderWrite = beginGitStatusLineStatsCacheWrite('native\0/repo')
const latestWrite = beginGitStatusLineStatsCacheWrite('native\0/repo')
storeGitStatusLineStats({
cacheKey: 'native\0/repo',
head: 'head-1',
entries: [{ ...cachedEntries[0], added: 8 }],
writeToken: latestWrite
})
storeGitStatusLineStats({
cacheKey: 'native\0/repo',
head: 'head-1',
entries: cachedEntries,
writeToken: olderWrite
})
const entries: { path: string; status: string; area: string; added?: number }[] = [
{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }
]
expect(
applyCachedGitStatusLineStats({ cacheKey: 'native\0/repo', head: 'head-1', entries })
).toBe(true)
expect(entries[0]?.added).toBe(8)
const invalidatedWrite = beginGitStatusLineStatsCacheWrite('native\0/other')
clearGitStatusLineStatsCache()
storeGitStatusLineStats({
cacheKey: 'native\0/other',
head: 'head-1',
entries: cachedEntries,
writeToken: invalidatedWrite
})
expect(
applyCachedGitStatusLineStats({
cacheKey: 'native\0/other',
head: 'head-1',
entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }]
})
).toBe(false)
})
})