diff --git a/crates/radicle-cli/src/commands/lfs.rs b/crates/radicle-cli/src/commands/lfs.rs index 02ecd624..373d9d42 100644 --- a/crates/radicle-cli/src/commands/lfs.rs +++ b/crates/radicle-cli/src/commands/lfs.rs @@ -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), } } diff --git a/crates/radicle-cli/src/commands/lfs/args.rs b/crates/radicle-cli/src/commands/lfs/args.rs index eca20d31..6b8feaad 100644 --- a/crates/radicle-cli/src/commands/lfs/args.rs +++ b/crates/radicle-cli/src/commands/lfs/args.rs @@ -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 + /// " " lines from stdin. + #[command(hide = true)] + Precommit, } diff --git a/crates/radicle-cli/src/commands/lfs/init.rs b/crates/radicle-cli/src/commands/lfs/init.rs index 158baef3..76798810 100644 --- a/crates/radicle-cli/src/commands/lfs/init.rs +++ b/crates/radicle-cli/src/commands/lfs/init.rs @@ -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 " " 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"#; diff --git a/crates/radicle-cli/src/commands/lfs/precommit.rs b/crates/radicle-cli/src/commands/lfs/precommit.rs new file mode 100644 index 00000000..146f5298 --- /dev/null +++ b/crates/radicle-cli/src/commands/lfs/precommit.rs @@ -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 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(()) +} diff --git a/crates/radicle-cli/src/commands/lfs/store.rs b/crates/radicle-cli/src/commands/lfs/store.rs index 5256a664..b10ebc10 100644 --- a/crates/radicle-cli/src/commands/lfs/store.rs +++ b/crates/radicle-cli/src/commands/lfs/store.rs @@ -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, +) -> anyhow::Result { 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) }