Skip to content

Commit 5b664bf

Browse files
committed
Dramatically speed up git ignored file checking using pathspec directly and make getting files mentions roughly ~100 faster
1 parent 568d763 commit 5b664bf

2 files changed

Lines changed: 102 additions & 50 deletions

File tree

cecli/coders/base_coder.py

Lines changed: 29 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2809,73 +2809,57 @@ def add_assistant_reply_to_cur_messages(self):
28092809
)
28102810

28112811
def get_file_mentions(self, content, ignore_current=False):
2812-
# Get file-like words from content (contiguous strings containing slashes or periods)
2812+
# 1. Extract words once: O(N)
28132813
words = set()
28142814
for word in content.split():
2815-
# Strip quotes and punctuation
28162815
word = word.strip("\"'`*_,.!;:?")
28172816
if re.search(r"[\\\/._-]", word):
28182817
words.add(word)
28192818

2820-
# Also check basenames of file-like words
2821-
basename_words = set()
2822-
for word in words:
2823-
basename = os.path.basename(word)
2824-
if basename and basename != word: # Only add if basename is different
2825-
basename_words.add(basename)
2826-
2827-
# Combine all words to check
2819+
basename_words = {os.path.basename(w) for w in words if os.path.basename(w) != w}
28282820
all_words = words | basename_words
28292821

2830-
if ignore_current:
2831-
files_to_check = self.get_all_relative_files()
2832-
existing_basenames = set()
2833-
else:
2834-
files_to_check = self.get_addable_relative_files()
2835-
# Get basenames of files already in chat or read-only
2822+
# Pre-normalize for O(1) lookups: O(W)
2823+
normalized_words = {w.replace("\\", "/") for w in all_words}
2824+
2825+
# 2. Get files and filter ignored once: O(F)
2826+
raw_files = (
2827+
self.get_all_relative_files() if ignore_current else self.get_addable_relative_files()
2828+
)
2829+
2830+
# Filter ignored files once to avoid repeated expensive calls
2831+
files_to_check = [f for f in raw_files if not (self.repo and self.repo.git_ignored_file(f))]
2832+
2833+
# 3. Existing basenames setup
2834+
existing_basenames = set()
2835+
2836+
if not ignore_current:
28362837
existing_basenames = {os.path.basename(f) for f in self.get_inchat_relative_files()} | {
28372838
os.path.basename(self.get_rel_fname(f))
28382839
for f in self.abs_read_only_fnames | self.abs_read_only_stubs_fnames
28392840
}
28402841

2841-
# Build map of basenames to files for uniqueness check
2842-
# Only consider basenames that look like filenames (contain /, \, ., _, or -)
2843-
# to avoid false matches on common words like "run" or "make"
2842+
# 4. Build map: O(F)
28442843
basename_to_files = {}
28452844
for rel_fname in files_to_check:
2846-
# Skip git-ignored files
2847-
if self.repo and self.repo.git_ignored_file(rel_fname):
2848-
continue
2849-
2850-
basename = os.path.basename(rel_fname)
2851-
# Only include basenames that look like filenames
2852-
if re.search(r"[\\\/._-]", basename):
2853-
if basename not in basename_to_files:
2854-
basename_to_files[basename] = []
2855-
basename_to_files[basename].append(rel_fname)
2845+
bn = os.path.basename(rel_fname)
2846+
if re.search(r"[\\\/._-]", bn):
2847+
basename_to_files.setdefault(bn, []).append(rel_fname)
28562848

2849+
# 5. Final selection: O(F)
28572850
mentioned_rel_fnames = set()
2858-
28592851
for rel_fname in files_to_check:
2860-
# Skip git-ignored files
2861-
if self.repo and self.repo.git_ignored_file(rel_fname):
2862-
continue
2863-
2864-
# Check if full path matches
2865-
normalized_fname = rel_fname.replace("\\", "/")
2866-
normalized_words = {w.replace("\\", "/") for w in all_words}
2867-
2868-
if normalized_fname in normalized_words:
2852+
# Full path match
2853+
if rel_fname.replace("\\", "/") in normalized_words:
28692854
mentioned_rel_fnames.add(rel_fname)
28702855
continue
28712856

