nornir 0.4.2

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
//! Pure-Rust git helpers (via `gix`). The single home for read-side
//! git inspection so the rest of the crate never shells out to the
//! `git` binary. No `std::process::Command`, no libgit2/C — matches
//! nornir's pure-Rust ethos (gix over libgit2, like `ureq` over curl).
//!
//! Write/network release operations (commit-on-release, push) live in
//! [`crate::release::publish`]. The write helpers here — [`init`] and
//! [`commit_all`] — exist only to build *new* repositories from scratch
//! (test fixtures and the synthetic-repo generator) without ever
//! shelling out; they use a fixed, deterministic identity so fixtures
//! are reproducible and need no ambient git config.

use std::path::Path;

use anyhow::{anyhow, Context, Result};

/// Full 40-char hex SHA of the commit `HEAD` points at.
pub fn head_sha(root: &Path) -> Result<String> {
    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
    let id = repo
        .head()
        .context("read HEAD")?
        .id()
        .ok_or_else(|| anyhow!("HEAD has no commit (unborn?) in {}", root.display()))?;
    Ok(id.to_string())
}

/// Short branch name for `HEAD`, or `"(detached)"` when detached.
pub fn head_branch(root: &Path) -> Result<String> {
    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
    Ok(match repo.head_name().context("read HEAD name")? {
        Some(name) => name.shorten().to_string(),
        None => "(detached)".to_string(),
    })
}

/// `(sha, branch)` for `HEAD` in one open. Branch falls back to
/// `"(detached)"` when `HEAD` is not on a branch.
pub fn head_sha_and_branch(root: &Path) -> Result<(String, String)> {
    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
    let sha = repo
        .head()
        .context("read HEAD")?
        .id()
        .ok_or_else(|| anyhow!("HEAD has no commit (unborn?) in {}", root.display()))?
        .to_string();
    let branch = match repo.head_name().context("read HEAD name")? {
        Some(name) => name.shorten().to_string(),
        None => "(detached)".to_string(),
    };
    Ok((sha, branch))
}

/// Commit SHA the tag `refs/tags/<tag>` resolves to, peeling annotated
/// tags down to their target commit. `Ok(None)` if the tag is absent.
pub fn tag_commit_sha(root: &Path, tag: &str) -> Result<Option<String>> {
    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
    let full = format!("refs/tags/{tag}");
    let Some(reference) = repo
        .try_find_reference(full.as_str())
        .with_context(|| format!("look up {full}"))?
    else {
        return Ok(None);
    };
    let id = reference
        .into_fully_peeled_id()
        .with_context(|| format!("peel {full}"))?;
    Ok(Some(id.to_string()))
}

/// True iff `refs/tags/<tag>` exists and (after peeling) points at the
/// same commit as `HEAD`.
pub fn tag_points_at_head(root: &Path, tag: &str) -> Result<bool> {
    match tag_commit_sha(root, tag)? {
        Some(tag_sha) => Ok(tag_sha == head_sha(root)?),
        None => Ok(false),
    }
}

/// Fixed identity for fixture/generated commits — deterministic so test
/// repos hash reproducibly and require no ambient `git` config.
const FIXTURE_NAME: &str = "Nornir Fixture";
const FIXTURE_EMAIL: &str = "fixtures@nornir.invalid";
const FIXTURE_TIME: &str = "1700000000 +0000";

/// Initialise a fresh git repository at `root` (pure-Rust via `gix`).
/// The directory is created if missing. Used by test fixtures and the
/// synthetic-repo generator so setup code is also free of `git`
/// subprocesses.
pub fn init(root: &Path) -> Result<()> {
    std::fs::create_dir_all(root).with_context(|| format!("mkdir {}", root.display()))?;
    gix::init(root).with_context(|| format!("gix init {}", root.display()))?;
    Ok(())
}

/// Snapshot the entire working tree (every file except those under
/// `.git/`) into a new commit on `HEAD`, returning the commit SHA.
///
/// Works for the initial commit (unborn `HEAD`, no parent) and for
/// subsequent commits (parent = current `HEAD`). Because the tree is
/// rebuilt from the filesystem each call, additions, modifications and
/// deletions are all captured — a faithful `git add -A && git commit`
/// for synthetic repos that have no `.gitattributes` content filters.
pub fn commit_all(root: &Path, message: &str) -> Result<String> {
    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
    let empty = gix::ObjectId::empty_tree(repo.object_hash());
    let mut editor = repo.edit_tree(empty).context("seed empty tree editor")?;

    add_dir_recursive(&repo, &mut editor, root, root)?;
    let tree = editor.write().context("write fixture tree")?.detach();

    let parents: Vec<gix::ObjectId> = repo.head_commit().ok().map(|c| c.id).into_iter().collect();
    let sig = gix::actor::SignatureRef {
        name: gix::bstr::BStr::new(FIXTURE_NAME),
        email: gix::bstr::BStr::new(FIXTURE_EMAIL),
        time: FIXTURE_TIME,
    };
    let id = repo
        .commit_as(sig, sig, "HEAD", message, tree, parents)
        .context("create fixture commit")?;

    // Write the index from the new tree so the freshly-built repo reads
    // clean (gix::init leaves no index; without this the whole worktree
    // would look untracked to a subsequent status check).
    let mut index = repo
        .index_from_tree(&tree)
        .context("rebuild index from fixture tree")?;
    index
        .write(gix::index::write::Options::default())
        .context("persist fixture index")?;

    Ok(id.to_string())
}

