use crate::error::{Error, Result};
use crate::repo::VaultRepo;
use git2::{Commit, Index, IndexEntry, IndexTime, Oid, Signature};
use std::path::Path;
use tracing::instrument;
#[derive(Debug, Clone)]
pub enum TreeChange {
Upsert { path: String, content: Vec<u8> },
Remove { path: String },
}
impl TreeChange {
pub fn path(&self) -> &str {
match self {
TreeChange::Upsert { path, .. } | TreeChange::Remove { path } => path,
}
}
}
impl VaultRepo {
#[instrument(
skip(self, changes),
fields(base = ?base, n_changes = changes.len()),
name = "git_build_tree"
)]
pub fn build_tree(&self, base: Option<Oid>, changes: &[TreeChange]) -> Result<Oid> {
let repo = self.git();
let mut index = Index::new()?;
if let Some(base_oid) = base {
let tree = repo.find_tree(base_oid)?;
index.read_tree(&tree)?;
}
for change in changes {
match change {
TreeChange::Upsert { path, content } => {
let blob = repo.blob(content)?;
index.add(&IndexEntry {
ctime: IndexTime::new(0, 0),
mtime: IndexTime::new(0, 0),
dev: 0,
ino: 0,
mode: 0o100_644,
uid: 0,
gid: 0,
file_size: content.len() as u32,
id: blob,
flags: 0,
flags_extended: 0,
path: path.as_bytes().to_vec(),
})?;
}
TreeChange::Remove { path } => {
index.remove_path(Path::new(path))?;
}
}
}
Ok(index.write_tree_to(repo)?)
}
#[instrument(
skip(self),
fields(tree = %tree, n_parents = parents.len(), message = %message),
name = "git_commit_tree"
)]
pub fn commit_tree(&self, tree: Oid, parents: &[Oid], message: &str) -> Result<Oid> {
let repo = self.git();
let sig = self.author_signature()?;
let tree = repo.find_tree(tree)?;
let parent_commits: Vec<Commit> = parents
.iter()
.map(|oid| repo.find_commit(*oid))
.collect::<std::result::Result<_, _>>()?;
let parent_refs: Vec<&Commit> = parent_commits.iter().collect();
Ok(repo.commit(None, &sig, &sig, message, &tree, &parent_refs)?)
}
pub fn blob_oid_at(&self, tree: Oid, path: &str) -> Result<Option<Oid>> {
let tree = self.git().find_tree(tree)?;
match tree.get_path(Path::new(path)) {
Ok(entry) => Ok(Some(entry.id())),
Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None),
Err(e) => Err(Error::Git(e)),
}
}
pub fn read_blob(&self, oid: Oid) -> Result<Vec<u8>> {
Ok(self.git().find_blob(oid)?.content().to_vec())
}
fn author_signature(&self) -> Result<Signature<'static>> {
Ok(Signature::now("TurboVault", "turbovault@localhost")?)
}
}
#[cfg(test)]
mod tests {
use super::*;
use git2::Repository;
use tempfile::TempDir;
fn open_unborn() -> (TempDir, VaultRepo) {
let tmp = TempDir::new().unwrap();
let mut opts = git2::RepositoryInitOptions::new();
opts.initial_head("main");
Repository::init_opts(tmp.path(), &opts).unwrap();
let vr = VaultRepo::open(tmp.path()).unwrap();
(tmp, vr)
}
fn upsert(path: &str, content: &str) -> TreeChange {
TreeChange::Upsert {
path: path.to_string(),
content: content.as_bytes().to_vec(),
}
}
#[test]
fn build_tree_from_empty_base() {
let (_tmp, vr) = open_unborn();
let t = vr.build_tree(None, &[upsert("a.md", "alpha")]).unwrap();
let oid = vr.blob_oid_at(t, "a.md").unwrap().expect("a.md present");
assert_eq!(vr.read_blob(oid).unwrap(), b"alpha");
assert!(vr.blob_oid_at(t, "missing.md").unwrap().is_none());
}
#[test]
fn build_tree_seeds_from_base_and_adds() {
let (_tmp, vr) = open_unborn();
let t1 = vr.build_tree(None, &[upsert("a.md", "alpha")]).unwrap();
let t2 = vr.build_tree(Some(t1), &[upsert("b.md", "beta")]).unwrap();
assert!(vr.blob_oid_at(t2, "a.md").unwrap().is_some());
let b = vr.blob_oid_at(t2, "b.md").unwrap().unwrap();
assert_eq!(vr.read_blob(b).unwrap(), b"beta");
}
#[test]
fn upsert_overwrites_existing() {
let (_tmp, vr) = open_unborn();
let t1 = vr.build_tree(None, &[upsert("a.md", "v1")]).unwrap();
let t2 = vr.build_tree(Some(t1), &[upsert("a.md", "v2")]).unwrap();
let oid = vr.blob_oid_at(t2, "a.md").unwrap().unwrap();
assert_eq!(vr.read_blob(oid).unwrap(), b"v2");
}
#[test]
fn remove_drops_path() {
let (_tmp, vr) = open_unborn();
let t1 = vr
.build_tree(None, &[upsert("a.md", "alpha"), upsert("b.md", "beta")])
.unwrap();
let t2 = vr
.build_tree(
Some(t1),
&[TreeChange::Remove {
path: "a.md".to_string(),
}],
)
.unwrap();
assert!(
vr.blob_oid_at(t2, "a.md").unwrap().is_none(),
"a.md removed"
);
assert!(vr.blob_oid_at(t2, "b.md").unwrap().is_some(), "b.md kept");
}
#[test]
fn commit_tree_creates_object_without_moving_ref() {
let (_tmp, vr) = open_unborn();
let t1 = vr.build_tree(None, &[upsert("a.md", "alpha")]).unwrap();
let c1 = vr.commit_tree(t1, &[], "init").unwrap();
assert!(vr.is_unborn(), "commit_tree must NOT advance any ref");
assert_eq!(vr.head_oid(), None);
let t2 = vr.build_tree(Some(t1), &[upsert("b.md", "beta")]).unwrap();
let c2 = vr.commit_tree(t2, &[c1], "add b").unwrap();
let commit2 = vr.git().find_commit(c2).unwrap();
assert_eq!(commit2.parent_count(), 1);
assert_eq!(commit2.parent_id(0).unwrap(), c1);
let b = vr.blob_oid_at(commit2.tree_id(), "b.md").unwrap().unwrap();
assert_eq!(vr.read_blob(b).unwrap(), b"beta");
}
}