Files
oai/oai/mcp/gitignore.py
Rune Olsen b0cf88704e 2.1 (#2)
Final release of version 2.1.

Headlights:

### Core Features
- 🤖 Interactive chat with 300+ AI models via OpenRouter
- 🔍 Model selection with search and filtering
- 💾 Conversation save/load/export (Markdown, JSON, HTML)
- 📎 File attachments (images, PDFs, code files)
- 💰 Real-time cost tracking and credit monitoring
- 🎨 Rich terminal UI with syntax highlighting
- 📝 Persistent command history with search (Ctrl+R)
- 🌐 Online mode (web search capabilities)
- 🧠 Conversation memory toggle

### MCP Integration
- 🔧 **File Mode**: AI can read, search, and list local files
  - Automatic .gitignore filtering
  - Virtual environment exclusion
  - Large file handling (auto-truncates >50KB)

- ✍️ **Write Mode**: AI can modify files with permission
  - Create, edit, delete files
  - Move, copy, organize files
  - Always requires explicit opt-in

- 🗄️ **Database Mode**: AI can query SQLite databases
  - Read-only access (safe)
  - Schema inspection
  - Full SQL query support

Reviewed-on: #2
Co-authored-by: Rune Olsen <rune@rune.pm>
Co-committed-by: Rune Olsen <rune@rune.pm>
2026-02-03 09:02:44 +01:00

167 lines
5.0 KiB
Python

"""
Gitignore pattern parsing for oAI MCP.
This module implements .gitignore pattern matching to filter files
during MCP filesystem operations.
"""
import fnmatch
from pathlib import Path
from typing import List, Tuple
from oai.utils.logging import get_logger
class GitignoreParser:
"""
Parse and apply .gitignore patterns.
Supports standard gitignore syntax including:
- Wildcards (*) and double wildcards (**)
- Directory-only patterns (ending with /)
- Negation patterns (starting with !)
- Comments (lines starting with #)
Patterns are applied relative to the directory containing
the .gitignore file.
"""
def __init__(self):
"""Initialize an empty pattern collection."""
# List of (pattern, is_negation, source_dir)
self.patterns: List[Tuple[str, bool, Path]] = []
def add_gitignore(self, gitignore_path: Path) -> None:
"""
Parse and add patterns from a .gitignore file.
Args:
gitignore_path: Path to the .gitignore file
"""
logger = get_logger()
if not gitignore_path.exists():
return
try:
source_dir = gitignore_path.parent
with open(gitignore_path, "r", encoding="utf-8") as f:
for line_num, line in enumerate(f, 1):
line = line.rstrip("\n\r")
# Skip empty lines and comments
if not line or line.startswith("#"):
continue
# Check for negation pattern
is_negation = line.startswith("!")
if is_negation:
line = line[1:]
# Remove leading slash (make relative to gitignore location)
if line.startswith("/"):
line = line[1:]
self.patterns.append((line, is_negation, source_dir))
logger.debug(
f"Loaded {len(self.patterns)} patterns from {gitignore_path}"
)
except Exception as e:
logger.warning(f"Error reading {gitignore_path}: {e}")
def should_ignore(self, path: Path) -> bool:
"""
Check if a path should be ignored based on gitignore patterns.
Patterns are evaluated in order, with later patterns overriding
earlier ones. Negation patterns (starting with !) un-ignore
previously matched paths.
Args:
path: Path to check
Returns:
True if the path should be ignored
"""
if not self.patterns:
return False
ignored = False
for pattern, is_negation, source_dir in self.patterns:
# Only apply pattern if path is under the source directory
try:
rel_path = path.relative_to(source_dir)
except ValueError:
# Path is not relative to this gitignore's directory
continue
rel_path_str = str(rel_path)
# Check if pattern matches
if self._match_pattern(pattern, rel_path_str, path.is_dir()):
if is_negation:
ignored = False # Negation patterns un-ignore
else:
ignored = True
return ignored
def _match_pattern(self, pattern: str, path: str, is_dir: bool) -> bool:
"""
Match a gitignore pattern against a path.
Args:
pattern: The gitignore pattern
path: The relative path string to match
is_dir: Whether the path is a directory
Returns:
True if the pattern matches
"""
# Directory-only pattern (ends with /)
if pattern.endswith("/"):
if not is_dir:
return False
pattern = pattern[:-1]
# Handle ** patterns (matches any number of directories)
if "**" in pattern:
pattern_parts = pattern.split("**")
if len(pattern_parts) == 2:
prefix, suffix = pattern_parts
# Match if path starts with prefix and ends with suffix
if prefix:
if not path.startswith(prefix.rstrip("/")):
return False
if suffix:
suffix = suffix.lstrip("/")
if not (path.endswith(suffix) or f"/{suffix}" in path):
return False
return True
# Direct match using fnmatch
if fnmatch.fnmatch(path, pattern):
return True
# Match as subdirectory pattern (pattern without / matches in any directory)
if "/" not in pattern:
parts = path.split("/")
if any(fnmatch.fnmatch(part, pattern) for part in parts):
return True
return False
def clear(self) -> None:
"""Clear all loaded patterns."""
self.patterns = []
@property
def pattern_count(self) -> int:
"""Get the number of loaded patterns."""
return len(self.patterns)