fix(i18n): harden stopped-merge recovery

This commit is contained in:
Tianyi Cui
2026-08-08 21:49:23 +08:00
parent 75570ba31e
commit 9a4b70cf3b
14 changed files with 237 additions and 59 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md
2026-08-08-automatic-translation-pairing-merges.md: a1b92c97240e2019b188b606dc079b2211a89238
2026-08-08-automatic-translation-pairing-merges.zh.md: 5c7249dc436d24aa955d9c042c8d1bdc84655868
2026-08-08-automatic-translation-pairing-merges.md: e70864f1227fe11d7895dd85b0a99d166753a685
2026-08-08-automatic-translation-pairing-merges.zh.md: 08da67d78e5cca23e3c5c698644c667813697812

View File

@@ -14,15 +14,15 @@ A bilingual consistency record contains the two owner files' exact blob hashes.
The driver parses the ancestor, current, and other records and loads the six owner blobs named by their hashes. It independently runs Git's default three-way text merge for the English and Chinese triplets, requires both merges to be clean, verifies language switchers and the pairing structural signature, stores the two merged blobs, and writes their hashes as the canonical record. This composes confirmations already present in both parents; it never records an ordinary one-sided documentation edit.
The driver fails with an ordinary unresolved sidecar when a record is malformed, an object is missing, an owner uses another merge strategy, either owner has content conflicts, or the merged pair violates structural checks. Add/delete and rename shapes remain manual because their path ownership is not the same three-record operation.
The driver fails with an ordinary unresolved sidecar when a record is malformed, an object is missing, an owner uses another merge strategy (including a non-text `merge.default` inherited by an otherwise unspecified path), either owner has content conflicts, or the merged pair violates structural checks. Add/delete and rename shapes remain manual because their path ownership is not the same three-record operation.
`pnpm run resolve-translation-pairing-conflicts` applies the same algorithm after a merge has already stopped. Before writing any sidecar, it proves that the staged owner blob IDs and working-tree bytes equal its independent merges; then it writes and stages every safe record as one batch while leaving unrelated conflicts untouched.
`pnpm run resolve-translation-pairing-conflicts` applies the same algorithm after a merge has already stopped. Before writing any sidecar, it proves that the sidecar still contains Git's untouched conflict result and that the staged owner blob IDs and working-tree bytes equal its independent merges. It writes and stages every safe record as one batch even when another pair still needs manual work, then reports the remaining pairing conflicts and exits unsuccessfully so callers cannot mistake a partial resolution for a completed merge.
`pre-merge-commit` and `pre-commit` verify staged `.i18n.yaml` files against the exact index bytes of their owners. They validate driver output but do not regenerate records, so bypassing a hook cannot silently bless translation drift; the corpus-wide `doc-sync` check remains authoritative in CI.
## Verification
Script tests exercise clean composition through a real custom-driver Git merge, explicit recovery from an unresolved index, owner-content conflicts, record parsing, and worktree-local installation. The existing corpus verifier continues to prove that a committed record matches its two owners.
Script tests exercise clean composition through a real custom-driver Git merge, explicit recovery from an unresolved index, mixed safe and owner-conflicted pairs, edited sidecars, non-text default merge configuration, record parsing, and worktree-local installation. The existing corpus verifier continues to prove that a committed record matches its two owners.
## Alternatives considered

View File

