|
|
@@ -0,0 +1,508 @@
|
|
|
+/** Integration coverage for automatic and explicit pairing-record conflict resolution. */
|
|
|
+
|
|
|
+import { execFileSync, spawnSync } from 'node:child_process'
|
|
|
+import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
|
|
+import { tmpdir } from 'node:os'
|
|
|
+import { delimiter, 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 driverLauncher = fileURLToPath(new URL('./merge-translation-pairing-driver.sh', import.meta.url))
|
|
|
+const workspaceRoot = fileURLToPath(new URL('../', 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 installFixtureRuntime(root: string): void {
|
|
|
+ const linkType = process.platform === 'win32' ? 'junction' : 'dir'
|
|
|
+ symlinkSync(
|
|
|
+ join(workspaceRoot, 'node_modules'),
|
|
|
+ join(root, 'node_modules'),
|
|
|
+ linkType,
|
|
|
+ )
|
|
|
+ symlinkSync(join(workspaceRoot, 'scripts'), join(root, 'scripts'), linkType)
|
|
|
+}
|
|
|
+
|
|
|
+function startMergeWithFakeNode(
|
|
|
+ fixture: Fixture,
|
|
|
+ nodeScript = '#!/bin/sh\nexit 72\n',
|
|
|
+) {
|
|
|
+ const fakeBin = join(fixture.root, 'fake-bin')
|
|
|
+ const fakeNode = join(fakeBin, 'node')
|
|
|
+ write(fixture.root, 'fake-bin/node', nodeScript)
|
|
|
+ chmodSync(fakeNode, 0o755)
|
|
|
+ git(fixture, [
|
|
|
+ 'config',
|
|
|
+ 'merge.dsh-translation-pairing.driver',
|
|
|
+ `${shellQuote(driverLauncher)} %O %A %B %P`,
|
|
|
+ ])
|
|
|
+ return spawnSync('git', ['-C', fixture.root, 'merge', '--no-commit', 'master'], {
|
|
|
+ encoding: 'utf8',
|
|
|
+ env: {
|
|
|
+ ...fixture.env,
|
|
|
+ PATH: `${fakeBin}${delimiter}${fixture.env.PATH ?? ''}`,
|
|
|
+ },
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+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('乙基础。', '乙对侧。')
|
|
|
+const manualBaseSource = baseSource.replace('guide.zh.md', 'manual.zh.md')
|
|
|
+const manualBaseZh = baseZh.replace('guide.md', 'manual.md')
|
|
|
+const manualCurrentSource = manualBaseSource.replace('Alpha base.', 'Alpha current.')
|
|
|
+const manualCurrentZh = manualBaseZh.replace('甲基础。', '甲当前。')
|
|
|
+const manualOtherSource = manualBaseSource.replace('Alpha base.', 'Alpha other.')
|
|
|
+const manualOtherZh = manualBaseZh.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 commitTextCleanPair(fixture: Fixture, source: string, zh: string, message: string): void {
|
|
|
+ const sidecar = record(fixture.root, 'docs/guide.md', source, zh)
|
|
|
+ write(
|
|
|
+ fixture.root,
|
|
|
+ 'docs/guide.i18n.yaml',
|
|
|
+ sidecar.replace('\nguide.zh.md:', '\n# Stable separator for independent line merges.\nguide.zh.md:'),
|
|
|
+ )
|
|
|
+ git(fixture, ['add', '.'])
|
|
|
+ git(fixture, ['commit', '-m', message])
|
|
|
+}
|
|
|
+
|
|
|
+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 createTextCleanDivergedPair(fixture: Fixture): void {
|
|
|
+ commitTextCleanPair(fixture, baseSource, baseZh, 'base')
|
|
|
+ git(fixture, ['switch', '-c', 'current'])
|
|
|
+ commitTextCleanPair(fixture, currentSource, baseZh, 'current source')
|
|
|
+ git(fixture, ['switch', 'master'])
|
|
|
+ commitTextCleanPair(fixture, baseSource, otherZh, 'other translation')
|
|
|
+ git(fixture, ['switch', 'current'])
|
|
|
+}
|
|
|
+
|
|
|
+function startStoppedPairingMerge(fixture: Fixture): void {
|
|
|
+ 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')
|
|
|
+}
|
|
|
+
|
|
|
+function commitMixedPairs(
|
|
|
+ fixture: Fixture,
|
|
|
+ guide: { source: string; zh: string },
|
|
|
+ manual: { source: string; zh: string },
|
|
|
+ message: string,
|
|
|
+): void {
|
|
|
+ record(fixture.root, 'docs/guide.md', guide.source, guide.zh)
|
|
|
+ record(fixture.root, 'docs/manual.md', manual.source, manual.zh)
|
|
|
+ git(fixture, ['add', '.'])
|
|
|
+ git(fixture, ['commit', '-m', message])
|
|
|
+}
|
|
|
+
|
|
|
+function startMixedPairingMerge(fixture: Fixture): void {
|
|
|
+ commitMixedPairs(
|
|
|
+ fixture,
|
|
|
+ { source: baseSource, zh: baseZh },
|
|
|
+ { source: manualBaseSource, zh: manualBaseZh },
|
|
|
+ 'base',
|
|
|
+ )
|
|
|
+ git(fixture, ['switch', '-c', 'current'])
|
|
|
+ commitMixedPairs(
|
|
|
+ fixture,
|
|
|
+ { source: currentSource, zh: currentZh },
|
|
|
+ { source: manualCurrentSource, zh: manualCurrentZh },
|
|
|
+ 'current',
|
|
|
+ )
|
|
|
+ git(fixture, ['switch', 'master'])
|
|
|
+ commitMixedPairs(
|
|
|
+ fixture,
|
|
|
+ { source: otherSource, zh: otherZh },
|
|
|
+ { source: manualOtherSource, zh: manualOtherZh },
|
|
|
+ 'other',
|
|
|
+ )
|
|
|
+ git(fixture, ['switch', 'current'])
|
|
|
+ const merge = spawnSync('git', ['-C', fixture.root, 'merge', '--no-commit', 'master'], {
|
|
|
+ encoding: 'utf8',
|
|
|
+ env: fixture.env,
|
|
|
+ })
|
|
|
+ expect(merge.status).toBe(1)
|
|
|
+}
|
|
|
+
|
|
|
+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)
|
|
|
+ git(fixture, ['config', 'merge.default', 'text'])
|
|
|
+ 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('refuses unspecified owners affected by merge.default', () => {
|
|
|
+ const fixture = createFixture(false)
|
|
|
+ git(fixture, ['config', 'merge.default', 'custom-owner'])
|
|
|
+ const records = createDivergedPair(fixture)
|
|
|
+
|
|
|
+ expect(() => mergeTranslationPairingRecords(
|
|
|
+ fixture.root,
|
|
|
+ 'docs/guide.i18n.yaml',
|
|
|
+ records.ancestor,
|
|
|
+ records.current,
|
|
|
+ records.other,
|
|
|
+ )).toThrow('merge.default=custom-owner')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('runs as Git\'s custom driver and commits a clean composed record', () => {
|
|
|
+ const fixture = createFixture()
|
|
|
+ createDivergedPair(fixture)
|
|
|
+ installFixtureRuntime(fixture.root)
|
|
|
+ git(fixture, [
|
|
|
+ 'config',
|
|
|
+ 'merge.dsh-translation-pairing.driver',
|
|
|
+ 'scripts/merge-translation-pairing-driver.sh %O %A %B %P',
|
|
|
+ ])
|
|
|
+
|
|
|
+ git(fixture, ['merge', '--no-edit', 'master'])
|
|
|
+
|
|
|
+ expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('')
|
|
|
+ expectMergedPair(fixture)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('leaves an ordinary recoverable conflict when the configured runtime is unavailable', () => {
|
|
|
+ const fixture = createFixture()
|
|
|
+ const records = createDivergedPair(fixture)
|
|
|
+ const headBefore = git(fixture, ['rev-parse', 'HEAD'])
|
|
|
+
|
|
|
+ const result = startMergeWithFakeNode(fixture)
|
|
|
+
|
|
|
+ expect(result.status).toBe(1)
|
|
|
+ expect(result.stderr).toContain('runtime is unavailable; leaving an ordinary text conflict')
|
|
|
+ expect(git(fixture, ['rev-parse', 'HEAD'])).toBe(headBefore)
|
|
|
+ expect(git(fixture, ['rev-parse', '--verify', 'MERGE_HEAD'])).not.toBe('')
|
|
|
+ expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
|
|
|
+ expect(git(fixture, ['ls-files', '--unmerged', '--', 'docs/guide.i18n.yaml']).split('\n')).toHaveLength(3)
|
|
|
+ const conflicted = readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')
|
|
|
+ expect(conflicted).toContain('<<<<<<< docs/guide.i18n.yaml:current')
|
|
|
+ for (const record of [records.current, records.other]) {
|
|
|
+ const dataLines = record.split('\n').filter(line => line !== '' && !line.startsWith('#')).join('\n')
|
|
|
+ expect(conflicted).toContain(dataLines)
|
|
|
+ }
|
|
|
+
|
|
|
+ expect(resolveTranslationPairingConflicts(fixture.root)).toEqual(['docs/guide.i18n.yaml'])
|
|
|
+ expectMergedPair(fixture)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('falls back before a broken driver entrypoint can replace the launcher', () => {
|
|
|
+ const fixture = createFixture()
|
|
|
+ createDivergedPair(fixture)
|
|
|
+ const result = startMergeWithFakeNode(
|
|
|
+ fixture,
|
|
|
+ '#!/bin/sh\nif [ "$3" = "--eval" ]; then exit 0; fi\nexit 72\n',
|
|
|
+ )
|
|
|
+
|
|
|
+ expect(result.status).toBe(1)
|
|
|
+ expect(result.stderr).toContain('runtime is unavailable; leaving an ordinary text conflict')
|
|
|
+ expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toContain(
|
|
|
+ '<<<<<<< docs/guide.i18n.yaml:current',
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps a clean text fallback unresolved until the explicit resolver confirms it', () => {
|
|
|
+ const fixture = createFixture()
|
|
|
+ createTextCleanDivergedPair(fixture)
|
|
|
+ const result = startMergeWithFakeNode(fixture)
|
|
|
+
|
|
|
+ expect(result.status).toBe(1)
|
|
|
+ expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
|
|
|
+ const canonicalRecord = renderTranslationPairingRecord(translationPairPaths('docs/guide.md'), {
|
|
|
+ sourceHash: gitBlobHash(Buffer.from(currentSource)),
|
|
|
+ zhHash: gitBlobHash(Buffer.from(otherZh)),
|
|
|
+ })
|
|
|
+ expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toBe(
|
|
|
+ canonicalRecord.replace(
|
|
|
+ '\nguide.zh.md:',
|
|
|
+ '\n# Stable separator for independent line merges.\nguide.zh.md:',
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ expect(resolveTranslationPairingConflicts(fixture.root)).toEqual(['docs/guide.i18n.yaml'])
|
|
|
+ expect(readFileSync(join(fixture.root, 'docs/guide.md'), 'utf8')).toBe(currentSource)
|
|
|
+ expect(readFileSync(join(fixture.root, 'docs/guide.zh.md'), 'utf8')).toBe(otherZh)
|
|
|
+ expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toBe(canonicalRecord)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('leaves a staged merge when the pre-merge-commit hook rejects it', () => {
|
|
|
+ const fixture = createFixture()
|
|
|
+ createDivergedPair(fixture)
|
|
|
+ installFixtureRuntime(fixture.root)
|
|
|
+ git(fixture, [
|
|
|
+ 'config',
|
|
|
+ 'merge.dsh-translation-pairing.driver',
|
|
|
+ 'scripts/merge-translation-pairing-driver.sh %O %A %B %P',
|
|
|
+ ])
|
|
|
+ const hooks = join(fixture.root, 'hooks')
|
|
|
+ write(
|
|
|
+ fixture.root,
|
|
|
+ 'hooks/pre-merge-commit',
|
|
|
+ '#!/bin/sh\necho "fixture pre-merge-commit rejection" >&2\nexit 77\n',
|
|
|
+ )
|
|
|
+ chmodSync(join(hooks, 'pre-merge-commit'), 0o755)
|
|
|
+ git(fixture, ['config', 'core.hooksPath', hooks])
|
|
|
+ const headBefore = git(fixture, ['rev-parse', 'HEAD'])
|
|
|
+
|
|
|
+ const result = spawnSync('git', ['-C', fixture.root, 'merge', '--no-edit', 'master'], {
|
|
|
+ encoding: 'utf8',
|
|
|
+ env: fixture.env,
|
|
|
+ })
|
|
|
+
|
|
|
+ expect(result.status).toBe(1)
|
|
|
+ expect(result.stderr).toContain('fixture pre-merge-commit rejection')
|
|
|
+ expect(git(fixture, ['rev-parse', 'HEAD'])).toBe(headBefore)
|
|
|
+ expect(git(fixture, ['rev-parse', '--verify', 'MERGE_HEAD'])).not.toBe('')
|
|
|
+ expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('')
|
|
|
+ expect(git(fixture, ['diff', '--cached', '--name-only']).split('\n')).toContain(
|
|
|
+ 'docs/guide.i18n.yaml',
|
|
|
+ )
|
|
|
+ expectMergedPair(fixture)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('prints the recovery path when driver input is not composable', () => {
|
|
|
+ const fixture = createFixture(false)
|
|
|
+ const result = spawnSync(process.execPath, ['--import', tsxLoader, driver], {
|
|
|
+ cwd: fixture.root,
|
|
|
+ encoding: 'utf8',
|
|
|
+ env: fixture.env,
|
|
|
+ })
|
|
|
+
|
|
|
+ expect(result.status).toBe(1)
|
|
|
+ expect(result.stderr).toContain('pnpm run verify-translation-pairing --write <pair>')
|
|
|
+ expect(result.stderr).toContain('pnpm run resolve-translation-pairing-conflicts')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('resolves an already-stopped generated-only conflict from index stages', () => {
|
|
|
+ const fixture = createFixture(false)
|
|
|
+ startStoppedPairingMerge(fixture)
|
|
|
+
|
|
|
+ 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)
|
|
|
+ startStoppedPairingMerge(fixture)
|
|
|
+ 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')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('refuses to overwrite an edited sidecar after a stopped merge', () => {
|
|
|
+ const fixture = createFixture(false)
|
|
|
+ startStoppedPairingMerge(fixture)
|
|
|
+ write(fixture.root, 'docs/guide.i18n.yaml', 'manually resolved\n')
|
|
|
+
|
|
|
+ expect(() => resolveTranslationPairingConflicts(fixture.root)).toThrow(
|
|
|
+ 'docs/guide.i18n.yaml has edited conflict content',
|
|
|
+ )
|
|
|
+ expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toBe('manually resolved\n')
|
|
|
+ expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('resolves safe records while leaving an owner-conflicted pair untouched', () => {
|
|
|
+ const fixture = createFixture(false)
|
|
|
+ startMixedPairingMerge(fixture)
|
|
|
+
|
|
|
+ expect(() => resolveTranslationPairingConflicts(fixture.root)).toThrow(
|
|
|
+ 'docs/manual.i18n.yaml: docs/manual.md has content conflicts',
|
|
|
+ )
|
|
|
+
|
|
|
+ expect(git(fixture, ['diff', '--name-only', '--diff-filter=U']).split('\n')).toEqual([
|
|
|
+ 'docs/manual.i18n.yaml',
|
|
|
+ 'docs/manual.md',
|
|
|
+ 'docs/manual.zh.md',
|
|
|
+ ])
|
|
|
+ expectMergedPair(fixture)
|
|
|
+ })
|
|
|
+})
|