git.spec.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. /** Git command bounds, snapshot recovery, and diff failure reporting. */
  2. import { mkdir, writeFile } from 'node:fs/promises'
  3. import { join } from 'node:path'
  4. import { afterEach, describe, expect, it } from 'vitest'
  5. import { Context } from '@deepseek-ai/cordis'
  6. import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
  7. import { GitRunner, diffTrees, ignoredPaths, locateGitWorkspace, snapshotTree } from '../src/git.ts'
  8. import { TurnRecorder } from '../src/recorder.ts'
  9. import { temporaryRoots } from '../src/paths.ts'
  10. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  11. import { git, scratchDir } from './support.ts'
  12. const cleanups: Array<() => Promise<unknown>> = []
  13. afterEach(async () => {
  14. for (const cleanup of cleanups.reverse()) await cleanup()
  15. cleanups.length = 0
  16. })
  17. async function runner(limits = { timeoutMs: 30_000, outputMaxBytes: 1024 * 1024 }, executable = 'git') {
  18. const ctx = new Context()
  19. cleanups.push(() => ctx.fiber.dispose())
  20. await ctx.plugin(SessionStore)
  21. await ctx.plugin(LocalSubprocessRuntime)
  22. return { ctx, git: new GitRunner(ctx.subprocess, await ctx.subprocess.resolveExecutable(executable).catch(() => executable), limits) }
  23. }
  24. const signal = new AbortController().signal
  25. describe('GitRunner', () => {
  26. it('reports timeouts and external aborts as failures', async () => {
  27. const cwd = await scratchDir('dsh-git-runner-', cleanups)
  28. const { git: slow } = await runner({ timeoutMs: 1, outputMaxBytes: 1024 })
  29. await expect(slow.run(['--version'], { cwd, signal })).rejects.toThrow('timed out after 1ms')
  30. const { git: quick } = await runner()
  31. const aborted = new AbortController()
  32. setTimeout(() => { aborted.abort() }, 0)
  33. await expect(quick.run(['--version'], { cwd, signal: aborted.signal })).rejects.toThrow('git --version was aborted')
  34. const ok = await quick.run(['--version'], { cwd, signal })
  35. expect(ok.exitCode).toBe(0)
  36. expect(ok.stdout).toContain('git version')
  37. })
  38. })
  39. describe('snapshots and diffs', () => {
  40. it('snapshots a repository whose index holds unmerged entries without touching that index', async () => {
  41. const cwd = await scratchDir('dsh-git-conflict-', cleanups)
  42. git(cwd, 'init', '-q', '-b', 'main')
  43. await writeFile(join(cwd, 'f.txt'), 'base\n')
  44. git(cwd, 'add', '-A'); git(cwd, 'commit', '-q', '-m', 'base')
  45. git(cwd, 'checkout', '-q', '-b', 'side')
  46. await writeFile(join(cwd, 'f.txt'), 'side\n')
  47. git(cwd, 'commit', '-q', '-am', 'side')
  48. git(cwd, 'checkout', '-q', 'main')
  49. await writeFile(join(cwd, 'f.txt'), 'main\n')
  50. git(cwd, 'commit', '-q', '-am', 'main')
  51. expect(() => git(cwd, 'merge', 'side')).toThrow()
  52. expect(git(cwd, 'status', '--porcelain')).toContain('UU f.txt')
  53. const { git: runnerGit } = await runner()
  54. const workspace = await locateGitWorkspace(runnerGit, cwd, { home: join(cwd, 'unused'), excludes: [] }, signal)
  55. expect(workspace.kind).toBe('repository')
  56. const tree = await snapshotTree(runnerGit, workspace, signal)
  57. expect(tree).toMatch(/^[0-9a-f]{40,64}$/)
  58. expect(git(cwd, 'status', '--porcelain')).toContain('UU f.txt')
  59. })
  60. it('fails loudly when the addressed repository cannot be written or diffed', async () => {
  61. const cwd = await scratchDir('dsh-git-broken-', cleanups)
  62. const { git: runnerGit } = await runner()
  63. const broken = { kind: 'shadow' as const, root: cwd, gitDir: join(cwd, 'missing'), env: { GIT_DIR: join(cwd, 'missing'), GIT_WORK_TREE: cwd } }
  64. await expect(snapshotTree(runnerGit, broken, signal)).rejects.toThrow('git add in')
  65. await expect(ignoredPaths(runnerGit, broken, ['x'], signal)).rejects.toThrow('git check-ignore failed')
  66. expect(await ignoredPaths(runnerGit, broken, [], signal)).toEqual(new Set())
  67. git(cwd, 'init', '-q')
  68. const workspace = await locateGitWorkspace(runnerGit, cwd, { home: join(cwd, 'unused'), excludes: [] }, signal)
  69. await expect(diffTrees(runnerGit, workspace, 'a'.repeat(40), 'b'.repeat(40), signal)).rejects.toThrow('git diff-tree failed')
  70. await writeFile(join(cwd, 'many.txt'), Array.from({ length: 50 }, (_, index) => `line ${index}`).join('\n'))
  71. const before = await snapshotTree(runnerGit, workspace, signal)
  72. await writeFile(join(cwd, 'many.txt'), 'gone')
  73. for (let index = 0; index < 20; index += 1) await writeFile(join(cwd, `file-${index}.txt`), 'x\n')
  74. const after = await snapshotTree(runnerGit, workspace, signal)
  75. const { git: tiny } = await runner({ timeoutMs: 30_000, outputMaxBytes: 16 })
  76. await expect(diffTrees(tiny, workspace, before, after, signal)).rejects.toThrow('exceeded')
  77. expect((await diffTrees(runnerGit, workspace, before, after, signal)).length).toBe(21)
  78. })
  79. it('refuses a shadow home whose git directory cannot be initialized', async () => {
  80. const cwd = await scratchDir('dsh-git-shadow-broken-', cleanups)
  81. const home = join(cwd, 'home')
  82. const { git: runnerGit } = await runner()
  83. const probe = await locateGitWorkspace(runnerGit, cwd, { home, excludes: ['x/'] }, signal)
  84. expect(probe.kind).toBe('shadow')
  85. await mkdir(join(probe.gitDir, 'objects', 'broken'), { recursive: true })
  86. await writeFile(join(probe.gitDir, 'HEAD'), 'not a ref')
  87. await writeFile(join(probe.gitDir, 'config'), '[core]\n\tbare = maybe\n')
  88. await expect(locateGitWorkspace(runnerGit, cwd, { home, excludes: [] }, signal)).rejects.toThrow('git init of shadow repository')
  89. })
  90. })
  91. describe('TurnRecorder', () => {
  92. it('stays silent when disposed while git work is pending, and warns on failures otherwise', async () => {
  93. const cwd = await scratchDir('dsh-recorder-', cleanups)
  94. const { ctx, git: runnerGit } = await runner()
  95. const session = ctx.sessions.create(SessionId('recorder'), { meta: { cwd } })
  96. const warnings: string[] = []
  97. let release!: (runner: GitRunner | null) => void
  98. const gate = new Promise<GitRunner | null>((resolve) => { release = resolve })
  99. const env = { git: gate, shadow: { home: join(cwd, 'home'), excludes: [] }, home: '', temporaryRoots: temporaryRoots(), maxFiles: 10, warn: (m: string) => { warnings.push(m) } }
  100. const disposed = new TurnRecorder(session, cwd, env)
  101. disposed.start(1)
  102. await new Promise(resolve => setTimeout(resolve, 5))
  103. disposed.dispose()
  104. release(runnerGit)
  105. await disposed.settled()
  106. disposed.start(2)
  107. await disposed.settled()
  108. expect(warnings).toEqual([])
  109. const { git: missing } = await runner(undefined, '/nonexistent/git-binary')
  110. const failing = new TurnRecorder(session, cwd, { ...env, git: Promise.resolve(missing) })
  111. failing.start(1)
  112. await failing.settled()
  113. expect(warnings).toHaveLength(1)
  114. expect(warnings[0]).toContain('workspace-changes:')
  115. })
  116. })