/// Create a lightweight tag `refs/tags/<name>` pointing at `target_sha`.
/// Pure-gix (no shell). Fails if the tag already exists.
pub fn tag_lightweight(root: &Path, name: &str, target_sha: &str) -> Result<()> {
    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
    let target = gix::ObjectId::from_hex(target_sha.as_bytes())
        .with_context(|| format!("parse target sha `{target_sha}`"))?;
    repo.tag_reference(name, target, gix::refs::transaction::PreviousValue::MustNotExist)
        .with_context(|| format!("create tag `{name}` -> {target_sha}"))?;
    Ok(())
}

// ── Network (server-monitored poll/fetch) ───────────────────────────────────
//
// Pure-Rust git over HTTPS (gix + rustls — no `git`/`ssh` subprocess, no C).
// SSH transport in gix execs the `ssh` program, which would break the no-shell
// rule, so SSH URLs are refused here pending a pure-Rust `russh` transport.

/// `true` for SSH-style remotes (`git@host:…` or `ssh://…`) we can't yet drive
/// without shelling out to `ssh`.
pub fn is_ssh_url(url: &str) -> bool {
    url.starts_with("ssh://") || (url.contains('@') && !url.contains("://"))
}

fn reject_ssh(url: &str) -> Result<()> {
    if is_ssh_url(url) {
        return Err(anyhow!(
            "SSH remote `{url}`: pure-Rust SSH `ls-remote` (key-authenticated, \
             via `crate::ssh`) is implemented, but **pack transfer** — the object \
             negotiation behind a full clone/fetch — is not yet, so the worktree \
             can't be materialized over SSH. Use an `https://` remote for the \
             clone for now. (gix's own SSH transport execs `ssh`, which breaks \
             nornir's no-shellout rule — hence the russh path.)"
        ));
    }
    Ok(())
}

/// Clone `url` into `dest` (full worktree). Pure Rust over HTTPS.
fn clone_repo(url: &str, dest: &Path) -> Result<()> {
    reject_ssh(url)?;
    std::fs::create_dir_all(dest).with_context(|| format!("create {}", dest.display()))?;
    let mut prepare = gix::prepare_clone(url, dest)
        .with_context(|| format!("prepare clone {url}{}", dest.display()))?;
    let (mut checkout, _) = prepare
        .fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
        .with_context(|| format!("clone-fetch {url}"))?;
    checkout
        .main_worktree(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
        .with_context(|| format!("checkout worktree for {url}"))?;
    Ok(())
}

/// Fetch the default remote of an existing clone at `dest`.
fn fetch_repo(dest: &Path) -> Result<()> {
    let repo = gix::open(dest).with_context(|| format!("gix::open {}", dest.display()))?;
    let remote = repo
        .find_default_remote(gix::remote::Direction::Fetch)
        .ok_or_else(|| anyhow!("{} has no fetch remote", dest.display()))?
        .context("resolve default remote")?;
    if let Some(url) = remote.url(gix::remote::Direction::Fetch) {
        reject_ssh(&url.to_bstring().to_string())?;
    }
    remote
        .connect(gix::remote::Direction::Fetch)
        .context("connect to remote")?
        .prepare_fetch(gix::progress::Discard, Default::default())
        .context("prepare fetch")?
        .receive(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
        .context("fetch")?;
    Ok(())
}

/// Ensure `dest` is an up-to-date clone of `url`: clone if absent, else fetch.
/// Returns the resulting `HEAD` SHA. HTTPS only (see [`is_ssh_url`]).
pub fn clone_or_fetch(url: &str, dest: &Path) -> Result<String> {
    if dest.join(".git").exists() {
        fetch_repo(dest)?;
    } else {
        clone_repo(url, dest)?;
    }
    head_sha(dest)
}

/// Recursively upsert every file under `dir` into `editor`, keyed by its
/// path relative to `repo_root`. Skips the `.git` directory.
fn add_dir_recursive(
    repo: &gix::Repository,
    editor: &mut gix::object::tree::Editor<'_>,
    repo_root: &Path,
    dir: &Path,
) -> Result<()> {
    use gix::object::tree::EntryKind;

    let mut entries: Vec<_> = std::fs::read_dir(dir)
        .with_context(|| format!("read_dir {}", dir.display()))?
        .collect::<std::io::Result<Vec<_>>>()
        .with_context(|| format!("iterate {}", dir.display()))?;
    // Deterministic order keeps generated history reproducible.
    entries.sort_by_key(|e| e.file_name());

    for entry in entries {
        let path = entry.path();
        let name = entry.file_name();
        if name == ".git" {
            continue;
        }
        let meta = std::fs::symlink_metadata(&path)
            .with_context(|| format!("stat {}", path.display()))?;
        let ft = meta.file_type();
        if ft.is_dir() {
            add_dir_recursive(repo, editor, repo_root, &path)?;
            continue;
        }
        let rela = path
            .strip_prefix(repo_root)
            .expect("path is under repo_root");
        let rela = gix::path::into_bstr(rela).into_owned();

        let (bytes, kind): (Vec<u8>, EntryKind) = if ft.is_symlink() {
            let target = std::fs::read_link(&path)
                .with_context(|| format!("readlink {}", path.display()))?;
            (gix::path::into_bstr(target).into_owned().into(), EntryKind::Link)
        } else {
            let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?;
            #[cfg(unix)]
            let kind = {
                use std::os::unix::fs::PermissionsExt;
                if meta.permissions().mode() & 0o111 != 0 {
                    EntryKind::BlobExecutable
                } else {
                    EntryKind::Blob
                }
            };
            #[cfg(not(unix))]
            let kind = EntryKind::Blob;
            (bytes, kind)
        };

        let blob = repo.write_blob(&bytes).context("write blob")?;
        editor
            .upsert(rela.as_ref() as &gix::bstr::BStr, kind, blob.detach())
            .with_context(|| format!("tree upsert {rela}"))?;
    }
    Ok(())
}