mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(i18n): preserve merge conflicts without runtime
This commit is contained in:
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# 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
|
# 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: e70864f1227fe11d7895dd85b0a99d166753a685
|
2026-08-08-automatic-translation-pairing-merges.md: ac4e0c11290abfd48d0f3caf037062aa2fe36c4d
|
||||||
2026-08-08-automatic-translation-pairing-merges.zh.md: 08da67d78e5cca23e3c5c698644c667813697812
|
2026-08-08-automatic-translation-pairing-merges.zh.md: 38e8c0a5613a6bd0b3e76143a93c93653430d0c2
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ A bilingual consistency record contains the two owner files' exact blob hashes.
|
|||||||
|
|
||||||
`*.i18n.yaml` uses the repository-owned `dsh-translation-pairing` merge driver. The worktree-local Git installer registers its command alongside Lefthook setup; Git configuration remains local because a tracked attribute can name a driver but cannot carry its executable command.
|
`*.i18n.yaml` uses the repository-owned `dsh-translation-pairing` merge driver. The worktree-local Git installer registers its command alongside Lefthook setup; Git configuration remains local because a tracked attribute can name a driver but cannot carry its executable command.
|
||||||
|
|
||||||
|
The installer loads the exact Node/tsx entrypoint before publishing worktree integration. Git invokes a checked-in shell launcher that does not require Node and repeats this probe before every driver execution. When the runtime or entrypoint is unavailable, the launcher materializes Git's ordinary three-way text result in the sidecar but returns a conflict even when that text merge is clean, so Git retains the unmerged index stages and never accepts unverified metadata.
|
||||||
|
|
||||||
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 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 (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.
|
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.
|
||||||
@@ -20,9 +22,21 @@ The driver fails with an ordinary unresolved sidecar when a record is malformed,
|
|||||||
|
|
||||||
`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.
|
`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.
|
||||||
|
|
||||||
|
## Failure contract
|
||||||
|
|
||||||
|
| Failure during a normal `git merge` | Observable state | Recovery |
|
||||||
|
|---|---|---|
|
||||||
|
| A fresh install cannot probe the driver or install Lefthook | No new driver or hook-path configuration is published; any newly added integration is rolled back to the previous hook lookup. | Restore the dependencies and rerun `node scripts/install-lefthook.mjs`. |
|
||||||
|
| Node, tsx, or the driver entrypoint becomes unavailable after installation | The merge stops with the sidecar at `UU`, index stages 1/2/3 remain, the worktree sidecar contains Git's text result, `MERGE_HEAD` exists, and no commit is created. | Restore the dependencies and run `pnpm run resolve-translation-pairing-conflicts`, or run `git merge --abort`. |
|
||||||
|
| The repository-aware driver rejects the records | The merge stops with the sidecar unresolved and no commit; the driver prints the owner-repair and explicit-resolver path. | Repair the owner conflict or record, then run the printed resolver workflow or abort. |
|
||||||
|
| The driver process crashes with a status above 128 | Git aborts the merge strategy without publishing `MERGE_HEAD` or unmerged index stages. | Repair the runtime and rerun the merge. |
|
||||||
|
| `pre-merge-commit` rejects an otherwise clean file merge | No unmerged entries remain, the complete result is staged with `MERGE_HEAD`, and no merge commit is created. | Repair the hook failure and run `git commit`, or run `git merge --abort`. |
|
||||||
|
|
||||||
|
An installer rollback failure reports both the original installation error and every rollback error. Because worktree configuration may then be partial, the contributor repairs or inspects it before merging instead of relying on a silent fallback.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
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.
|
Script tests exercise clean composition through the installed launcher, missing-runtime and broken-entrypoint text fallback, installer probe rollback, a rejecting `pre-merge-commit` hook, 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
|
## Alternatives considered
|
||||||
|
|
||||||
@@ -38,4 +52,4 @@ Script tests exercise clean composition through a real custom-driver Git merge,
|
|||||||
|
|
||||||
Installed worktrees automatically remove pairing-record-only conflicts while preserving human judgment for owner conflicts and translation quality. GitHub's hosted mergeability calculation does not run the worktree-local executable, so a contributor or agent must still merge the base and push the resulting commit before the remote conflict badge clears.
|
Installed worktrees automatically remove pairing-record-only conflicts while preserving human judgment for owner conflicts and translation quality. GitHub's hosted mergeability calculation does not run the worktree-local executable, so a contributor or agent must still merge the base and push the resulting commit before the remote conflict badge clears.
|
||||||
|
|
||||||
The installer reserves `merge.dsh-translation-pairing.*` in worktree configuration and refuses a conflicting custom value. Merge execution depends on the installed Node dependencies, like the repository's contributor hooks; missing setup fails rather than selecting stale metadata.
|
The installer reserves `merge.dsh-translation-pairing.*` in worktree configuration and refuses a conflicting custom value. Automatic composition depends on the installed Node dependencies, like the repository's contributor hooks; runtime loss produces a visible unresolved text result rather than selecting stale metadata.
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ Status: implemented
|
|||||||
|
|
||||||
`*.i18n.yaml` 使用由仓库维护的 `dsh-translation-pairing` 合并驱动。worktree 本地的 Git 安装脚本在设置 Lefthook 的同时注册该驱动命令;Git 配置仍限定于当前 worktree,因为受跟踪的属性可以指定驱动,却无法携带其可执行命令。
|
`*.i18n.yaml` 使用由仓库维护的 `dsh-translation-pairing` 合并驱动。worktree 本地的 Git 安装脚本在设置 Lefthook 的同时注册该驱动命令;Git 配置仍限定于当前 worktree,因为受跟踪的属性可以指定驱动,却无法携带其可执行命令。
|
||||||
|
|
||||||
|
安装脚本会在发布 worktree 集成前加载确切的 Node/tsx 入口点。Git 会调用仓库内已提交且不依赖 Node 的 shell 启动器;该启动器会在每次执行驱动前重复这项探测。运行时或入口点不可用时,即使文本合并干净完成,启动器也会将 Git 的普通三方文本合并结果写入伴随文件并返回冲突状态,使 Git 保留未合并的索引阶段,绝不接受未经验证的元数据。
|
||||||
|
|
||||||
驱动会解析祖先、当前和另一侧的记录,并加载其中 hash 指向的六个 Markdown blob。它分别对英文文档的三个 blob 与中文文档的三个 blob 运行 Git 默认的三方文本合并,要求两次合并都干净完成,校验语言切换行与配对结构签名,存储两个合并后的 blob,再将其 hash 写入规范记录。这一过程组合两个父提交中已经存在的确认;它绝不会为普通的单侧文档编辑写入记录。
|
驱动会解析祖先、当前和另一侧的记录,并加载其中 hash 指向的六个 Markdown blob。它分别对英文文档的三个 blob 与中文文档的三个 blob 运行 Git 默认的三方文本合并,要求两次合并都干净完成,校验语言切换行与配对结构签名,存储两个合并后的 blob,再将其 hash 写入规范记录。这一过程组合两个父提交中已经存在的确认;它绝不会为普通的单侧文档编辑写入记录。
|
||||||
|
|
||||||
记录格式错误、对象缺失、配对文档采用其他合并策略(包括原本未单独指定策略的路径继承了非文本 `merge.default`)、任一配对文档存在内容冲突,或合并后的配对违反结构检查时,驱动会让伴随文件保持为普通的未解决冲突。新增、删除及重命名情形仍需手工处理,因为它们的路径归属关系无法由上述基于三份记录的操作处理。
|
记录格式错误、对象缺失、配对文档采用其他合并策略(包括原本未单独指定策略的路径继承了非文本 `merge.default`)、任一配对文档存在内容冲突,或合并后的配对违反结构检查时,驱动会让伴随文件保持为普通的未解决冲突。新增、删除及重命名情形仍需手工处理,因为它们的路径归属关系无法由上述基于三份记录的操作处理。
|
||||||
@@ -20,9 +22,21 @@ Status: implemented
|
|||||||
|
|
||||||
`pre-merge-commit` 与 `pre-commit` 通过限定范围的配对检查,对照两侧文档在索引中的精确字节校验暂存的 `.i18n.yaml` 文件。这些钩子会验证驱动输出,但不会重新生成记录,因此绕过钩子也无法默默确认发生漂移的译文;CI 中全语料的 `doc-sync`(文档同步门禁)仍是权威检查。
|
`pre-merge-commit` 与 `pre-commit` 通过限定范围的配对检查,对照两侧文档在索引中的精确字节校验暂存的 `.i18n.yaml` 文件。这些钩子会验证驱动输出,但不会重新生成记录,因此绕过钩子也无法默默确认发生漂移的译文;CI 中全语料的 `doc-sync`(文档同步门禁)仍是权威检查。
|
||||||
|
|
||||||
|
## 失败契约
|
||||||
|
|
||||||
|
| 普通 `git merge` 期间的失败 | 可观察状态 | 恢复 |
|
||||||
|
|---|---|---|
|
||||||
|
| 全新安装无法探测驱动或安装 Lefthook | 不会发布新的驱动或钩子路径配置;任何新增集成都会回滚到原有的钩子查找方式。 | 恢复依赖并重新运行 `node scripts/install-lefthook.mjs`。 |
|
||||||
|
| 安装后 Node、tsx 或驱动入口点变得不可用 | 合并停止,伴随文件状态为 `UU`;索引阶段 1/2/3 保留,worktree 中的伴随文件包含 Git 的文本结果,`MERGE_HEAD` 存在,且不创建提交。 | 恢复依赖并运行 `pnpm run resolve-translation-pairing-conflicts`,或运行 `git merge --abort`。 |
|
||||||
|
| 了解仓库状态的驱动拒绝记录 | 合并停止,伴随文件保持未解决状态且不创建提交;驱动会打印修复配对文档和运行显式冲突解决命令的路径。 | 修复配对文档冲突或记录,然后运行打印出的冲突解决工作流,或中止合并。 |
|
||||||
|
| 驱动进程以大于 128 的状态码崩溃 | Git 中止合并策略,既不写入 `MERGE_HEAD`,也不产生未合并的索引阶段。 | 修复运行时并重新执行合并。 |
|
||||||
|
| `pre-merge-commit` 拒绝原本能干净完成的文件合并 | 不再有未合并条目,完整结果已暂存且 `MERGE_HEAD` 存在,但不会创建合并提交。 | 修复钩子失败并运行 `git commit`,或运行 `git merge --abort`。 |
|
||||||
|
|
||||||
|
安装脚本回滚失败时,会同时报告最初的安装错误和每一项回滚错误。此时 worktree 配置可能只完成了一部分,因此贡献者会先修复或检查配置再执行合并,而不是依赖静默回退。
|
||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
脚本测试通过一次使用真实自定义驱动的 Git 合并来覆盖无冲突组合,并覆盖从含未解决条目的索引中显式恢复、可安全解决的配对与配对文档冲突的配对并存、被编辑过的伴随文件、非文本默认合并配置、记录解析和 worktree 本地安装。既有的全语料校验器继续证明已提交的记录与两侧配对文档相符。
|
脚本测试通过已安装的启动器覆盖无冲突组合,并覆盖运行时缺失与入口点损坏时的文本回退、安装脚本探测回滚、拒绝合并的 `pre-merge-commit` 钩子、从含未解决条目的索引中显式恢复、可安全解决的配对与配对文档冲突的配对并存、被编辑过的伴随文件、非文本默认合并配置、记录解析和 worktree 本地安装。既有的全语料校验器继续证明已提交的记录与两侧配对文档相符。
|
||||||
|
|
||||||
## 考虑过的替代方案
|
## 考虑过的替代方案
|
||||||
|
|
||||||
@@ -38,4 +52,4 @@ Status: implemented
|
|||||||
|
|
||||||
完成安装的 worktree 会自动消除仅由配对记录引起的冲突,同时仍由人工处理配对文档冲突并评判翻译质量。GitHub 托管的可合并性计算不会运行 worktree 本地的可执行命令,因此仍须由贡献者或 agent 将 base 分支合入当前分支并推送结果提交,远端冲突标记才会消失。
|
完成安装的 worktree 会自动消除仅由配对记录引起的冲突,同时仍由人工处理配对文档冲突并评判翻译质量。GitHub 托管的可合并性计算不会运行 worktree 本地的可执行命令,因此仍须由贡献者或 agent 将 base 分支合入当前分支并推送结果提交,远端冲突标记才会消失。
|
||||||
|
|
||||||
安装脚本会在 worktree 配置中预留 `merge.dsh-translation-pairing.*`,并拒绝与之冲突的自定义值。与仓库贡献者钩子一样,合并过程依赖已安装的 Node 依赖;安装未完成时,合并会失败,而不是选择陈旧元数据。
|
安装脚本会在 worktree 配置中预留 `merge.dsh-translation-pairing.*`,并拒绝与之冲突的自定义值。与仓库贡献者钩子一样,自动组合依赖已安装的 Node 依赖;运行时不可用时会产生可见的未解决文本结果,而不是选择陈旧元数据。
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write docs/development.md
|
# pnpm run verify-translation-pairing --write docs/development.md
|
||||||
development.md: ad0447a0e121eaa31fb6e98502c6a55a9456d768
|
development.md: 0032000e298ef49516d618e932258e79cf90e802
|
||||||
development.zh.md: aef8b25d2115d00d5cfdb76b342a57662030feee
|
development.zh.md: d681306e31991794519b62c3edbb6ca35ebc228b
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ DEEPSEEK_BASE_URL=https://... # optional
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
The installer probes the exact Node/tsx driver entrypoint before publishing its worktree configuration. If that runtime later becomes unavailable, the Node-independent launcher writes Git's ordinary text result, leaves the sidecar unresolved, and prints the recovery path; restore dependencies and run `pnpm run resolve-translation-pairing-conflicts`, or run `git merge --abort`. If `pre-merge-commit` rejects an otherwise clean merge, Git leaves the complete result staged without a commit; repair the failure and run `git commit`, or abort. The [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract) owns the exact index and `MERGE_HEAD` states.
|
||||||
|
|
||||||
lefthook is configured in `lefthook.yml` as a fast local checkpoint:
|
lefthook is configured in `lefthook.yml` as a fast local checkpoint:
|
||||||
|
|
||||||
- `pre-commit` verifies staged pairing records against the staged owner blobs, applies formatting-only ESLint fixes, validates the staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint's native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.
|
- `pre-commit` verifies staged pairing records against the staged owner blobs, applies formatting-only ESLint fixes, validates the staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint's native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ 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)。
|
||||||
|
|
||||||
|
安装脚本在发布 worktree 配置前,会探测确切的 Node/tsx 驱动入口点。如果该运行时之后变得不可用,不依赖 Node 的启动器会写入 Git 的普通文本合并结果、让伴随文件保持未解决状态,并打印恢复路径;请恢复依赖后运行 `pnpm run resolve-translation-pairing-conflicts`,或运行 `git merge --abort`。如果 `pre-merge-commit` 拒绝原本能干净完成的合并,Git 会把完整结果留在暂存区但不创建提交;请修复失败后运行 `git commit`,或中止合并。确切的索引与 `MERGE_HEAD` 状态由[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract)负责记录。
|
||||||
|
|
||||||
lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:
|
lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:
|
||||||
|
|
||||||
- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,应用仅用于格式化的 ESLint 修复,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件并应用 Oxlint 的原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;
|
- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,应用仅用于格式化的 ESLint 修复,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件并应用 Oxlint 的原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;
|
||||||
|
|||||||
@@ -31,9 +31,15 @@ const PAIRING_MERGE_DRIVER_CONFIG = [
|
|||||||
['merge.dsh-translation-pairing.name', 'DeepSeek Harness bilingual pairing records'],
|
['merge.dsh-translation-pairing.name', 'DeepSeek Harness bilingual pairing records'],
|
||||||
[
|
[
|
||||||
'merge.dsh-translation-pairing.driver',
|
'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) {
|
function errorCode(error) {
|
||||||
return typeof error === 'object' && error !== null && 'code' in 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() {
|
async function main() {
|
||||||
if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') return
|
if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') return
|
||||||
if (typeof lefthookPackage.bin?.lefthook !== 'string') return
|
if (typeof lefthookPackage.bin?.lefthook !== 'string') return
|
||||||
@@ -767,6 +777,7 @@ async function main() {
|
|||||||
let pathChanged = false
|
let pathChanged = false
|
||||||
let rollbackPairingMergeDriver = () => {}
|
let rollbackPairingMergeDriver = () => {}
|
||||||
try {
|
try {
|
||||||
|
probePairingMergeDriver(root)
|
||||||
rollbackPairingMergeDriver = installPairingMergeDriver(root, worktreeConfigPath)
|
rollbackPairingMergeDriver = installPairingMergeDriver(root, worktreeConfigPath)
|
||||||
git(['config', '--worktree', 'core.hooksPath', hooksPath], root)
|
git(['config', '--worktree', 'core.hooksPath', hooksPath], root)
|
||||||
pathChanged = worktreePath !== hooksPath
|
pathChanged = worktreePath !== hooksPath
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import { fileURLToPath } from 'node:url'
|
|||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
const installer = fileURLToPath(new URL('./install-lefthook.mjs', import.meta.url))
|
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[] = []
|
const fixtures: string[] = []
|
||||||
// Multi-worktree cases spawn several Git and Node subprocesses; coverage concurrency can
|
// Multi-worktree cases spawn several Git and Node subprocesses; coverage concurrency can
|
||||||
// legitimately exceed Vitest's default deadline without changing the installer behavior.
|
// legitimately exceed Vitest's default deadline without changing the installer behavior.
|
||||||
@@ -123,6 +125,12 @@ function installFakeLefthook(root: string): void {
|
|||||||
chmodSync(shim, 0o755)
|
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 {
|
function createFixture(names: { main?: string; linked?: string } = {}): Fixture {
|
||||||
const container = mkdtempSync(join(tmpdir(), 'dsh-lefthook-'))
|
const container = mkdtempSync(join(tmpdir(), 'dsh-lefthook-'))
|
||||||
fixtures.push(container)
|
fixtures.push(container)
|
||||||
@@ -152,6 +160,8 @@ function createFixture(names: { main?: string; linked?: string } = {}): Fixture
|
|||||||
write(join(linked, 'lefthook.yml'), 'linked-worktree-config\n')
|
write(join(linked, 'lefthook.yml'), 'linked-worktree-config\n')
|
||||||
installFakeLefthook(main)
|
installFakeLefthook(main)
|
||||||
installFakeLefthook(linked)
|
installFakeLefthook(linked)
|
||||||
|
installPairingProbeFixture(main)
|
||||||
|
installPairingProbeFixture(linked)
|
||||||
return fixture
|
return fixture
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,6 +297,7 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
|
|||||||
git(fixture, fixture.main, ['worktree', 'add', '-b', 'late-linked', lateLinked])
|
git(fixture, fixture.main, ['worktree', 'add', '-b', 'late-linked', lateLinked])
|
||||||
write(join(lateLinked, 'lefthook.yml'), 'late-linked-worktree-config\n')
|
write(join(lateLinked, 'lefthook.yml'), 'late-linked-worktree-config\n')
|
||||||
installFakeLefthook(lateLinked)
|
installFakeLefthook(lateLinked)
|
||||||
|
installPairingProbeFixture(lateLinked)
|
||||||
expect(git(fixture, lateLinked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(mainHooks)
|
expect(git(fixture, lateLinked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(mainHooks)
|
||||||
|
|
||||||
const linkedInstall = await runInstaller(fixture, lateLinked)
|
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')
|
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 () => {
|
it('reports installation and hook-path rollback failures together', async () => {
|
||||||
const fixture = createFixture()
|
const fixture = createFixture()
|
||||||
|
|
||||||
|
|||||||
35
scripts/merge-translation-pairing-driver.sh
Executable file
35
scripts/merge-translation-pairing-driver.sh
Executable 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
|
||||||
@@ -10,31 +10,35 @@ import {
|
|||||||
const args = process.argv.slice(2)
|
const args = process.argv.slice(2)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim()
|
if (args[0] === '--probe') {
|
||||||
if (args[0] === '--resolve') {
|
if (args.length !== 1) throw new Error('--probe takes no other arguments')
|
||||||
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 {
|
} else {
|
||||||
if (args.length !== 4) {
|
const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim()
|
||||||
throw new Error('merge-driver mode requires <ancestor> <current> <other> <repository-path>')
|
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) {
|
} catch (error) {
|
||||||
console.error(`merge-translation-pairing: ${error instanceof Error ? error.message : String(error)}`)
|
console.error(`merge-translation-pairing: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/** Integration coverage for automatic and explicit pairing-record conflict resolution. */
|
/** Integration coverage for automatic and explicit pairing-record conflict resolution. */
|
||||||
|
|
||||||
import { execFileSync, spawnSync } from 'node:child_process'
|
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 { tmpdir } from 'node:os'
|
||||||
import { dirname, join } from 'node:path'
|
import { delimiter, dirname, join } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import { gitBlobHash, storeGitBlob } from './translation-pairing-git.ts'
|
import { gitBlobHash, storeGitBlob } from './translation-pairing-git.ts'
|
||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
} from './translation-pairing-record.ts'
|
} from './translation-pairing-record.ts'
|
||||||
|
|
||||||
const driver = fileURLToPath(new URL('./merge-translation-pairing.ts', import.meta.url))
|
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 tsxLoader = fileURLToPath(import.meta.resolve('tsx/esm'))
|
||||||
const fixtures: string[] = []
|
const fixtures: string[] = []
|
||||||
|
|
||||||
@@ -46,6 +48,16 @@ function shellQuote(value: string): string {
|
|||||||
return `"${value.replace(/["\\$`]/g, '\\$&')}"`
|
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 {
|
function createFixture(attributes = true): Fixture {
|
||||||
const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-merge-'))
|
const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-merge-'))
|
||||||
fixtures.push(root)
|
fixtures.push(root)
|
||||||
@@ -99,6 +111,17 @@ function commitPair(fixture: Fixture, source: string, zh: string, message: strin
|
|||||||
return sidecar
|
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 } {
|
function createDivergedPair(fixture: Fixture): { ancestor: string; current: string; other: string } {
|
||||||
const ancestor = commitPair(fixture, baseSource, baseZh, 'base')
|
const ancestor = commitPair(fixture, baseSource, baseZh, 'base')
|
||||||
git(fixture, ['switch', '-c', 'current'])
|
git(fixture, ['switch', '-c', 'current'])
|
||||||
@@ -109,6 +132,15 @@ function createDivergedPair(fixture: Fixture): { ancestor: string; current: stri
|
|||||||
return { ancestor, current, other }
|
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 {
|
function startStoppedPairingMerge(fixture: Fixture): void {
|
||||||
createDivergedPair(fixture)
|
createDivergedPair(fixture)
|
||||||
const merge = spawnSync('git', ['-C', fixture.root, 'merge', '--no-commit', 'master'], {
|
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', () => {
|
it('runs as Git\'s custom driver and commits a clean composed record', () => {
|
||||||
const fixture = createFixture()
|
const fixture = createFixture()
|
||||||
createDivergedPair(fixture)
|
createDivergedPair(fixture)
|
||||||
const command = [
|
installFixtureRuntime(fixture.root)
|
||||||
shellQuote(process.execPath),
|
git(fixture, [
|
||||||
'--import', shellQuote(tsxLoader),
|
'config',
|
||||||
shellQuote(driver),
|
'merge.dsh-translation-pairing.driver',
|
||||||
'%O', '%A', '%B', '%P',
|
'scripts/merge-translation-pairing-driver.sh %O %A %B %P',
|
||||||
].join(' ')
|
])
|
||||||
git(fixture, ['config', 'merge.dsh-translation-pairing.driver', command])
|
|
||||||
|
|
||||||
git(fixture, ['merge', '--no-edit', 'master'])
|
git(fixture, ['merge', '--no-edit', 'master'])
|
||||||
|
|
||||||
@@ -293,6 +324,152 @@ describe('translation pairing merge composition', () => {
|
|||||||
expectMergedPair(fixture)
|
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', () => {
|
it('prints the recovery path when driver input is not composable', () => {
|
||||||
const fixture = createFixture(false)
|
const fixture = createFixture(false)
|
||||||
const result = spawnSync(process.execPath, ['--import', tsxLoader, driver], {
|
const result = spawnSync(process.execPath, ['--import', tsxLoader, driver], {
|
||||||
|
|||||||
@@ -97,13 +97,13 @@ function assertDefaultTextMerge(root: string, paths: TranslationPairPaths): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeBlobTriplet(
|
function runTextMerge(
|
||||||
root: string,
|
root: string,
|
||||||
owner: string,
|
label: string,
|
||||||
ancestor: Buffer,
|
ancestor: Buffer | string,
|
||||||
current: Buffer,
|
current: Buffer | string,
|
||||||
other: Buffer,
|
other: Buffer | string,
|
||||||
): Buffer {
|
): { output: Buffer; status: number | null } {
|
||||||
const temporary = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-merge-'))
|
const temporary = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-merge-'))
|
||||||
try {
|
try {
|
||||||
const ancestorPath = join(temporary, 'ancestor')
|
const ancestorPath = join(temporary, 'ancestor')
|
||||||
@@ -115,26 +115,37 @@ function mergeBlobTriplet(
|
|||||||
const result = spawnSync('git', [
|
const result = spawnSync('git', [
|
||||||
'-C', root,
|
'-C', root,
|
||||||
'merge-file', '-p',
|
'merge-file', '-p',
|
||||||
'-L', `${owner}:current`,
|
'-L', `${label}:current`,
|
||||||
'-L', `${owner}:ancestor`,
|
'-L', `${label}:ancestor`,
|
||||||
'-L', `${owner}:other`,
|
'-L', `${label}:other`,
|
||||||
currentPath, ancestorPath, otherPath,
|
currentPath, ancestorPath, otherPath,
|
||||||
], { maxBuffer: GIT_COMMAND_MAX_BUFFER })
|
], { maxBuffer: GIT_COMMAND_MAX_BUFFER })
|
||||||
if (result.error) {
|
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) {
|
return { output: result.stdout, status: result.status }
|
||||||
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
|
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(temporary, { recursive: true, force: true })
|
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(
|
function loadRecordOwners(
|
||||||
root: string,
|
root: string,
|
||||||
label: string,
|
label: string,
|
||||||
@@ -243,11 +254,14 @@ function unmergedSidecars(root: string): Map<string, UnmergedStages> {
|
|||||||
function assertUneditedSidecar(
|
function assertUneditedSidecar(
|
||||||
root: string,
|
root: string,
|
||||||
metaPath: string,
|
metaPath: string,
|
||||||
|
ancestorRecord: string,
|
||||||
currentRecord: string,
|
currentRecord: string,
|
||||||
otherRecord: string,
|
otherRecord: string,
|
||||||
): void {
|
): void {
|
||||||
const worktreeRecord = readFileSync(join(root, metaPath), 'utf8')
|
const worktreeRecord = readFileSync(join(root, metaPath), 'utf8')
|
||||||
if (worktreeRecord === currentRecord || worktreeRecord === otherRecord) return
|
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]
|
const stageDataLines = [currentRecord, otherRecord]
|
||||||
.flatMap(record => record.split(/\r?\n/))
|
.flatMap(record => record.split(/\r?\n/))
|
||||||
.filter(line => line !== '' && !line.startsWith('#'))
|
.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 ancestorRecord = readGitBlob(root, stages.ancestor, `ancestor ${metaPath}`).toString('utf8')
|
||||||
const currentRecord = readGitBlob(root, stages.current, `current ${metaPath}`).toString('utf8')
|
const currentRecord = readGitBlob(root, stages.current, `current ${metaPath}`).toString('utf8')
|
||||||
const otherRecord = readGitBlob(root, stages.other, `other ${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(
|
const result = mergeTranslationPairingRecords(
|
||||||
root,
|
root,
|
||||||
metaPath,
|
metaPath,
|
||||||
|
|||||||
Reference in New Issue
Block a user