Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

crates/mimir-ingest/src/git.rs

//! The git half: a repo's tracked branch, walked first-parent, as the event
//! stream; and the per-commit classification that skips docs-only commits
//! before any cargo runs.

use std::ops::ControlFlow;
use std::path::Path;

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

/// What a first-parent commit changed, relative to its first parent (the root
/// commit: relative to the empty tree).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Change {
    /// At least one path that can change what rustc sees.
    Code,
    /// Nothing rustc reads moved: the consumer records `skipped_docs_only`.
    DocsOnly,
}

/// Can a change to `path` change what rustc compiles?
///
/// `*.rs` (build scripts included), `Cargo.toml`, `Cargo.lock`, and
/// `rust-toolchain` / `rust-toolchain.toml`, at any depth.
pub fn is_code_path(path: &str) -> bool {
    let name = path.rsplit('/').next().unwrap_or(path);
    name.ends_with(".rs")
        || name == "Cargo.toml"
        || name == "Cargo.lock"
        || name.starts_with("rust-toolchain")
}

/// The branch to follow is not in the clone: no remote-tracking ref and no
/// local branch of that name. A fact about the clone (it is on a detached
/// HEAD, the branch was renamed, the name was a guess), not an IO failure.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NoSuchBranch {
    pub repo: std::path::PathBuf,
    pub branch: String,
}

impl std::fmt::Display for NoSuchBranch {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}: neither refs/remotes/origin/{b} nor refs/heads/{b} exists", self.repo.display(), b = self.branch)
    }
}

impl std::error::Error for NoSuchBranch {}

/// The history of the tracked branch was rewritten under the consumer: the
/// stored offset is not on the first-parent chain of the branch's head any
/// more (a force-push, a repo moved with its history rewritten). A fact about
/// the branch, not an IO failure.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HistoryRewritten {
    pub repo: std::path::PathBuf,
    pub branch: String,
    /// The stored offset (in the old history).
    pub offset: String,
    /// The branch's head now (in the new one).
    pub head: String,
}

impl std::fmt::Display for HistoryRewritten {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "history rewritten: offset {} is not on the first-parent chain of `{}` (head {}) in {}",
            self.offset,
            self.branch,
            self.head,
            self.repo.display()
        )
    }
}

impl std::error::Error for HistoryRewritten {}

/// Resolve the tip of the tracked `branch`: the remote-tracking ref of the
/// consumer's clone first (`refs/remotes/origin/<branch>`, what a fetch moves),
/// then a local branch of that name. A branch the clone does not have is the
/// typed [`NoSuchBranch`] (downcast the error).
pub fn branch_tip(repo: &gix::Repository, branch: &str) -> Result<gix::ObjectId> {
    for name in [format!("refs/remotes/origin/{branch}"), format!("refs/heads/{branch}")] {
        if let Some(r) = repo
            .try_find_reference(name.as_str())
            .with_context(|| format!("look up {name}"))?
        {
            let id = r
                .into_fully_peeled_id()
                .with_context(|| format!("peel {name}"))?
                .detach();
            return Ok(id);
        }
    }
    Err(anyhow::Error::new(NoSuchBranch { repo: repo.git_dir().to_path_buf(), branch: branch.to_string() }))
}

/// The branch a clone's REMOTE names as its default — what the clone was
/// made on. `git clone` records it as the symbolic ref
/// `refs/remotes/origin/HEAD`; a gix clone (how nornir clones) writes that ref
/// as a plain id and records the branch as the ONE `[branch "<name>"]` section
/// of the clone's config that tracks a remote. `None` when neither names one:
/// a remote whose own HEAD was detached, a repo with no remote, a clone whose
/// config tracks several branches.
pub fn remote_default_branch(repo_dir: &Path) -> Result<Option<String>> {
    let repo = gix::open(repo_dir).with_context(|| format!("gix::open {}", repo_dir.display()))?;
    if let Some(head) = repo.try_find_reference("refs/remotes/origin/HEAD").context("look up refs/remotes/origin/HEAD")?
        && let gix::refs::TargetRef::Symbolic(name) = head.target()
    {
        return Ok(name.as_bstr().to_string().strip_prefix("refs/remotes/origin/").map(str::to_string));
    }
    let tracking: Vec<&str> =
        repo.branch_names().into_iter().filter(|b| repo.branch_remote_name(*b, gix::remote::Direction::Fetch).is_some()).collect();
    Ok(match tracking.as_slice() {
        [one] => Some(one.to_string()),
        _ => None,
    })
}

