diff --git a/src/gitingest/entrypoint.py b/src/gitingest/entrypoint.py index f6b5c8c8..bc80c576 100644 --- a/src/gitingest/entrypoint.py +++ b/src/gitingest/entrypoint.py @@ -269,7 +269,7 @@ def _apply_gitignores(query: IngestionQuery) -> None: """ for fname in (".gitignore", ".gitingestignore"): - query.ignore_patterns.update(load_ignore_patterns(query.local_path, filename=fname)) + query.ignore_patterns = [*query.ignore_patterns, *load_ignore_patterns(query.local_path, filename=fname)] @asynccontextmanager diff --git a/src/gitingest/utils/ignore_patterns.py b/src/gitingest/utils/ignore_patterns.py index 5236e4d1..50af274b 100644 --- a/src/gitingest/utils/ignore_patterns.py +++ b/src/gitingest/utils/ignore_patterns.py @@ -168,7 +168,7 @@ } -def load_ignore_patterns(root: Path, filename: str) -> set[str]: +def load_ignore_patterns(root: Path, filename: str) -> list[str]: """Load ignore patterns from ``filename`` found under ``root``. The loader walks the directory tree, looks for the supplied ``filename``, @@ -185,20 +185,20 @@ def load_ignore_patterns(root: Path, filename: str) -> set[str]: Returns ------- - set[str] - A set of ignore patterns extracted from the ``filename`` file found under the ``root`` directory. + list[str] + Ignore patterns extracted from the ``filename`` file found under the ``root`` directory. """ - patterns: set[str] = set() + patterns: list[str] = [] for ignore_file in root.rglob(filename): if ignore_file.is_file(): - patterns.update(_parse_ignore_file(ignore_file, root)) + patterns.extend(_parse_ignore_file(ignore_file, root)) return patterns -def _parse_ignore_file(ignore_file: Path, root: Path) -> set[str]: - """Parse an ignore file and return a set of ignore patterns. +def _parse_ignore_file(ignore_file: Path, root: Path) -> list[str]: + """Parse an ignore file and return ignore patterns. Parameters ---------- @@ -209,11 +209,11 @@ def _parse_ignore_file(ignore_file: Path, root: Path) -> set[str]: Returns ------- - set[str] - A set of ignore patterns. + list[str] + Ignore patterns in file order. """ - patterns: set[str] = set() + patterns: list[str] = [] # Path of the ignore file relative to the repository root rel_dir = ignore_file.parent.relative_to(root) @@ -235,6 +235,6 @@ def _parse_ignore_file(ignore_file: Path, root: Path) -> set[str]: line = line.lstrip("/") pattern_body = (base_dir / line).as_posix() - patterns.add(f"!{pattern_body}" if negated else pattern_body) + patterns.append(f"!{pattern_body}" if negated else pattern_body) return patterns diff --git a/src/gitingest/utils/ingestion_utils.py b/src/gitingest/utils/ingestion_utils.py index 8795b66c..a9501168 100644 --- a/src/gitingest/utils/ingestion_utils.py +++ b/src/gitingest/utils/ingestion_utils.py @@ -7,6 +7,7 @@ from pathspec import PathSpec if TYPE_CHECKING: + from collections.abc import Iterable from pathlib import Path @@ -40,7 +41,7 @@ def _should_include(path: Path, base_path: Path, include_patterns: set[str]) -> return spec.match_file(str(rel_path)) -def _should_exclude(path: Path, base_path: Path, ignore_patterns: set[str]) -> bool: +def _should_exclude(path: Path, base_path: Path, ignore_patterns: Iterable[str]) -> bool: """Return ``True`` if ``path`` matches any of ``ignore_patterns``. Parameters diff --git a/tests/test_gitignore_feature.py b/tests/test_gitignore_feature.py index 68ab821a..ceb30dfd 100644 --- a/tests/test_gitignore_feature.py +++ b/tests/test_gitignore_feature.py @@ -48,6 +48,14 @@ def test_load_gitignore_patterns(tmp_path: Path) -> None: assert not pattern.startswith("#") +def test_load_gitignore_patterns_preserves_negation_order(tmp_path: Path) -> None: + """Test that negated gitignore patterns keep their file order.""" + gitignore = tmp_path / ".gitignore" + gitignore.write_text("*.log\n!important.log\n") + + assert load_ignore_patterns(tmp_path, filename=".gitignore") == ["*.log", "!important.log"] + + @pytest.mark.asyncio async def test_ingest_with_gitignore(repo_path: Path) -> None: """Integration test for ``ingest_async()`` respecting ``.gitignore`` rules. @@ -67,3 +75,21 @@ async def test_ingest_with_gitignore(repo_path: Path) -> None: # Now both files should be present. assert "This file should be excluded." in content_without_ignore assert "This file should be included." in content_without_ignore + + +@pytest.mark.asyncio +async def test_ingest_with_gitignore_negation(tmp_path: Path) -> None: + """Test that later negated gitignore rules can re-include files.""" + gitignore = tmp_path / ".gitignore" + gitignore.write_text("*.log\n!important.log\n") + + ignored_file = tmp_path / "ignored.log" + ignored_file.write_text("This log should be ignored.") + + important_file = tmp_path / "important.log" + important_file.write_text("This log should be included.") + + _, _, content = await ingest_async(source=str(tmp_path)) + + assert "This log should be ignored." not in content + assert "This log should be included." in content