translation-pairing-merge.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. /** Fail-closed composition of bilingual pairing records during Git merges. */
  2. import { spawnSync } from 'node:child_process'
  3. import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path'
  6. import {
  7. GIT_COMMAND_MAX_BUFFER,
  8. gitBlobHash,
  9. readGitIndexBlob,
  10. runGit,
  11. storeGitBlob,
  12. } from './translation-pairing-git.ts'
  13. import {
  14. linksTo,
  15. isTranslationScopeFile,
  16. parseTranslationMarkdown,
  17. requiresSourceLanguageSwitcher,
  18. translationStructureDiff,
  19. translationStructureSignature,
  20. } from './translation-pairing.ts'
  21. import {
  22. parseTranslationPairingRecord,
  23. renderTranslationPairingRecord,
  24. translationPairPathsFromMeta,
  25. type TranslationPairPaths,
  26. type TranslationPairingRecord,
  27. } from './translation-pairing-record.ts'
  28. const UNMERGED_ENTRY = /^(\d+) ([0-9a-f]+) ([123])\t([\s\S]+)$/
  29. /** A mechanically composed record and the exact merged owner contents it names. */
  30. export interface TranslationPairingMergeResult extends TranslationPairingRecord {
  31. /** Canonical generated sidecar text. */
  32. record: string
  33. /** Clean three-way merge of the English owner. */
  34. sourceContent: Buffer
  35. /** Clean three-way merge of the Simplified Chinese owner. */
  36. zhContent: Buffer
  37. }
  38. interface UnmergedStages {
  39. ancestor?: string
  40. current?: string
  41. other?: string
  42. }
  43. function readGitBlob(root: string, objectId: string, owner: string): Buffer {
  44. const content = runGit(root, ['cat-file', 'blob', objectId], `reading ${owner} blob ${objectId}`)
  45. if (gitBlobHash(content) !== objectId) {
  46. throw new Error(`${owner} record names ${objectId}, which is not its SHA-1 git blob hash`)
  47. }
  48. return content
  49. }
  50. function readMergeDefault(root: string): string | undefined {
  51. const result = spawnSync('git', ['-C', root, 'config', '--get', 'merge.default'], {
  52. maxBuffer: GIT_COMMAND_MAX_BUFFER,
  53. })
  54. if (result.error) {
  55. throw new Error(`reading merge.default failed: ${result.error.message}`, { cause: result.error })
  56. }
  57. if (result.status === 1) return undefined
  58. if (result.status !== 0) {
  59. throw new Error(
  60. `reading merge.default failed with status ${String(result.status)}: ${result.stderr.toString('utf8').trim()}`,
  61. )
  62. }
  63. return result.stdout.toString('utf8').trim()
  64. }
  65. function assertDefaultTextMerge(root: string, paths: TranslationPairPaths): void {
  66. const output = runGit(
  67. root,
  68. ['check-attr', '-z', 'merge', '--', paths.source, paths.zh],
  69. 'checking bilingual owner merge attributes',
  70. ).toString('utf8')
  71. const fields = output.split('\0')
  72. fields.pop()
  73. let mergeDefault: string | undefined
  74. for (let index = 0; index < fields.length; index += 3) {
  75. const path = fields[index]
  76. const value = fields[index + 2]
  77. if (path === undefined || value === undefined) {
  78. throw new Error('git check-attr returned a malformed result')
  79. }
  80. if (!['unspecified', 'set', 'text'].includes(value)) {
  81. throw new Error(`${path} uses merge=${value}; the pairing driver only composes Git's default text merge`)
  82. }
  83. if (value === 'unspecified') {
  84. mergeDefault ??= readMergeDefault(root)
  85. if (mergeDefault !== undefined && mergeDefault !== 'text') {
  86. throw new Error(
  87. `${path} inherits merge.default=${mergeDefault}; the pairing driver only composes Git's default text merge`,
  88. )
  89. }
  90. }
  91. }
  92. }
  93. function runTextMerge(
  94. root: string,
  95. label: string,
  96. ancestor: Buffer | string,
  97. current: Buffer | string,
  98. other: Buffer | string,
  99. ): { output: Buffer; status: number | null } {
  100. const temporary = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-merge-'))
  101. try {
  102. const ancestorPath = join(temporary, 'ancestor')
  103. const currentPath = join(temporary, 'current')
  104. const otherPath = join(temporary, 'other')
  105. writeFileSync(ancestorPath, ancestor)
  106. writeFileSync(currentPath, current)
  107. writeFileSync(otherPath, other)
  108. const result = spawnSync('git', [
  109. '-C', root,
  110. 'merge-file', '-p',
  111. '-L', `${label}:current`,
  112. '-L', `${label}:ancestor`,
  113. '-L', `${label}:other`,
  114. currentPath, ancestorPath, otherPath,
  115. ], { maxBuffer: GIT_COMMAND_MAX_BUFFER })
  116. if (result.error) {
  117. throw new Error(`merging ${label} failed: ${result.error.message}`, { cause: result.error })
  118. }
  119. return { output: result.stdout, status: result.status }
  120. } finally {
  121. rmSync(temporary, { recursive: true, force: true })
  122. }
  123. }
  124. function mergeBlobTriplet(
  125. root: string,
  126. owner: string,
  127. ancestor: Buffer,
  128. current: Buffer,
  129. other: Buffer,
  130. ): Buffer {
  131. const result = runTextMerge(root, owner, ancestor, current, other)
  132. if (result.status !== 0) {
  133. const kind = result.status !== null && result.status > 0 && result.status <= 127
  134. ? 'has content conflicts'
  135. : `failed with status ${String(result.status)}`
  136. throw new Error(`${owner} ${kind}`)
  137. }
  138. return result.output
  139. }
  140. function loadRecordOwners(
  141. root: string,
  142. label: string,
  143. content: string,
  144. paths: TranslationPairPaths,
  145. ): { source: Buffer; zh: Buffer } {
  146. const record = parseTranslationPairingRecord(content, paths)
  147. if (record === undefined) throw new Error(`${label} ${paths.meta} is not a valid two-hash pairing record`)
  148. return {
  149. source: readGitBlob(root, record.sourceHash, `${label} ${paths.source}`),
  150. zh: readGitBlob(root, record.zhHash, `${label} ${paths.zh}`),
  151. }
  152. }
  153. function assertMergedPairStructure(paths: TranslationPairPaths, source: Buffer, zh: Buffer): void {
  154. const sourceTree = parseTranslationMarkdown(source.toString('utf8'))
  155. const zhTree = parseTranslationMarkdown(zh.toString('utf8'))
  156. if (requiresSourceLanguageSwitcher(paths.source) && !linksTo(sourceTree, basename(paths.zh))) {
  157. throw new Error(`${paths.source} clean merge lost its language-switcher link to ${basename(paths.zh)}`)
  158. }
  159. if (!linksTo(zhTree, basename(paths.source))) {
  160. throw new Error(`${paths.zh} clean merge lost its language-switcher link to ${basename(paths.source)}`)
  161. }
  162. const divergences = translationStructureDiff(
  163. translationStructureSignature(sourceTree, basename(paths.zh)),
  164. translationStructureSignature(zhTree, basename(paths.source)),
  165. )
  166. if (divergences.length > 0) {
  167. throw new Error(`${paths.source} and ${paths.zh} clean merges diverge structurally: ${divergences.join('; ')}`)
  168. }
  169. }
  170. function normalizeMetaPath(root: string, meta: string): string {
  171. if (isAbsolute(meta)) throw new Error(`pairing record must be repository-relative: ${JSON.stringify(meta)}`)
  172. const repositoryRelative = relative(resolve(root), resolve(root, meta))
  173. if (repositoryRelative === '' || repositoryRelative === '..' || repositoryRelative.startsWith(`..${sep}`)) {
  174. throw new Error(`pairing record escapes the repository: ${JSON.stringify(meta)}`)
  175. }
  176. return repositoryRelative.split(sep).join('/')
  177. }
  178. /**
  179. * Compose one generated sidecar from the ancestor, current, and other records.
  180. *
  181. * Each input record is already a confirmation of its two owner blobs. The
  182. * result exists only when Git's default text merge succeeds independently for
  183. * both languages and the composed documents retain the pairing structure.
  184. *
  185. * @param root - Repository root containing the referenced Git objects.
  186. * @param metaPath - Repository-relative sidecar path.
  187. * @param ancestorRecord - Common-ancestor sidecar text.
  188. * @param currentRecord - Current-side sidecar text.
  189. * @param otherRecord - Other-side sidecar text.
  190. * @returns The canonical record and exact merged owner contents.
  191. * @throws Error when the input is not mechanically composable.
  192. */
  193. export function mergeTranslationPairingRecords(
  194. root: string,
  195. metaPath: string,
  196. ancestorRecord: string,
  197. currentRecord: string,
  198. otherRecord: string,
  199. ): TranslationPairingMergeResult {
  200. const normalizedMeta = normalizeMetaPath(root, metaPath)
  201. if (!isTranslationScopeFile(normalizedMeta)) {
  202. throw new Error(`${normalizedMeta} is outside the active bilingual documentation corpus`)
  203. }
  204. const paths = translationPairPathsFromMeta(normalizedMeta)
  205. assertDefaultTextMerge(root, paths)
  206. const ancestor = loadRecordOwners(root, 'ancestor', ancestorRecord, paths)
  207. const current = loadRecordOwners(root, 'current', currentRecord, paths)
  208. const other = loadRecordOwners(root, 'other', otherRecord, paths)
  209. const sourceContent = mergeBlobTriplet(root, paths.source, ancestor.source, current.source, other.source)
  210. const zhContent = mergeBlobTriplet(root, paths.zh, ancestor.zh, current.zh, other.zh)
  211. assertMergedPairStructure(paths, sourceContent, zhContent)
  212. const sourceHash = storeGitBlob(root, sourceContent)
  213. const zhHash = storeGitBlob(root, zhContent)
  214. return {
  215. record: renderTranslationPairingRecord(paths, { sourceHash, zhHash }),
  216. sourceContent,
  217. sourceHash,
  218. zhContent,
  219. zhHash,
  220. }
  221. }
  222. function unmergedSidecars(root: string): Map<string, UnmergedStages> {
  223. const output = runGit(root, ['ls-files', '--unmerged', '-z'], 'listing unresolved merge entries').toString('utf8')
  224. const records = new Map<string, UnmergedStages>()
  225. for (const entry of output.split('\0')) {
  226. if (entry === '') continue
  227. const match = UNMERGED_ENTRY.exec(entry)
  228. if (!match?.[2] || !match[3] || match[4] === undefined) {
  229. throw new Error(`git ls-files returned a malformed unmerged entry: ${JSON.stringify(entry)}`)
  230. }
  231. const path = match[4]
  232. if (!path.endsWith('.i18n.yaml')) continue
  233. const stages = records.get(path) ?? {}
  234. const field = match[3] === '1' ? 'ancestor' : match[3] === '2' ? 'current' : 'other'
  235. stages[field] = match[2]
  236. records.set(path, stages)
  237. }
  238. return records
  239. }
  240. function assertUneditedSidecar(
  241. root: string,
  242. metaPath: string,
  243. ancestorRecord: string,
  244. currentRecord: string,
  245. otherRecord: string,
  246. ): void {
  247. const worktreeRecord = readFileSync(join(root, metaPath), 'utf8')
  248. if (worktreeRecord === currentRecord || worktreeRecord === otherRecord) return
  249. const textMerge = runTextMerge(root, metaPath, ancestorRecord, currentRecord, otherRecord)
  250. if (textMerge.status === 0 && textMerge.output.toString('utf8') === worktreeRecord) return
  251. const stageDataLines = [currentRecord, otherRecord]
  252. .flatMap(record => record.split(/\r?\n/))
  253. .filter(line => line !== '' && !line.startsWith('#'))
  254. const hasUneditedConflict = worktreeRecord.includes('<<<<<<<')
  255. && worktreeRecord.includes('=======')
  256. && worktreeRecord.includes('>>>>>>>')
  257. && stageDataLines.every(line => worktreeRecord.includes(line))
  258. if (!hasUneditedConflict) {
  259. throw new Error(`${metaPath} has edited conflict content; refusing to overwrite manual work`)
  260. }
  261. }
  262. /**
  263. * Resolve every mechanically composable `.i18n.yaml` conflict in the index.
  264. *
  265. * The command first proves that Git's already-staged owner merges match the
  266. * independently composed contents, then writes and stages all sidecars as one
  267. * batch. Other conflicts remain untouched; after staging the safe records, an
  268. * aggregate error reports any pairing conflicts that still need manual work.
  269. *
  270. * @param root - Repository root with an in-progress merge-like operation.
  271. * @returns Repository-relative sidecar paths resolved and staged.
  272. */
  273. export function resolveTranslationPairingConflicts(root: string): string[] {
  274. const resolutions: { path: string; record: string }[] = []
  275. const failures: { path: string; reason: string }[] = []
  276. for (const [metaPath, stages] of [...unmergedSidecars(root)].sort(([left], [right]) => left.localeCompare(right))) {
  277. try {
  278. if (stages.ancestor === undefined || stages.current === undefined || stages.other === undefined) {
  279. throw new Error('is an add/delete or incomplete-stage conflict and requires manual resolution')
  280. }
  281. const ancestorRecord = readGitBlob(root, stages.ancestor, `ancestor ${metaPath}`).toString('utf8')
  282. const currentRecord = readGitBlob(root, stages.current, `current ${metaPath}`).toString('utf8')
  283. const otherRecord = readGitBlob(root, stages.other, `other ${metaPath}`).toString('utf8')
  284. assertUneditedSidecar(root, metaPath, ancestorRecord, currentRecord, otherRecord)
  285. const result = mergeTranslationPairingRecords(
  286. root,
  287. metaPath,
  288. ancestorRecord,
  289. currentRecord,
  290. otherRecord,
  291. )
  292. const paths = translationPairPathsFromMeta(metaPath)
  293. if (readGitIndexBlob(root, paths.source)?.objectId !== result.sourceHash) {
  294. throw new Error(`${paths.source} staged merge does not match the pairing driver's clean merge`)
  295. }
  296. if (readGitIndexBlob(root, paths.zh)?.objectId !== result.zhHash) {
  297. throw new Error(`${paths.zh} staged merge does not match the pairing driver's clean merge`)
  298. }
  299. for (const [path, expected] of [[paths.source, result.sourceHash], [paths.zh, result.zhHash]] as const) {
  300. if (gitBlobHash(readFileSync(join(root, path))) !== expected) {
  301. throw new Error(`${path} has unstaged content; refusing to confirm bytes outside the merge result`)
  302. }
  303. }
  304. resolutions.push({ path: metaPath, record: result.record })
  305. } catch (error) {
  306. failures.push({ path: metaPath, reason: error instanceof Error ? error.message : String(error) })
  307. }
  308. }
  309. for (const resolution of resolutions) writeFileSync(join(root, resolution.path), resolution.record)
  310. if (resolutions.length > 0) {
  311. runGit(root, ['add', '--', ...resolutions.map(resolution => resolution.path)], 'staging resolved pairing records')
  312. }
  313. if (failures.length > 0) {
  314. const resolved = resolutions.length === 0
  315. ? ''
  316. : `resolved and staged ${resolutions.map(resolution => resolution.path).join(', ')}; `
  317. throw new Error(
  318. `${resolved}left ${String(failures.length)} pairing conflict(s) unresolved:\n`
  319. + failures.map(failure => `- ${failure.path}: ${failure.reason}`).join('\n'),
  320. )
  321. }
  322. return resolutions.map(resolution => resolution.path)
  323. }