Files
orca/src/shared/git-uncommitted-line-stats.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

180 lines
6.2 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const { lstatMock, readFileMock } = vi.hoisted(() => ({
lstatMock: vi.fn(),
readFileMock: vi.fn()
}))
vi.mock('fs/promises', () => ({ lstat: lstatMock, readFile: readFileMock }))
import {
applyLineStats,
collectUntrackedAdditions,
MAX_UNTRACKED_LINE_COUNT_BYTES,
parseNumstat
} from './git-uncommitted-line-stats'
function mockFileStat(size: number, mtimeMs = 1) {
return {
size,
mtimeMs,
ctimeMs: mtimeMs,
isFile: () => true,
isSymbolicLink: () => false
}
}
describe('parseNumstat', () => {
it('parses added/removed counts keyed by path', () => {
const stats = parseNumstat('3\t4\tsrc/app.ts\n10\t0\tsrc/new.ts\n')
expect(stats.get('src/app.ts')).toEqual({ added: 3, removed: 4 })
expect(stats.get('src/new.ts')).toEqual({ added: 10, removed: 0 })
})
it('treats binary "-" columns as undefined counts', () => {
expect(parseNumstat('-\t-\tassets/logo.png\n').get('assets/logo.png')).toEqual({
added: undefined,
removed: undefined
})
})
it('keys renames to the post-rename path', () => {
const braced = parseNumstat('2\t1\tsrc/{old => new}/file.ts\n')
expect(braced.get('src/new/file.ts')).toEqual({ added: 2, removed: 1 })
const plain = parseNumstat('2\t1\told.ts => new.ts\n')
expect(plain.get('new.ts')).toEqual({ added: 2, removed: 1 })
})
it('keeps literal rename-marker filenames when parsing NUL-delimited numstat', () => {
const stats = parseNumstat('1\t0\tdocs/a => b.txt\0')
expect(stats.get('docs/a => b.txt')).toEqual({ added: 1, removed: 0 })
})
it('keys NUL-delimited renames to the post-rename path', () => {
const stats = parseNumstat('2\t1\t\0old.ts\0new.ts\0')
expect(stats.get('new.ts')).toEqual({ added: 2, removed: 1 })
})
it('decodes Git C-quoted paths before keying stats', () => {
expect(parseNumstat('1\t1\t"tab\\tfile.txt"\n').get('tab\tfile.txt')).toEqual({
added: 1,
removed: 1
})
})
it('ignores blank lines', () => {
expect(parseNumstat('').size).toBe(0)
})
})
describe('collectUntrackedAdditions', () => {
beforeEach(() => {
lstatMock.mockReset()
readFileMock.mockReset()
})
it('counts file lines as additions, with or without a trailing newline', async () => {
lstatMock.mockImplementation((target: string) =>
Promise.resolve(mockFileStat(String(target).endsWith('trailing.ts') ? 6 : 5))
)
readFileMock.mockImplementation((target: string) =>
Promise.resolve(
String(target).endsWith('trailing.ts') ? Buffer.from('a\nb\nc\n') : Buffer.from('a\nb\nc')
)
)
const stats = await collectUntrackedAdditions('/repo', ['trailing.ts', 'no-trailing.ts'])
expect(stats.get('trailing.ts')).toEqual({ added: 3 })
expect(stats.get('no-trailing.ts')).toEqual({ added: 3 })
})
it('reports an empty file as zero additions', async () => {
lstatMock.mockResolvedValue(mockFileStat(0))
readFileMock.mockResolvedValue(Buffer.from(''))
expect((await collectUntrackedAdditions('/repo', ['empty.ts'])).get('empty.ts')).toEqual({
added: 0
})
})
it('omits counts for binary files', async () => {
lstatMock.mockResolvedValue(mockFileStat(3))
readFileMock.mockResolvedValue(Buffer.from([0x00, 0x01, 0x02]))
expect((await collectUntrackedAdditions('/repo', ['bin.dat'])).get('bin.dat')).toEqual({})
})
it('counts untracked symbolic links without following the target', async () => {
lstatMock.mockResolvedValue({
size: 4,
mtimeMs: 2,
ctimeMs: 2,
isFile: () => false,
isSymbolicLink: () => true
})
expect((await collectUntrackedAdditions('/repo', ['link.txt'])).get('link.txt')).toEqual({
added: 1
})
expect(readFileMock).not.toHaveBeenCalled()
})
it('skips oversized untracked files instead of reading them during status polling', async () => {
lstatMock.mockResolvedValue(mockFileStat(MAX_UNTRACKED_LINE_COUNT_BYTES + 1, 3))
expect((await collectUntrackedAdditions('/repo', ['large.log'])).get('large.log')).toEqual({})
expect(readFileMock).not.toHaveBeenCalled()
})
it('reuses cached counts while size and mtime are unchanged', async () => {
lstatMock.mockResolvedValue(mockFileStat(5, 4))
readFileMock.mockResolvedValue(Buffer.from('a\nb\nc'))
await collectUntrackedAdditions('/repo', ['cached.ts'])
const stats = await collectUntrackedAdditions('/repo', ['cached.ts'])
expect(stats.get('cached.ts')).toEqual({ added: 3 })
expect(readFileMock).toHaveBeenCalledTimes(1)
})
it('rejects when the status request is aborted instead of returning partial counts', async () => {
const controller = new AbortController()
controller.abort()
await expect(
collectUntrackedAdditions('/repo', ['a.ts'], controller.signal)
).rejects.toMatchObject({ name: 'AbortError' })
expect(lstatMock).not.toHaveBeenCalled()
})
it('keeps the cache effective across polls for a status-limit-sized change set', async () => {
// Why: git status caps at DEFAULT_GIT_STATUS_LIMIT (10,000) entries. A
// cache smaller than one scan FIFO-evicts every entry mid-scan, so the
// next poll re-reads every file (#8013). Scan the full limit twice; the
// second pass must be stat-only.
lstatMock.mockResolvedValue(mockFileStat(5, 7))
readFileMock.mockResolvedValue(Buffer.from('a\nb\nc'))
const paths = Array.from({ length: 10_000 }, (_, i) => `poll-scale/file-${i}.ts`)
await collectUntrackedAdditions('/repo', paths)
const firstPassReads = readFileMock.mock.calls.length
await collectUntrackedAdditions('/repo', paths)
expect(firstPassReads).toBe(paths.length)
expect(readFileMock).toHaveBeenCalledTimes(paths.length)
})
})
describe('applyLineStats', () => {
it('copies defined counts onto the entry', () => {
const entry: { added?: number; removed?: number } = {}
applyLineStats(entry, { added: 5, removed: 2 })
expect(entry).toEqual({ added: 5, removed: 2 })
})
it('leaves the entry untouched for undefined counts or missing stats', () => {
const entry: { added?: number; removed?: number } = {}
applyLineStats(entry, { added: undefined, removed: undefined })
applyLineStats(entry, undefined)
expect(entry).toEqual({})
})
})