Fix: rad lfs store/rekey fail with git ref D/F conflict on notes ref

Found live while migrating the blog's food-gallery images into LFS,
diagnosed in NOTES-lfs-store-note-write-bug.md, confirmed and fixed here.

Root cause: store.rs/rekey.rs wrote local notes to the bare
refs/notes/rad-lfs, but the earlier fetch-bug fix (1c65ee62) has the
fetch refspec populate refs/notes/rad-lfs/<peer> siblings locally --
including your own peer's copy, fetched back after your own push. A bare
refs/notes/rad-lfs ref can't coexist with refs/notes/rad-lfs/<peer> in
the same git ref namespace (a leaf ref vs. a directory prefix at the same
path) -- so the very first commit+push+fetch cycle on a repo left it
permanently unable to `rad lfs store` again, failing with "failed to
write LFS note" and no further detail (git itself hits the same conflict
from the fetch direction and just refuses that one ref cleanly; it's
specifically libgit2's ref-write path used here that fails hard instead).

Fix: stop writing to the bare ref locally at all. New
lfs_crypto::LOCAL_NOTES_REF (refs/notes/rad-lfs/local) is what
store.rs/rekey.rs write to now -- living under the same
refs/notes/rad-lfs/ prefix as every fetched peer ref makes it just
another sibling leaf ref, structurally incapable of conflicting with
them ("local" can never collide with an actual peer ID). NOTES_REF
(bare) stays exactly as it was for the remote-side/push-destination
role, unchanged. rad lfs init's push refspec becomes asymmetric
(+refs/notes/rad-lfs/local:refs/notes/rad-lfs) instead of symmetric;
fetch refspec is untouched.

Added a migration step (migrate_local_notes_ref, run by rad lfs init)
for already-affected repos: reads any existing bare-ref notes out,
deletes the bare ref, then re-writes them under the new local ref --
also removes the old symmetric push refspec config entry the same way
the earlier fetch-refspec migration did, so re-running rad lfs init
fully repairs an already-broken repo with no manual git surgery needed.
(First version of the migration itself had the identical bug -- tried to
write the new ref before deleting the old one, hit the same conflict
self-inflicted -- fixed by reading all notes into memory before deleting
the bare ref.)

Verified locally: reproduced the exact failure in the precise order that
triggers it, confirmed the fix resolves that exact same failing call in
the same repo, verified the migration path preserves note content
exactly, and confirmed a full commit->push->fetch->decrypt round trip
plus `rad lfs rekey` all still work correctly afterward.
This commit is contained in:
Maciek "mab122" Bator 2026-07-15 19:02:53 +02:00
parent 945da6f7f8
commit a9428a9ef9
6 changed files with 328 additions and 34 deletions

View File

@ -259,6 +259,25 @@ explicit `+refs/heads/*:refs/remotes/rad/*`-equivalent push default is present,
"always `git push rad <branch>` explicitly" as a hard requirement), but out of scope for this
specific fetch-bug fix.
## Post-verification cleanup (2026-07-14, later still)
VPS only needs the compiled binaries, not a persistent build environment. After confirming
`/home/blog/.radicle/bin/{rad,radicle-node,git-remote-rad,rad-lfs-transfer}` are correct and
working (live deploy already succeeded, see above), removed the source checkout and Rust
toolchain to reclaim disk: `rm -rf /home/blog/radicle-heartwood-lfs /home/blog/.rustup
/home/blog/.cargo`, plus dropped the now-dangling `. "$HOME/.cargo/env"` lines from
`blog`'s `.bashrc`/`.profile`. Reclaimed ~3.2GB (9.7G → 6.5G used on `/`). Confirmed no
systemd unit referenced the source dir or cargo/rustup, and the binaries are dynamically
linked only against standard system libs (`ldd` shows just `libgcc_s`/`libm`/`libc`) — fine
to run standalone.
**Implication for next time a rebuild is needed**: the VPS has no source checkout or Rust
toolchain anymore. Re-deploying a new fix means redoing the copy-source-and-build steps
from scratch (or, better, sorting out `blog`'s lack of Forgejo SSH credentials so it can
`git clone`/`pull` properly instead of the source being hand-copied in — see Part 1 of Bug
#2 below for why that copy-instead-of-clone approach already caused one bit of git-history
drift).
## Root cause of Bug #2, actually found (2026-07-14, same day, later)
Got shell access to the VPS (`ssh mbator.pl`, passwordless sudo in a real tty) and reproduced

View File

