Converge the annex plumbing: required=true always, pinned where PATH cannot serve - #193
Merged
Conversation
…cannot serve `filter=annex` makes the researcher's ordinary `git add` dispatch git-annex — through the filter drivers in `.git/config` and the four hooks `git annex init` writes, all resolved from *their* shell's PATH. When that PATH holds no git-annex (the engine driven through uvx puts nothing on it), the failure is the worst kind: git prints `git-annex: command not found`, exits 0, and stages the raw bytes into git history — a multi-gigabyte dataset in git proper, silently, on every clone forever. Measured, and now pinned by test_stock_plumbing_without_required_stages_raw_bytes_silently. Dataset convergence therefore owns the plumbing as one item (`annex-plumbing`), converged right after `git annex init`: - `filter.annex.required=true`, unconditionally. The same situation becomes git's own hard refusal (`fatal: … clean filter 'annex' failed`, exit 128, nothing staged), and the refusal's remedy is one `lc init`. - The filter drivers and hooks are pinned to the absolute path of the engine's bundled git-annex (the sibling of sys.executable) exactly where the shell's own PATH resolves none — probed with the engine's bin directory stripped out, since uvx and `uv run` front it on lc's environment and a naive which always finds the bundled copy. Where PATH does serve, the stock form is kept: it already works, and it outlives a prunable uv-cache path. - Repair only ever touches a broken state. A pin that runs is left alone even when ambient git-annex has appeared or the pin is not this engine's copy — both work, and rewriting would flip-flop between a tool install and a uvx cache. A pin that no longer runs is rewritten toward stock when the shell resolves git-annex, else toward the current engine's copy, and reports `repaired`; `required=true` makes the broken interval loud instead of corrupting. Hooks keep git-annex's own shebang and marker comment, which is also the ownership test — a hook without the marker is the user's and is never touched, by the write and the drift check alike. The stock spellings are mirrored from a real `git annex init`, never invented (test_stock_spellings_mirror_what_git_annex_itself_writes), so an upstream change fails the suite rather than repairing every project forever. `require_git_annex` stays a PATH check on purpose: it gates lc's *own* `git annex` subprocesses, which every install channel serves by fronting the bundled copy. Verified end to end: with the plumbing pinned and no git-annex on PATH, a plain `git add` stages an annex pointer and `git commit` runs the pinned pre-commit hook; a dead pin fails loudly, `lc init` reports it repaired, and the same add then succeeds. Every command and refusal quoted in the docs was executed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsttSNGJ8kEMavA27ezW9L Signed-off-by: Francois Lanusse <fr.eiffel@gmail.com>
Contributor
✅ Eval
lc statusConfusion & pain points (Claude analysis)Confusion & pain points
Full trace: |
The pin is reverted before merge; `filter.annex.required=true` stays. `git annex init` resolves git-annex from the PATH of whichever git runs, so a researcher whose shell has none gets a `git add` that prints an error, exits 0, and stages the raw bytes into git history. The flag makes that git's own hard refusal instead, and it is the whole fix: one per-repository config key, no absolute path, nothing that can go stale. Why the pin goes: - It wrote durable repository state pointing at a prunable uvx cache. - `core.hooksPath` can resolve outside the repository, so the four hooks could land in a machine-shared directory and break commits in unrelated repositories. - uv documents uvx environments as disposable; `uv cache prune` deletes them, and the path is a symlink into cache internals. - git-lfs solves the same problem with the same mechanism and has never baked a path: `filter.lfs.required = true` beside PATH-relative drivers. Reachability is the install's job, not the repository's: `uv tool install lightcone-cli` links the git-annex wheel's entry points beside `lc`, so PATH resolves it for free. uvx runs lc fine but cannot support a bare `git add`, which the docs now say. Deleted with the pin: _FILTERS, _HOOKS, _HOOK_MARKER, pinned_annex, annex_runs, _annex_spellings, _hooks_dir, _config, and project's bundled_annex / ambient_annex / _engine_bin. No hook file is written and no filter driver git-annex wrote is rewritten. Both measured tests stay: the loud refusal, and the silent raw-bytes staging it prevents. The stderr assertion no longer pins bash's wording, which dash — /bin/sh on Debian and Ubuntu — does not produce. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016MWNSzVFUMmieXA9ayPtbL
`annex_filter_required` asked `git config --get`, which reads the merged system + global + local config, while `require_annex_filter` writes the repository-local one. Measured: with `filter.annex.required = true` in `~/.gitconfig`, `--get` returns true/exit 0 while `--local --get` exits 1 — so on such a host `lc init` reported the item unchanged and never wrote the key into the project at all. The protection then vanished the moment that repository was used under another HOME (a clone, CI, a container), with `--check` still calling it converged. `--type=bool` fixes the mirror case: `1`, `yes`, `on` and a valueless key are all true to the filter, and the string compare read them as drift, so `--check` failed on a repository that was already correct. Both directions are pinned by tests against real git, and both were mutation-checked — four of the five fail without the fix. Docs: the refusal is wider than "a `git add` refuses". Measured on a project holding committed annexed content, `git status`, `git diff` and `git checkout` all abort with exit 128 too, so the troubleshooting entry and the init and architecture pages now say the project reads as broken until git-annex is back, which is the intended trade. CLAUDE.md records the external check: the rule neighbours agree on is route-through-a-filter ⇒ set `required`. git-lfs routes and sets it; `datalad create` does neither, and a plain `git add` in a DataLad dataset stages raw bytes into git even with git-annex working, its safeguard being procedural (`datalad save`). lc promised the researcher their own `git add`, so lc owns the failure mode that promise creates. Also `GIT_CONFIG_NOSYSTEM` in the researcher-shell fixture, so a site-wide `/etc/gitconfig` cannot decide those measurements. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016MWNSzVFUMmieXA9ayPtbL
The Tests workflow has been red on macOS since the branch opened, on both measured tests, for one underlying reason: Linux git and macOS git handle a filter that will not start differently. Linux git reports the failed handshake, falls back to smudge/clean, and with `required` unset **exits 0 and stages the raw bytes** — the hazard this whole change exists to close. macOS git instead dies on the pkt-line read with `fatal: the remote end hung up unexpectedly`, exit 128, whether or not the flag is set. So: - The loud-refusal test asserts the two facts that are the claim — nonzero exit, nothing staged — instead of pinning `clean filter 'annex' failed`, which is Linux's wording. (Same lesson as the bash/dash one, one level up.) - The silent-passthrough test is Linux-only by `skipif`, because the behaviour it records is Linux's. macOS being safe there is a git implementation detail that can change on any release, not a guarantee — the flag is what makes it one, so this is a reason to keep the flag unscoped, not to narrow it. CLAUDE.md records the asymmetry beside the storage invariants: the hazard is Linux's, the guarantee is ours, and CI running both platforms is what surfaced it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016MWNSzVFUMmieXA9ayPtbL
`require_*` means "assert this precondition or refuse" everywhere else in the engine — `require_uv`, `require_git`, `require_git_annex`, `require_committer` all raise ProjectError and mutate nothing. This one writes config, so the prefix said the opposite of what it does, and both return None, so nothing would have caught the misreading. `set_annex_filter_required` also pairs by name with the `annex_filter_required` probe. The two have to agree about config scope, which is exactly where the local-versus-merged bug came from, so a name that makes a future divergence obvious is worth having. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016MWNSzVFUMmieXA9ayPtbL
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.
The problem (measured)
git annex initwires the repository to a baregit-annex— the filter drivers in.git/configand four hooks — resolved from whatever PATH the researcher's git runs under. lc itself always has git-annex (it bundles one), but the shell the researcher typesgit addinto may hold nothing of the sort: an engine invoked throughuvxputs nothing on PATH. In that state, a plaingit addof annexed content printsgit-annex: command not found, exits 0, and stages the raw bytes into git history — a 2 GB dataset lands in git proper, silently, on every clone forever. This failure mode is recorded as a measured test in this PR.The fix: convergence owns the plumbing
lc initgains anannex-plumbingconvergence item, right aftergit annex init:filter.annex.required = true, always. The silent exit-0 corruption becomes git's own hard refusal (fatal: ... clean filter 'annex' failed, nothing staged), withlc initas the remedy — quoted verbatim from a real run in the troubleshooting doc.ambient_annex(): PATH minus the engine's own bin directory, because launchers front that directory on lc's environment and a naivewhichfrom inside lc always finds the bundled copy. A stock spelling that works outlives any prunable engine path.process, plus thesmudge/cleanfallback pair so both routes resolve the same binary) and the four hooks. Measured: with the filter pinned,git addstages a pointer andcommitruns the pinned pre-commit hook with no git-annex on PATH at all.lc init --checkreports drift without writing.automatically configured by git-annexmarker; the hooks directory is asked ofgit rev-parse --git-path hooks, so linked worktrees andcore.hooksPathbehave.require_git_annex()stays a PATH check, unchanged: it gates lc's owngit annexsubprocesses, which dispatch from lc's process PATH — a different question from the researcher's shell, and recorded as such in CLAUDE.md.Verification
ruff checkclean,mypy src/strict clean,zensical buildclean.--checkflags drift and writes nothing, user hook untouched, probe regressions (thesys.executablesymlink resolve; a tool install's~/.local/bin/git-annexsymlink counting as genuinely ambient).required=truealone failing hard with nothing staged; the exit-0 raw-bytes hazard pinned as a measured fact; stock spellings pinned against a realgit annex init.docs/cli/init.md,docs/user/troubleshooting.md,docs/api/{project,dataset}.md,docs/architecture.md, CLAUDE.md invariants — every added command block executed before shipping.Companion change: LightconeResearch/agent-skills#34 carries a skill-side interim snippet doing the same convergence from the outside; once this ships in a release, that snippet reduces to nothing.
🤖 Generated with Claude Code
https://claude.ai/code/session_01LsttSNGJ8kEMavA27ezW9L