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
581 lines · 17.2 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("forced git colors do not hide a changed file's hunks", async () => {
152 const deps = depsOf({
153 'HEAD --shortstat': Fixtures.ok(
154 ' 1 file changed, 1 insertion(+), 1 deletion(-)',
155 ),
156 'HEAD --numstat': Fixtures.ok('1\t1\ta.ts\0'),
157 'ls-files': Fixtures.ok(),
158 })
159
160 const git = Fixtures.scriptedGitOf({
161 '--no-color': Fixtures.ok('@@ -1 +1 @@\n-old\n+new\n'),
162 diff: Fixtures.ok(
163 '\x1b[36m@@ -1 +1 @@\x1b[m\n\x1b[31m-old\x1b[m\n' +
164 '\x1b[32m+\x1b[m\x1b[32mnew\x1b[m\n',
165 ),
166 })
167
168 const outcome = await Git.fetchDiff(deps, 'uncommitted')
169 const data = outcome.kind === 'data' ? outcome.data : null
170 const [row] = data?.files ?? []
171
172 expect(data?.stats).toEqual({
173 filesCount: 1,
174 linesAdded: 1,
175 linesRemoved: 1,
176 })
177
178 expect(
179 data && row ? await Git.fetchFileHunks(git.run, data, row) : null,
180 ).toEqual({
181 hunks: [{ oldStart: 1, newStart: 1, lines: ['-old', '+new'] }],
182 isTruncated: false,
183 isLarge: false,
184 })
185 })
186
187 test('uncommitted mode drops pre-session untracked files', async () => {
188 const outcome = await Git.fetchDiff(
189 depsOf(
190 {
191 'HEAD --shortstat': Fixtures.ok(),
192 'HEAD --numstat': Fixtures.ok(),
193 'ls-files': Fixtures.ok('stale.txt\0fresh.txt\0'),
194 },
195 { mtimeOf: datedBefore('stale.txt') },
196 ),
197 'uncommitted',
198 )
199
200 expect(
201 outcome.kind === 'data' && outcome.data.files.map(file => file.path),
202 ).toEqual(['fresh.txt'])
203 })
204
205 test('past 500 files: totals only, no numstat, no untracked', async () => {
206 const deps = depsOf({
207 'HEAD --shortstat': Fixtures.ok(' 501 files changed, 9 insertions(+)'),
208 })
209
210 const outcome = await Git.fetchDiff(deps, 'session')
211
212 expect(outcome.kind === 'data' && outcome.data.stats.filesCount).toBe(501)
213 expect(outcome.kind === 'data' && outcome.data.files).toEqual([])
214 expect(deps.argvs.some(argv => argv.includes('--numstat'))).toBe(false)
215 expect(deps.argvs.some(argv => argv.includes('ls-files'))).toBe(false)
216 })
217
218 test('a transient state file: unavailable, and no diff runs', async () => {
219 const deps = depsOf(
220 {},
221 {
222 entryKindsOf: dir =>
223 Promise.resolve(
224 listingOf(
225 dir === '/repo/.git' ? Fixtures.MERGING : Fixtures.FILES_LISTING,
226 ),
227 ),
228 },
229 )
230
231 expect(await Git.fetchDiff(deps, 'session')).toEqual({
232 kind: 'unavailable',
233 })
234
235 expect(deps.argvs.some(argv => argv.includes('diff'))).toBe(false)
236 })
237
238 test('unborn HEAD: staged rows with unstaged edits folded in', async () => {
239 const outcome = await Git.fetchDiff(
240 depsOf({
241 [Fixtures.VS_HEAD]: Fixtures.UNSCRIPTED,
242 'rev-parse --verify --quiet HEAD': Fixtures.PROBE_MISSED,
243 '--cached --shortstat': Fixtures.ok(' 1 file changed, 3 insertions(+)'),
244 '--cached --numstat': Fixtures.ok('3\t0\tstaged.ts\0'),
245 '--submodule=short --numstat': Fixtures.ok('2\t1\tstaged.ts\0'),
246 'ls-files': Fixtures.ok(),
247 }),
248 'session',
249 )
250
251 const data = outcome.kind === 'data' ? outcome.data : null
252
253 expect(data?.isUnborn).toBe(true)
254 expect(data?.baseRef).toBe('--cached')
255
256 expect(
257 data?.files.map(file => [file.path, file.added, file.removed]),
258 ).toEqual([['staged.ts', 4, 0]])
259
260 expect(data?.stats.linesAdded).toBe(4)
261 expect(data?.stalePaths).toEqual(['staged.ts'])
262 })
263
264 test('unborn HEAD: a staged or unstaged listing git withheld', async () => {
265 const noStaged = await Git.fetchDiff(depsOf(unbornScriptOf()), 'session')
266
267 const noUnstaged = await Git.fetchDiff(
268 depsOf({
269 ...unbornScriptOf(),
270 '--cached --numstat': Fixtures.ok('3\t0\tstaged.ts\0'),
271 '--submodule=short --numstat': Fixtures.ok('2\t1\tstaged.ts'),
272 }),
273 'session',
274 )
275
276 expect(noStaged).toEqual({ kind: 'unavailable' })
277 expect(noUnstaged).toEqual({ kind: 'unavailable' })
278 })
279
280 test('a withheld untracked listing: tracked rows, flagged', async () => {
281 const outcomes = await Promise.all([
282 Git.fetchDiff(depsOf(trackedNoUntrackedOf()), 'session'),
283 Git.fetchDiff(depsOf(trackedNoUntrackedOf()), 'uncommitted'),
284 Git.fetchDiff(depsOf(onFeatureNoUntrackedOf()), 'branch'),
285 Git.fetchDiff(depsOf(unbornNoUntrackedOf()), 'session'),
286 ])
287
288 const listed = await Git.fetchDiff(
289 depsOf({
290 ...trackedNoUntrackedOf(),
291 'ls-files': Fixtures.ok('new.ts\0'),
292 }),
293 'uncommitted',
294 )
295
296 expect(
297 outcomes.map(
298 outcome =>
299 outcome.kind === 'data' && [
300 outcome.data.files.map(file => file.path),
301 outcome.data.stats.filesCount,
302 outcome.data.isUntrackedWithheld,
303 ],
304 ),
305 ).toEqual([
306 [['a.ts'], 1, true],
307 [['a.ts'], 1, true],
308 [['a.ts'], 1, true],
309 [['staged.ts'], 1, true],
310 ])
311
312 expect(listed.kind === 'data' && listed.data.isUntrackedWithheld).toBe(
313 false,
314 )
315 })
316
317 test('a failed vs-HEAD diff with commits present: unavailable', async () => {
318 expect(
319 await Git.fetchDiff(
320 depsOf({
321 'rev-parse --verify --quiet HEAD': Fixtures.ok('abc\n'),
322 }),
323 'session',
324 ),
325 ).toEqual({ kind: 'unavailable' })
326 })
327
328 test('a tracked or untracked symlink is never dated: no stat', async () => {
329 const probed: string[] = []
330
331 const outcome = await Git.fetchDiff(
332 depsOf(
333 {
334 'HEAD --shortstat': Fixtures.ok(' 2 files changed, 2 insertions(+)'),
335 'HEAD --numstat': Fixtures.ok('1\t0\told.ts\x001\t0\tlink.ts\0'),
336 'ls-files': Fixtures.ok('leak\0scratch.txt\0'),
337 },
338 {
339 mtimeOf: path => {
340 probed.push(path)
341
342 return Promise.resolve(Fixtures.BEFORE_SESSION_MS)
343 },
344 },
345 ),
346 'session',
347 )
348
349 const data = outcome.kind === 'data' ? outcome.data : null
350
351 expect(probed.toSorted()).toEqual(['/repo/old.ts', '/repo/scratch.txt'])
352
353 expect(data?.files.map(file => [file.path, file.isPreSession])).toEqual([
354 ['old.ts', true],
355 ['link.ts', false],
356 ['leak', false],
357 ['scratch.txt', true],
358 ])
359 })
360
361 test('an unlistable directory dates nothing and stats nothing', async () => {
362 const probed: string[] = []
363
364 await Git.fetchDiff(
365 depsOf(
366 {
367 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'),
368 'HEAD --numstat': Fixtures.ok('1\t0\told.ts\0'),
369 'ls-files': Fixtures.ok('scratch.txt\0'),
370 },
371 {
372 entryKindsOf: () => Promise.resolve(null),
373 mtimeOf: path => {
374 probed.push(path)
375
376 return Promise.resolve(Fixtures.BEFORE_SESSION_MS)
377 },
378 },
379 ),
380 'session',
381 )
382
383 expect(probed).toEqual([])
384 })
385
386 test('a MERGE_HEAD that is a symbolic link is no merge', async () => {
387 const outcome = await Git.fetchDiff(
388 depsOf(
389 {
390 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'),
391 'HEAD --numstat': Fixtures.ok('1\t0\ta.ts\0'),
392 'ls-files': Fixtures.ok(),
393 },
394 {
395 entryKindsOf: dir => {
396 const isGitDir = dir === '/repo/.git'
397
398 return Promise.resolve(
399 listingOf(
400 isGitDir ? Fixtures.MERGE_HEAD_LINKED : Fixtures.FILES_LISTING,
401 ),
402 )
403 },
404 },
405 ),
406 'session',
407 )
408
409 expect(outcome).toMatchObject({ kind: 'data' })
410 expect(outcome.kind === 'data' && outcome.data.files).toHaveLength(1)
411 })
412
413 test('a path under a symlinked directory: unlisted, undated', async () => {
414 const listed: string[] = []
415 const probed: string[] = []
416
417 const outcome = await Git.fetchDiff(
418 depsOf(
419 {
420 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'),
421 'HEAD --numstat': Fixtures.ok('1\t0\tsub/inner.ts\0'),
422 'ls-files': Fixtures.ok(),
423 },
424 {
425 entryKindsOf: dir => {
426 listed.push(dir)
427
428 return Promise.resolve(listingOf(Fixtures.FILES_LISTING))
429 },
430 mtimeOf: path => {
431 probed.push(path)
432
433 return Promise.resolve(Fixtures.BEFORE_SESSION_MS)
434 },
435 },
436 ),
437 'session',
438 )
439
440 expect(listed).not.toContain('/repo/sub')
441 expect(probed).toEqual([])
442
443 expect(
444 outcome.kind === 'data' && outcome.data.files.map(f => f.isPreSession),
445 ).toEqual([false])
446 })
447
448 test('a listing the host cut short is never parsed', async () => {
449 const cutNumstat = await Git.fetchDiff(
450 depsOf({
451 'HEAD --shortstat': Fixtures.ok(' 2 files changed, 2 insertions(+)'),
452 'HEAD --numstat': Fixtures.ok('1\t0\ta.ts\x001\t0\told'),
453 'rev-parse --verify --quiet HEAD': Fixtures.ok('abc\n'),
454 }),
455 'uncommitted',
456 )
457
458 const cutUntracked = await Git.fetchDiff(
459 depsOf({
460 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'),
461 'HEAD --numstat': Fixtures.ok('1\t0\ta.ts\0'),
462 'ls-files': Fixtures.ok('scratch.txt\0fresh.tx'),
463 }),
464 'uncommitted',
465 )
466
467 expect(cutNumstat).toEqual({ kind: 'unavailable' })
468
469 expect(
470 cutUntracked.kind === 'data' && [
471 cutUntracked.data.files.map(f => f.path),
472 cutUntracked.data.isUntrackedWithheld,
473 ],
474 ).toEqual([['a.ts'], true])
475
476 expect(Git.isWholeListing('a\0'.repeat(HALF_CAP_RECORDS))).toBe(false)
477 expect(Git.isWholeListing('a\0'.repeat(NEAR_CAP_RECORDS))).toBe(true)
478 })
479
480 test('an untracked directory git prints with a slash is dated', async () => {
481 const probed: string[] = []
482
483 const outcome = await Git.fetchDiff(
484 depsOf(
485 {
486 'HEAD --shortstat': Fixtures.ok(),
487 'HEAD --numstat': Fixtures.ok(),
488 'ls-files': Fixtures.ok('nested/\0'),
489 },
490 {
491 entryKindsOf: () => Promise.resolve(listingOf([['nested', 'dir']])),
492 mtimeOf: path => {
493 probed.push(path)
494
495 return Promise.resolve(Fixtures.BEFORE_SESSION_MS)
496 },
497 },
498 ),
499 'session',
500 )
501
502 expect(probed).toEqual(['/repo/nested'])
503
504 expect(
505 outcome.kind === 'data' &&
506 outcome.data.files.map(file => [file.path, file.isPreSession]),
507 ).toEqual([['nested/', true]])
508 })
509
510 test('a hundred directories deep and wide still date', async () => {
511 const listed: string[] = []
512
513 const paths = Array.from(
514 { length: Fixtures.DEEP_DIRECTORIES },
515 (_, at) => `d${at}/nested/file${at}.txt`,
516 )
517
518 const outcome = await Git.fetchDiff(
519 depsOf(
520 {
521 'HEAD --shortstat': Fixtures.ok(),
522 'HEAD --numstat': Fixtures.ok(),
523 'ls-files': Fixtures.ok(paths.map(path => `${path}\0`).join('')),
524 },
525 {
526 entryKindsOf: dir => {
527 listed.push(dir)
528
529 const isLeaf = dir.endsWith('/nested')
530
531 const entries: Listing = isLeaf
532 ? paths.map(path => [path.split('/').at(-1) ?? '', 'file'])
533 : [
534 ['nested', 'dir'],
535 ...paths.map(
536 path => [path.slice(0, path.indexOf('/')), 'dir'] as const,
537 ),
538 ]
539
540 return Promise.resolve(listingOf(entries))
541 },
542 mtimeOf: () => Promise.resolve(Fixtures.BEFORE_SESSION_MS),
543 },
544 ),
545 'session',
546 )
547
548 const rows = outcome.kind === 'data' ? outcome.data.files : []
549
550 expect(
551 new Set(listed.filter(dir => dir !== Fixtures.FETCH_REPOSITORY.gitDir))
552 .size,
553 ).toBe(1 + 2 * Fixtures.DEEP_DIRECTORIES)
554
555 expect(rows).toHaveLength(Limits.MAX_FILES)
556 expect(rows.every(row => row.isPreSession)).toBe(true)
557 })
558
559 test('a rename is one row under its new name and reads no body', async () => {
560 const deps = depsOf({
561 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'),
562 'HEAD --numstat': Fixtures.ok('1\t1\t\0old.ts\0new.ts\0'),
563 'ls-files': Fixtures.ok(),
564 '-- old.ts new.ts': Fixtures.ok('@@ -1 +1 @@\n-a\n+b\n'),
565 })
566
567 const outcome = await Git.fetchDiff(deps, 'uncommitted')
568 const data = outcome.kind === 'data' ? outcome.data : null
569 const [row] = data?.files ?? []
570
571 const body =
572 data && row ? await Git.fetchFileHunks(deps.run, data, row) : null
573
574 expect(data?.files.map(f => [f.path, f.renamedFrom])).toEqual([
575 ['new.ts', 'old.ts'],
576 ])
577
578 expect(body).toEqual(Git.EMPTY_FILE_HUNKS)
579 expect(deps.argvs.some(argv => argv.includes('new.ts'))).toBe(false)
580 })
581})