Skip to content

Report exempt-path merge blocks instead of merging over them - #29

Open
gnguralnick wants to merge 12 commits into
mainfrom
gabriel/merge-honors-exempt-paths
Open

Report exempt-path merge blocks instead of merging over them#29
gnguralnick wants to merge 12 commits into
mainfrom
gabriel/merge-honors-exempt-paths

Conversation

@gnguralnick

@gnguralnick gnguralnick commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Problem

uncommitted_exempt_paths lets a dir declare that some subtree holds expected machine-generated state -- the setting's own comment names "a vendored subtree a dev loop rsyncs into the working tree". But it was wired into the clean-tree check only, and step 4 then runs a bare git merge. Git refuses to merge over working-tree state it would overwrite no matter what that check was told to ignore, so the merge aborted on exactly the state the config had just declared uninteresting.

It is not a rare case either. The base branch is where such generated state gets updated -- a release-time sync lands it on main -- so whenever such a sync is in the incoming range, the merge collides with the files the dev loop just rewrote and reports a merge conflict listing hundreds of vendored files. It is not every merge: git only refuses when the incoming change actually touches the dirty paths, so a base branch that has not re-vendored merges fine. But the collision recurs by design rather than by accident, and it is opaque when it happens.

Concretely, in mngr's .external_worktrees/default-workspace-template (which sets uncommitted_exempt_paths: ["system/vendor/mngr"] and fetch_and_merge: true), just minds-start rsyncs the local mngr tree into system/vendor/mngr on every run, deliberately uncommitted.

Fix

The first commit on this branch resolved it by resetting the exempt paths to HEAD before the merge. That was the wrong actor, so this branch drops that behavior and reports instead:

  • The hook does not know how to regenerate an exempt path -- only the repo does. Resetting it silently stripped a dev loop's live working copy, and invisibly: the log line announcing the reset never reaches anyone, because a dir's stderr is relayed only when that dir fails.
  • It also fired on any dirt under an exempt path, not just dirt the incoming merge would actually have collided with.

So _merge_ref now classifies the failure. Git's refusal names the paths it would overwrite; when every one of them is a dirty path matching the exempt pathspecs, the hook reports that specific cause and the response it calls for -- drop the generated state, merge, regenerate -- rather than "resolve the merge conflicts", which is not the fix here. Anything else still gets the generic report. Git does the pathspec matching (via ls-files/diff --cached against the configured pathspecs), so real pathspec semantics hold rather than string prefixes.

Both of git's refusals are covered: tracked modifications ("Your local changes to the following files would be overwritten by merge") and untracked collisions ("The following untracked working tree files..."). The latter is the likelier real case -- a dev loop generates files that a later base-branch sync then commits.

  • README: the row for the setting states that the hook never discards the state, and what it does instead.
  • plugin.json: 0.3.0 -> 0.4.0 (unreleased; carried from the first commit).

The consuming repo supplies the regeneration step. mngr does this in CLAUDE.md plus a just sync-vendor-mngr-live recipe, in imbue-ai/mngr-internal#374.

Also here: per-dir settings resolution in the skill prose

Riding along because it is the same "a secondary dir is not the root" class of bug, found while testing this change.

autofix and verify-architecture Phase 0 read settings twice. Step 1 (the root) says ".reviewer/settings.json (and .reviewer/settings.local.json if present)"; step 2 (each additional dir) said only "{dir}/.reviewer/settings.json". So an agent following the letter read the committed file for a secondary dir and missed whatever its gitignored local override changed. The runtime never had this bug -- read_json_config derives the .local.json sibling of whatever path it is handed, and stop_hook_gates.sh hands it each dir's path -- so this was purely the prose disagreeing with the code.

Both steps now state the same precedence explicitly.

The reported symptom was actually one step further out: an agent concluded a dir had autofix disabled, reading autofix.is_enabled from that dir's committed settings.json while its settings.local.json re-enabled it. Neither skill ever asks for that read -- per-dir gate enablement is stop_hook_gates.sh's decision -- so both now say not to do it.

Testing

bash tests/test_multi_dir_stop_hook.sh -- 60 passed, 0 failed (14 pre-existing scenarios unchanged).

Three scenarios cover the merge behavior, each with a base branch that advances and touches the exempt subtree:

  • 15 -- a blocked merge reports the exempt-path cause, names the blocking file, is not reported as a merge conflict, and leaves both the tracked and untracked local state intact. Then, restoring only the tracked file, the untracked collision blocks the same way and classifies the same. Finally, dropping the generated state lets the merge land, proving the message's own remedy works.
  • 16 -- dirt outside the list gets the generic conflict report and is not misattributed to the exempt path, even with exempt dirt present alongside it.
  • 17 -- exempt-path dirt the incoming merge does not touch is a non-event: the merge proceeds and the local copy is untouched.

shellcheck -x on the changed script is clean.

