Skip to content

Commit 109c2bf

Browse files
committed
Fix .gitignore precedence and pattern handling
Fixes #144 by correcting the order and handling of subdirectory .gitignore files: 1. **Fixed pattern precedence**: Changed sort order from deepest-first to shallowest-first, allowing subdirectory patterns to properly override parent patterns (Git processes .gitignore from root to leaf) 2. **Fixed negation patterns**: Preserve ! prefix at the beginning when adjusting patterns for subdirectories (was becoming `dir/!pattern` instead of `!dir/**/pattern`) 3. **Fixed subdirectory pattern scope**: Patterns in subdirectory .gitignore files now use `/**/` to match at any depth under that directory (e.g., `level1/**/*.cache` instead of `level1/*.cache`), matching Git's actual behavior 4. **Added comprehensive tests**: - Test CodeSearcher respects subdirectory .gitignore - Test absolute patterns in subdirectories - Test complex negation scenarios - Test deeply nested .gitignore files with multiple levels All original tests from PR #144 now pass, plus 4 additional edge case tests.
1 parent 421f753 commit 109c2bf

File tree

3 files changed

+151
-10
lines changed

3 files changed

+151
-10
lines changed

src/kit/code_searcher.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def _load_gitignore(self):
4040
"""Load all .gitignore files in repository tree and merge them.
4141
4242
Returns a PathSpec that respects all .gitignore files, with proper
43-
precedence (deeper paths override shallower ones).
43+
precedence (patterns from deeper directories can override root patterns).
4444
"""
4545
gitignore_files = []
4646

@@ -57,8 +57,9 @@ def _load_gitignore(self):
5757
if not gitignore_files:
5858
return None
5959

60-
# Sort by depth (deepest first) for correct precedence
61-
gitignore_files.sort(key=lambda p: len(p.parts), reverse=True)
60+
# Sort by depth (shallowest first) for correct precedence
61+
# Git processes .gitignore files from root to leaf, so later patterns can override earlier ones
62+
gitignore_files.sort(key=lambda p: len(p.parts))
6263

6364
# Collect all patterns with proper path prefixes
6465
all_patterns = []
@@ -84,19 +85,29 @@ def _load_gitignore(self):
8485
if not pattern or pattern.startswith("#"):
8586
continue
8687

88+
# Handle negation patterns (must preserve ! at the beginning)
89+
is_negation = pattern.startswith("!")
90+
if is_negation:
91+
pattern = pattern[1:] # Remove the ! temporarily
92+
8793
# Adjust pattern to be relative to repo root
8894
if str(rel_base) != ".":
8995
# Pattern is in subdirectory - prefix with path
9096
if pattern.startswith("/"):
9197
# Absolute pattern (from gitignore dir) - make relative to repo
9298
adjusted = f"{rel_base}/{pattern[1:]}"
9399
else:
94-
# Relative pattern - prefix with directory path
95-
adjusted = f"{rel_base}/{pattern}"
100+
# Relative pattern - applies to directory and all subdirectories
101+
# Use /** to match files at any depth under the directory
102+
adjusted = f"{rel_base}/**/{pattern}" if "*" in pattern else f"{rel_base}/{pattern}"
96103
else:
97104
# Pattern is in root .gitignore - use as-is
98105
adjusted = pattern
99106

107+
# Re-add negation prefix if needed
108+
if is_negation:
109+
adjusted = f"!{adjusted}"
110+
100111
all_patterns.append(adjusted)
101112

102113
except Exception as e:

src/kit/repo_mapper.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def _load_gitignore(self):
2727
"""Load all .gitignore files in repository tree and merge them.
2828
2929
Returns a PathSpec that respects all .gitignore files, with proper
30-
precedence (deeper paths override shallower ones).
30+
precedence (patterns from deeper directories can override root patterns).
3131
"""
3232
gitignore_files = []
3333

@@ -44,8 +44,9 @@ def _load_gitignore(self):
4444
if not gitignore_files:
4545
return None
4646

47-
# Sort by depth (deepest first) for correct precedence
48-
gitignore_files.sort(key=lambda p: len(p.parts), reverse=True)
47+
# Sort by depth (shallowest first) for correct precedence
48+
# Git processes .gitignore files from root to leaf, so later patterns can override earlier ones
49+
gitignore_files.sort(key=lambda p: len(p.parts))
4950

5051
# Collect all patterns with proper path prefixes
5152
all_patterns = []
@@ -71,19 +72,29 @@ def _load_gitignore(self):
7172
if not pattern or pattern.startswith("#"):
7273
continue
7374

75+
# Handle negation patterns (must preserve ! at the beginning)
76+
is_negation = pattern.startswith("!")
77+
if is_negation:
78+
pattern = pattern[1:] # Remove the ! temporarily
79+
7480
# Adjust pattern to be relative to repo root
7581
if str(rel_base) != ".":
7682
# Pattern is in subdirectory - prefix with path
7783
if pattern.startswith("/"):
7884
# Absolute pattern (from gitignore dir) - make relative to repo
7985
adjusted = f"{rel_base}/{pattern[1:]}"
8086
else:
81-
# Relative pattern - prefix with directory path
82-
adjusted = f"{rel_base}/{pattern}"
87+
# Relative pattern - applies to directory and all subdirectories
88+
# Use /** to match files at any depth under the directory
89+
adjusted = f"{rel_base}/**/{pattern}" if "*" in pattern else f"{rel_base}/{pattern}"
8390
else:
8491
# Pattern is in root .gitignore - use as-is
8592
adjusted = pattern
8693

