Skip to content
Closed
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
123 changes: 123 additions & 0 deletions src/authorship/ignore_patterns.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
use std::fs;
use std::path::Path;

use crate::config::Config;
use crate::git::repository::Repository;
use crate::utils::debug_log;

/// Load ignore patterns from various sources and merge with CLI args
/// Priority order: global config > project file > CLI args (all are additive)
///
/// Sources:
/// 1. Global config: ~/.git-ai/config.json (stats_ignore_patterns field)
/// 2. Project file: .git-ai-ignore in repo root
/// 3. CLI args: --ignore patterns passed by user
pub fn load_ignore_patterns_from_files(repo: &Repository) -> Vec<String> {
let mut patterns = Vec::new();

// 1. Load from global config (~/.git-ai/config.json)
let global_config_patterns = Config::get().stats_ignore_patterns();
if !global_config_patterns.is_empty() {
debug_log(&format!(
"Loaded {} patterns from ~/.git-ai/config.json",
global_config_patterns.len()
));
patterns.extend_from_slice(global_config_patterns);
}

// 2. Load project ignore (.git-ai-ignore in workdir root)
if let Some(project_patterns) = load_project_ignore(repo) {
debug_log(&format!(
"Loaded {} patterns from .git-ai-ignore",
project_patterns.len()
));
patterns.extend(project_patterns);
}

patterns
}

/// Load project ignore from .git-ai-ignore in workdir root
fn load_project_ignore(repo: &Repository) -> Option<Vec<String>> {
let workdir = repo.workdir().ok()?;
let path = workdir.join(".git-ai-ignore");
read_ignore_file(&path)
}

/// Read ignore file and parse patterns
/// Supports comments (#) and blank lines
fn read_ignore_file(path: &Path) -> Option<Vec<String>> {
if !path.exists() {
return None;
}

let content = match fs::read_to_string(path) {
Ok(content) => content,
Err(e) => {
debug_log(&format!("Failed to read ignore file {:?}: {}", path, e));
return None;
}
};

let patterns: Vec<String> = content
.lines()
.map(|line| line.trim())
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(|line| line.to_string())
.collect();

if patterns.is_empty() {
None
} else {
Some(patterns)
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_read_ignore_file_with_comments() {
use std::io::Write;
let temp_dir = tempfile::tempdir().unwrap();
let ignore_file = temp_dir.path().join("test-ignore");

let mut file = fs::File::create(&ignore_file).unwrap();
writeln!(file, "# This is a comment").unwrap();
writeln!(file, "").unwrap(); // blank line
writeln!(file, "*.lock").unwrap();
writeln!(file, "dist/**").unwrap();
writeln!(file, " # Another comment ").unwrap();
writeln!(file, " *.min.js ").unwrap(); // with spaces
drop(file);

let patterns = read_ignore_file(&ignore_file).unwrap();
assert_eq!(patterns.len(), 3);
assert_eq!(patterns[0], "*.lock");
assert_eq!(patterns[1], "dist/**");
assert_eq!(patterns[2], "*.min.js");
}

#[test]
fn test_read_ignore_file_empty() {
use std::io::Write;
let temp_dir = tempfile::tempdir().unwrap();
let ignore_file = temp_dir.path().join("empty-ignore");

let mut file = fs::File::create(&ignore_file).unwrap();
writeln!(file, "# Only comments").unwrap();
writeln!(file, "").unwrap();
writeln!(file, " ").unwrap();
drop(file);

let patterns = read_ignore_file(&ignore_file);
assert!(patterns.is_none());
}

#[test]
fn test_read_ignore_file_nonexistent() {
let patterns = read_ignore_file(Path::new("/nonexistent/path/ignore"));
assert!(patterns.is_none());
}
}
1 change: 1 addition & 0 deletions src/authorship/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub mod attribution_tracker;
pub mod authorship_log;
pub mod authorship_log_serialization;
pub mod diff_ai_accepted;
pub mod ignore_patterns;
pub mod imara_diff_utils;
pub mod internal_db;
pub mod move_detection;
Expand Down
12 changes: 12 additions & 0 deletions src/commands/git_ai_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -946,6 +946,18 @@ fn handle_stats(args: &[String]) {
}
}

// Merge ignore patterns from config files and CLI
// Priority: global config > project file > CLI args (all additive)
let file_patterns = crate::authorship::ignore_patterns::load_ignore_patterns_from_files(&repo);
let mut all_ignore_patterns = file_patterns;
all_ignore_patterns.extend(ignore_patterns);
let ignore_patterns = all_ignore_patterns;

crate::utils::debug_log(&format!(
"Total ignore patterns loaded: {}",
ignore_patterns.len()
));

// Handle commit range if detected
if let Some(range) = commit_range {
match range_authorship::range_authorship(range, false, &ignore_patterns) {
Expand Down
17 changes: 17 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub struct Config {
prompt_storage: String,
api_key: Option<String>,
quiet: bool,
stats_ignore_patterns: Vec<String>,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
Expand Down Expand Up @@ -98,6 +99,8 @@ pub struct FileConfig {
pub api_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quiet: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stats_ignore_patterns: Option<Vec<String>>,
}

static CONFIG: OnceLock<Config> = OnceLock::new();
Expand Down Expand Up @@ -244,6 +247,10 @@ impl Config {
self.update_channel
}

pub fn stats_ignore_patterns(&self) -> &[String] {
&self.stats_ignore_patterns
}

pub fn feature_flags(&self) -> &FeatureFlags {
&self.feature_flags
}
Expand Down Expand Up @@ -437,6 +444,12 @@ fn build_config() -> Config {
.and_then(|c| c.quiet)
.unwrap_or(false);

// Get stats ignore patterns (defaults to empty vec)
let stats_ignore_patterns = file_cfg
.as_ref()
.and_then(|c| c.stats_ignore_patterns.clone())
.unwrap_or_else(Vec::new);

#[cfg(any(test, feature = "test-support"))]
{
let mut config = Config {
Expand All @@ -454,6 +467,7 @@ fn build_config() -> Config {
prompt_storage,
api_key,
quiet,
stats_ignore_patterns,
};
apply_test_config_patch(&mut config);
config
Expand All @@ -475,6 +489,7 @@ fn build_config() -> Config {
prompt_storage,
api_key,
quiet,
stats_ignore_patterns,
}
}

Expand Down Expand Up @@ -743,6 +758,7 @@ mod tests {
prompt_storage: "default".to_string(),
api_key: None,
quiet: false,
stats_ignore_patterns: vec![],
}
}

Expand Down Expand Up @@ -848,6 +864,7 @@ mod tests {
prompt_storage: "default".to_string(),
api_key: None,
quiet: false,
stats_ignore_patterns: vec![],
}
}

Expand Down