Gabriel Guralnick and others added 2 commits August 12, 2026 14:55
uncommitted_exempt_paths let a dir declare that some subtree holds expected
machine-generated state -- the setting's own comment names "a vendored subtree a
dev loop rsyncs into the working tree". But it was honoured by the clean-tree
check only. Git refuses to merge over a working tree it would overwrite no
matter what that check was told to ignore, so step 4's `git merge` aborted on
exactly the state the config had just declared uninteresting.

That is not an edge case: the base branch is where such generated state gets
updated (a sync pipeline lands it on main), so the incoming merge almost always
touches the same files the dev loop just rewrote. The result was a dir that
could never take on base-branch changes at all -- every stop-hook run reported a
merge conflict listing hundreds of vendored files, and the merge silently never
happened.

The exempt paths are now reset to HEAD immediately before the merge. Declaring a
path exempt already asserts its contents are generated and reproducible, which
is the licence to drop them. Only exempt paths are touched: anything dirty
outside them has already failed the clean-tree check (where enabled) and still
blocks the merge loudly.

Each reset goes to the dir's own stop_hook.jsonl rather than stderr -- the
orchestrator relays a dir's stderr only when that dir FAILS, so a message
emitted on this path would never reach anyone.

Tests: scenario 15 covers the merge landing over exempt dirt (and the untracked
half being cleared); scenario 16 covers non-exempt dirt still blocking, with the
local edit left intact. 50 passed, 0 failed.
Without a bump `claude plugin update` reports clients as already up to date and
never installs the change. Minor rather than patch because the hook now writes
to the working tree on a path where it previously only read: paths listed in
uncommitted_exempt_paths are reset to HEAD before the base-branch merge.

Co-authored-by: Sculptor <sculptor@imbue.com>
@hynek-urban

Copy link
Copy Markdown

Throwing away changes is fundamentally a bit dangerous. As a user, I wouldn't expect the plugin to delete my data in paths that I simply marked as "exempt" (the doc nothwithstanding). Would it make sense to rename the config key so that the danger is obvious?

(Side note: If we follow semver then this probably warrants a major version bump - not sure if it matters since I'm not a user of code-guardian :) )

In any case, up to you :) LGTM - feel free to merge as soon as you're happy with it.

The previous commit resolved this by resetting exempt paths to HEAD before
the merge. That is the wrong actor: an exempt path is machine-generated
state whose regeneration only the repo knows, and the reset silently
stripped a dev loop's live working copy (the log line announcing it never
reaches anyone, since a dir's stderr is relayed only when that dir fails).
It also fired on any dirt, not just dirt the incoming merge would touch.

Instead, classify the failure. Git's refusal to merge over working-tree
state it would overwrite names the offending paths; when every one of them
is a dirty path matching the exempt pathspecs (git does the matching, so
real pathspec semantics hold), report that specific cause and what to do
about it -- drop the generated state, merge, regenerate -- rather than the
generic "resolve the merge conflicts", which is not the fix here. Anything
else still gets the generic report. Covers both of git's refusals, over
tracked and untracked collisions alike.

Co-authored-by: Sculptor <sculptor@imbue.com>
@gnguralnick gnguralnick changed the title reset uncommitted_exempt_paths before the base-branch merge Report exempt-path merge blocks instead of merging over them Aug 13, 2026
Gabriel Guralnick and others added 9 commits August 13, 2026 12:31
The message said "See this repo's CLAUDE.md". The blocked dir is the one holding
the vendored copy, but what generates that copy is the other checkout it was
rsynced from, so the regeneration command lives there -- pointing at the blocked
dir's own instructions sends the reader somewhere that says nothing about it.
The hook cannot know which checkout owns it, so stop asserting one.

Co-authored-by: Sculptor <sculptor@imbue.com>
Phase 0 step 1 (the root read) named settings.local.json; step 2 (the per-dir
read) named only settings.json. An agent following the letter therefore read
the committed file for a secondary dir and missed anything its gitignored local
override changed -- the runtime never had this bug, since read_json_config
derives the .local.json sibling of whatever path it is handed.

Also tell the orchestrator not to read per-dir gate toggles at all. That is
stop_hook_gates.sh's decision, and it is the read that actually went wrong in
practice: an agent concluded a dir had autofix disabled from its committed
settings.json while its settings.local.json re-enabled it.

Co-authored-by: Sculptor <sculptor@imbue.com>
…exempt-paths

Renumbered this branch's exempt-path merge scenarios to 16/17/18 so main's
detached-HEAD scenario keeps 15, and renamed their locals to match (the two
sides had both claimed R15/E15).

