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): ReadonlyMap { 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 = {}): Promise { 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((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((_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((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 { return vi.fn(async () => { nowMs += durationMs return '10\t2\tsrc/a.ts\0' }) } function inputFor( runDiffNumstat: () => Promise, worktreePath = '/repo' ): Parameters[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) }) })