spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

claude-code / mods / diff / tests / git / fetch-diff.test.ts
544 lines · 16.1 KBRaw
1import type { ProcessRunResult } from 'claude-code'
2import { describe, expect, test, tier } from 'claude-code/testing'
3
4import Git from '../../hooks/git'
5import Limits from '../../hooks/limits'
6import Fixtures from '../fixtures'
7
8tier('builtin')
9
10describe('fetch-diff', () => {
11 const HALF_CAP_RECORDS = Limits.HOST_OUTPUT_CAP_BYTES / 2
12 const NEAR_CAP_RECORDS = HALF_CAP_RECORDS - 2
13
14 type Script = Record<string, ProcessRunResult>
15 type Listing = readonly (readonly [string, Git.EntryKind])[]
16 type ScriptedDeps = Git.GitDeps & Pick<Fixtures.ScriptedGit, 'argvs'>
17
18 const listingOf = (entries: Listing): ReadonlyMap<string, Git.EntryKind> =>
19 new Map(entries)
20
21 function depsOf(
22 script: Script,
23 overrides: Partial<Git.GitDeps> = {},
24 ): ScriptedDeps {
25 const scripted = Fixtures.scriptedGitOf(script)
26
27 return {
28 argvs: scripted.argvs,
29 run: scripted.run,
30 repository: Fixtures.FETCH_REPOSITORY,
31 mtimeOf: () => Promise.resolve(Fixtures.DURING_SESSION_MS),
32 entryKindsOf: () => Promise.resolve(listingOf(Fixtures.FILES_LISTING)),
33 sessionStartMs: Fixtures.GIT_SESSION_START_MS,
34 onBranchBase: () => undefined,
35 ...overrides,
36 }
37 }
38
39 const unbornScriptOf = (): Script => ({
40 [Fixtures.VS_HEAD]: Fixtures.UNSCRIPTED,
41 'rev-parse --verify --quiet HEAD': Fixtures.PROBE_MISSED,
42 '--cached --shortstat': Fixtures.ok(Fixtures.ONE_STAGED),
43 'ls-files': Fixtures.ok(),
44 })
45
46 const datedBefore =
47 (suffix: string): Git.GitDeps['mtimeOf'] =>
48 path =>
49 Promise.resolve(
50 path.endsWith(suffix)
51 ? Fixtures.BEFORE_SESSION_MS
52 : Fixtures.DURING_SESSION_MS,
53 )
54
55 const trackedNoUntrackedOf = (): Script => ({
56 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'),
57 'HEAD --numstat': Fixtures.ok('1\t0\ta.ts\0'),
58 'ls-files': Fixtures.GIT_TIMED_OUT,
59 })
60
61 const onFeatureNoUntrackedOf = (): Script => ({
62 'rev-parse --abbrev-ref HEAD': Fixtures.ok('feature\n'),
63 'symbolic-ref --short refs/remotes/origin/HEAD':
64 Fixtures.ok('origin/main\n'),
65 'show-ref --verify --quiet refs/remotes/origin/main': Fixtures.ok(),
66 'merge-base HEAD origin/main': Fixtures.ok(`${Fixtures.ORIGIN_BASE}\n`),
67 'rev-parse HEAD': Fixtures.ok(`${Fixtures.FEATURE_HEAD}\n`),
68 [`${Fixtures.ORIGIN_BASE} --shortstat`]: Fixtures.ok(Fixtures.ONE_STAGED),
69 [`${Fixtures.ORIGIN_BASE} --numstat`]: Fixtures.ok('3\t0\ta.ts\0'),
70 'ls-files': Fixtures.GIT_TIMED_OUT,
71 })
72
73 const unbornNoUntrackedOf = (): Script => ({
74 ...unbornScriptOf(),
75 '--cached --numstat': Fixtures.ok('3\t0\tstaged.ts\0'),
76 '--submodule=short --numstat': Fixtures.ok('2\t1\tstaged.ts\0'),
77 'ls-files': Fixtures.GIT_TIMED_OUT,
78 })
79
80 test('session mode: rows tagged by mtime, untracked merged', async () => {
81 const outcome = await Git.fetchDiff(
82 depsOf(
83 {
84 'HEAD --shortstat': Fixtures.ok(
85 ' 2 files changed, 3 insertions(+), 1 deletion(-)',
86 ),
87 'HEAD --numstat': Fixtures.ok('2\t1\told.ts\x001\t0\tnew.ts\0'),
88 'ls-files': Fixtures.ok('scratch.txt\0'),
89 },
90 { mtimeOf: datedBefore('old.ts') },
91 ),
92 'session',
93 )
94
95 const data = outcome.kind === 'data' ? outcome.data : null
96
97 expect(data?.repository).toEqual(Fixtures.FETCH_REPOSITORY)
98 expect(data?.baseRef).toBe('HEAD')
99 expect(data?.isUnborn).toBe(false)
100
101 expect(data?.stats).toEqual({
102 filesCount: 3,
103 linesAdded: 3,
104 linesRemoved: 1,
105 })
106
107 expect(
108 data?.files.map(file => [file.path, file.isPreSession, file.isUntracked]),
109 ).toEqual([
110 ['old.ts', true, false],
111 ['new.ts', false, false],
112 ['scratch.txt', false, true],
113 ])
114 })
115
116 test('every diff spawn carries the pinned leading argv', async () => {
117 const deps = depsOf({
118 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'),
119 'HEAD --numstat': Fixtures.ok('1\t0\ta.ts\0'),
120 'ls-files': Fixtures.ok(),
121 })
122
123 await Git.fetchDiff(deps, 'uncommitted')
124
125 const diffs = deps.argvs.filter(argv => argv.includes('diff'))
126
127 expect(deps.argvs.every(argv => argv[0] === '--no-optional-locks')).toBe(
128 true,
129 )
130
131 expect(diffs).toHaveLength(2)
132
133 expect(diffs.every(argv => argv.includes('core.quotePath=false'))).toBe(
134 true,
135 )
136
137 expect(diffs.every(argv => argv.includes('--no-ext-diff'))).toBe(true)
138 expect(diffs.every(argv => argv.includes('--no-textconv'))).toBe(true)
139 expect(diffs[1]).toContain('-z')
140 expect(deps.argvs.find(argv => argv.includes('ls-files'))).toContain('-z')
141
142 expect(
143 diffs.every(
144 argv =>
145 argv.slice(0, Git.DIFF_LEADING_ARGS.length).join(' ') ===
146 Git.DIFF_LEADING_ARGS.join(' '),
147 ),
148 ).toBe(true)
149 })
150
151 test('uncommitted mode drops pre-session untracked files', async () => {
152 const outcome = await Git.fetchDiff(
153 depsOf(
154 {
155 'HEAD --shortstat': Fixtures.ok(),
156 'HEAD --numstat': Fixtures.ok(),
157 'ls-files': Fixtures.ok('stale.txt\0fresh.txt\0'),
158 },
159 { mtimeOf: datedBefore('stale.txt') },
160 ),
161 'uncommitted',
162 )
163
164 expect(
165 outcome.kind === 'data' && outcome.data.files.map(file => file.path),
166 ).toEqual(['fresh.txt'])
167 })
168
169 test('past 500 files: totals only, no numstat, no untracked', async () => {
170 const deps = depsOf({
171 'HEAD --shortstat': Fixtures.ok(' 501 files changed, 9 insertions(+)'),
172 })
173
174 const outcome = await Git.fetchDiff(deps, 'session')
175
176 expect(outcome.kind === 'data' && outcome.data.stats.filesCount).toBe(501)
177 expect(outcome.kind === 'data' && outcome.data.files).toEqual([])
178 expect(deps.argvs.some(argv => argv.includes('--numstat'))).toBe(false)
179 expect(deps.argvs.some(argv => argv.includes('ls-files'))).toBe(false)
180 })
181
182 test('a transient state file: unavailable, and no diff runs', async () => {
183 const deps = depsOf(
184 {},
185 {
186 entryKindsOf: dir =>
187 Promise.resolve(
188 listingOf(
189 dir === '/repo/.git' ? Fixtures.MERGING : Fixtures.FILES_LISTING,
190 ),
191 ),
192 },
193 )
194
195 expect(await Git.fetchDiff(deps, 'session')).toEqual({
196 kind: 'unavailable',
197 })
198
199 expect(deps.argvs.some(argv => argv.includes('diff'))).toBe(false)
200 })
201
202 test('unborn HEAD: staged rows with unstaged edits folded in', async () => {
203 const outcome = await Git.fetchDiff(
204 depsOf({
205 [Fixtures.VS_HEAD]: Fixtures.UNSCRIPTED,
206 'rev-parse --verify --quiet HEAD': Fixtures.PROBE_MISSED,
207 '--cached --shortstat': Fixtures.ok(' 1 file changed, 3 insertions(+)'),
208 '--cached --numstat': Fixtures.ok('3\t0\tstaged.ts\0'),
209 '--submodule=short --numstat': Fixtures.ok('2\t1\tstaged.ts\0'),
210 'ls-files': Fixtures.ok(),
211 }),
212 'session',
213 )
214
215 const data = outcome.kind === 'data' ? outcome.data : null
216
217 expect(data?.isUnborn).toBe(true)
218 expect(data?.baseRef).toBe('--cached')
219
220 expect(
221 data?.files.map(file => [file.path, file.added, file.removed]),
222 ).toEqual([['staged.ts', 4, 0]])
223
224 expect(data?.stats.linesAdded).toBe(4)
225 expect(data?.stalePaths).toEqual(['staged.ts'])
226 })
227
228 test('unborn HEAD: a staged or unstaged listing git withheld', async () => {
229 const noStaged = await Git.fetchDiff(depsOf(unbornScriptOf()), 'session')
230
231 const noUnstaged = await Git.fetchDiff(
232 depsOf({
233 ...unbornScriptOf(),
234 '--cached --numstat': Fixtures.ok('3\t0\tstaged.ts\0'),
235 '--submodule=short --numstat': Fixtures.ok('2\t1\tstaged.ts'),
236 }),
237 'session',
238 )
239
240 expect(noStaged).toEqual({ kind: 'unavailable' })
241 expect(noUnstaged).toEqual({ kind: 'unavailable' })
242 })
243
244 test('a withheld untracked listing: tracked rows, flagged', async () => {
245 const outcomes = await Promise.all([
246 Git.fetchDiff(depsOf(trackedNoUntrackedOf()), 'session'),
247 Git.fetchDiff(depsOf(trackedNoUntrackedOf()), 'uncommitted'),
248 Git.fetchDiff(depsOf(onFeatureNoUntrackedOf()), 'branch'),
249 Git.fetchDiff(depsOf(unbornNoUntrackedOf()), 'session'),
250 ])
251
252 const listed = await Git.fetchDiff(
253 depsOf({
254 ...trackedNoUntrackedOf(),
255 'ls-files': Fixtures.ok('new.ts\0'),
256 }),
257 'uncommitted',
258 )
259
260 expect(
261 outcomes.map(
262 outcome =>
263 outcome.kind === 'data' && [
264 outcome.data.files.map(file => file.path),
265 outcome.data.stats.filesCount,
266 outcome.data.isUntrackedWithheld,
267 ],
268 ),
269 ).toEqual([
270 [['a.ts'], 1, true],
271 [['a.ts'], 1, true],
272 [['a.ts'], 1, true],
273 [['staged.ts'], 1, true],
274 ])
275
276 expect(listed.kind === 'data' && listed.data.isUntrackedWithheld).toBe(
277 false,
278 )
279 })
280
281 test('a failed vs-HEAD diff with commits present: unavailable', async () => {
282 expect(
283 await Git.fetchDiff(
284 depsOf({
285 'rev-parse --verify --quiet HEAD': Fixtures.ok('abc\n'),
286 }),
287 'session',
288 ),
289 ).toEqual({ kind: 'unavailable' })
290 })
291
292 test('a tracked or untracked symlink is never dated: no stat', async () => {
293 const probed: string[] = []
294
295 const outcome = await Git.fetchDiff(
296 depsOf(
297 {
298 'HEAD --shortstat': Fixtures.ok(' 2 files changed, 2 insertions(+)'),
299 'HEAD --numstat': Fixtures.ok('1\t0\told.ts\x001\t0\tlink.ts\0'),
300 'ls-files': Fixtures.ok('leak\0scratch.txt\0'),
301 },
302 {
303 mtimeOf: path => {
304 probed.push(path)
305
306 return Promise.resolve(Fixtures.BEFORE_SESSION_MS)
307 },
308 },
309 ),
310 'session',
311 )
312
313 const data = outcome.kind === 'data' ? outcome.data : null
314
315 expect(probed.toSorted()).toEqual(['/repo/old.ts', '/repo/scratch.txt'])
316
317 expect(data?.files.map(file => [file.path, file.isPreSession])).toEqual([
318 ['old.ts', true],
319 ['link.ts', false],
320 ['leak', false],
321 ['scratch.txt', true],
322 ])
323 })
324
325 test('an unlistable directory dates nothing and stats nothing', async () => {
326 const probed: string[] = []
327
328 await Git.fetchDiff(
329 depsOf(
330 {
331 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'),
332 'HEAD --numstat': Fixtures.ok('1\t0\told.ts\0'),
333 'ls-files': Fixtures.ok('scratch.txt\0'),
334 },
335 {
336 entryKindsOf: () => Promise.resolve(null),
337 mtimeOf: path => {
338 probed.push(path)
339
340 return Promise.resolve(Fixtures.BEFORE_SESSION_MS)
341 },
342 },
343 ),
344 'session',
345 )
346
347 expect(probed).toEqual([])
348 })
349
350 test('a MERGE_HEAD that is a symbolic link is no merge', async () => {
351 const outcome = await Git.fetchDiff(
352 depsOf(
353 {
354 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'),
355 'HEAD --numstat': Fixtures.ok('1\t0\ta.ts\0'),
356 'ls-files': Fixtures.ok(),
357 },
358 {
359 entryKindsOf: dir => {
360 const isGitDir = dir === '/repo/.git'
361
362 return Promise.resolve(
363 listingOf(
364 isGitDir ? Fixtures.MERGE_HEAD_LINKED : Fixtures.FILES_LISTING,
365 ),
366 )
367 },
368 },
369 ),
370 'session',
371 )
372
373 expect(outcome).toMatchObject({ kind: 'data' })
374 expect(outcome.kind === 'data' && outcome.data.files).toHaveLength(1)
375 })
376
377 test('a path under a symlinked directory: unlisted, undated', async () => {
378 const listed: string[] = []
379 const probed: string[] = []
380
381 const outcome = await Git.fetchDiff(
382 depsOf(
383 {
384 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'),
385 'HEAD --numstat': Fixtures.ok('1\t0\tsub/inner.ts\0'),
386 'ls-files': Fixtures.ok(),
387 },
388 {
389 entryKindsOf: dir => {
390 listed.push(dir)
391
392 return Promise.resolve(listingOf(Fixtures.FILES_LISTING))
393 },
394 mtimeOf: path => {
395 probed.push(path)
396
397 return Promise.resolve(Fixtures.BEFORE_SESSION_MS)
398 },
399 },
400 ),
401 'session',
402 )
403
404 expect(listed).not.toContain('/repo/sub')
405 expect(probed).toEqual([])
406
407 expect(
408 outcome.kind === 'data' && outcome.data.files.map(f => f.isPreSession),
409 ).toEqual([false])
410 })
411
412 test('a listing the host cut short is never parsed', async () => {
413 const cutNumstat = await Git.fetchDiff(
414 depsOf({
415 'HEAD --shortstat': Fixtures.ok(' 2 files changed, 2 insertions(+)'),
416 'HEAD --numstat': Fixtures.ok('1\t0\ta.ts\x001\t0\told'),
417 'rev-parse --verify --quiet HEAD': Fixtures.ok('abc\n'),
418 }),
419 'uncommitted',
420 )
421
422 const cutUntracked = await Git.fetchDiff(
423 depsOf({
424 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'),
425 'HEAD --numstat': Fixtures.ok('1\t0\ta.ts\0'),
426 'ls-files': Fixtures.ok('scratch.txt\0fresh.tx'),
427 }),
428 'uncommitted',
429 )
430
431 expect(cutNumstat).toEqual({ kind: 'unavailable' })
432
433 expect(
434 cutUntracked.kind === 'data' && [
435 cutUntracked.data.files.map(f => f.path),
436 cutUntracked.data.isUntrackedWithheld,
437 ],
438 ).toEqual([['a.ts'], true])
439
440 expect(Git.isWholeListing('a\0'.repeat(HALF_CAP_RECORDS))).toBe(false)
441 expect(Git.isWholeListing('a\0'.repeat(NEAR_CAP_RECORDS))).toBe(true)
442 })
443
444 test('an untracked directory git prints with a slash is dated', async () => {
445 const probed: string[] = []
446
447 const outcome = await Git.fetchDiff(
448 depsOf(
449 {
450 'HEAD --shortstat': Fixtures.ok(),
451 'HEAD --numstat': Fixtures.ok(),
452 'ls-files': Fixtures.ok('nested/\0'),
453 },
454 {
455 entryKindsOf: () => Promise.resolve(listingOf([['nested', 'dir']])),
456 mtimeOf: path => {
457 probed.push(path)
458
459 return Promise.resolve(Fixtures.BEFORE_SESSION_MS)
460 },
461 },
462 ),
463 'session',
464 )
465
466 expect(probed).toEqual(['/repo/nested'])
467
468 expect(
469 outcome.kind === 'data' &&
470 outcome.data.files.map(file => [file.path, file.isPreSession]),
471 ).toEqual([['nested/', true]])
472 })
473
474 test('a hundred directories deep and wide still date', async () => {
475 const listed: string[] = []
476
477 const paths = Array.from(
478 { length: Fixtures.DEEP_DIRECTORIES },
479 (_, at) => `d${at}/nested/file${at}.txt`,
480 )
481
482 const outcome = await Git.fetchDiff(
483 depsOf(
484 {
485 'HEAD --shortstat': Fixtures.ok(),
486 'HEAD --numstat': Fixtures.ok(),
487 'ls-files': Fixtures.ok(paths.map(path => `${path}\0`).join('')),
488 },
489 {
490 entryKindsOf: dir => {
491 listed.push(dir)
492
493 const isLeaf = dir.endsWith('/nested')
494
495 const entries: Listing = isLeaf
496 ? paths.map(path => [path.split('/').at(-1) ?? '', 'file'])
497 : [
498 ['nested', 'dir'],
499 ...paths.map(
500 path => [path.slice(0, path.indexOf('/')), 'dir'] as const,
501 ),
502 ]
503
504 return Promise.resolve(listingOf(entries))
505 },
506 mtimeOf: () => Promise.resolve(Fixtures.BEFORE_SESSION_MS),
507 },
508 ),
509 'session',
510 )
511
512 const rows = outcome.kind === 'data' ? outcome.data.files : []
513
514 expect(
515 new Set(listed.filter(dir => dir !== Fixtures.FETCH_REPOSITORY.gitDir))
516 .size,
517 ).toBe(1 + 2 * Fixtures.DEEP_DIRECTORIES)
518
519 expect(rows).toHaveLength(Limits.MAX_FILES)
520 expect(rows.every(row => row.isPreSession)).toBe(true)
521 })
522
523 test('a rename is one row under its new name, diffed as a pair', async () => {
524 const deps = depsOf({
525 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'),
526 'HEAD --numstat': Fixtures.ok('1\t1\t\0old.ts\0new.ts\0'),
527 'ls-files': Fixtures.ok(),
528 '-- old.ts new.ts': Fixtures.ok('@@ -1 +1 @@\n-a\n+b\n'),
529 })
530
531 const outcome = await Git.fetchDiff(deps, 'uncommitted')
532 const data = outcome.kind === 'data' ? outcome.data : null
533 const [row] = data?.files ?? []
534
535 const body =
536 data && row ? await Git.fetchFileHunks(deps.run, data, row) : null
537
538 expect(data?.files.map(f => [f.path, f.renamedFrom])).toEqual([
539 ['new.ts', 'old.ts'],
540 ])
541
542 expect(body?.hunks[0]?.lines).toEqual(['-a', '+b'])
543 })
544})