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