@ -0,0 +1,198 @@
# 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 at `127.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`:
```rust
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:
1. Add temporary `eprintln!("{:#}", e)` (or similar) around the `repo.note(...)` call in
`store.rs` to print the full `anyhow` chain instead of just the top context, to get the real
libgit2 error text.
2. If it is a D/F conflict: `NOTES_REF` in `lfs_crypto.rs:42` needs to move to the same
per-peer-namespaced shape `store.rs` writes with (probably `refs/notes/rad-lfs/<own-peer-id>`,
matching what `init.rs`'s migrated refspec expects), not just the fetch/init side.
3. 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-lfs` inside
`~/.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 what `list.rs`'s `lfs_notes_refs` looks for within each peer's namespace on
the server. Still correct and unchanged.
- `LOCAL_NOTES_REF` (new: `refs/notes/rad-lfs/local`) — what `rad lfs store`/`rad lfs rekey`
actually write to now. Living under the same `refs/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 — `local` isn'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 store` call → `✗ 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/local` and `refs/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 to
`refs/notes/rad-lfs/local` with identical content, bare ref gone.
- Full round-trip after the fix: committed a new file (pre-commit hook → `rad lfs store`
writes `refs/notes/rad-lfs/local`), pushed (asymmetric refspec correctly maps it to the
bare remote ref), a second checkout fetched it and `rad lfs fetch`'d the content —
SHA-256 matched exactly.
- `rad lfs rekey` smoke-tested post-fix (it writes notes too, via the same `LOCAL_NOTES_REF`
now) — 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.

View File

