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`].
79pub fn is_git_repo_in(cwd: &Path) -> bool {
80    git_output_in(cwd, &["rev-parse", "--git-dir"]).is_ok()
81}
82
83/// Return the `git status --porcelain` output showing dirty files.
84pub 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
89/// Return the `git status --porcelain` output from a repository at `cwd`.
90///
91/// Path-taking sibling of [`git_status_porcelain`].
92pub fn git_status_porcelain_in(cwd: &Path) -> String {
93    git_output_in(cwd, &["status", "--porcelain"]).unwrap_or_default()
94}
95
96/// Return the `git status --porcelain` output from a repository at `cwd`,
97/// surfacing the underlying git failure instead of swallowing it.
98///
99/// `Ok(String)` carries the porcelain output (empty = clean tree); `Err`
100/// signals that the `git status` invocation itself could not run or determine
101/// cleanliness (cwd is not a git repository, git is absent, the index is
102/// locked, …). Use this — not [`git_status_porcelain_in`] — for any guard that
103/// must FAIL when it cannot prove the tree is clean, rather than treating an
104/// indeterminate result as clean.
105pub fn git_status_porcelain_result_in(cwd: &Path) -> Result<String> {
106    git_output_in(cwd, &["status", "--porcelain"])
107}
108
109/// List the repository's tracked files (`git ls-files`) as repo-relative paths.
110///
111/// Drives the `anodizer init --version-files` enrollment discovery: the
112/// candidate set is the tracked, text files that embed the current version, so
113/// untracked build output and ignored scratch never enter the prompt. Returns
114/// an empty list when the repository tracks no files; errors only if `git`
115/// itself fails (not a repository, git unavailable).
116pub 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
125/// Check whether the current repository is a shallow clone.
126///
127/// Returns `true` if the `.git/shallow` sentinel file exists, which git creates
128/// when a repository was cloned with `--depth`.
129pub 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
134/// Check whether the repository at `cwd` is a shallow clone.
135///
136/// Path-taking sibling of [`is_shallow_clone`]. The `.git/shallow` sentinel
137/// is resolved relative to `cwd` via `git rev-parse --git-dir`; when that
138/// command returns a relative path (the common case for non-worktree repos),
139/// it is joined onto `cwd` so the check stays self-contained.
140pub fn is_shallow_clone_in(cwd: &Path) -> bool {
141    // Use `git rev-parse --git-dir` to find the actual .git directory,
142    // which handles worktrees and non-standard layouts.
143    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}