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:
parent
a9428a9ef9
commit
c070aac6fe
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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"#;
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue