1use crate::error::{Error, Result};
16use crate::repo::VaultRepo;
17use git2::Oid;
18use std::path::Path;
19use tracing::instrument;
20use uuid::Uuid;
21
22impl VaultRepo {
23 pub(crate) fn ensure_worktree_matches_commit(
28 &self,
29 base: Option<Oid>,
30 paths: &[String],
31 ) -> Result<()> {
32 let repo = self.git();
33 let staged_mask = git2::Status::INDEX_NEW
34 | git2::Status::INDEX_MODIFIED
35 | git2::Status::INDEX_DELETED
36 | git2::Status::INDEX_RENAMED
37 | git2::Status::INDEX_TYPECHANGE;
38 if repo
39 .statuses(None)?
40 .iter()
41 .any(|entry| entry.status().intersects(staged_mask))
42 {
43 return Err(Error::Other(
44 "Git index contains staged changes; commit or unstage them before a TurboVault write"
45 .to_string(),
46 ));
47 }
48 let workdir = repo
49 .workdir()
50 .ok_or_else(|| Error::Other("bare repository has no working tree".to_string()))?;
51 let tree = match base {
52 Some(oid) => Some(repo.find_commit(oid)?.tree()?),
53 None => None,
54 };
55
56 for rel in paths {
57 let expected = match tree.as_ref() {
58 Some(tree) => match tree.get_path(Path::new(rel)) {
59 Ok(entry) => Some(repo.find_blob(entry.id())?.content().to_vec()),
60 Err(error) if error.code() == git2::ErrorCode::NotFound => None,
61 Err(error) => return Err(Error::Git(error)),
62 },
63 None => None,
64 };
65 let target = workdir.join(rel);
66 let actual = match std::fs::symlink_metadata(&target) {
67 Ok(metadata) if metadata.file_type().is_file() => Some(std::fs::read(&target)?),
68 Ok(_) => {
69 return Err(Error::Other(format!(
70 "working-tree path '{rel}' is not a regular file; refusing to overwrite it"
71 )));
72 }
73 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
74 Err(error) => return Err(Error::Io(error)),
75 };
76 if actual != expected {
77 return Err(Error::Other(format!(
78 "working-tree path '{rel}' differs from HEAD; commit, restore, or move the local change before retrying"
79 )));
80 }
81 }
82 Ok(())
83 }
84
85 #[instrument(
90 skip(self, paths),
91 fields(commit = %commit, n_paths = paths.len()),
92 name = "git_materialize"
93 )]
94 pub fn materialize(&self, commit: Oid, paths: &[String]) -> Result<()> {
95 let repo = self.git();
96 let workdir = repo
97 .workdir()
98 .ok_or_else(|| Error::Other("bare repository has no working tree".to_string()))?
99 .to_path_buf();
100 let tree = repo.find_commit(commit)?.tree()?;
101
102 for rel in paths {
103 let target = workdir.join(rel);
104 match tree.get_path(Path::new(rel)) {
105 Ok(entry) => {
106 let blob = repo.find_blob(entry.id())?;
107 if let Some(parent) = target.parent() {
108 std::fs::create_dir_all(parent)?;
109 }
110 let tmp = target.with_extension(format!("tmp.{}", Uuid::new_v4()));
112 if let Err(e) = std::fs::write(&tmp, blob.content()) {
113 let _ = std::fs::remove_file(&tmp);
114 return Err(Error::Io(e));
115 }
116 if let Err(e) = std::fs::rename(&tmp, &target) {
117 let _ = std::fs::remove_file(&tmp);
118 return Err(Error::Io(e));
119 }
120 }
121 Err(e) if e.code() == git2::ErrorCode::NotFound => {
122 if target.exists() {
124 std::fs::remove_file(&target)?;
125 }
126 }
127 Err(e) => return Err(Error::Git(e)),
128 }
129 }
130
131 let mut index = repo.index()?;
134 index.read_tree(&tree)?;
135 index.write()?;
136 Ok(())
137 }
138
139 pub fn resync_to_head(&self, paths: &[String]) -> Result<()> {
143 match self.head_oid() {
144 Some(head) => self.materialize(head, paths),
145 None => Ok(()),
146 }
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use crate::plumbing::TreeChange;
154 use git2::Repository;
155 use tempfile::TempDir;
156
157 const MAIN: &str = "refs/heads/main";
158
159 fn open_unborn() -> (TempDir, VaultRepo) {
160 let tmp = TempDir::new().unwrap();
161 let mut opts = git2::RepositoryInitOptions::new();
162 opts.initial_head("main");
163 Repository::init_opts(tmp.path(), &opts).unwrap();
164 let vr = VaultRepo::open(tmp.path()).unwrap();
165 (tmp, vr)
166 }
167
168 fn upsert(path: &str, content: &str) -> TreeChange {
169 TreeChange::Upsert {
170 path: path.to_string(),
171 content: content.as_bytes().to_vec(),
172 }
173 }
174
175 fn commit(vr: &VaultRepo, changes: &[TreeChange]) -> Oid {
177 let tip = vr.head_oid();
178 let base = tip.map(|c| vr.git().find_commit(c).unwrap().tree_id());
179 let tree = vr.build_tree(base, changes).unwrap();
180 let parents: Vec<Oid> = tip.into_iter().collect();
181 let c = vr.commit_tree(tree, &parents, "c").unwrap();
182 vr.cas_ref(MAIN, tip, c).unwrap();
183 c
184 }
185
186 fn workfile(vr: &VaultRepo, rel: &str) -> std::path::PathBuf {
187 vr.git().workdir().unwrap().join(rel)
188 }
189
190 #[test]
192 fn materialize_refuses_to_replace_directory_with_file() {
193 let (_tmp, vr) = open_unborn();
194 let c = commit(&vr, &[upsert("x", "i am a file")]);
195 let target = workfile(&vr, "x");
197 std::fs::create_dir_all(target.join("child")).unwrap();
198 std::fs::write(target.join("child/leaf"), "stale").unwrap();
199
200 assert!(vr.materialize(c, &["x".into()]).is_err());
201 assert!(target.join("child/leaf").exists());
202 }
203
204 #[test]
205 fn materialize_writes_upserts() {
206 let (_tmp, vr) = open_unborn();
207 let c = commit(&vr, &[upsert("a.md", "alpha"), upsert("dir/b.md", "beta")]);
208 vr.materialize(c, &["a.md".into(), "dir/b.md".into()])
209 .unwrap();
210
211 assert_eq!(
212 std::fs::read_to_string(workfile(&vr, "a.md")).unwrap(),
213 "alpha"
214 );
215 assert_eq!(
216 std::fs::read_to_string(workfile(&vr, "dir/b.md")).unwrap(),
217 "beta",
218 "nested parent dirs created"
219 );
220 }
221
222 #[test]
223 fn materialize_removes_deletes() {
224 let (_tmp, vr) = open_unborn();
225 let c1 = commit(&vr, &[upsert("a.md", "alpha")]);
226 vr.materialize(c1, &["a.md".into()]).unwrap();
227 assert!(workfile(&vr, "a.md").exists());
228
229 let c2 = commit(
231 &vr,
232 &[TreeChange::Remove {
233 path: "a.md".to_string(),
234 }],
235 );
236 vr.materialize(c2, &["a.md".into()]).unwrap();
237 assert!(
238 !workfile(&vr, "a.md").exists(),
239 "delete removed from working tree"
240 );
241 }
242
243 #[test]
244 fn materialize_syncs_index_clean_status() {
245 let (_tmp, vr) = open_unborn();
246 let c = commit(&vr, &[upsert("a.md", "alpha")]);
247 vr.materialize(c, &["a.md".into()]).unwrap();
248 let status = vr.git().status_file(Path::new("a.md")).unwrap();
250 assert_eq!(
251 status,
252 git2::Status::CURRENT,
253 "no pending changes after materialize"
254 );
255 }
256
257 #[test]
258 fn materialize_is_idempotent() {
259 let (_tmp, vr) = open_unborn();
260 let c = commit(&vr, &[upsert("a.md", "alpha")]);
261 vr.materialize(c, &["a.md".into()]).unwrap();
262 vr.materialize(c, &["a.md".into()]).unwrap(); assert_eq!(
264 std::fs::read_to_string(workfile(&vr, "a.md")).unwrap(),
265 "alpha"
266 );
267 }
268
269 #[test]
270 fn resync_restores_clobbered_working_tree() {
271 let (_tmp, vr) = open_unborn();
272 let _c = commit(&vr, &[upsert("a.md", "alpha")]);
273 vr.resync_to_head(&["a.md".into()]).unwrap();
274 assert_eq!(
275 std::fs::read_to_string(workfile(&vr, "a.md")).unwrap(),
276 "alpha"
277 );
278
279 std::fs::write(workfile(&vr, "a.md"), "CORRUPT").unwrap();
281 vr.resync_to_head(&["a.md".into()]).unwrap();
282 assert_eq!(
283 std::fs::read_to_string(workfile(&vr, "a.md")).unwrap(),
284 "alpha",
285 "resync restores HEAD content"
286 );
287 }
288
289 #[test]
290 fn resync_unborn_is_noop() {
291 let (_tmp, vr) = open_unborn();
292 vr.resync_to_head(&["whatever.md".into()])
293 .expect("no-op on unborn branch");
294 }
295}