use crate::changeset::Changeset;
use crate::error::{Error, Result};
use crate::repo::VaultRepo;
use git2::Oid;
use std::path::Path;
use tracing::instrument;
impl VaultRepo {
pub fn read_at(&self, commit: Oid, path: &str) -> Result<Option<Vec<u8>>> {
let tree = self.git().find_commit(commit)?.tree()?;
match tree.get_path(Path::new(path)) {
Ok(entry) => Ok(Some(self.read_blob(entry.id())?)),
Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None),
Err(e) => Err(Error::Git(e)),
}
}
pub fn paths_changed_between(&self, a: Oid, b: Oid) -> Result<Vec<String>> {
let r = self.git();
let a_tree = r.find_commit(a)?.tree()?;
let b_tree = r.find_commit(b)?.tree()?;
let diff = r.diff_tree_to_tree(Some(&a_tree), Some(&b_tree), None)?;
let mut paths = Vec::new();
diff.foreach(
&mut |delta, _| {
if let Some(p) = delta.new_file().path().or_else(|| delta.old_file().path()) {
paths.push(p.to_string_lossy().to_string());
}
true
},
None,
None,
None,
)?;
Ok(paths)
}
pub fn diff_path_statuses(&self, a: Option<Oid>, b: Oid) -> Result<Vec<(String, bool)>> {
let r = self.git();
let b_tree = r.find_commit(b)?.tree()?;
let a_tree = match a {
Some(oid) => Some(r.find_commit(oid)?.tree()?),
None => None,
};
let diff = r.diff_tree_to_tree(a_tree.as_ref(), Some(&b_tree), None)?;
let mut out = Vec::new();
diff.foreach(
&mut |delta, _| {
let status = delta.status();
let path = match status {
git2::Delta::Deleted => delta.old_file().path(),
_ => delta.new_file().path().or_else(|| delta.old_file().path()),
};
if let Some(p) = path {
let present_in_b = !matches!(status, git2::Delta::Deleted);
out.push((p.to_string_lossy().to_string(), present_in_b));
}
true
},
None,
None,
None,
)?;
Ok(out)
}
#[instrument(
skip(self, paths, message),
fields(target_commit = %target_commit, n_paths = paths.len()),
name = "git_build_restore_changeset"
)]
pub fn build_restore_changeset(
&self,
target_commit: Oid,
paths: &[String],
message: impl Into<String>,
) -> Result<Option<Changeset>> {
let head_oid = self
.head_oid()
.ok_or_else(|| Error::Other("cannot restore: branch is unborn".to_string()))?;
let head_tree = self.git().find_commit(head_oid)?.tree_id();
let target_tree = self.git().find_commit(target_commit)?.tree_id();
let mut txn = Changeset::new(message);
let mut any = false;
for path in paths {
let current = self.blob_oid_at(head_tree, path)?;
let target = self.blob_oid_at(target_tree, path)?;
if current == target {
continue; }
match (current, target) {
(Some(current_oid), Some(target_oid)) => {
let content = self.read_blob(target_oid)?;
txn = txn.update(path, content, current_oid);
}
(Some(current_oid), None) => {
txn = txn.delete(path, current_oid);
}
(None, Some(target_oid)) => {
let content = self.read_blob(target_oid)?;
txn = txn.create(path, content);
}
(None, None) => unreachable!("filtered by current == target above"),
}
any = true;
}
Ok(if any { Some(txn) } else { None })
}
}
#[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 workfile(vr: &VaultRepo, rel: &str) -> std::path::PathBuf {
vr.git().workdir().unwrap().join(rel)
}
fn read_wt(vr: &VaultRepo, rel: &str) -> String {
std::fs::read_to_string(workfile(vr, rel)).unwrap()
}
fn commit(vr: &VaultRepo, txn: Changeset) -> Oid {
vr.commit_changeset(&txn).unwrap().commit
}
#[test]
fn read_at_returns_content_or_none() {
let (_t, vr) = open_unborn();
let c1 = commit(&vr, Changeset::new("c").create("a.md", "v1"));
assert_eq!(
vr.read_at(c1, "a.md").unwrap().as_deref(),
Some(b"v1".as_slice())
);
assert_eq!(vr.read_at(c1, "missing.md").unwrap(), None);
}
#[test]
fn paths_changed_between_diff_two_commits() {
let (_t, vr) = open_unborn();
let c1 = commit(&vr, Changeset::new("c").create("a.md", "alpha"));
let blob_a = VaultRepo::blob_oid_of(b"alpha").unwrap();
let c2 = commit(
&vr,
Changeset::new("c2")
.update("a.md", "ALPHA", blob_a)
.create("b.md", "beta"),
);
let mut paths = vr.paths_changed_between(c1, c2).unwrap();
paths.sort();
assert_eq!(paths, vec!["a.md".to_string(), "b.md".to_string()]);
}
#[test]
fn restore_updates_a_changed_path_back() {
let (_t, vr) = open_unborn();
let c1 = commit(&vr, Changeset::new("c").create("a.md", "v1"));
let blob_v1 = VaultRepo::blob_oid_of(b"v1").unwrap();
let _c2 = commit(&vr, Changeset::new("u").update("a.md", "v2", blob_v1));
let restore_txn = vr
.build_restore_changeset(c1, &["a.md".to_string()], "rollback to c1")
.unwrap()
.expect("there IS something to restore");
vr.commit_changeset(&restore_txn).unwrap();
assert_eq!(read_wt(&vr, "a.md"), "v1", "restored to c1's content");
}
#[test]
fn restore_recreates_a_deleted_path() {
let (_t, vr) = open_unborn();
let c1 = commit(&vr, Changeset::new("c").create("a.md", "v1"));
let blob_v1 = VaultRepo::blob_oid_of(b"v1").unwrap();
let _c2 = commit(&vr, Changeset::new("d").delete("a.md", blob_v1));
assert!(!workfile(&vr, "a.md").exists());
let restore_txn = vr
.build_restore_changeset(c1, &["a.md".to_string()], "undo delete")
.unwrap()
.unwrap();
vr.commit_changeset(&restore_txn).unwrap();
assert_eq!(read_wt(&vr, "a.md"), "v1");
}
#[test]
fn restore_deletes_a_created_path() {
let (_t, vr) = open_unborn();
let c1 = commit(&vr, Changeset::new("seed").create("seed.md", "S"));
let _c2 = commit(&vr, Changeset::new("c").create("a.md", "alpha"));
assert!(workfile(&vr, "a.md").exists());
let restore_txn = vr
.build_restore_changeset(c1, &["a.md".to_string()], "undo create")
.unwrap()
.unwrap();
vr.commit_changeset(&restore_txn).unwrap();
assert!(
!workfile(&vr, "a.md").exists(),
"a.md absent in target, removed"
);
}
#[test]
fn restore_no_op_when_current_matches_target() {
let (_t, vr) = open_unborn();
let c1 = commit(&vr, Changeset::new("c").create("a.md", "v1"));
let result = vr
.build_restore_changeset(c1, &["a.md".to_string()], "nothing to do")
.unwrap();
assert!(result.is_none(), "no-op restore returns None");
}
#[test]
fn restore_full_commit_undoes_its_changes() {
let (_t, vr) = open_unborn();
let c1 = commit(
&vr,
Changeset::new("seed")
.create("a.md", "A1")
.create("b.md", "B1"),
);
let blob_a1 = VaultRepo::blob_oid_of(b"A1").unwrap();
let blob_b1 = VaultRepo::blob_oid_of(b"B1").unwrap();
let c2 = commit(
&vr,
Changeset::new("multi")
.update("a.md", "A2", blob_a1)
.update("b.md", "B2", blob_b1),
);
let paths = vr.paths_changed_between(c1, c2).unwrap();
let restore_txn = vr
.build_restore_changeset(c1, &paths, "rollback c2")
.unwrap()
.unwrap();
vr.commit_changeset(&restore_txn).unwrap();
assert_eq!(read_wt(&vr, "a.md"), "A1");
assert_eq!(read_wt(&vr, "b.md"), "B1");
}
#[test]
fn restore_aborts_loudly_if_path_changed_since_request() {
let (_t, vr) = open_unborn();
let c1 = commit(&vr, Changeset::new("c").create("a.md", "v1"));
let blob_v1 = VaultRepo::blob_oid_of(b"v1").unwrap();
let _c2 = commit(&vr, Changeset::new("u").update("a.md", "v2", blob_v1));
let restore_txn = vr
.build_restore_changeset(c1, &["a.md".to_string()], "rollback to c1")
.unwrap()
.unwrap();
let blob_v2 = VaultRepo::blob_oid_of(b"v2").unwrap();
commit(&vr, Changeset::new("u2").update("a.md", "v3", blob_v2));
let res = vr.commit_changeset(&restore_txn);
assert!(matches!(res, Err(Error::PreconditionFailed { path, .. }) if path == "a.md"));
assert_eq!(read_wt(&vr, "a.md"), "v3", "concurrent change preserved");
}
#[test]
fn diff_path_statuses_initial_commit_treats_everything_as_added() {
let (_t, vr) = open_unborn();
let c = commit(
&vr,
Changeset::new("init")
.create("a.md", "A")
.create("dir/b.md", "B"),
);
let mut out = vr.diff_path_statuses(None, c).unwrap();
out.sort();
assert_eq!(
out,
vec![("a.md".to_string(), true), ("dir/b.md".to_string(), true)],
);
}
#[test]
fn diff_path_statuses_distinguishes_added_modified_deleted() {
let (_t, vr) = open_unborn();
let c1 = commit(
&vr,
Changeset::new("seed")
.create("keep.md", "K")
.create("gone.md", "G")
.create("mod.md", "M1"),
);
let m1 = VaultRepo::blob_oid_of(b"M1").unwrap();
let g = VaultRepo::blob_oid_of(b"G").unwrap();
let c2 = commit(
&vr,
Changeset::new("mix")
.create("new.md", "N")
.update("mod.md", "M2", m1)
.delete("gone.md", g),
);
let mut out = vr.diff_path_statuses(Some(c1), c2).unwrap();
out.sort();
assert_eq!(
out,
vec![
("gone.md".to_string(), false), ("mod.md".to_string(), true), ("new.md".to_string(), true), ],
"keep.md (unchanged) is NOT in the diff"
);
}
#[test]
fn diff_path_statuses_empty_for_identical_commits() {
let (_t, vr) = open_unborn();
let c = commit(&vr, Changeset::new("c").create("a.md", "x"));
let out = vr.diff_path_statuses(Some(c), c).unwrap();
assert!(out.is_empty());
}
}