Skip to main content

anodizer_core/git/
status.rs

1use anyhow::{Result, bail};
2use std::path::Path;
3use std::process::Command;
4
5use super::git_output_in;
6
7/// Check whether the working tree has uncommitted changes.
8pub 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
13/// Check whether the working tree in `cwd` has uncommitted changes.
14///
15/// Path-taking sibling of [`is_git_dirty`] so callers (notably tests against a
16/// fixture repo under `tempfile::tempdir()`) don't have to mutate the process cwd.
17pub 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
23/// Read `git config user.name`, or `None` if unset / git is unavailable.
24pub 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
29/// Read `git config user.name` from a repository at `cwd`.
30///
31/// Path-taking sibling of [`local_git_user_name`].
32pub 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
38/// Read `git config user.email`, or `None` if unset / git is unavailable.
39pub 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
44/// Read `git config user.email` from a repository at `cwd`.
45///
46/// Path-taking sibling of [`local_git_user_email`].
47pub 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
53/// Check whether `git` is available in PATH.
54///
55/// Binary-presence probe; the working directory has no effect on
56/// `git --version`, so this function deliberately has no `_in` sibling. The
57/// spawn is pinned to a guaranteed-existing dir so the probe survives an
58/// inherited cwd that was removed (see `path_util::probe_dir`).
59pub 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
70/// Check whether the current directory is inside a git repository.
71pub 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
76/// Check whether `cwd` is inside a git repository.
77///
78/// Path-taking sibling of [`is_git_repo`]. A failed check is reported through
79/// `tracing` at `warn` before `false` is returned, so a repository git refuses
80/// to read — the canonical case being `detected dubious ownership in repository
81/// at '<path>'` — is distinguishable from a directory that is genuinely not a
82/// repository.
83pub 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
93/// Return the `git status --porcelain` output showing dirty files.
94pub 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
99/// Return the `git status --porcelain` output from a repository at `cwd`.
100///
101/// Path-taking sibling of [`git_status_porcelain`].
102pub fn git_status_porcelain_in(cwd: &Path) -> String {
103    git_output_in(cwd, &["status", "--porcelain"]).unwrap_or_default()
104}
105
106/// Return the `git status --porcelain` output from a repository at `cwd`,
107/// surfacing the underlying git failure instead of swallowing it.
108///
109/// `Ok(String)` carries the porcelain output (empty = clean tree); `Err`
110/// signals that the `git status` invocation itself could not run or determine
111/// cleanliness (cwd is not a git repository, git is absent, the index is
112/// locked, …). Use this — not [`git_status_porcelain_in`] — for any guard that
113/// must FAIL when it cannot prove the tree is clean, rather than treating an
114/// indeterminate result as clean.
115pub fn git_status_porcelain_result_in(cwd: &Path) -> Result<String> {
116    git_output_in(cwd, &["status", "--porcelain"])
117}
118
119/// List the repository's tracked files (`git ls-files`) as repo-relative paths.
120///
121/// Drives the `anodizer init --version-files` enrollment discovery: the
122/// candidate set is the tracked, text files that embed the current version, so
123/// untracked build output and ignored scratch never enter the prompt. Returns
124/// an empty list when the repository tracks no files; errors only if `git`
125/// itself fails (not a repository, git unavailable).
126pub 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
135/// Check whether the current repository is a shallow clone.
136///
137/// Returns `true` if the `.git/shallow` sentinel file exists, which git creates
138/// when a repository was cloned with `--depth`.
139pub 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
144/// Check whether the repository at `cwd` is a shallow clone.
145///
146/// Path-taking sibling of [`is_shallow_clone`]. The `.git/shallow` sentinel
147/// is resolved relative to `cwd` via `git rev-parse --git-dir`; when that
148/// command returns a relative path (the common case for non-worktree repos),
149/// it is joined onto `cwd` so the check stays self-contained.
150pub fn is_shallow_clone_in(cwd: &Path) -> bool {
151    // Use `git rev-parse --git-dir` to find the actual .git directory,
152    // which handles worktrees and non-standard layouts.
153    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}