/// The first-parent commits of `branch` after `offset` (exclusive), OLDEST
/// first — the events a consumer at `offset` has not consumed yet. `None` =
/// the whole first-parent history.
///
/// An `offset` that is not on the first-parent chain (history rewritten under
/// the consumer) is the typed [`HistoryRewritten`] (downcast the error), never
/// a silent restart: what to do about it is the consumer's call.
pub fn first_parent_since(repo_dir: &Path, branch: &str, offset: Option<&str>) -> Result<Vec<String>> {
    let repo = gix::open(repo_dir).with_context(|| format!("gix::open {}", repo_dir.display()))?;
    let tip = branch_tip(&repo, branch)?;
    let walk = repo
        .rev_walk([tip])
        .first_parent_only()
        .all()
        .context("first-parent rev_walk")?;
    let mut out = Vec::new();
    let mut found = offset.is_none();
    for info in walk {
        let info = info.context("rev_walk step")?;
        let sha = info.id.to_string();
        if offset == Some(sha.as_str()) {
            found = true;
            break;
        }
        out.push(sha);
    }
    if !found {
        return Err(anyhow::Error::new(HistoryRewritten {
            repo: repo_dir.to_path_buf(),
            branch: branch.to_string(),
            offset: offset.unwrap_or_default().to_string(),
            head: tip.to_string(),
        }));
    }
    out.reverse();
    Ok(out)
}

