mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(i18n): harden stopped-merge recovery
This commit is contained in:
@@ -38,5 +38,10 @@ try {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`merge-translation-pairing: ${error instanceof Error ? error.message : String(error)}`)
|
||||
console.error(
|
||||
'merge-translation-pairing: resolve owner conflicts, then confirm the pair with '
|
||||
+ '`pnpm run verify-translation-pairing --write <pair>`; rerun '
|
||||
+ '`pnpm run resolve-translation-pairing-conflicts` for other safe records',
|
||||
)
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -5,6 +5,9 @@ import { createHash } from 'node:crypto'
|
||||
|
||||
const SNAPSHOT_REF_PREFIX = 'refs/dsh/translation-pairing/snapshots'
|
||||
|
||||
/** Maximum buffered stdout or stderr for repository-owned Git subprocesses. */
|
||||
export const GIT_COMMAND_MAX_BUFFER = 1 << 26
|
||||
|
||||
/** Full SHA-1 Git blob hash (the 40-hex format used by pairing records). */
|
||||
export function gitBlobHash(content: Buffer): string {
|
||||
const hash = createHash('sha1')
|
||||
@@ -13,10 +16,20 @@ export function gitBlobHash(content: Buffer): string {
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
function runGit(root: string, args: string[], operation: string, input?: Buffer): Buffer {
|
||||
/**
|
||||
* Run one Git subprocess and return its exact stdout bytes.
|
||||
*
|
||||
* @param root - Repository root used as Git's working directory.
|
||||
* @param args - Arguments following the `git` executable.
|
||||
* @param operation - Human-readable operation for failure diagnostics.
|
||||
* @param input - Optional stdin bytes.
|
||||
* @returns Exact stdout bytes.
|
||||
* @throws Error when Git cannot start or exits unsuccessfully.
|
||||
*/
|
||||
export function runGit(root: string, args: string[], operation: string, input?: Buffer): Buffer {
|
||||
const result = spawnSync('git', ['-C', root, ...args], {
|
||||
input,
|
||||
maxBuffer: 1 << 26,
|
||||
maxBuffer: GIT_COMMAND_MAX_BUFFER,
|
||||
})
|
||||
if (result.error) {
|
||||
throw new Error(`${operation} failed: ${result.error.message}`, { cause: result.error })
|
||||
|
||||
@@ -85,6 +85,12 @@ 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)
|
||||
@@ -113,6 +119,47 @@ function startStoppedPairingMerge(fixture: Fixture): void {
|
||||
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)
|
||||
@@ -139,6 +186,7 @@ describe('translation pairing merge composition', () => {
|
||||
|
||||
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(
|
||||
@@ -214,6 +262,20 @@ describe('translation pairing merge composition', () => {
|
||||
)).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)
|
||||
@@ -231,6 +293,19 @@ describe('translation pairing merge composition', () => {
|
||||
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)
|
||||
@@ -251,4 +326,32 @@ describe('translation pairing merge composition', () => {
|
||||
)
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,13 @@ import { spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
||||
import { gitBlobHash, readGitIndexBlob, storeGitBlob } from './translation-pairing-git.ts'
|
||||
import {
|
||||
GIT_COMMAND_MAX_BUFFER,
|
||||
gitBlobHash,
|
||||
readGitIndexBlob,
|
||||
runGit,
|
||||
storeGitBlob,
|
||||
} from './translation-pairing-git.ts'
|
||||
import {
|
||||
linksTo,
|
||||
isTranslationScopeFile,
|
||||
@@ -20,7 +26,6 @@ import {
|
||||
type TranslationPairingRecord,
|
||||
} from './translation-pairing-record.ts'
|
||||
|
||||
const MAX_GIT_OUTPUT = 1 << 26
|
||||
const UNMERGED_ENTRY = /^(\d+) ([0-9a-f]+) ([123])\t([\s\S]+)$/
|
||||
|
||||
/** A mechanically composed record and the exact merged owner contents it names. */
|
||||
@@ -39,18 +44,6 @@ interface UnmergedStages {
|
||||
other?: string
|
||||
}
|
||||
|
||||
function runGit(root: string, args: string[], operation: string, input?: Buffer): Buffer {
|
||||
const result = spawnSync('git', ['-C', root, ...args], {
|
||||
input,
|
||||
maxBuffer: MAX_GIT_OUTPUT,
|
||||
})
|
||||
if (result.error) throw new Error(`${operation} failed: ${result.error.message}`, { cause: result.error })
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${operation} failed with status ${String(result.status)}: ${result.stderr.toString('utf8').trim()}`)
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
function readGitBlob(root: string, objectId: string, owner: string): Buffer {
|
||||
const content = runGit(root, ['cat-file', 'blob', objectId], `reading ${owner} blob ${objectId}`)
|
||||
if (gitBlobHash(content) !== objectId) {
|
||||
@@ -59,6 +52,22 @@ function readGitBlob(root: string, objectId: string, owner: string): Buffer {
|
||||
return content
|
||||
}
|
||||
|
||||
function readMergeDefault(root: string): string | undefined {
|
||||
const result = spawnSync('git', ['-C', root, 'config', '--get', 'merge.default'], {
|
||||
maxBuffer: GIT_COMMAND_MAX_BUFFER,
|
||||
})
|
||||
if (result.error) {
|
||||
throw new Error(`reading merge.default failed: ${result.error.message}`, { cause: result.error })
|
||||
}
|
||||
if (result.status === 1) return undefined
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`reading merge.default failed with status ${String(result.status)}: ${result.stderr.toString('utf8').trim()}`,
|
||||
)
|
||||
}
|
||||
return result.stdout.toString('utf8').trim()
|
||||
}
|
||||
|
||||
function assertDefaultTextMerge(root: string, paths: TranslationPairPaths): void {
|
||||
const output = runGit(
|
||||
root,
|
||||
@@ -67,6 +76,7 @@ function assertDefaultTextMerge(root: string, paths: TranslationPairPaths): void
|
||||
).toString('utf8')
|
||||
const fields = output.split('\0')
|
||||
fields.pop()
|
||||
let mergeDefault: string | undefined
|
||||
for (let index = 0; index < fields.length; index += 3) {
|
||||
const path = fields[index]
|
||||
const value = fields[index + 2]
|
||||
@@ -76,6 +86,14 @@ function assertDefaultTextMerge(root: string, paths: TranslationPairPaths): void
|
||||
if (!['unspecified', 'set', 'text'].includes(value)) {
|
||||
throw new Error(`${path} uses merge=${value}; the pairing driver only composes Git's default text merge`)
|
||||
}
|
||||
if (value === 'unspecified') {
|
||||
mergeDefault ??= readMergeDefault(root)
|
||||
if (mergeDefault !== undefined && mergeDefault !== 'text') {
|
||||
throw new Error(
|
||||
`${path} inherits merge.default=${mergeDefault}; the pairing driver only composes Git's default text merge`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +119,7 @@ function mergeBlobTriplet(
|
||||
'-L', `${owner}:ancestor`,
|
||||
'-L', `${owner}:other`,
|
||||
currentPath, ancestorPath, otherPath,
|
||||
], { maxBuffer: MAX_GIT_OUTPUT })
|
||||
], { maxBuffer: GIT_COMMAND_MAX_BUFFER })
|
||||
if (result.error) {
|
||||
throw new Error(`merging ${owner} failed: ${result.error.message}`, { cause: result.error })
|
||||
}
|
||||
@@ -222,46 +240,85 @@ function unmergedSidecars(root: string): Map<string, UnmergedStages> {
|
||||
return records
|
||||
}
|
||||
|
||||
function assertUneditedSidecar(
|
||||
root: string,
|
||||
metaPath: string,
|
||||
currentRecord: string,
|
||||
otherRecord: string,
|
||||
): void {
|
||||
const worktreeRecord = readFileSync(join(root, metaPath), 'utf8')
|
||||
if (worktreeRecord === currentRecord || worktreeRecord === otherRecord) return
|
||||
const stageDataLines = [currentRecord, otherRecord]
|
||||
.flatMap(record => record.split(/\r?\n/))
|
||||
.filter(line => line !== '' && !line.startsWith('#'))
|
||||
const hasUneditedConflict = worktreeRecord.includes('<<<<<<<')
|
||||
&& worktreeRecord.includes('=======')
|
||||
&& worktreeRecord.includes('>>>>>>>')
|
||||
&& stageDataLines.every(line => worktreeRecord.includes(line))
|
||||
if (!hasUneditedConflict) {
|
||||
throw new Error(`${metaPath} has edited conflict content; refusing to overwrite manual work`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve every mechanically composable `.i18n.yaml` conflict in the index.
|
||||
*
|
||||
* The command first proves that Git's already-staged owner merges match the
|
||||
* independently composed contents, then writes and stages all sidecars as one
|
||||
* batch. Other conflicts remain untouched.
|
||||
* batch. Other conflicts remain untouched; after staging the safe records, an
|
||||
* aggregate error reports any pairing conflicts that still need manual work.
|
||||
*
|
||||
* @param root - Repository root with an in-progress merge-like operation.
|
||||
* @returns Repository-relative sidecar paths resolved and staged.
|
||||
*/
|
||||
export function resolveTranslationPairingConflicts(root: string): string[] {
|
||||
const resolutions: { path: string; record: string }[] = []
|
||||
const failures: { path: string; reason: string }[] = []
|
||||
for (const [metaPath, stages] of [...unmergedSidecars(root)].sort(([left], [right]) => left.localeCompare(right))) {
|
||||
if (stages.ancestor === undefined || stages.current === undefined || stages.other === undefined) {
|
||||
throw new Error(`${metaPath} is an add/delete or incomplete-stage conflict and requires manual resolution`)
|
||||
}
|
||||
const result = mergeTranslationPairingRecords(
|
||||
root,
|
||||
metaPath,
|
||||
readGitBlob(root, stages.ancestor, `ancestor ${metaPath}`).toString('utf8'),
|
||||
readGitBlob(root, stages.current, `current ${metaPath}`).toString('utf8'),
|
||||
readGitBlob(root, stages.other, `other ${metaPath}`).toString('utf8'),
|
||||
)
|
||||
const paths = translationPairPathsFromMeta(metaPath)
|
||||
if (readGitIndexBlob(root, paths.source)?.objectId !== result.sourceHash) {
|
||||
throw new Error(`${paths.source} staged merge does not match the pairing driver's clean merge`)
|
||||
}
|
||||
if (readGitIndexBlob(root, paths.zh)?.objectId !== result.zhHash) {
|
||||
throw new Error(`${paths.zh} staged merge does not match the pairing driver's clean merge`)
|
||||
}
|
||||
for (const [path, expected] of [[paths.source, result.sourceHash], [paths.zh, result.zhHash]] as const) {
|
||||
if (gitBlobHash(readFileSync(join(root, path))) !== expected) {
|
||||
throw new Error(`${path} has unstaged content; refusing to confirm bytes outside the merge result`)
|
||||
try {
|
||||
if (stages.ancestor === undefined || stages.current === undefined || stages.other === undefined) {
|
||||
throw new Error('is an add/delete or incomplete-stage conflict and requires manual resolution')
|
||||
}
|
||||
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)
|
||||
const result = mergeTranslationPairingRecords(
|
||||
root,
|
||||
metaPath,
|
||||
ancestorRecord,
|
||||
currentRecord,
|
||||
otherRecord,
|
||||
)
|
||||
const paths = translationPairPathsFromMeta(metaPath)
|
||||
if (readGitIndexBlob(root, paths.source)?.objectId !== result.sourceHash) {
|
||||
throw new Error(`${paths.source} staged merge does not match the pairing driver's clean merge`)
|
||||
}
|
||||
if (readGitIndexBlob(root, paths.zh)?.objectId !== result.zhHash) {
|
||||
throw new Error(`${paths.zh} staged merge does not match the pairing driver's clean merge`)
|
||||
}
|
||||
for (const [path, expected] of [[paths.source, result.sourceHash], [paths.zh, result.zhHash]] as const) {
|
||||
if (gitBlobHash(readFileSync(join(root, path))) !== expected) {
|
||||
throw new Error(`${path} has unstaged content; refusing to confirm bytes outside the merge result`)
|
||||
}
|
||||
}
|
||||
resolutions.push({ path: metaPath, record: result.record })
|
||||
} catch (error) {
|
||||
failures.push({ path: metaPath, reason: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
resolutions.push({ path: metaPath, record: result.record })
|
||||
}
|
||||
for (const resolution of resolutions) writeFileSync(join(root, resolution.path), resolution.record)
|
||||
if (resolutions.length > 0) {
|
||||
runGit(root, ['add', '--', ...resolutions.map(resolution => resolution.path)], 'staging resolved pairing records')
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
const resolved = resolutions.length === 0
|
||||
? ''
|
||||
: `resolved and staged ${resolutions.map(resolution => resolution.path).join(', ')}; `
|
||||
throw new Error(
|
||||
`${resolved}left ${String(failures.length)} pairing conflict(s) unresolved:\n`
|
||||
+ failures.map(failure => `- ${failure.path}: ${failure.reason}`).join('\n'),
|
||||
)
|
||||
}
|
||||
return resolutions.map(resolution => resolution.path)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user