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.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
35pub 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
110pub 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
130pub(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
182fn 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
191fn 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 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}