@@ -14,15 +14,15 @@ Status: implemented
驱动会解析祖先、当前和另一侧的记录,并加载其中 hash 指向的六个 Markdown blob。它分别对英文文档的三个 blob 与中文文档的三个 blob 运行 Git 默认的三方文本合并,要求两次合并都干净完成,校验语言切换行与配对结构签名,存储两个合并后的 blob再将其 hash 写入规范记录。这一过程组合两个父提交中已经存在的确认;它绝不会为普通的单侧文档编辑写入记录。
记录格式错误、对象缺失、配对文档采用其他合并策略、任一配对文档存在内容冲突,或合并后的配对违反结构检查时,驱动会让伴随文件保持为普通的未解决冲突。新增、删除及重命名情形仍需手工处理,因为它们的路径归属关系无法由上述基于三份记录的操作处理。
记录格式错误、对象缺失、配对文档采用其他合并策略(包括原本未单独指定策略的路径继承了非文本 `merge.default`、任一配对文档存在内容冲突,或合并后的配对违反结构检查时,驱动会让伴随文件保持为普通的未解决冲突。新增、删除及重命名情形仍需手工处理,因为它们的路径归属关系无法由上述基于三份记录的操作处理。
`pnpm run resolve-translation-pairing-conflicts` 会在合并已经停止后应用同一算法。写入任何伴随文件之前,它会验证暂存区中配对文档的 blob ID 以及工作树中的文件字节都与工具独立完成的合并结果一致;随后它会将所有可安全生成的记录作为一个批次写入并暂存,同时不改动无关冲突
`pnpm run resolve-translation-pairing-conflicts` 会在合并已经停止后应用同一算法。写入任何伴随文件之前,它会验证伴随文件仍保留 Git 生成且未经改动的冲突结果,并验证暂存区中配对文档的 blob ID 以及工作树中的文件字节都与工具独立完成的合并结果一致。即使其他配对仍需手工处理,它也会将所有可安全生成的记录作为一个批次写入并暂存;随后报告剩余的配对冲突并以非零状态退出,确保调用方不会把仅部分解决误认为合并已经完成
`pre-merge-commit``pre-commit` 通过限定范围的配对检查,对照两侧文档在索引中的精确字节校验暂存的 `.i18n.yaml` 文件。这些钩子会验证驱动输出但不会重新生成记录因此绕过钩子也无法默默确认发生漂移的译文CI 中全语料的 `doc-sync`(文档同步门禁)仍是权威检查。
## 验证
脚本测试通过一次使用真实自定义驱动的 Git 合并来覆盖无冲突组合,并覆盖从含未解决条目的索引中显式恢复、配对文档内容冲突、记录解析和 worktree 本地安装。既有的全语料校验器继续证明已提交的记录与两侧配对文档相符。
脚本测试通过一次使用真实自定义驱动的 Git 合并来覆盖无冲突组合,并覆盖从含未解决条目的索引中显式恢复、可安全解决的配对与配对文档冲突的配对并存、被编辑过的伴随文件、非文本默认合并配置、记录解析和 worktree 本地安装。既有的全语料校验器继续证明已提交的记录与两侧配对文档相符。
## 考虑过的替代方案

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/development.md
development.md: 1b0a412cd55224740a074a88734bc5663a14effc
development.zh.md: 3c01f680dd8107fe6b3369415a979cea69cd19a0
development.md: ad0447a0e121eaa31fb6e98502c6a55a9456d768
development.zh.md: aef8b25d2115d00d5cfdb76b342a57662030feee

View File

@@ -100,7 +100,7 @@ DEEPSEEK_BASE_URL=https://... # optional
### Git integrations
The pairing merge driver derives a conflicted `.i18n.yaml` record from the confirmed ancestor, current, and other owner blobs when both language files merge cleanly. It fails closed on owner conflicts or invalid records; after an already-stopped merge, run `pnpm run resolve-translation-pairing-conflicts`. See the [bilingual documentation contract](i18n/README.md#the-pairing-contract) for the exact boundary.
The pairing merge driver derives a conflicted `.i18n.yaml` record from the confirmed ancestor, current, and other owner blobs when both language files use Git's default text strategy and merge cleanly. It fails closed on owner conflicts, non-text merge configuration, or invalid records; after an already-stopped merge, run `pnpm run resolve-translation-pairing-conflicts`, which stages every safe pairing record and exits unsuccessfully if other pairing conflicts still need manual work. See the [bilingual documentation contract](i18n/README.md#the-pairing-contract) for the exact boundary.
lefthook is configured in `lefthook.yml` as a fast local checkpoint:

View File

@@ -100,7 +100,7 @@ DEEPSEEK_BASE_URL=https://... # optional
### Git 集成
当两种语言的文件都能干净合并时,配对合并驱动会根据已确认的祖先、当前和另一侧的配对文档 blob推导出发生冲突的 `.i18n.yaml` 记录。配对文档发生冲突或记录无效时,它会拒绝处理并保留冲突;如果合并已经因冲突而停止,请运行 `pnpm run resolve-translation-pairing-conflicts`。确切边界见[双语文档契约](i18n/README.md#the-pairing-contract)。
当两种语言的文件都使用 Git 默认文本策略且能干净合并时,配对合并驱动会根据已确认的祖先、当前和另一侧的配对文档 blob推导出发生冲突的 `.i18n.yaml` 记录。配对文档发生冲突、存在非文本合并配置或记录无效时,它会拒绝处理并保留冲突;如果合并已经因冲突而停止,请运行 `pnpm run resolve-translation-pairing-conflicts`,该命令会暂存每份可安全生成的配对记录;如果其他配对冲突仍需手工处理,则以非零状态退出。确切边界见[双语文档契约](i18n/README.md#the-pairing-contract)。
lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/i18n/README.md
README.md: 2f9d7d1112492545e4e82d8d99c3d11df383e42d
README.zh.md: 65ac3552aa4b66cbd43e6b55b98f0e1d9491feea
README.md: 30f56eeebf15a3edec559e3080247dab9ab14178
README.zh.md: 91d5b35a00994beea70f2bbd674cfb557d6f834d

View File

@@ -17,7 +17,7 @@ This repo's documentation is read by people and agents both inside and outside t
Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. `pnpm run gen-translation-brief <pair>` assembles that update's working set mechanically at the narrowest safely aligned granularity — changed Markdown units, then heading sections, then whole document — with the edited side's diff since last confirmation, each changed span's three-way text, the terminology rows the change touches, and the binding update rules; a change confined to the pair's byte-identical code fences is computed outright, and `--apply` splices it into the counterpart after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write <pair>` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).
When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its switchers and structural signature. Any uncertain shape remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives.
When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its switchers and structural signature. Any uncertain shape remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives.
- **Language switcher.** Both files link to each other immediately after their H1 heading: the English file carries `English | [中文](foo.zh.md)` and the Chinese file carries `[English](foo.md) | 中文`.
- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).

View File

@@ -17,7 +17,7 @@
用 blob hash 而不是 commit hash这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。`pnpm run gen-translation-brief <pair>` 会以能安全对齐的最窄粒度——先是有改动的 Markdown 单元,再是标题小节,最后是整篇文档——机械地汇集这次更新的工作集:被改一侧自上次确认以来的 diff、每个改动块的三方文本、改动触及的术语表行以及有约束力的更新规则仅落在配对中逐字节一致的围栏代码块内的改动可以直接算出`--apply` 则经结构签名校验后把它拼接进对侧文件([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write <pair>` 重新记录两个 hash那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。
当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob且合并后的配对仍保留两侧的语言切换行和结构签名时组合出一份新记录。任何无法确定的情形都保留为普通冲突`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。
当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob且合并后的配对仍保留两侧的语言切换行和结构签名时组合出一份新记录。任何无法确定的情形都保留为普通冲突`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。
- **语言切换行。** 两个文件在各自 H1 标题之后立即互链:英文文件带 `English | [中文](foo.zh.md)`,中文文件带 `[English](foo.md) | 中文`。
- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。

View File

@@ -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

View File

@@ -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 })

View File

@@ -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)
})
})

View File

@@ -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)
}