Files
zeekayandHanzo Dev d3110e47ea Major refactor: Unified tools, palette system, dev mode, and shell improvements (v0.6.0)
🎨 **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]>
2025-06-26 17:59:24 -04:00

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)