Skip to content

Preserve merge conflict contents in imported worktrees - #77

Open
wesm wants to merge 13 commits into
mainfrom
fix/merge-driver-conflict-markers
Open

Preserve merge conflict contents in imported worktrees#77
wesm wants to merge 13 commits into
mainfrom
fix/merge-driver-conflict-markers

Conversation

@wesm

@wesm wesm commented Aug 30, 2026

Copy link
Copy Markdown
Member

Merge-request imports replaced every tree-selectable custom merge driver with
f() { return 1; }; f. That reports a conflict without writing %A, so Git
recorded all three stages in the index while the working file kept only the
current side — no conflict markers, no other-side content. Staging the file
dropped the other side's changes silently. Reported as kenn-io/kwt#112.

The replacement now performs Git's own three-way text merge, so an imported
worktree behaves like an ordinary one: non-overlapping edits merge cleanly,
overlapping edits produce diff3 markers carrying base, current, and other, and
only a real overlap returns a conflict.

The driver is built at import time around the absolute path of the same git
executable git/cmd.Runner uses, resolved before the untrusted worktree is
materialized so an imported tree cannot steer it through PATH. Every
invocation guards that path with [ -x ] and returns 129 if it moved, which
Git treats as a driver failure and aborts the operation instead of recording a
current-only conflict. Each input pair is then classified with
git diff --no-index --numstat: binary content returns 1 and stays an ordinary
per-file conflict holding the current bytes, matching Git's built-in binary
driver, while text goes to git merge-file --diff3 with its status passed
through unchanged.

Two decisions carry the risk.

The command uses fixed current/base/other labels rather than %S, %X,
and %Y. Git substitutes %A, %O, and %B unquoted, so the template needs
double quotes around them, but it substitutes the label placeholders already
single-quoted — inside those same double quotes, a branch name or commit
subject containing $(...) becomes executable during any merge or rebase in
the imported worktree. Not interpolating the labels removes that interaction
entirely. TestUntrustedTreeMergeDriverDoesNotEvaluateGitLabels covers both
the branch-name and commit-subject routes.

Binary input is caught by preclassification rather than by mapping
merge-file's exit status, because 255 covers both binary rejection and I/O
failure; mapping it would turn a real failure back into a silent current-only
conflict. The classifier runs under the pinned executable with repository
bindings unset, GIT_CONFIG_COUNT=0, system attributes disabled, discovery
ceilinged, and --no-ext-diff --no-textconv, so nothing the tree selects can
steer classification. core.bigFileThreshold=1023m matches merge-file's
maximum text size so the two agree on what counts as binary.

Untrusted-tree import now requires Git 2.42.0 on non-Windows platforms, up from
2.39.1. Before 2.42 Git collapsed every positive custom-driver status into an
ordinary conflict, so the missing-executable guard could not be told apart from
a conflict. The Git for Windows floor is unchanged at 2.53.0.windows.3. On
Windows the pinned path is emitted drive-qualified with forward slashes, which
both Git for Windows' shell and the Windows loader accept.

git/internal/shellquote now owns the POSIX single-quote rule that git/cmd
kept privately, so the credential helper and the merge driver share one
implementation.

Ten behavior tests in git/managed/untrusted_tree_merge_test.go drive the
persisted driver through real merge-request imports, and the existing
PATH-hijack test now plants both git and sh shims in the tree and runs a
merge through them. The isolation boundary itself is unchanged: this is a Git
execution boundary, not an OS sandbox.

wesm and others added 12 commits August 29, 2026 20:50
Merge-request isolation currently reports conflicts without preserving the
other side in the working file. Define a fallback that uses the trusted Git
executable for normal three-way merges while keeping the PATH-hijack boundary.

Keep trusted global attribute-driver policy separate from this focused data
preservation fix.

Generated with Codex
Co-authored-by: Codex <codex@openai.com>
Git already shell-quotes merge labels, so adding double quotes around those
placeholders would make hostile command substitutions executable. A missing
persisted executable would also look like an ordinary conflict and reproduce
the data-hiding failure.

Use fixed labels, an explicit executable guard, and documented binary-status
mapping so the implementation plan preserves the untrusted-tree boundary.

Generated with Codex
Co-authored-by: Codex <codex@openai.com>
Mapping every status above 128 would hide a signaled merge process as an
ordinary conflict and could leave the current side without markers. Limit the
binary exception to merge-file's exact status 255 and preserve all signal
failures.

Make the Windows command path and cross-platform test coverage explicit before
implementation starts.

Generated with Codex
Co-authored-by: Codex <codex@openai.com>
The design now has verified decisions for shell quoting, missing executables, binary conflicts, signal failures, and Git for Windows paths. Record the test-first execution sequence before production code changes so these security and data-preservation constraints stay coupled to behavior tests.

