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>
167 lines
5.0 KiB
Python
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)
|