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
18pub fn create_ghost_snapshot(repo_path: &Path) -> io::Result<()> {
21 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 drop(temp_file);
36
37 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 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 let _ = std::fs::remove_file(&index_path);
62 return Err(io::Error::other("Git add to temp index failed"));
63 }
64
65 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 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 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 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
122pub 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 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 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 fs::write(repo_path.join("file2.txt"), "untracked").unwrap();
212 fs::write(repo_path.join("file1.txt"), "modified").unwrap();
213
214 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 create_ghost_snapshot(repo_path).unwrap();
226
227 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 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}