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::new(
29 io::ErrorKind::Other,
30 format!("Failed to create temp index: {}", 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::new(
63 io::ErrorKind::Other,
64 "Git add to temp index failed",
65 ));
66 }
67
68 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 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 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 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
134pub 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 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 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 fs::write(repo_path.join("file2.txt"), "untracked").unwrap();
227 fs::write(repo_path.join("file1.txt"), "modified").unwrap();
228
229 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 create_ghost_snapshot(repo_path).unwrap();
241
242 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 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}