Skip to content

feat(coordination): add a git merge-tree textual verdict to in-flight conflicts - #497

Merged
clay-good merged 28 commits into
mainfrom
feat/merge-tree-conflict-oracle
Sep 13, 2026
Merged

clay-good merged 28 commits into
mainfrom
feat/merge-tree-conflict-oracle

Conversation

@clay-good

@clay-good clay-good commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Status

LGTM. Four parallel adversarial reviews, then 22 verification rounds, closed every confirmed finding (1 security issue and 37 false cleans); the last verification round found no false clean and no repository-chosen command. CI is green on caf879228, including Windows unit tests and Windows smoke.

What was missing

map_in_flight_conflicts reports symbol-level hazards (WAW, RAW, shared-append, WAR). It could not say whether git will actually conflict. Two branches that edit different parts of resolveCallSite were a WAW, the same as two branches that rewrite the same lines. Those need different landing decisions. A shared-append pair was always called "safe to land in either order", even when git would conflict.

What it does

  • Each conflict pair gets textualMerge:

    • textual-conflict, with the conflicted files named;
    • clean-automerge, where the hazard is behavioral only;
    • not-assessed, with a detail.
  • The oracle (merge-oracle.ts): git merge-tree --write-tree -z --no-messages --merge-base=<base> (the mode/stage lines flag submodule conflicts) between the two tip commits. The merge base is resolved in the real repository.

  • Read-only and safe on an untrusted repository. Probes on git 2.50 showed two problems:

    • merge-tree --write-tree writes objects into .git/objects.
    • It runs a repository-chosen merge driver, even from $GIT_DIR/info/attributes, and --attr-source does not stop it.

    So the merge runs in a new, empty bare repository in the OS temp directory. It reads the real objects through objects/info/alternates, and it is removed in finally. Inherited GIT_DIR and GIT_OBJECT_DIRECTORY-style variables are stripped.

  • Never silently clean. These cases are not-assessed:

    • no merge base (shallow clone, unrelated histories);
    • several merge bases (criss-cross history);
    • a PR head commit (the new headRefOid) that is not present locally;
    • an agent task;
    • a cross-repo pair;
    • the 60-simulation cap for one call (with a caveat).
  • Output changes:

    • The suggestion notes a textual conflict or a clean auto-merge, and a conflicting shared append is no longer called safe.
    • The headline counts textual conflicts.
    • textualConflictCount is added.
    • A caveat says that repository merge drivers and attributes are ignored.

Round-1 review fixes

Finding Fix
Partial clone: cat-file -e / merge-base lazy-fetched and ran a repository-chosen remote.origin.uploadpack command GIT_NO_LAZY_FETCH=1 on every analyzed-repo read in this tool (incl. its existing git show / git diff). Repo-wide hardening filed as a separate task.
False clean: -merge, binary, merge=binary (also via info/attributes) git check-attr (base, both tips, repo files) on paths both sides change; any non-default merge attribute is not-assessed
False clean: repo-local merge.renames=false Rename settings are read with git config and forwarded as -c values; merge.renormalize is not-assessed
False conflict: submodule gitlink not-assessed (the scratch repo cannot see submodule commits)
Response over 256 KB with long paths Paths capped at 240 chars, suggestion names one file plus a count, boundResponse halves until it fits
No overall time limit 20-second budget; each spawn gets min(30 s, remaining); the rest is not-assessed
Misleading details, racy scratch-dir test, Windows file:// URL, branch diff vs tip Command-free details, scratchParent option, pathToFileURL, diff mergeBase..tip

Round-2 verification fixes

Finding Fix
False clean: merge.default=binary (or a custom driver) merge.default other than text is not-assessed
False clean: A renames f.txt to f.bin (*.bin -merge), B edits f.txt Attributes are checked on paths changed by either side, not only both
False clean: replace refs GIT_NO_REPLACE_OBJECTS=1 on every read; replace refs or grafts are not-assessed
branch.<name>.mergeOptions not-assessed
Git 2.40–2.44 ignores GIT_NO_LAZY_FETCH Config is read first; a partial clone on git older than 2.45 is not-assessed before any object read

