Skip to main content

ferro_cli/doctor/checks/
dirty_git_tree.rs

1//! Dirty git tree (SCOPE §12.9): warn when `git status --porcelain` has output
2//! or when there are unpushed commits relative to upstream.
3
4use crate::doctor::check::{CheckResult, DoctorCheck};
5use std::path::Path;
6use std::process::Command;
7
8pub struct DirtyGitTreeCheck;
9
10const NAME: &str = "git_clean_and_pushed";
11
12impl DoctorCheck for DirtyGitTreeCheck {
13    fn name(&self) -> &'static str {
14        NAME
15    }
16    fn run(&self, root: &Path) -> CheckResult {
17        check_impl(root)
18    }
19}
20
21pub(crate) fn check_impl(root: &Path) -> CheckResult {
22    if !root.join(".git").exists() {
23        return CheckResult::ok(NAME, "skipped (not a git repo)");
24    }
25
26    let porcelain = match run_git(root, &["status", "--porcelain"]) {
27        Ok(s) => s,
28        Err(e) => return CheckResult::warn(NAME, format!("git status failed: {e}")),
29    };
30    let dirty = !porcelain.trim().is_empty();
31
32    let unpushed = match run_git(root, &["log", "@{upstream}..HEAD", "--oneline"]) {
33        Ok(s) => !s.trim().is_empty(),
34        Err(_) => false, // No upstream configured — treat as clean for this signal.
35    };
36
37    match (dirty, unpushed) {
38        (false, false) => CheckResult::ok(NAME, "working tree clean and pushed"),
39        (true, false) => CheckResult::warn(NAME, "working tree has uncommitted changes"),
40        (false, true) => CheckResult::warn(NAME, "local commits not pushed to upstream"),
41        (true, true) => CheckResult::warn(NAME, "uncommitted changes and unpushed commits present"),
42    }
43}
44
45fn run_git(root: &Path, args: &[&str]) -> Result<String, String> {
46    let out = Command::new("git")
47        .args(args)
48        .current_dir(root)
49        .output()
50        .map_err(|e| e.to_string())?;
51    if !out.status.success() {
52        return Err(String::from_utf8_lossy(&out.stderr).into_owned());
53    }
54    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60    use tempfile::TempDir;
61
62    #[test]
63    fn name_is_git_clean_and_pushed() {
64        assert_eq!(DirtyGitTreeCheck.name(), "git_clean_and_pushed");
65    }
66
67    #[test]
68    fn skipped_when_not_a_git_repo() {
69        let tmp = TempDir::new().unwrap();
70        let r = check_impl(tmp.path());
71        assert_eq!(r.status, crate::doctor::check::CheckStatus::Ok);
72        assert!(r.message.contains("skipped"));
73    }
74}