Status: Research Document Created: 2025-01-11 Related: Discovery subsystem, scanner.rs
Tach-core uses the ignore crate for file discovery, which respects .ignore, .gitignore, and global gitignore files. This creates potential conflicts with other developer tools that also use or modify these files. Most critically, patterns like *.py in .ignore can completely break tach-core's test discovery.
In src/discovery/scanner.rs, tach-core uses WalkBuilder from the ignore crate:
let paths: Vec<PathBuf> = WalkBuilder::new(&canonical_root)
.standard_filters(true) // <-- This enables .ignore/.gitignore respect
.follow_links(true)
.build()
.filter_map(|e| e.ok())
.filter(|e| is_test_file(e.path()))
.map(|e| /* path canonicalization */)
.collect();The standard_filters(true) call enables:
.gitignorefile parsing and respect.ignorefile parsing (HIGHER priority than .gitignore)- Global gitignore (
~/.config/git/ignoreor~/.gitignore_global) - Hidden file filtering (files starting with
.) - Parent directory ignore file inheritance
.ignore- Highest priority, used by ignore-crate tools.gitignore- Standard git ignore patterns- Global gitignore - User's global git configuration
- Hidden file rules - Files/directories starting with
.
The .ignore file takes precedence over .gitignore, meaning a negation in .ignore can override a pattern in .gitignore, and vice versa.
| Tool | Purpose | Notes |
|---|---|---|
| ripgrep (rg) | Fast text search | Primary user of .ignore; created the convention |
| fd | Fast file finder | Alternative to find |
| tach-core | Python test runner | Uses for test file discovery |
| tokei | Code statistics | Line counting |
| watchexec | File watcher | Re-run commands on file changes |
| delta | Git diff viewer | Syntax highlighting |
| Tool | Patterns Added | Impact on Tach |
|---|---|---|
| Claude Code | *.py |
CRITICAL: Blocks ALL Python files |
| Various AI assistants | Various patterns | May add broad patterns |
| IDE plugins | Language-specific patterns | Varies |
| Pattern | Effect | Severity |
|---|---|---|
*.py |
Blocks all Python files | CRITICAL |
test*.py |
Blocks all test files | CRITICAL |
*test*.py |
Blocks test files | CRITICAL |
tests/ |
Blocks test directory | CRITICAL |
test/ |
Blocks test directory | CRITICAL |
**/test_*.py |
Blocks test files recursively | CRITICAL |
| Pattern | Effect | Severity |
|---|---|---|
conftest.py |
Blocks pytest fixtures | HIGH |
**/conftest.py |
Blocks all conftest files | HIGH |
src/ + tests/ combo |
May orphan test files | HIGH |
*.pyc |
Generally safe, but watch for typos | LOW |
| Pattern | Purpose | Safe? |
|---|---|---|
__pycache__/ |
Python bytecode cache | Yes |
*.pyc |
Compiled Python | Yes |
.venv/ |
Virtual environment | Yes |
venv/ |
Virtual environment | Yes |
node_modules/ |
Node.js dependencies | Yes |
target/ |
Rust build artifacts | Yes |
.tach/ |
Tach cache directory | Yes |
.git/ |
Git metadata | Yes |
The current .ignore file in tach-core is safe:
/target
fuzz/target/
/.venv
__pycache__/
.tach/
# Git worktrees
.worktrees/
All patterns target build artifacts or caches, not source files.
Claude Code (Anthropic's AI coding assistant) adds *.py to .ignore to prevent itself from recursively processing Python files during certain operations. This pattern is inherited by all tools using the ignore crate.
When *.py is in .ignore:
WalkBuilderskips ALL Python filesdiscover()returns zero test modules- Tach reports "no tests found" with exit code 0
- Users see no error, just empty results
This is particularly insidious because:
- No error message is displayed
- Exit code is 0 (success)
- Users may think they have no tests
Currently, tach-core does NOT detect this condition. The discovery silently returns an empty result.
Add a CLI flag to bypass ignore files:
// In config.rs
#[arg(long)]
pub no_ignore: bool,
// In scanner.rs
WalkBuilder::new(&canonical_root)
.standard_filters(!config.no_ignore) // Disable when flag is setPros:
- Simple to implement
- Follows ripgrep/fd conventions
- User has explicit control
Cons:
- User must know to use the flag
- Doesn't solve the detection problem
Scan .ignore for patterns that would block Python files:
fn check_ignore_file(root: &Path) -> Option<Vec<String>> {
let ignore_path = root.join(".ignore");
if !ignore_path.exists() {
return None;
}
let content = std::fs::read_to_string(&ignore_path).ok()?;
let dangerous: Vec<String> = content
.lines()
.filter(|line| !line.starts_with('#') && !line.is_empty())
.filter(|line| {
line.contains("*.py") ||
line.contains("test") ||
line.contains("conftest")
})
.map(|s| s.to_string())
.collect();
if dangerous.is_empty() {
None
} else {
Some(dangerous)
}
}Pros:
- Proactive warning to users
- Explains why no tests are found
- Can suggest fixes
Cons:
- May have false positives
- Adds startup overhead
Create a tach-specific ignore file:
WalkBuilder::new(&canonical_root)
.standard_filters(false) // Don't use .ignore/.gitignore
.add_custom_ignore_filename(".tachignore") // Use our ownPros:
- Complete isolation from other tools
- No conflict possible
Cons:
- Users must duplicate ignore patterns
- Breaks convention
- More cognitive overhead
Force-include Python files regardless of ignore:
WalkBuilder::new(&canonical_root)
.standard_filters(true)
.add("!*.py") // Force include Python
.add("!**/test_*.py")Pros:
- Automatic fix
Cons:
- May include files user intentionally ignored
- Breaks user expectations
- Could include vendor/third-party code
When discovery returns no tests, check for potential causes:
if discovery_result.test_count() == 0 {
// Check .ignore for dangerous patterns
if let Some(patterns) = check_ignore_file(&cwd) {
eprintln!("[tach:warning] No tests found!");
eprintln!("[tach:warning] The following patterns in .ignore may be blocking Python files:");
for pattern in patterns {
eprintln!(" - {}", pattern);
}
eprintln!("[tach:warning] Try running with --no-ignore or remove these patterns");
}
}Implement safeguards in this order:
- Add check for dangerous patterns in
.ignore - Warn user when zero tests found AND dangerous patterns exist
- Suggest
--no-ignoreas workaround
- Add
--no-ignoreCLI flag - Add
TACH_NO_IGNORE=1environment variable - Add
[tool.tach] ignore_files = falseconfig option
- Document the
.ignoreinteraction in troubleshooting - Add FAQ entry for "no tests found"
- Document which tools may conflict
- Check your
.ignorefile regularly - Never add
*.pyto.ignoreunless you understand the consequences - Use more specific patterns:
generated/*.pyinstead of*.py - If AI adds broad patterns, immediately revert or narrow them
# Safe patterns - won't break tach-core
__pycache__/
*.pyc
*.pyo
*.pyd
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
*.egg-info/
dist/
build/
.venv/
venv/
env/
.env/
node_modules/
target/
.tach/
.worktrees/
# DANGER: These patterns break tach-core discovery
# *.py # DON'T DO THIS
# test*.py # DON'T DO THIS
# tests/ # DON'T DO THIS- Check if
.ignoreexists:cat .ignore - Look for
*.pyortestpatterns - Either remove the pattern or run:
tach --no-ignore - Report the issue to the tool that added the pattern
- Discovery returns 0 tests when
*.pyin.ignore - No warning when dangerous patterns detected
- No CLI flag to bypass ignore files
- ignore crate documentation
- ripgrep ignore file format
- gitignore pattern format
- Claude Code behavior documentation (internal)
let walker = WalkBuilder::new(path)
// Filter controls
.standard_filters(true) // Enable all standard filters
.hidden(true) // Skip hidden files
.parents(true) // Check parent directories for ignore files
.git_ignore(true) // Respect .gitignore
.git_global(true) // Respect global gitignore
.git_exclude(true) // Respect .git/info/exclude
.ignore(true) // Respect .ignore files
// Custom patterns
.add_custom_ignore_filename(".customignore")
// Performance
.threads(num_cpus::get()) // Parallel walking
.follow_links(true) // Follow symlinks
.build();When a file matches multiple patterns, the most specific wins:
- Negation (
!pattern) always has highest priority at its level - Child directories override parent directories
.ignoreoverrides.gitignoreat the same level- Later patterns in the same file override earlier ones
# .gitignore
*.py # Ignore all Python
# .ignore (OVERRIDES .gitignore)
!test_*.py # But keep test filesIn this case, test_*.py files would be INCLUDED because .ignore has higher priority and contains a negation.
However:
# .ignore
*.py # Ignore ALL Python - nothing can override thisThis blocks everything because there's no negation.