Implements the foundational infrastructure for Timmy's self-modification capability:
## New Services
1. **GitSafety** (src/self_coding/git_safety.py)
- Atomic git operations with rollback capability
- Snapshot/restore for safe experimentation
- Feature branch management (timmy/self-edit/{timestamp})
- Merge to main only after tests pass
2. **CodebaseIndexer** (src/self_coding/codebase_indexer.py)
- AST-based parsing of Python source files
- Extracts classes, functions, imports, docstrings
- Builds dependency graph for blast radius analysis
- SQLite storage with hash-based incremental indexing
- get_summary() for LLM context (<4000 tokens)
- get_relevant_files() for task-based file discovery
3. **ModificationJournal** (src/self_coding/modification_journal.py)
- Persistent log of all self-modification attempts
- Tracks outcomes: success, failure, rollback
- find_similar() for learning from past attempts
- Success rate metrics and recent failure tracking
- Supports vector embeddings (Phase 2)
4. **ReflectionService** (src/self_coding/reflection.py)
- LLM-powered analysis of modification attempts
- Generates lessons learned from successes and failures
- Fallback templates when LLM unavailable
- Supports context from similar past attempts
## Test Coverage
- 104 new tests across 7 test files
- 95% code coverage on self_coding module
- Green path tests: full workflow integration
- Red path tests: errors, rollbacks, edge cases
- Safety constraint tests: test coverage requirements, protected files
## Usage
from self_coding import GitSafety, CodebaseIndexer, ModificationJournal
git = GitSafety(repo_path=/path/to/repo)
indexer = CodebaseIndexer(repo_path=/path/to/repo)
journal = ModificationJournal()
Phase 2 will build the Self-Edit MCP Tool that orchestrates these services.
506 lines
16 KiB
Python
506 lines
16 KiB
Python
"""Git Safety Layer — Atomic git operations with rollback capability.
|
|
|
|
All self-modifications happen on feature branches. Only merge to main after
|
|
full test suite passes. Snapshots enable rollback on failure.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import logging
|
|
import subprocess
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Snapshot:
|
|
"""Immutable snapshot of repository state before modification.
|
|
|
|
Attributes:
|
|
commit_hash: Git commit hash at snapshot time
|
|
branch: Current branch name
|
|
timestamp: When snapshot was taken
|
|
test_status: Whether tests were passing at snapshot time
|
|
test_output: Pytest output from test run
|
|
clean: Whether working directory was clean
|
|
"""
|
|
commit_hash: str
|
|
branch: str
|
|
timestamp: datetime
|
|
test_status: bool
|
|
test_output: str
|
|
clean: bool
|
|
|
|
|
|
class GitSafetyError(Exception):
|
|
"""Base exception for git safety operations."""
|
|
pass
|
|
|
|
|
|
class GitNotRepositoryError(GitSafetyError):
|
|
"""Raised when operation is attempted outside a git repository."""
|
|
pass
|
|
|
|
|
|
class GitDirtyWorkingDirectoryError(GitSafetyError):
|
|
"""Raised when working directory is not clean and clean_required=True."""
|
|
pass
|
|
|
|
|
|
class GitOperationError(GitSafetyError):
|
|
"""Raised when a git operation fails."""
|
|
pass
|
|
|
|
|
|
class GitSafety:
|
|
"""Safe git operations for self-modification workflows.
|
|
|
|
All operations are atomic and support rollback. Self-modifications happen
|
|
on feature branches named 'timmy/self-edit/{timestamp}'. Only merged to
|
|
main after tests pass.
|
|
|
|
Usage:
|
|
safety = GitSafety(repo_path="/path/to/repo")
|
|
|
|
# Take snapshot before changes
|
|
snapshot = await safety.snapshot()
|
|
|
|
# Create feature branch
|
|
branch = await safety.create_branch(f"timmy/self-edit/{timestamp}")
|
|
|
|
# Make changes, commit them
|
|
await safety.commit("Add error handling", ["src/file.py"])
|
|
|
|
# Run tests, merge if pass
|
|
if tests_pass:
|
|
await safety.merge_to_main(branch)
|
|
else:
|
|
await safety.rollback(snapshot)
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
repo_path: Optional[str | Path] = None,
|
|
main_branch: str = "main",
|
|
test_command: str = "python -m pytest --tb=short -q",
|
|
) -> None:
|
|
"""Initialize GitSafety with repository path.
|
|
|
|
Args:
|
|
repo_path: Path to git repository. Defaults to current working directory.
|
|
main_branch: Name of main branch (main, master, etc.)
|
|
test_command: Command to run tests for snapshot validation
|
|
"""
|
|
self.repo_path = Path(repo_path).resolve() if repo_path else Path.cwd()
|
|
self.main_branch = main_branch
|
|
self.test_command = test_command
|
|
self._verify_git_repo()
|
|
logger.info("GitSafety initialized for %s", self.repo_path)
|
|
|
|
def _verify_git_repo(self) -> None:
|
|
"""Verify that repo_path is a git repository."""
|
|
git_dir = self.repo_path / ".git"
|
|
if not git_dir.exists():
|
|
raise GitNotRepositoryError(
|
|
f"{self.repo_path} is not a git repository"
|
|
)
|
|
|
|
async def _run_git(
|
|
self,
|
|
*args: str,
|
|
check: bool = True,
|
|
capture_output: bool = True,
|
|
timeout: float = 30.0,
|
|
) -> subprocess.CompletedProcess:
|
|
"""Run a git command asynchronously.
|
|
|
|
Args:
|
|
*args: Git command arguments
|
|
check: Whether to raise on non-zero exit
|
|
capture_output: Whether to capture stdout/stderr
|
|
timeout: Maximum time to wait for command
|
|
|
|
Returns:
|
|
CompletedProcess with returncode, stdout, stderr
|
|
|
|
Raises:
|
|
GitOperationError: If git command fails and check=True
|
|
"""
|
|
cmd = ["git", *args]
|
|
logger.debug("Running: %s", " ".join(cmd))
|
|
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
cwd=self.repo_path,
|
|
stdout=asyncio.subprocess.PIPE if capture_output else None,
|
|
stderr=asyncio.subprocess.PIPE if capture_output else None,
|
|
)
|
|
|
|
stdout, stderr = await asyncio.wait_for(
|
|
proc.communicate(),
|
|
timeout=timeout,
|
|
)
|
|
|
|
result = subprocess.CompletedProcess(
|
|
args=cmd,
|
|
returncode=proc.returncode or 0,
|
|
stdout=stdout.decode() if stdout else "",
|
|
stderr=stderr.decode() if stderr else "",
|
|
)
|
|
|
|
if check and result.returncode != 0:
|
|
raise GitOperationError(
|
|
f"Git command failed: {' '.join(args)}\n"
|
|
f"stdout: {result.stdout}\nstderr: {result.stderr}"
|
|
)
|
|
|
|
return result
|
|
|
|
except asyncio.TimeoutError as e:
|
|
proc.kill()
|
|
raise GitOperationError(f"Git command timed out after {timeout}s: {' '.join(args)}") from e
|
|
|
|
async def _run_shell(
|
|
self,
|
|
command: str,
|
|
timeout: float = 120.0,
|
|
) -> subprocess.CompletedProcess:
|
|
"""Run a shell command asynchronously.
|
|
|
|
Args:
|
|
command: Shell command to run
|
|
timeout: Maximum time to wait
|
|
|
|
Returns:
|
|
CompletedProcess with returncode, stdout, stderr
|
|
"""
|
|
logger.debug("Running shell: %s", command)
|
|
|
|
proc = await asyncio.create_subprocess_shell(
|
|
command,
|
|
cwd=self.repo_path,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
|
|
stdout, stderr = await asyncio.wait_for(
|
|
proc.communicate(),
|
|
timeout=timeout,
|
|
)
|
|
|
|
return subprocess.CompletedProcess(
|
|
args=command,
|
|
returncode=proc.returncode or 0,
|
|
stdout=stdout.decode(),
|
|
stderr=stderr.decode(),
|
|
)
|
|
|
|
async def is_clean(self) -> bool:
|
|
"""Check if working directory is clean (no uncommitted changes).
|
|
|
|
Returns:
|
|
True if clean, False if there are uncommitted changes
|
|
"""
|
|
result = await self._run_git("status", "--porcelain", check=False)
|
|
return result.stdout.strip() == ""
|
|
|
|
async def get_current_branch(self) -> str:
|
|
"""Get current git branch name.
|
|
|
|
Returns:
|
|
Current branch name
|
|
"""
|
|
result = await self._run_git("branch", "--show-current")
|
|
return result.stdout.strip()
|
|
|
|
async def get_current_commit(self) -> str:
|
|
"""Get current commit hash.
|
|
|
|
Returns:
|
|
Full commit hash
|
|
"""
|
|
result = await self._run_git("rev-parse", "HEAD")
|
|
return result.stdout.strip()
|
|
|
|
async def _run_tests(self) -> tuple[bool, str]:
|
|
"""Run test suite and return results.
|
|
|
|
Returns:
|
|
Tuple of (all_passed, test_output)
|
|
"""
|
|
logger.info("Running tests: %s", self.test_command)
|
|
result = await self._run_shell(self.test_command, timeout=300.0)
|
|
passed = result.returncode == 0
|
|
output = result.stdout + "\n" + result.stderr
|
|
|
|
if passed:
|
|
logger.info("Tests passed")
|
|
else:
|
|
logger.warning("Tests failed with returncode %d", result.returncode)
|
|
|
|
return passed, output
|
|
|
|
async def snapshot(self, run_tests: bool = True) -> Snapshot:
|
|
"""Take a snapshot of current repository state.
|
|
|
|
Captures commit hash, branch, test status. Used for rollback if
|
|
modifications fail.
|
|
|
|
Args:
|
|
run_tests: Whether to run tests as part of snapshot
|
|
|
|
Returns:
|
|
Snapshot object with current state
|
|
|
|
Raises:
|
|
GitOperationError: If git operations fail
|
|
"""
|
|
logger.info("Taking snapshot of repository state")
|
|
|
|
commit_hash = await self.get_current_commit()
|
|
branch = await self.get_current_branch()
|
|
clean = await self.is_clean()
|
|
timestamp = datetime.now(timezone.utc)
|
|
|
|
test_status = False
|
|
test_output = ""
|
|
|
|
if run_tests:
|
|
test_status, test_output = await self._run_tests()
|
|
else:
|
|
test_status = True # Assume OK if not running tests
|
|
test_output = "Tests skipped"
|
|
|
|
snapshot = Snapshot(
|
|
commit_hash=commit_hash,
|
|
branch=branch,
|
|
timestamp=timestamp,
|
|
test_status=test_status,
|
|
test_output=test_output,
|
|
clean=clean,
|
|
)
|
|
|
|
logger.info(
|
|
"Snapshot taken: %s@%s (clean=%s, tests=%s)",
|
|
branch,
|
|
commit_hash[:8],
|
|
clean,
|
|
test_status,
|
|
)
|
|
|
|
return snapshot
|
|
|
|
async def create_branch(self, name: str, base: Optional[str] = None) -> str:
|
|
"""Create and checkout a new feature branch.
|
|
|
|
Args:
|
|
name: Branch name (e.g., 'timmy/self-edit/20260226-143022')
|
|
base: Base branch to create from (defaults to main_branch)
|
|
|
|
Returns:
|
|
Name of created branch
|
|
|
|
Raises:
|
|
GitOperationError: If branch creation fails
|
|
"""
|
|
base = base or self.main_branch
|
|
|
|
# Ensure we're on base branch and it's up to date
|
|
await self._run_git("checkout", base)
|
|
await self._run_git("pull", "origin", base, check=False) # May fail if no remote
|
|
|
|
# Create and checkout new branch
|
|
await self._run_git("checkout", "-b", name)
|
|
|
|
logger.info("Created branch %s from %s", name, base)
|
|
return name
|
|
|
|
async def commit(
|
|
self,
|
|
message: str,
|
|
files: Optional[list[str | Path]] = None,
|
|
allow_empty: bool = False,
|
|
) -> str:
|
|
"""Commit changes to current branch.
|
|
|
|
Args:
|
|
message: Commit message
|
|
files: Specific files to commit (None = all changes)
|
|
allow_empty: Whether to allow empty commits
|
|
|
|
Returns:
|
|
Commit hash of new commit
|
|
|
|
Raises:
|
|
GitOperationError: If commit fails
|
|
"""
|
|
# Add files
|
|
if files:
|
|
for file_path in files:
|
|
full_path = self.repo_path / file_path
|
|
if not full_path.exists():
|
|
logger.warning("File does not exist: %s", file_path)
|
|
await self._run_git("add", str(file_path))
|
|
else:
|
|
await self._run_git("add", "-A")
|
|
|
|
# Check if there's anything to commit
|
|
if not allow_empty:
|
|
diff_result = await self._run_git(
|
|
"diff", "--cached", "--quiet", check=False
|
|
)
|
|
if diff_result.returncode == 0:
|
|
logger.warning("No changes to commit")
|
|
return await self.get_current_commit()
|
|
|
|
# Commit
|
|
commit_args = ["commit", "-m", message]
|
|
if allow_empty:
|
|
commit_args.append("--allow-empty")
|
|
|
|
await self._run_git(*commit_args)
|
|
|
|
commit_hash = await self.get_current_commit()
|
|
logger.info("Committed %s: %s", commit_hash[:8], message)
|
|
|
|
return commit_hash
|
|
|
|
async def get_diff(self, from_hash: str, to_hash: Optional[str] = None) -> str:
|
|
"""Get diff between commits.
|
|
|
|
Args:
|
|
from_hash: Starting commit hash (or Snapshot object hash)
|
|
to_hash: Ending commit hash (None = current)
|
|
|
|
Returns:
|
|
Git diff as string
|
|
"""
|
|
args = ["diff", from_hash]
|
|
if to_hash:
|
|
args.append(to_hash)
|
|
|
|
result = await self._run_git(*args)
|
|
return result.stdout
|
|
|
|
async def rollback(self, snapshot: Snapshot | str) -> str:
|
|
"""Rollback to a previous snapshot.
|
|
|
|
Hard resets to the snapshot commit and deletes any uncommitted changes.
|
|
Use with caution — this is destructive.
|
|
|
|
Args:
|
|
snapshot: Snapshot object or commit hash to rollback to
|
|
|
|
Returns:
|
|
Commit hash after rollback
|
|
|
|
Raises:
|
|
GitOperationError: If rollback fails
|
|
"""
|
|
if isinstance(snapshot, Snapshot):
|
|
target_hash = snapshot.commit_hash
|
|
target_branch = snapshot.branch
|
|
else:
|
|
target_hash = snapshot
|
|
target_branch = None
|
|
|
|
logger.warning("Rolling back to %s", target_hash[:8])
|
|
|
|
# Reset to target commit
|
|
await self._run_git("reset", "--hard", target_hash)
|
|
|
|
# Clean any untracked files
|
|
await self._run_git("clean", "-fd")
|
|
|
|
# If we know the original branch, switch back to it
|
|
if target_branch:
|
|
branch_exists = await self._run_git(
|
|
"branch", "--list", target_branch, check=False
|
|
)
|
|
if branch_exists.stdout.strip():
|
|
await self._run_git("checkout", target_branch)
|
|
logger.info("Switched back to branch %s", target_branch)
|
|
|
|
current = await self.get_current_commit()
|
|
logger.info("Rolled back to %s", current[:8])
|
|
|
|
return current
|
|
|
|
async def merge_to_main(
|
|
self,
|
|
branch: str,
|
|
require_tests: bool = True,
|
|
) -> str:
|
|
"""Merge a feature branch into main after tests pass.
|
|
|
|
Args:
|
|
branch: Feature branch to merge
|
|
require_tests: Whether to require tests to pass before merging
|
|
|
|
Returns:
|
|
Merge commit hash
|
|
|
|
Raises:
|
|
GitOperationError: If merge fails or tests don't pass
|
|
"""
|
|
logger.info("Preparing to merge %s into %s", branch, self.main_branch)
|
|
|
|
# Checkout the feature branch and run tests
|
|
await self._run_git("checkout", branch)
|
|
|
|
if require_tests:
|
|
passed, output = await self._run_tests()
|
|
if not passed:
|
|
raise GitOperationError(
|
|
f"Cannot merge {branch}: tests failed\n{output}"
|
|
)
|
|
|
|
# Checkout main and merge
|
|
await self._run_git("checkout", self.main_branch)
|
|
await self._run_git("merge", "--no-ff", "-m", f"Merge {branch}", branch)
|
|
|
|
# Optionally delete the feature branch
|
|
await self._run_git("branch", "-d", branch, check=False)
|
|
|
|
merge_hash = await self.get_current_commit()
|
|
logger.info("Merged %s into %s: %s", branch, self.main_branch, merge_hash[:8])
|
|
|
|
return merge_hash
|
|
|
|
async def get_modified_files(self, since_hash: Optional[str] = None) -> list[str]:
|
|
"""Get list of files modified since a commit.
|
|
|
|
Args:
|
|
since_hash: Commit to compare against (None = uncommitted changes)
|
|
|
|
Returns:
|
|
List of modified file paths
|
|
"""
|
|
if since_hash:
|
|
result = await self._run_git(
|
|
"diff", "--name-only", since_hash, "HEAD"
|
|
)
|
|
else:
|
|
result = await self._run_git(
|
|
"diff", "--name-only", "HEAD"
|
|
)
|
|
|
|
files = [f.strip() for f in result.stdout.split("\n") if f.strip()]
|
|
return files
|
|
|
|
async def stage_file(self, file_path: str | Path) -> None:
|
|
"""Stage a single file for commit.
|
|
|
|
Args:
|
|
file_path: Path to file relative to repo root
|
|
"""
|
|
await self._run_git("add", str(file_path))
|
|
logger.debug("Staged %s", file_path)
|