Skip to content

About

Layered gitignore-compatible ignore pattern resolution for Python

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

ignoretree

CI Codecov PyPI - Version PyPI - Python Version License: MIT

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.

Features

  • Four-layer precedence: defaults < .git/info/exclude < .gitignore (root to deepest) < custom ignore files. Last match wins.
  • Nested .gitignore files are scoped to their directory, matching git behavior.
  • explain() tells you exactly which pattern in which file caused the decision.
  • Backed by pathspec's GitIgnoreSpec for correct gitignore semantics.
  • Fully typed (PEP 561).

Installation

pip install ignoretree

Or with uv:

uv add ignoretree

Requires Python 3.11+.

Quick Start

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 False

Bulk load

If 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.

Walker integration

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 of os.walk().

Debugging with explain()

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.

Usage Modes

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.

Layer Precedence

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.

Paths and symlinks

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 pattern parsing

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.

Case sensitivity

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.

Development

Clone and install dependencies:

git clone https://github.com/SergiPantoja/ignoretree.git
cd ignoretree
uv sync

Running checks

uv 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 check

Pre-commit hooks (optional)

If you tend to forget (like me) to run linting before committing:

uv run pre-commit install

This sets up hooks that run ruff (lint + format) and check uv.lock consistency on every commit.

Code style

License

MIT

About

Layered gitignore-compatible ignore pattern resolution for Python

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages