Skip to main content

turbovault_git/
materialize.rs

1//! Working-tree materialization (GWS.5): make the working tree match a commit.
2//!
3//! After the ref advances (GWS.3), the working tree is stale — the substrate's
4//! truth is the commit graph, and the working tree is a materialized *view* of
5//! HEAD. This step writes a commit's bytes for the touched paths into the
6//! working tree (atomic **temp + rename** per file; removals deleted) and syncs
7//! the index to the commit's tree so `git status` stays clean.
8//!
9//! The operation is **idempotent**: re-running it re-writes HEAD's content, so
10//! it doubles as the crash/partial-failure **resync** ("advance ref, then write
11//! file" is two steps; if the second is interrupted, re-materialize). The
12//! per-worktree commit mutex (GWS.6) serializes concurrent materializations,
13//! which contend on the shared index.
14
15use 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    /// Refuse a commit when any touched working-tree path does not currently
24    /// match the commit it is based on. This protects untracked files and
25    /// unsaved/manual edits from being overwritten during materialization.
26    /// Call while holding the commit lock.
27    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    /// Materialize `paths` from `commit`'s tree into the working tree, and sync
86    /// the index to that tree. For each path: present in the tree → write its
87    /// blob atomically (temp + rename, parent dirs created); absent → remove the
88    /// working-tree file if present. Idempotent (safe to re-run as a resync).
89    #[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                    // Atomic per-file write: temp (unique suffix) + rename.
111                    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                    // Removed in this commit: delete the working-tree file if present.
123                    if target.exists() {
124                        std::fs::remove_file(&target)?;
125                    }
126                }
127                Err(e) => return Err(Error::Git(e)),
128            }
129        }
130
131        // Sync the real index to the commit's tree so working tree == index ==
132        // HEAD and `git status` is clean for the touched paths.
133        let mut index = repo.index()?;
134        index.read_tree(&tree)?;
135        index.write()?;
136        Ok(())
137    }
138
139    /// Re-materialize `paths` from the current HEAD commit (the resync entry
140    /// point after a crash/partial materialization). No-op if the branch is
141    /// unborn (nothing committed yet).
142    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    /// Commit `changes` on top of current HEAD and advance main; returns the new tip.
176    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    /// Never recursively delete an unexpected working-tree directory.
191    #[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        // Simulate a prior working-tree state where `x` was a non-empty dir.
196        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        // Second commit removes a.md.
230        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        // Index + working tree both match HEAD -> the path is CURRENT (clean).
249        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(); // again
263        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        // Simulate an interrupted materialization / external clobber, then resync.
280        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}