From 95937c63ad6e4f8217def50365556a39e714d96b Mon Sep 17 00:00:00 2001 From: yoshibase Date: Fri, 24 Jul 2026 08:12:02 -0700 Subject: [PATCH] fix(tui): respect .gitignore in @ file-reference completions Fixes #436. Parse the project root .gitignore and skip ignored paths when building @ file-reference autocomplete suggestions. --- src/tau_coding/gitignore.py | 98 ++++++++++++++++++++++++++++++ src/tau_coding/tui/autocomplete.py | 15 ++++- tests/test_gitignore.py | 25 ++++++++ tests/test_tui_autocomplete.py | 19 ++++++ 4 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 src/tau_coding/gitignore.py create mode 100644 tests/test_gitignore.py diff --git a/src/tau_coding/gitignore.py b/src/tau_coding/gitignore.py new file mode 100644 index 000000000..7416c9e52 --- /dev/null +++ b/src/tau_coding/gitignore.py @@ -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}/") diff --git a/src/tau_coding/tui/autocomplete.py b/src/tau_coding/tui/autocomplete.py index 7f462bf5e..bb473ef9d 100644 --- a/src/tau_coding/tui/autocomplete.py +++ b/src/tau_coding/tui/autocomplete.py @@ -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 @@ -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: diff --git a/tests/test_gitignore.py b/tests/test_gitignore.py new file mode 100644 index 000000000..bea556111 --- /dev/null +++ b/tests/test_gitignore.py @@ -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) diff --git a/tests/test_tui_autocomplete.py b/tests/test_tui_autocomplete.py index 1c46ff0d1..6d3d05ce8 100644 --- a/tests/test_tui_autocomplete.py +++ b/tests/test_tui_autocomplete.py @@ -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")