use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use ignore::gitignore::GitignoreBuilder;
pub(crate) fn local_pattern(root: &Path, target: &Path) -> Option<String> {
let rel = target.strip_prefix(root).ok()?;
let rel = rel.to_string_lossy().replace('\\', "/");
match rel.as_str() {
"CLAUDE.local.md" | "AGENTS.local.md" | ".claude/settings.local.json" => Some(rel),
_ if rel.starts_with(".ai-rulez/local/") => Some(".ai-rulez/local/".to_string()),
_ => None,
}
}
pub(crate) fn is_covered(root: &Path, target: &Path) -> Result<bool> {
let mut builder = GitignoreBuilder::new(root);
for dir in ancestor_dirs(root, target) {
let candidate = dir.join(".gitignore");
if candidate.is_file()
&& let Some(err) = builder.add(&candidate)
{
return Err(anyhow::Error::new(err).context(format!("parse {}", candidate.display())));
}
}
let matcher = builder.build().context("compile gitignore matcher")?;
Ok(matcher.matched(target, false).is_ignore())
}
pub(crate) fn ensure_coverage(root: &Path, target: &Path, pattern: &str, assume_yes: bool) -> Result<Option<PathBuf>> {
if is_covered(root, target)? {
return Ok(None);
}
let should_add = assume_yes || !std::io::stdin().is_terminal() || prompt_add_to_gitignore(pattern)?;
if !should_add {
return Ok(None);
}
let gitignore_path = root.join(".gitignore");
append_pattern(&gitignore_path, pattern)?;
Ok(Some(gitignore_path))
}
fn prompt_add_to_gitignore(pattern: &str) -> Result<bool> {
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
write!(stdout, "add {pattern:?} to .gitignore? [Y/n] ").context("write prompt")?;
stdout.flush().context("flush prompt")?;
let mut line = String::new();
stdin.read_line(&mut line).context("read stdin")?;
let answer = line.trim().to_ascii_lowercase();
Ok(answer.is_empty() || answer == "y" || answer == "yes")
}
fn append_pattern(gitignore_path: &Path, pattern: &str) -> Result<()> {
let existing = match std::fs::read_to_string(gitignore_path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(anyhow::Error::new(e).context(format!("read {}", gitignore_path.display()))),
};
if existing.lines().any(|l| l.trim() == pattern) {
return Ok(());
}
let mut out = existing;
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out.push_str(pattern);
out.push('\n');
std::fs::write(gitignore_path, out).with_context(|| format!("write {}", gitignore_path.display()))
}
fn ancestor_dirs(root: &Path, target: &Path) -> Vec<PathBuf> {
let mut dirs = vec![root.to_path_buf()];
let Ok(rel) = target.strip_prefix(root) else {
return dirs;
};
let mut cur = root.to_path_buf();
if let Some(parent) = rel.parent() {
for comp in parent.components() {
cur = cur.join(comp);
dirs.push(cur.clone());
}
}
dirs
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_pattern_covers_known_local_targets_and_ignores_committed_ones() {
let root = Path::new("/repo");
assert_eq!(
local_pattern(root, &root.join("CLAUDE.local.md")),
Some("CLAUDE.local.md".to_string())
);
assert_eq!(
local_pattern(root, &root.join("AGENTS.local.md")),
Some("AGENTS.local.md".to_string())
);
assert_eq!(
local_pattern(root, &root.join(".claude").join("settings.local.json")),
Some(".claude/settings.local.json".to_string())
);
assert_eq!(
local_pattern(
root,
&root
.join(".ai-rulez")
.join("local")
.join("rules")
.join("basemind-usage.md")
),
Some(".ai-rulez/local/".to_string())
);
assert_eq!(
local_pattern(root, &root.join("CLAUDE.md")),
None,
"committed file untouched"
);
assert_eq!(
local_pattern(root, &root.join(".ai-rulez").join("rules").join("basemind-usage.md")),
None,
"committed ai-rulez rule untouched"
);
}
#[test]
fn ensure_coverage_appends_a_missing_pattern_and_is_idempotent() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
let target = root.join("CLAUDE.local.md");
assert!(
!is_covered(root, &target).expect("check coverage"),
"nothing covers it yet"
);
let appended = ensure_coverage(root, &target, "CLAUDE.local.md", true)
.expect("ensure coverage")
.expect("a pattern was appended");
assert_eq!(appended, root.join(".gitignore"));
let contents = std::fs::read_to_string(root.join(".gitignore")).expect("read .gitignore");
assert_eq!(contents, "CLAUDE.local.md\n");
assert!(is_covered(root, &target).expect("check coverage"), "now covered");
let second = ensure_coverage(root, &target, "CLAUDE.local.md", true).expect("second run");
assert!(second.is_none(), "already covered — nothing to append");
let contents_after = std::fs::read_to_string(root.join(".gitignore")).expect("read .gitignore");
assert_eq!(contents_after, contents, "no duplicate pattern written");
}
#[test]
fn ensure_coverage_is_a_no_op_when_already_covered_by_an_existing_gitignore() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
std::fs::write(root.join(".gitignore"), "# comment\nCLAUDE.local.md\nother.txt\n").expect("seed .gitignore");
let target = root.join("CLAUDE.local.md");
assert!(is_covered(root, &target).expect("check coverage"));
let result = ensure_coverage(root, &target, "CLAUDE.local.md", true).expect("ensure coverage");
assert!(result.is_none(), "already covered — nothing to append");
let contents = std::fs::read_to_string(root.join(".gitignore")).expect("read .gitignore");
assert_eq!(
contents, "# comment\nCLAUDE.local.md\nother.txt\n",
"existing file preserved verbatim"
);
}
#[test]
fn ensure_coverage_respects_a_glob_pattern_in_a_nested_gitignore() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
std::fs::create_dir_all(root.join(".claude")).expect("mkdir .claude");
std::fs::write(root.join(".claude").join(".gitignore"), "settings.local.json\n")
.expect("seed nested gitignore");
let target = root.join(".claude").join("settings.local.json");
assert!(
is_covered(root, &target).expect("check coverage"),
"a nested .gitignore must be honored, not just the root one"
);
let result = ensure_coverage(root, &target, ".claude/settings.local.json", true).expect("ensure coverage");
assert!(result.is_none(), "already covered by the nested file");
assert!(
!root.join(".gitignore").exists(),
"no root .gitignore should be created"
);
}
}