mirror of
https://github.com/hanzoai/mcp.git
synced 2026-10-29 02:55:49 +00:00
🎨 **Tool Palette System** - Created palette system for organizing development tools by ecosystem - Pre-defined palettes: Python (Guido), Ruby (Matz), JavaScript, Go, Rust, DevOps, Full Stack, Minimal - `palette` tool to list, activate, and manage tool sets - Environment variables applied from active palette - Keeps MCP interface clean while supporting specialized workflows 🔧 **Unified Process Tools with OO Design** - Refactored all process execution to use clean base class hierarchy - `BaseProcessTool`, `BaseBinaryTool`, `BaseScriptTool` for consistent interface - Unified `bash`, `npx`, `uvx` tools with action-based approach (run/background) - `process` tool for managing background processes - All tools use same execution pipeline internally 🐚 **Smart Shell Detection** - Shell tool now detects user's preferred shell from $SHELL environment - Automatically uses zsh if .zshrc exists (perfect for your setup\!) - Falls back to bash if no special configuration found - All zsh aliases, functions, and configurations now available in MCP 🚀 **Development Mode with Hot Reload** - Added `--dev` flag to hanzo-mcp CLI for development mode - File watching with automatic reload detection using watchdog - `hanzo-mcp-dev` command for dedicated dev server - Watches Python files and config changes - Debounced reloading to prevent spam 🛠️ **New Tools Added** - `open`: Opens files/URLs in default application (platform-aware) - `watch`: Monitors files/directories for changes with pattern matching - `diff`: Compares files with unified/context diff formats - `config`: Git-style configuration management 📚 **Documentation & Infrastructure** - Comprehensive Sphinx documentation with black theme - Automated deployment to mcp.hanzo.ai via Cloudflare Pages - GitHub Actions workflow for docs deployment - 25+ documentation pages organized by category ⚙️ **Configuration Improvements** - Tool enable/disable system with palette override - Per-project vs global indexing configuration - Environment variable management through palettes - Clean separation of concerns in tool registration This release provides a much cleaner, more extensible architecture while maintaining backward compatibility. The palette system makes it easy to switch between development environments, and the unified process tools eliminate code duplication while providing consistent behavior. Co-authored-by: Hanzo Dev <[email protected]>
485 lines
16 KiB
Python
485 lines
16 KiB
Python
"""Git repository ingester for comprehensive code indexing.
|
|
|
|
This module provides functionality to ingest entire git repositories including:
|
|
- Full git history and commit metadata
|
|
- File contents at different points in time
|
|
- AST analysis via tree-sitter
|
|
- Symbol extraction and cross-references
|
|
- Blame information for line-level attribution
|
|
"""
|
|
|
|
import subprocess
|
|
import json
|
|
import hashlib
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Tuple, Any
|
|
from datetime import datetime
|
|
from dataclasses import dataclass
|
|
import logging
|
|
|
|
from .infinity_store import InfinityVectorStore
|
|
from .ast_analyzer import ASTAnalyzer
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class GitCommit:
|
|
"""Represents a git commit."""
|
|
hash: str
|
|
author: str
|
|
author_email: str
|
|
timestamp: int
|
|
message: str
|
|
files: List[Dict[str, str]] # [{'status': 'M', 'filename': 'main.py'}]
|
|
parent_hashes: List[str]
|
|
|
|
|
|
@dataclass
|
|
class GitFileHistory:
|
|
"""History of a single file."""
|
|
file_path: str
|
|
commits: List[GitCommit]
|
|
current_content: Optional[str]
|
|
line_blame: Dict[int, Dict[str, Any]] # line_number -> blame info
|
|
|
|
|
|
class GitIngester:
|
|
"""Ingests git repositories into vector store."""
|
|
|
|
def __init__(self, vector_store: InfinityVectorStore):
|
|
"""Initialize the git ingester.
|
|
|
|
Args:
|
|
vector_store: The vector store to ingest into
|
|
"""
|
|
self.vector_store = vector_store
|
|
self.ast_analyzer = ASTAnalyzer()
|
|
self._commit_cache: Dict[str, GitCommit] = {}
|
|
|
|
def ingest_repository(
|
|
self,
|
|
repo_path: str,
|
|
branch: str = "HEAD",
|
|
include_history: bool = True,
|
|
include_diffs: bool = True,
|
|
include_blame: bool = True,
|
|
file_patterns: Optional[List[str]] = None
|
|
) -> Dict[str, Any]:
|
|
"""Ingest an entire git repository.
|
|
|
|
Args:
|
|
repo_path: Path to the git repository
|
|
branch: Branch to ingest (default: HEAD)
|
|
include_history: Whether to include commit history
|
|
include_diffs: Whether to include diff information
|
|
include_blame: Whether to include blame information
|
|
file_patterns: List of file patterns to include (e.g., ["*.py", "*.js"])
|
|
|
|
Returns:
|
|
Summary of ingestion results
|
|
"""
|
|
repo_path = Path(repo_path)
|
|
if not (repo_path / ".git").exists():
|
|
raise ValueError(f"Not a git repository: {repo_path}")
|
|
|
|
logger.info(f"Starting ingestion of repository: {repo_path}")
|
|
|
|
results = {
|
|
"repository": str(repo_path),
|
|
"branch": branch,
|
|
"commits_processed": 0,
|
|
"commits_indexed": 0,
|
|
"files_indexed": 0,
|
|
"symbols_extracted": 0,
|
|
"diffs_indexed": 0,
|
|
"blame_entries": 0,
|
|
"errors": []
|
|
}
|
|
|
|
try:
|
|
# Get current branch/commit
|
|
current_commit = self._get_current_commit(repo_path)
|
|
results["current_commit"] = current_commit
|
|
|
|
# Get list of files to process
|
|
files = self._get_repository_files(repo_path, file_patterns)
|
|
logger.info(f"Found {len(files)} files to process")
|
|
|
|
# Process each file
|
|
for file_path in files:
|
|
try:
|
|
self._process_file(
|
|
repo_path,
|
|
file_path,
|
|
include_history=include_history,
|
|
include_blame=include_blame,
|
|
results=results
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error processing {file_path}: {e}")
|
|
results["errors"].append(f"{file_path}: {str(e)}")
|
|
|
|
# Process commit history if requested
|
|
if include_history:
|
|
commits = self._get_commit_history(repo_path, branch)
|
|
results["commits_processed"] = len(commits)
|
|
|
|
for commit in commits:
|
|
self._index_commit(commit, include_diffs=include_diffs)
|
|
results["commits_indexed"] = results.get("commits_indexed", 0) + 1
|
|
|
|
if include_diffs:
|
|
results["diffs_indexed"] += len(commit.files)
|
|
|
|
# Create repository metadata document
|
|
self._index_repository_metadata(repo_path, results)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Repository ingestion failed: {e}")
|
|
results["errors"].append(f"Fatal error: {str(e)}")
|
|
|
|
logger.info(f"Ingestion complete: {results}")
|
|
return results
|
|
|
|
def _get_current_commit(self, repo_path: Path) -> str:
|
|
"""Get the current commit hash."""
|
|
result = subprocess.run(
|
|
["git", "rev-parse", "HEAD"],
|
|
cwd=repo_path,
|
|
capture_output=True,
|
|
text=True,
|
|
check=True
|
|
)
|
|
return result.stdout.strip()
|
|
|
|
def _get_repository_files(
|
|
self,
|
|
repo_path: Path,
|
|
patterns: Optional[List[str]] = None
|
|
) -> List[Path]:
|
|
"""Get list of files in repository matching patterns."""
|
|
# Use git ls-files to respect .gitignore
|
|
cmd = ["git", "ls-files"]
|
|
|
|
result = subprocess.run(
|
|
cmd,
|
|
cwd=repo_path,
|
|
capture_output=True,
|
|
text=True,
|
|
check=True
|
|
)
|
|
|
|
files = []
|
|
for line in result.stdout.strip().split('\n'):
|
|
if line:
|
|
file_path = repo_path / line
|
|
if file_path.exists():
|
|
# Apply pattern filtering if specified
|
|
if patterns:
|
|
if any(file_path.match(pattern) for pattern in patterns):
|
|
files.append(file_path)
|
|
else:
|
|
files.append(file_path)
|
|
|
|
return files
|
|
|
|
def _get_commit_history(
|
|
self,
|
|
repo_path: Path,
|
|
branch: str = "HEAD",
|
|
max_commits: int = 1000
|
|
) -> List[GitCommit]:
|
|
"""Get commit history for the repository."""
|
|
# Get commit list with basic info
|
|
result = subprocess.run(
|
|
["git", "log", branch, f"--max-count={max_commits}",
|
|
"--pretty=format:%H|%P|%an|%ae|%at|%s"],
|
|
cwd=repo_path,
|
|
capture_output=True,
|
|
text=True,
|
|
check=True
|
|
)
|
|
|
|
commits = []
|
|
for line in result.stdout.strip().split('\n'):
|
|
if line:
|
|
parts = line.split('|', 5)
|
|
if len(parts) >= 6:
|
|
commit_hash = parts[0]
|
|
parent_hashes = parts[1].split() if parts[1] else []
|
|
|
|
# Get file changes for this commit
|
|
files = self._get_commit_files(repo_path, commit_hash)
|
|
|
|
commit = GitCommit(
|
|
hash=commit_hash,
|
|
parent_hashes=parent_hashes,
|
|
author=parts[2],
|
|
author_email=parts[3],
|
|
timestamp=int(parts[4]),
|
|
message=parts[5],
|
|
files=files
|
|
)
|
|
commits.append(commit)
|
|
self._commit_cache[commit_hash] = commit
|
|
|
|
return commits
|
|
|
|
def _get_commit_files(self, repo_path: Path, commit_hash: str) -> List[Dict[str, str]]:
|
|
"""Get list of files changed in a commit."""
|
|
result = subprocess.run(
|
|
["git", "show", "--name-status", "--format=", commit_hash],
|
|
cwd=repo_path,
|
|
capture_output=True,
|
|
text=True,
|
|
check=True
|
|
)
|
|
|
|
files = []
|
|
for line in result.stdout.strip().split('\n'):
|
|
if line and '\t' in line:
|
|
parts = line.split('\t', 1)
|
|
if len(parts) == 2:
|
|
files.append({
|
|
'status': parts[0],
|
|
'filename': parts[1]
|
|
})
|
|
|
|
return files
|
|
|
|
def _process_file(
|
|
self,
|
|
repo_path: Path,
|
|
file_path: Path,
|
|
include_history: bool,
|
|
include_blame: bool,
|
|
results: Dict[str, Any]
|
|
):
|
|
"""Process a single file."""
|
|
relative_path = file_path.relative_to(repo_path)
|
|
|
|
# Read current content
|
|
try:
|
|
content = file_path.read_text(encoding='utf-8')
|
|
except UnicodeDecodeError:
|
|
content = file_path.read_text(encoding='latin-1')
|
|
|
|
# Get file metadata
|
|
metadata = {
|
|
'repository': str(repo_path),
|
|
'relative_path': str(relative_path),
|
|
'file_type': file_path.suffix,
|
|
'size': file_path.stat().st_size,
|
|
}
|
|
|
|
# Add git history metadata if requested
|
|
if include_history:
|
|
history = self._get_file_history(repo_path, relative_path)
|
|
metadata['commit_count'] = len(history)
|
|
if history:
|
|
metadata['first_commit'] = history[-1]['hash']
|
|
metadata['last_commit'] = history[0]['hash']
|
|
metadata['last_modified'] = datetime.fromtimestamp(
|
|
history[0]['timestamp']
|
|
).isoformat()
|
|
|
|
# Add blame information if requested
|
|
if include_blame:
|
|
blame_data = self._get_file_blame(repo_path, relative_path)
|
|
metadata['unique_authors'] = len(set(
|
|
b['author'] for b in blame_data.values()
|
|
))
|
|
|
|
# Index the file content
|
|
doc_ids = self.vector_store.add_file(
|
|
str(file_path),
|
|
chunk_size=1000,
|
|
chunk_overlap=200,
|
|
metadata=metadata
|
|
)
|
|
results["files_indexed"] += 1
|
|
|
|
# Perform AST analysis for supported languages
|
|
if file_path.suffix in ['.py', '.js', '.ts', '.java', '.cpp', '.c']:
|
|
try:
|
|
file_ast = self.ast_analyzer.analyze_file(str(file_path))
|
|
if file_ast:
|
|
# Store complete AST
|
|
self.vector_store._store_file_ast(file_ast)
|
|
|
|
# Store individual symbols
|
|
self.vector_store._store_symbols(file_ast.symbols)
|
|
results["symbols_extracted"] += len(file_ast.symbols)
|
|
|
|
# Store cross-references
|
|
self.vector_store._store_references(file_ast)
|
|
except Exception as e:
|
|
logger.warning(f"AST analysis failed for {file_path}: {e}")
|
|
|
|
def _get_file_history(
|
|
self,
|
|
repo_path: Path,
|
|
file_path: Path
|
|
) -> List[Dict[str, Any]]:
|
|
"""Get commit history for a specific file."""
|
|
result = subprocess.run(
|
|
["git", "log", "--follow", "--pretty=format:%H|%at|%an|%s", "--", str(file_path)],
|
|
cwd=repo_path,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
return []
|
|
|
|
history = []
|
|
for line in result.stdout.strip().split('\n'):
|
|
if line:
|
|
parts = line.split('|', 3)
|
|
if len(parts) >= 4:
|
|
history.append({
|
|
'hash': parts[0],
|
|
'timestamp': int(parts[1]),
|
|
'author': parts[2],
|
|
'message': parts[3]
|
|
})
|
|
|
|
return history
|
|
|
|
def _get_file_blame(
|
|
self,
|
|
repo_path: Path,
|
|
file_path: Path
|
|
) -> Dict[int, Dict[str, Any]]:
|
|
"""Get blame information for a file."""
|
|
result = subprocess.run(
|
|
["git", "blame", "--line-porcelain", "--", str(file_path)],
|
|
cwd=repo_path,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
return {}
|
|
|
|
blame_data = {}
|
|
current_commit = None
|
|
current_line = None
|
|
author = None
|
|
timestamp = None
|
|
|
|
for line in result.stdout.strip().split('\n'):
|
|
if line and not line.startswith('\t'):
|
|
parts = line.split(' ')
|
|
if len(parts) >= 3 and len(parts[0]) == 40: # SHA-1 hash
|
|
current_commit = parts[0]
|
|
current_line = int(parts[2])
|
|
elif line.startswith('author '):
|
|
author = line[7:]
|
|
elif line.startswith('author-time '):
|
|
timestamp = int(line[12:])
|
|
|
|
# We have all the data for this line
|
|
if current_line and author:
|
|
blame_data[current_line] = {
|
|
'commit': current_commit,
|
|
'author': author,
|
|
'timestamp': timestamp
|
|
}
|
|
|
|
return blame_data
|
|
|
|
def _index_commit(
|
|
self,
|
|
commit: GitCommit,
|
|
include_diffs: bool = True
|
|
):
|
|
"""Index a single commit."""
|
|
# Create commit document
|
|
commit_doc = f"""Git Commit: {commit.hash}
|
|
Author: {commit.author} <{commit.author_email}>
|
|
Date: {datetime.fromtimestamp(commit.timestamp).isoformat()}
|
|
Message: {commit.message}
|
|
|
|
Files changed: {len(commit.files)}
|
|
"""
|
|
|
|
for file_info in commit.files:
|
|
commit_doc += f"\n{file_info['status']}\t{file_info['filename']}"
|
|
|
|
# Index commit
|
|
metadata = {
|
|
'type': 'git_commit',
|
|
'commit_hash': commit.hash,
|
|
'author': commit.author,
|
|
'timestamp': commit.timestamp,
|
|
'file_count': len(commit.files)
|
|
}
|
|
|
|
self.vector_store.add_document(commit_doc, metadata)
|
|
|
|
# Index diffs if requested
|
|
if include_diffs:
|
|
for file_info in commit.files:
|
|
self._index_commit_diff(commit, file_info['filename'])
|
|
|
|
def _index_commit_diff(self, commit: GitCommit, filename: str):
|
|
"""Index the diff for a specific file in a commit."""
|
|
# This is a simplified version - in practice you'd want to
|
|
# parse the actual diff and store meaningful chunks
|
|
metadata = {
|
|
'type': 'git_diff',
|
|
'commit_hash': commit.hash,
|
|
'filename': filename,
|
|
'author': commit.author,
|
|
'timestamp': commit.timestamp
|
|
}
|
|
|
|
# Create a document representing this change
|
|
diff_doc = f"""File: {filename}
|
|
Commit: {commit.hash}
|
|
Author: {commit.author}
|
|
Message: {commit.message}
|
|
"""
|
|
|
|
self.vector_store.add_document(diff_doc, metadata)
|
|
|
|
def _index_repository_metadata(
|
|
self,
|
|
repo_path: Path,
|
|
results: Dict[str, Any]
|
|
):
|
|
"""Index overall repository metadata."""
|
|
# Get repository info
|
|
remote_result = subprocess.run(
|
|
["git", "remote", "get-url", "origin"],
|
|
cwd=repo_path,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
remote_url = remote_result.stdout.strip() if remote_result.returncode == 0 else None
|
|
|
|
# Create repository summary document
|
|
repo_doc = f"""Repository: {repo_path.name}
|
|
Path: {repo_path}
|
|
Remote: {remote_url or 'No remote'}
|
|
Current Commit: {results.get('current_commit', 'Unknown')}
|
|
|
|
Statistics:
|
|
- Files indexed: {results['files_indexed']}
|
|
- Commits processed: {results['commits_processed']}
|
|
- Symbols extracted: {results['symbols_extracted']}
|
|
- Diffs indexed: {results['diffs_indexed']}
|
|
"""
|
|
|
|
metadata = {
|
|
'type': 'repository',
|
|
'name': repo_path.name,
|
|
'path': str(repo_path),
|
|
'remote_url': remote_url,
|
|
**results
|
|
}
|
|
|
|
self.vector_store.add_document(repo_doc, metadata) |