translation-pairing-merge.spec.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696
  1. /** Integration coverage for automatic and explicit pairing-record conflict resolution. */
  2. import { execFileSync, spawnSync } from 'node:child_process'
  3. import {
  4. chmodSync,
  5. mkdtempSync,
  6. mkdirSync,
  7. readFileSync,
  8. rmSync,
  9. symlinkSync,
  10. writeFileSync,
  11. } from 'node:fs'
  12. import { tmpdir } from 'node:os'
  13. import { delimiter, dirname, join } from 'node:path'
  14. import { fileURLToPath } from 'node:url'
  15. import { afterEach, describe, expect, it } from 'vitest'
  16. import { gitBlobHash, storeGitBlob } from './translation-pairing-git.ts'
  17. import {
  18. mergeTranslationPairingRecords as mergeTranslationPairingRecordsWithScope,
  19. resolveTranslationPairingConflicts as resolveTranslationPairingConflictsWithScope,
  20. } from './translation-pairing-merge.ts'
  21. import {
  22. renderTranslationPairingRecord,
  23. translationPairPaths,
  24. } from './translation-pairing-record.ts'
  25. import { removeFixtureSafely } from './test-fixture-cleanup.ts'
  26. const driver = fileURLToPath(new URL('./merge-translation-pairing.ts', import.meta.url))
  27. const driverLauncher = fileURLToPath(new URL('./merge-translation-pairing-driver.sh', import.meta.url))
  28. const workspaceRoot = fileURLToPath(new URL('../', import.meta.url))
  29. const tsxLoader = import.meta.resolve('tsx/esm')
  30. const fixtures: string[] = []
  31. interface Fixture {
  32. env: NodeJS.ProcessEnv
  33. root: string
  34. }
  35. function mergeTranslationPairingRecords(
  36. root: string,
  37. metaPath: string,
  38. ancestorRecord: string,
  39. currentRecord: string,
  40. otherRecord: string,
  41. ) {
  42. return mergeTranslationPairingRecordsWithScope(
  43. root,
  44. metaPath,
  45. ancestorRecord,
  46. currentRecord,
  47. otherRecord,
  48. () => true,
  49. )
  50. }
  51. function resolveTranslationPairingConflicts(root: string): string[] {
  52. return resolveTranslationPairingConflictsWithScope(root, () => true)
  53. }
  54. afterEach(() => {
  55. for (const fixture of fixtures.splice(0)) removeFixtureSafely(fixture)
  56. })
  57. function git(fixture: Fixture, args: string[]): string {
  58. return execFileSync('git', ['-C', fixture.root, ...args], {
  59. encoding: 'utf8',
  60. env: fixture.env,
  61. }).trim()
  62. }
  63. function write(root: string, path: string, content: string): void {
  64. const absolute = join(root, path)
  65. mkdirSync(dirname(absolute), { recursive: true })
  66. writeFileSync(absolute, content)
  67. }
  68. function shellQuote(value: string): string {
  69. return `"${value.replace(/["\\$`]/g, '\\$&')}"`
  70. }
  71. function installFixtureRuntime(root: string): void {
  72. const linkType = process.platform === 'win32' ? 'junction' : 'dir'
  73. symlinkSync(
  74. join(workspaceRoot, 'node_modules'),
  75. join(root, 'node_modules'),
  76. linkType,
  77. )
  78. symlinkSync(join(workspaceRoot, 'scripts'), join(root, 'scripts'), linkType)
  79. }
  80. function startMergeWithFakeNode(
  81. fixture: Fixture,
  82. nodeScript = '#!/bin/sh\nexit 72\n',
  83. ) {
  84. const fakeBin = join(fixture.root, 'fake-bin')
  85. const fakeNode = join(fakeBin, 'node')
  86. write(fixture.root, 'fake-bin/node', nodeScript)
  87. chmodSync(fakeNode, 0o755)
  88. git(fixture, [
  89. 'config',
  90. 'merge.dsh-translation-pairing.driver',
  91. `${shellQuote(driverLauncher)} %O %A %B %P`,
  92. ])
  93. return spawnSync('git', ['-C', fixture.root, 'merge', '--no-commit', 'master'], {
  94. encoding: 'utf8',
  95. env: {
  96. ...fixture.env,
  97. PATH: `${fakeBin}${delimiter}${fixture.env.PATH ?? ''}`,
  98. },
  99. })
  100. }
  101. function createFixture(attributes = true): Fixture {
  102. const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-merge-'))
  103. fixtures.push(root)
  104. const env: NodeJS.ProcessEnv = {
  105. ...process.env,
  106. GIT_AUTHOR_EMAIL: 'pairing@example.test',
  107. GIT_AUTHOR_NAME: 'Pairing Test',
  108. GIT_COMMITTER_EMAIL: 'pairing@example.test',
  109. GIT_COMMITTER_NAME: 'Pairing Test',
  110. GIT_CONFIG_GLOBAL: join(root, 'global.gitconfig'),
  111. GIT_CONFIG_NOSYSTEM: '1',
  112. GIT_DEFAULT_HASH: 'sha1',
  113. }
  114. const fixture = { env, root }
  115. execFileSync('git', ['init', '--quiet', '--initial-branch=master', root], { env })
  116. if (attributes) write(root, '.gitattributes', '*.i18n.yaml merge=dsh-translation-pairing\n')
  117. return fixture
  118. }
  119. function record(root: string, path: string, source: string, zh: string): string {
  120. const paths = translationPairPaths(path)
  121. write(root, paths.source, source)
  122. write(root, paths.zh, zh)
  123. const content = renderTranslationPairingRecord(paths, {
  124. sourceHash: storeGitBlob(root, Buffer.from(source)),
  125. zhHash: storeGitBlob(root, Buffer.from(zh)),
  126. })
  127. write(root, paths.meta, content)
  128. return content
  129. }
  130. const baseSource = '# Guide\n\nEnglish | [中文](guide.zh.md)\n\nAlpha base.\n\nBeta base.\n'
  131. const baseZh = '# 指南\n\n[English](guide.md) | 中文\n\n甲基础。\n\n乙基础。\n'
  132. const currentSource = baseSource.replace('Alpha base.', 'Alpha current.')
  133. const currentZh = baseZh.replace('甲基础。', '甲当前。')
  134. const otherSource = baseSource.replace('Beta base.', 'Beta other.')
  135. const otherZh = baseZh.replace('乙基础。', '乙对侧。')
  136. const mergedSource = currentSource.replace('Beta base.', 'Beta other.')
  137. const mergedZh = currentZh.replace('乙基础。', '乙对侧。')
  138. const generatedBaseSource = '# Module graph\n\nAlpha base.\n\nBeta base.\n'
  139. const generatedBaseZh = '# 模块图\n\n[English](module-graph.md) | 中文\n\n甲基础。\n\n乙基础。\n'
  140. const generatedCurrentSource = generatedBaseSource.replace('Alpha base.', 'Alpha current.')
  141. const generatedCurrentZh = generatedBaseZh.replace('甲基础。', '甲当前。')
  142. const generatedOtherSource = generatedBaseSource.replace('Beta base.', 'Beta other.')
  143. const generatedOtherZh = generatedBaseZh.replace('乙基础。', '乙对侧。')
  144. const manualBaseSource = baseSource.replace('guide.zh.md', 'manual.zh.md')
  145. const manualBaseZh = baseZh.replace('guide.md', 'manual.md')
  146. const manualCurrentSource = manualBaseSource.replace('Alpha base.', 'Alpha current.')
  147. const manualCurrentZh = manualBaseZh.replace('甲基础。', '甲当前。')
  148. const manualOtherSource = manualBaseSource.replace('Alpha base.', 'Alpha other.')
  149. const manualOtherZh = manualBaseZh.replace('甲基础。', '甲对侧。')
  150. function commitPair(fixture: Fixture, source: string, zh: string, message: string): string {
  151. const sidecar = record(fixture.root, 'docs/guide.md', source, zh)
  152. git(fixture, ['add', '.'])
  153. git(fixture, ['commit', '-m', message])
  154. return sidecar
  155. }
  156. function commitTextCleanPair(fixture: Fixture, source: string, zh: string, message: string): void {
  157. const sidecar = record(fixture.root, 'docs/guide.md', source, zh)
  158. write(
  159. fixture.root,
  160. 'docs/guide.i18n.yaml',
  161. sidecar.replace('\nguide.zh.md:', '\n# Stable separator for independent line merges.\nguide.zh.md:'),
  162. )
  163. git(fixture, ['add', '.'])
  164. git(fixture, ['commit', '-m', message])
  165. }
  166. function createDivergedPair(fixture: Fixture): { ancestor: string; current: string; other: string } {
  167. const ancestor = commitPair(fixture, baseSource, baseZh, 'base')
  168. git(fixture, ['switch', '-c', 'current'])
  169. const current = commitPair(fixture, currentSource, currentZh, 'current')
  170. git(fixture, ['switch', 'master'])
  171. const other = commitPair(fixture, otherSource, otherZh, 'other')
  172. git(fixture, ['switch', 'current'])
  173. return { ancestor, current, other }
  174. }
  175. function createTextCleanDivergedPair(fixture: Fixture): void {
  176. commitTextCleanPair(fixture, baseSource, baseZh, 'base')
  177. git(fixture, ['switch', '-c', 'current'])
  178. commitTextCleanPair(fixture, currentSource, baseZh, 'current source')
  179. git(fixture, ['switch', 'master'])
  180. commitTextCleanPair(fixture, baseSource, otherZh, 'other translation')
  181. git(fixture, ['switch', 'current'])
  182. }
  183. function startStoppedPairingMerge(fixture: Fixture): void {
  184. createDivergedPair(fixture)
  185. const merge = spawnSync('git', ['-C', fixture.root, 'merge', '--no-commit', 'master'], {
  186. encoding: 'utf8',
  187. env: fixture.env,
  188. })
  189. expect(merge.status).toBe(1)
  190. expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
  191. }
  192. function commitMixedPairs(
  193. fixture: Fixture,
  194. guide: { source: string; zh: string },
  195. manual: { source: string; zh: string },
  196. message: string,
  197. ): void {
  198. record(fixture.root, 'docs/guide.md', guide.source, guide.zh)
  199. record(fixture.root, 'docs/manual.md', manual.source, manual.zh)
  200. git(fixture, ['add', '.'])
  201. git(fixture, ['commit', '-m', message])
  202. }
  203. function startMixedPairingMerge(fixture: Fixture): void {
  204. commitMixedPairs(
  205. fixture,
  206. { source: baseSource, zh: baseZh },
  207. { source: manualBaseSource, zh: manualBaseZh },
  208. 'base',
  209. )
  210. git(fixture, ['switch', '-c', 'current'])
  211. commitMixedPairs(
  212. fixture,
  213. { source: currentSource, zh: currentZh },
  214. { source: manualCurrentSource, zh: manualCurrentZh },
  215. 'current',
  216. )
  217. git(fixture, ['switch', 'master'])
  218. commitMixedPairs(
  219. fixture,
  220. { source: otherSource, zh: otherZh },
  221. { source: manualOtherSource, zh: manualOtherZh },
  222. 'other',
  223. )
  224. git(fixture, ['switch', 'current'])
  225. const merge = spawnSync('git', ['-C', fixture.root, 'merge', '--no-commit', 'master'], {
  226. encoding: 'utf8',
  227. env: fixture.env,
  228. })
  229. expect(merge.status).toBe(1)
  230. }
  231. function expectMergedPair(fixture: Fixture): void {
  232. expect(readFileSync(join(fixture.root, 'docs/guide.md'), 'utf8')).toBe(mergedSource)
  233. expect(readFileSync(join(fixture.root, 'docs/guide.zh.md'), 'utf8')).toBe(mergedZh)
  234. expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toBe(
  235. renderTranslationPairingRecord(translationPairPaths('docs/guide.md'), {
  236. sourceHash: gitBlobHash(Buffer.from(mergedSource)),
  237. zhHash: gitBlobHash(Buffer.from(mergedZh)),
  238. }),
  239. )
  240. }
  241. describe('translation pairing merge composition', { timeout: 15_000 }, () => {
  242. it('rejects a pairing-record path outside the repository', () => {
  243. const fixture = createFixture(false)
  244. expect(() => mergeTranslationPairingRecords(
  245. fixture.root,
  246. '../guide.i18n.yaml',
  247. '',
  248. '',
  249. '',
  250. )).toThrow('pairing record escapes the repository')
  251. })
  252. it('rejects a pairing record excluded from the active corpus', () => {
  253. const fixture = createFixture(false)
  254. expect(() => mergeTranslationPairingRecordsWithScope(
  255. fixture.root,
  256. 'docs/guide.i18n.yaml',
  257. '',
  258. '',
  259. '',
  260. () => false,
  261. )).toThrow('docs/guide.i18n.yaml is excluded from the active bilingual documentation corpus')
  262. })
  263. it('merges the owner blobs named by three valid records', () => {
  264. const fixture = createFixture(false)
  265. git(fixture, ['config', 'merge.default', 'text'])
  266. const records = createDivergedPair(fixture)
  267. const result = mergeTranslationPairingRecords(
  268. fixture.root,
  269. 'docs/guide.i18n.yaml',
  270. records.ancestor,
  271. records.current,
  272. records.other,
  273. )
  274. expect(result.sourceContent.toString('utf8')).toBe(mergedSource)
  275. expect(result.zhContent.toString('utf8')).toBe(mergedZh)
  276. expect(result.sourceHash).toBe(gitBlobHash(Buffer.from(mergedSource)))
  277. expect(result.zhHash).toBe(gitBlobHash(Buffer.from(mergedZh)))
  278. })
  279. it('accepts locale-specific paths to the same paired document', () => {
  280. const fixture = createFixture(false)
  281. write(fixture.root, 'docs/reference.md', '# Overview\n')
  282. write(fixture.root, 'docs/reference.zh.md', '# 概览\n')
  283. git(fixture, ['add', 'docs/reference.md', 'docs/reference.zh.md'])
  284. const source = baseSource.replace('Alpha base.', '[Reference](reference.md#overview)')
  285. const zh = baseZh.replace('甲基础。', '[参考](reference.zh.md#overview)')
  286. const ancestor = record(fixture.root, 'docs/guide.md', source, zh)
  287. const current = record(fixture.root, 'docs/guide.md', source, zh)
  288. const other = record(fixture.root, 'docs/guide.md', source, zh)
  289. rmSync(join(fixture.root, 'docs/reference.md'))
  290. rmSync(join(fixture.root, 'docs/reference.zh.md'))
  291. expect(mergeTranslationPairingRecords(
  292. fixture.root,
  293. 'docs/guide.i18n.yaml',
  294. ancestor,
  295. current,
  296. other,
  297. ).zhContent.toString('utf8')).toBe(zh)
  298. })
  299. it('rejects a clean merge whose Chinese link uses the English sibling', () => {
  300. const fixture = createFixture(false)
  301. write(fixture.root, 'docs/reference.md', '# Overview\n')
  302. write(fixture.root, 'docs/reference.zh.md', '# 概览\n')
  303. git(fixture, ['add', 'docs/reference.md', 'docs/reference.zh.md'])
  304. const source = baseSource.replace('Alpha base.', '[Reference](reference.md)')
  305. const zh = baseZh.replace('甲基础。', '[参考](reference.md)')
  306. const ancestor = record(fixture.root, 'docs/guide.md', source, zh)
  307. const current = record(fixture.root, 'docs/guide.md', source, zh)
  308. const other = record(fixture.root, 'docs/guide.md', source, zh)
  309. expect(() => mergeTranslationPairingRecords(
  310. fixture.root,
  311. 'docs/guide.i18n.yaml',
  312. ancestor,
  313. current,
  314. other,
  315. )).toThrow('docs/guide.zh.md:5 clean merge uses "reference.md"; expected "reference.zh.md"')
  316. })
  317. it('merges a generated source without an English language switcher', () => {
  318. const fixture = createFixture(false)
  319. const ancestor = record(fixture.root, 'docs/module-graph.md', generatedBaseSource, generatedBaseZh)
  320. const current = record(fixture.root, 'docs/module-graph.md', generatedCurrentSource, generatedCurrentZh)
  321. const other = record(fixture.root, 'docs/module-graph.md', generatedOtherSource, generatedOtherZh)
  322. const result = mergeTranslationPairingRecords(
  323. fixture.root,
  324. 'docs/module-graph.i18n.yaml',
  325. ancestor,
  326. current,
  327. other,
  328. )
  329. expect(result.sourceContent.toString('utf8')).toBe(
  330. generatedCurrentSource.replace('Beta base.', 'Beta other.'),
  331. )
  332. expect(result.zhContent.toString('utf8')).toBe(generatedCurrentZh.replace('乙基础。', '乙对侧。'))
  333. })
  334. it('rejects an authored source without an English language switcher', () => {
  335. const fixture = createFixture(false)
  336. const source = baseSource.replace('English | [中文](guide.zh.md)\n\n', '')
  337. const ancestor = record(fixture.root, 'docs/guide.md', source, baseZh)
  338. const current = record(fixture.root, 'docs/guide.md', source, baseZh)
  339. const other = record(fixture.root, 'docs/guide.md', source, baseZh)
  340. expect(() => mergeTranslationPairingRecords(
  341. fixture.root,
  342. 'docs/guide.i18n.yaml',
  343. ancestor,
  344. current,
  345. other,
  346. )).toThrow('docs/guide.md clean merge lost its language-switcher link to guide.zh.md')
  347. })
  348. it('rejects generated Chinese content without its English backlink', () => {
  349. const fixture = createFixture(false)
  350. const zh = generatedBaseZh.replace('[English](module-graph.md) | 中文\n\n', '')
  351. const ancestor = record(fixture.root, 'docs/module-graph.md', generatedBaseSource, zh)
  352. const current = record(fixture.root, 'docs/module-graph.md', generatedBaseSource, zh)
  353. const other = record(fixture.root, 'docs/module-graph.md', generatedBaseSource, zh)
  354. expect(() => mergeTranslationPairingRecords(
  355. fixture.root,
  356. 'docs/module-graph.i18n.yaml',
  357. ancestor,
  358. current,
  359. other,
  360. )).toThrow(
  361. 'docs/module-graph.zh.md clean merge lost its language-switcher link to module-graph.md',
  362. )
  363. })
  364. it('leaves owner-content conflicts for a human', () => {
  365. const fixture = createFixture(false)
  366. const ancestor = record(fixture.root, 'docs/guide.md', baseSource, baseZh)
  367. const current = record(
  368. fixture.root,
  369. 'docs/guide.md',
  370. baseSource.replace('Alpha base.', 'Alpha current.'),
  371. baseZh.replace('甲基础。', '甲当前。'),
  372. )
  373. const other = record(
  374. fixture.root,
  375. 'docs/guide.md',
  376. baseSource.replace('Alpha base.', 'Alpha other.'),
  377. baseZh.replace('甲基础。', '甲对侧。'),
  378. )
  379. expect(() => mergeTranslationPairingRecords(
  380. fixture.root,
  381. 'docs/guide.i18n.yaml',
  382. ancestor,
  383. current,
  384. other,
  385. )).toThrow('docs/guide.md has content conflicts')
  386. })
  387. it('rejects structurally divergent clean owner merges', () => {
  388. const fixture = createFixture(false)
  389. const ancestor = record(fixture.root, 'docs/guide.md', baseSource, baseZh)
  390. const current = record(fixture.root, 'docs/guide.md', currentSource, currentZh)
  391. const other = record(
  392. fixture.root,
  393. 'docs/guide.md',
  394. `${otherSource}\n## Extra\n`,
  395. otherZh,
  396. )
  397. expect(() => mergeTranslationPairingRecords(
  398. fixture.root,
  399. 'docs/guide.i18n.yaml',
  400. ancestor,
  401. current,
  402. other,
  403. )).toThrow('clean merges diverge structurally')
  404. })
  405. it('refuses owners assigned to another merge strategy', () => {
  406. const fixture = createFixture(false)
  407. write(fixture.root, '.gitattributes', 'docs/*.md merge=custom-owner\n')
  408. const records = createDivergedPair(fixture)
  409. expect(() => mergeTranslationPairingRecords(
  410. fixture.root,
  411. 'docs/guide.i18n.yaml',
  412. records.ancestor,
  413. records.current,
  414. records.other,
  415. )).toThrow('docs/guide.md uses merge=custom-owner')
  416. })
  417. it('refuses unspecified owners affected by merge.default', () => {
  418. const fixture = createFixture(false)
  419. git(fixture, ['config', 'merge.default', 'custom-owner'])
  420. const records = createDivergedPair(fixture)
  421. expect(() => mergeTranslationPairingRecords(
  422. fixture.root,
  423. 'docs/guide.i18n.yaml',
  424. records.ancestor,
  425. records.current,
  426. records.other,
  427. )).toThrow('merge.default=custom-owner')
  428. })
  429. it('runs as Git\'s custom driver and commits a clean composed record', () => {
  430. const fixture = createFixture()
  431. createDivergedPair(fixture)
  432. installFixtureRuntime(fixture.root)
  433. git(fixture, [
  434. 'config',
  435. 'merge.dsh-translation-pairing.driver',
  436. 'scripts/merge-translation-pairing-driver.sh %O %A %B %P',
  437. ])
  438. git(fixture, ['merge', '--no-edit', 'master'])
  439. expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('')
  440. expectMergedPair(fixture)
  441. })
  442. it('sees a paired link target added by the other branch', () => {
  443. const fixture = createFixture()
  444. commitPair(fixture, baseSource, baseZh, 'base')
  445. git(fixture, ['switch', '-c', 'current'])
  446. commitPair(fixture, currentSource, currentZh, 'current guide')
  447. git(fixture, ['switch', 'master'])
  448. record(
  449. fixture.root,
  450. 'docs/guide.md',
  451. baseSource.replace('Beta base.', '[Reference](reference.md#overview)'),
  452. baseZh.replace('乙基础。', '[参考](reference.zh.md#overview)'),
  453. )
  454. record(
  455. fixture.root,
  456. 'docs/reference.md',
  457. '# Reference\n\nEnglish | [中文](reference.zh.md)\n\nOverview.\n',
  458. '# 参考\n\n[English](reference.md) | 中文\n\n概览。\n',
  459. )
  460. git(fixture, ['add', '.'])
  461. git(fixture, ['commit', '-m', 'other guide and target'])
  462. git(fixture, ['switch', 'current'])
  463. installFixtureRuntime(fixture.root)
  464. git(fixture, [
  465. 'config',
  466. 'merge.dsh-translation-pairing.driver',
  467. 'scripts/merge-translation-pairing-driver.sh %O %A %B %P',
  468. ])
  469. const merge = spawnSync('git', ['-C', fixture.root, 'merge', '--no-edit', 'master'], {
  470. encoding: 'utf8',
  471. env: fixture.env,
  472. })
  473. expect(merge.status, merge.stderr).toBe(0)
  474. expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('')
  475. expect(readFileSync(join(fixture.root, 'docs/guide.md'), 'utf8')).toContain(
  476. '[Reference](reference.md#overview)',
  477. )
  478. expect(readFileSync(join(fixture.root, 'docs/guide.zh.md'), 'utf8')).toContain(
  479. '[参考](reference.zh.md#overview)',
  480. )
  481. })
  482. it('leaves an ordinary recoverable conflict when the configured runtime is unavailable', () => {
  483. const fixture = createFixture()
  484. const records = createDivergedPair(fixture)
  485. const headBefore = git(fixture, ['rev-parse', 'HEAD'])
  486. const result = startMergeWithFakeNode(fixture)
  487. expect(result.status).toBe(1)
  488. expect(result.stderr).toContain('runtime is unavailable; leaving an ordinary text conflict')
  489. expect(git(fixture, ['rev-parse', 'HEAD'])).toBe(headBefore)
  490. expect(git(fixture, ['rev-parse', '--verify', 'MERGE_HEAD'])).not.toBe('')
  491. expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
  492. expect(git(fixture, ['ls-files', '--unmerged', '--', 'docs/guide.i18n.yaml']).split('\n')).toHaveLength(3)
  493. const conflicted = readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')
  494. expect(conflicted).toContain('<<<<<<< docs/guide.i18n.yaml:current')
  495. for (const record of [records.current, records.other]) {
  496. const dataLines = record.split('\n').filter(line => line !== '' && !line.startsWith('#')).join('\n')
  497. expect(conflicted).toContain(dataLines)
  498. }
  499. expect(resolveTranslationPairingConflicts(fixture.root)).toEqual(['docs/guide.i18n.yaml'])
  500. expectMergedPair(fixture)
  501. })
  502. it('falls back before a broken driver entrypoint can replace the launcher', () => {
  503. const fixture = createFixture()
  504. createDivergedPair(fixture)
  505. const result = startMergeWithFakeNode(
  506. fixture,
  507. '#!/bin/sh\nif [ "$3" = "--eval" ]; then exit 0; fi\nexit 72\n',
  508. )
  509. expect(result.status).toBe(1)
  510. expect(result.stderr).toContain('runtime is unavailable; leaving an ordinary text conflict')
  511. expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toContain(
  512. '<<<<<<< docs/guide.i18n.yaml:current',
  513. )
  514. })
  515. it('keeps a clean text fallback unresolved until the explicit resolver confirms it', () => {
  516. const fixture = createFixture()
  517. createTextCleanDivergedPair(fixture)
  518. const result = startMergeWithFakeNode(fixture)
  519. expect(result.status).toBe(1)
  520. expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
  521. const canonicalRecord = renderTranslationPairingRecord(translationPairPaths('docs/guide.md'), {
  522. sourceHash: gitBlobHash(Buffer.from(currentSource)),
  523. zhHash: gitBlobHash(Buffer.from(otherZh)),
  524. })
  525. expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toBe(
  526. canonicalRecord.replace(
  527. '\nguide.zh.md:',
  528. '\n# Stable separator for independent line merges.\nguide.zh.md:',
  529. ),
  530. )
  531. expect(resolveTranslationPairingConflicts(fixture.root)).toEqual(['docs/guide.i18n.yaml'])
  532. expect(readFileSync(join(fixture.root, 'docs/guide.md'), 'utf8')).toBe(currentSource)
  533. expect(readFileSync(join(fixture.root, 'docs/guide.zh.md'), 'utf8')).toBe(otherZh)
  534. expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toBe(canonicalRecord)
  535. })
  536. it('leaves a staged merge when the pre-merge-commit hook rejects it', () => {
  537. const fixture = createFixture()
  538. createDivergedPair(fixture)
  539. installFixtureRuntime(fixture.root)
  540. git(fixture, [
  541. 'config',
  542. 'merge.dsh-translation-pairing.driver',
  543. 'scripts/merge-translation-pairing-driver.sh %O %A %B %P',
  544. ])
  545. const hooks = join(fixture.root, 'hooks')
  546. write(
  547. fixture.root,
  548. 'hooks/pre-merge-commit',
  549. '#!/bin/sh\necho "fixture pre-merge-commit rejection" >&2\nexit 77\n',
  550. )
  551. chmodSync(join(hooks, 'pre-merge-commit'), 0o755)
  552. git(fixture, ['config', 'core.hooksPath', hooks])
  553. const headBefore = git(fixture, ['rev-parse', 'HEAD'])
  554. const result = spawnSync('git', ['-C', fixture.root, 'merge', '--no-edit', 'master'], {
  555. encoding: 'utf8',
  556. env: fixture.env,
  557. })
  558. expect(result.status).toBe(1)
  559. expect(result.stderr).toContain('fixture pre-merge-commit rejection')
  560. expect(git(fixture, ['rev-parse', 'HEAD'])).toBe(headBefore)
  561. expect(git(fixture, ['rev-parse', '--verify', 'MERGE_HEAD'])).not.toBe('')
  562. expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('')
  563. expect(git(fixture, ['diff', '--cached', '--name-only']).split('\n')).toContain(
  564. 'docs/guide.i18n.yaml',
  565. )
  566. expectMergedPair(fixture)
  567. })
  568. it('prints the recovery path when driver input is not composable', () => {
  569. const fixture = createFixture(false)
  570. const result = spawnSync(process.execPath, ['--import', tsxLoader, driver], {
  571. cwd: fixture.root,
  572. encoding: 'utf8',
  573. env: fixture.env,
  574. })
  575. expect(result.status).toBe(1)
  576. expect(result.stderr).toContain('pnpm run verify-translation-pairing --write <pair>')
  577. expect(result.stderr).toContain('pnpm run resolve-translation-pairing-conflicts')
  578. })
  579. it('resolves an already-stopped generated-only conflict from index stages', () => {
  580. const fixture = createFixture(false)
  581. startStoppedPairingMerge(fixture)
  582. expect(resolveTranslationPairingConflicts(fixture.root)).toEqual(['docs/guide.i18n.yaml'])
  583. expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('')
  584. expectMergedPair(fixture)
  585. })
  586. it('refuses to confirm unstaged owner bytes after a stopped merge', () => {
  587. const fixture = createFixture(false)
  588. startStoppedPairingMerge(fixture)
  589. write(fixture.root, 'docs/guide.md', `${mergedSource}\nunstaged\n`)
  590. expect(() => resolveTranslationPairingConflicts(fixture.root)).toThrow(
  591. 'docs/guide.md has unstaged content',
  592. )
  593. expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
  594. })
  595. it('refuses to overwrite an edited sidecar after a stopped merge', () => {
  596. const fixture = createFixture(false)
  597. startStoppedPairingMerge(fixture)
  598. write(fixture.root, 'docs/guide.i18n.yaml', 'manually resolved\n')
  599. expect(() => resolveTranslationPairingConflicts(fixture.root)).toThrow(
  600. 'docs/guide.i18n.yaml has edited conflict content',
  601. )
  602. expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toBe('manually resolved\n')
  603. expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
  604. })
  605. it('resolves safe records while leaving an owner-conflicted pair untouched', () => {
  606. const fixture = createFixture(false)
  607. startMixedPairingMerge(fixture)
  608. expect(() => resolveTranslationPairingConflicts(fixture.root)).toThrow(
  609. 'docs/manual.i18n.yaml: docs/manual.md has content conflicts',
  610. )
  611. expect(git(fixture, ['diff', '--name-only', '--diff-filter=U']).split('\n')).toEqual([
  612. 'docs/manual.i18n.yaml',
  613. 'docs/manual.md',
  614. 'docs/manual.zh.md',
  615. ])
  616. expectMergedPair(fixture)
  617. })
  618. })