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");
}
}
}