/// Bring the consumer's own clone at `worktree` up to date with `source`:
/// clone it when it is not there, else fetch — objects and
/// `refs/remotes/origin/*`; HEAD and the working tree are the checkout step's.
///
/// The fetch of an EXISTING clone goes through here, not through
/// `nornir_git::gitio::clone_or_fetch`: that one brings the checkout to the
/// remote's HEAD (it moves HEAD and rewrites the working tree), and in this
/// clone both belong to the checkout step, which walks commit by commit. The
/// committer identity set here is gix's generic fallback, in memory, for the
/// reflog of the remote refs the fetch moves — a box with no git config (a
/// service user, a container) has none.
pub fn sync_clone(source: &str, worktree: &Path) -> Result<()> {
    if !worktree.join(".git").exists() {
        // no content filter runs (git-lfs and its kind): the clone holds the
        // files as stored — Mimir reads code, and a smudge that fails (an LFS
        // object the source lacks) stops a checkout halfway (loki 2026-10-17)
        nornir_git::gitio::clone_or_fetch_with(source, worktree, None, nornir_git::gitio::Smudge::Skip).with_context(|| format!("clone {source} → {}", worktree.display()))?;
        return Ok(());
    }
    let mut repo = gix::open(worktree).with_context(|| format!("gix::open {}", worktree.display()))?;
    let _ = repo.committer_or_set_generic_fallback();
    let remote = repo
        .find_default_remote(gix::remote::Direction::Fetch)
        .ok_or_else(|| anyhow!("{} has no fetch remote", worktree.display()))?
        .context("resolve the clone's remote")?;
    remote
        .connect(gix::remote::Direction::Fetch)
        .with_context(|| format!("connect to {source}"))?
        .prepare_fetch(gix::progress::Discard, Default::default())
        .context("prepare fetch")?
        .receive(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
        .with_context(|| format!("fetch {source} → {}", worktree.display()))?;
    Ok(())
}

/// Is the commit `sha` in the clone at `repo_dir`? (What a fetch would
/// bring: a clone that has the commit needs none.)
pub fn has_commit(repo_dir: &Path, sha: &str) -> bool {
    let Ok(repo) = gix::open(repo_dir) else { return false };
    gix::ObjectId::from_hex(sha.as_bytes()).is_ok_and(|oid| repo.find_commit(oid).is_ok())
}

/// The commit the clone's HEAD is at (`None`: unborn or unreadable).
pub fn head(repo_dir: &Path) -> Option<String> {
    gix::open(repo_dir).ok()?.head_id().ok().map(|id| id.to_string())
}

/// The committer time of `sha`, in unix seconds.
pub fn commit_seconds(repo_dir: &Path, sha: &str) -> Result<i64> {
    let repo = gix::open(repo_dir).with_context(|| format!("gix::open {}", repo_dir.display()))?;
    let oid = gix::ObjectId::from_hex(sha.as_bytes()).map_err(|e| anyhow!("parse sha {sha}: {e}"))?;
    Ok(repo.find_commit(oid).with_context(|| format!("find commit {sha}"))?.time().context("commit time")?.seconds)
}

/// The LAST first-parent ancestor of HEAD (HEAD itself included) whose
/// committer time is at or before `when_seconds` — the clone as the world
/// was at that moment. `None`: even the root commit is younger.
pub fn head_ancestor_before(repo_dir: &Path, when_seconds: i64) -> Result<Option<String>> {
    let repo = gix::open(repo_dir).with_context(|| format!("gix::open {}", repo_dir.display()))?;
    let head = repo.head_id().context("HEAD")?.detach();
    let walk = repo.rev_walk([head]).first_parent_only().all().context("first-parent rev_walk")?;
    for info in walk {
        let info = info.context("rev_walk step")?;
        let when = repo.find_commit(info.id)?.time().context("commit time")?.seconds;
        if when <= when_seconds {
            return Ok(Some(info.id.to_string()));
        }
    }
    Ok(None)
}

/// The first parent of `sha` (`None`: a root commit).
pub fn first_parent_of(repo_dir: &Path, sha: &str) -> Result<Option<String>> {
    let repo = gix::open(repo_dir).with_context(|| format!("gix::open {}", repo_dir.display()))?;
    let oid = gix::ObjectId::from_hex(sha.as_bytes()).map_err(|e| anyhow!("parse sha {sha}: {e}"))?;
    let commit = repo.find_commit(oid).with_context(|| format!("find commit {sha}"))?;
    Ok(commit.parent_ids().next().map(|p| p.to_string()))
}

/// The tip of `branch` in the consumer's clone.
pub fn tip(repo_dir: &Path, branch: &str) -> Result<String> {
    let repo = gix::open(repo_dir).with_context(|| format!("gix::open {}", repo_dir.display()))?;
    Ok(branch_tip(&repo, branch)?.to_string())
}

/// Where a NEW consumer starts when its first consumed commit is to be `sha`:
/// the offset just before it (`None`: `sha` is a root commit, start at the
/// root). The value for `RepoSpec::initial_offset`.
pub fn offset_before(repo_dir: &Path, sha: &str) -> Result<Option<String>> {
    first_parent_of(repo_dir, sha)
}

/// The `--from` that names the branch's tip: replay the current commit only.
pub const FROM_HEAD: &str = "head";

/// The first-parent commit of `branch` a `--from` names: a full sha, a unique
/// sha prefix (≥ 7 hex digits), a date (`YYYY-MM-DD`, UTC midnight) — for
/// a date, the OLDEST first-parent commit committed at or after it — or
/// [`FROM_HEAD`], the branch's tip itself (a replay of the current commit
/// only, e.g. to dump it again with MIR facts). An error when nothing on the
/// first-parent chain matches (never a silent tip).
pub fn resolve_from(repo_dir: &Path, branch: &str, from: &str) -> Result<String> {
    let repo = gix::open(repo_dir).with_context(|| format!("gix::open {}", repo_dir.display()))?;
    let tip = branch_tip(&repo, branch)?;
    if from == FROM_HEAD {
        return Ok(tip.to_string());
    }
    let since = parse_date(from);
    let is_hex = from.len() >= 7 && from.bytes().all(|b| b.is_ascii_hexdigit());
    if since.is_none() && !is_hex {
        bail!("`{from}` is neither a commit (≥ 7 hex digits) nor a date (YYYY-MM-DD)");
    }
    let walk = repo.rev_walk([tip]).first_parent_only().all().context("first-parent rev_walk")?;
    let mut by_date: Option<String> = None;
    let mut by_sha: Vec<String> = Vec::new();
    for info in walk {
        let info = info.context("rev_walk step")?;
        let sha = info.id.to_string();
        match since {
            Some(t) => {
                let when = repo.find_commit(info.id)?.time().context("commit time")?.seconds;
                if when >= t {
                    by_date = Some(sha);
                } else {
                    break;
                }
            }
            None if sha.starts_with(&from.to_ascii_lowercase()) => by_sha.push(sha),
            None => {}
        }
    }
    match (since, by_date, by_sha.as_slice()) {
        (Some(_), Some(sha), _) => Ok(sha),
        (Some(_), None, _) => bail!("no first-parent commit of `{branch}` at or after {from}"),
        (None, _, [one]) => Ok(one.clone()),
        (None, _, []) => bail!("commit `{from}` is not on the first-parent chain of `{branch}`"),
        (None, _, many) => bail!("`{from}` names {} first-parent commits of `{branch}` — give more digits", many.len()),
    }
}

/// `YYYY-MM-DD` as unix seconds (UTC midnight); `None` when it is not a date.
fn parse_date(s: &str) -> Option<i64> {
    let mut it = s.split('-');
    let (y, m, d) = (it.next()?, it.next()?, it.next()?);
    if it.next().is_some() || y.len() != 4 || m.len() != 2 || d.len() != 2 {
        return None;
    }
    let (y, m, d): (i64, i64, i64) = (y.parse().ok()?, m.parse().ok()?, d.parse().ok()?);
    if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
        return None;
    }
    // days from civil (Howard Hinnant)
    let y = if m <= 2 { y - 1 } else { y };
    let era = if y >= 0 { y } else { y - 399 } / 400;
    let yoe = y - era * 400;
    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
    Some((era * 146_097 + doe - 719_468) * 86_400)
}

