1use crate::git::error::GitError;
18use crate::git::status::{parse_porcelain_z_entries, status_porcelain};
19use std::path::Path;
20
21pub const RALPH_RUN_CLEAN_ALLOWED_PATHS: &[&str] = &[
26 ".ralph/queue.jsonc",
27 ".ralph/done.jsonc",
28 ".ralph/config.jsonc",
29 ".ralph/cache/",
30];
31
32pub 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
107pub 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
127pub(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
179fn 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
188fn 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 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}