use std::path::Path; use anyhow::Context as _; use gix::commitgraph::write::{self, Split}; use knot_git::Repo; /// the caller holds the repo's exclusive lock, which keeps the refs read here still. /// a failed write only gets logged, since the graph only speeds up walks. pub(crate) fn refresh(repo_path: &Path) { if let Err(error) = extend(repo_path) { tracing::warn!( target: "knot_static::commit_graph", path = %repo_path.display(), "the commit-graph wasn't written: {error:#}" ); } } /// a kept layer can name commits maintenance removed, and `git commit-graph verify` refuses them. pub(crate) fn rewrite(repo_path: &Path) { if let Err(error) = discard(repo_path) { tracing::warn!( target: "knot_static::commit_graph", path = %repo_path.display(), "the commit-graph wasn't dropped: {error:#}" ); return; } refresh(repo_path); } fn discard(repo_path: &Path) -> anyhow::Result<()> { let info_dir = Repo::open(repo_path) .context("open repo for its commit-graph")? .objects_dir() .join("info"); let gone = |error: &std::io::Error| error.kind() == std::io::ErrorKind::NotFound; std::fs::remove_file(info_dir.join("commit-graph")) .or_else(|error| if gone(&error) { Ok(()) } else { Err(error) }) .context("remove the commit-graph")?; std::fs::remove_dir_all(info_dir.join("commit-graphs")) .or_else(|error| if gone(&error) { Ok(()) } else { Err(error) }) .context("remove the commit-graph chain")?; Ok(()) } fn extend(repo_path: &Path) -> anyhow::Result<()> { let repo = Repo::open(repo_path).context("open repo for its commit-graph")?; let info_dir = repo.objects_dir().join("info"); let graph = gix::commitgraph::Graph::from_info_dir(&info_dir).ok(); let covered = |oid: knot_types::Oid| { graph .as_ref() .is_some_and(|graph| graph.lookup(oid.object_id()).is_some()) }; // skip the refs the graph already covers, since peeling reads objects. let tips = repo .references()? .into_iter() .filter(|record| !covered(record.target)) .filter_map(|record| repo.peel_to_commit(record.target).ok()) .filter(|commit| !covered(*commit)) .map(|commit| commit.object_id()) .collect::>(); if tips.is_empty() { return Ok(()); } std::fs::create_dir_all(&info_dir).context("create the objects info directory")?; let options = write::Options { object_hash: repo.object_format().kind(), // static knots serve no path-limited log, which is why there are no changed-path filters. bloom_settings: write::bloom_filter_settings(graph.as_ref(), false, None), max_new_filters: None, thread_limit: knot_resource::gix_thread_limit().map(knot_resource::ThreadCount::get), split: Split::Merge { size_multiple: 2, max_commits: None, }, }; write::write_to_info_dir(&repo.git().objects, &info_dir, graph, tips, options, None) .map_err(|error| anyhow::anyhow!("{error}")) .context("write a commit-graph layer")?; Ok(()) } #[cfg(test)] mod tests { use super::*; fn git_at(repo: &Path, args: &[&str], date: Option<&str>) -> String { let mut command = crate::test_git(); command .args(args) .current_dir(repo) .env("GIT_AUTHOR_NAME", "t") .env("GIT_AUTHOR_EMAIL", "t@t") .env("GIT_COMMITTER_NAME", "t") .env("GIT_COMMITTER_EMAIL", "t@t") .env("GIT_CONFIG_GLOBAL", "/dev/null") .env("GIT_CONFIG_SYSTEM", "/dev/null"); if let Some(date) = date { command .env("GIT_AUTHOR_DATE", date) .env("GIT_COMMITTER_DATE", date); } let output = command.output().expect("run git"); assert!( output.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&output.stderr) ); String::from_utf8(output.stdout).unwrap().trim().to_string() } fn git(repo: &Path, args: &[&str]) -> String { git_at(repo, args, None) } fn commit(repo: &Path, message: &str, at: Option<&str>) -> String { std::fs::write(repo.join(format!("{message}.txt")), message).expect("write file"); git(repo, &["add", "-A"]); git_at(repo, &["commit", "-q", "-m", message], at); git(repo, &["rev-parse", "HEAD"]) } fn repo_with_a_commit(root: &Path) -> (std::path::PathBuf, String) { let path = root.join("repo"); git(root, &["init", "-q", "-b", "main", path.to_str().unwrap()]); let head = commit(&path, "first", None); (path, head) } fn covers(repo_path: &Path, oid: &str) -> bool { let repo = Repo::open(repo_path).expect("open repo"); let graph = gix::commitgraph::Graph::from_info_dir(&repo.objects_dir().join("info")) .expect("open the commit-graph"); graph .lookup(gix::ObjectId::from_hex(oid.as_bytes()).expect("oid")) .is_some() } #[test] fn a_later_commit_joins_the_graph_the_first_one_is_in() { let root = tempfile::tempdir().unwrap(); let (path, first) = repo_with_a_commit(root.path()); refresh(&path); assert!(covers(&path, &first)); let second = commit(&path, "second", None); refresh(&path); assert!(covers(&path, &first), "the layer below is still read"); assert!(covers(&path, &second)); assert_eq!( git(&path, &["commit-graph", "verify"]), "", "git has to accept what we wrote" ); } #[test] fn the_graph_keeps_the_ancestry_of_a_commit_older_than_its_parent() { let root = tempfile::tempdir().unwrap(); let (path, first) = repo_with_a_commit(root.path()); // older than its parent, which sends a time-ordered walk through all history. let backdated = commit(&path, "backdated", Some("2001-01-01T00:00:00Z")); refresh(&path); let repo = Repo::open(&path).unwrap(); let oid = |text: &str| knot_types::Oid::from_hex(text).unwrap(); assert!(covers(&path, &backdated)); assert_eq!( repo.merge_base(oid(&first), oid(&backdated)).unwrap(), Some(oid(&first)), "the older commit still fast-forwards its parent" ); } #[test] fn a_corrupt_graph_is_replaced_rather_than_read() { let root = tempfile::tempdir().unwrap(); let (path, first) = repo_with_a_commit(root.path()); let info = Repo::open(&path).unwrap().objects_dir().join("info"); std::fs::create_dir_all(&info).unwrap(); std::fs::write(info.join("commit-graph"), b"not a commit-graph").unwrap(); refresh(&path); assert!(covers(&path, &first)); assert_eq!(git(&path, &["commit-graph", "verify"]), ""); } #[test] fn a_tag_that_peels_to_a_tree_doesnt_stop_the_write() { let root = tempfile::tempdir().unwrap(); let (path, first) = repo_with_a_commit(root.path()); let tree = git(&path, &["rev-parse", "HEAD^{tree}"]); git(&path, &["tag", "-a", "-m", "a tree", "tree-tag", &tree]); refresh(&path); assert!(covers(&path, &first)); } }