translation-pairing-merge.spec.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704
  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. // Every case in this suite drives real `git` invocations against a scratch
  242. // repository, so it is bound by process creation rather than by its assertions.
  243. // The value matches DSH_COVERAGE_TEST_TIMEOUT_MS, which the Windows coverage
  244. // lane passes as --testTimeout: a describe value overrides that flag rather than
  245. // yielding to it, so a smaller one here lowers what the lane grants every case
  246. // in this file, none of which carries an allowance of its own. Measurements and
  247. // the rejected alternatives are in
  248. // .agents/notes/archived/testing/2026-08-27-translation-pairing-merge-budget.md.
  249. describe('translation pairing merge composition', { timeout: 90_000 }, () => {
  250. it('rejects a pairing-record path outside the repository', () => {
  251. const fixture = createFixture(false)
  252. expect(() => mergeTranslationPairingRecords(
  253. fixture.root,
  254. '../guide.i18n.yaml',
  255. '',
  256. '',
  257. '',
  258. )).toThrow('pairing record escapes the repository')
  259. })
  260. it('rejects a pairing record excluded from the active corpus', () => {
  261. const fixture = createFixture(false)
  262. expect(() => mergeTranslationPairingRecordsWithScope(
  263. fixture.root,
  264. 'docs/guide.i18n.yaml',
  265. '',
  266. '',
  267. '',
  268. () => false,
  269. )).toThrow('docs/guide.i18n.yaml is excluded from the active bilingual documentation corpus')
  270. })
  271. it('merges the owner blobs named by three valid records', () => {
  272. const fixture = createFixture(false)
  273. git(fixture, ['config', 'merge.default', 'text'])
  274. const records = createDivergedPair(fixture)
  275. const result = mergeTranslationPairingRecords(
  276. fixture.root,
  277. 'docs/guide.i18n.yaml',
  278. records.ancestor,
  279. records.current,
  280. records.other,
  281. )
  282. expect(result.sourceContent.toString('utf8')).toBe(mergedSource)
  283. expect(result.zhContent.toString('utf8')).toBe(mergedZh)
  284. expect(result.sourceHash).toBe(gitBlobHash(Buffer.from(mergedSource)))
  285. expect(result.zhHash).toBe(gitBlobHash(Buffer.from(mergedZh)))
  286. })
  287. it('accepts locale-specific paths to the same paired document', () => {
  288. const fixture = createFixture(false)
  289. write(fixture.root, 'docs/reference.md', '# Overview\n')
  290. write(fixture.root, 'docs/reference.zh.md', '# 概览\n')
  291. git(fixture, ['add', 'docs/reference.md', 'docs/reference.zh.md'])
  292. const source = baseSource.replace('Alpha base.', '[Reference](reference.md#overview)')
  293. const zh = baseZh.replace('甲基础。', '[参考](reference.zh.md#overview)')
  294. const ancestor = record(fixture.root, 'docs/guide.md', source, zh)
  295. const current = record(fixture.root, 'docs/guide.md', source, zh)
  296. const other = record(fixture.root, 'docs/guide.md', source, zh)
  297. rmSync(join(fixture.root, 'docs/reference.md'))
  298. rmSync(join(fixture.root, 'docs/reference.zh.md'))
  299. expect(mergeTranslationPairingRecords(
  300. fixture.root,
  301. 'docs/guide.i18n.yaml',
  302. ancestor,
  303. current,
  304. other,
  305. ).zhContent.toString('utf8')).toBe(zh)
  306. })
  307. it('rejects a clean merge whose Chinese link uses the English sibling', () => {
  308. const fixture = createFixture(false)
  309. write(fixture.root, 'docs/reference.md', '# Overview\n')
  310. write(fixture.root, 'docs/reference.zh.md', '# 概览\n')
  311. git(fixture, ['add', 'docs/reference.md', 'docs/reference.zh.md'])
  312. const source = baseSource.replace('Alpha base.', '[Reference](reference.md)')
  313. const zh = baseZh.replace('甲基础。', '[参考](reference.md)')
  314. const ancestor = record(fixture.root, 'docs/guide.md', source, zh)
  315. const current = record(fixture.root, 'docs/guide.md', source, zh)
  316. const other = record(fixture.root, 'docs/guide.md', source, zh)
  317. expect(() => mergeTranslationPairingRecords(
  318. fixture.root,
  319. 'docs/guide.i18n.yaml',
  320. ancestor,
  321. current,
  322. other,
  323. )).toThrow('docs/guide.zh.md:5 clean merge uses "reference.md"; expected "reference.zh.md"')
  324. })
  325. it('merges a generated source without an English language switcher', () => {
  326. const fixture = createFixture(false)
  327. const ancestor = record(fixture.root, 'docs/module-graph.md', generatedBaseSource, generatedBaseZh)
  328. const current = record(fixture.root, 'docs/module-graph.md', generatedCurrentSource, generatedCurrentZh)
  329. const other = record(fixture.root, 'docs/module-graph.md', generatedOtherSource, generatedOtherZh)
  330. const result = mergeTranslationPairingRecords(
  331. fixture.root,
  332. 'docs/module-graph.i18n.yaml',
  333. ancestor,
  334. current,
  335. other,
  336. )
  337. expect(result.sourceContent.toString('utf8')).toBe(
  338. generatedCurrentSource.replace('Beta base.', 'Beta other.'),
  339. )
  340. expect(result.zhContent.toString('utf8')).toBe(generatedCurrentZh.replace('乙基础。', '乙对侧。'))
  341. })
  342. it('rejects an authored source without an English language switcher', () => {
  343. const fixture = createFixture(false)
  344. const source = baseSource.replace('English | [中文](guide.zh.md)\n\n', '')
  345. const ancestor = record(fixture.root, 'docs/guide.md', source, baseZh)
  346. const current = record(fixture.root, 'docs/guide.md', source, baseZh)
  347. const other = record(fixture.root, 'docs/guide.md', source, baseZh)
  348. expect(() => mergeTranslationPairingRecords(
  349. fixture.root,
  350. 'docs/guide.i18n.yaml',
  351. ancestor,
  352. current,
  353. other,
  354. )).toThrow('docs/guide.md clean merge lost its language-switcher link to guide.zh.md')
  355. })
  356. it('rejects generated Chinese content without its English backlink', () => {
  357. const fixture = createFixture(false)
  358. const zh = generatedBaseZh.replace('[English](module-graph.md) | 中文\n\n', '')
  359. const ancestor = record(fixture.root, 'docs/module-graph.md', generatedBaseSource, zh)
  360. const current = record(fixture.root, 'docs/module-graph.md', generatedBaseSource, zh)
  361. const other = record(fixture.root, 'docs/module-graph.md', generatedBaseSource, zh)
  362. expect(() => mergeTranslationPairingRecords(
  363. fixture.root,
  364. 'docs/module-graph.i18n.yaml',
  365. ancestor,
  366. current,
  367. other,
  368. )).toThrow(
  369. 'docs/module-graph.zh.md clean merge lost its language-switcher link to module-graph.md',
  370. )
  371. })
  372. it('leaves owner-content conflicts for a human', () => {
  373. const fixture = createFixture(false)
  374. const ancestor = record(fixture.root, 'docs/guide.md', baseSource, baseZh)
  375. const current = record(
  376. fixture.root,
  377. 'docs/guide.md',
  378. baseSource.replace('Alpha base.', 'Alpha current.'),
  379. baseZh.replace('甲基础。', '甲当前。'),
  380. )
  381. const other = record(
  382. fixture.root,
  383. 'docs/guide.md',
  384. baseSource.replace('Alpha base.', 'Alpha other.'),
  385. baseZh.replace('甲基础。', '甲对侧。'),
  386. )
  387. expect(() => mergeTranslationPairingRecords(
  388. fixture.root,
  389. 'docs/guide.i18n.yaml',
  390. ancestor,
  391. current,
  392. other,
  393. )).toThrow('docs/guide.md has content conflicts')
  394. })
  395. it('rejects structurally divergent clean owner merges', () => {
  396. const fixture = createFixture(false)
  397. const ancestor = record(fixture.root, 'docs/guide.md', baseSource, baseZh)
  398. const current = record(fixture.root, 'docs/guide.md', currentSource, currentZh)
  399. const other = record(
  400. fixture.root,
  401. 'docs/guide.md',
  402. `${otherSource}\n## Extra\n`,
  403. otherZh,
  404. )
  405. expect(() => mergeTranslationPairingRecords(
  406. fixture.root,
  407. 'docs/guide.i18n.yaml',
  408. ancestor,
  409. current,
  410. other,
  411. )).toThrow('clean merges diverge structurally')
  412. })
  413. it('refuses owners assigned to another merge strategy', () => {
  414. const fixture = createFixture(false)
  415. write(fixture.root, '.gitattributes', 'docs/*.md merge=custom-owner\n')
  416. const records = createDivergedPair(fixture)
  417. expect(() => mergeTranslationPairingRecords(
  418. fixture.root,
  419. 'docs/guide.i18n.yaml',
  420. records.ancestor,
  421. records.current,
  422. records.other,
  423. )).toThrow('docs/guide.md uses merge=custom-owner')
  424. })
  425. it('refuses unspecified owners affected by merge.default', () => {
  426. const fixture = createFixture(false)
  427. git(fixture, ['config', 'merge.default', 'custom-owner'])
  428. const records = createDivergedPair(fixture)
  429. expect(() => mergeTranslationPairingRecords(
  430. fixture.root,
  431. 'docs/guide.i18n.yaml',
  432. records.ancestor,
  433. records.current,
  434. records.other,
  435. )).toThrow('merge.default=custom-owner')
  436. })
  437. it('runs as Git\'s custom driver and commits a clean composed record', () => {
  438. const fixture = createFixture()
  439. createDivergedPair(fixture)
  440. installFixtureRuntime(fixture.root)
  441. git(fixture, [
  442. 'config',
  443. 'merge.dsh-translation-pairing.driver',
  444. 'scripts/merge-translation-pairing-driver.sh %O %A %B %P',
  445. ])
  446. git(fixture, ['merge', '--no-edit', 'master'])
  447. expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('')
  448. expectMergedPair(fixture)
  449. })
  450. it('sees a paired link target added by the other branch', () => {
  451. const fixture = createFixture()
  452. commitPair(fixture, baseSource, baseZh, 'base')
  453. git(fixture, ['switch', '-c', 'current'])
  454. commitPair(fixture, currentSource, currentZh, 'current guide')
  455. git(fixture, ['switch', 'master'])
  456. record(
  457. fixture.root,
  458. 'docs/guide.md',
  459. baseSource.replace('Beta base.', '[Reference](reference.md#overview)'),
  460. baseZh.replace('乙基础。', '[参考](reference.zh.md#overview)'),
  461. )
  462. record(
  463. fixture.root,
  464. 'docs/reference.md',
  465. '# Reference\n\nEnglish | [中文](reference.zh.md)\n\nOverview.\n',
  466. '# 参考\n\n[English](reference.md) | 中文\n\n概览。\n',
  467. )
  468. git(fixture, ['add', '.'])
  469. git(fixture, ['commit', '-m', 'other guide and target'])
  470. git(fixture, ['switch', 'current'])
  471. installFixtureRuntime(fixture.root)
  472. git(fixture, [
  473. 'config',
  474. 'merge.dsh-translation-pairing.driver',
  475. 'scripts/merge-translation-pairing-driver.sh %O %A %B %P',
  476. ])
  477. const merge = spawnSync('git', ['-C', fixture.root, 'merge', '--no-edit', 'master'], {
  478. encoding: 'utf8',
  479. env: fixture.env,
  480. })
  481. expect(merge.status, merge.stderr).toBe(0)
  482. expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('')
  483. expect(readFileSync(join(fixture.root, 'docs/guide.md'), 'utf8')).toContain(
  484. '[Reference](reference.md#overview)',
  485. )
  486. expect(readFileSync(join(fixture.root, 'docs/guide.zh.md'), 'utf8')).toContain(
  487. '[参考](reference.zh.md#overview)',
  488. )
  489. })
  490. it('leaves an ordinary recoverable conflict when the configured runtime is unavailable', () => {
  491. const fixture = createFixture()
  492. const records = createDivergedPair(fixture)
  493. const headBefore = git(fixture, ['rev-parse', 'HEAD'])
  494. const result = startMergeWithFakeNode(fixture)
  495. expect(result.status).toBe(1)
  496. expect(result.stderr).toContain('runtime is unavailable; leaving an ordinary text conflict')
  497. expect(git(fixture, ['rev-parse', 'HEAD'])).toBe(headBefore)
  498. expect(git(fixture, ['rev-parse', '--verify', 'MERGE_HEAD'])).not.toBe('')
  499. expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
  500. expect(git(fixture, ['ls-files', '--unmerged', '--', 'docs/guide.i18n.yaml']).split('\n')).toHaveLength(3)
  501. const conflicted = readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')
  502. expect(conflicted).toContain('<<<<<<< docs/guide.i18n.yaml:current')
  503. for (const record of [records.current, records.other]) {
  504. const dataLines = record.split('\n').filter(line => line !== '' && !line.startsWith('#')).join('\n')
  505. expect(conflicted).toContain(dataLines)
  506. }
  507. expect(resolveTranslationPairingConflicts(fixture.root)).toEqual(['docs/guide.i18n.yaml'])
  508. expectMergedPair(fixture)
  509. })
  510. it('falls back before a broken driver entrypoint can replace the launcher', () => {
  511. const fixture = createFixture()
  512. createDivergedPair(fixture)
  513. const result = startMergeWithFakeNode(
  514. fixture,
  515. '#!/bin/sh\nif [ "$3" = "--eval" ]; then exit 0; fi\nexit 72\n',
  516. )
  517. expect(result.status).toBe(1)
  518. expect(result.stderr).toContain('runtime is unavailable; leaving an ordinary text conflict')
  519. expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toContain(
  520. '<<<<<<< docs/guide.i18n.yaml:current',
  521. )
  522. })
  523. it('keeps a clean text fallback unresolved until the explicit resolver confirms it', () => {
  524. const fixture = createFixture()
  525. createTextCleanDivergedPair(fixture)
  526. const result = startMergeWithFakeNode(fixture)
  527. expect(result.status).toBe(1)
  528. expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
  529. const canonicalRecord = renderTranslationPairingRecord(translationPairPaths('docs/guide.md'), {
  530. sourceHash: gitBlobHash(Buffer.from(currentSource)),
  531. zhHash: gitBlobHash(Buffer.from(otherZh)),
  532. })
  533. expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toBe(
  534. canonicalRecord.replace(
  535. '\nguide.zh.md:',
  536. '\n# Stable separator for independent line merges.\nguide.zh.md:',
  537. ),
  538. )
  539. expect(resolveTranslationPairingConflicts(fixture.root)).toEqual(['docs/guide.i18n.yaml'])
  540. expect(readFileSync(join(fixture.root, 'docs/guide.md'), 'utf8')).toBe(currentSource)
  541. expect(readFileSync(join(fixture.root, 'docs/guide.zh.md'), 'utf8')).toBe(otherZh)
  542. expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toBe(canonicalRecord)
  543. })
  544. it('leaves a staged merge when the pre-merge-commit hook rejects it', () => {
  545. const fixture = createFixture()
  546. createDivergedPair(fixture)
  547. installFixtureRuntime(fixture.root)
  548. git(fixture, [
  549. 'config',
  550. 'merge.dsh-translation-pairing.driver',
  551. 'scripts/merge-translation-pairing-driver.sh %O %A %B %P',
  552. ])
  553. const hooks = join(fixture.root, 'hooks')
  554. write(
  555. fixture.root,
  556. 'hooks/pre-merge-commit',
  557. '#!/bin/sh\necho "fixture pre-merge-commit rejection" >&2\nexit 77\n',
  558. )
  559. chmodSync(join(hooks, 'pre-merge-commit'), 0o755)
  560. git(fixture, ['config', 'core.hooksPath', hooks])
  561. const headBefore = git(fixture, ['rev-parse', 'HEAD'])
  562. const result = spawnSync('git', ['-C', fixture.root, 'merge', '--no-edit', 'master'], {
  563. encoding: 'utf8',
  564. env: fixture.env,
  565. })
  566. expect(result.status).toBe(1)
  567. expect(result.stderr).toContain('fixture pre-merge-commit rejection')
  568. expect(git(fixture, ['rev-parse', 'HEAD'])).toBe(headBefore)
  569. expect(git(fixture, ['rev-parse', '--verify', 'MERGE_HEAD'])).not.toBe('')
  570. expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('')
  571. expect(git(fixture, ['diff', '--cached', '--name-only']).split('\n')).toContain(
  572. 'docs/guide.i18n.yaml',
  573. )
  574. expectMergedPair(fixture)
  575. })
  576. it('prints the recovery path when driver input is not composable', () => {
  577. const fixture = createFixture(false)
  578. const result = spawnSync(process.execPath, ['--import', tsxLoader, driver], {
  579. cwd: fixture.root,
  580. encoding: 'utf8',
  581. env: fixture.env,
  582. })
  583. expect(result.status).toBe(1)
  584. expect(result.stderr).toContain('pnpm run verify-translation-pairing --write <pair>')
  585. expect(result.stderr).toContain('pnpm run resolve-translation-pairing-conflicts')
  586. })
  587. it('resolves an already-stopped generated-only conflict from index stages', () => {
  588. const fixture = createFixture(false)
  589. startStoppedPairingMerge(fixture)
  590. expect(resolveTranslationPairingConflicts(fixture.root)).toEqual(['docs/guide.i18n.yaml'])
  591. expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('')
  592. expectMergedPair(fixture)
  593. })
  594. it('refuses to confirm unstaged owner bytes after a stopped merge', () => {
  595. const fixture = createFixture(false)
  596. startStoppedPairingMerge(fixture)
  597. write(fixture.root, 'docs/guide.md', `${mergedSource}\nunstaged\n`)
  598. expect(() => resolveTranslationPairingConflicts(fixture.root)).toThrow(
  599. 'docs/guide.md has unstaged content',
  600. )
  601. expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
  602. })
  603. it('refuses to overwrite an edited sidecar after a stopped merge', () => {
  604. const fixture = createFixture(false)
  605. startStoppedPairingMerge(fixture)
  606. write(fixture.root, 'docs/guide.i18n.yaml', 'manually resolved\n')
  607. expect(() => resolveTranslationPairingConflicts(fixture.root)).toThrow(
  608. 'docs/guide.i18n.yaml has edited conflict content',
  609. )
  610. expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toBe('manually resolved\n')
  611. expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
  612. })
  613. it('resolves safe records while leaving an owner-conflicted pair untouched', () => {
  614. const fixture = createFixture(false)
  615. startMixedPairingMerge(fixture)
  616. expect(() => resolveTranslationPairingConflicts(fixture.root)).toThrow(
  617. 'docs/manual.i18n.yaml: docs/manual.md has content conflicts',
  618. )
  619. expect(git(fixture, ['diff', '--name-only', '--diff-filter=U']).split('\n')).toEqual([
  620. 'docs/manual.i18n.yaml',
  621. 'docs/manual.md',
  622. 'docs/manual.zh.md',
  623. ])
  624. expectMergedPair(fixture)
  625. })
  626. })