94+
# Re-add negation prefix if needed
95+
if is_negation:
96+
adjusted = f"!{adjusted}"
97+
8798
all_patterns.append(adjusted)
8899

89100
except Exception as e:

tests/test_gitignore.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,122 @@ def test_no_gitignore_files():
136136
paths = [item["path"] for item in tree]
137137
assert "test.py" in paths
138138
assert "src/main.py" in paths
139+
140+
141+
def test_code_searcher_respects_subdirectory_gitignore():
142+
"""Test CodeSearcher also respects subdirectory .gitignore files."""
143+
with tempfile.TemporaryDirectory() as tmpdir:
144+
repo = Path(tmpdir)
145+
from kit.code_searcher import CodeSearcher
146+
147+
# Create subdirectory with its own .gitignore
148+
subdir = repo / "src"
149+
subdir.mkdir()
150+
(subdir / ".gitignore").write_text("*.log\n")
151+
152+
# Create test files with searchable content
153+
(repo / "root.py").write_text("search_pattern")
154+
(subdir / "code.py").write_text("search_pattern")
155+
(subdir / "debug.log").write_text("search_pattern")
156+
157+
searcher = CodeSearcher(str(repo))
158+
results = searcher.search_text("search_pattern")
159+
160+
# Should find matches in .py but not .log
161+
files = [r["file"] for r in results]
162+
assert "root.py" in files
163+
assert "src/code.py" in files
164+
assert "src/debug.log" not in files
165+
166+
167+
def test_absolute_patterns_in_subdirectory():
168+
"""Test absolute patterns (starting with /) in subdirectory .gitignore."""
169+
with tempfile.TemporaryDirectory() as tmpdir:
170+
repo = Path(tmpdir)
171+
172+
# Subdirectory with absolute pattern
173+
subdir = repo / "frontend"
174+
subdir.mkdir()
175+
(subdir / ".gitignore").write_text("/build/\n")
176+
177+
# Create test files
178+
(subdir / "src").mkdir()
179+
(subdir / "src" / "app.js").touch()
180+
(subdir / "build").mkdir()
181+
(subdir / "build" / "bundle.js").touch()
182+
(subdir / "src" / "build").mkdir()
183+
(subdir / "src" / "build" / "config.js").touch()
184+
185+
mapper = RepoMapper(str(repo))
186+
tree = mapper.get_file_tree()
187+
188+
paths = [item["path"] for item in tree]
189+
# /build/ should only ignore frontend/build/, not frontend/src/build/
190+
assert "frontend/src/app.js" in paths
191+
assert "frontend/build/bundle.js" not in paths
192+
assert "frontend/src/build/config.js" in paths
193+
194+
195+
def test_complex_negation_patterns():
196+
"""Test complex negation scenarios."""
197+
with tempfile.TemporaryDirectory() as tmpdir:
198+
repo = Path(tmpdir)
199+
200+
# Root ignores all .env files
201+
(repo / ".gitignore").write_text("*.env\n")
202+
203+
# Config directory allows .env.example
204+
config = repo / "config"
205+
config.mkdir()
206+
(config / ".gitignore").write_text("!*.env.example\n")
207+
208+
# Create test files
209+
(repo / "root.env").touch()
210+
(repo / "README.md").touch()
211+
(config / "app.env").touch()
212+
(config / "template.env.example").touch()
213+
214+
mapper = RepoMapper(str(repo))
215+
tree = mapper.get_file_tree()
216+
217+
paths = [item["path"] for item in tree]
218+
assert "README.md" in paths
219+
assert "root.env" not in paths
220+
assert "config/app.env" not in paths
221+
assert "config/template.env.example" in paths # Negation allows it
222+
223+
224+
def test_deeply_nested_gitignores():
225+
"""Test .gitignore files at multiple depth levels."""
226+
with tempfile.TemporaryDirectory() as tmpdir:
227+
repo = Path(tmpdir)
228+
229+
# Root .gitignore
230+
(repo / ".gitignore").write_text("*.tmp\n")
231+
232+
# Level 1
233+
l1 = repo / "level1"
234+
l1.mkdir()
235+
(l1 / ".gitignore").write_text("*.cache\n")
236+
(l1 / "file.txt").touch()
237+
(l1 / "file.tmp").touch()
238+
(l1 / "file.cache").touch()
239+
240+
# Level 2
241+
l2 = l1 / "level2"
242+
l2.mkdir()
243+
(l2 / ".gitignore").write_text("!*.tmp\n") # Re-allow .tmp here
244+
(l2 / "deep.txt").touch()
245+
(l2 / "deep.tmp").touch()
246+
(l2 / "deep.cache").touch()
247+
248+
mapper = RepoMapper(str(repo))
249+
tree = mapper.get_file_tree()
250+
251+
paths = [item["path"] for item in tree]
252+
assert "level1/file.txt" in paths
253+
assert "level1/file.tmp" not in paths # Ignored by root
254+
assert "level1/file.cache" not in paths # Ignored by level1
255+
assert "level1/level2/deep.txt" in paths
256+
assert "level1/level2/deep.tmp" in paths # Negation allows it
257+
assert "level1/level2/deep.cache" not in paths # Still ignored by level1

0 commit comments

Comments
 (0)