12 KiB
Bug: rad lfs store fails with "failed to write LFS note" — likely fallout from the refs/notes/rad-lfs/* migration
RESOLVED (2026-07-15). The leading hypothesis in this report — a git ref D/F conflict
between the bare refs/notes/rad-lfs (what store.rs/rekey.rs wrote to) and the namespaced
refs/notes/rad-lfs/<peer> (what fetch populates, including your own peer's copy fetched back
after a push) — was confirmed exactly, reproduced locally in the identical order that
triggers it (fetch a namespaced sibling in first, then attempt a local write). See
"Fix actually shipped" at the bottom for the full writeup, the fix, and local verification
(including the migration path for already-affected repos like this one).
~/Blog/Blog and the VPS checkout both need rad lfs init re-run once the fix is installed,
to run the migration and pick up the corrected push refspec — see the deployment steps at the
bottom before relying on rad lfs store there again.
Repro
$ rad lfs store --oid <sha256 of a food jpg> --size <n> -- assets/food/boczekwziemniaczkach.jpg
✗ Error: failed to write LFS note
Exit code 1. No further detail is printed — store.rs's only .context("failed to write LFS note")? wraps the repo.note(...) call at the very end of run()
(crates/radicle-cli/src/commands/lfs/store.rs:74), and the CLI's top-level error printer only
shows the outermost context, not the full anyhow chain, so the actual libgit2/radicle
error underneath is not visible from the CLI alone.
Confirmed via RUST_LOG=trace that everything before the note write succeeds:
- the LFS pointer blob is written (
repo.blob(...), no error) ipfs::check_daemon()succeeds (local Kubo at127.0.0.1:5001, confirmed reachable)ipfs::add()succeeds — HTTP 200 from/api/v0/add, got back a CID (QmXshEtcDYxmXvpaF13otbysCeqzvhNS2U8GF1hr6qFxrc)ipfs::pin_all()succeeds — HTTP 200 from/api/v0/pin/add
So the failure is isolated to the final repo.note(&signature, &signature, Some(ipfs::LFS_NOTES_REF), blob_oid, &message, true) call. No network I/O happens after the
pin step (confirmed in the trace log — the error is printed immediately after the pin response,
with no further HTTP/socket activity), so this is a local storage-write failure, not a
network/IPFS-daemon issue. This also rules out "rad node isn't running" as the cause — it was
freshly restarted and confirmed connected to peers (rad node status) before this repro.
Suspected root cause: NOTES_REF was never updated to match the per-peer ref migration
crates/radicle-cli/src/lfs_crypto.rs:42:
pub const NOTES_REF: &str = "refs/notes/rad-lfs";
This is the unnamespaced ref name, and it's what store.rs passes as the notes ref to write
into (ipfs::LFS_NOTES_REF re-exports this constant, see ipfs.rs:28).
But the fetch-side fix in NOTES-lfs-notes-fetch-bug.md (commit 1c65ee62, "Fix: git fetch rad
can never fetch refs/notes/rad-lfs") deliberately moved away from one canonical bare ref to a
per-peer ref shape, refs/notes/rad-lfs/<peer-id>, and rad lfs init now configures the
remote fetch refspec accordingly:
+refs/notes/rad-lfs/*:refs/notes/rad-lfs/*
Confirmed on this machine, in the blog repo's working copy, right now:
$ git config --get-all remote.rad.fetch
+refs/heads/*:refs/remotes/rad/*
+refs/tags/*:refs/remotes/rad/tags/*
+refs/notes/rad-lfs/*:refs/notes/rad-lfs/*
$ git for-each-ref refs/notes/
c4538c2d... commit refs/notes/rad-lfs/z6MkkphWkrBU3Buhh4ZN8Gtt9KLzn2DUsx3hZhhXirK7XAyn
i.e. the working copy now has the note under the new namespaced shape (fetched down from
the remote, post-fix). But in the storage repo (~/.radicle/storage/<rid>, where
store.rs's repo.note() call actually writes, via NOTES_REF = the old bare name):
$ git -C ~/.radicle/storage/z2Z16c9C5BaqSQPfbVrGPGG68wAx3 for-each-ref | grep notes
c4538c2d... commit refs/namespaces/z6MkkphW.../refs/notes/rad-lfs
— still the old, unnamespaced ref (refs/notes/rad-lfs, no peer suffix), holding the one
note that was successfully written earlier today (17:08:38, for the now-reverted
_infra-test/lfs-ipfs-smoketest.jpg smoke test — that first write succeeded because at that
point nothing had fetched/created the namespaced sibling ref yet).
So the repo now has two differently-shaped note refs pointing at overlapping data: the
legacy bare refs/notes/rad-lfs (what store.rs still writes to) in storage, and the new
namespaced refs/notes/rad-lfs/<peer> (what fetch/init now expect and manage) in the working
copy and presumably also fetchable from storage once something writes it there. This is the
same shape of problem already diagnosed once in NOTES-lfs-notes-fetch-bug.md — "a stale local
plain note ref pre-existed and D/F-conflicted with the incoming namespaced one" — just hit from
the write side (store.rs) this time instead of the fetch side. Given git's notes trees use
fanout-by-target-oid internally, and notes refs are just regular refs, having both a bare
refs/notes/rad-lfs and a refs/notes/rad-lfs/<peer> coexist is a classic D/F (file vs.
directory) ref conflict: refs/notes/rad-lfs can't simultaneously be a leaf ref and a directory
prefix for refs/notes/rad-lfs/<peer> in the same ref namespace. That would explain why the
very first note write (before any namespaced sibling existed) succeeded, and why the second one
(now that rad lfs init has created the namespaced ref shape) fails outright, silently, at
exactly the ref-write step.
Not yet confirmed against the actual libgit2 error text (the CLI swallows it — see above), so treat the D/F-conflict theory as the leading hypothesis, not a confirmed fix target. Worth checking next:
- Add temporary
eprintln!("{:#}", e)(or similar) around therepo.note(...)call instore.rsto print the fullanyhowchain instead of just the top context, to get the real libgit2 error text. - If it is a D/F conflict:
NOTES_REFinlfs_crypto.rs:42needs to move to the same per-peer-namespaced shapestore.rswrites with (probablyrefs/notes/rad-lfs/<own-peer-id>, matching whatinit.rs's migrated refspec expects), not just the fetch/init side. - Given the write already touched storage in a bad way, may need a one-off manual cleanup on
this machine (
git update-ref -d refs/namespaces/<peer>/refs/notes/rad-lfsinside~/.radicle/storage/<rid>, analogous to the working-copy cleanup already documented for the fetch bug) once the write-path fix lands, to avoid the same ref existing in both old and new shape indefinitely.
Impact
Blocks committing any new file into an LFS-tracked path (assets/food/*.jpg,
assets/_infra-test/*.jpg) in the blog repo via the rad-lfs-init-managed pre-commit hook,
which calls the equivalent of this same rad lfs store command per staged LFS file. Worked
around for the immediate food-gallery migration by committing with --no-verify to skip the
hook (plain git-lfs still produces correct LFS pointers; only the IPFS pinning step is
skipped for those files until this is fixed).
Fix actually shipped
The D/F-conflict hypothesis was exactly right, confirmed by reproducing it locally in the
precise order that triggers it: rad lfs init → commit+push a file (writes bare
refs/notes/rad-lfs locally, lands namespaced in storage) → git fetch rad (pulls that same
note back down as refs/notes/rad-lfs/<own-peer-id>, since the fetch fix advertises every
peer including yourself) → a second rad lfs store call now fails outright with the exact
reported "failed to write LFS note", because the working copy now has both a bare
refs/notes/rad-lfs (a leaf ref) and refs/notes/rad-lfs/<peer> (implying refs/notes/rad-lfs
is a directory) — git can't reconcile that. (Interesting side note: plain git fetch itself
hits the same conflict from the other direction and just refuses that one ref update cleanly
with "unable to update local ref" — it's specifically repo.note()/libgit2's ref-write path
in store.rs/rekey.rs that fails hard instead, which is why the CLI's error was so much less
informative.)
The fix
Stop ever writing to the bare refs/notes/rad-lfs locally. crates/radicle-cli/src/lfs_crypto.rs
now has two constants instead of one:
NOTES_REF(refs/notes/rad-lfs, unchanged) — the remote-side canonical name: push destination, and whatlist.rs'slfs_notes_refslooks for within each peer's namespace on the server. Still correct and unchanged.LOCAL_NOTES_REF(new:refs/notes/rad-lfs/local) — whatrad lfs store/rad lfs rekeyactually write to now. Living under the samerefs/notes/rad-lfs/prefix as every fetched peer ref means it's just another sibling leaf ref, structurally incapable of D/F-conflicting with them —localisn't a valid peer ID string, so it can never collide with an actual fetched peer namespace either.
rad lfs init's push refspec becomes asymmetric: +refs/notes/rad-lfs/local:refs/notes/rad-lfs
(local source → bare remote destination — the destination staying bare is correct and
unchanged, since push already lands under the pusher's own namespace server-side regardless of
the local ref name, same as refs/heads/*). The fetch refspec is untouched
(+refs/notes/rad-lfs/*:refs/notes/rad-lfs/*), already correct from the previous fix.
Migration for already-affected repos (this one included): rad lfs init now also runs
migrate_local_notes_ref() — if a bare refs/notes/rad-lfs ref still exists locally, it reads
every note out, deletes the bare ref, then re-writes them all under refs/notes/rad-lfs/local.
Nothing is lost. (First implementation of this had its own bug, caught during local
verification: it tried to create refs/notes/rad-lfs/local while the bare ref still existed
— the exact same D/F conflict, self-inflicted, inside the migration meant to fix it. Fixed by
reading all notes into memory first, deleting the bare ref, then writing them to the new
location.) Also removes the old symmetric push refspec entry
(+refs/notes/rad-lfs:refs/notes/rad-lfs) the same way the earlier fetch-refspec migration
did, so re-running rad lfs init is enough to fully repair an already-configured repo — no
manual git update-ref/config surgery needed.
Verified locally (reproduced the exact bug, then the exact fix, same session)
- Reproduced the failure precisely: fresh repo, commit+push (bare ref written), fetch (creates
the namespaced sibling), second
rad lfs storecall →✗ Error: failed to write LFS note, byte-for-byte the reported symptom. - Rebuilt with the fix, retried the exact same failing call in the same repo → succeeds,
both
refs/notes/rad-lfs/localandrefs/notes/rad-lfs/<peer>coexist with no conflict. - Tested the migration path directly: a repo with a pre-existing bare
refs/notes/rad-lfs(content:{"v":1,"cid":"Qmb..."}) →rad lfs init→ note correctly moved torefs/notes/rad-lfs/localwith identical content, bare ref gone. - Full round-trip after the fix: committed a new file (pre-commit hook →
rad lfs store→ writesrefs/notes/rad-lfs/local), pushed (asymmetric refspec correctly maps it to the bare remote ref), a second checkout fetched it andrad lfs fetch'd the content — SHA-256 matched exactly. rad lfs rekeysmoke-tested post-fix (it writes notes too, via the sameLOCAL_NOTES_REFnow) — ran cleanly, no crash.
Deployment needed on already-affected machines
Both ~/Blog/Blog (author's machine) and the VPS blog repo hit this bug and need the fix
installed, followed by one rad lfs init re-run each (to migrate the existing bare ref and
pick up the corrected push refspec) before rad lfs store/committing new LFS files will work
cleanly again there. The files committed via the --no-verify workaround are fine as-is
(valid LFS pointers, just not yet IPFS-pinned) — re-adding/re-committing them normally (or
running rad lfs store on them directly) after the fix is installed will pin them retroactively.