use bytes::Bytes;
use indexmap::IndexMap;
use tracing::instrument;
use crate::{
low_level::{
Commit, CommitUserInfo, HashOutput, PackFileEntry, TreeItem as LowLevelTreeItem,
TreeItemKind,
},
util::ArcOrCowStr,
Error,
};
#[derive(Default, Debug)]
pub struct GitRepository {
packfile_entries: IndexMap<HashOutput, PackFileEntry>,
tree: Tree,
}
impl GitRepository {
#[instrument(skip(self, file, content), err)]
pub fn insert(
&mut self,
path: &[&'static str],
file: impl Into<ArcOrCowStr>,
content: Bytes,
) -> Result<(), Error> {
let mut directory = &mut self.tree;
for part in path {
let tree_item = directory
.0
.entry((*part).into())
.or_insert_with(|| Box::new(TreeItem::Tree(Tree::default())));
if let TreeItem::Tree(d) = tree_item.as_mut() {
directory = d;
} else {
return Err(Error::NotDirectory(part));
}
}
let entry = PackFileEntry::Blob(content);
let file_hash = entry.hash()?;
directory
.0
.insert(file.into(), Box::new(TreeItem::Blob(file_hash)));
self.packfile_entries.insert(file_hash, entry);
Ok(())
}
#[instrument(skip(self, name, email, message), err)]
pub fn commit(
mut self,
name: &'static str,
email: &'static str,
message: &'static str,
) -> Result<(HashOutput, Vec<PackFileEntry>), Error> {
let tree_hash = self
.tree
.into_packfile_entries(&mut self.packfile_entries)?;
let commit_user = CommitUserInfo {
name,
email,
time: time::OffsetDateTime::UNIX_EPOCH,
};
let commit = PackFileEntry::Commit(Commit {
tree: tree_hash,
author: commit_user,
committer: commit_user,
message,
});
let commit_hash = commit.hash()?;
self.packfile_entries.insert(commit_hash, commit);
Ok((
commit_hash,
self.packfile_entries.into_iter().map(|(_, v)| v).collect(),
))
}
}
#[derive(Default, Debug)]
struct Tree(IndexMap<ArcOrCowStr, Box<TreeItem>>);
impl Tree {
#[instrument(skip(self, pack_file), err)]
fn into_packfile_entries(
self,
pack_file: &mut IndexMap<HashOutput, PackFileEntry>,
) -> Result<HashOutput, Error> {
let mut tree = Vec::with_capacity(self.0.len());
for (name, item) in self.0 {
tree.push(match *item {
TreeItem::Blob(hash) => LowLevelTreeItem {
kind: TreeItemKind::File,
sort_name: name.to_string(),
name,
hash,
},
TreeItem::Tree(tree) => LowLevelTreeItem {
kind: TreeItemKind::Directory,
sort_name: format!("{name}/"),
name,
hash: tree.into_packfile_entries(pack_file)?,
},
});
}
tree.sort_unstable_by(|a, b| a.sort_name.cmp(&b.sort_name));
let tree = PackFileEntry::Tree(tree);
let hash = tree.hash()?;
pack_file.insert(hash, tree);
Ok(hash)
}
}
#[derive(Debug)]
enum TreeItem {
Blob(HashOutput),
Tree(Tree),
}
#[cfg(test)]
mod test {
use crate::{high_level::GitRepository, low_level::PackFile};
use bytes::{Bytes, BytesMut};
use std::process::{Command, Stdio};
use tempfile::TempDir;
#[test]
fn deterministic() {
let mut repo = GitRepository::default();
repo.insert(&["a", "b"], "c.txt", Bytes::from("hello world!"))
.unwrap();
repo.insert(&["c", "d"], "c.txt", Bytes::from("test"))
.unwrap();
let (hash, packfile) = repo
.commit("me", "me@example.com", "initial commit")
.unwrap();
assert_eq!(
hex::encode(&hash),
"6ba08bda5731edfb2a0a00e602d1dd4bbd9d341c"
);
insta::assert_debug_snapshot!(packfile);
}
#[test]
fn git_verify_pack() {
let mut repo = GitRepository::default();
repo.insert(&[], "c.txt", Bytes::from(vec![0; 256]))
.unwrap();
repo.insert(&["e", "f"], "c.txt", Bytes::from("hiya"))
.unwrap();
repo.insert(&["c", "d"], "c.txt", Bytes::from("hello world!"))
.unwrap();
let (_hash, packfile) = repo
.commit("me", "me@example.com", "initial commit")
.unwrap();
let scratch_dir = TempDir::new().unwrap();
let packfile_path = scratch_dir.path().join("example.pack");
let mut output = BytesMut::new();
PackFile::new(&packfile).encode_to(&mut output).unwrap();
std::fs::write(&packfile_path, &output).unwrap();
let res = Command::new("git")
.arg("index-pack")
.arg(&packfile_path)
.stdout(Stdio::piped())
.spawn()
.unwrap()
.wait()
.unwrap();
assert!(res.success());
let command = Command::new("git")
.arg("verify-pack")
.arg("-v")
.stdout(Stdio::piped())
.arg(&packfile_path)
.spawn()
.unwrap();
let out = command.wait_with_output().unwrap();
assert!(out.status.success(), "git exited non-0");
let stdout = String::from_utf8_lossy(&out.stdout);
insta::with_settings!({filters => vec![
(r#"/(.*)/example.pack"#, "/path/to/example.pack")
]}, {
insta::assert_snapshot!(stdout);
});
}
}