lfs: batch pre-commit hook into one `rad lfs precommit` process

Previously the pre-commit hook shelled out to `rad lfs store` once per
staged LFS file, each a separate process. For private repos, each of
those needs a keystore passphrase for an ECDH key-agreement operation,
so a multi-file commit meant a separate interactive passphrase prompt
per file -- observed live against the blog repo (9+ prompts in a row).

Add `rad lfs precommit`, which reads every staged file's oid/size/path
from stdin and processes them all in one process, loading the signer
at most once and reusing it across the batch. `store::run` now
delegates to a shared `store_object` helper that both it and
`precommit::run` call.
This commit is contained in:
Maciek "mab122" Bator 2026-07-15 19:23:21 +02:00
parent a9428a9ef9
commit c070aac6fe
5 changed files with 127 additions and 26 deletions

View File

@ -2,6 +2,7 @@
pub mod fetch;
pub mod init;
pub mod precommit;
pub mod rekey;
pub mod store;
@ -18,5 +19,6 @@ pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
Command::Store { oid, size, path } => self::store::run(oid, size, path, ctx),
Command::Fetch { oid, size, out } => self::fetch::run(oid, size, out, ctx),
Command::Rekey => self::rekey::run(ctx),
Command::Precommit => self::precommit::run(ctx),
}
}

View File

@ -46,4 +46,9 @@ pub enum Command {
/// Grant newly-authorized collaborators access to previously-encrypted
/// LFS objects
Rekey,
/// Store every staged LFS file's content in IPFS in one batch (invoked
/// by the pre-commit hook; not for interactive use). Reads
/// "<oid> <size> <path>" lines from stdin.
#[command(hide = true)]
Precommit,
}

View File

@ -42,18 +42,24 @@ const HOOK_END: &str = "# <<< rad-lfs-init managed pre-commit hook <<<";
/// Body of the managed pre-commit hook block (POSIX `sh`).
///
/// For every file staged in the commit that's tracked by Git LFS (i.e.
/// matches a `filter=lfs` pattern in `.gitattributes`), this:
/// 1. computes the file's LFS oid (sha256) and size, and
/// 2. shells out to `rad lfs store` with those values, which does the
/// actual IPFS add/pin (encrypting first for private repos) and writes
/// the corresponding note on `refs/notes/rad-lfs` itself.
/// matches a `filter=lfs` pattern in `.gitattributes`), this computes the
/// file's LFS oid (sha256) and size, then feeds all of them to a single
/// `rad lfs precommit` invocation as "<oid> <size> <path>" lines on stdin.
/// `rad lfs precommit` does the actual IPFS add/pin (encrypting first for
/// private repos) and writes the corresponding notes on `refs/notes/rad-lfs`
/// itself — see `commands/lfs/precommit.rs` and `commands/lfs/store.rs`.
///
/// Batching into one `rad` process (rather than one per file) matters for
/// private repos specifically: each file needs the keystore passphrase for
/// an ECDH key-agreement operation, and a separate process per file meant a
/// separate passphrase prompt per file. One process loads it once.
///
/// The hook only computes the cheap, dependency-free oid/size values in
/// shell; the CID/encryption/git-notes logic lives in `rad lfs store`
/// (see `commands/lfs/store.rs`) since it needs access to the repo's
/// identity/visibility and (for private repos) key material that a plain
/// shell script has no business handling — a git hook shelling out to our
/// own `rad` binary is far simpler than reimplementing that here.
/// shell; the CID/encryption/git-notes logic lives in `rad lfs precommit`
/// since it needs access to the repo's identity/visibility and (for private
/// repos) key material that a plain shell script has no business handling —
/// a git hook shelling out to our own `rad` binary is far simpler than
/// reimplementing that here.
///
/// Requires both `ipfs` and `rad` to be available on `PATH` at commit time.
const HOOK_BODY: &str = r#"rad_lfs_precommit() {
@ -75,6 +81,7 @@ const HOOK_BODY: &str = r#"rad_lfs_precommit() {
}
staged=$(mktemp) || return 1
batch=$(mktemp) || { rm -f "$staged"; return 1; }
git diff --cached --name-only --diff-filter=ACM > "$staged"
while IFS= read -r file; do
@ -84,17 +91,21 @@ const HOOK_BODY: &str = r#"rad_lfs_precommit() {
attr=$(git check-attr filter -- "$file" | sed -n 's/.*: filter: //p')
[ "$attr" = "lfs" ] || continue
oid=$(sha256 "$file") || { rm -f "$staged"; exit 1; }
oid=$(sha256 "$file") || { rm -f "$staged" "$batch"; exit 1; }
size=$(wc -c < "$file" | tr -d ' ')
rad lfs store --oid "$oid" --size "$size" -- "$file" >/dev/null || {
echo "rad-lfs: 'rad lfs store' failed for $file" >&2
rm -f "$staged"
printf '%s %s %s\n' "$oid" "$size" "$file" >> "$batch"
done < "$staged"
rm -f "$staged"
if [ -s "$batch" ]; then
rad lfs precommit < "$batch" || {
echo "rad-lfs: 'rad lfs precommit' failed" >&2
rm -f "$batch"
exit 1
}
done < "$staged"
rm -f "$staged"
fi
rm -f "$batch"
}
rad_lfs_precommit"#;

View File

