Skip to main content

hematite/agent/
git.rs

1use std::io;
2use std::path::Path;
3use std::process::{Command, Stdio};
4
5pub fn is_git_repo(path: &Path) -> bool {
6    Command::new("git")
7        .arg("-C")
8        .arg(path)
9        .arg("rev-parse")
10        .arg("--is-inside-work-tree")
11        .stdout(Stdio::null())
12        .stderr(Stdio::null())
13        .status()
14        .map(|s| s.success())
15        .unwrap_or(false)
16}
17
18/// Takes an "Isolated Ghost Snapshot" by saving the current state to a hidden ref
19/// using a temporary Git index. This prevents pollution of the user's staged changes.
20pub fn create_ghost_snapshot(repo_path: &Path) -> io::Result<()> {
21    // 1. Create a temporary index file to avoid touching the user's actual index.
22    let (temp_file, index_path) = match tempfile::NamedTempFile::new() {
23        Ok(t) => {
24            let (file, path) = t.into_parts();
25            (file, path)
26        }
27        Err(e) => {
28            return Err(io::Error::new(
29                io::ErrorKind::Other,
30                format!("Failed to create temp index: {}", e),
31            ))
32        }
33    };
34    // Close the file handle immediately so Git can own it.
35    drop(temp_file);
36
37    // 2. Pre-populate the temporary index with HEAD so unchanged tracked files
38    // are included in the snapshot tree.
39    let _ = Command::new("git")
40        .arg("-C")
41        .arg(repo_path)
42        .env("GIT_INDEX_FILE", &index_path)
43        .arg("read-tree")
44        .arg("HEAD")
45        .stdout(Stdio::null())
46        .stderr(Stdio::null())
47        .status();
48
49    // 3. Stage all current working directory changes (tracked + untracked) into the temp index.
50    let add_status = Command::new("git")
51        .arg("-C")
52        .arg(repo_path)
53        .env("GIT_INDEX_FILE", &index_path)
54        .arg("add")
55        .arg("--all")
56        .stderr(Stdio::piped())
57        .status()?;
58
59    if !add_status.success() {
60        // Cleanup on failure
61        let _ = std::fs::remove_file(&index_path);
62        return Err(io::Error::new(
63            io::ErrorKind::Other,
64            "Git add to temp index failed",
65        ));
66    }
67
68    // 4. Create a tree object from the temporary index state.
69    let tree_output = Command::new("git")
70        .arg("-C")
71        .arg(repo_path)
72        .env("GIT_INDEX_FILE", &index_path)
73        .arg("write-tree")
74        .stderr(Stdio::null())
75        .output()?;
76
77    // Cleanup temp index now that we have the tree SHA
78    let _ = std::fs::remove_file(&index_path);
79
80    if !tree_output.status.success() {
81        return Err(io::Error::new(
82            io::ErrorKind::Other,
83            "Git write-tree failed",
84        ));
85    }
86    let tree_sha = String::from_utf8_lossy(&tree_output.stdout)
87        .trim()
88        .to_string();
89
90    // 5. Create a commit object (parent is HEAD).
91    let commit_output = Command::new("git")
92        .arg("-C")
93        .arg(repo_path)
94        .arg("commit-tree")
95        .arg(&tree_sha)
96        .arg("-p")
97        .arg("HEAD")
98        .arg("-m")
99        .arg("Hematite Ghost Snapshot [Isolated]")
100        .stderr(Stdio::null())
101        .output()?;
102
103    if !commit_output.status.success() {
104        return Err(io::Error::new(
105            io::ErrorKind::Other,
106            "Git commit-tree failed",
107        ));
108    }
109    let commit_sha = String::from_utf8_lossy(&commit_output.stdout)
110        .trim()
111        .to_string();
112
113    // 6. Update the hidden ghost ref.
114    let update_status = Command::new("git")
115        .arg("-C")
116        .arg(repo_path)
117        .arg("update-ref")
118        .arg("refs/hematite/ghost")
119        .arg(&commit_sha)
120        .stdout(Stdio::null())
121        .stderr(Stdio::null())
122        .status()?;
123
124    if !update_status.success() {
125        return Err(io::Error::new(
126            io::ErrorKind::Other,
127            "Git update-ref failed",
128        ));
129    }
130
131    Ok(())
132}
133
134/// Reverts a file to its state in the last Ghost Snapshot.
135pub fn revert_from_ghost(repo_path: &Path, file_path: &str) -> io::Result<String> {
136    let status = Command::new("git")
137        .arg("-C")
138        .arg(repo_path)
139        .arg("checkout")
140        .arg("refs/hematite/ghost")
141        .arg("--")
142        .arg(file_path)
143        .stdout(Stdio::null())
144        .stderr(Stdio::null())
145        .status()?;
146
147    if !status.success() {
148        return Err(io::Error::new(
149            io::ErrorKind::Other,
150            "Git checkout from ghost ref failed",
151        ));
152    }
153
154    Ok(format!("Restored {} from Git Ghost ref", file_path))
155}
156
157pub fn get_active_branch(repo_path: &Path) -> io::Result<String> {
158    let output = Command::new("git")
159        .arg("-C")
160        .arg(repo_path)
161        .arg("rev-parse")
162        .arg("--abbrev-ref")
163        .arg("HEAD")
164        .stderr(Stdio::null())
165        .output()?;
166    if !output.status.success() {
167        return Err(io::Error::new(io::ErrorKind::Other, "Git rev-parse failed"));
168    }
169    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use std::fs;
176    use tempfile::tempdir;
177
178    #[test]
179    fn test_ghost_snapshot_isolation() {
180        let dir = tempdir().unwrap();
181        let repo_path = dir.path();
182
183        // Initialize a fake repo
184        Command::new("git")
185            .arg("-C")
186            .arg(repo_path)
187            .arg("init")
188            .status()
189            .unwrap();
190        Command::new("git")
191            .arg("-C")
192            .arg(repo_path)
193            .arg("config")
194            .arg("user.email")
195            .arg("test@example.com")
196            .status()
197            .unwrap();
198        Command::new("git")
199            .arg("-C")
200            .arg(repo_path)
201            .arg("config")
202            .arg("user.name")
203            .arg("Test")
204            .status()
205            .unwrap();
206
207        // Create initial commit
208        fs::write(repo_path.join("file1.txt"), "hello").unwrap();
209        Command::new("git")
210            .arg("-C")
211            .arg(repo_path)
212            .arg("add")
213            .arg(".")
214            .status()
215            .unwrap();
216        Command::new("git")
217            .arg("-C")
218            .arg(repo_path)
219            .arg("commit")
220            .arg("-m")
221            .arg("first")
222            .status()
223            .unwrap();
224
225        // Make an unstaged change
226        fs::write(repo_path.join("file2.txt"), "untracked").unwrap();
227        fs::write(repo_path.join("file1.txt"), "modified").unwrap();
228
229        // Pre-condition: git status should show changes
230        let status_before = Command::new("git")
231            .arg("-C")
232            .arg(repo_path)
233            .arg("status")
234            .arg("--porcelain")
235            .output()
236            .unwrap();
237        let status_before_str = String::from_utf8_lossy(&status_before.stdout).to_string();
238
239        // Take ghost snapshot
240        create_ghost_snapshot(repo_path).unwrap();
241
242        // Post-condition: git status should be IDENTICAL (nothing extra staged in real index)
243        let status_after = Command::new("git")
244            .arg("-C")
245            .arg(repo_path)
246            .arg("status")
247            .arg("--porcelain")
248            .output()
249            .unwrap();
250        let status_after_str = String::from_utf8_lossy(&status_after.stdout).to_string();
251
252        assert_eq!(
253            status_before_str, status_after_str,
254            "Ghost snapshot should not pollute the user's Git index"
255        );
256
257        // Verify the ghost ref exists
258        let ref_check = Command::new("git")
259            .arg("-C")
260            .arg(repo_path)
261            .arg("rev-parse")
262            .arg("refs/hematite/ghost")
263            .status()
264            .unwrap();
265        assert!(ref_check.success());
266    }
267}