Report exempt-path merge blocks instead of merging over them - #29
Open
gnguralnick wants to merge 12 commits into
Open
Report exempt-path merge blocks instead of merging over them#29gnguralnick wants to merge 12 commits into
gnguralnick wants to merge 12 commits into
Conversation
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>
|
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
uncommitted_exempt_pathslets 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 baregit 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 setsuncommitted_exempt_paths: ["system/vendor/mngr"]andfetch_and_merge: true),just minds-startrsyncs the local mngr tree intosystem/vendor/mngron 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:
So
_merge_refnow 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 (vials-files/diff --cachedagainst 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.
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.mdplus ajust sync-vendor-mngr-liverecipe, 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.
autofixandverify-architecturePhase 0 read settings twice. Step 1 (the root) says ".reviewer/settings.json(and.reviewer/settings.local.jsonif 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_configderives the.local.jsonsibling of whatever path it is handed, andstop_hook_gates.shhands 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_enabledfrom that dir's committedsettings.jsonwhile itssettings.local.jsonre-enabled it. Neither skill ever asks for that read -- per-dir gate enablement isstop_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:
shellcheck -xon the changed script is clean.