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::other(format!(
29                "Failed to create temp index: {}",
30                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::other("Git add to temp index failed"));
63    }
64
65    // 4. Create a tree object from the temporary index state.
66    let tree_output = Command::new("git")
67        .arg("-C")
68        .arg(repo_path)
69        .env("GIT_INDEX_FILE", &index_path)
70        .arg("write-tree")
71        .stderr(Stdio::null())
72        .output()?;
73
74    // Cleanup temp index now that we have the tree SHA
75    let _ = std::fs::remove_file(&index_path);
76
77    if !tree_output.status.success() {
78        return Err(io::Error::other("Git write-tree failed"));
79    }
80    let tree_sha = String::from_utf8_lossy(&tree_output.stdout)
81        .trim()
82        .to_string();
83
84    // 5. Create a commit object (parent is HEAD).
85    let commit_output = Command::new("git")
86        .arg("-C")
87        .arg(repo_path)
88        .arg("commit-tree")
89        .arg(&tree_sha)
90        .arg("-p")
91        .arg("HEAD")
92        .arg("-m")
93        .arg("Hematite Ghost Snapshot [Isolated]")
94        .stderr(Stdio::null())
95        .output()?;
96
97    if !commit_output.status.success() {
98        return Err(io::Error::other("Git commit-tree failed"));
99    }
100    let commit_sha = String::from_utf8_lossy(&commit_output.stdout)
101        .trim()
102        .to_string();
103
104    // 6. Update the hidden ghost ref.
105    let update_status = Command::new("git")
106        .arg("-C")
107        .arg(repo_path)
108        .arg("update-ref")
109        .arg("refs/hematite/ghost")
110        .arg(&commit_sha)
111        .stdout(Stdio::null())
112        .stderr(Stdio::null())
113        .status()?;
114
115    if !update_status.success() {
116        return Err(io::Error::other("Git update-ref failed"));
117    }
118
119    Ok(())
120}
121
122/// Reverts a file to its state in the last Ghost Snapshot.
123pub fn revert_from_ghost(repo_path: &Path, file_path: &str) -> io::Result<String> {
124    let status = Command::new("git")
125        .arg("-C")
126        .arg(repo_path)
127        .arg("checkout")
128        .arg("refs/hematite/ghost")
129        .arg("--")
130        .arg(file_path)
131        .stdout(Stdio::null())
132        .stderr(Stdio::null())
133        .status()?;
134
135    if !status.success() {
136        return Err(io::Error::other("Git checkout from ghost ref failed"));
137    }
138
139    Ok(format!("Restored {} from Git Ghost ref", file_path))
140}
141
142pub fn get_active_branch(repo_path: &Path) -> io::Result<String> {
143    let output = Command::new("git")
144        .arg("-C")
145        .arg(repo_path)
146        .arg("rev-parse")
147        .arg("--abbrev-ref")
148        .arg("HEAD")
149        .stderr(Stdio::null())
150        .output()?;
151    if !output.status.success() {
152        return Err(io::Error::other("Git rev-parse failed"));
153    }
154    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use std::fs;
161    use tempfile::tempdir;
162
163    #[test]
164    fn test_ghost_snapshot_isolation() {
165        let dir = tempdir().unwrap();
166        let repo_path = dir.path();
167
168        // Initialize a fake repo
169        Command::new("git")
170            .arg("-C")
171            .arg(repo_path)
172            .arg("init")
173            .status()
174            .unwrap();
175        Command::new("git")
176            .arg("-C")
177            .arg(repo_path)
178            .arg("config")
179            .arg("user.email")
180            .arg("test@example.com")
181            .status()
182            .unwrap();
183        Command::new("git")
184            .arg("-C")
185            .arg(repo_path)
186            .arg("config")
187            .arg("user.name")
188            .arg("Test")
189            .status()
190            .unwrap();
191
192        // Create initial commit
193        fs::write(repo_path.join("file1.txt"), "hello").unwrap();
194        Command::new("git")
195            .arg("-C")
196            .arg(repo_path)
197            .arg("add")
198            .arg(".")
199            .status()
200            .unwrap();
201        Command::new("git")
202            .arg("-C")
203            .arg(repo_path)
204            .arg("commit")
205            .arg("-m")
206            .arg("first")
207            .status()
208            .unwrap();
209
210        // Make an unstaged change
211        fs::write(repo_path.join("file2.txt"), "untracked").unwrap();
212        fs::write(repo_path.join("file1.txt"), "modified").unwrap();
213
214        // Pre-condition: git status should show changes
215        let status_before = Command::new("git")
216            .arg("-C")
217            .arg(repo_path)
218            .arg("status")
219            .arg("--porcelain")
220            .output()
221            .unwrap();
222        let status_before_str = String::from_utf8_lossy(&status_before.stdout).into_owned();
223
224        // Take ghost snapshot
225        create_ghost_snapshot(repo_path).unwrap();
226
227        // Post-condition: git status should be IDENTICAL (nothing extra staged in real index)
228        let status_after = Command::new("git")
229            .arg("-C")
230            .arg(repo_path)
231            .arg("status")
232            .arg("--porcelain")
233            .output()
234            .unwrap();
235        let status_after_str = String::from_utf8_lossy(&status_after.stdout).into_owned();
236
237        assert_eq!(
238            status_before_str, status_after_str,
239            "Ghost snapshot should not pollute the user's Git index"
240        );
241
242        // Verify the ghost ref exists
243        let ref_check = Command::new("git")
244            .arg("-C")
245            .arg(repo_path)
246            .arg("rev-parse")
247            .arg("refs/hematite/ghost")
248            .status()
249            .unwrap();
250        assert!(ref_check.success());
251    }
252}