299 lines
12 KiB
Rust
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(())
|
|
}
|