Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
254 changes: 254 additions & 0 deletions src/authorship/ignore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,40 @@ fn load_root_gitattributes_contents(repo: &Repository) -> Option<String> {
fs::read_to_string(gitattributes_path).ok()
}

/// Load ignore patterns from a `.git-ai-ignore` file at the repository root.
/// The file follows `.gitignore` syntax: one glob pattern per line, blank lines
/// and lines starting with `#` are skipped.
pub fn load_git_ai_ignore_patterns(repo: &Repository) -> Vec<String> {
let Some(contents) = load_root_git_ai_ignore_contents(repo) else {
return Vec::new();
};

let mut patterns = Vec::new();

for raw_line in contents.lines() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
patterns.push(line.to_string());
}

dedupe_patterns(patterns)
}

fn load_root_git_ai_ignore_contents(repo: &Repository) -> Option<String> {
if repo.is_bare_repository().unwrap_or(false) {
return repo
.get_file_content(".git-ai-ignore", "HEAD")
.ok()
.and_then(|bytes| String::from_utf8(bytes).ok());
}

let workdir = repo.workdir().ok()?;
let ignore_path = workdir.join(".git-ai-ignore");
fs::read_to_string(ignore_path).ok()
}

pub fn effective_ignore_patterns(
repo: &Repository,
user_patterns: &[String],
Expand All @@ -159,6 +193,7 @@ pub fn effective_ignore_patterns(
patterns.extend(load_linguist_generated_patterns_from_root_gitattributes(
repo,
));
patterns.extend(load_git_ai_ignore_patterns(repo));
patterns.extend(extra_patterns.iter().cloned());
patterns.extend(user_patterns.iter().cloned());
dedupe_patterns(patterns)
Expand Down Expand Up @@ -431,4 +466,223 @@ generated/** linguist-generated=true
let patterns = load_linguist_generated_patterns_from_root_gitattributes(&bare_repo);
assert!(patterns.is_empty());
}

#[test]
fn loads_git_ai_ignore_patterns_from_workdir() {
let tmp_repo = TmpRepo::new().expect("tmp repo");
tmp_repo
.write_file(
".git-ai-ignore",
"\
# This is a comment
docs/**
*.pdf

assets/images/**
",
true,
)
.expect("write .git-ai-ignore");
tmp_repo
.commit_with_message("add .git-ai-ignore")
.expect("commit");

let patterns = load_git_ai_ignore_patterns(tmp_repo.gitai_repo());
assert_eq!(patterns.len(), 3);
assert!(patterns.contains(&"docs/**".to_string()));
assert!(patterns.contains(&"*.pdf".to_string()));
assert!(patterns.contains(&"assets/images/**".to_string()));
}

#[test]
fn git_ai_ignore_skips_comments_and_blank_lines() {
let tmp_repo = TmpRepo::new().expect("tmp repo");
tmp_repo
.write_file(
".git-ai-ignore",
"\
# comment line
# indented comment

*.log
build/**
",
true,
)
.expect("write .git-ai-ignore");
tmp_repo
.commit_with_message("add .git-ai-ignore")
.expect("commit");

let patterns = load_git_ai_ignore_patterns(tmp_repo.gitai_repo());
assert_eq!(patterns.len(), 2);
assert!(patterns.contains(&"*.log".to_string()));
assert!(patterns.contains(&"build/**".to_string()));
}

#[test]
fn git_ai_ignore_deduplicates_patterns() {
let tmp_repo = TmpRepo::new().expect("tmp repo");
tmp_repo
.write_file(
".git-ai-ignore",
"\
*.pdf
docs/**
*.pdf
",
true,
)
.expect("write .git-ai-ignore");
tmp_repo
.commit_with_message("add .git-ai-ignore")
.expect("commit");

let patterns = load_git_ai_ignore_patterns(tmp_repo.gitai_repo());
assert_eq!(patterns.len(), 2);
}

#[test]
fn git_ai_ignore_returns_empty_when_file_missing() {
let tmp_repo = TmpRepo::new().expect("tmp repo");
tmp_repo
.write_file("README.md", "# repo\n", true)
.expect("write readme");
tmp_repo.commit_with_message("initial").expect("commit");

let patterns = load_git_ai_ignore_patterns(tmp_repo.gitai_repo());
assert!(patterns.is_empty());
}

#[test]
fn effective_patterns_include_git_ai_ignore() {
let tmp_repo = TmpRepo::new().expect("tmp repo");
tmp_repo
.write_file(".git-ai-ignore", "custom/**\n*.secret\n", true)
.expect("write .git-ai-ignore");
tmp_repo
.commit_with_message("add .git-ai-ignore")
.expect("commit");

let patterns = effective_ignore_patterns(tmp_repo.gitai_repo(), &[], &[]);
assert!(patterns.contains(&"custom/**".to_string()));
assert!(patterns.contains(&"*.secret".to_string()));
// Default patterns should still be present
assert!(patterns.contains(&"*.lock".to_string()));
}

#[test]
fn effective_patterns_union_gitattributes_and_git_ai_ignore() {
let tmp_repo = TmpRepo::new().expect("tmp repo");
tmp_repo
.write_file(
".gitattributes",
"generated/** linguist-generated=true\n",
true,
)
.expect("write .gitattributes");
tmp_repo
.write_file(".git-ai-ignore", "docs/**\n", true)
.expect("write .git-ai-ignore");
tmp_repo
.commit_with_message("add gitattributes and git-ai-ignore")
.expect("commit");

let patterns = effective_ignore_patterns(tmp_repo.gitai_repo(), &[], &[]);
// From .gitattributes linguist-generated
assert!(patterns.contains(&"generated/**".to_string()));
// From .git-ai-ignore
assert!(patterns.contains(&"docs/**".to_string()));
// Defaults
assert!(patterns.contains(&"*.lock".to_string()));
}

#[test]
fn effective_patterns_union_git_ai_ignore_and_user_patterns() {
let tmp_repo = TmpRepo::new().expect("tmp repo");
tmp_repo
.write_file(".git-ai-ignore", "docs/**\n", true)
.expect("write .git-ai-ignore");
tmp_repo
.commit_with_message("add .git-ai-ignore")
.expect("commit");

let user = vec!["tests/**".to_string()];
let patterns = effective_ignore_patterns(tmp_repo.gitai_repo(), &user, &[]);
// From .git-ai-ignore
assert!(patterns.contains(&"docs/**".to_string()));
// From user --ignore flag
assert!(patterns.contains(&"tests/**".to_string()));
// Defaults
assert!(patterns.contains(&"*.lock".to_string()));
}

fn make_bare_repo_with_ignore(
root_gitattributes: Option<&str>,
git_ai_ignore: Option<&str>,
) -> (tempfile::TempDir, Repository) {
let temp = tempfile::tempdir().expect("tempdir");
let source = temp.path().join("source");
let bare = temp.path().join("bare.git");
fs::create_dir_all(&source).expect("create source");

run_git(&source, &["init"]);
run_git(&source, &["config", "user.name", "Test User"]);
run_git(&source, &["config", "user.email", "test@example.com"]);

fs::write(source.join("README.md"), "# repo\n").expect("write readme");
if let Some(attrs) = root_gitattributes {
fs::write(source.join(".gitattributes"), attrs).expect("write attrs");
}
if let Some(ignore) = git_ai_ignore {
fs::write(source.join(".git-ai-ignore"), ignore).expect("write .git-ai-ignore");
}

run_git(&source, &["add", "."]);
run_git(&source, &["commit", "-m", "initial"]);
run_git(
temp.path(),
&[
"clone",
"--bare",
source.to_str().unwrap(),
bare.to_str().unwrap(),
],
);

(
temp,
from_bare_repository(&bare).expect("bare repository should load"),
)
}

#[test]
fn loads_git_ai_ignore_from_bare_repo_head() {
let (_tmp, bare_repo) = make_bare_repo_with_ignore(None, Some("docs/**\n*.pdf\n"));

let patterns = load_git_ai_ignore_patterns(&bare_repo);
assert!(patterns.contains(&"docs/**".to_string()));
assert!(patterns.contains(&"*.pdf".to_string()));
}

#[test]
fn bare_repo_returns_empty_when_git_ai_ignore_missing() {
let (_tmp, bare_repo) = make_bare_repo_with_ignore(None, None);

let patterns = load_git_ai_ignore_patterns(&bare_repo);
assert!(patterns.is_empty());
}

#[test]
fn bare_repo_effective_patterns_union_gitattributes_and_git_ai_ignore() {
let (_tmp, bare_repo) = make_bare_repo_with_ignore(
Some("generated/** linguist-generated=true\n"),
Some("docs/**\n"),
);

let patterns = effective_ignore_patterns(&bare_repo, &[], &[]);
assert!(patterns.contains(&"generated/**".to_string()));
assert!(patterns.contains(&"docs/**".to_string()));
assert!(patterns.contains(&"*.lock".to_string()));
}
}
103 changes: 103 additions & 0 deletions tests/status_ignore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,106 @@ fn test_status_with_only_ignored_changes_reports_zero_diff() {
assert_eq!(status.stats.git_diff_deleted_lines, 0);
assert_eq!(status.stats.ai_accepted, 0);
}

#[test]
fn test_checkpoint_honors_git_ai_ignore_file() {
let repo = TestRepo::new();

write_file(&repo, "src/main.rs", "fn main() {}\n");
repo.stage_all_and_commit("initial").unwrap();

write_file(&repo, ".git-ai-ignore", "docs/**\n");
write_file(&repo, "src/main.rs", "fn main() {}\nfn added() {}\n");
write_file(&repo, "docs/guide.md", "# Guide\nLine 1\nLine 2\n");

repo.git_ai(&["checkpoint", "mock_ai", "src/main.rs", "docs/guide.md"])
.unwrap();

let checkpoints = repo.current_working_logs().read_all_checkpoints().unwrap();
let latest = checkpoints.last().expect("checkpoint should be present");

assert!(
latest
.entries
.iter()
.any(|entry| entry.file == "src/main.rs"),
"Expected regular source file to be checkpointed"
);
assert!(
latest
.entries
.iter()
.all(|entry| entry.file != "docs/guide.md"),
"Expected .git-ai-ignore pattern to filter out docs/guide.md"
);
}

#[test]
fn test_status_honors_git_ai_ignore_file() {
let repo = TestRepo::new();

write_file(&repo, "src/app.ts", "export const app = 1;\n");
repo.stage_all_and_commit("initial").unwrap();

write_file(&repo, ".git-ai-ignore", "docs/**\n");
write_file(
&repo,
"src/app.ts",
"export const app = 1;\nexport const next = 2;\n",
);
write_file(&repo, "docs/api.md", "# API\nendpoint 1\nendpoint 2\n");

repo.git_ai(&["checkpoint", "mock_ai", "src/app.ts", "docs/api.md"])
.unwrap();

let status = status_from_args(&repo, &["status", "--json"]);

assert_eq!(status.stats.git_diff_added_lines, 1);
assert_eq!(status.stats.git_diff_deleted_lines, 0);
assert_eq!(status.stats.ai_accepted, 1);
}

#[test]
fn test_status_git_ai_ignore_union_with_gitattributes() {
let repo = TestRepo::new();

write_file(&repo, "src/app.ts", "export const app = 1;\n");
repo.stage_all_and_commit("initial").unwrap();

// Set up both .gitattributes and .git-ai-ignore
write_file(
&repo,
".gitattributes",
"generated/** linguist-generated=true\n",
);
write_file(&repo, ".git-ai-ignore", "docs/**\n");
write_file(
&repo,
"src/app.ts",
"export const app = 1;\nexport const next = 2;\n",
);
write_file(
&repo,
"generated/out.ts",
"export const gen = 1;\nexport const gen2 = 2;\n",
);
write_file(&repo, "docs/api.md", "# API\nendpoint 1\nendpoint 2\n");

repo.git_ai(&[
"checkpoint",
"mock_ai",
"src/app.ts",
"generated/out.ts",
"docs/api.md",
])
.unwrap();

let status = status_from_args(&repo, &["status", "--json"]);

// Only src/app.ts addition should be counted (1 line)
// generated/out.ts ignored by .gitattributes linguist-generated
// docs/api.md ignored by .git-ai-ignore
assert_eq!(status.stats.git_diff_added_lines, 1);
assert_eq!(status.stats.git_diff_deleted_lines, 0);
assert_eq!(status.stats.ai_accepted, 1);
}
Loading