//! A mirror's objects read in process, for pages that must be quick: //! starting a git process costs more than these reads take. Each mirror is //! opened once; new packs a sync brings are found when first needed. use anyhow::{Context, Result}; use gix::revision::walk::Sorting; use gix::traverse::commit::simple::CommitTimeOrder; use knotless::read::{Author, Commit, Entry}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{LazyLock, Mutex}; static OPEN: LazyLock>> = LazyLock::new(Mutex::default); pub struct Objects(gix::Repository); /// The repository at `dir`, a bare mirror. pub fn open(dir: &Path) -> Result { let mut open = OPEN.lock().unwrap(); let safe = match open.get(dir) { Some(safe) => safe.clone(), None => { let safe = gix::ThreadSafeRepository::open(dir)?; open.insert(dir.to_path_buf(), safe.clone()); safe } }; let mut repo = safe.to_thread_local(); repo.object_cache_size_if_unset(4 << 20); Ok(Objects(repo)) } /// The id of whatever `path` names in `tree`, if anything. fn id_at(tree: &gix::Tree<'_>, path: &str) -> Result> { Ok(tree.lookup_entry_by_path(path)?.map(|e| e.oid().to_owned())) } impl Objects { /// The branch HEAD names, if it names one. pub fn head(&self) -> Option { let name = self.0.head_name().ok()??; Some(name.shorten().to_string()) } /// Every branch and tag, by its short name. pub fn names(&self) -> Result> { let platform = self.0.references()?; let mut names = Vec::new(); for r in platform.all()? { let r = r?; let full = r.name().as_bstr().to_string(); if let Some(short) = full .strip_prefix("refs/heads/") .or(full.strip_prefix("refs/tags/")) { names.push(short.to_string()); } } Ok(names) } fn commit(&self, rev: &str) -> Result> { let id = self .0 .rev_parse_single(rev) .with_context(|| format!("no revision {rev}"))?; Ok(id.object()?.peel_to_commit()?) } /// The commit `rev` names, by its full id. pub fn resolve(&self, rev: &str) -> Result { Ok(self.commit(rev)?.id.to_string()) } pub fn tree(&self, rev: &str, path: &str) -> Result> { let mut tree = self.commit(rev)?.tree()?; if !path.is_empty() { let entry = tree .lookup_entry_by_path(path)? .with_context(|| format!("no {path} at {rev}"))?; tree = entry .object()? .try_into_tree() .with_context(|| format!("{path} is not a directory"))?; } let mut out = Vec::new(); for e in tree.iter() { let e = e?; let mode = e.mode(); let kind = if mode.is_tree() { "tree" } else if mode.is_commit() { "commit" } else { "blob" }; let size = match kind { "blob" => Some(self.0.find_header(e.oid())?.size()), _ => None, }; out.push(Entry { name: e.filename().to_string(), mode: mode.kind().as_octal_str().to_string(), kind: kind.into(), sha: e.oid().to_string(), size, }); } Ok(out) } pub fn blob(&self, rev: &str, path: &str) -> Result> { let tree = self.commit(rev)?.tree()?; let entry = tree .lookup_entry_by_path(path)? .with_context(|| format!("no {path} at {rev}"))?; let object = entry.object()?; anyhow::ensure!( object.kind == gix::object::Kind::Blob, "{path} is not a file" ); Ok(object.detach().data) } /// Whether `commit` changed `path` from each of its parents: a commit /// that left it as one parent had it is not part of its history. fn touches(&self, commit: &gix::Commit<'_>, path: &str) -> Result { let here = id_at(&commit.tree()?, path)?; let mut parents = commit.parent_ids().peekable(); if parents.peek().is_none() { return Ok(here.is_some()); } for parent in parents { if id_at(&parent.object()?.peel_to_commit()?.tree()?, path)? == here { return Ok(false); } } Ok(true) } /// History from `rev`, newest first, touching `path` if it is not /// empty, after its first `skip` commits. pub fn log(&self, rev: &str, path: &str, skip: usize, limit: usize) -> Result> { let tip = self.commit(rev)?.id; let walk = self .0 .rev_walk([tip]) .sorting(Sorting::ByCommitTime(CommitTimeOrder::NewestFirst)) .all()?; let mut out = Vec::new(); let mut skipped = 0; for info in walk { let commit = info?.object()?; if !path.is_empty() && !self.touches(&commit, path)? { continue; } if skipped < skip { skipped += 1; continue; } out.push(summary(&commit)?); if out.len() == limit { break; } } Ok(out) } /// What moving a ref to `to` brought: the commits it reaches that none /// of `known` does, newest first, at most `limit` of them. A revision /// in `known` that is not here is passed over. pub fn brought(&self, to: &str, known: &[&str], limit: usize) -> Result> { let tip = self.commit(to)?.id; let hidden: Vec = known .iter() .filter_map(|k| self.commit(k).ok().map(|c| c.id)) .collect(); let walk = self .0 .rev_walk([tip]) .with_hidden(hidden) .sorting(Sorting::ByCommitTime(CommitTimeOrder::NewestFirst)) .all()?; let mut out = Vec::new(); for info in walk.take(limit) { out.push(summary(&info?.object()?)?); } Ok(out) } } fn summary(commit: &gix::Commit<'_>) -> Result { let author = commit.author()?; let date = author .time()? .format(gix::date::time::format::ISO8601_STRICT)?; Ok(Commit { sha: commit.id.to_string(), parents: commit.parent_ids().map(|p| p.to_string()).collect(), author: Author { name: author.name.to_string(), email: author.email.to_string(), date, }, message: commit.message_raw()?.to_string().trim_end().to_string(), }) } #[cfg(test)] mod tests { use super::*; use knotless::git::Git; async fn git(dir: &Path, args: &[&str]) -> String { Git::new(dir).text(args, None).await.unwrap() } #[tokio::test] async fn reads_as_git_does() { let dir = tempfile::tempdir().unwrap(); let d = dir.path(); let run = |args: Vec<&str>| { let out = std::process::Command::new("git") .args(args) .current_dir(d) .env("GIT_CONFIG_GLOBAL", "/dev/null") .env("GIT_AUTHOR_NAME", "A") .env("GIT_AUTHOR_EMAIL", "a@x") .env("GIT_COMMITTER_NAME", "A") .env("GIT_COMMITTER_EMAIL", "a@x") .output() .unwrap(); assert!(out.status.success(), "{out:?}"); }; run(vec!["init", "-q", "-b", "main"]); std::fs::create_dir(d.join("src")).unwrap(); std::fs::write(d.join("src/a.rs"), "fn a() {}\n").unwrap(); std::fs::write(d.join("b"), "b\n").unwrap(); run(vec!["add", "-A"]); run(vec!["commit", "-q", "-m", "one\n\nwith a body"]); std::fs::write(d.join("b"), "bb\n").unwrap(); run(vec!["commit", "-qam", "two"]); run(vec!["checkout", "-q", "-b", "side", "HEAD~1"]); std::fs::write(d.join("src/a.rs"), "fn a() { 1 }\n").unwrap(); run(vec!["commit", "-qam", "three"]); run(vec!["checkout", "-q", "main"]); run(vec!["merge", "-q", "--no-edit", "side"]); let one = git(&d.join(".git"), &["rev-parse", "HEAD~1"]).await; run(vec![ "update-index", "--add", "--cacheinfo", &format!("160000,{one},vendored"), ]); run(vec!["commit", "-qm", "vendor"]); run(vec!["tag", "v1"]); run(vec!["update-ref", "refs/notes/x", "HEAD"]); let o = open(&d.join(".git")).unwrap(); let again = open(&d.join(".git")).unwrap(); assert_eq!(o.head().as_deref(), Some("main")); assert_eq!( again.resolve("v1").unwrap(), git(&d.join(".git"), &["rev-parse", "HEAD"]).await ); let mut names = o.names().unwrap(); names.sort(); assert_eq!(names, ["main", "side", "v1"]); let tree = o.tree("main", "").unwrap(); let shown: Vec<(&str, &str, Option)> = tree .iter() .map(|e| (e.name.as_str(), e.kind.as_str(), e.size)) .collect(); assert_eq!( shown, [ ("b", "blob", Some(3)), ("src", "tree", None), ("vendored", "commit", None) ] ); assert_eq!(tree[1].mode, "40000"); assert_eq!(o.tree("main", "src").unwrap()[0].name, "a.rs"); assert!(o.tree("main", "b").is_err()); assert!(o.tree("main", "nope").is_err()); assert!(o.tree("nope", "").is_err()); assert_eq!(o.blob("main", "src/a.rs").unwrap(), b"fn a() { 1 }\n"); assert!(o.blob("main", "src").is_err()); assert!(o.blob("main", "nope").is_err()); let subjects = |log: Vec| { log.iter() .map(|c| c.message.lines().next().unwrap().to_string()) .collect::>() }; let all = o.log("main", "", 0, 10).unwrap(); assert_eq!(all.len(), 5); assert_eq!(all[1].parents.len(), 2); assert_eq!(all[4].message, "one\n\nwith a body"); assert_eq!(all[4].author.name, "A"); assert!(all[4].author.date.contains('T'), "{}", all[4].author.date); assert_eq!(o.log("main", "", 1, 2).unwrap().len(), 2); assert_eq!(subjects(o.log("main", "b", 0, 10).unwrap()), ["two", "one"]); assert_eq!( subjects(o.log("main", "src", 0, 10).unwrap()), ["three", "one"] ); assert_eq!(subjects(o.log("main", "src", 1, 10).unwrap()), ["one"]); assert!(o.log("main", "nowhere", 0, 10).unwrap().is_empty()); assert!(o.log("nope", "", 0, 10).is_err()); assert!(open(&d.join("missing")).is_err()); // What a push of main brought, past side and a ref it never saw. assert_eq!( subjects(o.brought("main", &["side", "gone"], 10).unwrap()), ["vendor", "Merge branch 'side'", "two"] ); assert_eq!(o.brought("main", &[], 10).unwrap().len(), 5); assert_eq!(o.brought("main", &[], 2).unwrap().len(), 2); assert!(o.brought("main", &["main"], 10).unwrap().is_empty()); assert!(o.brought("nope", &[], 10).is_err()); run(vec!["checkout", "-q", "--detach"]); assert_eq!(open(&d.join(".git")).unwrap().head(), None); } }