Files
orca/src/shared/git-branch-line-total.test.ts
T
2026-08-07 09:55:54 -07:00

512 lines
16 KiB
TypeScript

import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import * as path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
buildGitBranchLineTotalDiffArgs,
invalidateGitBranchLineTotalInFlight,
computeGitBranchLineTotal,
GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS,
GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS,
isGitBranchLineTotalMergeBase,
readGitBranchLineTotalMergeBaseParam,
sumGitBranchLineTotal
} from './git-branch-line-total'
import type { GitLineStats } from './git-uncommitted-line-stats'
const MERGE_BASE = 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4'
const NO_LINES = { added: 0, removed: 0 }
const OTHER_MERGE_BASE = '0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c'
function statsMap(entries: Record<string, GitLineStats>): ReadonlyMap<string, GitLineStats> {
return new Map(Object.entries(entries))
}
function createAbortError(): Error {
const error = new Error('The operation was aborted.')
error.name = 'AbortError'
return error
}
const tempRoots: string[] = []
async function createWorktreeDir(files: Record<string, string> = {}): Promise<string> {
const root = await mkdtemp(path.join(tmpdir(), 'orca-branch-line-total-'))
tempRoots.push(root)
for (const [relativePath, contents] of Object.entries(files)) {
await writeFile(path.join(root, relativePath), contents)
}
return root
}
beforeEach(() => {
invalidateGitBranchLineTotalInFlight()
})
afterEach(async () => {
invalidateGitBranchLineTotalInFlight()
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('sumGitBranchLineTotal', () => {
it('counts binary entries as zero rather than NaN', () => {
expect(
sumGitBranchLineTotal({
mergeBase: MERGE_BASE,
tracked: statsMap({
'blob.bin': { added: undefined, removed: undefined },
'src/a.ts': { added: 4, removed: 1 }
}),
untracked: statsMap({})
})
).toEqual({ added: 4, removed: 1, mergeBase: MERGE_BASE, test: NO_LINES, generated: NO_LINES })
})
it('counts a half-binary entry on the side git did report', () => {
expect(
sumGitBranchLineTotal({
mergeBase: MERGE_BASE,
tracked: statsMap({ 'odd.txt': { added: 3 } }),
untracked: statsMap({ 'new.bin': {} })
})
).toEqual({ added: 3, removed: 0, mergeBase: MERGE_BASE, test: NO_LINES, generated: NO_LINES })
})
it('adds untracked additions on top of the tracked range and echoes the merge base', () => {
expect(
sumGitBranchLineTotal({
mergeBase: MERGE_BASE,
tracked: statsMap({ 'src/a.ts': { added: 10, removed: 2 } }),
untracked: statsMap({ 'src/new.ts': { added: 7, removed: 0 }, 'src/also.ts': { added: 1 } })
})
).toEqual({ added: 18, removed: 2, mergeBase: MERGE_BASE, test: NO_LINES, generated: NO_LINES })
})
it('returns an all-zero total for a pure rename rather than omitting it', () => {
expect(
sumGitBranchLineTotal({
mergeBase: MERGE_BASE,
tracked: statsMap({ 'g.txt': { added: 0, removed: 0 } }),
untracked: statsMap({})
})
).toEqual({ added: 0, removed: 0, mergeBase: MERGE_BASE, test: NO_LINES, generated: NO_LINES })
})
it('splits out the test-file share of both halves, untracked files included', () => {
expect(
sumGitBranchLineTotal({
mergeBase: MERGE_BASE,
tracked: statsMap({
'src/a.ts': { added: 10, removed: 2 },
'src/a.test.ts': { added: 30, removed: 5 },
'e2e/login.ts': { added: 4, removed: 1 }
}),
untracked: statsMap({ 'src/b.spec.ts': { added: 6 }, 'src/b.ts': { added: 2 } })
})
).toEqual({
added: 52,
removed: 8,
mergeBase: MERGE_BASE,
test: { added: 40, removed: 6 },
generated: NO_LINES
})
})
it('splits generated lines out of both the total and the test share', () => {
expect(
sumGitBranchLineTotal({
mergeBase: MERGE_BASE,
tracked: statsMap({
'src/a.ts': { added: 10, removed: 2 },
'src/a.test.ts': { added: 30, removed: 5 },
'pnpm-lock.yaml': { added: 900, removed: 400 },
'api/service.pb.go': { added: 120, removed: 0 },
// Generated wins the overlap: a snapshot is both, and counts as churn.
'src/__snapshots__/a.tsx.snap': { added: 50, removed: 20 }
}),
untracked: statsMap({ 'dist/bundle.js': { added: 5000 } })
})
).toEqual({
added: 6110,
removed: 427,
mergeBase: MERGE_BASE,
test: { added: 30, removed: 5 },
generated: { added: 6070, removed: 420 }
})
})
it('keeps the three buckets disjoint so they sum back to the total', () => {
const total = sumGitBranchLineTotal({
mergeBase: MERGE_BASE,
tracked: statsMap({
'src/a.ts': { added: 10, removed: 2 },
'src/a.test.ts': { added: 30, removed: 5 },
'go.sum': { added: 7, removed: 3 },
'tests/fixtures/repo.json': { added: 4, removed: 1 }
}),
untracked: statsMap({ 'src/b.ts': { added: 2 } })
})
const source = {
added: total.added - total.test!.added - total.generated!.added,
removed: total.removed - total.test!.removed - total.generated!.removed
}
expect(source).toEqual({ added: 12, removed: 2 })
expect(total.test).toEqual({ added: 34, removed: 6 })
expect(total.generated).toEqual({ added: 7, removed: 3 })
})
})
describe('merge base param validation', () => {
it('accepts abbreviated and full object names', () => {
for (const value of ['abc1234', MERGE_BASE, 'f'.repeat(64)]) {
expect(isGitBranchLineTotalMergeBase(value)).toBe(true)
expect(readGitBranchLineTotalMergeBaseParam(value)).toBe(value)
}
})
it('rejects anything that is not an object name, notably flag-shaped input', () => {
const rejected: unknown[] = [
'--upload-pack=x',
'-M',
'HEAD',
'',
'origin/main',
'A1B2C3D',
'abc123',
'f'.repeat(65),
'abc1234 --output=/tmp/x',
undefined,
null,
42,
{ toString: () => MERGE_BASE }
]
for (const value of rejected) {
expect(isGitBranchLineTotalMergeBase(value)).toBe(false)
expect(readGitBranchLineTotalMergeBaseParam(value)).toBeUndefined()
}
})
})
describe('buildGitBranchLineTotalDiffArgs', () => {
it('diffs the merge base against the working tree with -M only', () => {
expect(buildGitBranchLineTotalDiffArgs(MERGE_BASE)).toEqual([
'-c',
'core.quotePath=false',
'diff',
'-z',
'--numstat',
'-M',
MERGE_BASE,
'--'
])
})
it('never asks for --cached or -C, which would change the measured range', () => {
const args = buildGitBranchLineTotalDiffArgs(MERGE_BASE)
expect(args).not.toContain('--cached')
expect(args).not.toContain('-C')
expect(args.at(-1)).toBe('--')
})
it('exposes a finite timeout budget so a huge branch cannot block the status response', () => {
expect(GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS).toBeGreaterThan(0)
expect(Number.isFinite(GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS)).toBe(true)
})
})
describe('computeGitBranchLineTotal', () => {
it('never invokes git for a merge base that is not an object name', async () => {
const runDiffNumstat = vi.fn()
for (const mergeBase of ['--upload-pack=x', 'HEAD', '', 'origin/main']) {
await expect(
computeGitBranchLineTotal({
worktreePath: '/repo',
hostKey: 'native',
mergeBase,
untrackedPaths: [],
runDiffNumstat
})
).resolves.toBeUndefined()
}
expect(runDiffNumstat).not.toHaveBeenCalled()
})
it('includes untracked additions the ranged diff cannot see', async () => {
const worktreePath = await createWorktreeDir({ 'new.txt': 'one\ntwo\nthree\n' })
await expect(
computeGitBranchLineTotal({
worktreePath,
hostKey: 'native',
mergeBase: MERGE_BASE,
untrackedPaths: ['new.txt'],
runDiffNumstat: async () => '4\t1\tsrc/a.ts\0'
})
).resolves.toEqual({
added: 7,
removed: 1,
mergeBase: MERGE_BASE,
test: NO_LINES,
generated: NO_LINES
})
})
it('omits the total when the ranged numstat fails instead of publishing untracked-only zeros', async () => {
const worktreePath = await createWorktreeDir({ 'new.txt': 'one\ntwo\n' })
await expect(
computeGitBranchLineTotal({
worktreePath,
hostKey: 'native',
mergeBase: MERGE_BASE,
untrackedPaths: ['new.txt'],
runDiffNumstat: async () => {
throw new Error('fatal: bad object')
}
})
).resolves.toBeUndefined()
})
it('coalesces concurrent callers sharing a host, worktree and merge base into one exec', async () => {
let release = (): void => {}
const gate = new Promise<void>((resolve) => {
release = resolve
})
const runDiffNumstat = vi.fn(async () => {
await gate
return '4\t1\tsrc/a.ts\0'
})
const input = {
worktreePath: '/repo',
hostKey: 'native',
mergeBase: MERGE_BASE,
untrackedPaths: [],
runDiffNumstat
}
const both = Promise.all([computeGitBranchLineTotal(input), computeGitBranchLineTotal(input)])
release()
expect(await both).toEqual([
{ added: 4, removed: 1, mergeBase: MERGE_BASE, test: NO_LINES, generated: NO_LINES },
{ added: 4, removed: 1, mergeBase: MERGE_BASE, test: NO_LINES, generated: NO_LINES }
])
expect(runDiffNumstat).toHaveBeenCalledTimes(1)
})
it('keeps separate execs for a different merge base, worktree or host', async () => {
const runDiffNumstat = vi.fn(async () => '1\t0\tsrc/a.ts\0')
const base = {
worktreePath: '/repo',
hostKey: 'native',
mergeBase: MERGE_BASE,
untrackedPaths: [],
runDiffNumstat
}
await Promise.all([
computeGitBranchLineTotal(base),
computeGitBranchLineTotal({ ...base, mergeBase: OTHER_MERGE_BASE }),
computeGitBranchLineTotal({ ...base, worktreePath: '/other-repo' }),
computeGitBranchLineTotal({ ...base, hostKey: 'Ubuntu' })
])
expect(runDiffNumstat).toHaveBeenCalledTimes(4)
})
it('re-execs once the shared lease has settled', async () => {
const runDiffNumstat = vi.fn(async () => '1\t0\tsrc/a.ts\0')
const input = {
worktreePath: '/repo',
hostKey: 'native',
mergeBase: MERGE_BASE,
untrackedPaths: [],
runDiffNumstat
}
await computeGitBranchLineTotal(input)
await computeGitBranchLineTotal(input)
expect(runDiffNumstat).toHaveBeenCalledTimes(2)
})
it('rejects when the shared diff is aborted instead of resolving a partial total', async () => {
const controller = new AbortController()
const runDiffNumstat = vi.fn(
(_args: string[], signal: AbortSignal) =>
new Promise<string>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(createAbortError()), { once: true })
})
)
const pending = computeGitBranchLineTotal({
worktreePath: '/repo',
hostKey: 'native',
mergeBase: MERGE_BASE,
untrackedPaths: [],
runDiffNumstat,
signal: controller.signal
})
const assertion = expect(pending).rejects.toMatchObject({ name: 'AbortError' })
controller.abort()
await assertion
})
it('rejects immediately for a signal that is already aborted', async () => {
const runDiffNumstat = vi.fn(async () => '1\t0\tsrc/a.ts\0')
await expect(
computeGitBranchLineTotal({
worktreePath: '/repo',
hostKey: 'native',
mergeBase: MERGE_BASE,
untrackedPaths: [],
runDiffNumstat,
signal: AbortSignal.abort()
})
).rejects.toMatchObject({ name: 'AbortError' })
expect(runDiffNumstat).not.toHaveBeenCalled()
})
it('passes the built argv straight through to the runner', async () => {
const runDiffNumstat = vi.fn(async () => '')
await computeGitBranchLineTotal({
worktreePath: '/repo',
hostKey: 'native',
mergeBase: MERGE_BASE,
untrackedPaths: [],
runDiffNumstat
})
expect(runDiffNumstat).toHaveBeenCalledWith(
buildGitBranchLineTotalDiffArgs(MERGE_BASE),
expect.anything()
)
})
it('invalidateGitBranchLineTotalInFlight stops a later pass joining a pre-mutation diff', async () => {
let release = (): void => {}
const gate = new Promise<void>((resolve) => {
release = resolve
})
const runDiffNumstat = vi.fn(async () => {
await gate
return '1\t0\tsrc/a.ts\0'
})
const input = {
worktreePath: '/repo',
hostKey: 'native',
mergeBase: MERGE_BASE,
untrackedPaths: [],
runDiffNumstat
}
const first = computeGitBranchLineTotal(input)
invalidateGitBranchLineTotalInFlight()
const second = computeGitBranchLineTotal(input)
release()
await Promise.all([first, second])
expect(runDiffNumstat).toHaveBeenCalledTimes(2)
})
})
describe('computeGitBranchLineTotal ranged-diff cooldown', () => {
let nowMs = 0
function diffTaking(durationMs: number): () => Promise<string> {
return vi.fn(async () => {
nowMs += durationMs
return '10\t2\tsrc/a.ts\0'
})
}
function inputFor(
runDiffNumstat: () => Promise<string>,
worktreePath = '/repo'
): Parameters<typeof computeGitBranchLineTotal>[0] {
return {
worktreePath,
hostKey: 'native',
mergeBase: MERGE_BASE,
untrackedPaths: [],
runDiffNumstat
}
}
const TOTAL = {
added: 10,
removed: 2,
mergeBase: MERGE_BASE,
test: NO_LINES,
generated: NO_LINES
}
beforeEach(() => {
nowMs = 0
vi.spyOn(performance, 'now').mockImplementation(() => nowMs)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('skips a rerun for as long as the overrunning diff itself took', async () => {
const runDiffNumstat = diffTaking(900)
const input = inputFor(runDiffNumstat)
await expect(computeGitBranchLineTotal(input)).resolves.toEqual(TOTAL)
await expect(computeGitBranchLineTotal(input)).resolves.toBeUndefined()
expect(runDiffNumstat).toHaveBeenCalledTimes(1)
nowMs += 900
await expect(computeGitBranchLineTotal(input)).resolves.toEqual(TOTAL)
expect(runDiffNumstat).toHaveBeenCalledTimes(2)
})
it('leaves a diff inside the soft deadline uncooled, so ordinary repos are untouched', async () => {
const runDiffNumstat = diffTaking(GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS - 1)
const input = inputFor(runDiffNumstat)
await expect(computeGitBranchLineTotal(input)).resolves.toEqual(TOTAL)
await expect(computeGitBranchLineTotal(input)).resolves.toEqual(TOTAL)
expect(runDiffNumstat).toHaveBeenCalledTimes(2)
})
it('arms the cooldown on a failing diff too, since a timeout is the costliest outcome', async () => {
const runDiffNumstat = vi.fn(async () => {
nowMs += GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS
throw new Error('timed out')
})
const input = inputFor(runDiffNumstat)
await expect(computeGitBranchLineTotal(input)).resolves.toBeUndefined()
await expect(computeGitBranchLineTotal(input)).resolves.toBeUndefined()
expect(runDiffNumstat).toHaveBeenCalledTimes(1)
})
it('clears the cooldown on a git mutation, when a fresh total matters most', async () => {
const runDiffNumstat = diffTaking(900)
const input = inputFor(runDiffNumstat)
await expect(computeGitBranchLineTotal(input)).resolves.toEqual(TOTAL)
invalidateGitBranchLineTotalInFlight()
await expect(computeGitBranchLineTotal(input)).resolves.toEqual(TOTAL)
expect(runDiffNumstat).toHaveBeenCalledTimes(2)
})
it('cools down per worktree, so one slow branch cannot mute another', async () => {
const slow = diffTaking(900)
const other = diffTaking(900)
await expect(computeGitBranchLineTotal(inputFor(slow, '/repo'))).resolves.toEqual(TOTAL)
await expect(computeGitBranchLineTotal(inputFor(slow, '/repo'))).resolves.toBeUndefined()
await expect(computeGitBranchLineTotal(inputFor(other, '/other'))).resolves.toEqual(TOTAL)
expect(slow).toHaveBeenCalledTimes(1)
expect(other).toHaveBeenCalledTimes(1)
})
})