Co-authored-by: Sculptor <sculptor@imbue.com>
Problem: _exempt_dirty_files built its comparison set with `git ls-files` and
`git diff --name-only`, which honor core.quotePath (default true) and therefore
C-quote any path with a non-ASCII byte -- "vendor/mngr/caf\303\251.py". The
blocked-path list _merge_ref extracts from git's "would be overwritten by merge"
refusal is printed raw. The grep -qxF comparison could never match such a file,
so all_exempt flipped to false and a merge blocked purely by exempt-path dirt
was reported as "Merge conflict detected" -- exactly the misdiagnosis this
feature exists to remove, and advice that cannot be followed since the merge was
aborted rather than left conflicted. One non-ASCII filename anywhere in a
vendored subtree was enough.

Fix: pass -c core.quotePath=false to both listing commands so they emit the same
raw form the merge refusal does. Scenario 16 now carries a non-ASCII file
through the exempt subtree and asserts it is named unquoted in the report;
reverting the flag drops that scenario to the generic conflict path.

Co-authored-by: Sculptor <sculptor@imbue.com>
Running the guardian against its own checkout writes .reviewer/outputs/
(issue records, plans, verification markers), which was untracked here --
so the very next run's require_committed check blocks on the dirt the
previous run created. Ignore the same two paths this repo's own test
helper already gitignores for the fixture repos it builds.

Co-authored-by: Sculptor <sculptor@imbue.com>
Problem: Phase 0 of the autofix and verify-architecture skills told the agent
to resolve stop_hook.base_branch from settings.local.json, then settings.json,
then ${GIT_BASE_BRANCH:-main}. That omits the layer read_json_config applies
first, CODE_GUARDIAN_STOP_HOOK__BASE_BRANCH, which mngr exports into every
agent it creates and which stop_hook_dir_pipeline.sh blanks for non-root dirs.
GIT_BASE_BRANCH is set by nothing in either repo. In any agent whose base
branch is not literally "main", the skills reviewed main...HEAD while the hook
merged and diffed against the exported base -- a different range than the gate
they were satisfying.

Fix: state the hook's own precedence in both skills -- the env var for the root
dir only, then the dir's settings.local.json, then its settings.json, then main
-- with the reason the env layer stops at the root, and allow the skills to read
that variable.

Co-authored-by: Sculptor <sculptor@imbue.com>
Problem: _merge_ref decided whether a refused merge was blocked purely by
exempt-path dirt by forking one `grep -qxF` per blocked path, each scanning the
whole exempt list. The case this exists for is a vendored subtree of several
thousand files against a base branch carrying a revendor, and git prints every
rejected path, so that is thousands of processes over a haystack of comparable
size -- on the path where the hook is already blocking. It also passed each
path as a bare grep argument, so a path starting with "-" was parsed as options
and silently downgraded a real exempt-path block to a merge conflict.

Fix: ask the question directly with a single `grep -vxFf` -- are there blocked
paths that are not exempt? -- reading the exempt list as a pattern file. With no
exempt paths configured the pattern file is empty, so every blocked path
survives the inversion and the generic conflict report is reached, as before.

Co-authored-by: Sculptor <sculptor@imbue.com>
Problem: the new gitignore block covered .reviewer/outputs/ and .reviewer/logs/
but not .reviewer/settings.local.json, which is generated state of the same
kind -- every reviewer-* toggle skill this plugin ships writes it, README.md
and config_utils.sh both describe it as gitignored, and both repos the guardian
actually reviews ignore it. Running one of this plugin's own skills against this
repo therefore left an untracked file that this plugin's own clean-tree check
blocked on. The README's setup checklist named only the two runtime dirs, so it
propagated the same hole to every adopting repo.

Fix: add the path to this repo's .gitignore and name it in the README bullet,
with the reason it appears.

Co-authored-by: Sculptor <sculptor@imbue.com>
…ssage

Problem: _merge_ref decided whether a refused merge was blocked only by
exempt machine-generated state by parsing the path list out of git's
"would be overwritten by merge:" text. Git formats that message through
error()'s fixed 4096-byte buffer, so beyond ~4KB the list is cut off
mid-line and its final entry is a path fragment. That fragment matches
no line of the exempt listing, so the hook fell through to the generic
"Merge conflict detected" report -- the exact misdiagnosis this code
exists to remove, and in its motivating case: the vendored subtree it
was built for holds thousands of files (3923 in the mngr worktree), so
truncation is the normal path, not an edge case. Reproduced with a new
scenario (400 exempt files, git 2.50.1): reported as a merge conflict.

Fix: derive the blocked set from git plumbing instead. Every path git
can name in that refusal is both dirty and written by the incoming
merge, so intersect the dirty listings (split by the exempt pathspecs)
with `git diff --name-only --no-renames HEAD...<ref>`; the block is an
exempt-path block iff some exempt path is in it and no non-exempt one
is. --no-renames so a rename contributes its source as well as its
destination. The tab-parsing sed is gone. The report now shows a
20-path sample and the total count: the complete list is no longer
bounded by git's own 4096-byte cap, and this stderr is relayed to the
agent verbatim.

Co-authored-by: Sculptor <sculptor@imbue.com>
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.

2 participants