fix(i18n): preserve merge conflicts without runtime

This commit is contained in:
Tianyi Cui
2026-08-09 00:37:46 +08:00
parent 9a4b70cf3b
commit 9c13a62702
12 changed files with 358 additions and 60 deletions

View File

@@ -31,9 +31,15 @@ const PAIRING_MERGE_DRIVER_CONFIG = [
['merge.dsh-translation-pairing.name', 'DeepSeek Harness bilingual pairing records'],
[
'merge.dsh-translation-pairing.driver',
'node --import tsx/esm scripts/merge-translation-pairing.ts %O %A %B %P',
'scripts/merge-translation-pairing-driver.sh %O %A %B %P',
],
]
const PAIRING_MERGE_DRIVER_PROBE = [
'--import',
'tsx/esm',
'scripts/merge-translation-pairing.ts',
'--probe',
]
function errorCode(error) {
return typeof error === 'object' && error !== null && 'code' in error
@@ -678,6 +684,10 @@ function installPairingMergeDriver(root, worktreeConfigPath) {
}
}
function probePairingMergeDriver(root) {
capture(process.execPath, PAIRING_MERGE_DRIVER_PROBE, { cwd: root })
}
async function main() {
if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') return
if (typeof lefthookPackage.bin?.lefthook !== 'string') return
@@ -767,6 +777,7 @@ async function main() {
let pathChanged = false
let rollbackPairingMergeDriver = () => {}
try {
probePairingMergeDriver(root)
rollbackPairingMergeDriver = installPairingMergeDriver(root, worktreeConfigPath)
git(['config', '--worktree', 'core.hooksPath', hooksPath], root)
pathChanged = worktreePath !== hooksPath

View File

@@ -18,7 +18,9 @@ import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
const installer = fileURLToPath(new URL('./install-lefthook.mjs', import.meta.url))
const pairingMergeDriver = 'node --import tsx/esm scripts/merge-translation-pairing.ts %O %A %B %P'
const pairingMergeDriver = 'scripts/merge-translation-pairing-driver.sh %O %A %B %P'
const scriptsDirectory = fileURLToPath(new URL('.', import.meta.url))
const tsxPackageDirectory = dirname(fileURLToPath(import.meta.resolve('tsx/package.json')))
const fixtures: string[] = []
// Multi-worktree cases spawn several Git and Node subprocesses; coverage concurrency can
// legitimately exceed Vitest's default deadline without changing the installer behavior.
@@ -123,6 +125,12 @@ function installFakeLefthook(root: string): void {
chmodSync(shim, 0o755)
}
function installPairingProbeFixture(root: string): void {
const linkType = process.platform === 'win32' ? 'junction' : 'dir'
symlinkSync(scriptsDirectory, join(root, 'scripts'), linkType)
symlinkSync(tsxPackageDirectory, join(root, 'node_modules/tsx'), linkType)
}
function createFixture(names: { main?: string; linked?: string } = {}): Fixture {
const container = mkdtempSync(join(tmpdir(), 'dsh-lefthook-'))
fixtures.push(container)
@@ -152,6 +160,8 @@ function createFixture(names: { main?: string; linked?: string } = {}): Fixture
write(join(linked, 'lefthook.yml'), 'linked-worktree-config\n')
installFakeLefthook(main)
installFakeLefthook(linked)
installPairingProbeFixture(main)
installPairingProbeFixture(linked)
return fixture
}
@@ -287,6 +297,7 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
git(fixture, fixture.main, ['worktree', 'add', '-b', 'late-linked', lateLinked])
write(join(lateLinked, 'lefthook.yml'), 'late-linked-worktree-config\n')
installFakeLefthook(lateLinked)
installPairingProbeFixture(lateLinked)
expect(git(fixture, lateLinked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(mainHooks)
const linkedInstall = await runInstaller(fixture, lateLinked)
@@ -791,6 +802,20 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy pre-push\n')
})
it('does not publish worktree integration when the pairing driver probe fails', async () => {
const fixture = createFixture()
rmSync(join(fixture.main, 'node_modules/tsx'), { recursive: true, force: true })
const result = await runInstaller(fixture, fixture.main)
expect(result.status).toBe(1)
expect(result.stderr).toContain('merge-translation-pairing.ts --probe failed')
expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
expect(gitResult(fixture, fixture.main, [
'config', '--get', 'merge.dsh-translation-pairing.driver',
]).status).toBe(1)
})
it('reports installation and hook-path rollback failures together', async () => {
const fixture = createFixture()

View File

@@ -0,0 +1,35 @@
#!/bin/sh
if [ "$#" -ne 4 ]; then
echo 'merge-translation-pairing: expected <ancestor> <current> <other> <repository-path>' >&2
exit 129
fi
ancestor_path=$1
current_path=$2
other_path=$3
meta_path=$4
driver_directory=$(CDPATH= cd -P "$(dirname "$0")" && pwd) || exit 129
driver_path=$driver_directory/merge-translation-pairing.ts
if command -v node >/dev/null 2>&1 \
&& node --import tsx/esm "$driver_path" --probe >/dev/null 2>&1; then
exec node --import tsx/esm "$driver_path" \
"$ancestor_path" "$current_path" "$other_path" "$meta_path"
fi
echo "merge-translation-pairing: runtime is unavailable; leaving an ordinary text conflict in $meta_path" >&2
git merge-file \
-L "$meta_path:current" \
-L "$meta_path:ancestor" \
-L "$meta_path:other" \
-- "$current_path" "$ancestor_path" "$other_path"
fallback_status=$?
echo 'merge-translation-pairing: restore Node dependencies, then rerun the merge or `pnpm run resolve-translation-pairing-conflicts`; use `git merge --abort` to cancel' >&2
# A clean text merge is still unverified pairing metadata, so the driver must
# leave Git's index stages unresolved until the repository-aware resolver runs.
if [ "$fallback_status" -gt 127 ]; then
exit "$fallback_status"
fi
exit 1

View File

@@ -10,31 +10,35 @@ import {
const args = process.argv.slice(2)
try {
const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim()
if (args[0] === '--resolve') {
if (args.length !== 1) throw new Error('--resolve takes no paths; it inspects the unmerged index')
const resolved = resolveTranslationPairingConflicts(root)
if (resolved.length === 0) {
console.log('merge-translation-pairing: no unresolved pairing records')
} else {
for (const path of resolved) console.log(`merge-translation-pairing: resolved ${path}`)
}
if (args[0] === '--probe') {
if (args.length !== 1) throw new Error('--probe takes no other arguments')
} else {
if (args.length !== 4) {
throw new Error('merge-driver mode requires <ancestor> <current> <other> <repository-path>')
const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim()
if (args[0] === '--resolve') {
if (args.length !== 1) throw new Error('--resolve takes no paths; it inspects the unmerged index')
const resolved = resolveTranslationPairingConflicts(root)
if (resolved.length === 0) {
console.log('merge-translation-pairing: no unresolved pairing records')
} else {
for (const path of resolved) console.log(`merge-translation-pairing: resolved ${path}`)
}
} else {
if (args.length !== 4) {
throw new Error('merge-driver mode requires <ancestor> <current> <other> <repository-path>')
}
const [ancestorPath, currentPath, otherPath, metaPath] = args
if (ancestorPath === undefined || currentPath === undefined || otherPath === undefined || metaPath === undefined) {
throw new Error('merge-driver arguments are incomplete')
}
const result = mergeTranslationPairingRecords(
root,
metaPath,
readFileSync(ancestorPath, 'utf8'),
readFileSync(currentPath, 'utf8'),
readFileSync(otherPath, 'utf8'),
)
writeFileSync(currentPath, result.record)
}
const [ancestorPath, currentPath, otherPath, metaPath] = args
if (ancestorPath === undefined || currentPath === undefined || otherPath === undefined || metaPath === undefined) {
throw new Error('merge-driver arguments are incomplete')
}
const result = mergeTranslationPairingRecords(
root,
metaPath,
readFileSync(ancestorPath, 'utf8'),
readFileSync(currentPath, 'utf8'),
readFileSync(otherPath, 'utf8'),
)
writeFileSync(currentPath, result.record)
}
} catch (error) {
console.error(`merge-translation-pairing: ${error instanceof Error ? error.message : String(error)}`)

View File

@@ -1,9 +1,9 @@
/** 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 { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
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'
@@ -17,6 +17,8 @@ import {
} 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[] = []
@@ -46,6 +48,16 @@ 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 createFixture(attributes = true): Fixture {
const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-merge-'))
fixtures.push(root)
@@ -99,6 +111,17 @@ function commitPair(fixture: Fixture, source: string, zh: string, message: strin
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'])
@@ -109,6 +132,15 @@ function createDivergedPair(fixture: Fixture): { ancestor: string; current: stri
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'], {
@@ -279,13 +311,12 @@ describe('translation pairing merge composition', () => {
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])
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'])
@@ -293,6 +324,152 @@ describe('translation pairing merge composition', () => {
expectMergedPair(fixture)
})
it('leaves an ordinary recoverable conflict when the configured runtime is unavailable', () => {
const fixture = createFixture()
const records = createDivergedPair(fixture)
const fakeBin = join(fixture.root, 'fake-bin')
const fakeNode = join(fakeBin, 'node')
write(fixture.root, 'fake-bin/node', '#!/bin/sh\nexit 72\n')
chmodSync(fakeNode, 0o755)
const command = [
shellQuote(driverLauncher),
'%O', '%A', '%B', '%P',
].join(' ')
git(fixture, ['config', 'merge.dsh-translation-pairing.driver', command])
const headBefore = git(fixture, ['rev-parse', 'HEAD'])
const result = spawnSync('git', ['-C', fixture.root, 'merge', '--no-commit', 'master'], {
encoding: 'utf8',
env: {
...fixture.env,
PATH: `${fakeBin}${delimiter}${fixture.env.PATH ?? ''}`,
},
})
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 fakeBin = join(fixture.root, 'fake-bin')
const fakeNode = join(fakeBin, 'node')
write(
fixture.root,
'fake-bin/node',
'#!/bin/sh\nif [ "$3" = "--eval" ]; then exit 0; fi\nexit 72\n',
)
chmodSync(fakeNode, 0o755)
git(fixture, [
'config',
'merge.dsh-translation-pairing.driver',
`${shellQuote(driverLauncher)} %O %A %B %P`,
])
const result = spawnSync('git', ['-C', fixture.root, 'merge', '--no-commit', 'master'], {
encoding: 'utf8',
env: {
...fixture.env,
PATH: `${fakeBin}${delimiter}${fixture.env.PATH ?? ''}`,
},
})
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 fakeBin = join(fixture.root, 'fake-bin')
const fakeNode = join(fakeBin, 'node')
write(fixture.root, 'fake-bin/node', '#!/bin/sh\nexit 72\n')
chmodSync(fakeNode, 0o755)
git(fixture, [
'config',
'merge.dsh-translation-pairing.driver',
`${shellQuote(driverLauncher)} %O %A %B %P`,
])
const result = spawnSync('git', ['-C', fixture.root, 'merge', '--no-commit', 'master'], {
encoding: 'utf8',
env: {
...fixture.env,
PATH: `${fakeBin}${delimiter}${fixture.env.PATH ?? ''}`,
},
})
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], {

View File

@@ -97,13 +97,13 @@ function assertDefaultTextMerge(root: string, paths: TranslationPairPaths): void
}
}
function mergeBlobTriplet(
function runTextMerge(
root: string,
owner: string,
ancestor: Buffer,
current: Buffer,
other: Buffer,
): Buffer {
label: string,
ancestor: Buffer | string,
current: Buffer | string,
other: Buffer | string,
): { output: Buffer; status: number | null } {
const temporary = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-merge-'))
try {
const ancestorPath = join(temporary, 'ancestor')
@@ -115,26 +115,37 @@ function mergeBlobTriplet(
const result = spawnSync('git', [
'-C', root,
'merge-file', '-p',
'-L', `${owner}:current`,
'-L', `${owner}:ancestor`,
'-L', `${owner}:other`,
'-L', `${label}:current`,
'-L', `${label}:ancestor`,
'-L', `${label}:other`,
currentPath, ancestorPath, otherPath,
], { maxBuffer: GIT_COMMAND_MAX_BUFFER })
if (result.error) {
throw new Error(`merging ${owner} failed: ${result.error.message}`, { cause: result.error })
throw new Error(`merging ${label} failed: ${result.error.message}`, { cause: result.error })
}
if (result.status !== 0) {
const kind = result.status !== null && result.status > 0 && result.status <= 127
? 'has content conflicts'
: `failed with status ${String(result.status)}`
throw new Error(`${owner} ${kind}`)
}
return result.stdout
return { output: result.stdout, status: result.status }
} finally {
rmSync(temporary, { recursive: true, force: true })
}
}
function mergeBlobTriplet(
root: string,
owner: string,
ancestor: Buffer,
current: Buffer,
other: Buffer,
): Buffer {
const result = runTextMerge(root, owner, ancestor, current, other)
if (result.status !== 0) {
const kind = result.status !== null && result.status > 0 && result.status <= 127
? 'has content conflicts'
: `failed with status ${String(result.status)}`
throw new Error(`${owner} ${kind}`)
}
return result.output
}
function loadRecordOwners(
root: string,
label: string,
@@ -243,11 +254,14 @@ function unmergedSidecars(root: string): Map<string, UnmergedStages> {
function assertUneditedSidecar(
root: string,
metaPath: string,
ancestorRecord: string,
currentRecord: string,
otherRecord: string,
): void {
const worktreeRecord = readFileSync(join(root, metaPath), 'utf8')
if (worktreeRecord === currentRecord || worktreeRecord === otherRecord) return
const textMerge = runTextMerge(root, metaPath, ancestorRecord, currentRecord, otherRecord)
if (textMerge.status === 0 && textMerge.output.toString('utf8') === worktreeRecord) return
const stageDataLines = [currentRecord, otherRecord]
.flatMap(record => record.split(/\r?\n/))
.filter(line => line !== '' && !line.startsWith('#'))
@@ -282,7 +296,7 @@ export function resolveTranslationPairingConflicts(root: string): string[] {
const ancestorRecord = readGitBlob(root, stages.ancestor, `ancestor ${metaPath}`).toString('utf8')
const currentRecord = readGitBlob(root, stages.current, `current ${metaPath}`).toString('utf8')
const otherRecord = readGitBlob(root, stages.other, `other ${metaPath}`).toString('utf8')
assertUneditedSidecar(root, metaPath, currentRecord, otherRecord)
assertUneditedSidecar(root, metaPath, ancestorRecord, currentRecord, otherRecord)
const result = mergeTranslationPairingRecords(
root,
metaPath,