use crate::error::{Error, Result};
use crate::repo::VaultRepo;
use git2::Oid;
use std::path::Path;
use tracing::instrument;
use uuid::Uuid;
impl VaultRepo {
pub(crate) fn ensure_worktree_matches_commit(
&self,
base: Option<Oid>,
paths: &[String],
) -> Result<()> {
let repo = self.git();
let staged_mask = git2::Status::INDEX_NEW
| git2::Status::INDEX_MODIFIED
| git2::Status::INDEX_DELETED
| git2::Status::INDEX_RENAMED
| git2::Status::INDEX_TYPECHANGE;
if repo
.statuses(None)?
.iter()
.any(|entry| entry.status().intersects(staged_mask))
{
return Err(Error::Other(
"Git index contains staged changes; commit or unstage them before a TurboVault write"
.to_string(),
));
}
let workdir = repo
.workdir()
.ok_or_else(|| Error::Other("bare repository has no working tree".to_string()))?;
let tree = match base {
Some(oid) => Some(repo.find_commit(oid)?.tree()?),
None => None,
};
for rel in paths {
let expected = match tree.as_ref() {
Some(tree) => match tree.get_path(Path::new(rel)) {
Ok(entry) => Some(repo.find_blob(entry.id())?.content().to_vec()),
Err(error) if error.code() == git2::ErrorCode::NotFound => None,
Err(error) => return Err(Error::Git(error)),
},
None => None,
};
let target = workdir.join(rel);
let actual = match std::fs::symlink_metadata(&target) {
Ok(metadata) if metadata.file_type().is_file() => Some(std::fs::read(&target)?),
Ok(_) => {
return Err(Error::Other(format!(
"working-tree path '{rel}' is not a regular file; refusing to overwrite it"
)));
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(Error::Io(error)),
};
if actual != expected {
return Err(Error::Other(format!(
"working-tree path '{rel}' differs from HEAD; commit, restore, or move the local change before retrying"
)));
}
}
Ok(())
}
#[instrument(
skip(self, paths),
fields(commit = %commit, n_paths = paths.len()),
name = "git_materialize"
)]
pub fn materialize(&self, commit: Oid, paths: &[String]) -> Result<()> {
let repo = self.git();
let workdir = repo
.workdir()
.ok_or_else(|| Error::Other("bare repository has no working tree".to_string()))?
.to_path_buf();
let tree = repo.find_commit(commit)?.tree()?;
for rel in paths {
let target = workdir.join(rel);
match tree.get_path(Path::new(rel)) {
Ok(entry) => {
let blob = repo.find_blob(entry.id())?;
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = target.with_extension(format!("tmp.{}", Uuid::new_v4()));
if let Err(e) = std::fs::write(&tmp, blob.content()) {
let _ = std::fs::remove_file(&tmp);
return Err(Error::Io(e));
}
if let Err(e) = std::fs::rename(&tmp, &target) {
let _ = std::fs::remove_file(&tmp);
return Err(Error::Io(e));
}
}
Err(e) if e.code() == git2::ErrorCode::NotFound => {
if target.exists() {
std::fs::remove_file(&target)?;
}
}
Err(e) => return Err(Error::Git(e)),
}
}
let mut index = repo.index()?;
index.read_tree(&tree)?;
index.write()?;
Ok(())
}
pub fn resync_to_head(&self, paths: &[String]) -> Result<()> {
match self.head_oid() {
Some(head) => self.materialize(head, paths),
None => Ok(()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plumbing::TreeChange;
use git2::Repository;
use tempfile::TempDir;
const MAIN: &str = "refs/heads/main";
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(),
}
}
fn commit(vr: &VaultRepo, changes: &[TreeChange]) -> Oid {
let tip = vr.head_oid();
let base = tip.map(|c| vr.git().find_commit(c).unwrap().tree_id());
let tree = vr.build_tree(base, changes).unwrap();
let parents: Vec<Oid> = tip.into_iter().collect();
let c = vr.commit_tree(tree, &parents, "c").unwrap();
vr.cas_ref(MAIN, tip, c).unwrap();
c
}
fn workfile(vr: &VaultRepo, rel: &str) -> std::path::PathBuf {
vr.git().workdir().unwrap().join(rel)
}
#[test]
fn materialize_refuses_to_replace_directory_with_file() {
let (_tmp, vr) = open_unborn();
let c = commit(&vr, &[upsert("x", "i am a file")]);
let target = workfile(&vr, "x");
std::fs::create_dir_all(target.join("child")).unwrap();
std::fs::write(target.join("child/leaf"), "stale").unwrap();
assert!(vr.materialize(c, &["x".into()]).is_err());
assert!(target.join("child/leaf").exists());
}
#[test]
fn materialize_writes_upserts() {
let (_tmp, vr) = open_unborn();
let c = commit(&vr, &[upsert("a.md", "alpha"), upsert("dir/b.md", "beta")]);
vr.materialize(c, &["a.md".into(), "dir/b.md".into()])
.unwrap();
assert_eq!(
std::fs::read_to_string(workfile(&vr, "a.md")).unwrap(),
"alpha"
);
assert_eq!(
std::fs::read_to_string(workfile(&vr, "dir/b.md")).unwrap(),
"beta",
"nested parent dirs created"
);
}
#[test]
fn materialize_removes_deletes() {
let (_tmp, vr) = open_unborn();
let c1 = commit(&vr, &[upsert("a.md", "alpha")]);
vr.materialize(c1, &["a.md".into()]).unwrap();
assert!(workfile(&vr, "a.md").exists());
let c2 = commit(
&vr,
&[TreeChange::Remove {
path: "a.md".to_string(),
}],
);
vr.materialize(c2, &["a.md".into()]).unwrap();
assert!(
!workfile(&vr, "a.md").exists(),
"delete removed from working tree"
);
}
#[test]
fn materialize_syncs_index_clean_status() {
let (_tmp, vr) = open_unborn();
let c = commit(&vr, &[upsert("a.md", "alpha")]);
vr.materialize(c, &["a.md".into()]).unwrap();
let status = vr.git().status_file(Path::new("a.md")).unwrap();
assert_eq!(
status,
git2::Status::CURRENT,
"no pending changes after materialize"
);
}
#[test]
fn materialize_is_idempotent() {
let (_tmp, vr) = open_unborn();
let c = commit(&vr, &[upsert("a.md", "alpha")]);
vr.materialize(c, &["a.md".into()]).unwrap();
vr.materialize(c, &["a.md".into()]).unwrap(); assert_eq!(
std::fs::read_to_string(workfile(&vr, "a.md")).unwrap(),
"alpha"
);
}
#[test]
fn resync_restores_clobbered_working_tree() {
let (_tmp, vr) = open_unborn();
let _c = commit(&vr, &[upsert("a.md", "alpha")]);
vr.resync_to_head(&["a.md".into()]).unwrap();
assert_eq!(
std::fs::read_to_string(workfile(&vr, "a.md")).unwrap(),
"alpha"
);
std::fs::write(workfile(&vr, "a.md"), "CORRUPT").unwrap();
vr.resync_to_head(&["a.md".into()]).unwrap();
assert_eq!(
std::fs::read_to_string(workfile(&vr, "a.md")).unwrap(),
"alpha",
"resync restores HEAD content"
);
}
#[test]
fn resync_unborn_is_noop() {
let (_tmp, vr) = open_unborn();
vr.resync_to_head(&["whatever.md".into()])
.expect("no-op on unborn branch");
}
}