use anyhow::{Context, Result};
use std::fs;
use std::path::Path;
pub fn ensure_cueloop_gitignore_entries(repo_root: &Path) -> Result<()> {
ensure_cueloop_gitignore_entries_changed(repo_root).map(|_| ())
}
pub(crate) fn ensure_cueloop_gitignore_entries_changed(repo_root: &Path) -> Result<bool> {
let gitignore_path = repo_root.join(".gitignore");
let existing_content = if gitignore_path.exists() {
fs::read_to_string(&gitignore_path)
.with_context(|| format!("read {}", gitignore_path.display()))?
} else {
String::new()
};
let mut new_content = normalize_legacy_runtime_gitignore_policy(&existing_content);
let runtime_name = active_runtime_name(repo_root);
let entries = default_runtime_gitignore_entries(&runtime_name);
let missing = entries
.iter()
.filter(|entry| !has_exact_gitignore_entry(&new_content, entry))
.cloned()
.collect::<Vec<_>>();
if missing.is_empty() && new_content == existing_content {
log::debug!("CueLoop local/runtime entries already in .gitignore");
return Ok(false);
}
if !missing.is_empty() {
if !new_content.is_empty() && !new_content.ends_with('\n') {
new_content.push('\n');
}
if !new_content.is_empty() {
new_content.push('\n');
}
new_content.push_str("# CueLoop local/runtime artifacts (do not commit)\n");
for entry in &missing {
new_content.push_str(entry);
new_content.push('\n');
}
}
if new_content != existing_content {
fs::write(&gitignore_path, &new_content)
.with_context(|| format!("write {}", gitignore_path.display()))?;
}
if !missing.is_empty() {
log::info!("Added CueLoop local/runtime artifacts to .gitignore");
}
Ok(new_content != existing_content)
}
fn default_runtime_gitignore_entries(runtime_name: &str) -> Vec<String> {
[
"cache/",
"logs/",
"workspaces/",
"lock/",
"undo/",
"webhooks/",
]
.into_iter()
.map(|child| format!("{runtime_name}/{child}"))
.chain([
format!("{runtime_name}/trust.json"),
format!("{runtime_name}/trust.jsonc"),
])
.collect()
}
fn has_exact_gitignore_entry(content: &str, entry: &str) -> bool {
let without_slash = entry.trim_end_matches('/');
content.lines().any(|line| {
let trimmed = line.trim();
trimmed == entry || trimmed == without_slash
})
}
pub(crate) fn normalize_legacy_runtime_gitignore_policy(content: &str) -> String {
let mut normalized = content
.lines()
.filter(|line| !is_legacy_broad_runtime_policy_line(line.trim()))
.map(str::to_string)
.collect::<Vec<_>>()
.join("\n");
if content.ends_with('\n') && !normalized.is_empty() {
normalized.push('\n');
}
normalized
}
fn is_legacy_broad_runtime_policy_line(trimmed: &str) -> bool {
matches!(
trimmed,
".cueloop"
| ".cueloop/"
| ".cueloop/*"
| "!.cueloop"
| "!.cueloop/"
| "!.cueloop/queue.jsonc"
| "!.cueloop/done.jsonc"
| "!.cueloop/config.jsonc"
| "!.cueloop/README.md"
| ".ralph"
| ".ralph/"
| ".ralph/*"
| "!.ralph"
| "!.ralph/"
| "!.ralph/queue.jsonc"
| "!.ralph/done.jsonc"
| "!.ralph/config.jsonc"
| "!.ralph/README.md"
)
}
fn active_runtime_name(repo_root: &Path) -> String {
crate::config::project_runtime_dir(repo_root)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(crate::constants::identity::PROJECT_RUNTIME_DIR)
.to_string()
}
pub fn ensure_local_queue_gitignore_entries(repo_root: &Path) -> Result<()> {
let runtime_name = active_runtime_name(repo_root);
let entries = vec![
format!("{runtime_name}/queue.jsonc"),
format!("{runtime_name}/done.jsonc"),
];
ensure_exact_gitignore_entries(
repo_root,
"# CueLoop local queue state",
&entries,
"local queue/done files",
)
}
fn ensure_exact_gitignore_entries(
repo_root: &Path,
header: &str,
entries: &[String],
label: &str,
) -> Result<()> {
let gitignore_path = repo_root.join(".gitignore");
let existing_content = if gitignore_path.exists() {
fs::read_to_string(&gitignore_path)
.with_context(|| format!("read {}", gitignore_path.display()))?
} else {
String::new()
};
let missing = entries
.iter()
.filter(|entry| {
!existing_content
.lines()
.any(|line| line.trim() == entry.as_str())
})
.collect::<Vec<_>>();
if missing.is_empty() {
return Ok(());
}
let mut new_content = existing_content;
if !new_content.is_empty() && !new_content.ends_with('\n') {
new_content.push('\n');
}
if !new_content.is_empty() {
new_content.push('\n');
}
new_content.push_str(header);
new_content.push('\n');
for entry in missing {
new_content.push_str(entry);
new_content.push('\n');
}
fs::write(&gitignore_path, new_content)
.with_context(|| format!("write {}", gitignore_path.display()))?;
log::info!("Added {} to .gitignore", label);
Ok(())
}
pub fn migrate_json_to_jsonc_gitignore(repo_root: &std::path::Path) -> anyhow::Result<bool> {
let gitignore_path = repo_root.join(".gitignore");
if !gitignore_path.exists() {
return Ok(false);
}
let content = fs::read_to_string(&gitignore_path)
.with_context(|| format!("read {}", gitignore_path.display()))?;
let patterns_to_migrate: &[(&str, &str)] = &[
(".cueloop/queue.json", ".cueloop/queue.jsonc"),
(".cueloop/done.json", ".cueloop/done.jsonc"),
(".cueloop/config.json", ".cueloop/config.jsonc"),
(".cueloop/*.json", ".cueloop/*.jsonc"),
];
let mut updated = content.clone();
let mut made_changes = false;
for (old_pattern, new_pattern) in patterns_to_migrate {
let has_old = updated.lines().any(|line| {
let trimmed = line.trim();
trimmed == *old_pattern || trimmed == old_pattern.trim_end_matches('/')
});
let has_new = updated.lines().any(|line| {
let trimmed = line.trim();
trimmed == *new_pattern || trimmed == new_pattern.trim_end_matches('/')
});
if has_old && !has_new {
updated = updated.replace(old_pattern, new_pattern);
log::info!(
"Migrated .gitignore pattern: {} -> {}",
old_pattern,
new_pattern
);
made_changes = true;
}
}
if made_changes {
fs::write(&gitignore_path, updated)
.with_context(|| format!("write {}", gitignore_path.display()))?;
}
Ok(made_changes)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn ensure_cueloop_gitignore_entries_creates_current_runtime_file() -> Result<()> {
let temp = TempDir::new()?;
let repo_root = temp.path();
ensure_cueloop_gitignore_entries(repo_root)?;
let gitignore_path = repo_root.join(".gitignore");
assert!(gitignore_path.exists());
let content = fs::read_to_string(&gitignore_path)?;
assert!(content.contains(".cueloop/workspaces/"));
assert!(content.contains(".cueloop/logs/"));
assert!(content.contains(".cueloop/cache/"));
assert!(content.contains(".cueloop/lock/"));
assert!(content.contains(".cueloop/undo/"));
assert!(content.contains(".cueloop/webhooks/"));
assert!(content.contains(".cueloop/trust.json"));
assert!(content.contains(".cueloop/trust.jsonc"));
assert!(content.contains("# CueLoop local/runtime artifacts"));
assert!(!content.contains(".cueloop/queue.jsonc"));
assert!(!content.contains(".cueloop/done.jsonc"));
Ok(())
}
#[test]
fn ensure_cueloop_gitignore_entries_appends_to_existing() -> Result<()> {
let temp = TempDir::new()?;
let repo_root = temp.path();
let gitignore_path = repo_root.join(".gitignore");
fs::write(&gitignore_path, ".env\ntarget/\n")?;
ensure_cueloop_gitignore_entries(repo_root)?;
let content = fs::read_to_string(&gitignore_path)?;
assert!(content.contains(".env"));
assert!(content.contains("target/"));
assert!(content.contains(".cueloop/workspaces/"));
assert!(content.contains(".cueloop/logs/"));
assert!(content.contains(".cueloop/trust.jsonc"));
Ok(())
}
#[test]
fn ensure_cueloop_gitignore_entries_is_idempotent() -> Result<()> {
let temp = TempDir::new()?;
let repo_root = temp.path();
ensure_cueloop_gitignore_entries(repo_root)?;
ensure_cueloop_gitignore_entries(repo_root)?;
let gitignore_path = repo_root.join(".gitignore");
let content = fs::read_to_string(&gitignore_path)?;
let workspaces_count = content.matches(".cueloop/workspaces/").count();
let logs_count = content.matches(".cueloop/logs/").count();
let cache_count = content.matches(".cueloop/cache/").count();
let trust_count = content.matches(".cueloop/trust.jsonc").count();
assert_eq!(
workspaces_count, 1,
"Should only have one .cueloop/workspaces/ entry"
);
assert_eq!(logs_count, 1, "Should only have one .cueloop/logs/ entry");
assert_eq!(cache_count, 1, "Should only have one .cueloop/cache/ entry");
assert_eq!(
trust_count, 1,
"Should only have one .cueloop/trust.jsonc entry"
);
Ok(())
}
#[test]
fn ensure_cueloop_gitignore_entries_detects_existing_workspaces_entry() -> Result<()> {
let temp = TempDir::new()?;
let repo_root = temp.path();
let gitignore_path = repo_root.join(".gitignore");
fs::write(&gitignore_path, ".cueloop/workspaces/\n")?;
ensure_cueloop_gitignore_entries(repo_root)?;
let content = fs::read_to_string(&gitignore_path)?;
assert!(content.contains(".cueloop/logs/"));
assert!(content.contains(".cueloop/trust.jsonc"));
let workspaces_count = content.matches(".cueloop/workspaces/").count();
assert_eq!(
workspaces_count, 1,
"Should not add duplicate workspaces entry"
);
Ok(())
}
#[test]
fn ensure_cueloop_gitignore_entries_detects_existing_logs_entry() -> Result<()> {
let temp = TempDir::new()?;
let repo_root = temp.path();
let gitignore_path = repo_root.join(".gitignore");
fs::write(&gitignore_path, ".cueloop/logs/\n")?;
ensure_cueloop_gitignore_entries(repo_root)?;
let content = fs::read_to_string(&gitignore_path)?;
assert!(content.contains(".cueloop/workspaces/"));
assert!(content.contains(".cueloop/trust.jsonc"));
let logs_count = content.matches(".cueloop/logs/").count();
assert_eq!(logs_count, 1, "Should not add duplicate logs entry");
Ok(())
}
#[test]
fn ensure_cueloop_gitignore_entries_normalizes_legacy_broad_policy() -> Result<()> {
let temp = TempDir::new()?;
let repo_root = temp.path();
fs::write(
repo_root.join(".gitignore"),
"!.cueloop\n.cueloop/*\n!.cueloop/queue.jsonc\n!.cueloop/done.jsonc\n!.cueloop/config.jsonc\n.ralph/*\n!.ralph/queue.jsonc\n",
)?;
ensure_cueloop_gitignore_entries(repo_root)?;
let content = fs::read_to_string(repo_root.join(".gitignore"))?;
assert!(!content.contains(".cueloop/*"));
assert!(!content.contains("!.cueloop/queue.jsonc"));
assert!(!content.contains(".ralph/*"));
assert!(!content.contains("!.ralph/queue.jsonc"));
assert!(content.contains(".cueloop/cache/"));
assert!(content.contains(".cueloop/logs/"));
assert!(content.contains(".cueloop/trust.jsonc"));
Ok(())
}
#[test]
fn ensure_local_queue_gitignore_entries_adds_queue_and_done_once() -> Result<()> {
let temp = TempDir::new()?;
let repo_root = temp.path();
ensure_local_queue_gitignore_entries(repo_root)?;
ensure_local_queue_gitignore_entries(repo_root)?;
let content = fs::read_to_string(repo_root.join(".gitignore"))?;
assert_eq!(content.matches(".cueloop/queue.jsonc").count(), 1);
assert_eq!(content.matches(".cueloop/done.jsonc").count(), 1);
Ok(())
}
#[test]
fn ensure_cueloop_gitignore_entries_detects_existing_entry_without_trailing_slash() -> Result<()>
{
let temp = TempDir::new()?;
let repo_root = temp.path();
let gitignore_path = repo_root.join(".gitignore");
fs::write(&gitignore_path, ".cueloop/workspaces\n.cueloop/logs\n")?;
ensure_cueloop_gitignore_entries(repo_root)?;
let content = fs::read_to_string(&gitignore_path)?;
let workspaces_count = content
.lines()
.filter(|l| l.contains(".cueloop/workspaces"))
.count();
let logs_count = content
.lines()
.filter(|l| l.contains(".cueloop/logs"))
.count();
assert_eq!(
workspaces_count, 1,
"Should not add duplicate workspaces entry"
);
assert_eq!(logs_count, 1, "Should not add duplicate logs entry");
Ok(())
}
#[test]
fn has_exact_gitignore_entry_matches_variations() {
assert!(has_exact_gitignore_entry(
".cueloop/logs/\n",
".cueloop/logs/"
));
assert!(has_exact_gitignore_entry(
".cueloop/logs\n",
".cueloop/logs/"
));
assert!(has_exact_gitignore_entry(
" .cueloop/logs/ \n",
".cueloop/logs/"
));
assert!(has_exact_gitignore_entry(
" .cueloop/logs \n",
".cueloop/logs/"
));
assert!(!has_exact_gitignore_entry(
".cueloop/logs/debug.log\n",
".cueloop/logs/"
));
assert!(!has_exact_gitignore_entry(
"# .cueloop/logs/\n",
".cueloop/logs/"
));
assert!(!has_exact_gitignore_entry(
"something else\n",
".cueloop/logs/"
));
}
}