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::<Vec<_>>();
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));
}
}