translation-pairing-git.ts 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
  1. /** Git-blob operations owned by the bilingual pairing workflow. */
  2. import { spawnSync } from 'node:child_process'
  3. import { createHash } from 'node:crypto'
  4. const SNAPSHOT_REF_PREFIX = 'refs/dsh/translation-pairing/snapshots'
  5. /** Maximum buffered stdout or stderr for repository-owned Git subprocesses. */
  6. export const GIT_COMMAND_MAX_BUFFER = 1 << 26
  7. /** Full SHA-1 Git blob hash (the 40-hex format used by pairing records). */
  8. export function gitBlobHash(content: Buffer): string {
  9. const hash = createHash('sha1')
  10. hash.update(`blob ${content.byteLength}\0`)
  11. hash.update(content)
  12. return hash.digest('hex')
  13. }
  14. /**
  15. * Run one Git subprocess and return its exact stdout bytes.
  16. *
  17. * @param root - Repository root used as Git's working directory.
  18. * @param args - Arguments following the `git` executable.
  19. * @param operation - Human-readable operation for failure diagnostics.
  20. * @param input - Optional stdin bytes.
  21. * @returns Exact stdout bytes.
  22. * @throws Error when Git cannot start or exits unsuccessfully.
  23. */
  24. export function runGit(root: string, args: string[], operation: string, input?: Buffer): Buffer {
  25. const result = spawnSync('git', ['-C', root, ...args], {
  26. input,
  27. maxBuffer: GIT_COMMAND_MAX_BUFFER,
  28. })
  29. if (result.error) {
  30. throw new Error(`${operation} failed: ${result.error.message}`, { cause: result.error })
  31. }
  32. if (result.status !== 0) {
  33. throw new Error(`${operation} failed with status ${String(result.status)}: ${result.stderr.toString('utf8').trim()}`)
  34. }
  35. return result.stdout
  36. }
  37. /** One regular stage-zero Git index entry and its exact blob bytes. */
  38. export interface GitIndexBlob {
  39. /** Object ID recorded in the index. */
  40. objectId: string
  41. /** Blob bytes stored under that object ID. */
  42. content: Buffer
  43. }
  44. /**
  45. * Read one path from the Git index without consulting working-tree bytes.
  46. *
  47. * @param root - Repository root.
  48. * @param path - Repository-relative path.
  49. * @returns The stage-zero blob, or `undefined` when the path is absent.
  50. * @throws Error when the path is unmerged or has an invalid index shape.
  51. */
  52. export function readGitIndexBlob(root: string, path: string): GitIndexBlob | undefined {
  53. const output = runGit(
  54. root,
  55. ['ls-files', '--stage', '-z', '--', path],
  56. `git ls-files --stage for ${path}`,
  57. ).toString('utf8')
  58. const entries = output.split('\0').filter(Boolean)
  59. if (entries.length === 0) return undefined
  60. if (entries.length !== 1) throw new Error(`${path} does not have exactly one resolved index entry`)
  61. const match = /^(?:\d+) ([0-9a-f]+) 0\t[\s\S]+$/.exec(entries[0] ?? '')
  62. if (!match?.[1]) throw new Error(`${path} remains unmerged or has an invalid index entry`)
  63. return {
  64. objectId: match[1],
  65. content: runGit(root, ['cat-file', 'blob', match[1]], `reading staged ${path}`),
  66. }
  67. }
  68. /**
  69. * Persist exact working-tree bytes so a pairing record can later recover them
  70. * with `git cat-file`, even when they have never appeared in the index or a
  71. * commit. The returned object ID is checked against the pairing format's own
  72. * content hash before the caller writes a sidecar.
  73. */
  74. export function storeGitBlob(root: string, content: Buffer): string {
  75. const expected = gitBlobHash(content)
  76. const stored = runGit(root, ['hash-object', '-w', '--stdin'], 'git hash-object -w --stdin', content)
  77. .toString('utf8')
  78. .trim()
  79. if (stored !== expected) {
  80. throw new Error(`git hash-object -w --stdin returned unexpected object ID ${JSON.stringify(stored)}; expected ${expected}`)
  81. }
  82. runGit(
  83. root,
  84. ['update-ref', `${SNAPSHOT_REF_PREFIX}/${stored}`, stored],
  85. 'git update-ref for translation snapshot',
  86. )
  87. return stored
  88. }