@ -19,6 +19,7 @@ use radicle::git::raw::Repository;
use crate::git;
use crate::ipfs::{self, LFS_NOTES_REF as NOTES_REF};
use crate::lfs_crypto::{LOCAL_NOTES_REF, NOTE_AUTHOR_EMAIL, NOTE_AUTHOR_NAME};
use crate::terminal as term;
/// Name under which our custom transfer agent is registered with Git LFS.
@ -131,10 +132,15 @@ pub fn run() -> anyhow::Result<()> {
"Configured Git LFS to transfer objects via `{TRANSFER_AGENT_BIN}` (backed by IPFS)"
);
// Push stays a plain (non-namespaced) refspec: `git push` lands
// whatever local ref we push under the pusher's own namespace on the
// server side regardless of the ref name used, the same way
// `refs/heads/*` does.
// Push maps our *local-only* notes ref (`LOCAL_NOTES_REF`) to the bare
// `NOTES_REF` on the remote: `git push` lands whatever local ref we
// push under the pusher's own namespace on the server regardless of
// the local ref name, the same way `refs/heads/*` does, so the
// destination staying bare is correct and matches what
// `list.rs`'s `lfs_notes_refs` looks for server-side. The *source*
// is deliberately not the bare name too -- see `LOCAL_NOTES_REF`'s
// doc comment for why (a git ref D/F conflict with fetched peer
// refs).
//
// Fetch is different: `refs/notes/rad-lfs` is a per-peer ref (any
// peer can commit an LFS-tracked file, not just delegates), so
@ -144,8 +150,9 @@ pub fn run() -> anyhow::Result<()> {
// so the fetch refspec needs a wildcard to pull them all; the LFS
// tooling merges across whatever's fetched (see
// `lfs_crypto::find_envelope`) rather than expecting one ref.
let push_refspec = format!("+{NOTES_REF}:{NOTES_REF}");
let push_refspec = format!("+{LOCAL_NOTES_REF}:{NOTES_REF}");
let fetch_refspec = format!("+{NOTES_REF}/*:{NOTES_REF}/*");
let push_key = format!("remote.{RAD_REMOTE}.push");
let fetch_key = format!("remote.{RAD_REMOTE}.fetch");
// Migration: earlier versions of `rad lfs init` configured a plain,
@ -157,13 +164,19 @@ pub fn run() -> anyhow::Result<()> {
// fixes an already-configured repository, rather than leaving the
// broken entry alongside the corrected one.
remove_refspec_if_present(workdir, &fetch_key, &format!("+{NOTES_REF}:{NOTES_REF}"))?;
// Migration: earlier versions also pushed from the bare local ref
// (symmetric `+refs/notes/rad-lfs:refs/notes/rad-lfs`). Replace it
// with the corrected asymmetric refspec above.
remove_refspec_if_present(workdir, &push_key, &format!("+{NOTES_REF}:{NOTES_REF}"))?;
ensure_refspec(workdir, &format!("remote.{RAD_REMOTE}.push"), &push_refspec)?;
ensure_refspec(workdir, &push_key, &push_refspec)?;
ensure_refspec(workdir, &fetch_key, &fetch_refspec)?;
term::success!(
"Configured the `{RAD_REMOTE}` remote to push/fetch the `{NOTES_REF}` notes refs"
);
migrate_local_notes_ref(&repo)?;
install_pre_commit_hook(&repo)?;
term::success!("Installed the `rad-lfs` pre-commit hook");
@ -245,6 +258,61 @@ fn remove_refspec_if_present(workdir: &Path, key: &str, value: &str) -> anyhow::
Ok(())
}
/// Migration: earlier versions of this fork wrote local notes directly to
/// the bare `NOTES_REF` (`refs/notes/rad-lfs`). Since the fetch refspec
/// populates `refs/notes/rad-lfs/<peer>` siblings locally -- including our
/// own peer's copy, fetched back after a push -- a leftover bare ref
/// causes a git ref D/F (file-vs-directory) 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 namespace. That made every
/// `rad lfs store`/`rad lfs rekey` call after the first fetch fail
/// outright with "failed to write LFS note".
///
/// If a bare `refs/notes/rad-lfs` ref exists, copy its notes into
/// `LOCAL_NOTES_REF` (so nothing already recorded there is lost) and
/// delete the bare ref, eliminating the conflict. A no-op if the bare ref
/// doesn't exist (fresh setups, or a repo that's already been migrated).
fn migrate_local_notes_ref(repo: &Repository) -> anyhow::Result<()> {
let Ok(mut reference) = repo.find_reference(NOTES_REF) else {
return Ok(());
};
// Read every note out *before* touching anything -- writing to
// `LOCAL_NOTES_REF` while the bare `NOTES_REF` still exists is the
// exact same D/F conflict this migration exists to fix, just
// self-inflicted. The bare ref has to be gone first.
let mut migrated = Vec::new();
if let Ok(notes) = repo.notes(Some(NOTES_REF)) {
for (_, target_oid) in notes.flatten() {
if let Ok(note) = repo.find_note(Some(NOTES_REF), target_oid)
&& let Some(message) = note.message()
{
migrated.push((target_oid, message.to_string()));
}
}
}
reference
.delete()
.context("failed to remove the legacy bare `refs/notes/rad-lfs` ref")?;
if migrated.is_empty() {
return Ok(());
}
let signature = radicle::git::raw::Signature::now(NOTE_AUTHOR_NAME, NOTE_AUTHOR_EMAIL)
.context("failed to construct note signature")?;
for (target_oid, message) in migrated {
repo.note(&signature, &signature, Some(LOCAL_NOTES_REF), target_oid, &message, true)
.with_context(|| {
format!("failed to migrate note for {target_oid} to `{LOCAL_NOTES_REF}`")
})?;
}
term::success!("Migrated local LFS notes from the legacy `{NOTES_REF}` ref");
Ok(())
}
/// Install (or update, or chain onto) the `.git/hooks/pre-commit` hook that
/// pins staged LFS objects to IPFS and records their CID as a git note.
fn install_pre_commit_hook(repo: &Repository) -> anyhow::Result<()> {

View File

@ -12,12 +12,9 @@ use radicle::git::raw::Signature;
use radicle::identity::Did;
use radicle::storage::{ReadRepository as _, ReadStorage as _};
use crate::lfs_crypto::{self, NOTES_REF};
use crate::lfs_crypto::{self, LOCAL_NOTES_REF, NOTE_AUTHOR_EMAIL, NOTE_AUTHOR_NAME};
use crate::terminal as term;
const NOTE_AUTHOR_NAME: &str = "rad-lfs";
const NOTE_AUTHOR_EMAIL: &str = "rad-lfs@localhost";
pub fn run(ctx: impl term::Context) -> anyhow::Result<()> {
let profile = ctx.profile()?;
let (repo, rid) = radicle::rad::cwd()
@ -136,7 +133,7 @@ pub fn run(ctx: impl term::Context) -> anyhow::Result<()> {
// original note.
let message = serde_json::to_string(&envelope)
.context("failed to serialize LFS note envelope")?;
repo.note(&signature, &signature, Some(NOTES_REF), target_oid, &message, true)
repo.note(&signature, &signature, Some(LOCAL_NOTES_REF), target_oid, &message, true)
.context("failed to write LFS note")?;
rekeyed_objects += 1;

View File

@ -11,16 +11,9 @@ use radicle::git::raw::Signature;
use radicle::storage::{ReadRepository as _, ReadStorage as _};
use crate::ipfs;
use crate::lfs_crypto::{self, Envelope};
use crate::lfs_crypto::{self, Envelope, LOCAL_NOTES_REF, NOTE_AUTHOR_EMAIL, NOTE_AUTHOR_NAME};
use crate::terminal as term;
/// Committer identity used for the `refs/notes/rad-lfs` notes this command
/// writes. There's no natural "author" for a note that just records a
/// CID, so a fixed identity is used, matching the shell version of this
/// logic in the `rad lfs init`-installed pre-commit hook.
const NOTE_AUTHOR_NAME: &str = "rad-lfs";
const NOTE_AUTHOR_EMAIL: &str = "rad-lfs@localhost";
pub fn run(oid: String, size: i64, path: PathBuf, ctx: impl term::Context) -> anyhow::Result<()> {
let profile = ctx.profile()?;
let (repo, rid) = radicle::rad::cwd()
@ -63,15 +56,8 @@ pub fn run(oid: String, size: i64, path: PathBuf, ctx: impl term::Context) -> an
serde_json::to_string(&envelope).context("failed to serialize LFS note envelope")?;
let signature = Signature::now(NOTE_AUTHOR_NAME, NOTE_AUTHOR_EMAIL)
.context("failed to construct note signature")?;
repo.note(
&signature,
&signature,
Some(ipfs::LFS_NOTES_REF),
blob_oid,
&message,
true,
)
.context("failed to write LFS note")?;
repo.note(&signature, &signature, Some(LOCAL_NOTES_REF), blob_oid, &message, true)
.context("failed to write LFS note")?;
term::println(cid);

View File

@ -34,15 +34,41 @@ pub const ENVELOPE_VERSION: u32 = 1;
const HKDF_DOMAIN: &[u8] = b"radicle-lfs-wrap-v1";
const ALG: &str = "xchacha20poly1305";
/// The local, not-yet-pushed note ref -- `rad lfs store`/`rekey` always
/// write here. Once pushed, this lands in the pusher's own namespace on
/// the server, and `rad lfs init`'s fetch refspec pulls every peer's copy
/// back down under `refs/notes/rad-lfs/<peer>` (see
/// `crates/radicle-remote-helper/src/list.rs`'s `lfs_notes_refs`).
/// The canonical, bare notes ref name. This is what the *remote* uses --
/// it's the push destination (lands in the pusher's own namespace on the
/// server regardless of the local source ref, same as `refs/heads/*`) and
/// what `crates/radicle-remote-helper/src/list.rs`'s `lfs_notes_refs`
/// looks for within each peer's namespace server-side.
///
/// It is deliberately **not** used as a local write target anymore (see
/// [`LOCAL_NOTES_REF`]): `rad lfs init`'s fetch refspec populates
/// `refs/notes/rad-lfs/<peer>` siblings locally, including our own peer's
/// copy fetched back after a push. A bare `refs/notes/rad-lfs` ref
/// coexisting with `refs/notes/rad-lfs/<peer>` is a git ref D/F
/// (file-vs-directory) conflict -- `refs/notes/rad-lfs` can't
/// simultaneously be a leaf ref and a directory prefix in the same ref
/// namespace -- which made every `rad lfs store` call after the first
/// fetch fail outright with "failed to write LFS note".
pub const NOTES_REF: &str = "refs/notes/rad-lfs";
/// Glob matching the local ref above plus every fetched peer ref.
/// The local, not-yet-pushed note ref -- `rad lfs store`/`rekey` write
/// here instead of the bare [`NOTES_REF`], specifically to avoid the D/F
/// conflict above: living under the same `refs/notes/rad-lfs/` prefix as
/// every fetched peer ref means it's just another sibling leaf ref, never
/// a conflicting directory/file pair. `rad lfs init`'s push refspec maps
/// this local name to the bare [`NOTES_REF`] on the remote.
pub const LOCAL_NOTES_REF: &str = "refs/notes/rad-lfs/local";
/// Glob matching [`LOCAL_NOTES_REF`] and every fetched peer ref, plus (for
/// backward compatibility while any not-yet-migrated repo or peer might
/// still have one) the legacy bare [`NOTES_REF`] itself, purely for
/// reading -- nothing writes there anymore.
const NOTES_REF_GLOB: &str = "refs/notes/rad-lfs*";
/// Committer identity used for `refs/notes/rad-lfs/*` notes. There's no
/// natural "author" for a note that just records a CID, so a fixed
/// identity is used everywhere a note gets written.
pub const NOTE_AUTHOR_NAME: &str = "rad-lfs";
pub const NOTE_AUTHOR_EMAIL: &str = "rad-lfs@localhost";
/// Note content stored on `refs/notes/rad-lfs`, replacing the earlier
/// bare `cid=<cid>` text. `enc` is `None` for public repos (plain
/// passthrough, unchanged behavior).