Layered gitignore-compatible ignore pattern resolution for Python.
I built this because I needed to resolve ignore patterns across multiple layers (default patterns, .gitignore, .git/info/exclude, custom ignore files) in my projects, and couldn't find a library that did that. pathspec handles pattern matching well, but you're on your own for layered precedence, nested .gitignore scoping, and figuring out which pattern caused a file to be ignored.
- Four-layer precedence: defaults <
.git/info/exclude<.gitignore(root to deepest) < custom ignore files. Last match wins. - Nested
.gitignorefiles are scoped to their directory, matching git behavior. explain()tells you exactly which pattern in which file caused the decision.- Backed by pathspec's
GitIgnoreSpecfor correct gitignore semantics. - Fully typed (PEP 561).
pip install ignoretreeOr with uv:
uv add ignoretreeRequires Python 3.11+.
from pathlib import Path
from ignoretree import IgnoreResolver
resolver = IgnoreResolver(
root=Path("/path/to/repo"),
default_patterns=["*.pyc", "__pycache__/", ".git/"], # optional
custom_ignore_filenames=[".myignore"], # optional
)
# Check a single file (`auto_enter=True` loads .gitignore files along the path automatically):
resolver.is_ignored("src/debug.log", auto_enter=True) # True or FalseIf you're going to check many files, load all .gitignore files upfront:
resolver.load_all() # walks the repo, discovers all .gitignore files
resolver.is_ignored("src/debug.log")
resolver.is_ignored("tests/conftest.py")load_all() captures a repository snapshot. Repeated calls on the same resolver return without walking again. Create a new resolver to observe ignore files or directories added after the snapshot.
Directories named .git are always skipped during bulk discovery, even when caller defaults do not ignore them.
For full control during directory traversal, call enter_directory() as you go. This lets you prune ignored directories so os.walk doesn't descend into them:
import os
root = Path("/path/to/repo")
resolver = IgnoreResolver(root, default_patterns=["*.pyc", "__pycache__/", ".git/"])
for dirpath, dirnames, filenames in os.walk(root):
rel_dir = os.path.relpath(dirpath, root).replace(os.sep, "/")
if rel_dir == ".":
rel_dir = ""
resolver.enter_directory(rel_dir)
# Prune ignored directories so os.walk doesn't descend into them.
dirnames[:] = [
d for d in dirnames if not resolver.is_dir_ignored(f"{rel_dir}/{d}" if rel_dir else d)
]
for fname in filenames:
rel_path = f"{rel_dir}/{fname}" if rel_dir else fname
if not resolver.is_ignored(rel_path):
print(rel_path)On Python 3.12+, you can use
Path.walk()instead ofos.walk().
When you need to know why a file is ignored (or not):
decision = resolver.explain("src/debug.log")
print(decision)
# IgnoreDecision(ignored=True, source=PatternSource(file='.gitignore', line=3, pattern='*.log'))
decision = resolver.explain("src/main.py")
print(decision)
# IgnoreDecision(ignored=False, source=None)Both explain() and explain_dir() return an IgnoreDecision with the winning pattern source. They also support auto_enter=True for on-demand loading.
Works well with standard logging:
import logging
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
for path in paths_to_check:
decision = resolver.explain(path)
logger.debug(f"Ignore decision for {path}: {decision}")See the examples/ directory for runnable scripts.
| Mode | Method | When to use |
|---|---|---|
| On-demand | is_ignored(..., auto_enter=True) |
Checking one or a few files. Loads .gitignore files along the path on demand. |
| Bulk | load_all() + is_ignored() |
Checking many files. Discovers all .gitignore files upfront. |
| Walker | enter_directory() + is_ignored() |
During os.walk() traversal. Maximum control over pruning. |
All three modes support defaults, .git/info/exclude, nested .gitignore scoping, and custom ignore files. The difference is how and when .gitignore files are loaded.
Patterns are evaluated across four layers, from lowest to highest priority:
| Priority | Layer | Source |
|---|---|---|
| 1 (lowest) | Defaults | default_patterns argument |
| 2 | Exclude | .git/info/exclude |
| 3 | Gitignore | .gitignore files (root to deepest directory) |
| 4 (highest) | Custom | Files listed in custom_ignore_filenames |
Within each layer, negation patterns (!) work per gitignore rules. Across layers, the last layer with a matching pattern wins.
For normal repositories and linked Git worktrees, ignoretree locates the effective common info/exclude file directly from Git metadata. It does not invoke Git at runtime. The public source label remains .git/info/exclude in both layouts.
The repository root must be an existing directory. Ignoretree resolves it to an absolute path when the resolver is created. The directory does not need to be a Git repository.
Paths are matched as written and do not need to exist. Pass them as nonempty str values using root-relative POSIX syntax. These forms are not accepted:
- Absolute paths and Windows drive or UNC paths
- Backslashes and NUL characters
- Repeated separators
.and..path components
Use enter_directory("") for the repository root. This is the only method that accepts an empty path. Directory methods accept one trailing slash. File methods do not.
Ignoretree does not follow symlinks when looking for ignore files. Rules from safe parent directories can still match a symlink path. Ignore files reached through a symlink are not read.
Custom ignore filenames must be unique root-level names. They cannot be .git or .gitignore.
Ignore files are read as UTF-8. A UTF-8 BOM is accepted at the start of a file, and both LF and CRLF line endings are supported.
Leading spaces and tabs are part of a pattern. Unescaped trailing spaces follow Git rules and are ignored during matching. Escape a trailing space with a backslash when it is part of a filename.
Malformed patterns and Git no-op patterns are skipped. They do not prevent valid rules later in the same file or source from applying. This behavior is consistent for defaults, .git/info/exclude, nested .gitignore files, and custom ignore files.
Matching is case-sensitive by default. Pass case_sensitive=False when creating a resolver to match the ignore behavior of a repository configured with core.ignoreCase=true.
resolver = IgnoreResolver(root, case_sensitive=False)Ignoretree does not read Git configuration automatically. Case-insensitive mode folds only the ASCII letters A through Z. Pattern provenance keeps its original spelling.
ignoretree matches Unicode path strings lexically and does not emulate core.precomposeUnicode.
Clone and install dependencies:
git clone https://github.com/SergiPantoja/ignoretree.git
cd ignoretree
uv syncuv run pytest # tests with coverage
uv run ruff check src/ tests/ # lint
uv run ruff format --check src/ tests/ # format check
uv run mypy src/ # type checkIf you tend to forget (like me) to run linting before committing:
uv run pre-commit installThis sets up hooks that run ruff (lint + format) and check uv.lock consistency on every commit.
- Ruff for linting and formatting.
- Google-style docstrings.
- mypy in strict mode.