From d23db66fdb7c94ff98d0be42b09e53fe6a1d7044 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 21 Feb 2026 21:37:48 +0000 Subject: [PATCH 1/2] feat: add .git-ai-ignore file support for custom ignore patterns Add support for a .git-ai-ignore file at the repository root that allows users to specify additional file patterns to ignore in git-ai tracking. The file follows .gitignore syntax (one glob pattern per line, # comments, blank lines skipped). Patterns from .git-ai-ignore are unioned with: - Default ignore patterns (lockfiles, generated files, etc.) - Linguist-generated patterns from .gitattributes - User-provided --ignore CLI patterns Works for both regular and bare repositories (reads from HEAD for bare). Includes unit tests for: - Loading patterns from workdir and bare repos - Comment/blank line handling - Deduplication - Missing file handling - Union with gitattributes and user patterns Includes integration tests for: - Checkpoint filtering with .git-ai-ignore - Status diff counting with .git-ai-ignore - Union behavior with .gitattributes linguist-generated Co-Authored-By: unknown <> --- src/authorship/ignore.rs | 254 +++++++++++++++++++++++++++++++++++++++ tests/status_ignore.rs | 125 +++++++++++++++++++ 2 files changed, 379 insertions(+) diff --git a/src/authorship/ignore.rs b/src/authorship/ignore.rs index 5ed25ca4e..ddab1533f 100644 --- a/src/authorship/ignore.rs +++ b/src/authorship/ignore.rs @@ -150,6 +150,40 @@ fn load_root_gitattributes_contents(repo: &Repository) -> Option { 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 { + 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 { + 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], @@ -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) @@ -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())); + } } diff --git a/tests/status_ignore.rs b/tests/status_ignore.rs index 73efca6f4..410c6dc30 100644 --- a/tests/status_ignore.rs +++ b/tests/status_ignore.rs @@ -180,3 +180,128 @@ 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); +} From 09050233685d298392e17498791c97460035d53b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 21 Feb 2026 21:39:29 +0000 Subject: [PATCH 2/2] style: fix formatting in status_ignore integration tests Co-Authored-By: unknown <> --- tests/status_ignore.rs | 36 +++++++----------------------------- 1 file changed, 7 insertions(+), 29 deletions(-) diff --git a/tests/status_ignore.rs b/tests/status_ignore.rs index 410c6dc30..2ac294d7d 100644 --- a/tests/status_ignore.rs +++ b/tests/status_ignore.rs @@ -190,19 +190,10 @@ fn test_checkpoint_honors_git_ai_ignore_file() { 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", - ); + 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(); + 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"); @@ -236,19 +227,10 @@ fn test_status_honors_git_ai_ignore_file() { "src/app.ts", "export const app = 1;\nexport const next = 2;\n", ); - write_file( - &repo, - "docs/api.md", - "# API\nendpoint 1\nendpoint 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(); + repo.git_ai(&["checkpoint", "mock_ai", "src/app.ts", "docs/api.md"]) + .unwrap(); let status = status_from_args(&repo, &["status", "--json"]); @@ -281,11 +263,7 @@ fn test_status_git_ai_ignore_union_with_gitattributes() { "generated/out.ts", "export const gen = 1;\nexport const gen2 = 2;\n", ); - write_file( - &repo, - "docs/api.md", - "# API\nendpoint 1\nendpoint 2\n", - ); + write_file(&repo, "docs/api.md", "# API\nendpoint 1\nendpoint 2\n"); repo.git_ai(&[ "checkpoint",