Metadata-Version: 2.5
Name: ignoretree
Version: 0.3.0
Summary: Layered gitignore-compatible ignore pattern resolution for Python
Project-URL: Homepage, https://github.com/SergiPantoja/ignoretree
Project-URL: Repository, https://github.com/SergiPantoja/ignoretree
Project-URL: Issues, https://github.com/SergiPantoja/ignoretree/issues
Project-URL: Changelog, https://github.com/SergiPantoja/ignoretree/blob/main/CHANGELOG.md
Author-email: SergiPantoja <62522545+SergiPantoja@users.noreply.github.com>
License-Expression: MIT
License-File: LICENSE
Keywords: gitignore,gitignore-patterns,ignore,layered-resolution,pathspec
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Software Development :: Version Control :: Git
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: pathspec>=1.1.0
Description-Content-Type: text/markdown

# ignoretree

[![CI](https://github.com/SergiPantoja/ignoretree/actions/workflows/ci.yml/badge.svg)](https://github.com/SergiPantoja/ignoretree/actions/workflows/ci.yml)
![Codecov](https://img.shields.io/codecov/c/github/sergipantoja/ignoretree)
[![PyPI - Version](https://img.shields.io/pypi/v/ignoretree)](https://pypi.org/project/ignoretree/)
![PyPI - Python Version](https://img.shields.io/pypi/pyversions/ignoretree)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/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](https://github.com/cpburnz/python-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](https://github.com/cpburnz/python-pathspec)'s `GitIgnoreSpec` for correct gitignore semantics.
- Fully typed (PEP 561).

## Installation

```bash
pip install ignoretree
```
Or with uv:

```bash
uv add ignoretree
```

Requires Python 3.11+.

## Quick Start

```python
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:

```python
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:

```python
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):

```python
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:

```python
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/](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`.

```python
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:

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

### Running checks

```bash
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:

```bash
uv run pre-commit install
```

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

### Code style

- [Ruff](https://docs.astral.sh/ruff/) for linting and formatting.
- [Google-style docstrings](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings).
- [mypy](https://mypy-lang.org/) in strict mode.

## License

[MIT](LICENSE)