Round-3 verification fixes

Finding Fix
False clean: a merge driver named text, set, or unspecified (check-attr prints it like the default state) merge.*.driver keys are read; those names are not-assessed
False clean: analysis root is a subdirectory (diff paths are top-relative, check-attr is cwd-relative) Every read runs from rev-parse --show-toplevel
merge.renormalize=2 / remote.X.promisor=2 not treated as true Git boolean parsing (any non-zero integer)
gitPathArgs hides diff from the hardened runner's driver flags --no-ext-diff --no-textconv passed explicitly

Round-4 verification fixes

Finding Fix
False clean: merge.text.name / merge.text.recursive without a .driver line Any merge.<name>.* section named text/set/unspecified is not-assessed
False clean: repo diff.algorithm=patience diff.algorithm and diff.indentHeuristic forwarded; a test compares against git merge-tree in the repository itself for patience, myers, and histogram
Denylist kept missing settings Any repository-local merge.* key outside an allowlist of output/tooling keys is not-assessed
False clean on macOS: decomposed (NFD) path with a matching attribute check-attr runs with core.precomposeunicode=false
False clean on case-insensitive checkouts: .GITATTRIBUTES, Sub/.gitattributes for sub/f.txt Root and ancestor directories of changed paths are listed per tree; a case variant is not-assessed
False clean: core.worktree pointing into another repository not-assessed when the top level resolves to a different git directory

Round-5 verification fixes

Finding Fix
False clean: core.ignorecase=false/unset on a case-insensitive filesystem The case-variant check always runs
False clean: sub/ and Sub/ both ancestors of changed paths Two changed directories that differ only by case are not-assessed
False clean: a newline in a config value forged core.ignorecase false git config -z parsing
False clean: :/Sub/.gitattributes read as pathspec magic by ls-tree --literal-pathspecs

Round-6 verification fixes

Finding Fix
False clean: Unicode folds JS toLowerCase misses (.gitattributeſ, ς/ vs σ/, ß/ vs ss/) A non-ASCII name in a changed directory or beside a changed path is not-assessed
False clean: merge.renames "" (git reads empty as false; the oracle forwarded true) -z value parsing: no = is true, empty is false; an empty forwarded value is not-assessed
Uncapped ls-tree directory argv Capped at 24,000 characters

Round-7 verification fixes

Finding Fix
False clean: merge.conflictStyle=diff3/zdiff3 (skips conflict refinement) Forwarded (only merge, diff3, zdiff3); a test compares against git merge-tree in the repository for all three
False clean: pull.twohead=resolve/octopus Anything but ort/recursive is not-assessed
Global includeIf "gitdir:" reaches only the real repository The merge.* allowlist applies to every scope
Forwarded key with no value (git dies on it) not-assessed
NTFS aliases (trailing dot/space, 8.3 short names) Refused beside changed paths

Round-8 verification fixes (systematic config-key pass)

Finding Fix
False clean: -c diff.algorithm is ignored by merge-tree (read only for porcelain merges), and the test's ground truth was merge-tree too Forwarded as -X diff-algorithm=; the test's ground truth is now a real git merge in a worktree, with a non-vacuity check that myers changes the result
False clean: pull.twohead=ORT or "ort " (git is case- and space-sensitive) Exact comparison of the raw value
Bad value on a strictly parsed key (merge.stat, commit.cleanup, core.bigFileThreshold, …) makes git merge die Value must parse, or not-assessed
Changed path with a .git component (a real merge refuses to check it out) not-assessed

Round-9 verification fixes

