import type { ProcessRunResult } from 'claude-code' import { describe, expect, test, tier } from 'claude-code/testing' import Git from '../../hooks/git' import Limits from '../../hooks/limits' import Fixtures from '../fixtures' tier('builtin') describe('fetch-diff', () => { const HALF_CAP_RECORDS = Limits.HOST_OUTPUT_CAP_BYTES / 2 const NEAR_CAP_RECORDS = HALF_CAP_RECORDS - 2 type Script = Record type Listing = readonly (readonly [string, Git.EntryKind])[] type ScriptedDeps = Git.GitDeps & Pick const listingOf = (entries: Listing): ReadonlyMap => new Map(entries) function depsOf( script: Script, overrides: Partial = {}, ): ScriptedDeps { const scripted = Fixtures.scriptedGitOf(script) return { argvs: scripted.argvs, run: scripted.run, repository: Fixtures.FETCH_REPOSITORY, mtimeOf: () => Promise.resolve(Fixtures.DURING_SESSION_MS), entryKindsOf: () => Promise.resolve(listingOf(Fixtures.FILES_LISTING)), sessionStartMs: Fixtures.GIT_SESSION_START_MS, onBranchBase: () => undefined, ...overrides, } } const unbornScriptOf = (): Script => ({ [Fixtures.VS_HEAD]: Fixtures.UNSCRIPTED, 'rev-parse --verify --quiet HEAD': Fixtures.PROBE_MISSED, '--cached --shortstat': Fixtures.ok(Fixtures.ONE_STAGED), 'ls-files': Fixtures.ok(), }) const datedBefore = (suffix: string): Git.GitDeps['mtimeOf'] => path => Promise.resolve( path.endsWith(suffix) ? Fixtures.BEFORE_SESSION_MS : Fixtures.DURING_SESSION_MS, ) const trackedNoUntrackedOf = (): Script => ({ 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'), 'HEAD --numstat': Fixtures.ok('1\t0\ta.ts\0'), 'ls-files': Fixtures.GIT_TIMED_OUT, }) const onFeatureNoUntrackedOf = (): Script => ({ 'rev-parse --abbrev-ref HEAD': Fixtures.ok('feature\n'), 'symbolic-ref --short refs/remotes/origin/HEAD': Fixtures.ok('origin/main\n'), 'show-ref --verify --quiet refs/remotes/origin/main': Fixtures.ok(), 'merge-base HEAD origin/main': Fixtures.ok(`${Fixtures.ORIGIN_BASE}\n`), 'rev-parse HEAD': Fixtures.ok(`${Fixtures.FEATURE_HEAD}\n`), [`${Fixtures.ORIGIN_BASE} --shortstat`]: Fixtures.ok(Fixtures.ONE_STAGED), [`${Fixtures.ORIGIN_BASE} --numstat`]: Fixtures.ok('3\t0\ta.ts\0'), 'ls-files': Fixtures.GIT_TIMED_OUT, }) const unbornNoUntrackedOf = (): Script => ({ ...unbornScriptOf(), '--cached --numstat': Fixtures.ok('3\t0\tstaged.ts\0'), '--submodule=short --numstat': Fixtures.ok('2\t1\tstaged.ts\0'), 'ls-files': Fixtures.GIT_TIMED_OUT, }) test('session mode: rows tagged by mtime, untracked merged', async () => { const outcome = await Git.fetchDiff( depsOf( { 'HEAD --shortstat': Fixtures.ok( ' 2 files changed, 3 insertions(+), 1 deletion(-)', ), 'HEAD --numstat': Fixtures.ok('2\t1\told.ts\x001\t0\tnew.ts\0'), 'ls-files': Fixtures.ok('scratch.txt\0'), }, { mtimeOf: datedBefore('old.ts') }, ), 'session', ) const data = outcome.kind === 'data' ? outcome.data : null expect(data?.repository).toEqual(Fixtures.FETCH_REPOSITORY) expect(data?.baseRef).toBe('HEAD') expect(data?.isUnborn).toBe(false) expect(data?.stats).toEqual({ filesCount: 3, linesAdded: 3, linesRemoved: 1, }) expect( data?.files.map(file => [file.path, file.isPreSession, file.isUntracked]), ).toEqual([ ['old.ts', true, false], ['new.ts', false, false], ['scratch.txt', false, true], ]) }) test('every diff spawn carries the pinned leading argv', async () => { const deps = depsOf({ 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'), 'HEAD --numstat': Fixtures.ok('1\t0\ta.ts\0'), 'ls-files': Fixtures.ok(), }) await Git.fetchDiff(deps, 'uncommitted') const diffs = deps.argvs.filter(argv => argv.includes('diff')) expect(deps.argvs.every(argv => argv[0] === '--no-optional-locks')).toBe( true, ) expect(diffs).toHaveLength(2) expect(diffs.every(argv => argv.includes('core.quotePath=false'))).toBe( true, ) expect(diffs.every(argv => argv.includes('--no-ext-diff'))).toBe(true) expect(diffs.every(argv => argv.includes('--no-textconv'))).toBe(true) expect(diffs[1]).toContain('-z') expect(deps.argvs.find(argv => argv.includes('ls-files'))).toContain('-z') expect( diffs.every( argv => argv.slice(0, Git.DIFF_LEADING_ARGS.length).join(' ') === Git.DIFF_LEADING_ARGS.join(' '), ), ).toBe(true) }) test('uncommitted mode drops pre-session untracked files', async () => { const outcome = await Git.fetchDiff( depsOf( { 'HEAD --shortstat': Fixtures.ok(), 'HEAD --numstat': Fixtures.ok(), 'ls-files': Fixtures.ok('stale.txt\0fresh.txt\0'), }, { mtimeOf: datedBefore('stale.txt') }, ), 'uncommitted', ) expect( outcome.kind === 'data' && outcome.data.files.map(file => file.path), ).toEqual(['fresh.txt']) }) test('past 500 files: totals only, no numstat, no untracked', async () => { const deps = depsOf({ 'HEAD --shortstat': Fixtures.ok(' 501 files changed, 9 insertions(+)'), }) const outcome = await Git.fetchDiff(deps, 'session') expect(outcome.kind === 'data' && outcome.data.stats.filesCount).toBe(501) expect(outcome.kind === 'data' && outcome.data.files).toEqual([]) expect(deps.argvs.some(argv => argv.includes('--numstat'))).toBe(false) expect(deps.argvs.some(argv => argv.includes('ls-files'))).toBe(false) }) test('a transient state file: unavailable, and no diff runs', async () => { const deps = depsOf( {}, { entryKindsOf: dir => Promise.resolve( listingOf( dir === '/repo/.git' ? Fixtures.MERGING : Fixtures.FILES_LISTING, ), ), }, ) expect(await Git.fetchDiff(deps, 'session')).toEqual({ kind: 'unavailable', }) expect(deps.argvs.some(argv => argv.includes('diff'))).toBe(false) }) test('unborn HEAD: staged rows with unstaged edits folded in', async () => { const outcome = await Git.fetchDiff( depsOf({ [Fixtures.VS_HEAD]: Fixtures.UNSCRIPTED, 'rev-parse --verify --quiet HEAD': Fixtures.PROBE_MISSED, '--cached --shortstat': Fixtures.ok(' 1 file changed, 3 insertions(+)'), '--cached --numstat': Fixtures.ok('3\t0\tstaged.ts\0'), '--submodule=short --numstat': Fixtures.ok('2\t1\tstaged.ts\0'), 'ls-files': Fixtures.ok(), }), 'session', ) const data = outcome.kind === 'data' ? outcome.data : null expect(data?.isUnborn).toBe(true) expect(data?.baseRef).toBe('--cached') expect( data?.files.map(file => [file.path, file.added, file.removed]), ).toEqual([['staged.ts', 4, 0]]) expect(data?.stats.linesAdded).toBe(4) expect(data?.stalePaths).toEqual(['staged.ts']) }) test('unborn HEAD: a staged or unstaged listing git withheld', async () => { const noStaged = await Git.fetchDiff(depsOf(unbornScriptOf()), 'session') const noUnstaged = await Git.fetchDiff( depsOf({ ...unbornScriptOf(), '--cached --numstat': Fixtures.ok('3\t0\tstaged.ts\0'), '--submodule=short --numstat': Fixtures.ok('2\t1\tstaged.ts'), }), 'session', ) expect(noStaged).toEqual({ kind: 'unavailable' }) expect(noUnstaged).toEqual({ kind: 'unavailable' }) }) test('a withheld untracked listing: tracked rows, flagged', async () => { const outcomes = await Promise.all([ Git.fetchDiff(depsOf(trackedNoUntrackedOf()), 'session'), Git.fetchDiff(depsOf(trackedNoUntrackedOf()), 'uncommitted'), Git.fetchDiff(depsOf(onFeatureNoUntrackedOf()), 'branch'), Git.fetchDiff(depsOf(unbornNoUntrackedOf()), 'session'), ]) const listed = await Git.fetchDiff( depsOf({ ...trackedNoUntrackedOf(), 'ls-files': Fixtures.ok('new.ts\0'), }), 'uncommitted', ) expect( outcomes.map( outcome => outcome.kind === 'data' && [ outcome.data.files.map(file => file.path), outcome.data.stats.filesCount, outcome.data.isUntrackedWithheld, ], ), ).toEqual([ [['a.ts'], 1, true], [['a.ts'], 1, true], [['a.ts'], 1, true], [['staged.ts'], 1, true], ]) expect(listed.kind === 'data' && listed.data.isUntrackedWithheld).toBe( false, ) }) test('a failed vs-HEAD diff with commits present: unavailable', async () => { expect( await Git.fetchDiff( depsOf({ 'rev-parse --verify --quiet HEAD': Fixtures.ok('abc\n'), }), 'session', ), ).toEqual({ kind: 'unavailable' }) }) test('a tracked or untracked symlink is never dated: no stat', async () => { const probed: string[] = [] const outcome = await Git.fetchDiff( depsOf( { 'HEAD --shortstat': Fixtures.ok(' 2 files changed, 2 insertions(+)'), 'HEAD --numstat': Fixtures.ok('1\t0\told.ts\x001\t0\tlink.ts\0'), 'ls-files': Fixtures.ok('leak\0scratch.txt\0'), }, { mtimeOf: path => { probed.push(path) return Promise.resolve(Fixtures.BEFORE_SESSION_MS) }, }, ), 'session', ) const data = outcome.kind === 'data' ? outcome.data : null expect(probed.toSorted()).toEqual(['/repo/old.ts', '/repo/scratch.txt']) expect(data?.files.map(file => [file.path, file.isPreSession])).toEqual([ ['old.ts', true], ['link.ts', false], ['leak', false], ['scratch.txt', true], ]) }) test('an unlistable directory dates nothing and stats nothing', async () => { const probed: string[] = [] await Git.fetchDiff( depsOf( { 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'), 'HEAD --numstat': Fixtures.ok('1\t0\told.ts\0'), 'ls-files': Fixtures.ok('scratch.txt\0'), }, { entryKindsOf: () => Promise.resolve(null), mtimeOf: path => { probed.push(path) return Promise.resolve(Fixtures.BEFORE_SESSION_MS) }, }, ), 'session', ) expect(probed).toEqual([]) }) test('a MERGE_HEAD that is a symbolic link is no merge', async () => { const outcome = await Git.fetchDiff( depsOf( { 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'), 'HEAD --numstat': Fixtures.ok('1\t0\ta.ts\0'), 'ls-files': Fixtures.ok(), }, { entryKindsOf: dir => { const isGitDir = dir === '/repo/.git' return Promise.resolve( listingOf( isGitDir ? Fixtures.MERGE_HEAD_LINKED : Fixtures.FILES_LISTING, ), ) }, }, ), 'session', ) expect(outcome).toMatchObject({ kind: 'data' }) expect(outcome.kind === 'data' && outcome.data.files).toHaveLength(1) }) test('a path under a symlinked directory: unlisted, undated', async () => { const listed: string[] = [] const probed: string[] = [] const outcome = await Git.fetchDiff( depsOf( { 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'), 'HEAD --numstat': Fixtures.ok('1\t0\tsub/inner.ts\0'), 'ls-files': Fixtures.ok(), }, { entryKindsOf: dir => { listed.push(dir) return Promise.resolve(listingOf(Fixtures.FILES_LISTING)) }, mtimeOf: path => { probed.push(path) return Promise.resolve(Fixtures.BEFORE_SESSION_MS) }, }, ), 'session', ) expect(listed).not.toContain('/repo/sub') expect(probed).toEqual([]) expect( outcome.kind === 'data' && outcome.data.files.map(f => f.isPreSession), ).toEqual([false]) }) test('a listing the host cut short is never parsed', async () => { const cutNumstat = await Git.fetchDiff( depsOf({ 'HEAD --shortstat': Fixtures.ok(' 2 files changed, 2 insertions(+)'), 'HEAD --numstat': Fixtures.ok('1\t0\ta.ts\x001\t0\told'), 'rev-parse --verify --quiet HEAD': Fixtures.ok('abc\n'), }), 'uncommitted', ) const cutUntracked = await Git.fetchDiff( depsOf({ 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'), 'HEAD --numstat': Fixtures.ok('1\t0\ta.ts\0'), 'ls-files': Fixtures.ok('scratch.txt\0fresh.tx'), }), 'uncommitted', ) expect(cutNumstat).toEqual({ kind: 'unavailable' }) expect( cutUntracked.kind === 'data' && [ cutUntracked.data.files.map(f => f.path), cutUntracked.data.isUntrackedWithheld, ], ).toEqual([['a.ts'], true]) expect(Git.isWholeListing('a\0'.repeat(HALF_CAP_RECORDS))).toBe(false) expect(Git.isWholeListing('a\0'.repeat(NEAR_CAP_RECORDS))).toBe(true) }) test('an untracked directory git prints with a slash is dated', async () => { const probed: string[] = [] const outcome = await Git.fetchDiff( depsOf( { 'HEAD --shortstat': Fixtures.ok(), 'HEAD --numstat': Fixtures.ok(), 'ls-files': Fixtures.ok('nested/\0'), }, { entryKindsOf: () => Promise.resolve(listingOf([['nested', 'dir']])), mtimeOf: path => { probed.push(path) return Promise.resolve(Fixtures.BEFORE_SESSION_MS) }, }, ), 'session', ) expect(probed).toEqual(['/repo/nested']) expect( outcome.kind === 'data' && outcome.data.files.map(file => [file.path, file.isPreSession]), ).toEqual([['nested/', true]]) }) test('a hundred directories deep and wide still date', async () => { const listed: string[] = [] const paths = Array.from( { length: Fixtures.DEEP_DIRECTORIES }, (_, at) => `d${at}/nested/file${at}.txt`, ) const outcome = await Git.fetchDiff( depsOf( { 'HEAD --shortstat': Fixtures.ok(), 'HEAD --numstat': Fixtures.ok(), 'ls-files': Fixtures.ok(paths.map(path => `${path}\0`).join('')), }, { entryKindsOf: dir => { listed.push(dir) const isLeaf = dir.endsWith('/nested') const entries: Listing = isLeaf ? paths.map(path => [path.split('/').at(-1) ?? '', 'file']) : [ ['nested', 'dir'], ...paths.map( path => [path.slice(0, path.indexOf('/')), 'dir'] as const, ), ] return Promise.resolve(listingOf(entries)) }, mtimeOf: () => Promise.resolve(Fixtures.BEFORE_SESSION_MS), }, ), 'session', ) const rows = outcome.kind === 'data' ? outcome.data.files : [] expect( new Set(listed.filter(dir => dir !== Fixtures.FETCH_REPOSITORY.gitDir)) .size, ).toBe(1 + 2 * Fixtures.DEEP_DIRECTORIES) expect(rows).toHaveLength(Limits.MAX_FILES) expect(rows.every(row => row.isPreSession)).toBe(true) }) test('a rename is one row under its new name and reads no body', async () => { const deps = depsOf({ 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'), 'HEAD --numstat': Fixtures.ok('1\t1\t\0old.ts\0new.ts\0'), 'ls-files': Fixtures.ok(), '-- old.ts new.ts': Fixtures.ok('@@ -1 +1 @@\n-a\n+b\n'), }) const outcome = await Git.fetchDiff(deps, 'uncommitted') const data = outcome.kind === 'data' ? outcome.data : null const [row] = data?.files ?? [] const body = data && row ? await Git.fetchFileHunks(deps.run, data, row) : null expect(data?.files.map(f => [f.path, f.renamedFrom])).toEqual([ ['new.ts', 'old.ts'], ]) expect(body).toEqual(Git.EMPTY_FILE_HUNKS) expect(deps.argvs.some(argv => argv.includes('new.ts'))).toBe(false) }) })