mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 00:02:35 +00:00
* 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.
180 lines
6.2 KiB
TypeScript
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({})
|
|
})
|
|
})
|