Finding Fix
False clean: merge.stat " true", commit.cleanup " strip", merge.renames " true" (git does not trim quoted values) Strict and forwarded values are checked untrimmed
False clean: merge.log=-1, merge.verbosity=6 (git rejects; merge-ort asserts 0–5) Ranges checked
False clean: sub/.git./x, sub/GIT~1/x, .git::$DATA/x, a .gitmodules symlink (a real checkout refuses them) Both tips and the merged tree must pass read-tree with core.protectNTFS and core.protectHFS in the scratch repository
Over-refusals (merge.ff=Only, merge.log=1k, diff.algorithm=Myers) Relaxed to match git

Round-10 verification fixes

Finding Fix
False clean: merge.stat=3g, merge.log=2g, core.bigFileThreshold=17179869184g (out of git's range) Integers parsed like git: sign, k/m/g units, 32-bit ranges
False clean: -diff (or a binary diff driver) changes rename detection Attribute allowlist: check-attr -a on changed paths; anything outside text, eol, crlf, whitespace, export-*, linguist-* is not-assessed

Round-11 verification fix

Finding Fix
False clean: each side removes a different line that cleared filter, so only the merged .gitattributes applies a required filter a real merge runs check-attr -a --source=<merged tree> in the scratch repository (no config, so no filter runs) with the same allowlist

Round-12 verification fix

Finding Fix
False clean: with merge.directoryRenames=true, A moves d/ to e/ and B adds d/y; the merge writes e/y, which no check saw (a required filter on e/y fails the real merge) diff-tree from each tip to the merged tree; a path outside the checked set is not-assessed

Round-13 verification fixes

Finding Fix
Lost verdicts: the unseen-path check turned real conflicts (default directory rename, file/directory p~<id>) into not-assessed The unseen-path and merged-tree attribute checks run only for a clean merge (they exist to prevent a false clean)
An untracked local file can make a local git merge fail Out of scope (in-flight changes merge as commits); the caveat now says local uncommitted and untracked files are not considered

Round 13 found no false clean on commits.

Round-14 verification fix

Finding Fix
False clean: vendoring a submodule (gitlink replaced by ordinary files with the same names) collides with the checked-out submodule's files in a real merge A path whose mode changes to or from 160000 on either side is not-assessed; submodule bumps stay assessed

Round-15 verification fix

Finding Fix
False clean: a changed path git accepts but a checkout filesystem refuses (a 300-byte name, a 1,206-byte path on macOS) A changed path with a name over 255 bytes or a total over 1,000 bytes is not-assessed

Round-16 verification fix

Finding Fix
False clean: a symlink target of 1,024 bytes or more (git stores it; creating the link fails on macOS) A changed symlink whose target is 1,000 bytes or longer (ls-tree -l size) is not-assessed

Round-17 verification fix

Finding Fix
False clean: A adds README, B edits readme (they overwrite each other on a case-insensitive checkout) A changed file that differs from another changed path or a sibling entry only by letter case is not-assessed
A base that already cannot be checked out still gets a verdict Out of scope: the caveat states the verdict assumes each commit can already be checked out on its own

Round-18 verification fix

Finding Fix
False clean against a fresh clone: a local merge.renames=false or merge.directoryRenames=false was forwarded, modeling the local repository; a fresh clone or hosted merge does not carry it and reports the conflict Only git's defaults are assessed: renames off, a rename limit, merge.directoryRenames other than conflict, or a non-default diff.algorithm is not-assessed (the -X diff-algorithm path is removed)

Round-19 verification fix

Finding Fix
False clean: a symlinked d/.gitattributes whose target text is f merge hides the root d/f merge=binary from check-attr --source, while a real merge ignores the symlink and reports a binary conflict The directory listing carries modes; a .gitattributes with mode 120000 in the base or either tip is not-assessed

Round-20 verification fixes

Finding Fix
False clean: a UTF-8 byte-order mark on line 1 of .gitattributes (git strips it from a file on disk, not from a blob) A .gitattributes blob with a BOM in the base or either tip is not-assessed
False clean: a NUL byte in .gitattributes (the blob parser stops there; the file parser reads on) A .gitattributes blob with a NUL byte is not-assessed
False clean: a .gitattributes gitlink pointing at a blob (a real merge ignores it; check-attr --source reads it) Any .gitattributes mode other than 100644/100755 is not-assessed

Round-21 verification fix

Finding Fix
False clean: .gitattributes ident expands $Id$ on checkout and pushes a line past git's 2,048-byte attribute line limit, so a real merge drops a rule check-attr --source still reads The attribute allowlist also applies to each .gitattributes itself in the base and both tips; a .gitattributes line of 2,000 bytes or more is not-assessed

Round-22 verification fix

Finding Fix
False clean: a local .git/info/attributes (.gitattributes !ident) outranks a tree's .gitattributes in check-attr --source, hiding a rule a fresh clone applies Every tree attribute read runs with --git-dir=<scratch>, which has no info/attributes; the work-tree read stays in the analyzed repository

Proof

  • merge-oracle.test.ts (real git, 31 tests, including partial-clone lazy fetch, merge attributes, forwarded rename config, merge.renormalize, a submodule conflict, and a spent budget):
    • disjoint edits to one function give clean-automerge; same-line edits give textual-conflict;
    • the object count and git status do not change, and no scratch directory is left behind;
    • a merge driver from .gitattributes and info/attributes does not run, with a non-vacuity control that the same merge inside the repository does run it;
    • unrelated histories, criss-cross history, an unknown tip, an injected tip, and a shallow clone all give not-assessed.
  • interference-map.test.ts: 8 new tests cover the annotation, suggestions, headline, missing tip, agent task, cross-repo pair, a rejected simulation, the 60 cap, branch tip and PR headRefOid plumbing, and a forged oid.
  • Spec archived: 2026-09-13-add-merge-tree-conflict-oracle. openspec validate --specs --strict passes: 16 of 16.

Notes

  • Narrowed to one verdict per pair (file level). Mapping conflicted hunks to individual witness symbols is deferred.
  • Two changes that only add the same path share no base symbol, so they form no hazard pair and get no verdict. That gap remains, and the code comment says so.
  • Needs git 2.40 or later for --merge-base (2.43 for -X when diff.algorithm is set). Older git gives not-assessed with git's error. GIT_NO_LAZY_FETCH needs git 2.45+; on older git a partial clone's lazy fetch is not disabled.
  • Pre-existing, not changed here: refExists / resolveBaseRef in src/core/drift/git-diff.ts (used to resolve the base ref) still run without GIT_NO_LAZY_FETCH, and so do other tools' git reads. Central hardening in execFileGit is filed as a separate task.
  • A process killed mid-merge can leave an openlore-merge-* temp directory (about 12 KB typical); there is no sweep.
  • The standing-context token table in docs/mcp-tools.md is updated for the longer tool description.

🤖 Generated with Claude Code

clay-good and others added 28 commits September 13, 2026 02:03
… conflicts

Each map_in_flight_conflicts conflict pair now carries textualMerge
(textual-conflict | clean-automerge | not-assessed) from git merge-tree
between the tip commits over a merge base resolved in the real repo.
The merge runs in a scratch bare repository with an alternates file, so
the analyzed repository gets no objects and none of its merge drivers or
attributes run. Missing or criss-cross bases, non-local PR heads, agent
tasks, cross-repo pairs, and the 60-simulation cap are not-assessed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ayload budget

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e oracle

Adversarial review fixes:
- disable git lazy fetch on analyzed-repo reads (a partial clone ran a
  repository-chosen uploadpack command)
- check merge attributes on paths both sides change and refuse
  merge.renormalize; forward rename settings (each gave a false clean)
- not-assessed for submodule conflicts
- 20-second simulation budget with per-spawn timeouts; command-free
  failure details
- response-size loop, capped paths, conflictedFileCount
- diff the resolved branch tip; scratch parent for race-free tests

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- merge.default and branch.<name>.mergeOptions are not-assessed
- merge attributes are checked on paths changed by either side, so a
  rename onto an attributed name is caught
- replace refs and grafts are not-assessed; GIT_NO_REPLACE_OBJECTS on
  every read
- a partial clone on git older than 2.45 is not-assessed (lazy fetch
  cannot be turned off there); config is read before any object read

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- stop dropping conflicts when node details alone exceed the budget;
  cap node details; cut conflicts before findings
- cap merge-attribute paths by total characters (Windows argv) and cap
  paths in not-assessed details
- report the time budget when a spawn is killed at the deadline

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- a merge driver named text, set, or unspecified is not-assessed
  (check-attr prints it like the default merge state)
- run every read from the repository top level, so a subdirectory
  analysis root no longer hides merge attributes
- parse git booleans (non-zero integers are true)
- pass --no-ext-diff --no-textconv explicitly (gitPathArgs hides diff)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tory merge settings

Round-4 verification fixes:
- any repository-local merge.* key outside a harmless allowlist is
  not-assessed; any merge.<name>.* driver section named text, set, or
  unspecified is not-assessed (no .driver line needed)
- forward diff.algorithm and diff.indentHeuristic
- check-attr runs with core.precomposeunicode=false (NFD paths)
- on a case-insensitive checkout, a case-variant attributes file or
  ancestor directory is not-assessed
- a core.worktree that resolves to another git directory is not-assessed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round-5 verification fixes:
- the case-variant attributes check runs on every platform; a real merge
  on a case-insensitive filesystem ignores core.ignorecase
- two changed ancestor directories that differ only by case are
  not-assessed
- read config with -z so a newline in a value cannot forge an entry
- list directories with --literal-pathspecs (names starting with ':')

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ig values

Round-6 verification fixes:
- a non-ASCII name in a changed directory or beside a changed path is
  not-assessed (filesystems fold Unicode case beyond toLowerCase)
- git boolean parsing from -z output: no value is true, empty is false;
  an empty forwarded value is not-assessed
- cap the ls-tree directory argv at 24,000 characters

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…the merge oracle

Round-7 verification fixes:
- forward merge.conflictStyle (diff3 skips conflict refinement, so it
  changes whether a merge conflicts); only merge, diff3, zdiff3
- pull.twohead other than ort/recursive is not-assessed
- the merge.* allowlist applies to every config scope (global includeIf
  gitdir can reach only the real repository)
- a forwarded key with no value is not-assessed (git dies on it)
- refuse NTFS-aliasing names (trailing dot or space, 8.3 short names)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
NTFS cannot hold a path containing ':' and git for Windows refuses to
check one out (core.protectNTFS), so the literal-pathspec case cannot
occur there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…acle

Round-8 verification fixes (systematic pass over git merge's config readers):
- forward diff.algorithm as -X diff-algorithm (merge-tree ignores the
  config key); the test's ground truth is now a real git merge
- compare pull.twohead exactly (case- and space-sensitive)
- keys git merge parses strictly must hold a parseable value
- a changed path with a .git component is not-assessed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rotection

Round-9 verification fixes:
- strict and forwarded config values are checked untrimmed (git does not
  trim quoted values), with git's ranges (merge.log >= 0, verbosity 0-5)
- both tips and the merged tree must pass read-tree with
  core.protectNTFS and core.protectHFS, or the pair is not-assessed
  (.git., GIT~1, .git::$DATA, .gitmodules symlinks)
- relax over-refusals: any merge.ff value, int unit suffixes,
  case-insensitive diff.algorithm

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…in the merge oracle

Round-10 verification fixes:
- read all attributes (check-attr -a) on changed paths; anything outside
  text/eol/crlf/whitespace/export-*/linguist-* is not-assessed (the diff
  attribute changes rename detection)
- parse config integers like git: sign, k/m/g units, 32-bit ranges
  (merge.stat=3g, merge.log=2g now refused)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round-11 verification fix: each side can remove a different line that
cleared an attribute, so only the merged tree applies it (for example a
required filter a real merge would run). check-attr -a --source=<merged
tree> runs in the scratch repository with the same allowlist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… oracle

Round-12 verification fix: with merge.directoryRenames, a directory
rename places paths (e/y for an added d/y) outside the changed-path set
every attribute and path check covers. diff-tree from each tip to the
merged tree finds them; any unseen path is not-assessed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… new paths

Round-13 verification fixes:
- run the unseen-path and merged-tree attribute checks only for a clean
  merge; they exist to prevent a false clean, and a conflict verdict
  never claims clean (directory-rename and file/directory conflicts keep
  their textual-conflict verdict again)
- the caveat states that local uncommitted and untracked files are not
  considered

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… oracle

Round-14 verification fix: vendoring a submodule (its gitlink replaced by
ordinary files with the same names) collides with the checked-out
submodule's files in a real merge. A path whose mode changes to or from
160000 on either side is not-assessed; submodule bumps stay assessed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round-15 verification fix: a path git accepts can exceed a checkout
filesystem's limits (a 255-byte name, about 1,024 bytes on macOS), which
fails a real merge's checkout. A changed path with a name over 255 bytes
or a total over 1,000 bytes is not-assessed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…system allows

Round-16 verification fix: git stores a symlink target of any length, but
creating the link fails past the filesystem path limit (1,024 bytes on
macOS), which fails a real merge. A changed symlink whose target is
1,000 bytes or longer (ls-tree -l size) is not-assessed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… oracle

Round-17 verification fix: a changed file that differs from another
changed path or a sibling entry only by letter case (README vs readme)
overwrites it on a case-insensitive checkout, which fails a real merge.
The caveat now states the verdict assumes each commit can already be
checked out on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…on in the merge oracle

Round-18 verification fix: forwarding a local merge.renames=false,
merge.directoryRenames, a rename limit, or a non-default diff.algorithm
modeled the local repository, but a fresh clone or a hosted merge does
not carry those settings and reports the conflicts they hide. Such a
setting is now not-assessed; only git's defaults are assessed. The
unused -X diff-algorithm path is removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round-19 verification fix: a real merge ignores a symlinked
.gitattributes, but check-attr --source reads its target text as
attribute lines, which can hide a rule (merge=binary) the merge applies.
The directory listing now carries modes, and a .gitattributes with mode
120000 in the base or either tip is not-assessed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round-20 verification fix: a real merge reads only a regular
.gitattributes from disk, strips a UTF-8 byte-order mark, and reads past
a NUL byte; check-attr --source reads a gitlink's object as attribute
lines, keeps the BOM on line 1, and stops at a NUL. A .gitattributes with
a mode other than a regular file, a BOM, or a NUL byte in the base or
either tip is not-assessed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…erge oracle

Round-21 verification fix: a checkout attribute on a .gitattributes (for
example ident) rewrites the file a real merge reads, which can push a
line past git's 2,048-byte attribute line limit that check-attr --source
still reads from the blob. The attribute allowlist now also applies to
each .gitattributes in the base and both tips, and a line of 2,000 bytes
or more is not-assessed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round-22 verification fix: check-attr --source run in the analyzed
repository lets its local .git/info/attributes outrank a tree's
.gitattributes, which can hide a rule (such as ident on .gitattributes)
that a fresh clone applies. Every tree attribute read now runs with
--git-dir=<scratch>, which has no info/attributes; the work-tree read
stays in the analyzed repository, where it can only add restrictions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@clay-good
clay-good merged commit 6b7d42d into main Sep 13, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant