diff --git a/NOTES-lfs-notes-fetch-bug.md b/NOTES-lfs-notes-fetch-bug.md index 6a5e6f2c..f96f22d9 100644 --- a/NOTES-lfs-notes-fetch-bug.md +++ b/NOTES-lfs-notes-fetch-bug.md @@ -259,6 +259,25 @@ explicit `+refs/heads/*:refs/remotes/rad/*`-equivalent push default is present, "always `git push rad ` 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 diff --git a/NOTES-lfs-store-note-write-bug.md b/NOTES-lfs-store-note-write-bug.md new file mode 100644 index 00000000..663dc4c2 --- /dev/null +++ b/NOTES-lfs-store-note-write-bug.md @@ -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/` (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 --size -- 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/`, 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/`, 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/` (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/` **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/` 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/`, + 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//refs/notes/rad-lfs` inside + `~/.radicle/storage/`, 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/`, 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/` (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/` 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. diff --git a/crates/radicle-cli/src/commands/lfs/init.rs b/crates/radicle-cli/src/commands/lfs/init.rs index d84a150b..158baef3 100644 --- a/crates/radicle-cli/src/commands/lfs/init.rs +++ b/crates/radicle-cli/src/commands/lfs/init.rs @@ -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/` 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/` 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<()> { diff --git a/crates/radicle-cli/src/commands/lfs/rekey.rs b/crates/radicle-cli/src/commands/lfs/rekey.rs index af2e7b17..ae09710d 100644 --- a/crates/radicle-cli/src/commands/lfs/rekey.rs +++ b/crates/radicle-cli/src/commands/lfs/rekey.rs @@ -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; diff --git a/crates/radicle-cli/src/commands/lfs/store.rs b/crates/radicle-cli/src/commands/lfs/store.rs index 7484d48d..5256a664 100644 --- a/crates/radicle-cli/src/commands/lfs/store.rs +++ b/crates/radicle-cli/src/commands/lfs/store.rs @@ -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); diff --git a/crates/radicle-cli/src/lfs_crypto.rs b/crates/radicle-cli/src/lfs_crypto.rs index 60bd9cac..567611cf 100644 --- a/crates/radicle-cli/src/lfs_crypto.rs +++ b/crates/radicle-cli/src/lfs_crypto.rs @@ -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/` (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/` 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/` 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=` text. `enc` is `None` for public repos (plain /// passthrough, unchanged behavior).