git.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. /** Git working-tree snapshots, tree diffs, and ignore checks through the subprocess capability. */
  2. import { createHash } from 'node:crypto'
  3. import { copyFile, mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'
  4. import { tmpdir } from 'node:os'
  5. import { join, relative } from 'node:path'
  6. import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
  7. import { parseNumstat, type NumstatEntry } from './numstat.ts'
  8. import { isInside, toPosix } from './paths.ts'
  9. /** Milliseconds a git child gets to exit after termination starts; a fixed lifecycle constant. */
  10. const TERMINATE_GRACE_MS = 2_000
  11. /** Retained stderr tail for diagnostics. */
  12. const STDERR_TAIL_BYTES = 16 * 1024
  13. /** Settled git command facts; a nonzero exit is a result, not an exception. */
  14. export interface GitRunResult {
  15. exitCode: number | null
  16. stdout: string
  17. stderr: string
  18. /** True when stdout exceeded the output cap and lost its head. */
  19. truncated: boolean
  20. }
  21. /** Per-command spawn facts. */
  22. export interface GitRunOptions {
  23. cwd: string
  24. env?: Readonly<Record<string, string>> | undefined
  25. stdin?: string | undefined
  26. signal: AbortSignal
  27. }
  28. /** Bounds every git command runs under. */
  29. export interface GitLimits {
  30. /** Milliseconds before a command is terminated. */
  31. timeoutMs: number
  32. /** In-memory stdout cap in bytes. */
  33. outputMaxBytes: number
  34. }
  35. /** Runs one resolved git executable with scrubbed environment, timeout, and bounded output. */
  36. export class GitRunner {
  37. constructor(
  38. private readonly subprocess: SubprocessRuntime,
  39. private readonly executable: string,
  40. private readonly limits: GitLimits,
  41. ) {}
  42. /**
  43. * Run `git <args>` to completion.
  44. * @param args - git arguments; never shell-interpreted.
  45. * @param options - working directory, extra environment, stdin data, and cancellation.
  46. * @returns exit facts and collected output.
  47. * @throws when the command times out, is aborted, or cannot spawn.
  48. */
  49. async run(args: readonly string[], options: GitRunOptions): Promise<GitRunResult> {
  50. const timeout = AbortSignal.timeout(this.limits.timeoutMs)
  51. const signal = AbortSignal.any([options.signal, timeout])
  52. const handle = this.subprocess.spawn({
  53. argv: [this.executable, ...args],
  54. cwd: options.cwd,
  55. stdio: {
  56. stdin: options.stdin === undefined ? 'ignore' : { data: options.stdin },
  57. stdout: { maxBytes: this.limits.outputMaxBytes },
  58. stderr: { maxBytes: STDERR_TAIL_BYTES },
  59. },
  60. graceMs: TERMINATE_GRACE_MS,
  61. signal,
  62. env: { GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C', ...options.env },
  63. })
  64. const outcome = await handle.done
  65. if (signal.aborted) {
  66. throw new Error(`git ${args.join(' ')} ${timeout.aborted ? `timed out after ${this.limits.timeoutMs}ms` : 'was aborted'}`)
  67. }
  68. /* v8 ignore start -- collect-mode stdio always yields both readers. */
  69. const stdout = handle.collected.stdout?.readFrom(0) ?? { text: '', lossy: false }
  70. const stderr = handle.collected.stderr?.readFrom(0).text ?? ''
  71. /* v8 ignore stop */
  72. return { exitCode: outcome.exitCode, stdout: stdout.text, stderr, truncated: stdout.lossy }
  73. }
  74. }
  75. /**
  76. * Reject a failed command with its stderr.
  77. * @param result - settled command facts.
  78. * @param what - command description for the error message.
  79. * @returns the same result when it exited zero.
  80. */
  81. function ok(result: GitRunResult, what: string): GitRunResult {
  82. if (result.exitCode !== 0) throw new Error(`${what} failed: ${result.stderr.trim()}`)
  83. return result
  84. }
  85. /**
  86. * The git repository a Session working directory snapshots into: the
  87. * enclosing repository when one exists, otherwise a shadow repository whose
  88. * git directory lives under the Harness home while its work tree is the
  89. * working directory itself.
  90. */
  91. export interface GitWorkspace {
  92. kind: 'repository' | 'shadow'
  93. /** Work-tree root: the repository top level, or the working directory for a shadow. */
  94. root: string
  95. /** Absolute git directory holding the index and objects. */
  96. gitDir: string
  97. /** Environment that addresses the repository for every command. */
  98. env: Readonly<Record<string, string>>
  99. }
  100. /** Where and how shadow repositories are created. */
  101. export interface ShadowRepositoryOptions {
  102. /** Directory that holds one shadow git directory per working directory. */
  103. home: string
  104. /** `info/exclude` patterns written on every snapshot. */
  105. excludes: readonly string[]
  106. }
  107. /**
  108. * Locate the repository for a working directory, creating the shadow
  109. * repository when the directory is not inside one.
  110. * @param git - command runner.
  111. * @param cwd - absolute Session working directory.
  112. * @param shadow - shadow repository placement and excludes.
  113. * @param signal - cancellation.
  114. * @returns the addressed repository.
  115. */
  116. export async function locateGitWorkspace(
  117. git: GitRunner, cwd: string, shadow: ShadowRepositoryOptions, signal: AbortSignal,
  118. ): Promise<GitWorkspace> {
  119. const found = await git.run(['rev-parse', '--show-toplevel', '--absolute-git-dir'], { cwd, signal })
  120. if (found.exitCode === 0) {
  121. const [root, gitDir] = found.stdout.split('\n') as [string, string]
  122. return { kind: 'repository', root, gitDir, env: {} }
  123. }
  124. const gitDir = join(shadow.home, createHash('sha256').update(cwd).digest('hex').slice(0, 16))
  125. const env = { GIT_DIR: gitDir, GIT_WORK_TREE: cwd }
  126. await mkdir(gitDir, { recursive: true })
  127. ok(await git.run(['init', '-q'], { cwd, env, signal }), `git init of shadow repository ${gitDir}`)
  128. const excludes = [...shadow.excludes]
  129. if (isInside(cwd, shadow.home)) excludes.push(`/${toPosix(relative(cwd, shadow.home))}/`)
  130. await mkdir(join(gitDir, 'info'), { recursive: true })
  131. await writeFile(join(gitDir, 'info', 'exclude'), `${excludes.join('\n')}\n`)
  132. return { kind: 'shadow', root: cwd, gitDir, env }
  133. }
  134. /**
  135. * Write the complete work tree, including untracked and modified files but
  136. * not ignored ones, as a tree object through a private index. The
  137. * repository's own index seeds the stat cache and is never modified, and an
  138. * in-progress merge keeps its unmerged entries; a shadow repository keeps the
  139. * refreshed index for its next snapshot.
  140. * @param git - command runner.
  141. * @param workspace - addressed repository.
  142. * @param signal - cancellation.
  143. * @returns the tree object id.
  144. */
  145. export async function snapshotTree(git: GitRunner, workspace: GitWorkspace, signal: AbortSignal): Promise<string> {
  146. const scratch = await mkdtemp(join(tmpdir(), 'dsh-workspace-changes-'))
  147. try {
  148. const index = join(scratch, 'index')
  149. const persisted = join(workspace.gitDir, 'index')
  150. // A missing persisted index (a fresh shadow repository) starts from scratch.
  151. await copyFile(persisted, index).then(() => undefined, () => undefined)
  152. const env = { ...workspace.env, GIT_INDEX_FILE: index }
  153. ok(await git.run(['add', '--all', '--ignore-errors'], { cwd: workspace.root, env, signal }), `git add in ${workspace.root}`)
  154. const tree = ok(await git.run(['write-tree'], { cwd: workspace.root, env, signal }), 'git write-tree').stdout.trim()
  155. if (workspace.kind === 'shadow') await rename(index, persisted)
  156. return tree
  157. } finally {
  158. await rm(scratch, { recursive: true, force: true })
  159. }
  160. }
  161. /**
  162. * Per-file line counts between two snapshot trees, with renames detected.
  163. * @param git - command runner.
  164. * @param workspace - addressed repository.
  165. * @param before - turn-start tree id.
  166. * @param after - turn-end tree id.
  167. * @param signal - cancellation.
  168. * @returns changed files relative to the work-tree root.
  169. * @throws when git fails or the output exceeded the cap.
  170. */
  171. export async function diffTrees(
  172. git: GitRunner, workspace: GitWorkspace, before: string, after: string, signal: AbortSignal,
  173. ): Promise<NumstatEntry[]> {
  174. if (before === after) return []
  175. const result = ok(await git.run(['diff-tree', '-r', '-M', '-z', '--numstat', before, after], {
  176. cwd: workspace.root, env: workspace.env, signal,
  177. }), 'git diff-tree')
  178. if (result.truncated) throw new Error('git diff-tree output exceeded the configured cap')
  179. return parseNumstat(result.stdout)
  180. }
  181. /**
  182. * The subset of work-tree paths that the repository ignores. Tracked files
  183. * are never reported, so a tracked file matching an ignore pattern still
  184. * counts as covered by snapshots.
  185. * @param git - command runner.
  186. * @param workspace - addressed repository.
  187. * @param paths - slash-separated paths relative to the work-tree root.
  188. * @param signal - cancellation.
  189. * @returns the ignored members of `paths`.
  190. */
  191. export async function ignoredPaths(
  192. git: GitRunner, workspace: GitWorkspace, paths: readonly string[], signal: AbortSignal,
  193. ): Promise<Set<string>> {
  194. if (paths.length === 0) return new Set()
  195. const result = await git.run(['check-ignore', '-z', '--stdin'], {
  196. cwd: workspace.root, env: workspace.env, stdin: `${paths.join('\0')}\0`, signal,
  197. })
  198. if (result.exitCode === 1) return new Set()
  199. return new Set(ok(result, 'git check-ignore').stdout.split('\0').filter(path => path !== ''))
  200. }