radicle-heartwood-lfs/crates/radicle-cli/src/commands/lfs/init.rs

299 lines
12 KiB
Rust

//! `rad lfs init` — one-time, idempotent setup of Git LFS support for a
//! Radicle repository, backed by the contributor's local IPFS (Kubo) node.
//!
//! This deliberately does *not* talk to any seed-hosted HTTP LFS server:
//! large file content lives on each peer's own IPFS node, and the
//! oid -> CID mapping is carried in a git-notes ref (`refs/notes/rad-lfs`)
//! that replicates alongside the rest of the repository once the
//! push/fetch refspecs below are configured.
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::Command;
use anyhow::{Context as _, anyhow};
use radicle::git::raw::Repository;
use crate::git;
use crate::ipfs::{self, LFS_NOTES_REF as NOTES_REF};
use crate::terminal as term;
/// Name under which our custom transfer agent is registered with Git LFS.
const TRANSFER_AGENT_NAME: &str = "rad-ipfs";
/// Binary implementing the custom transfer agent. Must be on the user's
/// `PATH`; we intentionally don't assume a specific install location.
const TRANSFER_AGENT_BIN: &str = "rad-lfs-transfer";
/// Name of the remote that `rad init` sets up.
const RAD_REMOTE: &str = "rad";
/// Markers delimiting the block we manage inside `.git/hooks/pre-commit`,
/// so re-running `rad lfs init` updates our block in place instead of
/// duplicating it, and so we don't clobber any hook content that was
/// already there.
const HOOK_BEGIN: &str = "# >>> rad-lfs-init managed pre-commit hook >>>";
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.
///
/// 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.
///
/// Requires both `ipfs` and `rad` to be available on `PATH` at commit time.
const HOOK_BODY: &str = r#"rad_lfs_precommit() {
command -v ipfs >/dev/null 2>&1 || {
echo "rad-lfs: 'ipfs' CLI not found on PATH; skipping CID pinning for this commit" >&2
return 0
}
command -v rad >/dev/null 2>&1 || {
echo "rad-lfs: 'rad' CLI not found on PATH; skipping CID pinning for this commit" >&2
return 0
}
sha256() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{ print $1 }'
else
shasum -a 256 "$1" | awk '{ print $1 }'
fi
}
staged=$(mktemp) || return 1
git diff --cached --name-only --diff-filter=ACM > "$staged"
while IFS= read -r file; do
[ -n "$file" ] || continue
[ -f "$file" ] || continue
attr=$(git check-attr filter -- "$file" | sed -n 's/.*: filter: //p')
[ "$attr" = "lfs" ] || continue
oid=$(sha256 "$file") || { rm -f "$staged"; 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"
exit 1
}
done < "$staged"
rm -f "$staged"
}
rad_lfs_precommit"#;
pub fn run() -> anyhow::Result<()> {
let (repo, _rid) = radicle::rad::cwd()
.context("`rad lfs init` must be run inside a Radicle repository working copy")?;
let workdir = repo.workdir().ok_or_else(|| {
anyhow!("`rad lfs init` must be run inside a git working copy (not a bare repository)")
})?;
ipfs::check_daemon()?;
term::success!("Found a local IPFS (Kubo) daemon at {}", ipfs::kubo_api_url());
ensure_git_lfs_available()?;
git::git(workdir, ["lfs", "install", "--local"])
.context("failed to run `git lfs install --local`")?;
term::success!("Initialized Git LFS for this repository");
git::git(
workdir,
["config", "lfs.standalonetransferagent", TRANSFER_AGENT_NAME],
)
.context("failed to configure the LFS standalone transfer agent")?;
git::git(
workdir,
[
"config",
&format!("lfs.customtransfer.{TRANSFER_AGENT_NAME}.path"),
TRANSFER_AGENT_BIN,
],
)
.context("failed to configure the LFS custom transfer agent path")?;
term::success!(
"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.
//
// Fetch is different: `refs/notes/rad-lfs` is a per-peer ref (any
// peer can commit an LFS-tracked file, not just delegates), so
// there's no single canonical value the remote can advertise the way
// it does for `refs/heads`/`refs/tags`. The remote instead advertises
// every peer's note individually under `refs/notes/rad-lfs/<peer>`,
// 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 fetch_refspec = format!("+{NOTES_REF}/*:{NOTES_REF}/*");
let fetch_key = format!("remote.{RAD_REMOTE}.fetch");
// Migration: earlier versions of `rad lfs init` configured a plain,
// non-wildcard fetch refspec for the notes ref, which `git fetch` can
// never satisfy -- the remote never advertises a bare
// `refs/notes/rad-lfs` (only the wildcarded per-peer form below), so
// a stale entry here breaks the *entire* fetch, not just this one
// ref. Remove it if present so re-running `rad lfs init` actually
// 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}"))?;
ensure_refspec(workdir, &format!("remote.{RAD_REMOTE}.push"), &push_refspec)?;
ensure_refspec(workdir, &fetch_key, &fetch_refspec)?;
term::success!(
"Configured the `{RAD_REMOTE}` remote to push/fetch the `{NOTES_REF}` notes refs"
);
install_pre_commit_hook(&repo)?;
term::success!("Installed the `rad-lfs` pre-commit hook");
term::blank();
term::success!("Git LFS is now configured for this repository, backed by your local IPFS node.");
term::info!(
"File CIDs are recorded in the `{NOTES_REF}` git-notes ref, which now travels with every `rad push` / `rad pull`."
);
Ok(())
}
/// Make sure the `git-lfs` binary is installed before we shell out to it,
/// so we can give a clear, actionable error instead of a raw "unknown git
/// command" failure.
fn ensure_git_lfs_available() -> anyhow::Result<()> {
match Command::new("git-lfs").arg("version").output() {
Ok(output) if output.status.success() => Ok(()),
Ok(output) => Err(anyhow!(
"`git-lfs version` failed: {}",
String::from_utf8_lossy(&output.stderr)
)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Err(anyhow!(
"Git LFS is not installed (the `git-lfs` binary was not found on your PATH). \
Install Git LFS — see https://git-lfs.com — and try again."
)),
Err(err) => Err(err).context("failed to check for `git-lfs`"),
}
}
/// Add `value` to the (potentially multi-valued) git config key `key`,
/// unless it's already present, so re-running `rad lfs init` doesn't
/// duplicate refspecs.
fn ensure_refspec(workdir: &Path, key: &str, value: &str) -> anyhow::Result<()> {
// `git config --get-all` exits non-zero when the key is unset, which
// isn't an error for us, so we use the lower-level `run` rather than
// `git::git` (which treats a non-zero exit as failure).
let output = radicle::git::run(Some(workdir), ["config", "--get-all", key])
.with_context(|| format!("failed to read git config `{key}`"))?;
let existing = String::from_utf8_lossy(&output.stdout);
if existing.lines().any(|line| line.trim() == value) {
return Ok(());
}
git::git(workdir, ["config", "--add", key, value])
.with_context(|| format!("failed to set git config `{key}`"))?;
Ok(())
}
/// Remove `value` from the (potentially multi-valued) git config key
/// `key`, if present, without disturbing any other values under the same
/// key (e.g. the default `+refs/heads/*:refs/remotes/rad/*` that plain
/// `git remote add` already configures on `remote.<rad>.fetch`).
fn remove_refspec_if_present(workdir: &Path, key: &str, value: &str) -> anyhow::Result<()> {
let output = radicle::git::run(Some(workdir), ["config", "--get-all", key])
.with_context(|| format!("failed to read git config `{key}`"))?;
let existing = String::from_utf8_lossy(&output.stdout);
if !existing.lines().any(|line| line.trim() == value) {
return Ok(());
}
// `git config --unset` matches its value argument as a regex against
// the whole existing value, not literally -- anchor and escape so it
// removes exactly this one entry and nothing else.
let escaped: String = value
.chars()
.map(|c| {
if "\\^$.|?*+()[]{}".contains(c) {
format!("\\{c}")
} else {
c.to_string()
}
})
.collect();
let pattern = format!("^{escaped}$");
git::git(workdir, ["config", "--unset", key, &pattern])
.with_context(|| format!("failed to remove stale git config `{key}` entry"))?;
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<()> {
let hooks_dir = repo.path().join("hooks");
fs::create_dir_all(&hooks_dir)
.with_context(|| format!("failed to create hooks directory `{}`", hooks_dir.display()))?;
let hook_path = hooks_dir.join("pre-commit");
let existing = fs::read_to_string(&hook_path).unwrap_or_default();
let new_content = if let Some((before, after_begin)) = existing.split_once(HOOK_BEGIN) {
// We've already installed our block before: replace it in place so
// re-running `rad lfs init` doesn't duplicate it, while preserving
// anything the user added before/after it.
let after = after_begin
.split_once(HOOK_END)
.map_or("", |(_, after)| after);
format!("{before}{HOOK_BEGIN}\n{HOOK_BODY}\n{HOOK_END}{after}")
} else if existing.trim().is_empty() {
format!("#!/bin/sh\n\n{HOOK_BEGIN}\n{HOOK_BODY}\n{HOOK_END}\n")
} else {
// Some other hook (not ours) is already installed (`git lfs
// install` itself only installs a pre-push hook, so this is
// usually a hook the user or another tool set up). Chain onto it
// rather than clobbering it.
let mut content = existing;
if !content.ends_with('\n') {
content.push('\n');
}
content.push('\n');
content.push_str(HOOK_BEGIN);
content.push('\n');
content.push_str(HOOK_BODY);
content.push('\n');
content.push_str(HOOK_END);
content.push('\n');
content
};
fs::write(&hook_path, new_content)
.with_context(|| format!("failed to write hook `{}`", hook_path.display()))?;
#[cfg(unix)]
{
let mut perms = fs::metadata(&hook_path)?.permissions();
perms.set_mode(perms.mode() | 0o111);
fs::set_permissions(&hook_path, perms)?;
}
Ok(())
}