Skip to main content

ralph/commands/init/
gitignore.rs

1//! Gitignore management for Ralph initialization.
2//!
3//! Responsibilities:
4//! - Ensure `.ralph/workspaces/` is in `.gitignore` to prevent dirty repo issues.
5//! - Ensure `.ralph/logs/` is in `.gitignore` to prevent committing unredacted debug logs.
6//! - Provide idempotent updates to `.gitignore`.
7//!
8//! Not handled here:
9//! - Reading or parsing existing `.gitignore` patterns (only simple line-based checks).
10//! - Global gitignore configuration (only repo-local `.gitignore`).
11//!
12//! Invariants/assumptions:
13//! - Updates are additive only (never removes entries).
14//! - Safe to run multiple times (idempotent).
15
16use anyhow::{Context, Result};
17use std::fs;
18use std::path::Path;
19
20/// Ensures Ralph-specific entries exist in `.gitignore`.
21///
22/// Currently ensures:
23/// - `.ralph/workspaces/` is ignored (prevents dirty repo when using repo-local workspaces)
24/// - `.ralph/logs/` is ignored (prevents committing unredacted debug logs that may contain secrets)
25///
26/// This function is idempotent - calling it multiple times is safe.
27pub fn ensure_ralph_gitignore_entries(repo_root: &Path) -> Result<()> {
28    let gitignore_path = repo_root.join(".gitignore");
29
30    // Read existing content or start fresh
31    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    // Check if entries already exist (handle various formats)
39    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    // Append the entries
48    let mut new_content = existing_content;
49    let will_add_logs = needs_logs_entry;
50    let will_add_workspaces = needs_workspaces_entry;
51
52    // Add newline if file doesn't end with one (and isn't empty)
53    if !new_content.is_empty() && !new_content.ends_with('\n') {
54        new_content.push('\n');
55    }
56
57    // Add logs entry if missing
58    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    // Add workspaces entry if missing
67    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
88/// Check if a line is a workspaces ignore entry.
89///
90/// Matches:
91/// - `.ralph/workspaces/`
92/// - `.ralph/workspaces`
93fn is_workspaces_ignore_entry(line: &str) -> bool {
94    let trimmed = line.trim();
95    trimmed == ".ralph/workspaces/" || trimmed == ".ralph/workspaces"
96}
97
98/// Check if a line is a logs ignore entry.
99///
100/// Matches:
101/// - `.ralph/logs/`
102/// - `.ralph/logs`
103fn is_logs_ignore_entry(line: &str) -> bool {
104    let trimmed = line.trim();
105    trimmed == ".ralph/logs/" || trimmed == ".ralph/logs"
106}
107
108/// Migrate .json ignore patterns to .jsonc in .gitignore.
109///
110/// This updates Ralph-managed ignore patterns from .json to .jsonc variants.
111/// Patterns like `.ralph/queue.json` become `.ralph/queue.jsonc`.
112///
113/// Returns true if any changes were made.
114pub 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    // Define patterns to migrate: (old_pattern, new_pattern)
124    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        // Check if old pattern exists and new pattern doesn't
136        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        // Run twice
209        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        // Should only have one entry for each
216        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        // Should add logs but not duplicate workspaces
237        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        // Should add workspaces but not duplicate logs
257        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        // Should not add the trailing-slash version if non-trailing exists
275        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/  ")); // with whitespace
296        assert!(is_logs_ignore_entry("  .ralph/logs  ")); // with whitespace
297        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}