Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions src/tau_coding/gitignore.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Parse project .gitignore files for path filtering."""

from __future__ import annotations

import fnmatch
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path


@dataclass(frozen=True, slots=True)
class GitignoreRules:
"""Compiled ignore rules from a project root .gitignore file."""

ignored_dirs: frozenset[str]
ignored_ext: frozenset[str]
patterns: tuple[tuple[str, bool], ...]

def matches(self, relative_path: str, *, is_dir: bool) -> bool:
"""Return whether a cwd-relative path should be ignored."""
parts = relative_path.split("/")
for index in range(len(parts)):
segment_path = "/".join(parts[: index + 1])
segment_is_dir = index < len(parts) - 1 or is_dir
basename = parts[index]
if segment_is_dir and basename in self.ignored_dirs:
return True
if (
not segment_is_dir
and index == len(parts) - 1
and any(basename.endswith(ext) for ext in self.ignored_ext)
):
return True
for pattern, dir_only in self.patterns:
if dir_only and not segment_is_dir:
continue
if _match_gitignore_pattern(pattern, segment_path, is_dir=segment_is_dir):
return True
return False


def load_gitignore_rules(project_root: Path) -> GitignoreRules:
"""Load ignore rules from ``project_root/.gitignore``."""
gitignore_path = project_root / ".gitignore"
if not gitignore_path.is_file():
return GitignoreRules(frozenset(), frozenset(), ())

ignored_dirs: set[str] = set()
ignored_ext: set[str] = set()
patterns: list[tuple[str, bool]] = []
for raw_line in gitignore_path.read_text(encoding="utf-8").splitlines():
parsed = _classify_gitignore_line(raw_line)
if parsed is None:
continue
pattern, dir_only = parsed
patterns.append((pattern, dir_only))
if dir_only and "/" not in pattern:
ignored_dirs.add(pattern)
if not dir_only and pattern.startswith("*.") and "/" not in pattern:
ignored_ext.add(pattern[1:])

return GitignoreRules(frozenset(ignored_dirs), frozenset(ignored_ext), tuple(patterns))


@lru_cache(maxsize=32)
def gitignore_rules_for(project_root: str) -> GitignoreRules:
"""Return cached gitignore rules for a project root path string."""
return load_gitignore_rules(Path(project_root))


def _classify_gitignore_line(raw_line: str) -> tuple[str, bool] | None:
line = raw_line.strip()
if not line or line.startswith("#") or line.startswith("!"):
return None
if line.startswith("\\#"):
line = line[1:]
if line.startswith("\\!"):
line = line[1:]
if line.startswith("/"):
line = line[1:]
dir_only = line.endswith("/")
if dir_only:
line = line[:-1]
if not line:
return None
return line, dir_only


def _match_gitignore_pattern(pattern: str, path: str, *, is_dir: bool) -> bool:
normalized = path.replace("\\", "/")
if "/" not in pattern:
basename = normalized.rsplit("/", 1)[-1]
if fnmatch.fnmatch(basename, pattern):
return True
if is_dir:
return any(fnmatch.fnmatch(part, pattern) for part in normalized.split("/"))
return False
return fnmatch.fnmatch(normalized, pattern) or normalized.startswith(f"{pattern}/")
15 changes: 13 additions & 2 deletions src/tau_coding/tui/autocomplete.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from pathlib import Path

from tau_coding.commands import CommandRegistry, SlashCommand
from tau_coding.gitignore import gitignore_rules_for
from tau_coding.prompt_templates import PromptTemplate
from tau_coding.skills import Skill

Expand Down Expand Up @@ -209,9 +210,19 @@ def _is_ignored_file_completion_path(path: Path, *, cwd: Path) -> bool:
relative_parts = path.relative_to(cwd).parts
except ValueError:
return True
return any(
if any(
part.startswith(".") or part in IGNORED_FILE_COMPLETION_DIRS for part in relative_parts
)
):
return True
relative = path.relative_to(cwd).as_posix()
rules = gitignore_rules_for(str(cwd.resolve()))
segments = relative.split("/")
for index, _segment in enumerate(segments):
segment_path = "/".join(segments[: index + 1])
is_dir = index < len(segments) - 1 or path.is_dir()
if rules.matches(segment_path, is_dir=is_dir):
return True
return False


def _shell_path_completions(*, text: str, cwd: Path) -> tuple[CompletionItem, ...] | None:
Expand Down
25 changes: 25 additions & 0 deletions tests/test_gitignore.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Tests for .gitignore parsing."""

from __future__ import annotations

from pathlib import Path

from tau_coding.gitignore import load_gitignore_rules


def test_load_gitignore_rules_matches_directory_and_extension(tmp_path: Path) -> None:
(tmp_path / ".gitignore").write_text(
"build/\n*.log\n",
encoding="utf-8",
)
rules = load_gitignore_rules(tmp_path)

assert rules.matches("build", is_dir=True)
assert rules.matches("build/output.txt", is_dir=False)
assert rules.matches("notes.log", is_dir=False)
assert not rules.matches("src/main.py", is_dir=False)


def test_gitignore_rules_ignore_missing_file(tmp_path: Path) -> None:
rules = load_gitignore_rules(tmp_path)
assert not rules.matches("README.md", is_dir=False)
19 changes: 19 additions & 0 deletions tests/test_tui_autocomplete.py
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,25 @@ def test_file_reference_completion_matches_workspace_files(tmp_path: Path) -> No
assert state.selected.apply("please read @app") == "please read @src/app.py"


def test_file_reference_completion_respects_gitignore(tmp_path: Path) -> None:
(tmp_path / ".gitignore").write_text("build/\n*.log\n", encoding="utf-8")
(tmp_path / "src").mkdir()
(tmp_path / "src" / "app.py").write_text("print('hi')\n", encoding="utf-8")
(tmp_path / "build").mkdir()
(tmp_path / "build" / "ignored.py").write_text("", encoding="utf-8")
(tmp_path / "notes.log").write_text("", encoding="utf-8")

state = build_completion_state(
"please read @app",
command_registry=create_default_command_registry(),
skills=(),
prompt_templates=(),
cwd=tmp_path,
)

assert [item.display for item in state.items] == ["@src/app.py"]


def test_file_reference_completion_stays_off_for_slash_commands(tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("# Project\n", encoding="utf-8")

Expand Down