Removed
-
The
make install-manualandmake uninstall-manualtargets are deleted: install-manual was a second installer that had silently drifted into being broken — it copied the hook scripts into the git template directory under theirlocal-override-*source names (git only runs hooks namedpost-checkoutetc., so the installed hooks never fired), never installed managed wrapper hooks, and configured no filter driver, leaving a template install that looked successful but did nothing.make install/make uninstall(which delegate to the canonicalscripts/install.sh/scripts/uninstall.sh) are the supported paths -
The unsafe
get_override_for_fileresolver function is deleted: it returned the absolute override path for a target without the symlink-safety gate, was superseded by the read-side front doorresolve_safe_override_for_file(which anchors the containment check on the true resolution root), and had zero callers left anywhere in the CLI, hooks, installers, or tests. Keeping it around meant the safe front door had an ungated twin one autocomplete away from reintroducing the SEC-01 anchor-bypass class; it fails the deletion test outright, so it's gone -
The
hooks/local-override-resolver.shbyte-identical mirror is gone: the source tree carried a second 1592-line copy of the shared resolver, held in sync by hand under amake check-resolver-syncCI diff gate. Almost nothing consumed it — every install path (install.shrepo/global, the CLI, tests) copies or sourcesshared/local-override-resolver.sh, and the pre-commit-framework channel runs hooks from a full clone wherelocal-override-lib.sh's../shared/fallback resolves. Its two real consumers are rewired:make install-manualnow copies the shared resolver into the template hooks dir explicitly (it used to ride along via thehooks/local-override-*wildcard), and the filter-driver self-heal (plan 050) — which required the resolver to sit next to the hook entry points and so depended on the mirror inside the framework's cache clone — now copies the resolver from the lib's already-resolved$SHARED_RESOLVER_PATH, which handles both the hooks-sibling and../sharedlayouts. The mirror and the sync gate are deleted;make lint(and CI) now instead fail if a resolver copy ever reappears underhooks/
Added
-
End-to-end test for the
install.sh --globalvalue proposition: the existing global-install test asserted only that the template dir, hooks, andinit.templateDirexist — a broken global filter-config line would have shipped silently. A new install integration test runs the global install inside the sandboxedHOME/XDG_CONFIG_HOME,git inits a brand-new repo, and proves the whole inherited chain fires: the filter driver resolves from global config only (no local entries), a real branch switch makes the template post-checkout hook arm.git/info/attributes, a file checkout smudges the override content in, staging puts the original tracked bytes back in the index (file-basedcmp), and no local driver was self-healed into place — so the work provably ran on the inherited global driver (install 33→34) -
Tests pinning two config-stamp correctness properties: a linked-worktree integration test proves each checkout keeps its own config stamp under its own git dir with divergent config sets staying isolated (
listin each worktree reports only that worktree's targets — a shared/stale stamp would leak one checkout's config set into the other), and a unit test proves deleting a stamped gitignored config triggers the full-discovery fallback and drops its targets (only the cksum-edit drift case was covered before; deletion is a distinct branch). Both characterize existing behavior — no product change (unit 127→128, worktrees 23→24)
Changed
-
Test fixtures and assertions now cross the resolver's seam instead of re-encoding its private formats: the linked-worktree fixture hand-wrote the three
filter.local-override.*config lines and the literal attributes line (a private re-encoding of whatconfigure_filter_driverandsync_attributes_entriesown — the fixture would keep passing even if the real writers changed shape), and three unit tests hardcoded internal on-disk paths (the config stamp's filename under the git dir ×2, the reapply-state filename ×1). The fixture now calls the real writers, and the path assertions go through the owning accessors (get_config_stamp_file,get_post_commit_state_file), so a behavior-preserving rename of either internal file no longer breaks green tests. The.git/info/attributesline-format assertions elsewhere are deliberately kept: that format is git's interface, not the resolver's private representation -
One owner for each build/test list: three lists each had multiple hand-synced owners. (1) The Docker suite table was encoded three times inside
tests/docker/entrypoint.sh(arg-parse case, run-all ordering, help string) and re-enumerated per-line by the Makefile — the entrypoint now has oneSUITE_NAMESlist + one name→runner mapping (withrun_suitewidened to pass through args, absorbing the copy-pasted filterprocess bookkeeping),make test-dockerrunsallin a single container, the bash3 target passes its four suites in one invocation, and the macOS CI job loopstests/integration/test-*.shinstead of naming each script. One deliberate hardening: a missing suite script now fails the run instead of printing an info line and passing (a silently skipped suite is a false green). (2) The version-pin file list lived separately inscripts/release.sh(the writer) andtests/check-docs-sync.sh(the checker) — both now sourcescripts/pin-manifest.sh, so a new pinned doc reaches the releaser and the CI gate together. (3) The unit suite's ~140-line hand-maintained test registry is replaced by definition-order auto-discovery oftest_*()functions — defining a test IS registering it (previously a defined-but-unregistered test silently never ran);STRICT_PASS_COUNTstill enforces onepass()per test -
The managed runtime has one manifest and one copy-based materializer: "which files make up the runtime" (four hooks, three filter scripts, the lib, the resolver) was hand-carried by six sites — install.sh's two install paths, uninstall.sh's artifact removal, the unit-test fixture, two integration fixtures that inlined their own copies (one even hand-wrote the filter config), the bench harness, and a
make install-manualtarget — and the copies had already drifted (the test fixture skipped the process filter; install-manual installed prefixed hook files git never runs and configured no filter driver). The resolver now owns the manifest (managed_hook_types,managed_filter_scripts,managed_runtime_files, plusmanaged_runtime_source_pathfor the shared/-vs-hooks/ source layout) and a copy-based materializer,install_managed_runtime_from_checkout <project-dir> <dest> [include-entry-hooks]. install.sh and uninstall.sh iterate the manifest with their own acquisition (the curl-pipe path has no checkout to copy from);install_test_hooks, both integration fixtures, and the bench delegate to the materializer. install.sh's duplicatedGIT_LOCAL_OVERRIDE_FILTER_PROCESSopt-in block also folds into oneresolve_filter_mode+report_filter_mode_configuredpair. A new unit test pins the materializer's layout contract (unit 141→142) -
The parse-error sentinel is filtered once, not by every consumer: the raw per-config parser signals a bad path entry IN-BAND (a
__LOCAL_OVERRIDE_PARSE_ERROR__|configline — process substitution swallows its return code), and four separate consumers each had to remember to skip it (read_config,get_override_for_target,get_config_for_target, and the pre-commit hook); forgetting one would let a malformed marker flow through as a target. A new resolver reader,read_valid_config_entries_for_file, owns the skip — every consumer exceptvalidate_config(the one gate that inspects the sentinel and refuses) now reads through it, and the four hand-rolled filters are gone. Alongside it, the "is this override active" predicate (override file exists at the resolution root AND target exists in the checkout) — re-derived inline by the CLI's apply counting and both internal shell-wrapper commands — is now the resolver'soverride_is_active. Behavior unchanged (unit 140→141) -
Pre-commit's last inline knowledge moved behind seams — the hook is now only git mutations and messages: three pieces of decision logic survived the earlier decider extraction. The Group expansion ("all targets sharing this override") was re-implemented inline twice — because
get_targets_for_overridereads the full config rather than the hook's already-computed relevant entries — and is now the resolver'stargets_for_override_in_entries, the Group concept as one callable function. The new-target refusal (staged blob of a HEAD-less target byte-identical to its override) was an anonymous two-condition branch while its merge-leak sibling had a name; it is nowprecommit_new_target_leaks. And the reapply-state record — whose format (target|absolute-override, anchored at the resolution root, deduped) pre-commit hand-built inline whilelocal-override-lib.showned only the reader — is now written byrecord_reapply_state, the writer counterpart ofreapply_post_commit_state, so the record format lives behind one seam and round-trips in a unit test instead of only through full commit flows. Behavior unchanged (unit 137→140) -
One restore-side front door for putting tracked content back onto a target: the Restore direction — the exact mirror of
apply_override_to_target— was hand-rolled five ways (pre-commit,restore, pre-rebase,remove, and the shell wrapper) with two mechanisms and two symlink-gate policies; three of the five sites had no gate at all. A new resolver function,restore_target_to_head <checkout-root> <target> <worktree|full> [loud|trace], now owns the whole sequence: the HEAD-existence guard (returns 1 when there is nothing to restore from — replacing twols-filesguards whose failure the old sites masked with|| truewhile still printing "Restored"), the target symlink gate at every site (load-bearing, because the worktree write is a blob redirect that would follow a symlink), the unconditional worktree write (git show, nevergit checkout, which trusts the stat cache and silently no-ops on stat-clean applied overrides), and filter suppression viaGIT_LOCAL_OVERRIDE_DISABLE=1— the only mechanism that works in both filter modes (a-c filter.local-override.smudge=override suppresses nothing in process mode, where git never consults the smudge key). Two modes:worktree(working tree only —restoreuses it so a user's staged change is not clobbered) andfull(working tree + index — pre-commit, pre-rebase,remove, the shell wrapper). Pre-commit maps a failed restore to an aborted commit, preserving its fail-closed behavior. One deliberate semantic change from the guard swap:remove(andrestore) now resurrect a target whose deletion was staged — remove means "stop overriding, put the original back" (unit 134→137) -
Pre-commit's decisions are now directly testable resolver functions: the hook's trickiest branching — which overrides get a grouped restore, and whether a staged managed target is leaking override bytes — lived only inside its
main(), reachable solely by staging files and running a realgit committhrough installed hooks; the byte-exact leak check (mktemp +git show :path+cmp) was even duplicated twice within the hook (the merge/cherry-pick backstop and the new-target refusal). Three resolver functions now own the decisions, mirroring the smudge/clean filter-core split:staged_blob_matches_override(the shared leak predicate, one implementation for both refusals),precommit_plan_restores(entries + staged paths in → unique override files needing a grouped restore out; pure, unit-testable with string fixtures), andprecommit_find_merge_leak(prints the first leakingtarget|overrideduring a merge/cherry-pick). The hook keeps only git mutations and the user-facing refusal messages; behavior is unchanged (unit 132→134) -
One write-side front door for applying overrides: the security-sensitive "apply an override onto a target" sequence — existence checks, the target symlink gate, the override symlink gate anchored on the TRUE resolution root (the SEC-01 bypass class), and the copy — was implemented three times, in the CLI's
apply, the post-checkout hook's apply loop, and the aborted-commit reapply heal (which carried a five-line comment explaining its hand-rolled anchor recovery), with the refusal message duplicated at seven sites. A new resolver function,apply_override_to_target <checkout-root> <resolution-root> <target> <override> <loud|trace>— the write-side counterpart of the read-sideresolve_safe_override_for_file— now owns the whole sequence: it accepts absolute override paths (the reapply state records them absolute) and recovers the anchored relative form itself, logs refusals once (loud stderr, or trace-only for the routine per-commit reapply case), and returns a status (applied / skipped / refused / copy-failed) that callers map to their own presentation and post-actions. One user-visible normalization: post-checkout now distinguishesrefusing symlinked overridefromrefusing symlinked path(the CLI's more informative form) instead of printing the target-refusal message for both (unit 131→132) -
One writer for the filter-driver config: the
filter.local-override.*contract (script names, the%fconvention,required=false, and the scripts-vs-process mode choice) was re-expressed by hand at four sites —install.sh's repo and template paths, the CLI'ssync-filters, and the hooks' self-heal — which had to agree by comment ("same config cmd_sync_filters and install.sh write"). A new resolver function,configure_filter_driver <repo-root|global> <script-dir> <scripts|process>, is now the sole writer; it owns mode exclusivity (configuring one mode unsets the other, so the config never claims both drivers at once), reads no environment switches (callers resolve theGIT_LOCAL_OVERRIDE_FILTER_PROCESSopt-in), and never dies. This also fixes a latent inconsistency:sync-filtersused to write scripts-mode config over an experimental localfilter.processopt-in while leavingprocessset — both modes configured at once, with process silently winning.sync-filtersnow detects a local process opt-in and preserves it (refreshing its path if drifted) instead of reverting it (unit 129→131) -
The uninstaller now sources the shared resolver instead of re-implementing its primitives:
uninstall.shcarried ~70 hand-rolled lines — its ownget_common_git_dir,get_attributes_file, a managed-attributes stripper that re-encoded the managed-block format from scratch (and could silently drift from it), and a copy of the managed-hook marker predicate. It now locates and sources the resolver via an offline-only ladder (source checkout → CLI data dir → current repo's.git/hookscopy → template hooks dir; functions load into memory before any file is removed, and an installed resolver's formats match what's actually on disk), calls the canonicalsync_attributes_entrieswith empty entries to drop the managed block (foreign lines kept; an existence guard keeps uninstall from creating an attributes file), and degrades gracefully — warning and skipping hook/attributes cleanup — when no resolver copy exists anywhere. The managed-hook marker (MANAGED_HOOK_MARKER_PREFIX,managed_hook_marker_line,is_managed_wrapper_hook) now has one definition, in the resolver, shared by installer and uninstaller (the CLI'sdetect_hooks_installeddeliberately keeps its looser "is something of ours here?" display heuristic);install.shsources the resolver once at startup, its deadread_config_pairswrapper is deleted, and a new install integration test proves a standalone copy ofuninstall.sh(run from outside the checkout) still cleans hooks and attributes via an installed resolver copy (install 34→35) -
One support-file locator instead of six hand-rolled fallback ladders: "where does shared code live" (dev checkout vs. installed location) was re-answered independently at six sites with subtly different fallback orders and error modes — three in the CLI alone (the resolver bootstrap,
get_version_path, andcmd_shell_init's copy of the same dance) plus two identical dev-vs-curl blocks insideinstall.sh. The resolver is now the bootstrap: a newlocate_support_file <name>(anchored on the resolver's own sourced location, falling back to the checkout root for source trees and then the CLI data dir) owns the policy once, and the CLI's VERSION/shell-init lookups delegate to it. Each entry point keeps exactly one irreducible ladder — finding the resolver itself — andinstall.sh's two internal resolver-fetch blocks collapse into a memoizedget_resolver_lib_filehelper with a single EXIT-trap cleanup (unit 128→129) -
doctor --fixnow repairs all three states it diagnoses as fixable:--fixonly actually repaired a missing filter driver; the other two states doctor detects with a wired remedy — attributes out of sync with the effective config, and legacy skip-worktree bits left by pre-filter installs — still printed a manual "Run 'git-local-override sync-filters'" hint even under--fix, despite the repair being the verysync-filtersdelegation--fixalready used. Checks 5 and 6 now report "repairing via sync-filters" under--fixand trigger one sharedcmd_sync_filtersrun after both checks (never in read-only mode, which is unchanged); repaired states are not counted as warnings/failures, so the exit code reflects the post-fix state, matching the missing-driver repair (unit 124→127) -
The hot-then-full discovery kernel now lives in the shared resolver: the "cache hot with stamped paths → re-check the config stamp → fall back to a full walk on drift" sequence read-only CLI commands use was implemented inline in the CLI's
cache_config_files_readonly. It is now a resolver helper,discover_config_files_hot_then_full, that the CLI delegates to — behavior-identical (same trace lines, same walk counts), making the resolver the documented home of the pattern; the post-checkout hook deliberately keeps its own woven-in variant because its drift path also re-syncs attributes and writes the stamp (unit 123→124) -
listanddoctornow share one symlinked-override classifier: the two commands each re-derived the followed / ignored / tracked-refused / dangling state of a symlinked override independently and had already begun to drift. A new resolver helper,classify_symlinked_override, returns a single state token that both commands map onto their existing display strings — output is byte-identical to before; this is a pure extraction so future states are added once and reach both commands (unit 122→123)
Fixed
-
The restore front door can no longer be smuggled outside the repo through a symlinked directory:
restore_target_to_headcreated missing parent directories before its blob-redirect write, andmkdir -pfollows a symlinked directory component while the symlink gate is deliberately lenient about parents that do not resolve yet — together, a working tree whose ancestor directory was replaced by a symlink pointing outside the repo (with the resolved subpath not yet existing) let the restore write tracked HEAD content outside the checkout (found by adversarial review of this series, with an end-to-end repro; reachable from every restore caller, though it requires a hostile config plus an attacker-shaped working tree). The front door no longer creates parent directories: a missing resolved parent fails the restore closed (rc 3, no write — pre-commit aborts the commit), and a symlinked parent resolving to an existing outside directory is refused by the gate (rc 2). A unit test pins both variants with no-outside-write assertions -
A curl-pipe uninstall against an old install no longer half-uninstalls: the uninstaller's cleanup loops now derive from the resolver's runtime manifest, but the resolver it sources is by design the installed (possibly older) copy — and pre-manifest resolvers don't define those functions. A failed command substitution inside a
for-loop word does not tripset -e, so the loops iterated nothing: the CLI, data-dir resolver, and filter config were removed while every managed hook artifact stayed installed, and the run still printed "Uninstallation Complete!" (found by adversarial review, with a repro). The uninstaller now defines fallback manifest functions (the historical file set) when the sourced resolver lacks them; an install integration test pins the old-resolver path end-to-end -
removenow actually restores the original when a filter driver is active:removerestored with a baregit checkout HEAD -- <target>— no filter suppression — so with a configured filter driver and the override file still present, the smudge filter served the override content right back into the "restored" file:removeprinted "Restored original content" while the working tree kept the override bytes. The restore front door suppresses filters with the env kill-switch (covering both scripts and process modes), andremoveonly prints the success message when the restore actually happened (a new unit test runs remove under an active driver; the old test never configured one, which is why the bug survived) -
The shell wrapper's pre-checkout restore now works from subdirectories and under the process filter: the wrapper looped
git checkout HEAD -- <target>from the user's current directory, but targets are repo-root-relative — from any subdirectory the pathspec resolved against the wrong directory and the restore silently failed (2>/dev/null). Its-c filter.local-override.smudge=suppression also did nothing in the experimental process mode (git never consults the smudge key whenfilter.<driver>.processis set), andcheckoutskips stat-clean files anyway — which applied overrides always are. The wrapper now makes one call to a new internal CLI command,_restore-active-targets, which anchors on the repo root and restores every active target through the front door; the wrapper's duplicated_get-active-targetsprobe is also collapsed to a single call -
Merge and cherry-pick resolutions of managed files now survive the concluding commit: the pre-commit hook restored every staged managed target to
HEAD:targetwith only a rebase guard, and during an in-progress merge or cherry-pick HEAD is still "ours" (the pre-merge tip) — so concluding a conflictedgit mergeorgit cherry-pick(or agit merge --no-commit) withgit commitsilently overwrote the staged resolution with ours' content: the incoming branch's changes to a managed file vanished from the merge commit with no error. A new sharedis_merge_or_cherry_pick_in_progresspredicate (MERGE_HEAD / CHERRY_PICK_HEAD file checks, alongsideis_rebase_in_progressin the resolver) makes pre-commit skip the HEAD-restore during a merge/cherry-pick so genuine resolutions survive — with a byte-exact backstop closing the leak the skip would otherwise open: while a path is unmergedgit show :pathfails, so the clean filter passes working-tree bytes through duringgit add(and the smudge filter serves override content into the conflicted file, making it "look fine"), meaning a blindgit addof the unedited conflicted file stages override bytes verbatim; pre-commit now refuses the commit (non-zero, actionable message modeled on the new-target refusal) when a staged managed target's blob is byte-identical to its existing override file. No reapply state is written on the skip path — nothing was restored, so there is nothing for post-commit to undo (same shape as the rebase bail); normal commits,--no-verifysemantics, and the new-target refusal are unchanged (git-ops 39→44) -
Hooks now self-heal a missing filter driver, closing the pre-commit-framework install gap: the "recommended for teams" pre-commit-framework install copies only the four hook entry points and never configures
filter.local-override.*, so thefilter=local-overrideattribute lines written on the first branch switch referenced a driver git treats as identity — causing checkout/merge friction ("local changes would be overwritten"), a permanently dirtygit status, and, for path-limited commits, the override left staged in the main index where a latergit commit --no-verifycommitted it verbatim. The post-checkout and pre-commit hooks now detect an unconfigured driver in a repo with config to enforce, copy the filter machinery (smudge/clean scripts + lib + resolver) from their own directory — in the framework path the pre-commit cache clone, whose path is deliberately NOT written into config because it moves on rev bumps andpre-commit gc— into the stable$GIT_COMMON_DIR/hooks/, and set the samefilter.local-override.smudge/clean/requiredconfiginstall.shandsync-filterswrite. The heal is a one-time repair: when any driver is already configured (install.sh, template install,sync-filters, or the experimentalfilter.processopt-in) the check is a singlegit configlookup with zero writes, and when the filter scripts are not shipped next to the hooks it degrades to a trace-level note without breaking the hook (precommit 9→10) -
A refused symlinked override is now a uniform benign skip everywhere:
applyused todieon the first refused symlinked target/override (opt-in off, or a tracked symlink), aborting mid-loop — with multiple configured overrides, earlier entries applied, later ones did not, and.git/info/attributeswas left unsynced, an order-dependent partial state — even thoughgit checkout, the filters,doctor, andlistall treat the same repo as a benign skip/warn.applynow warns and continues per entry (matching the post-checkout cp loop), so remaining overrides still apply and attributes stay synced; the per-fileaddandrestorerefusals are unchanged (single-target commands, fail-loud is correct there). The post-commit reapply refusal — which printed a scaryrefusing symlinked pathstderr line on every commit when an ignored symlinked override exists — now logs at trace level only (GIT_LOCAL_OVERRIDE_TRACE=1); the refusal itself is unchanged (unit 121→122) -
applynow writes the config stamp after its full discovery walk, matchingsync-filters:applyandsync-filtersare both documented as ways to register a newly created gitignored config, but onlysync-filtersrecorded the resolved config set in the config stamp. A gitignored config registered viaapplylanded in.git/info/attributesyet stayed absent from the stamp — invisible to hot discovery (not tracked, not stamped) and invisible to the stamp comparison — so a later edit to that config adding/removing a target was silently missed by the post-checkout fast path until a full walk was forced.apply_to_checkoutnow writes the stamp after syncing attributes (its discovery cache is full-mode, so the stamp captures the complete set), leaving the same statesync-filtersdoes;apply --all-worktreesstamps each worktree via the per-checkout loop (unit 117→118) -
Post-checkout now re-syncs a deleted or truncated
.git/info/attributesfile: if the attributes file was removed or emptied out-of-band while the config stamp still matched and tracked configs were unchanged, every subsequent branch checkout took the fast path, "succeeded" with an empty entry set, and never rebuilt the managedfilter=local-overrideblock — silently disabling the filter driver so overrides stopped applying and originals could leak into commits. The fast path now disqualifies itself when the attributes file is missing or empty, forcing the slow path to re-sync attributes and rewrite the config stamp (git-ops 38→39)
Security
-
Config target paths can no longer inject attribute macros or glob patterns into
.git/info/attributes: managed target paths from.local-overrides.yaml(attacker-controlled in the malicious-repo threat model) were written verbatim into.git/info/attributes, a valid macro-definition location — so a crafted target could redefine a git attribute macro or wire the filter to every path via a wildcard, silently changing attribute semantics in the victim's checkout (no content leak: the filters re-check an exact config match before substituting).validate_confignow rejects any target containing a glob/attribute metacharacter (*?[]), and the attribute-line writer quotes targets containing whitespace, double quotes, or backslashes in git's double-quoted gitattributes form — which also closes the plan-010 gap where a space-containing target produced an unquoted line git rejected, leaving the filter driver unwired for that file (unit 118→121) -
Closed a repo-containment bypass in the override read gate: a symlinked parent directory inside an override path could bypass the repo-containment check in the smudge/clean filters and the post-commit reapply, because those call sites anchored the check on the override's own parent directory instead of the true resolution root. All override-read sites now go through a single safe resolver front door (
resolve_safe_override_for_file) that anchors containment on the resolution root — the same convention the CLI already used — so an override path traversing a repo-shipped symlinked directory is refused regardless of thelocal-override.followSymlinkedOverridesopt-in (the opt-in covers only the override file itself, never path components). The post-checkout apply loop was also aligned onto the opt-in-aware override gate, so a legitimate user-created untracked symlinked override is now applied there too, matching the filters (unit 113→117)