git.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. /** Git command bounds, snapshot recovery, and diff failure reporting. */
  2. import { chmod, mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises'
  3. import { join } from 'node:path'
  4. import { afterEach, describe, expect, it, vi } from 'vitest'
  5. import { Context } from '@deepseek-ai/cordis'
  6. import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
  7. import { GitRunner, blobText, diffTrees, ignoredPaths, locateGitWorkspace, snapshotTree, treeBlob } from '../src/git.ts'
  8. import { TurnRecorder } from '../src/recorder.ts'
  9. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  10. import { git, scratchDir, startTurn, toolCall } from './support.ts'
  11. /** An object directory factory under a scratch root. */
  12. const objectsIn = (root: string) => () => Promise.resolve(join(root, 'objects'))
  13. const cleanups: Array<() => Promise<unknown>> = []
  14. afterEach(async () => {
  15. for (const cleanup of cleanups.reverse()) await cleanup()
  16. cleanups.length = 0
  17. })
  18. async function runner(limits = { timeoutMs: 30_000, outputMaxBytes: 1024 * 1024 }, executable = 'git') {
  19. const ctx = new Context()
  20. cleanups.push(() => ctx.fiber.dispose())
  21. await ctx.plugin(SessionStore)
  22. await ctx.plugin(LocalSubprocessRuntime)
  23. return { ctx, git: new GitRunner(ctx.subprocess, await ctx.subprocess.resolveExecutable(executable).catch(() => executable), limits) }
  24. }
  25. const signal = new AbortController().signal
  26. describe('GitRunner', () => {
  27. it('ignores ambient indexed Git configuration after the credential scrub', async () => {
  28. const cwd = await scratchDir('dsh-git-env-', cleanups)
  29. git(cwd, 'init', '-q', '-b', 'main')
  30. const { git: command } = await runner()
  31. vi.stubEnv('GIT_CONFIG_COUNT', '1')
  32. vi.stubEnv('GIT_CONFIG_KEY_0', 'core.bare')
  33. vi.stubEnv('GIT_CONFIG_VALUE_0', 'true')
  34. try {
  35. const result = await command.run(['rev-parse', '--is-bare-repository'], { cwd, signal })
  36. expect(result.exitCode, result.stderr).toBe(0)
  37. expect(result.stdout.trim()).toBe('false')
  38. } finally {
  39. vi.unstubAllEnvs()
  40. }
  41. })
  42. it('reports timeouts and external aborts as failures', async () => {
  43. const cwd = await scratchDir('dsh-git-runner-', cleanups)
  44. const { git: slow } = await runner({ timeoutMs: 1, outputMaxBytes: 1024 })
  45. await expect(slow.run(['--version'], { cwd, signal })).rejects.toThrow('timed out after 1ms')
  46. const { git: quick } = await runner()
  47. const aborted = new AbortController()
  48. setTimeout(() => { aborted.abort() }, 0)
  49. await expect(quick.run(['--version'], { cwd, signal: aborted.signal })).rejects.toThrow('git --version was aborted')
  50. const ok = await quick.run(['--version'], { cwd, signal })
  51. expect(ok.exitCode).toBe(0)
  52. expect(ok.stdout).toContain('git version')
  53. })
  54. })
  55. describe('snapshots and diffs', () => {
  56. it('snapshots a repository whose index holds unmerged entries without touching that index', async () => {
  57. const cwd = await scratchDir('dsh-git-conflict-', cleanups)
  58. git(cwd, 'init', '-q', '-b', 'main')
  59. await writeFile(join(cwd, 'f.txt'), 'base\n')
  60. git(cwd, 'add', '-A'); git(cwd, 'commit', '-q', '-m', 'base')
  61. git(cwd, 'checkout', '-q', '-b', 'side')
  62. await writeFile(join(cwd, 'f.txt'), 'side\n')
  63. git(cwd, 'commit', '-q', '-am', 'side')
  64. git(cwd, 'checkout', '-q', 'main')
  65. await writeFile(join(cwd, 'f.txt'), 'main\n')
  66. git(cwd, 'commit', '-q', '-am', 'main')
  67. expect(() => git(cwd, 'merge', 'side')).toThrow()
  68. expect(git(cwd, 'status', '--porcelain')).toContain('UU f.txt')
  69. const { git: runnerGit } = await runner()
  70. const workspace = await locateGitWorkspace(runnerGit, cwd, objectsIn(await scratchDir('dsh-git-store-', cleanups)), signal)
  71. expect(workspace?.root).toBe(await realpath(cwd))
  72. const tree = await snapshotTree(runnerGit, workspace!, signal)
  73. expect(tree).toMatch(/^[0-9a-f]{40,64}$/)
  74. expect(git(cwd, 'status', '--porcelain')).toContain('UU f.txt')
  75. })
  76. it('fails loudly when the addressed repository cannot be written or diffed', async () => {
  77. const cwd = await scratchDir('dsh-git-broken-', cleanups)
  78. const { git: runnerGit } = await runner()
  79. const store = objectsIn(await scratchDir('dsh-git-store-', cleanups))
  80. expect(await locateGitWorkspace(runnerGit, cwd, store, signal)).toBeNull()
  81. const broken = { root: cwd, gitDir: join(cwd, 'missing'), scratch: cwd, env: {}, excludes: [] }
  82. await expect(snapshotTree(runnerGit, broken, signal)).rejects.toThrow('git add in')
  83. await expect(ignoredPaths(runnerGit, broken, ['x'], signal)).rejects.toThrow('git check-ignore failed')
  84. expect(await ignoredPaths(runnerGit, broken, [], signal)).toEqual(new Set())
  85. git(cwd, 'init', '-q')
  86. const workspace = (await locateGitWorkspace(runnerGit, cwd, store, signal))!
  87. await expect(diffTrees(runnerGit, workspace, 'a'.repeat(40), 'b'.repeat(40), signal)).rejects.toThrow('git diff-tree failed')
  88. await writeFile(join(cwd, 'many.txt'), Array.from({ length: 50 }, (_, index) => `line ${index}`).join('\n'))
  89. const before = await snapshotTree(runnerGit, workspace, signal)
  90. await writeFile(join(cwd, 'many.txt'), 'gone')
  91. for (let index = 0; index < 20; index += 1) await writeFile(join(cwd, `file-${index}.txt`), 'x\n')
  92. const after = await snapshotTree(runnerGit, workspace, signal)
  93. const { git: tiny } = await runner({ timeoutMs: 30_000, outputMaxBytes: 16 })
  94. await expect(diffTrees(tiny, workspace, before, after, signal)).rejects.toThrow('exceeded')
  95. expect((await diffTrees(runnerGit, workspace, before, after, signal)).length).toBe(21)
  96. })
  97. })
  98. describe('repository edge cases', () => {
  99. it.skipIf(process.platform === 'win32')('snapshots past an unreadable file', async () => {
  100. const cwd = await scratchDir('dsh-git-unreadable-', cleanups)
  101. git(cwd, 'init', '-q', '-b', 'main')
  102. await writeFile(join(cwd, 'ok.txt'), 'ok\n')
  103. await writeFile(join(cwd, 'locked.txt'), 'locked\n')
  104. await chmod(join(cwd, 'locked.txt'), 0o000)
  105. cleanups.push(() => chmod(join(cwd, 'locked.txt'), 0o644))
  106. const { git: runnerGit } = await runner()
  107. const store = objectsIn(await scratchDir('dsh-git-store-', cleanups))
  108. const workspace = (await locateGitWorkspace(runnerGit, cwd, store, signal))!
  109. expect(await snapshotTree(runnerGit, workspace, signal)).toMatch(/^[0-9a-f]{40,64}$/)
  110. })
  111. it('refuses an index that exists but cannot be copied instead of starting from an empty one', async () => {
  112. const cwd = await scratchDir('dsh-git-bad-index-', cleanups)
  113. git(cwd, 'init', '-q', '-b', 'main')
  114. await mkdir(join(cwd, '.git', 'index'))
  115. const { git: runnerGit } = await runner()
  116. const store = objectsIn(await scratchDir('dsh-git-store-', cleanups))
  117. const workspace = (await locateGitWorkspace(runnerGit, cwd, store, signal))!
  118. await expect(snapshotTree(runnerGit, workspace, signal)).rejects.toThrow()
  119. })
  120. it('reports a repository git cannot read instead of treating it as absent', async () => {
  121. const cwd = await scratchDir('dsh-git-unsupported-', cleanups)
  122. git(cwd, 'init', '-q')
  123. const config = join(cwd, '.git', 'config')
  124. const original = await readFile(config, 'utf8')
  125. await writeFile(config, original.replace(/repositoryformatversion = \d+/, 'repositoryformatversion = 99'))
  126. const { git: runnerGit } = await runner()
  127. const store = objectsIn(await scratchDir('dsh-git-store-', cleanups))
  128. await expect(locateGitWorkspace(runnerGit, cwd, store, signal)).rejects.toThrow('git rev-parse failed')
  129. await writeFile(config, original)
  130. const blocked = join(cwd, 'store-file')
  131. await writeFile(blocked, 'not a directory')
  132. await expect(locateGitWorkspace(runnerGit, cwd, objectsIn(blocked), signal)).rejects.toThrow()
  133. })
  134. })
  135. describe('treeBlob and blobText', () => {
  136. it('locates a blob by its literal path, sizes it, reads it, and reports trees and missing paths as null', async () => {
  137. const cwd = await scratchDir('dsh-git-blob-', cleanups)
  138. git(cwd, 'init', '-q', '-b', 'main')
  139. await mkdir(join(cwd, 'dir'))
  140. await writeFile(join(cwd, 'dir', 'inner.txt'), 'inner\n')
  141. await writeFile(join(cwd, 'a[1].txt'), 'bracket\n')
  142. await writeFile(join(cwd, 'a1.txt'), 'plain\n')
  143. const { git: runnerGit } = await runner()
  144. const workspace = await locateGitWorkspace(runnerGit, cwd, objectsIn(await scratchDir('dsh-git-store-', cleanups)), signal)
  145. if (workspace === null) throw new Error('repository not located')
  146. const tree = await snapshotTree(runnerGit, workspace, signal)
  147. const bracket = await treeBlob(runnerGit, workspace, tree, 'a[1].txt', signal)
  148. expect(bracket).toEqual({ oid: git(cwd, 'hash-object', 'a[1].txt').trim(), size: 8 })
  149. expect(await blobText(runnerGit, workspace, bracket!.oid, 64, signal)).toBe('bracket\n')
  150. expect(await treeBlob(runnerGit, workspace, tree, 'dir', signal)).toBeNull()
  151. expect(await treeBlob(runnerGit, workspace, tree, 'missing.txt', signal)).toBeNull()
  152. expect(await treeBlob(runnerGit, workspace, tree, 'dir/inner.txt', signal)).toMatchObject({ size: 6 })
  153. })
  154. })
  155. describe('TurnRecorder', () => {
  156. it('stays silent when disposed while git work is pending, and warns on failures otherwise', async () => {
  157. const cwd = await scratchDir('dsh-recorder-', cleanups)
  158. const { ctx, git: runnerGit } = await runner()
  159. const session = ctx.sessions.create(SessionId('recorder'), { meta: { cwd } })
  160. const warnings: string[] = []
  161. let release!: (runner: GitRunner | null) => void
  162. const gate = new Promise<GitRunner | null>((resolve) => { release = resolve })
  163. const tempRoot = await scratchDir('dsh-git-store-', cleanups)
  164. const env = { git: gate, tempRoot, maxFiles: 10, maxFileBytes: 1024, diffTimeoutMs: 100, warn: (m: string) => { warnings.push(m) } }
  165. const disposed = new TurnRecorder(session, cwd, env)
  166. disposed.start(1)
  167. await new Promise(resolve => setTimeout(resolve, 5))
  168. const disposal = disposed.dispose()
  169. release(runnerGit)
  170. await disposal
  171. disposed.start(2)
  172. await disposed.settled()
  173. expect(warnings).toEqual([])
  174. expect(disposed.summary(1)).toBeUndefined()
  175. const { git: missing } = await runner(undefined, '/nonexistent/git-binary')
  176. const failing = new TurnRecorder(session, cwd, { ...env, git: Promise.resolve(missing) })
  177. failing.start(1)
  178. await failing.settled()
  179. expect(warnings).toHaveLength(1)
  180. expect(warnings[0]).toContain('workspace-changes:')
  181. })
  182. it('removes its snapshot objects on disposal and never creates them outside a repository', async () => {
  183. const tempRoot = await scratchDir('dsh-git-store-', cleanups)
  184. const { ctx, git: runnerGit } = await runner()
  185. const env = {
  186. git: Promise.resolve(runnerGit), tempRoot, maxFiles: 10, maxFileBytes: 1024, diffTimeoutMs: 100,
  187. warn: (m: string) => { throw new Error(m) },
  188. }
  189. const plain = new TurnRecorder(ctx.sessions.create(SessionId('plain'), { meta: { cwd: tempRoot } }), tempRoot, env)
  190. plain.start(1)
  191. await plain.settled()
  192. await plain.dispose()
  193. const cwd = await scratchDir('dsh-recorder-repo-', cleanups)
  194. git(cwd, 'init', '-q', '-b', 'main')
  195. const repo = new TurnRecorder(ctx.sessions.create(SessionId('repo'), { meta: { cwd } }), cwd, env)
  196. repo.start(1)
  197. await repo.settled()
  198. const [objects, ...others] = (await readdir(tempRoot)).filter(entry => entry.startsWith('dsh-workspace-changes-'))
  199. expect(others).toEqual([])
  200. expect(objects).toBeDefined()
  201. await repo.dispose()
  202. expect(await readdir(tempRoot)).toEqual([])
  203. })
  204. it('keeps its own directory out of the snapshots when the temporary root lies inside the work tree', async () => {
  205. const cwd = await scratchDir('dsh-recorder-tmp-in-tree-', cleanups)
  206. git(cwd, 'init', '-q', '-b', 'main')
  207. await writeFile(join(cwd, 'tracked.txt'), 'one\n')
  208. git(cwd, 'add', '-A'); git(cwd, 'commit', '-q', '-m', 'init')
  209. const tempRoot = join(cwd, 'tmp')
  210. await mkdir(tempRoot)
  211. const { ctx, git: runnerGit } = await runner()
  212. const session = ctx.sessions.create(SessionId('tmp-in-tree'), { meta: { cwd } })
  213. const env = {
  214. git: Promise.resolve(runnerGit), tempRoot, maxFiles: 10, maxFileBytes: 1024, diffTimeoutMs: 100,
  215. warn: (m: string) => { throw new Error(m) },
  216. }
  217. const recorder = new TurnRecorder(session, cwd, env)
  218. startTurn(session, 1)
  219. recorder.start(1)
  220. await recorder.settled()
  221. await writeFile(join(cwd, 'tracked.txt'), 'one\ntwo\n')
  222. recorder.observe(toolCall(session, 1, 'bash', { command: 'x' }))
  223. await recorder.stopping(1)
  224. const announced = session.snapshotEvents().find(event => event.type === 'workspace/changes')
  225. expect(recorder.summary(announced!.seq)?.files.map(file => file.display)).toEqual(['tracked.txt'])
  226. await recorder.dispose()
  227. })
  228. })