Generated with Codex
Co-authored-by: Codex <codex@openai.com>
Keep POSIX quoting behavior in one reusable internal package so Git command
builders and related tests use the same safe treatment of spaces and quotes.
This removes the duplicated private helper without changing credential-helper
command output.

Generated with Codex
Co-authored-by: Codex <codex@openai.com>
Imported merge-request worktrees used a merge driver that reported every
merge as a conflict without writing the base or other side to the worktree.
This hid useful conflict contents and made non-overlapping edits impossible to
merge normally.

Use the resolved Git executable for three-way merge-file behavior. Keep fixed
marker labels and propagate operation failures so untrusted labels, PATH
changes, and missing executables cannot turn errors into incomplete conflicts.

Generated with Codex
Co-authored-by: Codex <codex@openai.com>
The PATH regression could pass after any pre-driver merge error and no longer
exercised the safe diff replacement. The label test also combined branch and
subject payloads in a rebase, which did not prove branch-label behavior.

Require the expected unmerged state and diff3 contents after preserving the
successful diff probe. Exercise hostile branch and subject labels through
merge and rebase separately so each Git label path remains covered.

Generated with Codex
Co-authored-by: Codex <codex@openai.com>
Future merge-driver changes need the same boundary as the current repair.
Without a durable contract, a later implementation could again hide text
conflict contents or turn an unavailable Git process into a per-file conflict.

Record the observable behavior for executable resolution, text and binary
conflicts, process failures, and Unix and Git for Windows support.

Generated with Codex
Co-authored-by: Codex <codex@openai.com>
Git expands merge-driver placeholders across the complete configured command
before the shell parses it. A resolved executable path containing `%Y` could
therefore splice an untrusted branch label into the command despite POSIX shell
quoting and run its command substitutions.

Keep literal percent signs intact through Git's template layer before applying
shell quoting. Record the two escaping layers and the existing platform Git
version floors so future changes preserve the same boundary.

Generated with Codex
Co-authored-by: Codex <codex@openai.com>
Git before 2.42 turns every positive custom merge-driver result into a
conflict. Git merge-file also uses status 255 for both binary content and
input or output failures. Together, those rules can hide a real operation
failure behind a current-only conflict.

Require Git 2.42 outside Windows. Classify binary inputs with the pinned Git
executable in an attribute-free, helper-free context, then pass all text merge
statuses through. Binary conflicts stay local without masking merge failures.

Generated with Codex
Co-authored-by: Codex <codex@openai.com>
Merge input classification could inherit a caller's repository bindings or
large-file threshold. Plain text could then look binary and leave only the
current side in an ordinary conflict even though merge-file could preserve all
three inputs.

Clear repository bindings and counted configuration before classification.
Pin the classifier to merge-file's maximum text size so ambient Git state
cannot change the result.

Generated with Codex
Co-authored-by: Codex <codex@openai.com>
The counted-config regression proves that the nested classifier resets
GIT_CONFIG_COUNT. It cannot independently detect removal of the explicit
large-file threshold because the same reset removes its test input.

Supply a one-byte threshold through an isolated global config instead. Removing
only the 1023m pin now reproduces the current-only conflict and protects the
reason for matching merge-file's text limit.

Generated with Codex
Co-authored-by: Codex <codex@openai.com>
@roborev-ci

roborev-ci Bot commented Aug 30, 2026

Copy link
Copy Markdown

roborev: Combined Review (ae50c28)

Verdict: One medium-severity path-handling issue remains.

Medium

  • git/managed/untrusted_tree.go:76-79GIT_CEILING_DIRECTORIES is colon-delimited on POSIX, but the configured ceiling path is inserted without handling colons. Repository paths containing : can be split, allowing Git to discover the repository and honor worktree attributes or diff configuration.

    Fix: Use a classifier directory outside the repository or avoid encoding paths containing the platform’s path-list separator; add test coverage.


Reviewers: 2 done | Synthesis: codex, 5s | Total: 16m14s

The merge-driver contract says a crashed merge process must fail the whole
operation, not record a conflict. Nothing tested that. The existing coverage
stops at status 255, which the driver reaches by returning normally, so a
change that mapped every status above 128 back to a conflict would keep every
test green while restoring the original bug: an unmerged path whose working
file holds only the current side.

Kill the merge process with a signal and require the operation to abort with a
clean tree. Reintroducing the status mapping now fails this test.

Also assert that no classifier helper marker appears in the hooks directory.
The classifier runs Git there with -C, so a helper invoked from that directory
would have left its marker where nothing looked.

Document that a caller's Git runner governs process policy rather than which
Git installation the merge driver pins. Git runs the driver itself and cannot
route it back through the callback, so a runner pointing at another Git does
not redirect it.

Generated with Claude Code (claude-fable-5)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@roborev-ci

roborev-ci Bot commented Aug 30, 2026

Copy link
Copy Markdown

roborev: Combined Review (449196e)

No issues found.


Reviewers: 2 done | Synthesis: codex | Total: 14m16s

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant