Skip to content

Converge the annex plumbing: required=true always, pinned where PATH cannot serve - #193

Merged
EiffL merged 5 commits into
mainfrom
annex-plumbing
Aug 21, 2026
Merged

Converge the annex plumbing: required=true always, pinned where PATH cannot serve#193
EiffL merged 5 commits into
mainfrom
annex-plumbing

Conversation

@EiffL

@EiffL EiffL commented Aug 21, 2026

Copy link
Copy Markdown
Member

The problem (measured)

git annex init wires the repository to a bare git-annex — the filter drivers in .git/config and 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 types git add into may hold nothing of the sort: an engine invoked through uvx puts nothing on PATH. In that state, a plain git add of annexed content prints git-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 init gains an annex-plumbing convergence item, right after git 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), with lc init as the remedy — quoted verbatim from a real run in the troubleshooting doc.
  • Where the researcher's shell resolves git-annex, stock plumbing is kept untouched. The probe is ambient_annex(): PATH minus the engine's own bin directory, because launchers front that directory on lc's environment and a naive which from inside lc always finds the bundled copy. A stock spelling that works outlives any prunable engine path.
  • Where it doesn't, the plumbing is pinned to the engine's bundled git-annex by absolute path: the three filter drivers (process, plus the smudge/clean fallback pair so both routes resolve the same binary) and the four hooks. Measured: with the filter pinned, git add stages a pointer and commit runs the pinned pre-commit hook with no git-annex on PATH at all.
  • Asymmetric repair. A pin that still runs is never rewritten — even when PATH could now serve, or it is not this engine's copy — because both work and rewriting would flip-flop between a tool install and a uvx cache. Only a broken pin is repaired: toward stock when the shell resolves git-annex, else to the current engine's copy. lc init --check reports drift without writing.
  • User hooks are never touched. Ownership is gated on git-annex's own automatically configured by git-annex marker; the hooks directory is asked of git rev-parse --git-path hooks, so linked worktrees and core.hooksPath behave.

require_git_annex() stays a PATH check, unchanged: it gates lc's own git annex subprocesses, which dispatch from lc's process PATH — a different question from the researcher's shell, and recorded as such in CLAUDE.md.

Verification

  • Full suite green (608 passed, run twice), ruff check clean, mypy src/ strict clean, zensical build clean.
  • New unit coverage (host-independent, fake tools): fresh pin, stock-plus-required, idempotent re-run, dead pin repaired to engine, dead pin repaired to stock, working pin surviving ambient git-annex appearing, --check flags drift and writes nothing, user hook untouched, probe regressions (the sys.executable symlink resolve; a tool install's ~/.local/bin/git-annex symlink counting as genuinely ambient).
  • New real-annex integration tests: pointer staged and pinned hook run with git-annex absent from PATH; required=true alone failing hard with nothing staged; the exit-0 raw-bytes hazard pinned as a measured fact; stock spellings pinned against a real git annex init.
  • Docs: 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

…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>
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

✅ Eval

Metric Value
Outputs check success
Agent run success
Turns 33
Tool calls 31
Cost $1.33
Agent wall time 5m35s
Model claude-sonnet-5
lc status
  mode:    direct
  sandbox: landlock (fs: declared, network: allowed)
  crate:   up to date with the outputs

  · current  baseline/best_fit        770afd0
  · current  baseline/hubble_diagram  770afd0
  · current  baseline/residuals       770afd0

3 current
Confusion & pain points (Claude analysis)

Confusion & pain points

  • Missing numpy blocked initial data exploration: the agent tried python3 -c "import numpy..." against the bare system interpreter (not the project .venv) and hit ModuleNotFoundError, forcing a fallback to hand-rolled stdlib parsing. Root cause: no awareness/reminder that analysis dependencies live in the project's uv-managed environment, not the ambient python3. A harness nudge (or lc/skill guidance) to always invoke .venv/bin/python — or run through uv run — would avoid this detour.
  • Reverse-engineered the data file's column semantics via a live web search: the SCPUnion2.1 table's 4th/5th columns (error, probability/weight-like field) weren't self-describing, so the agent used WebSearch plus a ToolSearch lookup to find search tooling before it could even search. This is a data/eval-fixture gap — a data/README.md note on column meaning would save a network round-trip and remove ambiguity risk in the fit itself.
  • Invented a systematic-error term not present in the data: with only one uncertainty column available, the agent derived a stat_and_sys quadrature term from the difference between two calibration constants quoted in the file's comment header, rather than from any declared/structured input. This is a workaround for missing provenance in the source data (no separate stat-only vs. sys columns), and it's the kind of ad-hoc scientific judgment call that ideally would be flagged for human review rather than silently baked into the recipe.
  • Otherwise a clean, linear run: spec validation, lc materialize, license declaration, and RO-Crate generation all worked on the first try with no CLI misunderstandings or sandbox denials.

Full trace: agent-trace artifact on this run.

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
EiffL and others added 3 commits August 21, 2026 17:00
`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

@EiffL EiffL left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@EiffL
EiffL merged commit 789d44f into main Aug 21, 2026
10 of 11 checks passed
@EiffL
EiffL deleted the annex-plumbing branch August 21, 2026 16:42
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