Skip to main content

perfgate_app/
discover.rs

1//! Auto-discovery of benchmarks in a repository.
2//!
3//! Scans a project directory for common benchmark frameworks and returns
4//! a list of discovered benchmarks with metadata about framework, language,
5//! and suggested command to run them.
6
7use serde::{Deserialize, Serialize};
8use std::fs;
9use std::path::Path;
10
11/// A benchmark discovered by scanning the repository.
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13pub struct DiscoveredBenchmark {
14    /// Human-readable name for the benchmark.
15    pub name: String,
16    /// Framework that was detected (e.g. "criterion", "go-bench", "pytest-benchmark").
17    pub framework: String,
18    /// Suggested command to run this benchmark.
19    pub command: String,
20    /// Path to the file or directory where this benchmark was found (relative to scan root).
21    pub path: String,
22    /// Programming language.
23    pub language: String,
24    /// Confidence level: "high", "medium", or "low".
25    pub confidence: String,
26}
27
28/// Orchestrates all framework-specific scanners and returns the combined results.
29pub fn discover_all(root: &Path) -> Vec<DiscoveredBenchmark> {
30    let mut results = Vec::new();
31    results.extend(scan_rust_criterion(root));
32    results.extend(scan_go_benchmarks(root));
33    results.extend(scan_python_pytest_benchmark(root));
34    results.extend(scan_javascript_benchmark(root));
35    results.extend(scan_custom_directories(root));
36    results.sort_by(|a, b| a.name.cmp(&b.name));
37    results
38}
39
40// ---------------------------------------------------------------------------
41// Rust / Criterion
42// ---------------------------------------------------------------------------
43
44/// Scan for Rust/Criterion benchmarks by looking for `[[bench]]` targets in
45/// `Cargo.toml` and `criterion_group!` macros in `benches/`.
46fn scan_rust_criterion(root: &Path) -> Vec<DiscoveredBenchmark> {
47    let mut results = Vec::new();
48
49    // Strategy 1: Parse Cargo.toml for [[bench]] targets
50    let cargo_toml = root.join("Cargo.toml");
51    if cargo_toml.is_file()
52        && let Ok(content) = fs::read_to_string(&cargo_toml)
53    {
54        results.extend(parse_cargo_bench_targets(&content, root));
55    }
56
57    // Strategy 2: Scan benches/ directory for criterion_group! macros
58    let benches_dir = root.join("benches");
59    if benches_dir.is_dir() {
60        results.extend(scan_dir_for_criterion(&benches_dir, root));
61    }
62
63    // Deduplicate by name (Cargo.toml targets take precedence)
64    let mut seen = std::collections::HashSet::new();
65    results.retain(|b| seen.insert(b.name.clone()));
66    results
67}
68
69/// Parse `[[bench]]` entries from a Cargo.toml string.
70fn parse_cargo_bench_targets(content: &str, root: &Path) -> Vec<DiscoveredBenchmark> {
71    let mut results = Vec::new();
72
73    // Simple line-based parser for [[bench]] sections.
74    // We look for `[[bench]]` headers and then `name = "..."` lines.
75    let lines: Vec<&str> = content.lines().collect();
76    let mut i = 0;
77    while i < lines.len() {
78        let trimmed = lines[i].trim();
79        if trimmed == "[[bench]]" {
80            let mut name = None;
81            let mut harness = true;
82            let mut j = i + 1;
83            while j < lines.len() {
84                let ltrimmed = lines[j].trim();
85                if ltrimmed.starts_with('[') {
86                    break;
87                }
88                if let Some(val) = extract_toml_string_value(ltrimmed, "name") {
89                    name = Some(val);
90                }
91                if ltrimmed.starts_with("harness") && ltrimmed.contains("false") {
92                    harness = false;
93                }
94                j += 1;
95            }
96            if let Some(bench_name) = name {
97                let confidence = if harness { "medium" } else { "high" };
98                let framework = if harness { "rust-bench" } else { "criterion" };
99                let command = format!("cargo bench --bench {bench_name}");
100
101                // Determine path
102                let bench_path = root.join("benches").join(format!("{bench_name}.rs"));
103                let rel_path = if bench_path.exists() {
104                    format!("benches/{bench_name}.rs")
105                } else {
106                    "Cargo.toml".to_string()
107                };
108
109                results.push(DiscoveredBenchmark {
110                    name: bench_name,
111                    framework: framework.to_string(),
112                    command,
113                    path: rel_path,
114                    language: "rust".to_string(),
115                    confidence: confidence.to_string(),
116                });
117            }
118            i = j;
119        } else {
120            i += 1;
121        }
122    }
123    results
124}
125
126/// Extract a string value from a TOML `key = "value"` line.
127fn extract_toml_string_value(line: &str, key: &str) -> Option<String> {
128    let trimmed = line.trim();
129    if !trimmed.starts_with(key) {
130        return None;
131    }
132    let rest = trimmed[key.len()..].trim();
133    let rest = rest.strip_prefix('=')?;
134    let rest = rest.trim();
135    let rest = rest.strip_prefix('"')?;
136    let rest = rest.strip_suffix('"')?;
137    Some(rest.to_string())
138}
139
140/// Recursively scan a directory for `.rs` files containing `criterion_group!`.
141fn scan_dir_for_criterion(dir: &Path, root: &Path) -> Vec<DiscoveredBenchmark> {
142    let mut results = Vec::new();
143    let entries = match fs::read_dir(dir) {
144        Ok(e) => e,
145        Err(_) => return results,
146    };
147    for entry in entries.flatten() {
148        let path = entry.path();
149        if path.is_dir() {
150            results.extend(scan_dir_for_criterion(&path, root));
151        } else if path.extension().is_some_and(|ext| ext == "rs")
152            && let Ok(content) = fs::read_to_string(&path)
153            && (content.contains("criterion_group!") || content.contains("criterion_main!"))
154        {
155            let rel_path = path
156                .strip_prefix(root)
157                .unwrap_or(&path)
158                .to_string_lossy()
159                .replace('\\', "/");
160            let bench_name = path
161                .file_stem()
162                .map(|s| s.to_string_lossy().to_string())
163                .unwrap_or_else(|| "unknown".to_string());
164            results.push(DiscoveredBenchmark {
165                name: bench_name.clone(),
166                framework: "criterion".to_string(),
167                command: format!("cargo bench --bench {bench_name}"),
168                path: rel_path,
169                language: "rust".to_string(),
170                confidence: "high".to_string(),
171            });
172        }
173    }
174    results
175}
176
177// ---------------------------------------------------------------------------
178// Go benchmarks
179// ---------------------------------------------------------------------------
180
181/// Scan for Go benchmarks by looking for `func Benchmark` in `*_test.go` files.
182fn scan_go_benchmarks(root: &Path) -> Vec<DiscoveredBenchmark> {
183    let mut results = Vec::new();
184    walk_files(root, &mut |path| {
185        let name = path.file_name().unwrap_or_default().to_string_lossy();
186        if name.ends_with("_test.go")
187            && let Ok(content) = fs::read_to_string(path)
188        {
189            for line in content.lines() {
190                let trimmed = line.trim();
191                if let Some(rest) = trimmed.strip_prefix("func Benchmark")
192                    && let Some(paren_pos) = rest.find('(')
193                {
194                    let func_name = rest[..paren_pos].trim();
195                    if !func_name.is_empty()
196                        && func_name.chars().next().is_some_and(|c| c.is_uppercase())
197                    {
198                        let full_name = format!("Benchmark{func_name}");
199                        let rel_path = path
200                            .strip_prefix(root)
201                            .unwrap_or(path)
202                            .to_string_lossy()
203                            .replace('\\', "/");
204                        let pkg_dir = path
205                            .parent()
206                            .and_then(|p| p.strip_prefix(root).ok())
207                            .map(|p| p.to_string_lossy().replace('\\', "/"))
208                            .unwrap_or_else(|| ".".to_string());
209                        results.push(DiscoveredBenchmark {
210                            name: full_name.clone(),
211                            framework: "go-bench".to_string(),
212                            command: format!("go test -bench=^{full_name}$ -benchmem ./{pkg_dir}"),
213                            path: rel_path,
214                            language: "go".to_string(),
215                            confidence: "high".to_string(),
216                        });
217                    }
218                }
219            }
220        }
221    });
222    results
223}
224
225// ---------------------------------------------------------------------------
226// Python / pytest-benchmark
227// ---------------------------------------------------------------------------
228
229/// Scan for Python pytest-benchmark usage by looking for `benchmark` fixture in test files.
230fn scan_python_pytest_benchmark(root: &Path) -> Vec<DiscoveredBenchmark> {
231    let mut results = Vec::new();
232    walk_files(root, &mut |path| {
233        let name = path.file_name().unwrap_or_default().to_string_lossy();
234        if (name.starts_with("test_") || name.ends_with("_test.py") || name.starts_with("bench_"))
235            && name.ends_with(".py")
236            && let Ok(content) = fs::read_to_string(path)
237            && content.contains("benchmark")
238            && content.contains("def ")
239        {
240            // Look for functions that use the benchmark fixture
241            for line in content.lines() {
242                let trimmed = line.trim();
243                if let Some(rest) = trimmed.strip_prefix("def ")
244                    && let Some(paren_pos) = rest.find('(')
245                    && rest[paren_pos..].contains("benchmark")
246                {
247                    let func_name = &rest[..paren_pos];
248                    let rel_path = path
249                        .strip_prefix(root)
250                        .unwrap_or(path)
251                        .to_string_lossy()
252                        .replace('\\', "/");
253                    results.push(DiscoveredBenchmark {
254                        name: func_name.to_string(),
255                        framework: "pytest-benchmark".to_string(),
256                        command: format!("pytest --benchmark-only {rel_path}::{func_name}"),
257                        path: rel_path,
258                        language: "python".to_string(),
259                        confidence: "high".to_string(),
260                    });
261                }
262            }
263        }
264    });
265    results
266}
267
268// ---------------------------------------------------------------------------
269// JavaScript / Benchmark.js
270// ---------------------------------------------------------------------------
271
272/// Scan for JavaScript Benchmark.js usage by looking for `suite.add` patterns.
273fn scan_javascript_benchmark(root: &Path) -> Vec<DiscoveredBenchmark> {
274    let mut results = Vec::new();
275    walk_files(root, &mut |path| {
276        let name = path.file_name().unwrap_or_default().to_string_lossy();
277        if (name.ends_with(".js") || name.ends_with(".mjs"))
278            && (name.contains("bench") || name.contains("perf"))
279            && let Ok(content) = fs::read_to_string(path)
280            && (content.contains("suite.add") || content.contains("Suite"))
281        {
282            let rel_path = path
283                .strip_prefix(root)
284                .unwrap_or(path)
285                .to_string_lossy()
286                .replace('\\', "/");
287            let bench_name = path
288                .file_stem()
289                .map(|s| s.to_string_lossy().to_string())
290                .unwrap_or_else(|| "unknown".to_string());
291            results.push(DiscoveredBenchmark {
292                name: bench_name,
293                framework: "benchmark.js".to_string(),
294                command: format!("node {rel_path}"),
295                path: rel_path,
296                language: "javascript".to_string(),
297                confidence: "medium".to_string(),
298            });
299        }
300    });
301    results
302}
303
304// ---------------------------------------------------------------------------
305// Custom executable directories
306// ---------------------------------------------------------------------------
307
308/// Scan well-known directories (`benchmarks/`, `bench/`, `perf/`) for executable files.
309fn scan_custom_directories(root: &Path) -> Vec<DiscoveredBenchmark> {
310    let mut results = Vec::new();
311    let dirs = ["benchmarks", "bench", "perf"];
312    for dir_name in &dirs {
313        let dir = root.join(dir_name);
314        if dir.is_dir() {
315            let entries = match fs::read_dir(&dir) {
316                Ok(e) => e,
317                Err(_) => continue,
318            };
319            for entry in entries.flatten() {
320                let path = entry.path();
321                if path.is_file() && is_likely_executable(&path) {
322                    let rel_path = path
323                        .strip_prefix(root)
324                        .unwrap_or(&path)
325                        .to_string_lossy()
326                        .replace('\\', "/");
327                    let bench_name = path
328                        .file_stem()
329                        .map(|s| s.to_string_lossy().to_string())
330                        .unwrap_or_else(|| "unknown".to_string());
331                    results.push(DiscoveredBenchmark {
332                        name: bench_name,
333                        framework: "custom".to_string(),
334                        command: format!("./{rel_path}"),
335                        path: rel_path,
336                        language: "unknown".to_string(),
337                        confidence: "low".to_string(),
338                    });
339                }
340            }
341        }
342    }
343    results
344}
345
346/// Heuristic to decide if a file is likely executable.
347/// On Unix we would check permissions; on all platforms we check for
348/// script shebangs or known executable extensions.
349fn is_likely_executable(path: &Path) -> bool {
350    // Check known executable extensions
351    if let Some(ext) = path.extension() {
352        let ext = ext.to_string_lossy().to_lowercase();
353        if matches!(
354            ext.as_str(),
355            "sh" | "bash" | "zsh" | "py" | "rb" | "pl" | "exe" | "bat" | "cmd" | "ps1"
356        ) {
357            return true;
358        }
359    }
360
361    // Check for shebang
362    if let Ok(content) = fs::read(path)
363        && content.starts_with(b"#!")
364    {
365        return true;
366    }
367
368    // No extension might be a compiled binary
369    path.extension().is_none()
370}
371
372// ---------------------------------------------------------------------------
373// Helpers
374// ---------------------------------------------------------------------------
375
376/// Walk files under `root`, skipping common non-source directories.
377fn walk_files(root: &Path, callback: &mut dyn FnMut(&Path)) {
378    walk_files_inner(root, root, callback);
379}
380
381fn walk_files_inner(dir: &Path, root: &Path, callback: &mut dyn FnMut(&Path)) {
382    let entries = match fs::read_dir(dir) {
383        Ok(e) => e,
384        Err(_) => return,
385    };
386    for entry in entries.flatten() {
387        let path = entry.path();
388        if path.is_dir() {
389            let name = path.file_name().unwrap_or_default().to_string_lossy();
390            // Skip common non-source directories
391            if matches!(
392                name.as_ref(),
393                "target"
394                    | "node_modules"
395                    | ".git"
396                    | ".hg"
397                    | ".svn"
398                    | "__pycache__"
399                    | "vendor"
400                    | "dist"
401                    | "build"
402                    | ".tox"
403                    | ".venv"
404                    | "venv"
405            ) {
406                continue;
407            }
408            // Limit depth: only go ~4 levels deep relative to root
409            let depth = path
410                .strip_prefix(root)
411                .map(|p| p.components().count())
412                .unwrap_or(0);
413            if depth < 5 {
414                walk_files_inner(&path, root, callback);
415            }
416        } else {
417            callback(&path);
418        }
419    }
420}
421
422// ---------------------------------------------------------------------------
423// Tests
424// ---------------------------------------------------------------------------
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use std::fs;
430
431    /// Helper to create a temp directory with files.
432    fn setup_temp_dir() -> tempfile::TempDir {
433        tempfile::tempdir().expect("failed to create temp dir")
434    }
435
436    #[test]
437    fn test_discover_empty_dir() {
438        let tmp = setup_temp_dir();
439        let results = discover_all(tmp.path());
440        assert!(results.is_empty());
441    }
442
443    #[test]
444    fn test_extract_toml_string_value() {
445        assert_eq!(
446            extract_toml_string_value(r#"name = "my_bench""#, "name"),
447            Some("my_bench".to_string())
448        );
449        assert_eq!(
450            extract_toml_string_value(r#"  name = "spaced"  "#, "name"),
451            Some("spaced".to_string())
452        );
453        assert_eq!(
454            extract_toml_string_value(r#"harness = false"#, "name"),
455            None
456        );
457        assert_eq!(
458            extract_toml_string_value(r#"name = "has_underscore""#, "name"),
459            Some("has_underscore".to_string())
460        );
461    }
462
463    #[test]
464    fn test_parse_cargo_bench_targets() {
465        let content = r#"
466[package]
467name = "myproject"
468
469[[bench]]
470name = "my_bench"
471harness = false
472
473[[bench]]
474name = "another"
475"#;
476        let tmp = setup_temp_dir();
477        let results = parse_cargo_bench_targets(content, tmp.path());
478        assert_eq!(results.len(), 2);
479
480        assert_eq!(results[0].name, "my_bench");
481        assert_eq!(results[0].framework, "criterion");
482        assert_eq!(results[0].confidence, "high");
483
484        assert_eq!(results[1].name, "another");
485        assert_eq!(results[1].framework, "rust-bench");
486        assert_eq!(results[1].confidence, "medium");
487    }
488
489    #[test]
490    fn test_scan_criterion_file() {
491        let tmp = setup_temp_dir();
492        let benches_dir = tmp.path().join("benches");
493        fs::create_dir(&benches_dir).unwrap();
494        fs::write(
495            benches_dir.join("sort_bench.rs"),
496            r#"
497use criterion::{criterion_group, criterion_main, Criterion};
498
499fn bench_sort(c: &mut Criterion) {
500    c.bench_function("sort_1000", |b| {
501        b.iter(|| {
502            let mut v: Vec<i32> = (0..1000).rev().collect();
503            v.sort();
504        })
505    });
506}
507
508criterion_group!(benches, bench_sort);
509criterion_main!(benches);
510"#,
511        )
512        .unwrap();
513
514        let results = scan_rust_criterion(tmp.path());
515        assert_eq!(results.len(), 1);
516        assert_eq!(results[0].name, "sort_bench");
517        assert_eq!(results[0].framework, "criterion");
518        assert_eq!(results[0].language, "rust");
519        assert_eq!(results[0].confidence, "high");
520        assert!(
521            results[0]
522                .command
523                .contains("cargo bench --bench sort_bench")
524        );
525    }
526
527    #[test]
528    fn test_scan_go_benchmarks() {
529        let tmp = setup_temp_dir();
530        fs::write(
531            tmp.path().join("sort_test.go"),
532            r#"
533package main
534
535import "testing"
536
537func BenchmarkSort(b *testing.B) {
538    for i := 0; i < b.N; i++ {
539        // sort something
540    }
541}
542
543func BenchmarkSearch(b *testing.B) {
544    for i := 0; i < b.N; i++ {
545        // search something
546    }
547}
548
549func TestNotABenchmark(t *testing.T) {}
550"#,
551        )
552        .unwrap();
553
554        let results = scan_go_benchmarks(tmp.path());
555        assert_eq!(results.len(), 2);
556
557        let names: Vec<&str> = results.iter().map(|b| b.name.as_str()).collect();
558        assert!(names.contains(&"BenchmarkSort"));
559        assert!(names.contains(&"BenchmarkSearch"));
560
561        for b in &results {
562            assert_eq!(b.framework, "go-bench");
563            assert_eq!(b.language, "go");
564            assert_eq!(b.confidence, "high");
565            assert!(b.command.contains("go test -bench="));
566        }
567    }
568
569    #[test]
570    fn test_scan_python_pytest_benchmark() {
571        let tmp = setup_temp_dir();
572        fs::write(
573            tmp.path().join("test_perf.py"),
574            r#"
575def test_sort_speed(benchmark):
576    benchmark(sorted, list(range(1000, 0, -1)))
577
578def test_not_a_benchmark():
579    assert True
580"#,
581        )
582        .unwrap();
583
584        let results = scan_python_pytest_benchmark(tmp.path());
585        assert_eq!(results.len(), 1);
586        assert_eq!(results[0].name, "test_sort_speed");
587        assert_eq!(results[0].framework, "pytest-benchmark");
588        assert_eq!(results[0].language, "python");
589        assert_eq!(results[0].confidence, "high");
590    }
591
592    #[test]
593    fn test_scan_javascript_benchmark() {
594        let tmp = setup_temp_dir();
595        fs::write(
596            tmp.path().join("bench.js"),
597            r#"
598const Benchmark = require('benchmark');
599const suite = new Benchmark.Suite;
600
601suite.add('sort', function() {
602    [3,1,2].sort();
603})
604.run();
605"#,
606        )
607        .unwrap();
608
609        let results = scan_javascript_benchmark(tmp.path());
610        assert_eq!(results.len(), 1);
611        assert_eq!(results[0].name, "bench");
612        assert_eq!(results[0].framework, "benchmark.js");
613        assert_eq!(results[0].language, "javascript");
614        assert_eq!(results[0].confidence, "medium");
615    }
616
617    #[test]
618    fn test_scan_custom_directories() {
619        let tmp = setup_temp_dir();
620        let bench_dir = tmp.path().join("benchmarks");
621        fs::create_dir(&bench_dir).unwrap();
622        fs::write(bench_dir.join("run_perf.sh"), "#!/bin/bash\necho hello").unwrap();
623        fs::write(bench_dir.join("README.md"), "# Benchmarks").unwrap();
624
625        let results = scan_custom_directories(tmp.path());
626        assert_eq!(results.len(), 1);
627        assert_eq!(results[0].name, "run_perf");
628        assert_eq!(results[0].framework, "custom");
629        assert_eq!(results[0].confidence, "low");
630    }
631
632    #[test]
633    fn test_is_likely_executable() {
634        let tmp = setup_temp_dir();
635        let sh_file = tmp.path().join("run.sh");
636        fs::write(&sh_file, "#!/bin/bash\necho hello").unwrap();
637        assert!(is_likely_executable(&sh_file));
638
639        let py_file = tmp.path().join("bench.py");
640        fs::write(&py_file, "print('hello')").unwrap();
641        assert!(is_likely_executable(&py_file));
642
643        let md_file = tmp.path().join("README.md");
644        fs::write(&md_file, "# Hello").unwrap();
645        assert!(!is_likely_executable(&md_file));
646
647        // File with no extension and shebang
648        let shebang_file = tmp.path().join("my_bench");
649        fs::write(&shebang_file, "#!/usr/bin/env python\nprint('bench')").unwrap();
650        assert!(is_likely_executable(&shebang_file));
651    }
652
653    #[test]
654    fn test_discover_all_mixed() {
655        let tmp = setup_temp_dir();
656
657        // Add a Cargo.toml with bench target
658        fs::write(
659            tmp.path().join("Cargo.toml"),
660            r#"
661[package]
662name = "test"
663
664[[bench]]
665name = "perf_test"
666harness = false
667"#,
668        )
669        .unwrap();
670
671        // Add a Go benchmark
672        fs::write(
673            tmp.path().join("algo_test.go"),
674            r#"
675package algo
676
677import "testing"
678
679func BenchmarkAlgo(b *testing.B) {}
680"#,
681        )
682        .unwrap();
683
684        let results = discover_all(tmp.path());
685        assert!(results.len() >= 2);
686
687        let names: Vec<&str> = results.iter().map(|b| b.name.as_str()).collect();
688        assert!(names.contains(&"perf_test"));
689        assert!(names.contains(&"BenchmarkAlgo"));
690    }
691
692    #[test]
693    fn test_discover_all_sorted_by_name() {
694        let tmp = setup_temp_dir();
695        let bench_dir = tmp.path().join("benchmarks");
696        fs::create_dir(&bench_dir).unwrap();
697        fs::write(bench_dir.join("zebra.sh"), "#!/bin/bash").unwrap();
698        fs::write(bench_dir.join("alpha.sh"), "#!/bin/bash").unwrap();
699
700        let results = discover_all(tmp.path());
701        if results.len() >= 2 {
702            assert!(results[0].name <= results[1].name);
703        }
704    }
705
706    #[test]
707    fn test_cargo_bench_targets_dedup() {
708        // If Cargo.toml declares a bench AND the .rs file has criterion_group!,
709        // we should only get one entry.
710        let tmp = setup_temp_dir();
711        fs::write(
712            tmp.path().join("Cargo.toml"),
713            r#"
714[package]
715name = "test"
716
717[[bench]]
718name = "my_bench"
719harness = false
720"#,
721        )
722        .unwrap();
723
724        let benches_dir = tmp.path().join("benches");
725        fs::create_dir(&benches_dir).unwrap();
726        fs::write(
727            benches_dir.join("my_bench.rs"),
728            "criterion_group!(benches, f);\ncriterion_main!(benches);",
729        )
730        .unwrap();
731
732        let results = scan_rust_criterion(tmp.path());
733        // Should deduplicate: only one entry named "my_bench"
734        let count = results.iter().filter(|b| b.name == "my_bench").count();
735        assert_eq!(count, 1);
736    }
737
738    #[test]
739    fn test_walk_files_skips_git_dir() {
740        let tmp = setup_temp_dir();
741        let git_dir = tmp.path().join(".git");
742        fs::create_dir(&git_dir).unwrap();
743        fs::write(git_dir.join("config"), "core").unwrap();
744
745        let mut visited = Vec::new();
746        walk_files(tmp.path(), &mut |path| {
747            visited.push(path.to_path_buf());
748        });
749        assert!(
750            visited.is_empty(),
751            "should not visit files inside .git directory"
752        );
753    }
754}