Coverage for src/lilbee/data/ingest/ignore.py: 100%
56 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-17 10:02 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-17 10:02 +0000
1"""``.lilbeeignore`` loading and matching for the discovery walk.
3Pattern syntax and precedence are gitignore's, matched by ``pathspec`` rather
4than re-implemented: negation, ``**``, dir-only trailing slashes and anchoring
5are subtle enough that a hand-rolled matcher is a standing bug source.
7Two layers apply. The corpus layer is a single file at the resolved data root,
8so it is project-local inside a ``lilbee init`` tree and global otherwise -- the
9same resolution ``--data-dir`` / ``LILBEE_DATA`` / ``.lilbee/`` already gives
10every other piece of lilbee state. Tree layers are ``.lilbeeignore`` files at any
11depth inside a walked root, each scoped to its own directory and below. Deeper
12layers win, so a repo can re-include what the corpus layer drops.
13"""
15from __future__ import annotations
17from pathlib import Path
19from pathspec import PathSpec
21IGNORE_FILENAME = ".lilbeeignore"
23_SYNTAX = "gitignore"
25IGNORE_TEMPLATE = """\
26# Files sync keeps out of the index. Same syntax as .gitignore.
27# This file covers everything you index. Put a .lilbeeignore inside a
28# tree to scope patterns to it; the deeper file wins, so ! adds back.
29#
30# Build and dependency directories, and every dot-directory, are already
31# excluded. Add what those miss:
32#
33# *.min.js
34# testdata/
35# fixtures/
36"""
39def _load_spec(path: Path) -> PathSpec | None:
40 """Compile the ignore file at *path*, or None if it holds no live pattern.
42 A comment or a blank line still compiles to a pattern object, one whose
43 ``include`` is None because it can never match. The file ``lilbee init``
44 scaffolds is entirely comments, so keeping those would charge every walked
45 file a lookup against a spec with nothing in it.
46 """
47 try:
48 text = path.read_text(encoding="utf-8")
49 except OSError:
50 return None
51 spec = PathSpec.from_lines(_SYNTAX, text.splitlines())
52 return spec if any(pattern.include is not None for pattern in spec.patterns) else None
55class IgnoreRules:
56 """Nested ``.lilbeeignore`` matching, with per-directory compilation cached.
58 One instance serves a whole sync pass: the discovery walk asks about each
59 entry it visits, and the pass that reconciles the index against the corpus
60 asks about paths the walk pruned before reaching. Both answers come from the
61 same compiled specs, so the two cannot disagree.
62 """
64 def __init__(self, corpus_spec: PathSpec | None = None) -> None:
65 self._corpus_spec = corpus_spec
66 self._specs: dict[Path, PathSpec | None] = {}
67 self._chains: dict[Path, tuple[tuple[Path, PathSpec], ...]] = {}
69 @classmethod
70 def for_corpus(cls) -> IgnoreRules:
71 """Build rules carrying the corpus-wide layer from the resolved data root."""
72 from lilbee.core.config import active_config
74 return cls(_load_spec(active_config().data_root / IGNORE_FILENAME))
76 def _spec_for(self, directory: Path) -> PathSpec | None:
77 if directory not in self._specs:
78 self._specs[directory] = _load_spec(directory / IGNORE_FILENAME)
79 return self._specs[directory]
81 def _chain_for(self, directory: Path, base: Path) -> tuple[tuple[Path, PathSpec], ...]:
82 """The ignore files covering *directory*, deepest first.
84 Cached per directory and built from the parent's chain, so a tree with no
85 ignore files costs one dict hit per directory and nothing per file. The
86 walk up terminates on *base*, which every caller guarantees is an
87 ancestor by building the path from it.
88 """
89 cached = self._chains.get(directory)
90 if cached is not None:
91 return cached
92 parent = () if directory == base else self._chain_for(directory.parent, base)
93 spec = self._spec_for(directory)
94 chain = (((directory, spec),) if spec is not None else ()) + parent
95 self._chains[directory] = chain
96 return chain
98 def _verdict(self, entry: Path, *, base: Path, is_dir: bool) -> bool | None:
99 """Whether the layers covering *entry* exclude it, or None if none match.
101 Layers are consulted deepest first and the first one that expresses an
102 opinion wins, which is what makes a nested file able to re-include what
103 a shallower one dropped.
104 """
105 for directory, spec in self._chain_for(entry.parent, base):
106 relative = entry.relative_to(directory).as_posix()
107 verdict = spec.check_file(relative + "/" if is_dir else relative).include
108 if verdict is not None:
109 return verdict
110 if self._corpus_spec is not None:
111 relative = entry.relative_to(base).as_posix()
112 return self._corpus_spec.check_file(relative + "/" if is_dir else relative).include
113 return None
115 def excludes_entry(self, entry: Path, *, base: Path, is_dir: bool) -> bool:
116 """Whether *entry* itself is excluded, assuming its ancestors are not.
118 The discovery walk prunes top-down, so by the time it asks about an entry
119 every directory above it has already been kept.
120 """
121 return self._verdict(entry, base=base, is_dir=is_dir) is True
123 def excludes_path(self, path: Path, *, base: Path) -> bool:
124 """Whether *path* or any directory between it and *base* is excluded.
126 Answers for files the walk never enumerated because it pruned a parent,
127 which is what lets the index be reconciled without a second walk. A file
128 under an excluded directory stays excluded even if a pattern names it
129 back, matching git: the walk never descends far enough to reconsider.
131 *path* must sit under *base*; both come from the same resolution step,
132 which builds one from the other.
133 """
134 parts = path.relative_to(base).parts
135 current = base
136 last = len(parts) - 1
137 for index, part in enumerate(parts):
138 current = current / part
139 if self.excludes_entry(current, base=base, is_dir=index != last):
140 return True
141 return False