workspacer_git/
ensure_git_clean_for_crate.rs

1// ---------------- [ File: workspacer-git/src/ensure_git_clean_for_crate.rs ]
2crate::ix!();
3
4#[async_trait]
5impl EnsureGitClean for CrateHandle {
6    type Error = GitError;
7
8    async fn ensure_git_clean(&self) -> Result<(), Self::Error> {
9        info!("Ensuring Git is clean (single crate) at {:?}", self.as_ref());
10        // If you want the same logic as workspace, you can copy-paste. 
11        // Or `todo!()` if you prefer. 
12        let output = Command::new("git")
13            .args(["status", "--porcelain"])
14            .current_dir(self.as_ref())  // important: run in crate's directory
15            .output()
16            .await
17            .map_err(|e| GitError::IoError { 
18                io: Arc::new(e),
19                context: format!("could not run git status --porcelain in current directory: {:?}", self.as_ref()),
20            })?;
21
22        if !output.status.success() {
23            return Err(GitError::FailedToRunGitStatusMakeSureGitIsInstalled);
24        }
25        let stdout_str = String::from_utf8_lossy(&output.stdout);
26        if !stdout_str.trim().is_empty() {
27            return Err(GitError::WorkingDirectoryIsNotCleanAborting);
28        }
29        Ok(())
30    }
31}
32
33#[cfg(test)]
34mod test_ensure_git_clean_for_crate_handle {
35    use super::*;
36    use std::process::Stdio;
37    use std::path::{Path, PathBuf};
38    use tempfile::tempdir;
39    use tokio::process::Command;
40    use tokio::io::AsyncWriteExt;
41    use tokio::fs::{File, create_dir_all};
42
43    /// Minimal helper struct that implements `HasCargoTomlPathBuf` so we can create a `CrateHandle`.
44    /// We'll initialize a dummy Cargo.toml in a git repo for testing `ensure_git_clean`.
45    #[derive(Clone)]
46    struct TempCratePath(PathBuf);
47
48    impl AsRef<Path> for TempCratePath {
49        fn as_ref(&self) -> &Path {
50            self.0.as_ref()
51        }
52    }
53
54    /// Writes a minimal Cargo.toml to the specified directory.
55    async fn write_minimal_cargo_toml(dir: &Path) {
56        let cargo_toml = r#"
57            [package]
58            name = "test_crate"
59            version = "0.1.0"
60            authors = ["Someone <someone@example.com>"]
61            license = "MIT"
62        "#;
63        let file_path = dir.join("Cargo.toml");
64        create_dir_all(dir).await.expect("Failed to create directories");
65        let mut f = File::create(&file_path).await.expect("Failed to create Cargo.toml");
66        f.write_all(cargo_toml.as_bytes())
67            .await
68            .expect("Failed to write Cargo.toml");
69    }
70
71    /// Helper to run a command in the given directory, returning Ok(()) if exit code == 0.
72    /// For debugging test failures, we also capture stdout/stderr.
73    async fn run_in_dir(cmd: &str, args: &[&str], dir: &Path) -> Result<(), String> {
74        let mut command = Command::new(cmd);
75        command.args(args).current_dir(dir).stdout(Stdio::piped()).stderr(Stdio::piped());
76        let output = command
77            .output()
78            .await
79            .map_err(|e| format!("Failed to spawn '{cmd}': {e}"))?;
80
81        if output.status.success() {
82            Ok(())
83        } else {
84            Err(format!(
85                "'{cmd} {:?}' failed with code {:?}\nstdout:\n{}\nstderr:\n{}",
86                args,
87                output.status.code(),
88                String::from_utf8_lossy(&output.stdout),
89                String::from_utf8_lossy(&output.stderr),
90            ))
91        }
92    }
93
94    /// Test 4: If `git` is not installed or `git status --porcelain` fails in some other way,
95    /// we expect `Err(GitError::FailedToRunGitStatusMakeSureGitIsInstalled)` or an IO error.
96    /// It's challenging to reliably force that scenario in normal environments. 
97    /// We can at least demonstrate how we might do it by overriding PATH or something. 
98    /// Here, we'll just show a test that might get ignored typically:
99    #[tokio::test]
100    #[ignore = "Requires mocking or a system without git installed to run meaningfully"]
101    async fn test_ensure_git_clean_no_git_available() {
102        let (temp_crate_path, repo_path) = setup_git_repo_for_crate().await;
103        // If we alter environment PATH in such a way that 'git' can't be found,
104        // we'd force an IO error. That is OS-specific. We'll skip actual implementation.
105        let handle = CrateHandle::new(&temp_crate_path)
106            .await
107            .expect("Failed to create CrateHandle");
108
109        // Hypothetically remove or rename 'git' from PATH, etc.
110        // For demonstration only:
111        unsafe { std::env::set_var("PATH", "") };
112
113        let result = handle.ensure_git_clean().await;
114        unsafe { std::env::remove_var("PATH") }; // restore if we want
115        assert!(
116            matches!(
117                result,
118                Err(GitError::IoError{..}) | Err(GitError::FailedToRunGitStatusMakeSureGitIsInstalled)
119            ),
120            "Expected IoError or FailedToRunGitStatusMakeSureGitIsInstalled, got: {result:?}"
121        );
122    }
123
124    /// Returns the `TempDir` so it stays alive and a `PathBuf` to that directory.
125    /// (We keep the `TempDir` in scope the entire time, ensuring the directory is not deleted.)
126    async fn setup_git_repo_for_crate() -> (TempDir, PathBuf) {
127        let tmp_dir = tempdir().expect("Failed to create temp directory");
128        let repo_path = tmp_dir.path().to_path_buf();
129
130        // Write Cargo.toml, init, commit, etc.
131        write_minimal_cargo_toml(tmp_dir.path()).await;
132        run_in_dir("git", &["init"], tmp_dir.path()).await.unwrap();
133        run_in_dir("git", &["add", "."], tmp_dir.path()).await.unwrap();
134        run_in_dir("git", &["commit", "-m", "Initial commit"], tmp_dir.path()).await.unwrap();
135
136        // Return both the TempDir and the path. The caller must keep the TempDir in scope.
137        (tmp_dir, repo_path)
138    }
139
140    #[tokio::test]
141    async fn test_ensure_git_clean_pristine_repo() {
142        // Keep the TempDir alive as long as we need the files
143        let (temp_crate_dir, repo_path) = setup_git_repo_for_crate().await;
144
145        // Now create the CrateHandle using the `repo_path` (which still exists!):
146        let handle = CrateHandle::new(&repo_path)
147            .await
148            .expect("Failed to create CrateHandle");
149
150        // This should succeed, because there are no uncommitted changes
151        handle.ensure_git_clean().await.expect("Expected clean repo");
152
153        // As soon as this function returns, `temp_crate_dir` drops and the directory is cleaned up.
154    }
155
156    // Similarly fix the other tests:
157    #[tokio::test]
158    async fn test_ensure_git_clean_untracked_changes() {
159        let (temp_crate_dir, repo_path) = setup_git_repo_for_crate().await;
160        let handle = CrateHandle::new(&repo_path)
161            .await
162            .expect("Failed to create CrateHandle");
163
164        // Create an untracked file
165        let untracked_file = repo_path.join("untracked_file.txt");
166        {
167            let mut f = File::create(&untracked_file)
168                .await
169                .expect("Failed to create untracked file");
170            f.write_all(b"This is untracked content.")
171                .await
172                .expect("Failed to write untracked content");
173        }
174
175        // Now ensure_git_clean should fail
176        let result = handle.ensure_git_clean().await;
177        assert!(
178            matches!(result, Err(GitError::WorkingDirectoryIsNotCleanAborting)),
179            "Expected WorkingDirectoryIsNotCleanAborting, got: {result:?}"
180        );
181        // Directory is cleaned up only after exiting this function
182    }
183
184    #[tokio::test]
185    async fn test_ensure_git_clean_modified_file() {
186        let (temp_crate_dir, repo_path) = setup_git_repo_for_crate().await;
187        let handle = CrateHandle::new(&repo_path)
188            .await
189            .expect("Failed to create CrateHandle");
190
191        // Modify the existing Cargo.toml (tracked but uncommitted)
192        let cargo_toml = repo_path.join("Cargo.toml");
193        {
194            let mut f = File::options()
195                .append(true)
196                .open(&cargo_toml)
197                .await
198                .expect("Failed to open Cargo.toml for appending");
199            f.write_all(b"# Adding a new line to Cargo.toml\n")
200                .await
201                .expect("Failed to write to Cargo.toml");
202        }
203
204        let result = handle.ensure_git_clean().await;
205        assert!(
206            matches!(result, Err(GitError::WorkingDirectoryIsNotCleanAborting)),
207            "Expected WorkingDirectoryIsNotCleanAborting, got: {result:?}"
208        );
209    }
210}