@ -0,0 +1,55 @@
//! `rad lfs precommit` — plumbing command invoked once per commit by the
//! `rad-lfs` pre-commit hook. Reads every staged LFS file's oid/size/path
//! from stdin and stores them all in a single process, so a private repo's
//! passphrase (needed for the ECDH key-agreement `rad lfs store` performs)
//! is prompted for at most once per commit, not once per file.
use std::io::BufRead as _;
use std::path::PathBuf;
use anyhow::Context as _;
use radicle::storage::{ReadRepository as _, ReadStorage as _};
use crate::terminal as term;
use super::store::store_object;
pub fn run(ctx: impl term::Context) -> anyhow::Result<()> {
let profile = ctx.profile()?;
let (repo, rid) = radicle::rad::cwd()
.context("`rad lfs precommit` must be run inside a Radicle repository working copy")?;
let doc = profile.storage.repository(rid)?.identity_doc()?.doc;
let mut signer = None;
let stdin = std::io::stdin();
for line in stdin.lock().lines() {
let line = line.context("failed to read stdin")?;
// Each line is "<oid> <size> <path>" -- oid is a fixed-length hex
// string and size is decimal, so splitting on the first two spaces
// leaves the path (which may itself contain spaces) intact as the
// remainder.
let mut parts = line.splitn(3, ' ');
let (Some(oid), Some(size), Some(path)) = (parts.next(), parts.next(), parts.next())
else {
anyhow::bail!("malformed `rad lfs precommit` input line: {line:?}");
};
let size: i64 = size
.parse()
.with_context(|| format!("invalid size in `rad lfs precommit` input: {size:?}"))?;
store_object(
&profile,
&repo,
rid,
&doc,
oid,
size,
&PathBuf::from(path),
&mut signer,
)
.with_context(|| format!("failed to store LFS object for `{path}`"))?;
}
Ok(())
}

View File

@ -3,12 +3,16 @@
//! to the local IPFS (Kubo) node, encrypting it first if the repository
//! is private.
use std::path::Path;
use std::path::PathBuf;
use anyhow::Context as _;
use radicle::git::raw::Signature;
use radicle::Profile;
use radicle::git::raw::{Repository, Signature};
use radicle::identity::{Doc, RepoId};
use radicle::storage::{ReadRepository as _, ReadStorage as _};
use radicle_crypto::ssh::keystore::MemorySigner;
use crate::ipfs;
use crate::lfs_crypto::{self, Envelope, LOCAL_NOTES_REF, NOTE_AUTHOR_EMAIL, NOTE_AUTHOR_NAME};
@ -20,6 +24,29 @@ pub fn run(oid: String, size: i64, path: PathBuf, ctx: impl term::Context) -> an
.context("`rad lfs store` must be run inside a Radicle repository working copy")?;
let doc = profile.storage.repository(rid)?.identity_doc()?.doc;
let mut signer = None;
let cid = store_object(&profile, &repo, rid, &doc, &oid, size, &path, &mut signer)?;
term::println(cid);
Ok(())
}
/// Stores one LFS object's content in IPFS (encrypting first for private
/// repos) and records the resulting CID as a git note. Factored out of
/// [`run`] so `rad lfs precommit` can process every staged LFS file in a
/// single process, loading (and prompting for) the signer at most once
/// across the whole batch instead of once per file.
#[allow(clippy::too_many_arguments)]
pub fn store_object(
profile: &Profile,
repo: &Repository,
rid: RepoId,
doc: &Doc,
oid: &str,
size: i64,
path: &Path,
signer: &mut Option<MemorySigner>,
) -> anyhow::Result<String> {
let pointer_text =
format!("version https://git-lfs.github.com/spec/v1\noid sha256:{oid}\nsize {size}\n");
let blob_oid = repo
@ -29,19 +56,22 @@ pub fn run(oid: String, size: i64, path: PathBuf, ctx: impl term::Context) -> an
ipfs::check_daemon()?;
let envelope = if doc.visibility().is_public() {
let bytes = std::fs::read(&path)
let bytes = std::fs::read(path)
.with_context(|| format!("failed to read `{}`", path.display()))?;
let cid = ipfs::add(&bytes)?;
let _ = ipfs::pin_all(std::slice::from_ref(&cid));
Envelope::plain(cid)
} else {
let signer = lfs_crypto::load_signer(&profile)?;
if signer.is_none() {
*signer = Some(lfs_crypto::load_signer(profile)?);
}
let signer = signer.as_ref().expect("just populated above");
let sender_did = profile.did();
let recipients = lfs_crypto::recipients_for(&doc, sender_did);
let plaintext = std::fs::read(&path)
let recipients = lfs_crypto::recipients_for(doc, sender_did);
let plaintext = std::fs::read(path)
.with_context(|| format!("failed to read `{}`", path.display()))?;
let (ciphertext, enc) =
lfs_crypto::encrypt_for(&plaintext, &signer, sender_did, &rid, &oid, &recipients)?;
lfs_crypto::encrypt_for(&plaintext, signer, sender_did, &rid, oid, &recipients)?;
let cid = ipfs::add(&ciphertext)?;
let _ = ipfs::pin_all(std::slice::from_ref(&cid));
Envelope {
@ -59,7 +89,5 @@ pub fn run(oid: String, size: i64, path: PathBuf, ctx: impl term::Context) -> an
repo.note(&signature, &signature, Some(LOCAL_NOTES_REF), blob_oid, &message, true)
.context("failed to write LFS note")?;
term::println(cid);
Ok(())
Ok(cid)
}