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