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/// List the repository's tracked files (`git ls-files`) as repo-relative paths.
97///
98/// Drives the `anodizer init --version-files` enrollment discovery: the
99/// candidate set is the tracked, text files that embed the current version, so
100/// untracked build output and ignored scratch never enter the prompt. Returns
101/// an empty list when the repository tracks no files; errors only if `git`
102/// itself fails (not a repository, git unavailable).
103pub fn list_tracked_files_in(cwd: &Path) -> Result<Vec<String>> {
104    let out = git_output_in(cwd, &["ls-files", "-z"])?;
105    Ok(out
106        .split('\0')
107        .filter(|s| !s.is_empty())
108        .map(|s| s.to_string())
109        .collect())
110}
111
112/// Check whether the current repository is a shallow clone.
113///
114/// Returns `true` if the `.git/shallow` sentinel file exists, which git creates
115/// when a repository was cloned with `--depth`.
116pub fn is_shallow_clone() -> bool {
117    let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
118    is_shallow_clone_in(&cwd)
119}
120
121/// Check whether the repository at `cwd` is a shallow clone.
122///
123/// Path-taking sibling of [`is_shallow_clone`]. The `.git/shallow` sentinel
124/// is resolved relative to `cwd` via `git rev-parse --git-dir`; when that
125/// command returns a relative path (the common case for non-worktree repos),
126/// it is joined onto `cwd` so the check stays self-contained.
127pub fn is_shallow_clone_in(cwd: &Path) -> bool {
128    // Use `git rev-parse --git-dir` to find the actual .git directory,
129    // which handles worktrees and non-standard layouts.
130    let git_dir =
131        git_output_in(cwd, &["rev-parse", "--git-dir"]).unwrap_or_else(|_| ".git".to_string());
132    let git_dir_path = Path::new(&git_dir);
133    let shallow = if git_dir_path.is_absolute() {
134        git_dir_path.join("shallow")
135    } else {
136        cwd.join(git_dir_path).join("shallow")
137    };
138    shallow.exists()
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use std::process::Command;
145
146    fn init_repo(dir: &Path) {
147        let run = |args: &[&str]| {
148            let out = Command::new("git")
149                .args(args)
150                .current_dir(dir)
151                .env("GIT_AUTHOR_NAME", "test")
152                .env("GIT_AUTHOR_EMAIL", "test@test.com")
153                .env("GIT_COMMITTER_NAME", "test")
154                .env("GIT_COMMITTER_EMAIL", "test@test.com")
155                .output()
156                .unwrap();
157            assert!(
158                out.status.success(),
159                "git {:?} failed: {}",
160                args,
161                String::from_utf8_lossy(&out.stderr)
162            );
163        };
164        run(&["init"]);
165        run(&["config", "user.email", "test@test.com"]);
166        run(&["config", "user.name", "Status Tester"]);
167        std::fs::write(dir.join("README"), "init").unwrap();
168        run(&["add", "."]);
169        run(&["commit", "-m", "initial"]);
170    }
171
172    #[test]
173    fn is_git_repo_in_returns_false_for_non_git_dir() {
174        let tmp = tempfile::tempdir().unwrap();
175        assert!(!is_git_repo_in(tmp.path()));
176    }
177
178    #[test]
179    fn is_git_repo_in_returns_true_for_initialized_repo() {
180        let tmp = tempfile::tempdir().unwrap();
181        init_repo(tmp.path());
182        assert!(is_git_repo_in(tmp.path()));
183    }
184
185    #[test]
186    fn is_git_dirty_in_is_false_for_clean_repo() {
187        let tmp = tempfile::tempdir().unwrap();
188        init_repo(tmp.path());
189        assert!(!is_git_dirty_in(tmp.path()));
190    }
191
192    #[test]
193    fn is_git_dirty_in_is_true_after_untracked_change() {
194        let tmp = tempfile::tempdir().unwrap();
195        init_repo(tmp.path());
196        std::fs::write(tmp.path().join("new.txt"), "hello").unwrap();
197        assert!(is_git_dirty_in(tmp.path()));
198    }
199
200    #[test]
201    fn git_status_porcelain_in_reflects_dirty_state() {
202        let tmp = tempfile::tempdir().unwrap();
203        init_repo(tmp.path());
204        std::fs::write(tmp.path().join("staged.txt"), "x").unwrap();
205        let status = git_status_porcelain_in(tmp.path());
206        assert!(status.contains("staged.txt"), "got: {status:?}");
207    }
208
209    #[test]
210    fn local_git_user_name_in_reads_repo_config() {
211        let tmp = tempfile::tempdir().unwrap();
212        init_repo(tmp.path());
213        assert_eq!(
214            local_git_user_name_in(tmp.path()).as_deref(),
215            Some("Status Tester")
216        );
217    }
218
219    #[test]
220    fn local_git_user_email_in_reads_repo_config() {
221        let tmp = tempfile::tempdir().unwrap();
222        init_repo(tmp.path());
223        assert_eq!(
224            local_git_user_email_in(tmp.path()).as_deref(),
225            Some("test@test.com")
226        );
227    }
228
229    #[test]
230    fn list_tracked_files_in_returns_committed_paths() {
231        let tmp = tempfile::tempdir().unwrap();
232        init_repo(tmp.path());
233        std::fs::write(tmp.path().join("extra.txt"), "x").unwrap();
234        let run = |args: &[&str]| {
235            Command::new("git")
236                .args(args)
237                .current_dir(tmp.path())
238                .output()
239                .unwrap();
240        };
241        run(&["add", "extra.txt"]);
242        run(&["commit", "-m", "add extra"]);
243        let files = list_tracked_files_in(tmp.path()).unwrap();
244        assert!(files.contains(&"README".to_string()), "got: {files:?}");
245        assert!(files.contains(&"extra.txt".to_string()), "got: {files:?}");
246    }
247
248    #[test]
249    fn is_shallow_clone_in_is_false_for_full_clone() {
250        let tmp = tempfile::tempdir().unwrap();
251        init_repo(tmp.path());
252        assert!(!is_shallow_clone_in(tmp.path()));
253    }
254}