/// Classify commit `sha` by its tree diff against its first parent.
pub fn classify(repo_dir: &Path, sha: &str) -> Result<Change> {
    let repo = gix::open(repo_dir).with_context(|| format!("gix::open {}", repo_dir.display()))?;
    let oid = gix::ObjectId::from_hex(sha.as_bytes()).map_err(|e| anyhow!("parse sha {sha}: {e}"))?;
    let commit = repo.find_commit(oid).with_context(|| format!("find commit {sha}"))?;
    let tree = commit.tree().with_context(|| format!("tree of {sha}"))?;
    let base = match commit.parent_ids().next() {
        Some(pid) => repo
            .find_commit(pid)
            .with_context(|| format!("first parent of {sha}"))?
            .tree()
            .with_context(|| format!("tree of first parent of {sha}"))?,
        None => repo.empty_tree(),
    };
    let mut code = false;
    let walked = base
        .changes()
        .context("tree changes")?
        .for_each_to_obtain_tree(&tree, |change| -> gix::Result<ControlFlow<()>> {
            if is_code_path(&change.location().to_string()) {
                code = true;
                return Ok(ControlFlow::Break(()));
            }
            Ok(ControlFlow::Continue(()))
        });
    // gix reports our own early stop (Break on the first code path) as a
    // "delegate cancelled" error; only an error WITHOUT that stop is real.
    if !code {
        walked.with_context(|| format!("diff {sha} against its first parent"))?;
    }
    Ok(if code { Change::Code } else { Change::DocsOnly })
}

#[cfg(test)]
mod tests {
    use super::is_code_path;

    #[test]
    fn dates_parse_to_utc_midnight() {
        assert_eq!(super::parse_date("1970-01-01"), Some(0));
        assert_eq!(super::parse_date("2026-10-13"), Some(1_791_849_600));
        assert_eq!(super::parse_date("2024-02-29"), Some(1_709_164_800));
        for bad in ["2026-13-01", "20261013", "2026-10", "abcdef1", "2026-10-13-1"] {
            assert_eq!(super::parse_date(bad), None, "{bad}");
        }
    }

    #[test]
    fn code_paths_are_what_rustc_reads() {
        for p in [
            "src/lib.rs",
            "crates/a/build.rs",
            "Cargo.toml",
            "crates/a/Cargo.toml",
            "Cargo.lock",
            "rust-toolchain",
            "rust-toolchain.toml",
        ] {
            assert!(is_code_path(p), "{p} must count as code");
        }
        for p in ["README.md", "docs/book.md", "src/lib.rs.orig", "Cargo.toml.bak", "rs", ".nornir/plan.toml"] {
            assert!(!is_code_path(p), "{p} must not count as code");
        }
    }
}