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 {
80 git_output_in(cwd, &["rev-parse", "--git-dir"]).is_ok()
81}
82
83pub fn git_status_porcelain() -> String {
85 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
86 git_status_porcelain_in(&cwd)
87}
88
89pub fn git_status_porcelain_in(cwd: &Path) -> String {
93 git_output_in(cwd, &["status", "--porcelain"]).unwrap_or_default()
94}
95
96pub fn git_status_porcelain_result_in(cwd: &Path) -> Result<String> {
106 git_output_in(cwd, &["status", "--porcelain"])
107}
108
109pub fn list_tracked_files_in(cwd: &Path) -> Result<Vec<String>> {
117 let out = git_output_in(cwd, &["ls-files", "-z"])?;
118 Ok(out
119 .split('\0')
120 .filter(|s| !s.is_empty())
121 .map(|s| s.to_string())
122 .collect())
123}
124
125pub fn is_shallow_clone() -> bool {
130 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
131 is_shallow_clone_in(&cwd)
132}
133
134pub fn is_shallow_clone_in(cwd: &Path) -> bool {
141 let git_dir =
144 git_output_in(cwd, &["rev-parse", "--git-dir"]).unwrap_or_else(|_| ".git".to_string());
145 let git_dir_path = Path::new(&git_dir);
146 let shallow = if git_dir_path.is_absolute() {
147 git_dir_path.join("shallow")
148 } else {
149 cwd.join(git_dir_path).join("shallow")
150 };
151 shallow.exists()
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157 use std::process::Command;
158
159 fn init_repo(dir: &Path) {
160 let run = |args: &[&str]| {
161 let out = anodizer_core::test_helpers::output_with_spawn_retry(
162 || {
163 let mut cmd = Command::new("git");
164 cmd.args(args)
165 .current_dir(dir)
166 .env("GIT_AUTHOR_NAME", "test")
167 .env("GIT_AUTHOR_EMAIL", "test@test.com")
168 .env("GIT_COMMITTER_NAME", "test")
169 .env("GIT_COMMITTER_EMAIL", "test@test.com");
170 cmd
171 },
172 "git",
173 );
174 assert!(
175 out.status.success(),
176 "git {:?} failed: {}",
177 args,
178 String::from_utf8_lossy(&out.stderr)
179 );
180 };
181 run(&["init"]);
182 run(&["config", "user.email", "test@test.com"]);
183 run(&["config", "user.name", "Status Tester"]);
184 std::fs::write(dir.join("README"), "init").unwrap();
185 run(&["add", "."]);
186 run(&["commit", "-m", "initial"]);
187 }
188
189 #[test]
190 fn is_git_repo_in_returns_false_for_non_git_dir() {
191 let tmp = tempfile::tempdir().unwrap();
192 assert!(!is_git_repo_in(tmp.path()));
193 }
194
195 #[test]
196 fn is_git_repo_in_returns_true_for_initialized_repo() {
197 let tmp = tempfile::tempdir().unwrap();
198 init_repo(tmp.path());
199 assert!(is_git_repo_in(tmp.path()));
200 }
201
202 #[test]
203 fn is_git_dirty_in_is_false_for_clean_repo() {
204 let tmp = tempfile::tempdir().unwrap();
205 init_repo(tmp.path());
206 assert!(!is_git_dirty_in(tmp.path()));
207 }
208
209 #[test]
210 fn is_git_dirty_in_is_true_after_untracked_change() {
211 let tmp = tempfile::tempdir().unwrap();
212 init_repo(tmp.path());
213 std::fs::write(tmp.path().join("new.txt"), "hello").unwrap();
214 assert!(is_git_dirty_in(tmp.path()));
215 }
216
217 #[test]
218 fn git_status_porcelain_in_reflects_dirty_state() {
219 let tmp = tempfile::tempdir().unwrap();
220 init_repo(tmp.path());
221 std::fs::write(tmp.path().join("staged.txt"), "x").unwrap();
222 let status = git_status_porcelain_in(tmp.path());
223 assert!(status.contains("staged.txt"), "got: {status:?}");
224 }
225
226 #[test]
227 fn local_git_user_name_in_reads_repo_config() {
228 let tmp = tempfile::tempdir().unwrap();
229 init_repo(tmp.path());
230 assert_eq!(
231 local_git_user_name_in(tmp.path()).as_deref(),
232 Some("Status Tester")
233 );
234 }
235
236 #[test]
237 fn local_git_user_email_in_reads_repo_config() {
238 let tmp = tempfile::tempdir().unwrap();
239 init_repo(tmp.path());
240 assert_eq!(
241 local_git_user_email_in(tmp.path()).as_deref(),
242 Some("test@test.com")
243 );
244 }
245
246 #[test]
247 fn list_tracked_files_in_returns_committed_paths() {
248 let tmp = tempfile::tempdir().unwrap();
249 init_repo(tmp.path());
250 std::fs::write(tmp.path().join("extra.txt"), "x").unwrap();
251 let run = |args: &[&str]| {
252 anodizer_core::test_helpers::output_with_spawn_retry(
253 || {
254 let mut cmd = Command::new("git");
255 cmd.args(args).current_dir(tmp.path());
256 cmd
257 },
258 "git",
259 );
260 };
261 run(&["add", "extra.txt"]);
262 run(&["commit", "-m", "add extra"]);
263 let files = list_tracked_files_in(tmp.path()).unwrap();
264 assert!(files.contains(&"README".to_string()), "got: {files:?}");
265 assert!(files.contains(&"extra.txt".to_string()), "got: {files:?}");
266 }
267
268 #[test]
269 fn is_shallow_clone_in_is_false_for_full_clone() {
270 let tmp = tempfile::tempdir().unwrap();
271 init_repo(tmp.path());
272 assert!(!is_shallow_clone_in(tmp.path()));
273 }
274
275 #[test]
276 fn porcelain_result_is_ok_empty_for_clean_repo() {
277 let tmp = tempfile::tempdir().unwrap();
278 init_repo(tmp.path());
279 let out = git_status_porcelain_result_in(tmp.path())
280 .expect("a clean git repo must yield Ok(empty)");
281 assert!(
282 out.trim().is_empty(),
283 "clean tree has no porcelain: {out:?}"
284 );
285 }
286
287 #[test]
288 fn porcelain_result_is_ok_with_paths_for_dirty_repo() {
289 let tmp = tempfile::tempdir().unwrap();
290 init_repo(tmp.path());
291 std::fs::write(tmp.path().join("dirty.txt"), "x").unwrap();
292 let out = git_status_porcelain_result_in(tmp.path())
293 .expect("a reachable repo yields Ok even when dirty");
294 assert!(out.contains("dirty.txt"), "dirty path listed: {out:?}");
295 }
296
297 #[test]
298 fn porcelain_result_is_err_for_non_git_dir() {
299 let tmp = tempfile::tempdir().unwrap();
300 assert!(
301 git_status_porcelain_result_in(tmp.path()).is_err(),
302 "a non-git dir cannot prove cleanliness — must surface Err, not fail open"
303 );
304 }
305}