diff --git a/docs/public/api-reference.md b/docs/public/api-reference.md index a1e4efb..1739e68 100644 --- a/docs/public/api-reference.md +++ b/docs/public/api-reference.md @@ -58,6 +58,7 @@ Recursively scan a directory and parse all supported source files. ```rust pub struct ParseOptions { pub include_tests: bool, // Include test files (default: true) + pub respect_gitignore: bool, // Respect .gitignore files (default: true) pub exclude: Vec, // Glob patterns to exclude pub max_file_size: usize, // Maximum file size in bytes (default: 10 MB) } diff --git a/docs/public/cli-reference.md b/docs/public/cli-reference.md index 23b6a07..33471bc 100644 --- a/docs/public/cli-reference.md +++ b/docs/public/cli-reference.md @@ -50,6 +50,9 @@ acb compile ./src --exclude="*test*" --exclude="vendor" # Write coverage report acb compile ./src --coverage-report coverage.json + +# Parse files even if matched by .gitignore +acb compile ./src --no-gitignore ``` | Option | Description | @@ -57,6 +60,7 @@ acb compile ./src --coverage-report coverage.json | `-o, --output ` | Output file path (default: `.acb` in current dir) | | `-e, --exclude ` | Glob patterns to exclude (may be repeated) | | `--include-tests` | Include test files in compilation (default: true) | +| `--no-gitignore` | Disable `.gitignore` filtering during file discovery | | `--coverage-report ` | Write ingestion coverage report JSON | ### `acb info` diff --git a/docs/public/command-surface.md b/docs/public/command-surface.md index 6f786d3..94397b9 100644 --- a/docs/public/command-surface.md +++ b/docs/public/command-surface.md @@ -30,6 +30,7 @@ acb budget acb compile -o graph.acb acb compile --exclude "target" --exclude "node_modules" acb compile --coverage-report coverage.json +acb compile --no-gitignore ``` Common options: @@ -37,6 +38,7 @@ Common options: - `--output ` - `--exclude ` (repeatable) - `--include-tests` +- `--no-gitignore` - `--coverage-report ` ## `acb query` types diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 397ba77..81cabf6 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -134,6 +134,7 @@ pub enum Command { /// acb compile ./src /// acb compile ./src -o myapp.acb /// acb compile ./src --exclude="*test*" --exclude="vendor" + /// acb compile ./src --no-gitignore #[command(alias = "build")] Compile { /// Path to the source directory to compile. @@ -151,6 +152,10 @@ pub enum Command { #[arg(long, default_value_t = true)] include_tests: bool, + /// Disable `.gitignore` filtering during file discovery. + #[arg(long)] + no_gitignore: bool, + /// Write ingestion coverage report JSON to this path. #[arg(long)] coverage_report: Option, @@ -415,12 +420,14 @@ pub fn run(cli: Cli) -> Result<(), Box> { output, exclude, include_tests, + no_gitignore, coverage_report, }) => cmd_compile( path, output.as_deref(), exclude, *include_tests, + *no_gitignore, coverage_report.as_deref(), &cli, ), @@ -1096,6 +1103,7 @@ fn cmd_compile( output: Option<&std::path::Path>, exclude: &[String], include_tests: bool, + no_gitignore: bool, coverage_report: Option<&Path>, cli: &Cli, ) -> Result<(), Box> { @@ -1134,6 +1142,7 @@ fn cmd_compile( // Build parse options. let mut opts = ParseOptions { include_tests, + respect_gitignore: !no_gitignore, ..ParseOptions::default() }; for pat in exclude { diff --git a/src/parse/parser.rs b/src/parse/parser.rs index ac69fb8..3b6910e 100644 --- a/src/parse/parser.rs +++ b/src/parse/parser.rs @@ -29,6 +29,8 @@ pub struct ParseOptions { pub exclude: Vec, /// Include test files. pub include_tests: bool, + /// Respect `.gitignore` files during file discovery. + pub respect_gitignore: bool, /// Maximum file size to parse (bytes). pub max_file_size: usize, } @@ -48,6 +50,7 @@ impl Default for ParseOptions { "**/build/**".into(), ], include_tests: true, + respect_gitignore: true, max_file_size: 10 * 1024 * 1024, // 10MB } } @@ -285,7 +288,10 @@ impl Parser { let mut files = Vec::new(); let mut coverage = ParseCoverageStats::default(); - let walker = WalkBuilder::new(root).hidden(true).git_ignore(true).build(); + let walker = WalkBuilder::new(root) + .hidden(true) + .git_ignore(options.respect_gitignore) + .build(); for entry in walker { let entry = match entry { diff --git a/tests/phase2_parsing.rs b/tests/phase2_parsing.rs index 6cad379..e05d947 100644 --- a/tests/phase2_parsing.rs +++ b/tests/phase2_parsing.rs @@ -6,6 +6,7 @@ use std::path::Path; use agentic_codebase::parse::{ParseOptions, Parser}; use agentic_codebase::types::{CodeUnitType, Language, Visibility}; +use tempfile::TempDir; // ============================================================ // Helper functions @@ -25,6 +26,19 @@ fn parse_test_file(relative: &str) -> Vec parser.parse_file(&path, &content).expect("Parse failed") } +fn create_gitignore_fixture() -> TempDir { + let dir = TempDir::new().expect("tempdir"); + std::fs::create_dir_all(dir.path().join(".git").join("info")).expect("create .git/info"); + std::fs::create_dir_all(dir.path().join("ignored")).expect("create ignored dir"); + + std::fs::write(dir.path().join(".gitignore"), "ignored/\n").expect("write .gitignore"); + std::fs::write(dir.path().join("main.rs"), "pub fn root() {}\n").expect("write main.rs"); + std::fs::write(dir.path().join("ignored").join("extra.rs"), "pub fn extra() {}\n") + .expect("write ignored file"); + + dir +} + fn find_unit_by_name<'a>( units: &'a [agentic_codebase::parse::RawCodeUnit], name: &str, @@ -77,6 +91,7 @@ fn test_parse_options_default() { let opts = ParseOptions::default(); assert!(opts.languages.is_empty()); assert!(opts.include_tests); + assert!(opts.respect_gitignore); assert_eq!(opts.max_file_size, 10 * 1024 * 1024); assert!(!opts.exclude.is_empty()); assert!(opts.exclude.iter().any(|e| e.contains("node_modules"))); @@ -841,6 +856,38 @@ fn test_parse_directory_exclude_tests() { assert!(result.stats.files_skipped > 0 || result.stats.files_parsed > 0); } +#[test] +fn test_parse_directory_respect_gitignore_toggle() { + let parser = Parser::new(); + let fixture = create_gitignore_fixture(); + + let default_opts = ParseOptions::default(); + let default_result = parser + .parse_directory(fixture.path(), &default_opts) + .expect("parse_directory with gitignore failed"); + assert_eq!(default_result.stats.files_parsed, 1); + assert_eq!(default_result.stats.coverage.files_candidate, 1); + + let no_gitignore_opts = ParseOptions { + respect_gitignore: false, + ..Default::default() + }; + let no_gitignore_result = parser + .parse_directory(fixture.path(), &no_gitignore_opts) + .expect("parse_directory without gitignore failed"); + + assert!( + no_gitignore_result.stats.coverage.files_seen >= default_result.stats.coverage.files_seen, + "disabling gitignore should not reduce files_seen" + ); + assert!( + no_gitignore_result.stats.coverage.files_candidate + > default_result.stats.coverage.files_candidate, + "disabling gitignore should include ignored candidates" + ); + assert_eq!(no_gitignore_result.stats.files_parsed, 2); +} + #[test] fn test_parse_directory_stats() { let parser = Parser::new(); diff --git a/tests/phase6_cli.rs b/tests/phase6_cli.rs index 54befa5..602dc52 100644 --- a/tests/phase6_cli.rs +++ b/tests/phase6_cli.rs @@ -45,6 +45,33 @@ mod tests { dir } +fn create_gitignore_fixture_dir() -> TempDir { + let dir = TempDir::new().unwrap(); + fs::create_dir_all(dir.path().join(".git").join("info")).unwrap(); + fs::create_dir_all(dir.path().join("ignored")).unwrap(); + + fs::write(dir.path().join(".gitignore"), "ignored/\n").unwrap(); + fs::write(dir.path().join("main.rs"), "pub fn root() {}\n").unwrap(); + fs::write(dir.path().join("ignored").join("extra.rs"), "pub fn extra() {}\n").unwrap(); + + dir +} + +fn read_coverage_counts(path: &std::path::Path) -> (u64, u64) { + let raw = fs::read_to_string(path).unwrap(); + let payload: serde_json::Value = serde_json::from_str(&raw).unwrap(); + let coverage = payload.get("coverage").expect("coverage object"); + let files_seen = coverage + .get("files_seen") + .and_then(|v| v.as_u64()) + .expect("files_seen"); + let files_candidate = coverage + .get("files_candidate") + .and_then(|v| v.as_u64()) + .expect("files_candidate"); + (files_seen, files_candidate) +} + /// Compile a sample directory and return the path to the .acb output. fn compile_sample() -> (TempDir, PathBuf) { let src_dir = create_sample_rust_dir(); @@ -160,6 +187,64 @@ fn test_cli_compile_with_exclude() { assert!(acb_path.exists()); } +#[test] +fn test_cli_compile_no_gitignore() { + let src_dir = create_gitignore_fixture_dir(); + let out_dir = TempDir::new().unwrap(); + + let default_acb = out_dir.path().join("default.acb"); + let no_gitignore_acb = out_dir.path().join("no-gitignore.acb"); + let default_cov = out_dir.path().join("default-coverage.json"); + let no_gitignore_cov = out_dir.path().join("no-gitignore-coverage.json"); + + let default_output = Command::new(acb_bin()) + .args([ + "compile", + src_dir.path().to_str().unwrap(), + "-o", + default_acb.to_str().unwrap(), + "--coverage-report", + default_cov.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!( + default_output.status.success(), + "compile default failed: {}", + String::from_utf8_lossy(&default_output.stderr) + ); + + let no_gitignore_output = Command::new(acb_bin()) + .args([ + "compile", + src_dir.path().to_str().unwrap(), + "-o", + no_gitignore_acb.to_str().unwrap(), + "--no-gitignore", + "--coverage-report", + no_gitignore_cov.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!( + no_gitignore_output.status.success(), + "compile --no-gitignore failed: {}", + String::from_utf8_lossy(&no_gitignore_output.stderr) + ); + + let (default_seen, default_candidate) = read_coverage_counts(&default_cov); + let (no_gitignore_seen, no_gitignore_candidate) = read_coverage_counts(&no_gitignore_cov); + + assert!( + no_gitignore_seen >= default_seen, + "--no-gitignore should not reduce files_seen (default={default_seen}, no_gitignore={no_gitignore_seen})" + ); + assert!( + no_gitignore_candidate > default_candidate, + "--no-gitignore should include ignored candidates (default={default_candidate}, no_gitignore={no_gitignore_candidate})" + ); +} + #[test] fn test_cli_compile_output_flag() { let src_dir = create_sample_rust_dir();