Skip to main content

ralph/git/
clean.rs

1//! Repository cleanliness validation.
2//!
3//! This module provides functions for validating that a repository is in a clean
4//! state, with support for allowing specific paths to be dirty (e.g., Ralph's
5//! own configuration files).
6//!
7//! # Invariants
8//! - Allowed paths must be normalized before comparison
9//! - Directory prefixes work with or without trailing slashes
10//! - Force flag bypasses all checks
11//!
12//! # What this does NOT handle
13//! - Actual git operations (see git/commit.rs)
14//! - Status parsing details (see git/status.rs)
15//! - LFS validation (see git/lfs.rs)
16
17use crate::git::error::GitError;
18use crate::git::status::{parse_porcelain_z_entries, status_porcelain};
19use std::path::Path;
20
21/// Paths that are allowed to be dirty during Ralph runs.
22///
23/// These are Ralph's own configuration and state files that may change
24/// during normal operation.
25pub const RALPH_RUN_CLEAN_ALLOWED_PATHS: &[&str] = &[
26    ".ralph/queue.jsonc",
27    ".ralph/done.jsonc",
28    ".ralph/config.jsonc",
29    ".ralph/cache/",
30];
31
32/// Require a clean repository, ignoring allowed paths.
33///
34/// Returns an error if the repository has uncommitted changes outside
35/// the allowed paths. The force flag bypasses this check entirely.
36///
37/// # Arguments
38/// * `repo_root` - Path to the repository root
39/// * `force` - If true, bypass the check entirely
40/// * `allowed_paths` - Paths that are allowed to be dirty
41///
42/// # Returns
43/// * `Ok(())` - Repository is clean or force was true
44/// * `Err(GitError::DirtyRepo)` - Repository has disallowed changes
45pub fn require_clean_repo_ignoring_paths(
46    repo_root: &Path,
47    force: bool,
48    allowed_paths: &[&str],
49) -> Result<(), GitError> {
50    let status = status_porcelain(repo_root)?;
51    if status.trim().is_empty() {
52        return Ok(());
53    }
54
55    if force {
56        return Ok(());
57    }
58
59    let mut tracked = Vec::new();
60    let mut untracked = Vec::new();
61
62    let entries = parse_porcelain_z_entries(&status)?;
63    for entry in entries {
64        let path = entry.path.as_str();
65        if !path_is_allowed_for_dirty_check(repo_root, path, allowed_paths) {
66            let display = format_porcelain_entry(&entry);
67            if entry.xy == "??" {
68                untracked.push(display);
69            } else {
70                tracked.push(display);
71            }
72        }
73    }
74
75    if tracked.is_empty() && untracked.is_empty() {
76        return Ok(());
77    }
78
79    let mut details = String::new();
80
81    if !tracked.is_empty() {
82        details.push_str("\n\nTracked changes (suggest 'git stash' or 'git commit'):");
83        for line in tracked.iter().take(10) {
84            details.push_str("\n  ");
85            details.push_str(line);
86        }
87        if tracked.len() > 10 {
88            details.push_str(&format!("\n  ...and {} more", tracked.len() - 10));
89        }
90    }
91
92    if !untracked.is_empty() {
93        details.push_str("\n\nUntracked files (suggest 'git clean -fd' or 'git add'):");
94        for line in untracked.iter().take(10) {
95            details.push_str("\n  ");
96            details.push_str(line);
97        }
98        if untracked.len() > 10 {
99            details.push_str(&format!("\n  ...and {} more", untracked.len() - 10));
100        }
101    }
102
103    details.push_str("\n\nUse --force to bypass this check if you are sure.");
104    Err(GitError::DirtyRepo { details })
105}
106
107/// Returns true when the repo has dirty paths and every dirty path is allowed.
108///
109/// This is useful for detecting if only Ralph's own files have changed.
110pub fn repo_dirty_only_allowed_paths(
111    repo_root: &Path,
112    allowed_paths: &[&str],
113) -> Result<bool, GitError> {
114    use crate::git::status::status_paths;
115
116    let status_paths = status_paths(repo_root)?;
117    if status_paths.is_empty() {
118        return Ok(false);
119    }
120
121    let has_disallowed = status_paths
122        .iter()
123        .any(|path| !path_is_allowed_for_dirty_check(repo_root, path, allowed_paths));
124    Ok(!has_disallowed)
125}
126
127/// Check if a path is allowed to be dirty.
128///
129/// Handles normalization of paths and directory prefix matching.
130pub(crate) fn path_is_allowed_for_dirty_check(
131    repo_root: &Path,
132    path: &str,
133    allowed_paths: &[&str],
134) -> bool {
135    let Some(normalized) = normalize_path_value(path) else {
136        return false;
137    };
138
139    let normalized_dir = if normalized.ends_with('/') {
140        normalized.to_string()
141    } else {
142        format!("{}/", normalized)
143    };
144    let normalized_is_dir = repo_root.join(normalized).is_dir();
145
146    allowed_paths.iter().any(|allowed| {
147        let Some(allowed_norm) = normalize_path_value(allowed) else {
148            return false;
149        };
150
151        if normalized == allowed_norm {
152            return true;
153        }
154
155        let is_dir_prefix = allowed_norm.ends_with('/') || repo_root.join(allowed_norm).is_dir();
156        if !is_dir_prefix {
157            return false;
158        }
159
160        let allowed_dir = allowed_norm.trim_end_matches('/');
161        if allowed_dir.is_empty() {
162            return false;
163        }
164
165        if normalized == allowed_dir {
166            return true;
167        }
168
169        let prefix = format!("{}/", allowed_dir);
170        if normalized.starts_with(&prefix) || normalized_dir.starts_with(&prefix) {
171            return true;
172        }
173
174        let allowed_dir_slash = prefix;
175        normalized_is_dir && allowed_dir_slash.starts_with(&normalized_dir)
176    })
177}
178
179/// Normalize a path value for comparison.
180fn normalize_path_value(value: &str) -> Option<&str> {
181    let trimmed = value.trim();
182    if trimmed.is_empty() {
183        return None;
184    }
185    Some(trimmed.strip_prefix("./").unwrap_or(trimmed))
186}
187
188/// Format a porcelain entry for display.
189fn format_porcelain_entry(entry: &crate::git::status::PorcelainZEntry) -> String {
190    if let Some(old) = entry.old_path.as_deref() {
191        format!("{} {} -> {}", entry.xy, old, entry.path)
192    } else {
193        format!("{} {}", entry.xy, entry.path)
194    }
195}
196
197#[cfg(test)]
198mod clean_repo_tests {
199    use super::*;
200    use crate::testsupport::git as git_test;
201    use tempfile::TempDir;
202
203    #[test]
204    fn run_clean_allowed_paths_include_jsonc_runtime_paths() {
205        for required in [
206            ".ralph/queue.jsonc",
207            ".ralph/done.jsonc",
208            ".ralph/config.jsonc",
209            ".ralph/cache/",
210        ] {
211            assert!(
212                RALPH_RUN_CLEAN_ALLOWED_PATHS.contains(&required),
213                "missing required allowlisted path: {required}"
214            );
215        }
216    }
217
218    #[test]
219    fn repo_dirty_only_allowed_paths_detects_config_only_changes() -> anyhow::Result<()> {
220        let temp = TempDir::new()?;
221        git_test::init_repo(temp.path())?;
222        std::fs::create_dir_all(temp.path().join(".ralph"))?;
223        let config_path = temp.path().join(".ralph/config.jsonc");
224        std::fs::write(&config_path, "{ \"version\": 1 }")?;
225        git_test::git_run(temp.path(), &["add", "-f", ".ralph/config.jsonc"])?;
226        git_test::git_run(temp.path(), &["commit", "-m", "init config"])?;
227
228        std::fs::write(&config_path, "{ \"version\": 2 }")?;
229
230        let dirty_allowed =
231            repo_dirty_only_allowed_paths(temp.path(), RALPH_RUN_CLEAN_ALLOWED_PATHS)?;
232        assert!(dirty_allowed, "expected config-only changes to be allowed");
233        require_clean_repo_ignoring_paths(temp.path(), false, RALPH_RUN_CLEAN_ALLOWED_PATHS)?;
234        Ok(())
235    }
236
237    #[test]
238    fn repo_dirty_only_allowed_paths_detects_config_jsonc_only_changes() -> anyhow::Result<()> {
239        let temp = TempDir::new()?;
240        git_test::init_repo(temp.path())?;
241        std::fs::create_dir_all(temp.path().join(".ralph"))?;
242        let config_path = temp.path().join(".ralph/config.jsonc");
243        std::fs::write(&config_path, "{ \"version\": 1 }")?;
244        git_test::git_run(temp.path(), &["add", "-f", ".ralph/config.jsonc"])?;
245        git_test::git_run(temp.path(), &["commit", "-m", "init config jsonc"])?;
246
247        std::fs::write(&config_path, "{ \"version\": 2 }")?;
248
249        let dirty_allowed =
250            repo_dirty_only_allowed_paths(temp.path(), RALPH_RUN_CLEAN_ALLOWED_PATHS)?;
251        assert!(
252            dirty_allowed,
253            "expected config.jsonc-only changes to be allowed"
254        );
255        require_clean_repo_ignoring_paths(temp.path(), false, RALPH_RUN_CLEAN_ALLOWED_PATHS)?;
256        Ok(())
257    }
258
259    #[test]
260    fn repo_dirty_only_allowed_paths_rejects_other_changes() -> anyhow::Result<()> {
261        let temp = TempDir::new()?;
262        git_test::init_repo(temp.path())?;
263        std::fs::write(temp.path().join("notes.txt"), "hello")?;
264
265        let dirty_allowed =
266            repo_dirty_only_allowed_paths(temp.path(), RALPH_RUN_CLEAN_ALLOWED_PATHS)?;
267        assert!(!dirty_allowed, "expected untracked change to be disallowed");
268        Ok(())
269    }
270
271    #[test]
272    fn repo_dirty_only_allowed_paths_accepts_directory_prefix_with_trailing_slash()
273    -> anyhow::Result<()> {
274        let temp = TempDir::new()?;
275        git_test::init_repo(temp.path())?;
276        std::fs::create_dir_all(temp.path().join("cache/plans"))?;
277        std::fs::write(temp.path().join("cache/plans/plan.md"), "plan")?;
278
279        let dirty_allowed = repo_dirty_only_allowed_paths(temp.path(), &["cache/plans/"])?;
280        assert!(dirty_allowed, "expected directory prefix to be allowed");
281        require_clean_repo_ignoring_paths(temp.path(), false, &["cache/plans/"])?;
282        Ok(())
283    }
284
285    #[test]
286    fn repo_dirty_only_allowed_paths_accepts_existing_directory_prefix_without_slash()
287    -> anyhow::Result<()> {
288        let temp = TempDir::new()?;
289        git_test::init_repo(temp.path())?;
290        std::fs::create_dir_all(temp.path().join("cache"))?;
291        std::fs::write(temp.path().join("cache/notes.txt"), "notes")?;
292
293        let dirty_allowed = repo_dirty_only_allowed_paths(temp.path(), &["cache"])?;
294        assert!(dirty_allowed, "expected existing directory to be allowed");
295        require_clean_repo_ignoring_paths(temp.path(), false, &["cache"])?;
296        Ok(())
297    }
298
299    #[test]
300    fn repo_dirty_only_allowed_paths_rejects_paths_outside_allowed_directory() -> anyhow::Result<()>
301    {
302        let temp = TempDir::new()?;
303        git_test::init_repo(temp.path())?;
304        std::fs::create_dir_all(temp.path().join("cache"))?;
305        std::fs::write(temp.path().join("cache/notes.txt"), "notes")?;
306        std::fs::write(temp.path().join("other.txt"), "nope")?;
307
308        let dirty_allowed = repo_dirty_only_allowed_paths(temp.path(), &["cache/"])?;
309        assert!(!dirty_allowed, "expected other paths to be disallowed");
310        assert!(
311            require_clean_repo_ignoring_paths(temp.path(), false, &["cache/"]).is_err(),
312            "expected clean-repo enforcement to fail"
313        );
314        Ok(())
315    }
316
317    #[test]
318    fn execution_history_json_is_in_allowed_paths() -> anyhow::Result<()> {
319        // Verify that execution_history.json is covered by RALPH_RUN_CLEAN_ALLOWED_PATHS
320        // via the .ralph/cache/ directory prefix
321        let temp = TempDir::new()?;
322        git_test::init_repo(temp.path())?;
323        std::fs::create_dir_all(temp.path().join(".ralph/cache"))?;
324        std::fs::write(
325            temp.path().join(".ralph/cache/execution_history.json"),
326            "{}",
327        )?;
328
329        let dirty_allowed =
330            repo_dirty_only_allowed_paths(temp.path(), RALPH_RUN_CLEAN_ALLOWED_PATHS)?;
331        assert!(
332            dirty_allowed,
333            "execution_history.json should be covered by RALPH_RUN_CLEAN_ALLOWED_PATHS"
334        );
335        Ok(())
336    }
337}