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

1"""``.lilbeeignore`` loading and matching for the discovery walk. 

2 

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. 

6 

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""" 

14 

15from __future__ import annotations 

16 

17from pathlib import Path 

18 

19from pathspec import PathSpec 

20 

21IGNORE_FILENAME = ".lilbeeignore" 

22 

23_SYNTAX = "gitignore" 

24 

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""" 

37 

38 

39def _load_spec(path: Path) -> PathSpec | None: 

40 """Compile the ignore file at *path*, or None if it holds no live pattern. 

41 

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 

53 

54 

55class IgnoreRules: 

56 """Nested ``.lilbeeignore`` matching, with per-directory compilation cached. 

57 

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 """ 

63 

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], ...]] = {} 

68 

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 

73 

74 return cls(_load_spec(active_config().data_root / IGNORE_FILENAME)) 

75 

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] 

80 

81 def _chain_for(self, directory: Path, base: Path) -> tuple[tuple[Path, PathSpec], ...]: 

82 """The ignore files covering *directory*, deepest first. 

83 

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 

97 

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. 

100 

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 

114 

115 def excludes_entry(self, entry: Path, *, base: Path, is_dir: bool) -> bool: 

116 """Whether *entry* itself is excluded, assuming its ancestors are not. 

117 

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 

122 

123 def excludes_path(self, path: Path, *, base: Path) -> bool: 

124 """Whether *path* or any directory between it and *base* is excluded. 

125 

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. 

130 

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