Skip to main content

shipper_core/ops/git/
cleanliness.rs

1//! Git working-tree cleanliness checks.
2//!
3//! Two entry points:
4//!
5//! - [`is_git_clean`] — returns `Ok(true)` iff `git status --porcelain` yields no lines.
6//! - [`ensure_git_clean`] — returns `Err` if the tree is dirty, with the
7//!   historical "commit/stash changes or use --allow-dirty" message that the
8//!   CLI snapshot tests pin.
9//!
10//! `SHIPPER_GIT_BIN` routing: when the env var is set, the override logic in
11//! [`super::bin_override`] is used (so tests can point at a fake git). Otherwise
12//! the default `git` binary is invoked directly.
13//!
14//! The default path wraps errors with a `git status failed:` prefix. The
15//! shipper-cli snapshot tests assert against this phrasing, so do not change
16//! the wording without updating the snapshots in `crates/shipper-cli/tests/snapshots/`.
17
18use std::env;
19use std::path::Path;
20use std::process::Command;
21
22use anyhow::{Context, Result};
23
24use super::bin_override;
25
26/// Check whether the git working tree is clean (no uncommitted changes).
27///
28/// When `SHIPPER_GIT_BIN` is set, routes through the override implementation in
29/// `super::bin_override::local_is_git_clean`. Otherwise uses the default git
30/// invocation. In both cases, an untracked file counts as "dirty".
31pub fn is_git_clean(repo_root: &Path) -> Result<bool> {
32    if let Ok(git_program) = env::var("SHIPPER_GIT_BIN") {
33        return bin_override::local_is_git_clean(repo_root, &git_program);
34    }
35
36    is_git_clean_default(repo_root).map_err(|err| anyhow::anyhow!("git status failed: {err}"))
37}
38
39/// Default-path (no override) cleanliness check.
40///
41/// Preserved from the standalone `shipper-git` crate so that the error phrasing
42/// is identical when no override is in effect. The outer wrapper in
43/// [`is_git_clean`] adds an extra `git status failed:` prefix for CLI
44/// backward-compatibility (see the module-level doc).
45pub(super) fn is_git_clean_default(path: &Path) -> Result<bool> {
46    let output = Command::new("git")
47        .args(["status", "--porcelain"])
48        .current_dir(path)
49        .output()
50        .context("failed to run git status")?;
51
52    if !output.status.success() {
53        return Err(anyhow::anyhow!(
54            "git status failed: {}",
55            String::from_utf8_lossy(&output.stderr)
56        ));
57    }
58
59    // If output is empty, the working tree is clean
60    Ok(output.stdout.is_empty())
61}
62
63/// Fail fast if the working tree is dirty.
64///
65/// Error message is pinned by the CLI snapshot tests:
66/// `"git working tree is not clean; commit/stash changes or use --allow-dirty"`.
67pub fn ensure_git_clean(repo_root: &Path) -> Result<()> {
68    if !is_git_clean(repo_root)? {
69        anyhow::bail!("git working tree is not clean; commit/stash changes or use --allow-dirty");
70    }
71    Ok(())
72}
73
74/// Legacy error phrasing retained for snapshot compatibility.
75///
76/// The standalone `shipper-git` crate originally emitted
77/// `"git working tree has uncommitted changes. Use --allow-dirty to bypass."`.
78/// That exact string is pinned by an `insta` yaml snapshot
79/// (`ensure_git_clean_error.snap`). This wrapper keeps that snapshot stable.
80#[cfg(test)]
81pub(super) fn ensure_git_clean_legacy(path: &Path) -> Result<()> {
82    if !is_git_clean_default(path)? {
83        return Err(anyhow::anyhow!(
84            "git working tree has uncommitted changes. Use --allow-dirty to bypass."
85        ));
86    }
87    Ok(())
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use std::fs;
94    use tempfile::tempdir;
95
96    fn init_git_repo(dir: &Path) {
97        Command::new("git")
98            .args(["init"])
99            .current_dir(dir)
100            .output()
101            .expect("git init");
102
103        Command::new("git")
104            .args(["config", "user.email", "test@example.com"])
105            .current_dir(dir)
106            .output()
107            .expect("git config");
108
109        Command::new("git")
110            .args(["config", "user.name", "Test User"])
111            .current_dir(dir)
112            .output()
113            .expect("git config");
114    }
115
116    fn make_commit(dir: &Path, msg: &str) {
117        Command::new("git")
118            .args(["commit", "--allow-empty", "-m", msg])
119            .current_dir(dir)
120            .output()
121            .expect("git commit");
122    }
123
124    #[test]
125    fn is_git_clean_for_empty_repo() {
126        let td = tempdir().expect("tempdir");
127        init_git_repo(td.path());
128        // Empty repo should be clean
129        assert!(is_git_clean_default(td.path()).unwrap_or(false));
130    }
131
132    #[test]
133    fn is_git_clean_dirty_with_untracked_file() {
134        let td = tempdir().expect("tempdir");
135        init_git_repo(td.path());
136        make_commit(td.path(), "initial");
137
138        fs::write(td.path().join("untracked.txt"), "hello").expect("write file");
139        assert!(!is_git_clean_default(td.path()).expect("git status"));
140    }
141
142    #[test]
143    fn is_git_clean_dirty_with_modified_tracked_file() {
144        let td = tempdir().expect("tempdir");
145        init_git_repo(td.path());
146
147        // Create, add, and commit a file
148        fs::write(td.path().join("file.txt"), "initial").expect("write file");
149        Command::new("git")
150            .args(["add", "."])
151            .current_dir(td.path())
152            .output()
153            .expect("git add");
154        make_commit(td.path(), "initial");
155
156        // Modify it
157        fs::write(td.path().join("file.txt"), "modified").expect("write file");
158        assert!(!is_git_clean_default(td.path()).expect("git status"));
159    }
160
161    #[test]
162    fn ensure_git_clean_ok_on_clean_repo() {
163        let td = tempdir().expect("tempdir");
164        init_git_repo(td.path());
165        assert!(ensure_git_clean_legacy(td.path()).is_ok());
166    }
167
168    #[test]
169    fn ensure_git_clean_errors_with_allow_dirty_hint() {
170        let td = tempdir().expect("tempdir");
171        init_git_repo(td.path());
172        make_commit(td.path(), "initial");
173
174        fs::write(td.path().join("dirty.txt"), "x").expect("write");
175        let err = ensure_git_clean_legacy(td.path()).unwrap_err();
176        let msg = err.to_string();
177        assert!(msg.contains("--allow-dirty"));
178        assert!(msg.contains("uncommitted changes"));
179    }
180
181    #[test]
182    fn ensure_git_clean_new_phrasing() {
183        let td = tempdir().expect("tempdir");
184        init_git_repo(td.path());
185        make_commit(td.path(), "initial");
186
187        fs::write(td.path().join("dirty.txt"), "x").expect("write");
188        let err = ensure_git_clean(td.path()).unwrap_err();
189        let msg = err.to_string();
190        // The new (canonical) phrasing used by the CLI.
191        assert!(msg.contains("--allow-dirty"));
192        assert!(msg.contains("not clean"));
193    }
194}