anodizer_core/git/
status.rs1use anyhow::{Result, bail};
2use std::path::Path;
3use std::process::Command;
4
5use super::git_output_in;
6
7pub fn is_git_dirty() -> bool {
9 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
10 is_git_dirty_in(&cwd)
11}
12
13pub fn is_git_dirty_in(cwd: &Path) -> bool {
18 git_output_in(cwd, &["status", "--porcelain"])
19 .map(|s| !s.is_empty())
20 .unwrap_or(false)
21}
22
23pub fn local_git_user_name() -> Option<String> {
25 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
26 local_git_user_name_in(&cwd)
27}
28
29pub fn local_git_user_name_in(cwd: &Path) -> Option<String> {
33 git_output_in(cwd, &["config", "user.name"])
34 .ok()
35 .filter(|s| !s.is_empty())
36}
37
38pub fn local_git_user_email() -> Option<String> {
40 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
41 local_git_user_email_in(&cwd)
42}
43
44pub fn local_git_user_email_in(cwd: &Path) -> Option<String> {
48 git_output_in(cwd, &["config", "user.email"])
49 .ok()
50 .filter(|s| !s.is_empty())
51}
52
53pub fn check_git_available() -> Result<()> {
60 let output = Command::new("git")
61 .arg("--version")
62 .current_dir(crate::path_util::probe_dir())
63 .output();
64 match output {
65 Ok(o) if o.status.success() => Ok(()),
66 _ => bail!("git is not installed or not in PATH. Install git and try again."),
67 }
68}
69
70pub fn is_git_repo() -> bool {
72 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
73 is_git_repo_in(&cwd)
74}
75
76pub fn is_git_repo_in(cwd: &Path) -> bool {
84 match git_output_in(cwd, &["rev-parse", "--git-dir"]) {
85 Ok(_) => true,
86 Err(e) => {
87 tracing::warn!("git repository check failed: {e}");
88 false
89 }
90 }
91}
92
93pub fn git_status_porcelain() -> String {
95 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
96 git_status_porcelain_in(&cwd)
97}
98
99pub fn git_status_porcelain_in(cwd: &Path) -> String {
103 git_output_in(cwd, &["status", "--porcelain"]).unwrap_or_default()
104}
105
106pub fn git_status_porcelain_result_in(cwd: &Path) -> Result<String> {
116 git_output_in(cwd, &["status", "--porcelain"])
117}
118
119pub fn list_tracked_files_in(cwd: &Path) -> Result<Vec<String>> {
127 let out = git_output_in(cwd, &["ls-files", "-z"])?;
128 Ok(out
129 .split('\0')
130 .filter(|s| !s.is_empty())
131 .map(|s| s.to_string())
132 .collect())
133}
134
135pub fn is_shallow_clone() -> bool {
140 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
141 is_shallow_clone_in(&cwd)
142}
143
144pub fn is_shallow_clone_in(cwd: &Path) -> bool {
151 let git_dir =
154 git_output_in(cwd, &["rev-parse", "--git-dir"]).unwrap_or_else(|_| ".git".to_string());
155 let git_dir_path = Path::new(&git_dir);
156 let shallow = if git_dir_path.is_absolute() {
157 git_dir_path.join("shallow")
158 } else {
159 cwd.join(git_dir_path).join("shallow")
160 };
161 shallow.exists()
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use std::process::Command;
168
169 fn init_repo(dir: &Path) {
170 let run = |args: &[&str]| {
171 let out = anodizer_core::test_helpers::output_with_spawn_retry(
172 || {
173 let mut cmd = Command::new("git");
174 cmd.args(args)
175 .current_dir(dir)
176 .env("GIT_AUTHOR_NAME", "test")
177 .env("GIT_AUTHOR_EMAIL", "test@test.com")
178 .env("GIT_COMMITTER_NAME", "test")
179 .env("GIT_COMMITTER_EMAIL", "test@test.com");
180 cmd
181 },
182 "git",
183 );
184 assert!(
185 out.status.success(),
186 "git {:?} failed: {}",
187 args,
188 String::from_utf8_lossy(&out.stderr)
189 );
190 };
191 run(&["init"]);
192 run(&["config", "user.email", "test@test.com"]);
193 run(&["config", "user.name", "Status Tester"]);
194 std::fs::write(dir.join("README"), "init").unwrap();
195 run(&["add", "."]);
196 run(&["commit", "-m", "initial"]);
197 }
198
199 #[test]
200 #[serial_test::serial(tracing)]
201 fn is_git_repo_in_warns_when_git_refuses_the_repository() {
202 let tmp = tempfile::tempdir().unwrap();
203 let captured = crate::test_helpers::tracing_capture::capture_tracing_warnings(|| {
204 assert!(!is_git_repo_in(tmp.path()));
205 });
206 assert!(
207 captured.contains("git repository check failed"),
208 "a refused repository check must be reported: {captured}"
209 );
210 assert!(
211 captured.contains("not a git repository"),
212 "git's own text must reach the log: {captured}"
213 );
214 }
215
216 #[test]
217 #[serial_test::serial(tracing)]
218 fn is_git_repo_in_is_silent_for_a_real_repository() {
219 let tmp = tempfile::tempdir().unwrap();
220 init_repo(tmp.path());
221 let captured = crate::test_helpers::tracing_capture::capture_tracing_warnings(|| {
222 assert!(is_git_repo_in(tmp.path()));
223 });
224 assert!(
225 captured.is_empty(),
226 "a readable repository warns about nothing: {captured}"
227 );
228 }
229
230 #[test]
231 fn is_git_repo_in_returns_false_for_non_git_dir() {
232 let tmp = tempfile::tempdir().unwrap();
233 assert!(!is_git_repo_in(tmp.path()));
234 }
235
236 #[test]
237 fn is_git_repo_in_returns_true_for_initialized_repo() {
238 let tmp = tempfile::tempdir().unwrap();
239 init_repo(tmp.path());
240 assert!(is_git_repo_in(tmp.path()));
241 }
242
243 #[test]
244 fn is_git_dirty_in_is_false_for_clean_repo() {
245 let tmp = tempfile::tempdir().unwrap();
246 init_repo(tmp.path());
247 assert!(!is_git_dirty_in(tmp.path()));
248 }
249
250 #[test]
251 fn is_git_dirty_in_is_true_after_untracked_change() {
252 let tmp = tempfile::tempdir().unwrap();
253 init_repo(tmp.path());
254 std::fs::write(tmp.path().join("new.txt"), "hello").unwrap();
255 assert!(is_git_dirty_in(tmp.path()));
256 }
257
258 #[test]
259 fn git_status_porcelain_in_reflects_dirty_state() {
260 let tmp = tempfile::tempdir().unwrap();
261 init_repo(tmp.path());
262 std::fs::write(tmp.path().join("staged.txt"), "x").unwrap();
263 let status = git_status_porcelain_in(tmp.path());
264 assert!(status.contains("staged.txt"), "got: {status:?}");
265 }
266
267 #[test]
268 fn local_git_user_name_in_reads_repo_config() {
269 let tmp = tempfile::tempdir().unwrap();
270 init_repo(tmp.path());
271 assert_eq!(
272 local_git_user_name_in(tmp.path()).as_deref(),
273 Some("Status Tester")
274 );
275 }
276
277 #[test]
278 fn local_git_user_email_in_reads_repo_config() {
279 let tmp = tempfile::tempdir().unwrap();
280 init_repo(tmp.path());
281 assert_eq!(
282 local_git_user_email_in(tmp.path()).as_deref(),
283 Some("test@test.com")
284 );
285 }
286
287 #[test]
288 fn list_tracked_files_in_returns_committed_paths() {
289 let tmp = tempfile::tempdir().unwrap();
290 init_repo(tmp.path());
291 std::fs::write(tmp.path().join("extra.txt"), "x").unwrap();
292 let run = |args: &[&str]| {
293 anodizer_core::test_helpers::output_with_spawn_retry(
294 || {
295 let mut cmd = Command::new("git");
296 cmd.args(args).current_dir(tmp.path());
297 cmd
298 },
299 "git",
300 );
301 };
302 run(&["add", "extra.txt"]);
303 run(&["commit", "-m", "add extra"]);
304 let files = list_tracked_files_in(tmp.path()).unwrap();
305 assert!(files.contains(&"README".to_string()), "got: {files:?}");
306 assert!(files.contains(&"extra.txt".to_string()), "got: {files:?}");
307 }
308
309 #[test]
310 fn is_shallow_clone_in_is_false_for_full_clone() {
311 let tmp = tempfile::tempdir().unwrap();
312 init_repo(tmp.path());
313 assert!(!is_shallow_clone_in(tmp.path()));
314 }
315
316 #[test]
317 fn porcelain_result_is_ok_empty_for_clean_repo() {
318 let tmp = tempfile::tempdir().unwrap();
319 init_repo(tmp.path());
320 let out = git_status_porcelain_result_in(tmp.path())
321 .expect("a clean git repo must yield Ok(empty)");
322 assert!(
323 out.trim().is_empty(),
324 "clean tree has no porcelain: {out:?}"
325 );
326 }
327
328 #[test]
329 fn porcelain_result_is_ok_with_paths_for_dirty_repo() {
330 let tmp = tempfile::tempdir().unwrap();
331 init_repo(tmp.path());
332 std::fs::write(tmp.path().join("dirty.txt"), "x").unwrap();
333 let out = git_status_porcelain_result_in(tmp.path())
334 .expect("a reachable repo yields Ok even when dirty");
335 assert!(out.contains("dirty.txt"), "dirty path listed: {out:?}");
336 }
337
338 #[test]
339 fn porcelain_result_is_err_for_non_git_dir() {
340 let tmp = tempfile::tempdir().unwrap();
341 assert!(
342 git_status_porcelain_result_in(tmp.path()).is_err(),
343 "a non-git dir cannot prove cleanliness — must surface Err, not fail open"
344 );
345 }
346}