ralph/commands/init/
gitignore.rs1use anyhow::{Context, Result};
17use std::fs;
18use std::path::Path;
19
20pub fn ensure_ralph_gitignore_entries(repo_root: &Path) -> Result<()> {
28 let gitignore_path = repo_root.join(".gitignore");
29
30 let existing_content = if gitignore_path.exists() {
32 fs::read_to_string(&gitignore_path)
33 .with_context(|| format!("read {}", gitignore_path.display()))?
34 } else {
35 String::new()
36 };
37
38 let needs_workspaces_entry = !existing_content.lines().any(is_workspaces_ignore_entry);
40 let needs_logs_entry = !existing_content.lines().any(is_logs_ignore_entry);
41
42 if !needs_workspaces_entry && !needs_logs_entry {
43 log::debug!(".ralph/workspaces/ and .ralph/logs/ already in .gitignore");
44 return Ok(());
45 }
46
47 let mut new_content = existing_content;
49 let will_add_logs = needs_logs_entry;
50 let will_add_workspaces = needs_workspaces_entry;
51
52 if !new_content.is_empty() && !new_content.ends_with('\n') {
54 new_content.push('\n');
55 }
56
57 if needs_logs_entry {
59 if !new_content.is_empty() {
60 new_content.push('\n');
61 }
62 new_content.push_str("# Ralph debug logs (raw/unredacted; do not commit)\n");
63 new_content.push_str(".ralph/logs/\n");
64 }
65
66 if needs_workspaces_entry {
68 if !new_content.is_empty() {
69 new_content.push('\n');
70 }
71 new_content.push_str("# Ralph parallel mode workspace directories\n");
72 new_content.push_str(".ralph/workspaces/\n");
73 }
74
75 fs::write(&gitignore_path, new_content)
76 .with_context(|| format!("write {}", gitignore_path.display()))?;
77
78 if will_add_logs {
79 log::info!("Added '.ralph/logs/' to .gitignore");
80 }
81 if will_add_workspaces {
82 log::info!("Added '.ralph/workspaces/' to .gitignore");
83 }
84
85 Ok(())
86}
87
88fn is_workspaces_ignore_entry(line: &str) -> bool {
94 let trimmed = line.trim();
95 trimmed == ".ralph/workspaces/" || trimmed == ".ralph/workspaces"
96}
97
98fn is_logs_ignore_entry(line: &str) -> bool {
104 let trimmed = line.trim();
105 trimmed == ".ralph/logs/" || trimmed == ".ralph/logs"
106}
107
108pub fn migrate_json_to_jsonc_gitignore(repo_root: &std::path::Path) -> anyhow::Result<bool> {
115 let gitignore_path = repo_root.join(".gitignore");
116 if !gitignore_path.exists() {
117 return Ok(false);
118 }
119
120 let content = fs::read_to_string(&gitignore_path)
121 .with_context(|| format!("read {}", gitignore_path.display()))?;
122
123 let patterns_to_migrate: &[(&str, &str)] = &[
125 (".ralph/queue.json", ".ralph/queue.jsonc"),
126 (".ralph/done.json", ".ralph/done.jsonc"),
127 (".ralph/config.json", ".ralph/config.jsonc"),
128 (".ralph/*.json", ".ralph/*.jsonc"),
129 ];
130
131 let mut updated = content.clone();
132 let mut made_changes = false;
133
134 for (old_pattern, new_pattern) in patterns_to_migrate {
135 let has_old = updated.lines().any(|line| {
137 let trimmed = line.trim();
138 trimmed == *old_pattern || trimmed == old_pattern.trim_end_matches('/')
139 });
140 let has_new = updated.lines().any(|line| {
141 let trimmed = line.trim();
142 trimmed == *new_pattern || trimmed == new_pattern.trim_end_matches('/')
143 });
144
145 if has_old && !has_new {
146 updated = updated.replace(old_pattern, new_pattern);
147 log::info!(
148 "Migrated .gitignore pattern: {} -> {}",
149 old_pattern,
150 new_pattern
151 );
152 made_changes = true;
153 }
154 }
155
156 if made_changes {
157 fs::write(&gitignore_path, updated)
158 .with_context(|| format!("write {}", gitignore_path.display()))?;
159 }
160
161 Ok(made_changes)
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use tempfile::TempDir;
168
169 #[test]
170 fn ensure_ralph_gitignore_entries_creates_new_file() -> Result<()> {
171 let temp = TempDir::new()?;
172 let repo_root = temp.path();
173
174 ensure_ralph_gitignore_entries(repo_root)?;
175
176 let gitignore_path = repo_root.join(".gitignore");
177 assert!(gitignore_path.exists());
178 let content = fs::read_to_string(&gitignore_path)?;
179 assert!(content.contains(".ralph/workspaces/"));
180 assert!(content.contains(".ralph/logs/"));
181 assert!(content.contains("# Ralph parallel mode"));
182 assert!(content.contains("# Ralph debug logs"));
183 Ok(())
184 }
185
186 #[test]
187 fn ensure_ralph_gitignore_entries_appends_to_existing() -> Result<()> {
188 let temp = TempDir::new()?;
189 let repo_root = temp.path();
190 let gitignore_path = repo_root.join(".gitignore");
191 fs::write(&gitignore_path, ".env\ntarget/\n")?;
192
193 ensure_ralph_gitignore_entries(repo_root)?;
194
195 let content = fs::read_to_string(&gitignore_path)?;
196 assert!(content.contains(".env"));
197 assert!(content.contains("target/"));
198 assert!(content.contains(".ralph/workspaces/"));
199 assert!(content.contains(".ralph/logs/"));
200 Ok(())
201 }
202
203 #[test]
204 fn ensure_ralph_gitignore_entries_is_idempotent() -> Result<()> {
205 let temp = TempDir::new()?;
206 let repo_root = temp.path();
207
208 ensure_ralph_gitignore_entries(repo_root)?;
210 ensure_ralph_gitignore_entries(repo_root)?;
211
212 let gitignore_path = repo_root.join(".gitignore");
213 let content = fs::read_to_string(&gitignore_path)?;
214
215 let workspaces_count = content.matches(".ralph/workspaces/").count();
217 let logs_count = content.matches(".ralph/logs/").count();
218 assert_eq!(
219 workspaces_count, 1,
220 "Should only have one .ralph/workspaces/ entry"
221 );
222 assert_eq!(logs_count, 1, "Should only have one .ralph/logs/ entry");
223 Ok(())
224 }
225
226 #[test]
227 fn ensure_ralph_gitignore_entries_detects_existing_workspaces_entry() -> Result<()> {
228 let temp = TempDir::new()?;
229 let repo_root = temp.path();
230 let gitignore_path = repo_root.join(".gitignore");
231 fs::write(&gitignore_path, ".ralph/workspaces/\n")?;
232
233 ensure_ralph_gitignore_entries(repo_root)?;
234
235 let content = fs::read_to_string(&gitignore_path)?;
236 assert!(content.contains(".ralph/logs/"));
238 let workspaces_count = content.matches(".ralph/workspaces/").count();
239 assert_eq!(
240 workspaces_count, 1,
241 "Should not add duplicate workspaces entry"
242 );
243 Ok(())
244 }
245
246 #[test]
247 fn ensure_ralph_gitignore_entries_detects_existing_logs_entry() -> Result<()> {
248 let temp = TempDir::new()?;
249 let repo_root = temp.path();
250 let gitignore_path = repo_root.join(".gitignore");
251 fs::write(&gitignore_path, ".ralph/logs/\n")?;
252
253 ensure_ralph_gitignore_entries(repo_root)?;
254
255 let content = fs::read_to_string(&gitignore_path)?;
256 assert!(content.contains(".ralph/workspaces/"));
258 let logs_count = content.matches(".ralph/logs/").count();
259 assert_eq!(logs_count, 1, "Should not add duplicate logs entry");
260 Ok(())
261 }
262
263 #[test]
264 fn ensure_ralph_gitignore_entries_detects_existing_entry_without_trailing_slash() -> Result<()>
265 {
266 let temp = TempDir::new()?;
267 let repo_root = temp.path();
268 let gitignore_path = repo_root.join(".gitignore");
269 fs::write(&gitignore_path, ".ralph/workspaces\n.ralph/logs\n")?;
270
271 ensure_ralph_gitignore_entries(repo_root)?;
272
273 let content = fs::read_to_string(&gitignore_path)?;
274 let workspaces_count = content
276 .lines()
277 .filter(|l| l.contains(".ralph/workspaces"))
278 .count();
279 let logs_count = content
280 .lines()
281 .filter(|l| l.contains(".ralph/logs"))
282 .count();
283 assert_eq!(
284 workspaces_count, 1,
285 "Should not add duplicate workspaces entry"
286 );
287 assert_eq!(logs_count, 1, "Should not add duplicate logs entry");
288 Ok(())
289 }
290
291 #[test]
292 fn is_logs_ignore_entry_matches_variations() {
293 assert!(is_logs_ignore_entry(".ralph/logs/"));
294 assert!(is_logs_ignore_entry(".ralph/logs"));
295 assert!(is_logs_ignore_entry(" .ralph/logs/ ")); assert!(is_logs_ignore_entry(" .ralph/logs ")); assert!(!is_logs_ignore_entry(".ralph/logs/debug.log"));
298 assert!(!is_logs_ignore_entry("# .ralph/logs/"));
299 assert!(!is_logs_ignore_entry("something else"));
300 }
301}