2872-
# Check basename - only add if unique among addable files and not already in chat
2873-
basename = os.path.basename(rel_fname)
2857+
# Basename match logic
2858+
bn = os.path.basename(rel_fname)
28742859
if (
2875-
basename in all_words
2876-
and basename not in existing_basenames
2877-
and len(basename_to_files.get(basename, [])) == 1
2878-
and basename_to_files[basename][0] == rel_fname
2860+
bn in all_words
2861+
and bn not in existing_basenames
2862+
and len(basename_to_files.get(bn, [])) == 1
28792863
):
28802864
mentioned_rel_fnames.add(rel_fname)
28812865

cecli/repo.py

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ class GitRepo:
5858
subtree_only = False
5959
ignore_file_cache = {}
6060
git_repo_error = None
61+
gitignore_spec_cache = {}
62+
gitignore_file_cache = {}
63+
gitignore_last_check = 0
6164

6265
def __init__(
6366
self,
@@ -525,16 +528,81 @@ def refresh_cecli_ignore(self):
525528
lines,
526529
)
527530

531+
def _get_gitignore_spec(self, dir_path):
532+
"""Get or create a GitIgnoreSpec for a directory, caching for performance."""
533+
dir_path = Path(dir_path).resolve()
534+
535+
# Check cache first
536+
if dir_path in self.gitignore_spec_cache:
537+
return self.gitignore_spec_cache[dir_path]
538+
539+
# Read .gitignore from this directory
540+
patterns = []
541+
gitignore_path = dir_path / ".gitignore"
542+
if gitignore_path.is_file():
543+
try:
544+
with open(gitignore_path, "r") as f:
545+
patterns = [
546+
line.rstrip("\n") for line in f if line.strip() and not line.startswith("#")
547+
]
548+
except (OSError, IOError):
549+
pass
550+
551+
# Create spec for this directory
552+
if patterns:
553+
spec = pathspec.GitIgnoreSpec.from_lines(patterns)
554+
else:
555+
spec = pathspec.GitIgnoreSpec.from_lines([])
556+
557+
self.gitignore_spec_cache[dir_path] = spec
558+
return spec
559+
560+
def _is_gitignored_by_pathspec(self, path):
561+
"""Check if a file is ignored by any .gitignore file using pathspec."""
562+
if not self.repo:
563+
return False
564+
565+
try:
566+
file_path = Path(path).resolve()
567+
if not file_path.is_relative_to(self.root):
568+
return False
569+
570+
# Walk up from file's directory to root
571+
current_dir = file_path.parent
572+
relative_path = file_path.relative_to(self.root)
573+
574+
# Check each directory level
575+
while current_dir.is_relative_to(self.root):
576+
spec = self._get_gitignore_spec(current_dir)
577+
578+
# Get path relative to the directory containing the .gitignore
579+
if current_dir == Path(self.root).resolve():
580+
path_to_check = str(relative_path)
581+
else:
582+
path_to_check = str(
583+
relative_path.relative_to(current_dir.relative_to(self.root))
584+
)
585+
586+
if spec.match_file(path_to_check):
587+
return True
588+
589+
# Move up one directory
590+
if current_dir == Path(self.root).resolve():
591+
break
592+
current_dir = current_dir.parent
593+
594+
return False
595+
except (ValueError, OSError):
596+
return False
597+
528598
def git_ignored_file(self, path):
529599
if not self.repo:
530600
return
531601
try:
532-
repo_ignored = self.repo.ignored(path)
533-
534602
if not self.cecli_ignore_file or not self.cecli_ignore_file.is_file():
535-
return repo_ignored
536-
537-
return self.ignored_file(path)
603+
return self._is_gitignored_by_pathspec(path)
604+
else:
605+
return self.ignored_file(path)
538606
except ANY_GIT_ERROR:
539607
return False
540608

0 commit comments

Comments
 (0)