256 lines
8.9 KiB
TypeScript
256 lines
8.9 KiB
TypeScript
/** Integration coverage for automatic and explicit pairing-record conflict resolution. */
|
|
|
|
import { execFileSync, spawnSync } from 'node:child_process'
|
|
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { dirname, join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
import { gitBlobHash, storeGitBlob } from './translation-pairing-git.ts'
|
|
import {
|
|
mergeTranslationPairingRecords,
|
|
resolveTranslationPairingConflicts,
|
|
} from './translation-pairing-merge.ts'
|
|
import {
|
|
renderTranslationPairingRecord,
|
|
translationPairPaths,
|
|
} from './translation-pairing-record.ts'
|
|
|
|
const driver = fileURLToPath(new URL('./merge-translation-pairing.ts', import.meta.url))
|
|
const tsxLoader = fileURLToPath(import.meta.resolve('tsx/esm'))
|
|
const fixtures: string[] = []
|
|
|
|
interface Fixture {
|
|
env: NodeJS.ProcessEnv
|
|
root: string
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const fixture of fixtures.splice(0)) rmSync(fixture, { recursive: true, force: true })
|
|
})
|
|
|
|
function git(fixture: Fixture, args: string[]): string {
|
|
return execFileSync('git', ['-C', fixture.root, ...args], {
|
|
encoding: 'utf8',
|
|
env: fixture.env,
|
|
}).trim()
|
|
}
|
|
|
|
function write(root: string, path: string, content: string): void {
|
|
const absolute = join(root, path)
|
|
mkdirSync(dirname(absolute), { recursive: true })
|
|
writeFileSync(absolute, content)
|
|
}
|
|
|
|
function shellQuote(value: string): string {
|
|
return `"${value.replace(/["\\$`]/g, '\\$&')}"`
|
|
}
|
|
|
|
function createFixture(attributes = true): Fixture {
|
|
const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-merge-'))
|
|
fixtures.push(root)
|
|
const env: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
GIT_AUTHOR_EMAIL: 'pairing@example.test',
|
|
GIT_AUTHOR_NAME: 'Pairing Test',
|
|
GIT_COMMITTER_EMAIL: 'pairing@example.test',
|
|
GIT_COMMITTER_NAME: 'Pairing Test',
|
|
GIT_CONFIG_GLOBAL: join(root, 'global.gitconfig'),
|
|
GIT_CONFIG_NOSYSTEM: '1',
|
|
GIT_DEFAULT_HASH: 'sha1',
|
|
}
|
|
const fixture = { env, root }
|
|
execFileSync('git', ['init', '--quiet', '--initial-branch=master', root], { env })
|
|
if (attributes) write(root, '.gitattributes', '*.i18n.yaml merge=dsh-translation-pairing\n')
|
|
return fixture
|
|
}
|
|
|
|
function record(root: string, path: string, source: string, zh: string): string {
|
|
const paths = translationPairPaths(path)
|
|
write(root, paths.source, source)
|
|
write(root, paths.zh, zh)
|
|
const content = renderTranslationPairingRecord(paths, {
|
|
sourceHash: storeGitBlob(root, Buffer.from(source)),
|
|
zhHash: storeGitBlob(root, Buffer.from(zh)),
|
|
})
|
|
write(root, paths.meta, content)
|
|
return content
|
|
}
|
|
|
|
const baseSource = '# Guide\n\nEnglish | [中文](guide.zh.md)\n\nAlpha base.\n\nBeta base.\n'
|
|
const baseZh = '# 指南\n\n[English](guide.md) | 中文\n\n甲基础。\n\n乙基础。\n'
|
|
const currentSource = baseSource.replace('Alpha base.', 'Alpha current.')
|
|
const currentZh = baseZh.replace('甲基础。', '甲当前。')
|
|
const otherSource = baseSource.replace('Beta base.', 'Beta other.')
|
|
const otherZh = baseZh.replace('乙基础。', '乙对侧。')
|
|
const mergedSource = currentSource.replace('Beta base.', 'Beta other.')
|
|
const mergedZh = currentZh.replace('乙基础。', '乙对侧。')
|
|
|
|
function commitPair(fixture: Fixture, source: string, zh: string, message: string): string {
|
|
const sidecar = record(fixture.root, 'docs/guide.md', source, zh)
|
|
git(fixture, ['add', '.'])
|
|
git(fixture, ['commit', '-m', message])
|
|
return sidecar
|
|
}
|
|
|
|
function createDivergedPair(fixture: Fixture): { ancestor: string; current: string; other: string } {
|
|
const ancestor = commitPair(fixture, baseSource, baseZh, 'base')
|
|
git(fixture, ['switch', '-c', 'current'])
|
|
const current = commitPair(fixture, currentSource, currentZh, 'current')
|
|
git(fixture, ['switch', 'master'])
|
|
const other = commitPair(fixture, otherSource, otherZh, 'other')
|
|
git(fixture, ['switch', 'current'])
|
|
return { ancestor, current, other }
|
|
}
|
|
|
|
function expectMergedPair(fixture: Fixture): void {
|
|
expect(readFileSync(join(fixture.root, 'docs/guide.md'), 'utf8')).toBe(mergedSource)
|
|
expect(readFileSync(join(fixture.root, 'docs/guide.zh.md'), 'utf8')).toBe(mergedZh)
|
|
expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toBe(
|
|
renderTranslationPairingRecord(translationPairPaths('docs/guide.md'), {
|
|
sourceHash: gitBlobHash(Buffer.from(mergedSource)),
|
|
zhHash: gitBlobHash(Buffer.from(mergedZh)),
|
|
}),
|
|
)
|
|
}
|
|
|
|
describe('translation pairing merge composition', () => {
|
|
it('rejects a pairing-record path outside the repository', () => {
|
|
const fixture = createFixture(false)
|
|
|
|
expect(() => mergeTranslationPairingRecords(
|
|
fixture.root,
|
|
'../guide.i18n.yaml',
|
|
'',
|
|
'',
|
|
'',
|
|
)).toThrow('pairing record escapes the repository')
|
|
})
|
|
|
|
it('merges the owner blobs named by three valid records', () => {
|
|
const fixture = createFixture(false)
|
|
const records = createDivergedPair(fixture)
|
|
|
|
const result = mergeTranslationPairingRecords(
|
|
fixture.root,
|
|
'docs/guide.i18n.yaml',
|
|
records.ancestor,
|
|
records.current,
|
|
records.other,
|
|
)
|
|
|
|
expect(result.sourceContent.toString('utf8')).toBe(mergedSource)
|
|
expect(result.zhContent.toString('utf8')).toBe(mergedZh)
|
|
expect(result.sourceHash).toBe(gitBlobHash(Buffer.from(mergedSource)))
|
|
expect(result.zhHash).toBe(gitBlobHash(Buffer.from(mergedZh)))
|
|
})
|
|
|
|
it('leaves owner-content conflicts for a human', () => {
|
|
const fixture = createFixture(false)
|
|
const ancestor = record(fixture.root, 'docs/guide.md', baseSource, baseZh)
|
|
const current = record(
|
|
fixture.root,
|
|
'docs/guide.md',
|
|
baseSource.replace('Alpha base.', 'Alpha current.'),
|
|
baseZh.replace('甲基础。', '甲当前。'),
|
|
)
|
|
const other = record(
|
|
fixture.root,
|
|
'docs/guide.md',
|
|
baseSource.replace('Alpha base.', 'Alpha other.'),
|
|
baseZh.replace('甲基础。', '甲对侧。'),
|
|
)
|
|
|
|
expect(() => mergeTranslationPairingRecords(
|
|
fixture.root,
|
|
'docs/guide.i18n.yaml',
|
|
ancestor,
|
|
current,
|
|
other,
|
|
)).toThrow('docs/guide.md has content conflicts')
|
|
})
|
|
|
|
it('rejects structurally divergent clean owner merges', () => {
|
|
const fixture = createFixture(false)
|
|
const ancestor = record(fixture.root, 'docs/guide.md', baseSource, baseZh)
|
|
const current = record(fixture.root, 'docs/guide.md', currentSource, currentZh)
|
|
const other = record(
|
|
fixture.root,
|
|
'docs/guide.md',
|
|
`${otherSource}\n## Extra\n`,
|
|
otherZh,
|
|
)
|
|
|
|
expect(() => mergeTranslationPairingRecords(
|
|
fixture.root,
|
|
'docs/guide.i18n.yaml',
|
|
ancestor,
|
|
current,
|
|
other,
|
|
)).toThrow('clean merges diverge structurally')
|
|
})
|
|
|
|
it('refuses owners assigned to another merge strategy', () => {
|
|
const fixture = createFixture(false)
|
|
write(fixture.root, '.gitattributes', 'docs/*.md merge=custom-owner\n')
|
|
const records = createDivergedPair(fixture)
|
|
|
|
expect(() => mergeTranslationPairingRecords(
|
|
fixture.root,
|
|
'docs/guide.i18n.yaml',
|
|
records.ancestor,
|
|
records.current,
|
|
records.other,
|
|
)).toThrow('docs/guide.md uses merge=custom-owner')
|
|
})
|
|
|
|
it('runs as Git\'s custom driver and commits a clean composed record', () => {
|
|
const fixture = createFixture()
|
|
createDivergedPair(fixture)
|
|
const command = [
|
|
shellQuote(process.execPath),
|
|
'--import', shellQuote(tsxLoader),
|
|
shellQuote(driver),
|
|
'%O', '%A', '%B', '%P',
|
|
].join(' ')
|
|
git(fixture, ['config', 'merge.dsh-translation-pairing.driver', command])
|
|
|
|
git(fixture, ['merge', '--no-edit', 'master'])
|
|
|
|
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('')
|
|
expectMergedPair(fixture)
|
|
})
|
|
|
|
it('resolves an already-stopped generated-only conflict from index stages', () => {
|
|
const fixture = createFixture(false)
|
|
createDivergedPair(fixture)
|
|
const merge = spawnSync('git', ['-C', fixture.root, 'merge', '--no-commit', 'master'], {
|
|
encoding: 'utf8',
|
|
env: fixture.env,
|
|
})
|
|
expect(merge.status).toBe(1)
|
|
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
|
|
|
|
expect(resolveTranslationPairingConflicts(fixture.root)).toEqual(['docs/guide.i18n.yaml'])
|
|
|
|
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('')
|
|
expectMergedPair(fixture)
|
|
})
|
|
|
|
it('refuses to confirm unstaged owner bytes after a stopped merge', () => {
|
|
const fixture = createFixture(false)
|
|
createDivergedPair(fixture)
|
|
const merge = spawnSync('git', ['-C', fixture.root, 'merge', '--no-commit', 'master'], {
|
|
encoding: 'utf8',
|
|
env: fixture.env,
|
|
})
|
|
expect(merge.status).toBe(1)
|
|
write(fixture.root, 'docs/guide.md', `${mergedSource}\nunstaged\n`)
|
|
|
|
expect(() => resolveTranslationPairingConflicts(fixture.root)).toThrow(
|
|
'docs/guide.md has unstaged content',
|
|
)
|
|
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
|
|
})
|
|
})
|