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
606 lines · 17.9 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 const fetchedIn = (gitDir: Listing, applyDir: Listing = []) =>
81 Git.fetchDiff(
82 depsOf(trackedNoUntrackedOf(), {
83 entryKindsOf: dir =>
84 Promise.resolve(
85 listingOf(
86 new Map([
87 ['/repo/.git', gitDir],
88 ['/repo/.git/rebase-apply', applyDir],
89 ]).get(dir) ?? Fixtures.FILES_LISTING,
90 ),
91 ),
92 }),
93 'session',
94 )
95
96 test('session mode: rows tagged by mtime, untracked merged', async () => {
97 const outcome = await Git.fetchDiff(
98 depsOf(
99 {
100 'HEAD --shortstat': Fixtures.ok(
101 ' 2 files changed, 3 insertions(+), 1 deletion(-)',
102 ),
103 'HEAD --numstat': Fixtures.ok('2\t1\told.ts\x001\t0\tnew.ts\0'),
104 'ls-files': Fixtures.ok('scratch.txt\0'),
105 },
106 { mtimeOf: datedBefore('old.ts') },
107 ),
108 'session',
109 )
110
111 const data = outcome.kind === 'data' ? outcome.data : null
112
113 expect(data?.repository).toEqual(Fixtures.FETCH_REPOSITORY)
114 expect(data?.baseRef).toBe('HEAD')
115 expect(data?.isUnborn).toBe(false)
116
117 expect(data?.stats).toEqual({
118 filesCount: 3,
119 linesAdded: 3,
120 linesRemoved: 1,
121 })
122
123 expect(
124 data?.files.map(file => [file.path, file.isPreSession, file.isUntracked]),
125 ).toEqual([
126 ['old.ts', true, false],
127 ['new.ts', false, false],
128 ['scratch.txt', false, true],
129 ])
130 })
131
132 test('every diff spawn carries the pinned leading argv', async () => {
133 const deps = depsOf({
134 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'),
135 'HEAD --numstat': Fixtures.ok('1\t0\ta.ts\0'),
136 'ls-files': Fixtures.ok(),
137 })
138
139 await Git.fetchDiff(deps, 'uncommitted')
140
141 const diffs = deps.argvs.filter(argv => argv.includes('diff'))
142
143 expect(deps.argvs.every(argv => argv[0] === '--no-optional-locks')).toBe(
144 true,
145 )
146
147 expect(diffs).toHaveLength(2)
148
149 expect(diffs.every(argv => argv.includes('core.quotePath=false'))).toBe(
150 true,
151 )
152
153 expect(diffs.every(argv => argv.includes('--no-ext-diff'))).toBe(true)
154 expect(diffs.every(argv => argv.includes('--no-textconv'))).toBe(true)
155 expect(diffs[1]).toContain('-z')
156 expect(deps.argvs.find(argv => argv.includes('ls-files'))).toContain('-z')
157
158 expect(
159 diffs.every(
160 argv =>
161 argv.slice(0, Git.DIFF_LEADING_ARGS.length).join(' ') ===
162 Git.DIFF_LEADING_ARGS.join(' '),
163 ),
164 ).toBe(true)
165 })
166
167 test('uncommitted mode drops pre-session untracked files', async () => {
168 const outcome = await Git.fetchDiff(
169 depsOf(
170 {
171 'HEAD --shortstat': Fixtures.ok(),
172 'HEAD --numstat': Fixtures.ok(),
173 'ls-files': Fixtures.ok('stale.txt\0fresh.txt\0'),
174 },
175 { mtimeOf: datedBefore('stale.txt') },
176 ),
177 'uncommitted',
178 )
179
180 expect(
181 outcome.kind === 'data' && outcome.data.files.map(file => file.path),
182 ).toEqual(['fresh.txt'])
183 })
184
185 test('past 500 files: totals only, no numstat, no untracked', async () => {
186 const deps = depsOf({
187 'HEAD --shortstat': Fixtures.ok(' 501 files changed, 9 insertions(+)'),
188 })
189
190 const outcome = await Git.fetchDiff(deps, 'session')
191
192 expect(outcome.kind === 'data' && outcome.data.stats.filesCount).toBe(501)
193 expect(outcome.kind === 'data' && outcome.data.files).toEqual([])
194 expect(deps.argvs.some(argv => argv.includes('--numstat'))).toBe(false)
195 expect(deps.argvs.some(argv => argv.includes('ls-files'))).toBe(false)
196 })
197
198 test('a transient state file: unavailable, and no diff runs', async () => {
199 const deps = depsOf(
200 {},
201 {
202 entryKindsOf: dir =>
203 Promise.resolve(
204 listingOf(
205 dir === '/repo/.git' ? Fixtures.MERGING : Fixtures.FILES_LISTING,
206 ),
207 ),
208 },
209 )
210
211 expect(await Git.fetchDiff(deps, 'session')).toEqual({
212 kind: 'unavailable',
213 })
214
215 expect(deps.argvs.some(argv => argv.includes('diff'))).toBe(false)
216 })
217
218 test('unborn HEAD: staged rows with unstaged edits folded in', async () => {
219 const outcome = await Git.fetchDiff(
220 depsOf({
221 [Fixtures.VS_HEAD]: Fixtures.UNSCRIPTED,
222 'rev-parse --verify --quiet HEAD': Fixtures.PROBE_MISSED,
223 '--cached --shortstat': Fixtures.ok(' 1 file changed, 3 insertions(+)'),
224 '--cached --numstat': Fixtures.ok('3\t0\tstaged.ts\0'),
225 '--submodule=short --numstat': Fixtures.ok('2\t1\tstaged.ts\0'),
226 'ls-files': Fixtures.ok(),
227 }),
228 'session',
229 )
230
231 const data = outcome.kind === 'data' ? outcome.data : null
232
233 expect(data?.isUnborn).toBe(true)
234 expect(data?.baseRef).toBe('--cached')
235
236 expect(
237 data?.files.map(file => [file.path, file.added, file.removed]),
238 ).toEqual([['staged.ts', 4, 0]])
239
240 expect(data?.stats.linesAdded).toBe(4)
241 expect(data?.stalePaths).toEqual(['staged.ts'])
242 })
243
244 test('unborn HEAD: a staged or unstaged listing git withheld', async () => {
245 const noStaged = await Git.fetchDiff(depsOf(unbornScriptOf()), 'session')
246
247 const noUnstaged = await Git.fetchDiff(
248 depsOf({
249 ...unbornScriptOf(),
250 '--cached --numstat': Fixtures.ok('3\t0\tstaged.ts\0'),
251 '--submodule=short --numstat': Fixtures.ok('2\t1\tstaged.ts'),
252 }),
253 'session',
254 )
255
256 expect(noStaged).toEqual({ kind: 'unavailable' })
257 expect(noUnstaged).toEqual({ kind: 'unavailable' })
258 })
259
260 test('a withheld untracked listing: tracked rows, flagged', async () => {
261 const outcomes = await Promise.all([
262 Git.fetchDiff(depsOf(trackedNoUntrackedOf()), 'session'),
263 Git.fetchDiff(depsOf(trackedNoUntrackedOf()), 'uncommitted'),
264 Git.fetchDiff(depsOf(onFeatureNoUntrackedOf()), 'branch'),
265 Git.fetchDiff(depsOf(unbornNoUntrackedOf()), 'session'),
266 ])
267
268 const listed = await Git.fetchDiff(
269 depsOf({
270 ...trackedNoUntrackedOf(),
271 'ls-files': Fixtures.ok('new.ts\0'),
272 }),
273 'uncommitted',
274 )
275
276 expect(
277 outcomes.map(
278 outcome =>
279 outcome.kind === 'data' && [
280 outcome.data.files.map(file => file.path),
281 outcome.data.stats.filesCount,
282 outcome.data.isUntrackedWithheld,
283 ],
284 ),
285 ).toEqual([
286 [['a.ts'], 1, true],
287 [['a.ts'], 1, true],
288 [['a.ts'], 1, true],
289 [['staged.ts'], 1, true],
290 ])
291
292 expect(listed.kind === 'data' && listed.data.isUntrackedWithheld).toBe(
293 false,
294 )
295 })
296
297 test('a failed vs-HEAD diff with commits present: unavailable', async () => {
298 expect(
299 await Git.fetchDiff(
300 depsOf({
301 'rev-parse --verify --quiet HEAD': Fixtures.ok('abc\n'),
302 }),
303 'session',
304 ),
305 ).toEqual({ kind: 'unavailable' })
306 })
307
308 test('a tracked or untracked symlink is never dated: no stat', async () => {
309 const probed: string[] = []
310
311 const outcome = await Git.fetchDiff(
312 depsOf(
313 {
314 'HEAD --shortstat': Fixtures.ok(' 2 files changed, 2 insertions(+)'),
315 'HEAD --numstat': Fixtures.ok('1\t0\told.ts\x001\t0\tlink.ts\0'),
316 'ls-files': Fixtures.ok('leak\0scratch.txt\0'),
317 },
318 {
319 mtimeOf: path => {
320 probed.push(path)
321
322 return Promise.resolve(Fixtures.BEFORE_SESSION_MS)
323 },
324 },
325 ),
326 'session',
327 )
328
329 const data = outcome.kind === 'data' ? outcome.data : null
330
331 expect(probed.toSorted()).toEqual(['/repo/old.ts', '/repo/scratch.txt'])
332
333 expect(data?.files.map(file => [file.path, file.isPreSession])).toEqual([
334 ['old.ts', true],
335 ['link.ts', false],
336 ['leak', false],
337 ['scratch.txt', true],
338 ])
339 })
340
341 test('an unlistable directory dates nothing and stats nothing', async () => {
342 const probed: string[] = []
343
344 await Git.fetchDiff(
345 depsOf(
346 {
347 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'),
348 'HEAD --numstat': Fixtures.ok('1\t0\told.ts\0'),
349 'ls-files': Fixtures.ok('scratch.txt\0'),
350 },
351 {
352 entryKindsOf: () => Promise.resolve(null),
353 mtimeOf: path => {
354 probed.push(path)
355
356 return Promise.resolve(Fixtures.BEFORE_SESSION_MS)
357 },
358 },
359 ),
360 'session',
361 )
362
363 expect(probed).toEqual([])
364 })
365
366 test('a MERGE_HEAD that is a symbolic link is no merge', async () => {
367 const outcome = await Git.fetchDiff(
368 depsOf(
369 {
370 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'),
371 'HEAD --numstat': Fixtures.ok('1\t0\ta.ts\0'),
372 'ls-files': Fixtures.ok(),
373 },
374 {
375 entryKindsOf: dir => {
376 const isGitDir = dir === '/repo/.git'
377
378 return Promise.resolve(
379 listingOf(
380 isGitDir ? Fixtures.MERGE_HEAD_LINKED : Fixtures.FILES_LISTING,
381 ),
382 )
383 },
384 },
385 ),
386 'session',
387 )
388
389 expect(outcome).toMatchObject({ kind: 'data' })
390 expect(outcome.kind === 'data' && outcome.data.files).toHaveLength(1)
391 })
392
393 test('a rebase stopped on a conflict: unavailable', async () => {
394 expect(await fetchedIn(Fixtures.REBASE_MERGING)).toEqual({
395 kind: 'unavailable',
396 })
397 })
398
399 test('a rebase applying patches, stopped: unavailable', async () => {
400 expect(
401 await fetchedIn(Fixtures.REBASE_APPLYING, Fixtures.APPLY_REBASING),
402 ).toEqual({ kind: 'unavailable' })
403 })
404
405 test('a REBASE_HEAD a finished rebase left is no rebase', async () => {
406 expect(await fetchedIn(Fixtures.REBASE_HEAD_LEFT)).toMatchObject({
407 kind: 'data',
408 })
409 })
410
411 test('git am beside a REBASE_HEAD left behind is no rebase', async () => {
412 expect(
413 await fetchedIn(Fixtures.REBASE_APPLYING, Fixtures.APPLY_MAILING),
414 ).toMatchObject({ kind: 'data' })
415 })
416
417 test('a rebase folder that is a symbolic link is no rebase', async () => {
418 expect(await fetchedIn(Fixtures.REBASE_LINKED)).toMatchObject({
419 kind: 'data',
420 })
421 })
422
423 test('git am stopped on a conflict reads as it did', async () => {
424 expect(
425 await fetchedIn(Fixtures.MAILING, Fixtures.APPLY_MAILING),
426 ).toMatchObject({ kind: 'data' })
427 })
428
429 test('a rebase waiting at a break reads as it did', async () => {
430 expect(await fetchedIn(Fixtures.REBASE_PAUSED)).toMatchObject({
431 kind: 'data',
432 })
433 })
434
435 test('a path under a symlinked directory: unlisted, undated', async () => {
436 const listed: string[] = []
437 const probed: string[] = []
438
439 const outcome = await Git.fetchDiff(
440 depsOf(
441 {
442 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'),
443 'HEAD --numstat': Fixtures.ok('1\t0\tsub/inner.ts\0'),
444 'ls-files': Fixtures.ok(),
445 },
446 {
447 entryKindsOf: dir => {
448 listed.push(dir)
449
450 return Promise.resolve(listingOf(Fixtures.FILES_LISTING))
451 },
452 mtimeOf: path => {
453 probed.push(path)
454
455 return Promise.resolve(Fixtures.BEFORE_SESSION_MS)
456 },
457 },
458 ),
459 'session',
460 )
461
462 expect(listed).not.toContain('/repo/sub')
463 expect(probed).toEqual([])
464
465 expect(
466 outcome.kind === 'data' && outcome.data.files.map(f => f.isPreSession),
467 ).toEqual([false])
468 })
469
470 test('a listing the host cut short is never parsed', async () => {
471 const cutNumstat = await Git.fetchDiff(
472 depsOf({
473 'HEAD --shortstat': Fixtures.ok(' 2 files changed, 2 insertions(+)'),
474 'HEAD --numstat': Fixtures.ok('1\t0\ta.ts\x001\t0\told'),
475 'rev-parse --verify --quiet HEAD': Fixtures.ok('abc\n'),
476 }),
477 'uncommitted',
478 )
479
480 const cutUntracked = await Git.fetchDiff(
481 depsOf({
482 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'),
483 'HEAD --numstat': Fixtures.ok('1\t0\ta.ts\0'),
484 'ls-files': Fixtures.ok('scratch.txt\0fresh.tx'),
485 }),
486 'uncommitted',
487 )
488
489 expect(cutNumstat).toEqual({ kind: 'unavailable' })
490
491 expect(
492 cutUntracked.kind === 'data' && [
493 cutUntracked.data.files.map(f => f.path),
494 cutUntracked.data.isUntrackedWithheld,
495 ],
496 ).toEqual([['a.ts'], true])
497
498 expect(Git.isWholeListing('a\0'.repeat(HALF_CAP_RECORDS))).toBe(false)
499 expect(Git.isWholeListing('a\0'.repeat(NEAR_CAP_RECORDS))).toBe(true)
500 })
501
502 test('an untracked directory git prints with a slash is dated', async () => {
503 const probed: string[] = []
504
505 const outcome = await Git.fetchDiff(
506 depsOf(
507 {
508 'HEAD --shortstat': Fixtures.ok(),
509 'HEAD --numstat': Fixtures.ok(),
510 'ls-files': Fixtures.ok('nested/\0'),
511 },
512 {
513 entryKindsOf: () => Promise.resolve(listingOf([['nested', 'dir']])),
514 mtimeOf: path => {
515 probed.push(path)
516
517 return Promise.resolve(Fixtures.BEFORE_SESSION_MS)
518 },
519 },
520 ),
521 'session',
522 )
523
524 expect(probed).toEqual(['/repo/nested'])
525
526 expect(
527 outcome.kind === 'data' &&
528 outcome.data.files.map(file => [file.path, file.isPreSession]),
529 ).toEqual([['nested/', true]])
530 })
531
532 test('a hundred directories deep and wide still date', async () => {
533 const listed: string[] = []
534
535 const paths = Array.from(
536 { length: Fixtures.DEEP_DIRECTORIES },
537 (_, at) => `d${at}/nested/file${at}.txt`,
538 )
539
540 const outcome = await Git.fetchDiff(
541 depsOf(
542 {
543 'HEAD --shortstat': Fixtures.ok(),
544 'HEAD --numstat': Fixtures.ok(),
545 'ls-files': Fixtures.ok(paths.map(path => `${path}\0`).join('')),
546 },
547 {
548 entryKindsOf: dir => {
549 listed.push(dir)
550
551 const isLeaf = dir.endsWith('/nested')
552
553 const entries: Listing = isLeaf
554 ? paths.map(path => [path.split('/').at(-1) ?? '', 'file'])
555 : [
556 ['nested', 'dir'],
557 ...paths.map(
558 path => [path.slice(0, path.indexOf('/')), 'dir'] as const,
559 ),
560 ]
561
562 return Promise.resolve(listingOf(entries))
563 },
564 mtimeOf: () => Promise.resolve(Fixtures.BEFORE_SESSION_MS),
565 },
566 ),
567 'session',
568 )
569
570 const rows = outcome.kind === 'data' ? outcome.data.files : []
571
572 expect(
573 new Set(listed.filter(dir => dir !== Fixtures.FETCH_REPOSITORY.gitDir))
574 .size,
575 ).toBe(1 + 2 * Fixtures.DEEP_DIRECTORIES)
576
577 expect(rows).toHaveLength(Limits.MAX_FILES)
578 expect(rows.every(row => row.isPreSession)).toBe(true)
579 })
580
581 test('a rename is one row under its new name and reads no body', async () => {
582 const deps = depsOf({
583 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'),
584 'HEAD --numstat': Fixtures.ok('1\t1\t\0old.ts\0new.ts\0'),
585 'ls-files': Fixtures.ok(),
586 '-- old.ts new.ts': Fixtures.ok('@@ -1 +1 @@\n-a\n+b\n'),
587 })
588
589 const outcome = await Git.fetchDiff(deps, 'uncommitted')
590 const data = outcome.kind === 'data' ? outcome.data : null
591 const [row] = data?.files ?? []
592
593 const isListed = data !== null && row !== undefined
594
595 const body = isListed
596 ? (await Git.fetchHunks(deps.run, data, [row])).get(row.path)
597 : null
598
599 expect(data?.files.map(f => [f.path, f.renamedFrom])).toEqual([
600 ['new.ts', 'old.ts'],
601 ])
602
603 expect(body).toEqual(Git.EMPTY_FILE_HUNKS)
604 expect(deps.argvs.some(argv => argv.includes('new.ts'))).toBe(false)
605 })
606})