From 4d7dd453be0c8c3e627f7748523ecffdc7da4f98 Mon Sep 17 00:00:00 2001 From: Hunter Read <21973361+hunter-read@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:45:41 -0700 Subject: [PATCH] Feature: Add support for .grimoireignore files. --- README.md | 28 ++++ backend/indexer.py | 84 +++++++++--- backend/library_ignore.py | 109 ++++++++++++++++ backend/requirements.txt | 1 + backend/tests/test_indexer_ignore.py | 188 +++++++++++++++++++++++++++ backend/tests/test_library_ignore.py | 138 ++++++++++++++++++++ 6 files changed, 531 insertions(+), 17 deletions(-) create mode 100644 backend/library_ignore.py create mode 100644 backend/tests/test_indexer_ignore.py create mode 100644 backend/tests/test_library_ignore.py diff --git a/README.md b/README.md index 1248b05..f187330 100644 --- a/README.md +++ b/README.md @@ -382,6 +382,34 @@ Tags are applied (or updated) every time the library is rescanned. Tags set via --- +## Ignoring Files with .grimoireignore + +Add a `.grimoireignore` file to keep files on disk but out of Grimoire. It uses the same syntax as `.gitignore` / `.dockerignore`, so anything matched by a rule is skipped during scanning and never appears in the UI — useful when a book ships extra print variants (black-and-white single pages, zine-sized layouts) you want kept next to the book but hidden. + +Place it at your **library root** to apply everywhere, or in any subfolder to add rules for just that subtree. Rules are cumulative and nested, like git. + +``` +library/ +├── .grimoireignore ← applies to the whole library +└── books/ + └── Example TTRPG/ + ├── core/ + │ └── Players Handbook.pdf + └── ignore/ ← whole folder skipped + └── Players Handbook BW Single Pages.pdf +``` + +``` +# .grimoireignore +ignore/ # skip an entire folder +*BW Single Pages*.pdf # skip print variants anywhere +!keep-this.pdf # re-include a file an earlier rule excluded +``` + +The full gitignore dialect is supported (`!` negation, `**` for arbitrary depth, anchoring with `/`), and rules apply to every collection: `books/`, `maps/`, `tokens/`, and `audio/`. Changes take effect on the next scan. Adding a rule that matches an already-indexed file hides it (marked missing) on the next rescan; remove the rule and rescan to bring it back. + +--- + ## Adding Files to Your Library Grimoire mounts your library folder **read-only** and never modifies your files. To upload, organize, or remove content, use a companion tool that mounts the same library folder with write access. diff --git a/backend/indexer.py b/backend/indexer.py index 39c2019..2d8b5b3 100644 --- a/backend/indexer.py +++ b/backend/indexer.py @@ -22,6 +22,7 @@ from sqlalchemy.orm import Session from . import config, ocr +from .library_ignore import IgnoreMatcher from .models import ( GameSystem, Book, @@ -802,14 +803,38 @@ def _commit(session: Session, label: str) -> None: session.rollback() -def _count_eligible_files(directory: Path, extensions: set) -> int: - """Count non-hidden files with matching extensions under directory.""" +def _prune_dirs(root: str, dirs: list[str], ignore: Optional[IgnoreMatcher]) -> list[str]: + """Return the walk subdirectories to descend into. + + Drops hidden dirs (``.``-prefixed) and, when an ``ignore`` matcher is given, + any directory excluded by a ``.grimoireignore`` rule — pruning the whole + subtree so ignored folders are never walked. + """ + return [ + d + for d in dirs + if not d.startswith(".") + and not (ignore and ignore.is_ignored(os.path.join(root, d), is_dir=True)) + ] + + +def _count_eligible_files( + directory: Path, extensions: set, ignore: Optional[IgnoreMatcher] = None +) -> int: + """Count non-hidden files with matching extensions under directory. + + When an ``ignore`` matcher is supplied, directories and files excluded by a + ``.grimoireignore`` rule are skipped so the count matches what the scan will + actually process (keeping progress totals accurate). + """ count = 0 for root, dirs, files in os.walk(directory): - dirs[:] = [d for d in dirs if not d.startswith(".")] + dirs[:] = _prune_dirs(root, dirs, ignore) for f in files: if f.startswith("."): continue + if ignore and ignore.is_ignored(os.path.join(root, f), is_dir=False): + continue if Path(f).suffix.lower() in extensions or archive_ext(f) in extensions: count += 1 return count @@ -1130,6 +1155,10 @@ def scan_library( scope_section, scope_dir = resolve_scope(library_path, scope_path) logger.debug(f"Scoped scan: section={scope_section}, dir={scope_dir}, mode={metadata_mode}") + # Matcher for .grimoireignore rules across the whole library tree (issue + # #224). Built once from the library root; queried per path in each walk. + ignore = IgnoreMatcher(library_path) + scan_books = scope_section in (None, "books") scan_maps = scope_section in (None, "maps") scan_tokens = scope_section in (None, "tokens") @@ -1142,22 +1171,22 @@ def scan_library( audio_walk_dir = scope_dir if scope_section == "audio" else audio_dir total_books = ( - _count_eligible_files(books_walk_dir, DOC_EXTS | IMAGE_EXTS | ARCHIVE_EXTS) + _count_eligible_files(books_walk_dir, DOC_EXTS | IMAGE_EXTS | ARCHIVE_EXTS, ignore) if scan_books and books_walk_dir.exists() else 0 ) total_maps = ( - _count_eligible_files(maps_walk_dir, MAP_IMAGE_EXTS) + _count_eligible_files(maps_walk_dir, MAP_IMAGE_EXTS, ignore) if scan_maps and maps_walk_dir.exists() else 0 ) total_tokens = ( - _count_eligible_files(tokens_walk_dir, IMAGE_EXTS) + _count_eligible_files(tokens_walk_dir, IMAGE_EXTS, ignore) if scan_tokens and tokens_walk_dir.exists() else 0 ) total_audio = ( - _count_eligible_files(audio_walk_dir, AUDIO_EXTS) + _count_eligible_files(audio_walk_dir, AUDIO_EXTS, ignore) if scan_audio and audio_walk_dir.exists() else 0 ) @@ -1236,7 +1265,7 @@ def scan_library( scope_dir if (scope_section == "books" and len(scope_parts) > 1) else system_dir ) for root, dirs, files in os.walk(walk_root): - dirs[:] = [d for d in dirs if not d.startswith(".")] + dirs[:] = _prune_dirs(root, dirs, ignore) # Collect cover image filenames declared in any OPF files in this # directory so we can skip them — Calibre exports a cover JPG that @@ -1260,6 +1289,10 @@ def scan_library( if ext not in DOC_EXTS and ext not in IMAGE_EXTS and not arc_ext: continue + if ignore.is_ignored(filepath, is_dir=False): + logger.debug(f"Ignored by .grimoireignore: {filepath}") + continue + if filename in opf_cover_filenames: logger.debug(f"Skipping OPF cover image: {filepath}") continue @@ -1475,7 +1508,7 @@ def scan_library( if scan_maps and maps_walk_dir.exists(): for root, dirs, files in os.walk(maps_walk_dir): - dirs[:] = [d for d in dirs if not d.startswith(".")] + dirs[:] = _prune_dirs(root, dirs, ignore) for filename in sorted(files): if filename.startswith("."): @@ -1487,6 +1520,10 @@ def scan_library( if ext not in MAP_IMAGE_EXTS: continue + if ignore.is_ignored(filepath, is_dir=False): + logger.debug(f"Ignored by .grimoireignore: {filepath}") + continue + scanned_maps += 1 if on_progress: on_progress( @@ -1563,7 +1600,7 @@ def scan_library( if scan_tokens and tokens_walk_dir.exists(): for root, dirs, files in os.walk(tokens_walk_dir): - dirs[:] = [d for d in dirs if not d.startswith(".")] + dirs[:] = _prune_dirs(root, dirs, ignore) for filename in sorted(files): if filename.startswith("."): @@ -1575,6 +1612,10 @@ def scan_library( if ext not in IMAGE_EXTS: continue + if ignore.is_ignored(filepath, is_dir=False): + logger.debug(f"Ignored by .grimoireignore: {filepath}") + continue + scanned_tokens += 1 if on_progress: on_progress( @@ -1651,7 +1692,7 @@ def scan_library( if scan_audio and audio_walk_dir.exists(): for root, dirs, files in os.walk(audio_walk_dir): - dirs[:] = [d for d in dirs if not d.startswith(".")] + dirs[:] = _prune_dirs(root, dirs, ignore) for filename in sorted(files): if filename.startswith("."): @@ -1663,6 +1704,10 @@ def scan_library( if ext not in AUDIO_EXTS: continue + if ignore.is_ignored(filepath, is_dir=False): + logger.debug(f"Ignored by .grimoireignore: {filepath}") + continue + scanned_audio += 1 if on_progress: on_progress( @@ -1737,8 +1782,10 @@ def scan_library( # --- Mark / unmark missing files --- # After walking the filesystem, any DB record whose file is gone gets # is_missing=True; records that exist on disk have is_missing cleared. - # When scoped, only reconcile records under the scope subtree so unrelated - # corners of the library are left untouched. + # A file newly matched by a ``.grimoireignore`` rule (still on disk but now + # excluded) is treated as gone too, so it disappears from the UI; clearing + # the rule brings it back on the next scan. When scoped, only reconcile + # records under the scope subtree so unrelated corners are left untouched. if should_stop and should_stop(): return stats @@ -1747,10 +1794,13 @@ def _scoped(query: Any, model: Any) -> Any: return query.filter(model.filepath.like(f"{scope_dir}{os.sep}%")) return query + def _gone(filepath: str) -> bool: + return not os.path.exists(filepath) or ignore.is_ignored(filepath, is_dir=False) + missing_books = missing_maps = missing_tokens = missing_audio = 0 if scan_books: for book in _scoped(session.query(Book), Book).all(): - gone = not os.path.exists(book.filepath) + gone = _gone(book.filepath) if gone != bool(book.is_missing): book.is_missing = gone if gone: @@ -1758,7 +1808,7 @@ def _scoped(query: Any, model: Any) -> Any: logger.warning(f"Missing book: '{book.title}' ({book.filepath})") if scan_maps: for m in _scoped(session.query(GenericMap), GenericMap).all(): - gone = not os.path.exists(m.filepath) + gone = _gone(m.filepath) if gone != bool(m.is_missing): m.is_missing = gone if gone: @@ -1766,7 +1816,7 @@ def _scoped(query: Any, model: Any) -> Any: logger.warning(f"Missing map: '{m.filename}' ({m.filepath})") if scan_tokens: for t in _scoped(session.query(Token), Token).all(): - gone = not os.path.exists(t.filepath) + gone = _gone(t.filepath) if gone != bool(t.is_missing): t.is_missing = gone if gone: @@ -1774,7 +1824,7 @@ def _scoped(query: Any, model: Any) -> Any: logger.warning(f"Missing token: '{t.filename}' ({t.filepath})") if scan_audio: for a in _scoped(session.query(Audio), Audio).all(): - gone = not os.path.exists(a.filepath) + gone = _gone(a.filepath) if gone != bool(a.is_missing): a.is_missing = gone if gone: diff --git a/backend/library_ignore.py b/backend/library_ignore.py new file mode 100644 index 0000000..41dd08a --- /dev/null +++ b/backend/library_ignore.py @@ -0,0 +1,109 @@ +"""``.grimoireignore`` support for the library scanner (issue #224). + +A ``.grimoireignore`` file uses the same syntax as ``.gitignore`` / +``.dockerignore`` (git's *gitignore* dialect): one pattern per line, blank +lines and ``#`` comments ignored, ``!`` negation to re-include, ``/`` to anchor +or mark a directory, and ``**`` for arbitrary-depth matching. + +Files and directories matched by an ignore rule are skipped by the scanner, so +they never appear in the library UI. Rules are **cumulative and nested** like +git: a ``.grimoireignore`` at the library root applies everywhere, and one in a +subfolder adds rules for its own subtree. Each file's patterns are matched +relative to the directory that file lives in. + +The public entry point is :class:`IgnoreMatcher`, built once per scan from the +library root and queried per path with :meth:`IgnoreMatcher.is_ignored`. +""" + +import logging +import os +from pathlib import Path +from typing import Optional + +from pathspec import PathSpec + +logger = logging.getLogger("grimoire.indexer") + +# Name of the per-directory ignore file, mirroring ``.gitignore``. +IGNORE_FILENAME = ".grimoireignore" + + +class IgnoreMatcher: + """Resolves ``.grimoireignore`` rules for paths under a library root. + + Ignore files are loaded lazily and cached per directory, so a full scan + reads each ``.grimoireignore`` at most once. Matching walks from the + library root down to the path's own directory, applying each directory's + patterns relative to that directory (git semantics), with deeper and + later rules overriding shallower ones — including ``!`` re-inclusion. + """ + + def __init__(self, library_root: str) -> None: + # Resolve so ancestor computation is stable regardless of how callers + # spell the root (trailing slash, symlinks in the walked paths, …). + self._root = Path(library_root).resolve() + # dir (resolved) -> compiled PathSpec, or None when that dir has no + # ignore file. None is cached too, so a missing file is stat'd once. + self._cache: dict[Path, Optional[PathSpec]] = {} + + def _spec_for_dir(self, directory: Path) -> Optional[PathSpec]: + """Load and cache the PathSpec for a single directory's ignore file.""" + if directory in self._cache: + return self._cache[directory] + spec: Optional[PathSpec] = None + ignore_file = directory / IGNORE_FILENAME + try: + if ignore_file.is_file(): + lines = ignore_file.read_text(encoding="utf-8").splitlines() + spec = PathSpec.from_lines("gitignore", lines) + except OSError as exc: + logger.warning(f"Could not read {ignore_file}: {exc}") + self._cache[directory] = spec + return spec + + def _ancestors(self, directory: Path) -> list[Path]: + """Return [root, …, directory], shallowest first, or [] if outside root.""" + try: + directory = directory.resolve() + except OSError: + return [] + if directory != self._root and self._root not in directory.parents: + return [] + chain = [directory] + while chain[-1] != self._root: + chain.append(chain[-1].parent) + chain.reverse() + return chain + + def is_ignored(self, path: str, is_dir: bool) -> bool: + """Return True if `path` is excluded by any applicable ignore rule. + + `path` is an absolute filesystem path. `is_dir` must be True for + directories so that directory-only patterns (``foo/``) match correctly. + Deeper directories' rules are evaluated after shallower ones, so a + nested ``.grimoireignore`` can override (including re-include via ``!``) + what an ancestor ignored; the last matching rule wins, as in git. + """ + # Resolve so the path is spelled the same way as the ancestor chain + # (which is derived from the resolved library root); otherwise a symlink + # in the root (e.g. macOS ``/var`` -> ``/private/var``) makes relpath + # produce a bogus ``../..`` prefix that never matches. resolve() is + # non-strict, so it works even for the removed-file reconciliation case + # where the path no longer exists on disk. + try: + p = Path(path).resolve() + except OSError: + p = Path(path) + result = False + for ancestor in self._ancestors(p.parent): + spec = self._spec_for_dir(ancestor) + if spec is None: + continue + # Patterns in `ancestor`'s ignore file are relative to `ancestor`. + rel = os.path.relpath(str(p), str(ancestor)).replace(os.sep, "/") + if is_dir: + rel += "/" + match = spec.check_file(rel) + if match.include is not None: + result = match.include + return result diff --git a/backend/requirements.txt b/backend/requirements.txt index 0ebe9d6..6439d86 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -20,4 +20,5 @@ mutagen==1.47.0 rarfile==4.2 py7zr==1.1.3 pytesseract==0.3.13 +pathspec==1.1.1 tzdata==2025.2 diff --git a/backend/tests/test_indexer_ignore.py b/backend/tests/test_indexer_ignore.py new file mode 100644 index 0000000..5d62f85 --- /dev/null +++ b/backend/tests/test_indexer_ignore.py @@ -0,0 +1,188 @@ +"""Integration tests for ``.grimoireignore`` in the library scanner (issue #224). + +Drives the real ``scan_library`` against a temp library and asserts that files +and directories matched by a ``.grimoireignore`` rule are never registered, that +progress totals exclude them, and that a file newly matched by an ignore rule is +marked missing (removed from the UI) on rescan. +""" +import tempfile +from pathlib import Path + +from backend.config import SessionLocal +from backend.indexer import scan_library +from backend.library_ignore import IGNORE_FILENAME +from backend.models import Audio, Book, GameSystem, GenericMap, Token + + +def _mk_lib(): + tmp = tempfile.mkdtemp() + lib = Path(tmp) / "library" + lib.mkdir() + return tmp, lib + + +def _mkdir(lib: Path, *parts: str) -> Path: + d = lib.joinpath(*parts) + d.mkdir(parents=True, exist_ok=True) + return d + + +def _touch_pdf(folder: Path, name: str) -> Path: + p = folder / name + p.write_bytes(b"%PDF-1.4") + return p + + +def _touch_img(folder: Path, name: str) -> Path: + p = folder / name + p.write_bytes(b"\x89PNG\r\n\x1a\n") + return p + + +def _touch_audio(folder: Path, name: str) -> Path: + p = folder / name + p.write_bytes(b"ID3fakefake") + return p + + +def _scan(lib: Path, tmp: str, **kw) -> dict: + db = SessionLocal() + try: + return scan_library(str(lib), tmp, db, **kw) + finally: + db.close() + + +def _book_filenames(system_slug: str) -> set[str]: + db = SessionLocal() + try: + system = db.query(GameSystem).filter_by(slug=system_slug).first() + if system is None: + return set() + return {b.filename for b in db.query(Book).filter_by(game_system_id=system.id).all()} + finally: + db.close() + + +class TestBooksIgnore: + def test_ignored_directory_and_glob_not_indexed(self): + tmp, lib = _mk_lib() + core = _mkdir(lib, "books", "Ign224 Books", "core") + ignore = _mkdir(lib, "books", "Ign224 Books", "ignore") + _touch_pdf(core, "Players Handbook.pdf") + _touch_pdf(core, "Players Handbook BW Single Pages.pdf") + _touch_pdf(ignore, "Zine Variant.pdf") + (lib / IGNORE_FILENAME).write_text("ignore/\n*BW Single Pages*.pdf\n") + + _scan(lib, tmp) + + names = _book_filenames("ign224-books") + assert "Players Handbook.pdf" in names + assert "Players Handbook BW Single Pages.pdf" not in names + assert "Zine Variant.pdf" not in names + + def test_ignore_file_itself_not_indexed(self): + tmp, lib = _mk_lib() + core = _mkdir(lib, "books", "Ign224 Self", "core") + _touch_pdf(core, "Book.pdf") + (lib / IGNORE_FILENAME).write_text("*.tmp\n") + _scan(lib, tmp) + # The dot-file is skipped like any hidden file; only the real book lands. + assert _book_filenames("ign224-self") == {"Book.pdf"} + + def test_nested_ignore_scoped_to_subtree(self): + tmp, lib = _mk_lib() + sys_a = _mkdir(lib, "books", "Ign224 SysA", "core") + sys_b = _mkdir(lib, "books", "Ign224 SysB", "core") + _touch_pdf(sys_a, "draft.bak.pdf") + _touch_pdf(sys_b, "draft.bak.pdf") + # Nested ignore under SysA only. + (lib / "books" / "Ign224 SysA" / IGNORE_FILENAME).write_text("*.bak.pdf\n") + + _scan(lib, tmp) + + assert "draft.bak.pdf" not in _book_filenames("ign224-sysa") + assert "draft.bak.pdf" in _book_filenames("ign224-sysb") + + def test_progress_total_excludes_ignored(self): + tmp, lib = _mk_lib() + core = _mkdir(lib, "books", "Ign224 Total", "core") + _touch_pdf(core, "keep.pdf") + _touch_pdf(core, "skip.pdf") + (lib / IGNORE_FILENAME).write_text("skip.pdf\n") + + seen_totals = [] + + def on_progress(sb, tb, *rest): + seen_totals.append(tb) + + _scan(lib, tmp, on_progress=on_progress) + # Total books reported to the progress callback counts only the kept file. + assert max(seen_totals) == 1 + + +class TestOtherCollectionsIgnore: + def test_maps_tokens_audio_respect_ignore(self): + tmp, lib = _mk_lib() + maps = _mkdir(lib, "maps", "Ign224") + tokens = _mkdir(lib, "tokens", "Ign224") + audio = _mkdir(lib, "audio", "Ign224") + _touch_img(maps, "keep.png") + _touch_img(maps, "skip.png") + _touch_img(tokens, "keep.png") + _touch_img(tokens, "skip.png") + _touch_audio(audio, "keep.mp3") + _touch_audio(audio, "skip.mp3") + (lib / IGNORE_FILENAME).write_text("skip.png\nskip.mp3\n") + + _scan(lib, tmp) + + db = SessionLocal() + try: + map_names = {m.filename for m in db.query(GenericMap).all()} + token_names = {t.filename for t in db.query(Token).all()} + audio_names = {a.filename for a in db.query(Audio).all()} + finally: + db.close() + assert "keep.png" in map_names and "skip.png" not in map_names + assert "keep.png" in token_names and "skip.png" not in token_names + assert "keep.mp3" in audio_names and "skip.mp3" not in audio_names + + +class TestReconciliation: + def test_newly_ignored_book_marked_missing_then_restored(self): + tmp, lib = _mk_lib() + core = _mkdir(lib, "books", "Ign224 Recon", "core") + _touch_pdf(core, "later-ignored.pdf") + + # First scan: no ignore file — the book is present and visible. + _scan(lib, tmp) + + def _book(): + db = SessionLocal() + try: + system = db.query(GameSystem).filter_by(slug="ign224-recon").first() + return db.query(Book).filter_by( + game_system_id=system.id, filename="later-ignored.pdf" + ).first(), db + finally: + pass # caller closes + + book, db = _book() + assert book is not None and not book.is_missing + db.close() + + # Add an ignore rule and rescan: the on-disk file is now excluded, so it + # should be flagged missing (hidden from the UI). + (lib / IGNORE_FILENAME).write_text("later-ignored.pdf\n") + _scan(lib, tmp) + book, db = _book() + assert book.is_missing is True + db.close() + + # Remove the rule and rescan: the book comes back. + (lib / IGNORE_FILENAME).unlink() + _scan(lib, tmp) + book, db = _book() + assert book.is_missing is False + db.close() diff --git a/backend/tests/test_library_ignore.py b/backend/tests/test_library_ignore.py new file mode 100644 index 0000000..8504714 --- /dev/null +++ b/backend/tests/test_library_ignore.py @@ -0,0 +1,138 @@ +"""Unit tests for the ``.grimoireignore`` matcher (issue #224). + +These exercise :class:`IgnoreMatcher` directly, without touching the scanner or +the database, covering gitwildmatch semantics: globs, directory-only patterns, +anchoring, ``**`` depth, cumulative nested files, and ``!`` re-inclusion. +""" +import tempfile +from pathlib import Path + +from backend.library_ignore import IGNORE_FILENAME, IgnoreMatcher + + +def _mk_root() -> Path: + root = Path(tempfile.mkdtemp()) / "library" + root.mkdir() + return root + + +def _write_ignore(directory: Path, *lines: str) -> None: + directory.mkdir(parents=True, exist_ok=True) + (directory / IGNORE_FILENAME).write_text("\n".join(lines) + "\n", encoding="utf-8") + + +class TestNoIgnoreFile: + def test_nothing_ignored_without_file(self): + root = _mk_root() + m = IgnoreMatcher(str(root)) + assert m.is_ignored(str(root / "books" / "a.pdf"), is_dir=False) is False + + def test_path_outside_root_is_not_ignored(self): + root = _mk_root() + _write_ignore(root, "*.pdf") + m = IgnoreMatcher(str(root)) + # A sibling of the library root is never governed by its ignore file. + outside = root.parent / "elsewhere" / "a.pdf" + assert m.is_ignored(str(outside), is_dir=False) is False + + +class TestGlobPatterns: + def test_wildcard_matches_at_any_depth(self): + root = _mk_root() + _write_ignore(root, "*BW Single Pages*.pdf") + m = IgnoreMatcher(str(root)) + deep = root / "books" / "Sys" / "core" / "Players BW Single Pages.pdf" + assert m.is_ignored(str(deep), is_dir=False) is True + keep = root / "books" / "Sys" / "core" / "Players Handbook.pdf" + assert m.is_ignored(str(keep), is_dir=False) is False + + def test_extension_glob(self): + root = _mk_root() + _write_ignore(root, "*.tmp") + m = IgnoreMatcher(str(root)) + assert m.is_ignored(str(root / "a" / "b.tmp"), is_dir=False) is True + assert m.is_ignored(str(root / "a" / "b.pdf"), is_dir=False) is False + + +class TestDirectoryPatterns: + def test_directory_only_pattern_matches_dir(self): + root = _mk_root() + _write_ignore(root, "ignore/") + m = IgnoreMatcher(str(root)) + d = root / "books" / "Sys" / "ignore" + assert m.is_ignored(str(d), is_dir=True) is True + + def test_directory_pattern_matches_contained_file(self): + root = _mk_root() + _write_ignore(root, "ignore/") + m = IgnoreMatcher(str(root)) + f = root / "books" / "Sys" / "ignore" / "variant.pdf" + assert m.is_ignored(str(f), is_dir=False) is True + + def test_directory_only_pattern_does_not_match_file_of_same_name(self): + root = _mk_root() + _write_ignore(root, "ignore/") + m = IgnoreMatcher(str(root)) + # A *file* literally named "ignore" is not a directory match. + f = root / "books" / "ignore" + assert m.is_ignored(str(f), is_dir=False) is False + + +class TestAnchoringAndDepth: + def test_anchored_pattern_matches_only_at_root(self): + root = _mk_root() + _write_ignore(root, "/drafts") + m = IgnoreMatcher(str(root)) + assert m.is_ignored(str(root / "drafts"), is_dir=True) is True + # A nested "drafts" is not anchored at the root, so it is kept. + assert m.is_ignored(str(root / "books" / "drafts"), is_dir=True) is False + + def test_globstar_matches_across_directories(self): + root = _mk_root() + _write_ignore(root, "books/**/scratch/**") + m = IgnoreMatcher(str(root)) + f = root / "books" / "Sys" / "deep" / "scratch" / "x.pdf" + assert m.is_ignored(str(f), is_dir=False) is True + + +class TestNestedCumulative: + def test_nested_file_adds_rules_for_subtree(self): + root = _mk_root() + sub = root / "books" / "Sys" + _write_ignore(sub, "*.bak") + m = IgnoreMatcher(str(root)) + # Governed by the nested file. + assert m.is_ignored(str(sub / "core" / "a.bak"), is_dir=False) is True + # A sibling subtree without that nested file is unaffected. + assert m.is_ignored(str(root / "books" / "Other" / "a.bak"), is_dir=False) is False + + def test_nested_negation_reincludes_ancestor_ignored_file(self): + root = _mk_root() + _write_ignore(root, "*.pdf") + sub = root / "books" / "Sys" + _write_ignore(sub, "!keep.pdf") + m = IgnoreMatcher(str(root)) + # Ancestor ignores all PDFs … + assert m.is_ignored(str(root / "books" / "other.pdf"), is_dir=False) is True + # … but the nested file re-includes this one. + assert m.is_ignored(str(sub / "keep.pdf"), is_dir=False) is False + + +class TestComments: + def test_comments_and_blank_lines_ignored(self): + root = _mk_root() + _write_ignore(root, "# a comment", "", "*.pdf") + m = IgnoreMatcher(str(root)) + assert m.is_ignored(str(root / "a.pdf"), is_dir=False) is True + + +class TestCaching: + def test_spec_cached_after_file_removed(self): + """The matcher reads each ignore file once; later removal doesn't re-stat.""" + root = _mk_root() + _write_ignore(root, "*.pdf") + m = IgnoreMatcher(str(root)) + assert m.is_ignored(str(root / "a.pdf"), is_dir=False) is True + (root / IGNORE_FILENAME).unlink() + # Cached spec still applies — a single scan sees a consistent rule set. + assert m.is_ignored(str(root / "b.pdf"), is_dir=False) is True