Coverage for src/lilbee/data/ingest/ignore.py: 100%
55 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +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, data_root: Path) -> IgnoreRules:
71 """Build rules carrying the corpus-wide layer from the ignore file in *data_root*."""
72 return cls(_load_spec(data_root / IGNORE_FILENAME))
74 def _spec_for(self, directory: Path) -> PathSpec | None:
75 if directory not in self._specs:
76 self._specs[directory] = _load_spec(directory / IGNORE_FILENAME)
77 return self._specs[directory]
79 def _chain_for(self, directory: Path, base: Path) -> tuple[tuple[Path, PathSpec], ...]:
80 """The ignore files covering *directory*, deepest first.
82 Cached per directory and built from the parent's chain, so a tree with no
83 ignore files costs one dict hit per directory and nothing per file. The
84 walk up terminates on *base*, which every caller guarantees is an
85 ancestor by building the path from it.
86 """
87 cached = self._chains.get(directory)
88 if cached is not None:
89 return cached
90 parent = () if directory == base else self._chain_for(directory.parent, base)
91 spec = self._spec_for(directory)
92 chain = (((directory, spec),) if spec is not None else ()) + parent
93 self._chains[directory] = chain
94 return chain
96 def _verdict(self, entry: Path, *, base: Path, is_dir: bool) -> bool | None:
97 """Whether the layers covering *entry* exclude it, or None if none match.
99 Layers are consulted deepest first and the first one that expresses an
100 opinion wins, which is what makes a nested file able to re-include what
101 a shallower one dropped.
102 """
103 for directory, spec in self._chain_for(entry.parent, base):
104 relative = entry.relative_to(directory).as_posix()
105 verdict = spec.check_file(relative + "/" if is_dir else relative).include
106 if verdict is not None:
107 return verdict
108 if self._corpus_spec is not None:
109 relative = entry.relative_to(base).as_posix()
110 return self._corpus_spec.check_file(relative + "/" if is_dir else relative).include
111 return None
113 def excludes_entry(self, entry: Path, *, base: Path, is_dir: bool) -> bool:
114 """Whether *entry* itself is excluded, assuming its ancestors are not.
116 The discovery walk prunes top-down, so by the time it asks about an entry
117 every directory above it has already been kept.
118 """
119 return self._verdict(entry, base=base, is_dir=is_dir) is True
121 def excludes_path(self, path: Path, *, base: Path) -> bool:
122 """Whether *path* or any directory between it and *base* is excluded.
124 Answers for files the walk never enumerated because it pruned a parent,
125 which is what lets the index be reconciled without a second walk. A file
126 under an excluded directory stays excluded even if a pattern names it
127 back, matching git: the walk never descends far enough to reconsider.
129 *path* must sit under *base*; both come from the same resolution step,
130 which builds one from the other.
131 """
132 parts = path.relative_to(base).parts
133 current = base
134 last = len(parts) - 1
135 for index, part in enumerate(parts):
136 current = current / part
137 if self.excludes_entry(current, base=base, is_dir=index != last):
138 return True
139 return False