merge logic v0.3
This commit is contained in:
@@ -132,11 +132,10 @@ def handle_cleanup_worktrees_command(project_dir: Path) -> None:
|
||||
|
||||
def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
|
||||
"""
|
||||
Check for git-level merge conflicts by attempting a dry-run merge.
|
||||
Check for git-level merge conflicts WITHOUT modifying the working directory.
|
||||
|
||||
This detects conflicts that occur when main has diverged from the
|
||||
worktree branch (e.g., other changes were merged to main since
|
||||
the worktree was created).
|
||||
Uses git merge-tree and git diff to detect conflicts in-memory,
|
||||
which avoids triggering Vite HMR or other file watchers.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
@@ -152,7 +151,7 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
debug(MODULE, "Checking for git-level merge conflicts...")
|
||||
debug(MODULE, "Checking for git-level merge conflicts (non-destructive)...")
|
||||
|
||||
spec_branch = f"auto-claude/{spec_name}"
|
||||
result = {
|
||||
@@ -175,95 +174,110 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
|
||||
if base_result.returncode == 0:
|
||||
result["base_branch"] = base_result.stdout.strip()
|
||||
|
||||
# Check how many commits main is ahead of the merge-base
|
||||
# Get the merge base commit
|
||||
merge_base_result = subprocess.run(
|
||||
["git", "merge-base", result["base_branch"], spec_branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if merge_base_result.returncode == 0:
|
||||
merge_base = merge_base_result.stdout.strip()
|
||||
if merge_base_result.returncode != 0:
|
||||
debug_warning(MODULE, "Could not find merge base")
|
||||
return result
|
||||
|
||||
# Count commits main is ahead
|
||||
ahead_result = subprocess.run(
|
||||
["git", "rev-list", "--count", f"{merge_base}..{result['base_branch']}"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if ahead_result.returncode == 0:
|
||||
commits_behind = int(ahead_result.stdout.strip())
|
||||
result["commits_behind"] = commits_behind
|
||||
if commits_behind > 0:
|
||||
result["needs_rebase"] = True
|
||||
debug(MODULE, f"Main is {commits_behind} commits ahead of worktree base")
|
||||
merge_base = merge_base_result.stdout.strip()
|
||||
|
||||
# Try a merge --no-commit --no-ff to see if there would be conflicts
|
||||
# First, save current state
|
||||
stash_result = subprocess.run(
|
||||
["git", "stash", "push", "-m", "merge-preview-check"],
|
||||
# Count commits main is ahead
|
||||
ahead_result = subprocess.run(
|
||||
["git", "rev-list", "--count", f"{merge_base}..{result['base_branch']}"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if ahead_result.returncode == 0:
|
||||
commits_behind = int(ahead_result.stdout.strip())
|
||||
result["commits_behind"] = commits_behind
|
||||
if commits_behind > 0:
|
||||
result["needs_rebase"] = True
|
||||
debug(MODULE, f"Main is {commits_behind} commits ahead of worktree base")
|
||||
|
||||
# Get commit hashes for merge-tree
|
||||
main_commit_result = subprocess.run(
|
||||
["git", "rev-parse", result["base_branch"]],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
spec_commit_result = subprocess.run(
|
||||
["git", "rev-parse", spec_branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
try:
|
||||
# Attempt the merge (dry run style)
|
||||
merge_result = subprocess.run(
|
||||
["git", "merge", "--no-commit", "--no-ff", spec_branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if main_commit_result.returncode != 0 or spec_commit_result.returncode != 0:
|
||||
debug_warning(MODULE, "Could not resolve branch commits")
|
||||
return result
|
||||
|
||||
if merge_result.returncode != 0:
|
||||
result["has_conflicts"] = True
|
||||
debug(MODULE, "Git merge would have conflicts")
|
||||
main_commit = main_commit_result.stdout.strip()
|
||||
spec_commit = spec_commit_result.stdout.strip()
|
||||
|
||||
# Parse conflicting files from the output
|
||||
# Look for "CONFLICT (content): Merge conflict in <file>"
|
||||
import re
|
||||
for line in merge_result.stdout.split("\n") + merge_result.stderr.split("\n"):
|
||||
# Match patterns like "CONFLICT (content): Merge conflict in path/to/file"
|
||||
match = re.search(r"CONFLICT.*:\s*(?:Merge conflict in\s+)?(.+)", line)
|
||||
# Use git merge-tree to check for conflicts WITHOUT touching working directory
|
||||
# This is a plumbing command that does a 3-way merge in memory
|
||||
merge_tree_result = subprocess.run(
|
||||
["git", "merge-tree", "--write-tree", "--no-messages", merge_base, main_commit, spec_commit],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# merge-tree returns exit code 1 if there are conflicts
|
||||
if merge_tree_result.returncode != 0:
|
||||
result["has_conflicts"] = True
|
||||
debug(MODULE, "Git merge-tree detected conflicts")
|
||||
|
||||
# Parse the output for conflicting files
|
||||
# merge-tree --write-tree outputs conflict info to stderr
|
||||
output = merge_tree_result.stdout + merge_tree_result.stderr
|
||||
for line in output.split("\n"):
|
||||
# Look for lines indicating conflicts
|
||||
if "CONFLICT" in line:
|
||||
# Extract file path from conflict message
|
||||
import re
|
||||
match = re.search(r"(?:Merge conflict in|CONFLICT.*?:)\s*(.+?)(?:\s*$|\s+\()", line)
|
||||
if match:
|
||||
file_path = match.group(1).strip()
|
||||
if file_path and file_path not in result["conflicting_files"]:
|
||||
result["conflicting_files"].append(file_path)
|
||||
|
||||
# Also check git status for unmerged files
|
||||
status_result = subprocess.run(
|
||||
["git", "diff", "--name-only", "--diff-filter=U"],
|
||||
# Fallback: if we didn't parse conflicts, use diff to find files changed in both branches
|
||||
if not result["conflicting_files"]:
|
||||
# Files changed in main since merge-base
|
||||
main_files_result = subprocess.run(
|
||||
["git", "diff", "--name-only", merge_base, main_commit],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if status_result.returncode == 0:
|
||||
for line in status_result.stdout.strip().split("\n"):
|
||||
if line and line not in result["conflicting_files"]:
|
||||
result["conflicting_files"].append(line)
|
||||
main_files = set(main_files_result.stdout.strip().split("\n")) if main_files_result.stdout.strip() else set()
|
||||
|
||||
debug(MODULE, f"Conflicting files: {result['conflicting_files']}")
|
||||
else:
|
||||
debug_success(MODULE, "Git merge would succeed without conflicts")
|
||||
|
||||
finally:
|
||||
# Always abort the merge and restore state
|
||||
subprocess.run(
|
||||
["git", "merge", "--abort"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
# Restore stash if we made one
|
||||
if "No local changes" not in stash_result.stdout:
|
||||
subprocess.run(
|
||||
["git", "stash", "pop"],
|
||||
# Files changed in spec branch since merge-base
|
||||
spec_files_result = subprocess.run(
|
||||
["git", "diff", "--name-only", merge_base, spec_commit],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
spec_files = set(spec_files_result.stdout.strip().split("\n")) if spec_files_result.stdout.strip() else set()
|
||||
|
||||
# Files modified in both = potential conflicts
|
||||
conflicting = main_files & spec_files
|
||||
result["conflicting_files"] = list(conflicting)
|
||||
debug(MODULE, f"Found {len(conflicting)} files modified in both branches")
|
||||
|
||||
debug(MODULE, f"Conflicting files: {result['conflicting_files']}")
|
||||
else:
|
||||
debug_success(MODULE, "Git merge-tree: no conflicts detected")
|
||||
|
||||
except Exception as e:
|
||||
debug_error(MODULE, f"Error checking git conflicts: {e}")
|
||||
|
||||
@@ -41,6 +41,21 @@ from .auto_merger import AutoMerger
|
||||
from .file_evolution import FileEvolutionTracker
|
||||
from .ai_resolver import AIResolver, create_claude_resolver
|
||||
from .orchestrator import MergeOrchestrator
|
||||
from .file_timeline import (
|
||||
FileTimelineTracker,
|
||||
FileTimeline,
|
||||
MainBranchEvent,
|
||||
BranchPoint,
|
||||
WorktreeState,
|
||||
TaskIntent,
|
||||
TaskFileView,
|
||||
MergeContext,
|
||||
)
|
||||
from .prompts import (
|
||||
build_timeline_merge_prompt,
|
||||
build_simple_merge_prompt,
|
||||
optimize_prompt_for_length,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Types
|
||||
@@ -62,4 +77,17 @@ __all__ = [
|
||||
"AIResolver",
|
||||
"create_claude_resolver",
|
||||
"MergeOrchestrator",
|
||||
# File Timeline (Intent-Aware Merge System)
|
||||
"FileTimelineTracker",
|
||||
"FileTimeline",
|
||||
"MainBranchEvent",
|
||||
"BranchPoint",
|
||||
"WorktreeState",
|
||||
"TaskIntent",
|
||||
"TaskFileView",
|
||||
"MergeContext",
|
||||
# Prompt Templates
|
||||
"build_timeline_merge_prompt",
|
||||
"build_simple_merge_prompt",
|
||||
"optimize_prompt_for_length",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,992 @@
|
||||
"""
|
||||
File Timeline Tracker
|
||||
=====================
|
||||
|
||||
Intent-aware file evolution tracking for multi-agent merge resolution.
|
||||
|
||||
This module implements the File-Centric Timeline Model that tracks:
|
||||
- Main branch evolution (human commits)
|
||||
- Task worktree modifications (AI agent changes)
|
||||
- Task branch points and intent
|
||||
- Pending task awareness for forward-compatible merges
|
||||
|
||||
The key insight is that each file has a TIMELINE of changes from multiple sources,
|
||||
and the Merge AI needs this complete context to make intelligent decisions.
|
||||
|
||||
Usage:
|
||||
tracker = FileTimelineTracker(project_dir)
|
||||
|
||||
# When a task starts
|
||||
tracker.on_task_start(
|
||||
task_id="task-001-auth",
|
||||
files_to_modify=["src/App.tsx"],
|
||||
branch_point_commit="abc123",
|
||||
task_intent="Add authentication via useAuth() hook"
|
||||
)
|
||||
|
||||
# When human commits to main (via git hook)
|
||||
tracker.on_main_branch_commit("def456")
|
||||
|
||||
# When getting merge context
|
||||
context = tracker.get_merge_context("task-001-auth", "src/App.tsx")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Literal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Import debug utilities
|
||||
try:
|
||||
from debug import debug, debug_detailed, debug_verbose, debug_success, debug_error, debug_warning, is_debug_enabled
|
||||
except ImportError:
|
||||
def debug(*args, **kwargs): pass
|
||||
def debug_detailed(*args, **kwargs): pass
|
||||
def debug_verbose(*args, **kwargs): pass
|
||||
def debug_success(*args, **kwargs): pass
|
||||
def debug_error(*args, **kwargs): pass
|
||||
def debug_warning(*args, **kwargs): pass
|
||||
def is_debug_enabled(): return False
|
||||
|
||||
MODULE = "merge.file_timeline"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DATA MODELS
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class MainBranchEvent:
|
||||
"""
|
||||
Represents a single commit to main branch affecting a file.
|
||||
|
||||
These events form the "spine" of the file's timeline - the authoritative
|
||||
history that all task worktrees diverge from and merge back into.
|
||||
"""
|
||||
# Git identification
|
||||
commit_hash: str
|
||||
timestamp: datetime
|
||||
|
||||
# Content at this point
|
||||
content: str
|
||||
|
||||
# Source of change
|
||||
source: Literal['human', 'merged_task']
|
||||
merged_from_task: Optional[str] = None # If source is 'merged_task'
|
||||
|
||||
# Intent/reason for change
|
||||
commit_message: str = ""
|
||||
|
||||
# For richer context (optional)
|
||||
author: Optional[str] = None
|
||||
diff_summary: Optional[str] = None # e.g., "+15 -3 lines"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"commit_hash": self.commit_hash,
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
"content": self.content,
|
||||
"source": self.source,
|
||||
"merged_from_task": self.merged_from_task,
|
||||
"commit_message": self.commit_message,
|
||||
"author": self.author,
|
||||
"diff_summary": self.diff_summary,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> MainBranchEvent:
|
||||
return cls(
|
||||
commit_hash=data["commit_hash"],
|
||||
timestamp=datetime.fromisoformat(data["timestamp"]),
|
||||
content=data["content"],
|
||||
source=data["source"],
|
||||
merged_from_task=data.get("merged_from_task"),
|
||||
commit_message=data.get("commit_message", ""),
|
||||
author=data.get("author"),
|
||||
diff_summary=data.get("diff_summary"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BranchPoint:
|
||||
"""The exact point a task branched from main."""
|
||||
commit_hash: str
|
||||
content: str
|
||||
timestamp: datetime
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"commit_hash": self.commit_hash,
|
||||
"content": self.content,
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> BranchPoint:
|
||||
return cls(
|
||||
commit_hash=data["commit_hash"],
|
||||
content=data["content"],
|
||||
timestamp=datetime.fromisoformat(data["timestamp"]),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorktreeState:
|
||||
"""Current state of a file in a task's worktree."""
|
||||
content: str
|
||||
last_modified: datetime
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"content": self.content,
|
||||
"last_modified": self.last_modified.isoformat(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> WorktreeState:
|
||||
return cls(
|
||||
content=data["content"],
|
||||
last_modified=datetime.fromisoformat(data["last_modified"]),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskIntent:
|
||||
"""What the task intends to do with this file."""
|
||||
title: str
|
||||
description: str
|
||||
from_plan: bool = False # True if extracted from implementation_plan.json
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"from_plan": self.from_plan,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> TaskIntent:
|
||||
return cls(
|
||||
title=data["title"],
|
||||
description=data["description"],
|
||||
from_plan=data.get("from_plan", False),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskFileView:
|
||||
"""
|
||||
A single task's relationship with a specific file.
|
||||
|
||||
This captures everything we need to know about how one task
|
||||
sees and modifies one file.
|
||||
"""
|
||||
task_id: str
|
||||
|
||||
# The exact point this task branched from main
|
||||
branch_point: BranchPoint
|
||||
|
||||
# Current state in the task's worktree (None if not modified yet)
|
||||
worktree_state: Optional[WorktreeState] = None
|
||||
|
||||
# What the task intends to do
|
||||
task_intent: TaskIntent = field(default_factory=lambda: TaskIntent("", ""))
|
||||
|
||||
# Drift tracking - how many commits happened in main since branch
|
||||
commits_behind_main: int = 0
|
||||
|
||||
# Lifecycle status
|
||||
status: Literal['active', 'merged', 'abandoned'] = 'active'
|
||||
merged_at: Optional[datetime] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"task_id": self.task_id,
|
||||
"branch_point": self.branch_point.to_dict(),
|
||||
"worktree_state": self.worktree_state.to_dict() if self.worktree_state else None,
|
||||
"task_intent": self.task_intent.to_dict(),
|
||||
"commits_behind_main": self.commits_behind_main,
|
||||
"status": self.status,
|
||||
"merged_at": self.merged_at.isoformat() if self.merged_at else None,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> TaskFileView:
|
||||
return cls(
|
||||
task_id=data["task_id"],
|
||||
branch_point=BranchPoint.from_dict(data["branch_point"]),
|
||||
worktree_state=WorktreeState.from_dict(data["worktree_state"]) if data.get("worktree_state") else None,
|
||||
task_intent=TaskIntent.from_dict(data["task_intent"]) if data.get("task_intent") else TaskIntent("", ""),
|
||||
commits_behind_main=data.get("commits_behind_main", 0),
|
||||
status=data.get("status", "active"),
|
||||
merged_at=datetime.fromisoformat(data["merged_at"]) if data.get("merged_at") else None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileTimeline:
|
||||
"""
|
||||
The core data structure tracking a single file's complete history.
|
||||
|
||||
This is the "file-centric" view - instead of asking "what did Task X change?",
|
||||
we ask "what happened to File Y over time, from ALL sources?"
|
||||
"""
|
||||
file_path: str
|
||||
|
||||
# Main branch evolution - the authoritative history
|
||||
main_branch_history: List[MainBranchEvent] = field(default_factory=list)
|
||||
|
||||
# Each task's isolated view of this file
|
||||
task_views: Dict[str, TaskFileView] = field(default_factory=dict)
|
||||
|
||||
# Metadata
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
last_updated: datetime = field(default_factory=datetime.now)
|
||||
|
||||
def add_main_event(self, event: MainBranchEvent) -> None:
|
||||
"""Add a main branch event and increment drift for all active tasks."""
|
||||
self.main_branch_history.append(event)
|
||||
self.last_updated = datetime.now()
|
||||
|
||||
# Update commits_behind_main for all active tasks
|
||||
for task_view in self.task_views.values():
|
||||
if task_view.status == 'active':
|
||||
task_view.commits_behind_main += 1
|
||||
|
||||
def add_task_view(self, task_view: TaskFileView) -> None:
|
||||
"""Add or update a task's view of this file."""
|
||||
self.task_views[task_view.task_id] = task_view
|
||||
self.last_updated = datetime.now()
|
||||
|
||||
def get_task_view(self, task_id: str) -> Optional[TaskFileView]:
|
||||
"""Get a task's view of this file."""
|
||||
return self.task_views.get(task_id)
|
||||
|
||||
def get_active_tasks(self) -> List[TaskFileView]:
|
||||
"""Get all tasks that are still active (not merged/abandoned)."""
|
||||
return [tv for tv in self.task_views.values() if tv.status == 'active']
|
||||
|
||||
def get_events_since_commit(self, commit_hash: str) -> List[MainBranchEvent]:
|
||||
"""Get all main branch events since a given commit."""
|
||||
events = []
|
||||
found_commit = False
|
||||
for event in self.main_branch_history:
|
||||
if found_commit:
|
||||
events.append(event)
|
||||
if event.commit_hash == commit_hash:
|
||||
found_commit = True
|
||||
return events
|
||||
|
||||
def get_current_main_state(self) -> Optional[MainBranchEvent]:
|
||||
"""Get the most recent main branch event."""
|
||||
if self.main_branch_history:
|
||||
return self.main_branch_history[-1]
|
||||
return None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"file_path": self.file_path,
|
||||
"main_branch_history": [e.to_dict() for e in self.main_branch_history],
|
||||
"task_views": {k: v.to_dict() for k, v in self.task_views.items()},
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"last_updated": self.last_updated.isoformat(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> FileTimeline:
|
||||
timeline = cls(
|
||||
file_path=data["file_path"],
|
||||
created_at=datetime.fromisoformat(data["created_at"]),
|
||||
last_updated=datetime.fromisoformat(data["last_updated"]),
|
||||
)
|
||||
timeline.main_branch_history = [
|
||||
MainBranchEvent.from_dict(e) for e in data.get("main_branch_history", [])
|
||||
]
|
||||
timeline.task_views = {
|
||||
k: TaskFileView.from_dict(v) for k, v in data.get("task_views", {}).items()
|
||||
}
|
||||
return timeline
|
||||
|
||||
|
||||
@dataclass
|
||||
class MergeContext:
|
||||
"""
|
||||
The complete context package provided to the Merge AI.
|
||||
|
||||
This is the "situational awareness" the AI needs to make intelligent
|
||||
merge decisions.
|
||||
"""
|
||||
file_path: str
|
||||
|
||||
# The task being merged
|
||||
task_id: str
|
||||
task_intent: TaskIntent
|
||||
|
||||
# Task's starting point
|
||||
task_branch_point: BranchPoint
|
||||
|
||||
# What happened in main since task branched (ordered from oldest to newest)
|
||||
main_evolution: List[MainBranchEvent]
|
||||
|
||||
# Task's changes
|
||||
task_worktree_content: str
|
||||
|
||||
# Current main state
|
||||
current_main_content: str
|
||||
current_main_commit: str
|
||||
|
||||
# Other tasks that also touch this file (for forward-compatibility)
|
||||
other_pending_tasks: List[Dict] # [{task_id, intent, branch_point, commits_behind}]
|
||||
|
||||
# Metrics
|
||||
total_commits_behind: int
|
||||
total_pending_tasks: int
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"file_path": self.file_path,
|
||||
"task_id": self.task_id,
|
||||
"task_intent": self.task_intent.to_dict(),
|
||||
"task_branch_point": self.task_branch_point.to_dict(),
|
||||
"main_evolution": [e.to_dict() for e in self.main_evolution],
|
||||
"task_worktree_content": self.task_worktree_content,
|
||||
"current_main_content": self.current_main_content,
|
||||
"current_main_commit": self.current_main_commit,
|
||||
"other_pending_tasks": self.other_pending_tasks,
|
||||
"total_commits_behind": self.total_commits_behind,
|
||||
"total_pending_tasks": self.total_pending_tasks,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FILE TIMELINE TRACKER SERVICE
|
||||
# =============================================================================
|
||||
|
||||
class FileTimelineTracker:
|
||||
"""
|
||||
Central service managing all file timelines.
|
||||
|
||||
This service is the "brain" of the intent-aware merge system. It:
|
||||
- Creates and manages FileTimeline objects
|
||||
- Handles events from git hooks and task lifecycle
|
||||
- Provides merge context to the AI resolver
|
||||
- Persists timelines to JSON storage
|
||||
"""
|
||||
|
||||
def __init__(self, project_path: Path, storage_path: Optional[Path] = None):
|
||||
"""
|
||||
Initialize the file timeline tracker.
|
||||
|
||||
Args:
|
||||
project_path: Root directory of the project
|
||||
storage_path: Directory for timeline storage (default: .auto-claude/)
|
||||
"""
|
||||
debug(MODULE, "Initializing FileTimelineTracker",
|
||||
project_path=str(project_path))
|
||||
|
||||
self.project_path = Path(project_path).resolve()
|
||||
self.storage_path = storage_path or (self.project_path / ".auto-claude")
|
||||
self.timelines_dir = self.storage_path / "file-timelines"
|
||||
|
||||
# Ensure storage directory exists
|
||||
self.timelines_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# In-memory cache of timelines
|
||||
self._timelines: Dict[str, FileTimeline] = {}
|
||||
|
||||
# Load existing timelines
|
||||
self._load_from_storage()
|
||||
|
||||
debug_success(MODULE, "FileTimelineTracker initialized",
|
||||
timelines_loaded=len(self._timelines))
|
||||
|
||||
# =========================================================================
|
||||
# EVENT HANDLERS
|
||||
# =========================================================================
|
||||
|
||||
def on_task_start(
|
||||
self,
|
||||
task_id: str,
|
||||
files_to_modify: List[str],
|
||||
files_to_create: Optional[List[str]] = None,
|
||||
branch_point_commit: Optional[str] = None,
|
||||
task_intent: str = "",
|
||||
task_title: str = "",
|
||||
) -> None:
|
||||
"""
|
||||
Called when a task creates its worktree and starts work.
|
||||
|
||||
This captures the task's "branch point" - what the file looked like
|
||||
when the task started, which is crucial for understanding what the
|
||||
task actually changed vs what was already there.
|
||||
"""
|
||||
debug(MODULE, f"on_task_start: {task_id}",
|
||||
files_to_modify=files_to_modify,
|
||||
branch_point=branch_point_commit)
|
||||
|
||||
# Get actual branch point commit if not provided
|
||||
if not branch_point_commit:
|
||||
branch_point_commit = self._get_current_main_commit()
|
||||
|
||||
timestamp = datetime.now()
|
||||
|
||||
for file_path in files_to_modify:
|
||||
# Get or create timeline for this file
|
||||
timeline = self._get_or_create_timeline(file_path)
|
||||
|
||||
# Get file content at branch point
|
||||
content = self._get_file_content_at_commit(file_path, branch_point_commit)
|
||||
if content is None:
|
||||
# File doesn't exist at this commit - might be created by task
|
||||
content = ""
|
||||
|
||||
# Create task file view
|
||||
task_view = TaskFileView(
|
||||
task_id=task_id,
|
||||
branch_point=BranchPoint(
|
||||
commit_hash=branch_point_commit,
|
||||
content=content,
|
||||
timestamp=timestamp,
|
||||
),
|
||||
task_intent=TaskIntent(
|
||||
title=task_title or task_id,
|
||||
description=task_intent,
|
||||
from_plan=bool(task_intent),
|
||||
),
|
||||
commits_behind_main=0,
|
||||
status='active',
|
||||
)
|
||||
|
||||
timeline.add_task_view(task_view)
|
||||
self._persist_timeline(file_path)
|
||||
|
||||
debug_success(MODULE, f"Task {task_id} registered with {len(files_to_modify)} files")
|
||||
|
||||
def on_main_branch_commit(self, commit_hash: str) -> None:
|
||||
"""
|
||||
Called via git post-commit hook when human commits to main.
|
||||
|
||||
This tracks the "drift" - how many commits have happened in main
|
||||
since each task branched.
|
||||
"""
|
||||
debug(MODULE, f"on_main_branch_commit: {commit_hash}")
|
||||
|
||||
# Get list of files changed in this commit
|
||||
changed_files = self._get_files_changed_in_commit(commit_hash)
|
||||
|
||||
for file_path in changed_files:
|
||||
# Only update existing timelines (we don't create new ones for random files)
|
||||
if file_path not in self._timelines:
|
||||
continue
|
||||
|
||||
timeline = self._timelines[file_path]
|
||||
|
||||
# Get file content at this commit
|
||||
content = self._get_file_content_at_commit(file_path, commit_hash)
|
||||
if content is None:
|
||||
continue
|
||||
|
||||
# Get commit metadata
|
||||
commit_info = self._get_commit_info(commit_hash)
|
||||
|
||||
# Create main branch event
|
||||
event = MainBranchEvent(
|
||||
commit_hash=commit_hash,
|
||||
timestamp=datetime.now(),
|
||||
content=content,
|
||||
source='human',
|
||||
commit_message=commit_info.get('message', ''),
|
||||
author=commit_info.get('author'),
|
||||
diff_summary=commit_info.get('diff_summary'),
|
||||
)
|
||||
|
||||
timeline.add_main_event(event)
|
||||
self._persist_timeline(file_path)
|
||||
|
||||
debug_success(MODULE, f"Processed main commit {commit_hash[:8]}",
|
||||
files_updated=len(changed_files))
|
||||
|
||||
def on_task_worktree_change(
|
||||
self,
|
||||
task_id: str,
|
||||
file_path: str,
|
||||
new_content: str,
|
||||
) -> None:
|
||||
"""
|
||||
Called when AI agent modifies a file in its worktree.
|
||||
|
||||
This updates the task's "worktree state" - what the file currently
|
||||
looks like in that task's isolated workspace.
|
||||
"""
|
||||
debug(MODULE, f"on_task_worktree_change: {task_id} -> {file_path}")
|
||||
|
||||
timeline = self._timelines.get(file_path)
|
||||
if not timeline:
|
||||
# Create timeline if it doesn't exist
|
||||
timeline = self._get_or_create_timeline(file_path)
|
||||
|
||||
task_view = timeline.get_task_view(task_id)
|
||||
if not task_view:
|
||||
debug_warning(MODULE, f"Task {task_id} not registered for {file_path}")
|
||||
return
|
||||
|
||||
# Update worktree state
|
||||
task_view.worktree_state = WorktreeState(
|
||||
content=new_content,
|
||||
last_modified=datetime.now(),
|
||||
)
|
||||
|
||||
self._persist_timeline(file_path)
|
||||
|
||||
def on_task_merged(self, task_id: str, merge_commit: str) -> None:
|
||||
"""
|
||||
Called after a task is successfully merged to main.
|
||||
|
||||
This updates the timeline to show:
|
||||
1. The task is now merged
|
||||
2. Main branch has a new commit (from this merge)
|
||||
"""
|
||||
debug(MODULE, f"on_task_merged: {task_id}")
|
||||
|
||||
# Get list of files this task modified
|
||||
task_files = self.get_files_for_task(task_id)
|
||||
|
||||
for file_path in task_files:
|
||||
timeline = self._timelines.get(file_path)
|
||||
if not timeline:
|
||||
continue
|
||||
|
||||
task_view = timeline.get_task_view(task_id)
|
||||
if not task_view:
|
||||
continue
|
||||
|
||||
# Mark task as merged
|
||||
task_view.status = 'merged'
|
||||
task_view.merged_at = datetime.now()
|
||||
|
||||
# Add main branch event for the merge
|
||||
content = self._get_file_content_at_commit(file_path, merge_commit)
|
||||
if content:
|
||||
event = MainBranchEvent(
|
||||
commit_hash=merge_commit,
|
||||
timestamp=datetime.now(),
|
||||
content=content,
|
||||
source='merged_task',
|
||||
merged_from_task=task_id,
|
||||
commit_message=f"Merged from {task_id}",
|
||||
)
|
||||
timeline.add_main_event(event)
|
||||
|
||||
self._persist_timeline(file_path)
|
||||
|
||||
debug_success(MODULE, f"Task {task_id} marked as merged")
|
||||
|
||||
def on_task_abandoned(self, task_id: str) -> None:
|
||||
"""
|
||||
Called if a task is cancelled/abandoned.
|
||||
"""
|
||||
debug(MODULE, f"on_task_abandoned: {task_id}")
|
||||
|
||||
task_files = self.get_files_for_task(task_id)
|
||||
|
||||
for file_path in task_files:
|
||||
timeline = self._timelines.get(file_path)
|
||||
if not timeline:
|
||||
continue
|
||||
|
||||
task_view = timeline.get_task_view(task_id)
|
||||
if task_view:
|
||||
task_view.status = 'abandoned'
|
||||
|
||||
self._persist_timeline(file_path)
|
||||
|
||||
# =========================================================================
|
||||
# QUERY METHODS
|
||||
# =========================================================================
|
||||
|
||||
def get_merge_context(self, task_id: str, file_path: str) -> Optional[MergeContext]:
|
||||
"""
|
||||
Build complete merge context for AI resolver.
|
||||
|
||||
This is the key method that produces the "situational awareness"
|
||||
the Merge AI needs.
|
||||
"""
|
||||
debug(MODULE, f"get_merge_context: {task_id} -> {file_path}")
|
||||
|
||||
timeline = self._timelines.get(file_path)
|
||||
if not timeline:
|
||||
debug_warning(MODULE, f"No timeline found for {file_path}")
|
||||
return None
|
||||
|
||||
task_view = timeline.get_task_view(task_id)
|
||||
if not task_view:
|
||||
debug_warning(MODULE, f"Task {task_id} not found in timeline for {file_path}")
|
||||
return None
|
||||
|
||||
# Get main evolution since task branched
|
||||
main_evolution = timeline.get_events_since_commit(task_view.branch_point.commit_hash)
|
||||
|
||||
# Get current main state
|
||||
current_main = timeline.get_current_main_state()
|
||||
current_main_content = current_main.content if current_main else task_view.branch_point.content
|
||||
current_main_commit = current_main.commit_hash if current_main else task_view.branch_point.commit_hash
|
||||
|
||||
# Get task's worktree content
|
||||
worktree_content = ""
|
||||
if task_view.worktree_state:
|
||||
worktree_content = task_view.worktree_state.content
|
||||
else:
|
||||
# Try to get from worktree path
|
||||
worktree_content = self._get_worktree_file_content(task_id, file_path)
|
||||
|
||||
# Get other pending tasks
|
||||
other_tasks = []
|
||||
for tv in timeline.get_active_tasks():
|
||||
if tv.task_id != task_id:
|
||||
other_tasks.append({
|
||||
"task_id": tv.task_id,
|
||||
"intent": tv.task_intent.description,
|
||||
"branch_point": tv.branch_point.commit_hash,
|
||||
"commits_behind": tv.commits_behind_main,
|
||||
})
|
||||
|
||||
context = MergeContext(
|
||||
file_path=file_path,
|
||||
task_id=task_id,
|
||||
task_intent=task_view.task_intent,
|
||||
task_branch_point=task_view.branch_point,
|
||||
main_evolution=main_evolution,
|
||||
task_worktree_content=worktree_content,
|
||||
current_main_content=current_main_content,
|
||||
current_main_commit=current_main_commit,
|
||||
other_pending_tasks=other_tasks,
|
||||
total_commits_behind=task_view.commits_behind_main,
|
||||
total_pending_tasks=len(other_tasks),
|
||||
)
|
||||
|
||||
debug_success(MODULE, f"Built merge context",
|
||||
commits_behind=task_view.commits_behind_main,
|
||||
main_events=len(main_evolution),
|
||||
other_tasks=len(other_tasks))
|
||||
|
||||
return context
|
||||
|
||||
def get_files_for_task(self, task_id: str) -> List[str]:
|
||||
"""Return all files this task is tracking."""
|
||||
files = []
|
||||
for file_path, timeline in self._timelines.items():
|
||||
if task_id in timeline.task_views:
|
||||
files.append(file_path)
|
||||
return files
|
||||
|
||||
def get_pending_tasks_for_file(self, file_path: str) -> List[TaskFileView]:
|
||||
"""Return all active tasks that modify this file."""
|
||||
timeline = self._timelines.get(file_path)
|
||||
if not timeline:
|
||||
return []
|
||||
return timeline.get_active_tasks()
|
||||
|
||||
def get_task_drift(self, task_id: str) -> Dict[str, int]:
|
||||
"""Return commits-behind-main for each file in task."""
|
||||
drift = {}
|
||||
for file_path, timeline in self._timelines.items():
|
||||
task_view = timeline.get_task_view(task_id)
|
||||
if task_view and task_view.status == 'active':
|
||||
drift[file_path] = task_view.commits_behind_main
|
||||
return drift
|
||||
|
||||
def has_timeline(self, file_path: str) -> bool:
|
||||
"""Check if a file has an active timeline."""
|
||||
return file_path in self._timelines
|
||||
|
||||
def get_timeline(self, file_path: str) -> Optional[FileTimeline]:
|
||||
"""Get the timeline for a file."""
|
||||
return self._timelines.get(file_path)
|
||||
|
||||
# =========================================================================
|
||||
# PERSISTENCE
|
||||
# =========================================================================
|
||||
|
||||
def _load_from_storage(self) -> None:
|
||||
"""Load timelines from disk on startup."""
|
||||
index_path = self.timelines_dir / "index.json"
|
||||
if not index_path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
with open(index_path) as f:
|
||||
index = json.load(f)
|
||||
|
||||
for file_path in index.get("files", []):
|
||||
timeline_file = self._get_timeline_file_path(file_path)
|
||||
if timeline_file.exists():
|
||||
with open(timeline_file) as f:
|
||||
data = json.load(f)
|
||||
self._timelines[file_path] = FileTimeline.from_dict(data)
|
||||
|
||||
debug(MODULE, f"Loaded {len(self._timelines)} timelines from storage")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load timelines: {e}")
|
||||
|
||||
def _persist_timeline(self, file_path: str) -> None:
|
||||
"""Save a single timeline to disk."""
|
||||
timeline = self._timelines.get(file_path)
|
||||
if not timeline:
|
||||
return
|
||||
|
||||
try:
|
||||
# Save timeline file
|
||||
timeline_file = self._get_timeline_file_path(file_path)
|
||||
timeline_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(timeline_file, "w") as f:
|
||||
json.dump(timeline.to_dict(), f, indent=2)
|
||||
|
||||
# Update index
|
||||
self._update_index()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to persist timeline for {file_path}: {e}")
|
||||
|
||||
def _update_index(self) -> None:
|
||||
"""Update the index file with all tracked files."""
|
||||
index_path = self.timelines_dir / "index.json"
|
||||
index = {
|
||||
"files": list(self._timelines.keys()),
|
||||
"last_updated": datetime.now().isoformat(),
|
||||
}
|
||||
with open(index_path, "w") as f:
|
||||
json.dump(index, f, indent=2)
|
||||
|
||||
def _get_timeline_file_path(self, file_path: str) -> Path:
|
||||
"""Get the storage path for a file's timeline."""
|
||||
# Encode path: src/App.tsx -> src_App.tsx.json
|
||||
safe_name = file_path.replace("/", "_").replace("\\", "_")
|
||||
return self.timelines_dir / f"{safe_name}.json"
|
||||
|
||||
def _get_or_create_timeline(self, file_path: str) -> FileTimeline:
|
||||
"""Get existing timeline or create new one."""
|
||||
if file_path not in self._timelines:
|
||||
self._timelines[file_path] = FileTimeline(file_path=file_path)
|
||||
return self._timelines[file_path]
|
||||
|
||||
# =========================================================================
|
||||
# GIT HELPERS
|
||||
# =========================================================================
|
||||
|
||||
def _get_current_main_commit(self) -> str:
|
||||
"""Get the current HEAD commit on main branch."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=self.project_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except subprocess.CalledProcessError:
|
||||
return "unknown"
|
||||
|
||||
def _get_file_content_at_commit(self, file_path: str, commit_hash: str) -> Optional[str]:
|
||||
"""Get file content at a specific commit."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "show", f"{commit_hash}:{file_path}"],
|
||||
cwd=self.project_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _get_files_changed_in_commit(self, commit_hash: str) -> List[str]:
|
||||
"""Get list of files changed in a commit."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff-tree", "--no-commit-id", "--name-only", "-r", commit_hash],
|
||||
cwd=self.project_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return [f for f in result.stdout.strip().split("\n") if f]
|
||||
except subprocess.CalledProcessError:
|
||||
return []
|
||||
|
||||
def _get_commit_info(self, commit_hash: str) -> dict:
|
||||
"""Get commit metadata."""
|
||||
info = {}
|
||||
try:
|
||||
# Get commit message
|
||||
result = subprocess.run(
|
||||
["git", "log", "-1", "--format=%s", commit_hash],
|
||||
cwd=self.project_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
info["message"] = result.stdout.strip()
|
||||
|
||||
# Get author
|
||||
result = subprocess.run(
|
||||
["git", "log", "-1", "--format=%an", commit_hash],
|
||||
cwd=self.project_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
info["author"] = result.stdout.strip()
|
||||
|
||||
# Get diff stat
|
||||
result = subprocess.run(
|
||||
["git", "diff-tree", "--stat", "--no-commit-id", commit_hash],
|
||||
cwd=self.project_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
info["diff_summary"] = result.stdout.strip().split("\n")[-1] if result.stdout.strip() else None
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return info
|
||||
|
||||
def _get_worktree_file_content(self, task_id: str, file_path: str) -> str:
|
||||
"""Get file content from a task's worktree."""
|
||||
# Extract spec name from task_id (remove 'task-' prefix if present)
|
||||
spec_name = task_id.replace("task-", "") if task_id.startswith("task-") else task_id
|
||||
|
||||
worktree_path = self.project_path / ".worktrees" / spec_name / file_path
|
||||
if worktree_path.exists():
|
||||
return worktree_path.read_text(encoding="utf-8")
|
||||
return ""
|
||||
|
||||
# =========================================================================
|
||||
# CAPTURE METHODS (for integration with existing code)
|
||||
# =========================================================================
|
||||
|
||||
def capture_worktree_state(self, task_id: str, worktree_path: Path) -> None:
|
||||
"""
|
||||
Capture the current state of all modified files in a worktree.
|
||||
|
||||
Called before merge to ensure we have the latest worktree content.
|
||||
"""
|
||||
debug(MODULE, f"capture_worktree_state: {task_id}")
|
||||
|
||||
try:
|
||||
# Get all changed files in worktree vs main
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", "main...HEAD"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return
|
||||
|
||||
changed_files = [f for f in result.stdout.strip().split("\n") if f]
|
||||
|
||||
for file_path in changed_files:
|
||||
full_path = worktree_path / file_path
|
||||
if full_path.exists():
|
||||
content = full_path.read_text(encoding="utf-8")
|
||||
self.on_task_worktree_change(task_id, file_path, content)
|
||||
|
||||
debug_success(MODULE, f"Captured {len(changed_files)} files from worktree")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to capture worktree state: {e}")
|
||||
|
||||
def initialize_from_worktree(
|
||||
self,
|
||||
task_id: str,
|
||||
worktree_path: Path,
|
||||
task_intent: str = "",
|
||||
task_title: str = "",
|
||||
) -> None:
|
||||
"""
|
||||
Initialize timeline tracking from an existing worktree.
|
||||
|
||||
Used for retroactive registration of tasks that were created
|
||||
before the timeline system was in place.
|
||||
"""
|
||||
debug(MODULE, f"initialize_from_worktree: {task_id}")
|
||||
|
||||
try:
|
||||
# Get the branch point (merge-base with main)
|
||||
result = subprocess.run(
|
||||
["git", "merge-base", "main", "HEAD"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
debug_warning(MODULE, "Could not determine branch point")
|
||||
return
|
||||
|
||||
branch_point = result.stdout.strip()
|
||||
|
||||
# Get changed files
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", f"{branch_point}...HEAD"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return
|
||||
|
||||
changed_files = [f for f in result.stdout.strip().split("\n") if f]
|
||||
|
||||
# Register task for these files
|
||||
self.on_task_start(
|
||||
task_id=task_id,
|
||||
files_to_modify=changed_files,
|
||||
branch_point_commit=branch_point,
|
||||
task_intent=task_intent,
|
||||
task_title=task_title,
|
||||
)
|
||||
|
||||
# Capture current worktree state
|
||||
self.capture_worktree_state(task_id, worktree_path)
|
||||
|
||||
# Calculate drift (commits behind main)
|
||||
result = subprocess.run(
|
||||
["git", "rev-list", "--count", f"{branch_point}..main"],
|
||||
cwd=self.project_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
drift = int(result.stdout.strip())
|
||||
for file_path in changed_files:
|
||||
timeline = self._timelines.get(file_path)
|
||||
if timeline:
|
||||
task_view = timeline.get_task_view(task_id)
|
||||
if task_view:
|
||||
task_view.commits_behind_main = drift
|
||||
self._persist_timeline(file_path)
|
||||
|
||||
debug_success(MODULE, f"Initialized from worktree",
|
||||
files=len(changed_files),
|
||||
branch_point=branch_point[:8])
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize from worktree: {e}")
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Git post-commit hook for FileTimelineTracker
|
||||
# =============================================
|
||||
#
|
||||
# This hook notifies the FileTimelineTracker when human commits
|
||||
# are made to the main branch, enabling drift tracking.
|
||||
#
|
||||
# Installation:
|
||||
# Copy to .git/hooks/post-commit and make executable
|
||||
# Or use: python -m auto_claude.merge.install_hook
|
||||
#
|
||||
|
||||
COMMIT_HASH=$(git rev-parse HEAD)
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
# Only track commits to main/master branch
|
||||
# Skip if we're in a worktree (auto-claude branches)
|
||||
if [[ "$BRANCH" == "main" ]] || [[ "$BRANCH" == "master" ]]; then
|
||||
# Check if this is the main working directory (not a worktree)
|
||||
# Worktrees have a .git file pointing to the main repo, not a .git directory
|
||||
if [[ -d ".git" ]]; then
|
||||
# Find python executable
|
||||
if command -v python3 &> /dev/null; then
|
||||
PYTHON=python3
|
||||
elif command -v python &> /dev/null; then
|
||||
PYTHON=python
|
||||
else
|
||||
# Python not found, skip silently
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Try to notify the tracker
|
||||
# Run in background to avoid slowing down commits
|
||||
($PYTHON -m auto_claude.merge.tracker_cli notify-commit "$COMMIT_HASH" 2>/dev/null &) &
|
||||
|
||||
# Don't let hook failures block commits
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Not main branch or in worktree, do nothing
|
||||
exit 0
|
||||
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Git Hook Installer for FileTimelineTracker
|
||||
==========================================
|
||||
|
||||
Installs the post-commit hook for tracking main branch commits.
|
||||
|
||||
Usage:
|
||||
python -m auto_claude.merge.install_hook [--project-path /path/to/project]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
HOOK_SCRIPT = '''#!/bin/bash
|
||||
#
|
||||
# Git post-commit hook for FileTimelineTracker
|
||||
# =============================================
|
||||
#
|
||||
# This hook notifies the FileTimelineTracker when human commits
|
||||
# are made to the main branch, enabling drift tracking.
|
||||
#
|
||||
|
||||
COMMIT_HASH=$(git rev-parse HEAD)
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
# Only track commits to main/master branch
|
||||
# Skip if we're in a worktree (auto-claude branches)
|
||||
if [[ "$BRANCH" == "main" ]] || [[ "$BRANCH" == "master" ]]; then
|
||||
# Check if this is the main working directory (not a worktree)
|
||||
# Worktrees have a .git file pointing to the main repo, not a .git directory
|
||||
if [[ -d ".git" ]]; then
|
||||
# Find python executable
|
||||
if command -v python3 &> /dev/null; then
|
||||
PYTHON=python3
|
||||
elif command -v python &> /dev/null; then
|
||||
PYTHON=python
|
||||
else
|
||||
# Python not found, skip silently
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Try to notify the tracker
|
||||
# Run in background to avoid slowing down commits
|
||||
($PYTHON -m auto_claude.merge.tracker_cli notify-commit "$COMMIT_HASH" 2>/dev/null &) &
|
||||
|
||||
# Don't let hook failures block commits
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Not main branch or in worktree, do nothing
|
||||
exit 0
|
||||
'''
|
||||
|
||||
|
||||
def find_project_root() -> Path:
|
||||
"""Find the project root by looking for .git directory."""
|
||||
current = Path.cwd()
|
||||
|
||||
while current != current.parent:
|
||||
if (current / ".git").exists():
|
||||
return current
|
||||
current = current.parent
|
||||
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def install_hook(project_path: Path) -> bool:
|
||||
"""Install the post-commit hook to a project."""
|
||||
git_dir = project_path / ".git"
|
||||
|
||||
# Handle worktrees (where .git is a file, not directory)
|
||||
if git_dir.is_file():
|
||||
# Read the gitdir from the file
|
||||
content = git_dir.read_text().strip()
|
||||
if content.startswith("gitdir:"):
|
||||
git_dir = Path(content.split(":", 1)[1].strip())
|
||||
else:
|
||||
print(f"Error: Cannot parse .git file at {git_dir}")
|
||||
return False
|
||||
|
||||
if not git_dir.is_dir():
|
||||
print(f"Error: No .git directory found at {project_path}")
|
||||
return False
|
||||
|
||||
hooks_dir = git_dir / "hooks"
|
||||
hooks_dir.mkdir(exist_ok=True)
|
||||
|
||||
hook_path = hooks_dir / "post-commit"
|
||||
|
||||
# Check if hook already exists
|
||||
if hook_path.exists():
|
||||
existing = hook_path.read_text()
|
||||
if "FileTimelineTracker" in existing:
|
||||
print(f"Hook already installed at {hook_path}")
|
||||
return True
|
||||
|
||||
# Backup existing hook
|
||||
backup_path = hooks_dir / "post-commit.backup"
|
||||
shutil.copy(hook_path, backup_path)
|
||||
print(f"Backed up existing hook to {backup_path}")
|
||||
|
||||
# Append our hook to existing
|
||||
with open(hook_path, "a") as f:
|
||||
f.write("\n\n# FileTimelineTracker integration\n")
|
||||
f.write(HOOK_SCRIPT.split("#!/bin/bash", 1)[1]) # Skip shebang
|
||||
print(f"Appended FileTimelineTracker hook to {hook_path}")
|
||||
else:
|
||||
# Write new hook
|
||||
hook_path.write_text(HOOK_SCRIPT)
|
||||
print(f"Created new hook at {hook_path}")
|
||||
|
||||
# Make executable
|
||||
hook_path.chmod(hook_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
print("Hook is now executable")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def uninstall_hook(project_path: Path) -> bool:
|
||||
"""Remove the post-commit hook from a project."""
|
||||
git_dir = project_path / ".git"
|
||||
|
||||
if git_dir.is_file():
|
||||
content = git_dir.read_text().strip()
|
||||
if content.startswith("gitdir:"):
|
||||
git_dir = Path(content.split(":", 1)[1].strip())
|
||||
|
||||
hook_path = git_dir / "hooks" / "post-commit"
|
||||
|
||||
if not hook_path.exists():
|
||||
print("No hook to uninstall")
|
||||
return True
|
||||
|
||||
content = hook_path.read_text()
|
||||
if "FileTimelineTracker" not in content:
|
||||
print("Hook does not contain FileTimelineTracker integration")
|
||||
return True
|
||||
|
||||
# Check if we can restore from backup
|
||||
backup_path = git_dir / "hooks" / "post-commit.backup"
|
||||
if backup_path.exists():
|
||||
shutil.move(backup_path, hook_path)
|
||||
print(f"Restored original hook from backup")
|
||||
else:
|
||||
# Remove the hook entirely
|
||||
hook_path.unlink()
|
||||
print(f"Removed hook at {hook_path}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Install/uninstall FileTimelineTracker git hook"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--project-path",
|
||||
type=Path,
|
||||
help="Path to project (default: current directory)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--uninstall",
|
||||
action="store_true",
|
||||
help="Uninstall the hook",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
project_path = args.project_path or find_project_root()
|
||||
|
||||
if args.uninstall:
|
||||
success = uninstall_hook(project_path)
|
||||
else:
|
||||
success = install_hook(project_path)
|
||||
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
AI Merge Prompt Templates
|
||||
=========================
|
||||
|
||||
Templates for providing rich context to the AI merge resolver,
|
||||
using the FileTimelineTracker's complete file evolution data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .file_timeline import MergeContext, MainBranchEvent
|
||||
|
||||
|
||||
def build_timeline_merge_prompt(context: "MergeContext") -> str:
|
||||
"""
|
||||
Build a complete merge prompt using FileTimelineTracker context.
|
||||
|
||||
This provides the AI with full situational awareness:
|
||||
- Task's starting point (branch point)
|
||||
- Complete main branch evolution since branch
|
||||
- Task's intent and changes
|
||||
- Other pending tasks that will merge later
|
||||
|
||||
Args:
|
||||
context: MergeContext from FileTimelineTracker.get_merge_context()
|
||||
|
||||
Returns:
|
||||
Formatted prompt string for AI merge resolution
|
||||
"""
|
||||
# Build main evolution section
|
||||
main_evolution_section = _build_main_evolution_section(context)
|
||||
|
||||
# Build pending tasks section
|
||||
pending_tasks_section = _build_pending_tasks_section(context)
|
||||
|
||||
prompt = f'''MERGING: {context.file_path}
|
||||
TASK: {context.task_id} ({context.task_intent.title})
|
||||
|
||||
{"=" * 79}
|
||||
|
||||
TASK'S STARTING POINT
|
||||
Branched from commit: {context.task_branch_point.commit_hash[:12]}
|
||||
Branched at: {context.task_branch_point.timestamp}
|
||||
{"─" * 79}
|
||||
```
|
||||
{context.task_branch_point.content}
|
||||
```
|
||||
|
||||
{"=" * 79}
|
||||
|
||||
{main_evolution_section}
|
||||
|
||||
CURRENT MAIN CONTENT (commit {context.current_main_commit[:12]}):
|
||||
{"─" * 79}
|
||||
```
|
||||
{context.current_main_content}
|
||||
```
|
||||
|
||||
{"=" * 79}
|
||||
|
||||
TASK'S CHANGES
|
||||
Intent: "{context.task_intent.description or context.task_intent.title}"
|
||||
{"─" * 79}
|
||||
```
|
||||
{context.task_worktree_content}
|
||||
```
|
||||
|
||||
{"=" * 79}
|
||||
|
||||
{pending_tasks_section}
|
||||
|
||||
YOUR TASK:
|
||||
|
||||
1. Merge {context.task_id}'s changes into the current main version
|
||||
|
||||
2. PRESERVE all changes from main branch commits listed above
|
||||
- Every human commit since the task branched must be retained
|
||||
- Every previously merged task's changes must be retained
|
||||
|
||||
3. APPLY {context.task_id}'s changes
|
||||
- Intent: {context.task_intent.description or context.task_intent.title}
|
||||
- The task's changes should achieve its stated intent
|
||||
|
||||
4. ENSURE COMPATIBILITY with pending tasks
|
||||
{_build_compatibility_instructions(context)}
|
||||
|
||||
5. OUTPUT only the complete merged file content
|
||||
|
||||
{"=" * 79}
|
||||
'''
|
||||
|
||||
return prompt
|
||||
|
||||
|
||||
def _build_main_evolution_section(context: "MergeContext") -> str:
|
||||
"""Build the main branch evolution section of the prompt."""
|
||||
if not context.main_evolution:
|
||||
return f"""MAIN BRANCH EVOLUTION (0 commits since task branched)
|
||||
{"─" * 79}
|
||||
No changes have been made to main branch since this task started.
|
||||
"""
|
||||
|
||||
lines = [f"MAIN BRANCH EVOLUTION ({len(context.main_evolution)} commits since task branched)"]
|
||||
lines.append("─" * 79)
|
||||
lines.append("")
|
||||
|
||||
for event in context.main_evolution:
|
||||
source_label = event.source.upper()
|
||||
if event.source == 'merged_task' and event.merged_from_task:
|
||||
source_label = f"MERGED FROM {event.merged_from_task}"
|
||||
|
||||
lines.append(f'COMMIT {event.commit_hash[:12]} [{source_label}]: "{event.commit_message}"')
|
||||
lines.append(f"Timestamp: {event.timestamp}")
|
||||
|
||||
if event.diff_summary:
|
||||
lines.append(f"Changes: {event.diff_summary}")
|
||||
else:
|
||||
lines.append("Changes: See content evolution below")
|
||||
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_pending_tasks_section(context: "MergeContext") -> str:
|
||||
"""Build the other pending tasks section."""
|
||||
separator = "─" * 79
|
||||
if not context.other_pending_tasks:
|
||||
return f"""OTHER TASKS MODIFYING THIS FILE
|
||||
{separator}
|
||||
No other tasks are pending for this file.
|
||||
"""
|
||||
|
||||
lines = ["OTHER TASKS ALSO MODIFYING THIS FILE (not yet merged)"]
|
||||
lines.append("─" * 79)
|
||||
lines.append("")
|
||||
|
||||
for task in context.other_pending_tasks:
|
||||
task_id = task.get("task_id", "unknown")
|
||||
intent = task.get("intent", "No intent specified")
|
||||
branch_point = task.get("branch_point", "unknown")[:12]
|
||||
commits_behind = task.get("commits_behind", 0)
|
||||
|
||||
lines.append(f"• {task_id} (branched at {branch_point}, {commits_behind} commits behind)")
|
||||
lines.append(f' Intent: "{intent}"')
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_compatibility_instructions(context: "MergeContext") -> str:
|
||||
"""Build compatibility instructions based on pending tasks."""
|
||||
if not context.other_pending_tasks:
|
||||
return "- No other tasks pending for this file"
|
||||
|
||||
lines = [f"- {len(context.other_pending_tasks)} other task(s) will merge after this"]
|
||||
lines.append(" - Structure your merge to accommodate their upcoming changes:")
|
||||
|
||||
for task in context.other_pending_tasks:
|
||||
task_id = task.get("task_id", "unknown")
|
||||
intent = task.get("intent", "")
|
||||
if intent:
|
||||
lines.append(f" - {task_id}: {intent[:80]}...")
|
||||
else:
|
||||
lines.append(f" - {task_id}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_simple_merge_prompt(
|
||||
file_path: str,
|
||||
main_content: str,
|
||||
worktree_content: str,
|
||||
base_content: str | None,
|
||||
spec_name: str,
|
||||
language: str,
|
||||
task_intent: dict | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Build a simple three-way merge prompt (fallback when timeline not available).
|
||||
|
||||
This is the traditional merge prompt without full timeline context.
|
||||
"""
|
||||
intent_section = ""
|
||||
if task_intent:
|
||||
intent_section = f"""
|
||||
=== FEATURE BRANCH INTENT ({spec_name}) ===
|
||||
Task: {task_intent.get('title', spec_name)}
|
||||
Description: {task_intent.get('description', 'No description')}
|
||||
"""
|
||||
if task_intent.get('spec_summary'):
|
||||
intent_section += f"Summary: {task_intent['spec_summary']}\n"
|
||||
|
||||
base_section = base_content if base_content else "(File did not exist in common ancestor)"
|
||||
|
||||
prompt = f'''You are a code merge expert. Merge the following conflicting versions of a file.
|
||||
|
||||
FILE: {file_path}
|
||||
|
||||
The file was modified in both the main branch and in the "{spec_name}" feature branch.
|
||||
Your task is to produce a merged version that incorporates ALL changes from both branches.
|
||||
{intent_section}
|
||||
=== COMMON ANCESTOR (base) ===
|
||||
{base_section}
|
||||
|
||||
=== MAIN BRANCH VERSION ===
|
||||
{main_content}
|
||||
|
||||
=== FEATURE BRANCH VERSION ({spec_name}) ===
|
||||
{worktree_content}
|
||||
|
||||
MERGE RULES:
|
||||
1. Keep ALL imports from both versions
|
||||
2. Keep ALL new functions/components from both versions
|
||||
3. If the same function was modified differently, combine the changes logically
|
||||
4. Preserve the intent of BOTH branches - main's changes are important too
|
||||
5. If there's a genuine semantic conflict (same thing done differently), prefer the feature branch version but include main's additions
|
||||
6. The merged code MUST be syntactically valid {language}
|
||||
|
||||
Output ONLY the merged code, wrapped in triple backticks:
|
||||
```{language}
|
||||
merged code here
|
||||
```
|
||||
'''
|
||||
return prompt
|
||||
|
||||
|
||||
def optimize_prompt_for_length(
|
||||
context: "MergeContext",
|
||||
max_content_chars: int = 50000,
|
||||
max_evolution_events: int = 10,
|
||||
) -> "MergeContext":
|
||||
"""
|
||||
Optimize a MergeContext for prompt length by trimming large content.
|
||||
|
||||
For very long files or many commits, this summarizes the middle
|
||||
parts to keep the prompt within reasonable bounds.
|
||||
|
||||
Args:
|
||||
context: Original MergeContext
|
||||
max_content_chars: Maximum characters for file content
|
||||
max_evolution_events: Maximum main branch events to include
|
||||
|
||||
Returns:
|
||||
Modified MergeContext with trimmed content
|
||||
"""
|
||||
# Trim main evolution to first N and last N events if too long
|
||||
if len(context.main_evolution) > max_evolution_events:
|
||||
half = max_evolution_events // 2
|
||||
first_events = context.main_evolution[:half]
|
||||
last_events = context.main_evolution[-half:]
|
||||
|
||||
# Create a placeholder event for the middle
|
||||
from datetime import datetime
|
||||
from .file_timeline import MainBranchEvent
|
||||
|
||||
omitted_count = len(context.main_evolution) - max_evolution_events
|
||||
placeholder = MainBranchEvent(
|
||||
commit_hash="...",
|
||||
timestamp=datetime.now(),
|
||||
content="[Content omitted for brevity]",
|
||||
source="human",
|
||||
commit_message=f"({omitted_count} commits omitted for brevity)",
|
||||
)
|
||||
|
||||
context.main_evolution = first_events + [placeholder] + last_events
|
||||
|
||||
# Trim content if too long
|
||||
def _trim_content(content: str, label: str) -> str:
|
||||
if len(content) > max_content_chars:
|
||||
half = max_content_chars // 2
|
||||
return (
|
||||
content[:half]
|
||||
+ f"\n\n... [{label}: {len(content) - max_content_chars} chars omitted] ...\n\n"
|
||||
+ content[-half:]
|
||||
)
|
||||
return content
|
||||
|
||||
context.task_branch_point.content = _trim_content(
|
||||
context.task_branch_point.content, "branch point"
|
||||
)
|
||||
context.task_worktree_content = _trim_content(
|
||||
context.task_worktree_content, "worktree"
|
||||
)
|
||||
context.current_main_content = _trim_content(
|
||||
context.current_main_content, "main"
|
||||
)
|
||||
|
||||
return context
|
||||
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
FileTimelineTracker CLI
|
||||
=======================
|
||||
|
||||
CLI interface for the FileTimelineTracker service.
|
||||
Used by git hooks and manual operations.
|
||||
|
||||
Usage:
|
||||
python -m auto_claude.merge.tracker_cli notify-commit <hash>
|
||||
python -m auto_claude.merge.tracker_cli show-timeline <file_path>
|
||||
python -m auto_claude.merge.tracker_cli show-drift <task_id>
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .file_timeline import FileTimelineTracker
|
||||
|
||||
|
||||
def find_project_root() -> Path:
|
||||
"""Find the project root by looking for .auto-claude or .git directory."""
|
||||
current = Path.cwd()
|
||||
|
||||
# Walk up until we find .auto-claude or .git
|
||||
while current != current.parent:
|
||||
if (current / ".auto-claude").exists() or (current / ".git").exists():
|
||||
return current
|
||||
current = current.parent
|
||||
|
||||
# Default to cwd
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def get_tracker() -> FileTimelineTracker:
|
||||
"""Get the FileTimelineTracker instance for this project."""
|
||||
project_path = find_project_root()
|
||||
return FileTimelineTracker(project_path)
|
||||
|
||||
|
||||
def cmd_notify_commit(args):
|
||||
"""Handle the notify-commit command from git post-commit hook."""
|
||||
tracker = get_tracker()
|
||||
commit_hash = args.commit_hash
|
||||
|
||||
print(f"[FileTimelineTracker] Processing commit: {commit_hash[:8]}")
|
||||
tracker.on_main_branch_commit(commit_hash)
|
||||
print(f"[FileTimelineTracker] Commit processed successfully")
|
||||
|
||||
|
||||
def cmd_show_timeline(args):
|
||||
"""Show the timeline for a file."""
|
||||
tracker = get_tracker()
|
||||
file_path = args.file_path
|
||||
|
||||
timeline = tracker.get_timeline(file_path)
|
||||
if not timeline:
|
||||
print(f"No timeline found for: {file_path}")
|
||||
return
|
||||
|
||||
print(f"\n=== Timeline for: {file_path} ===\n")
|
||||
print(f"Created: {timeline.created_at}")
|
||||
print(f"Last Updated: {timeline.last_updated}")
|
||||
|
||||
print(f"\n--- Main Branch History ({len(timeline.main_branch_history)} events) ---")
|
||||
for i, event in enumerate(timeline.main_branch_history):
|
||||
print(f" [{i+1}] {event.commit_hash[:8]} ({event.source}): {event.commit_message[:50]}...")
|
||||
|
||||
print(f"\n--- Task Views ({len(timeline.task_views)} tasks) ---")
|
||||
for task_id, view in timeline.task_views.items():
|
||||
status = f"[{view.status.upper()}]"
|
||||
behind = f"{view.commits_behind_main} commits behind"
|
||||
print(f" {task_id} {status} - {behind}")
|
||||
print(f" Branch point: {view.branch_point.commit_hash[:8]}")
|
||||
print(f" Intent: {view.task_intent.title}")
|
||||
|
||||
|
||||
def cmd_show_drift(args):
|
||||
"""Show commits-behind-main for a task."""
|
||||
tracker = get_tracker()
|
||||
task_id = args.task_id
|
||||
|
||||
drift = tracker.get_task_drift(task_id)
|
||||
if not drift:
|
||||
print(f"No files found for task: {task_id}")
|
||||
return
|
||||
|
||||
print(f"\n=== Drift Report for: {task_id} ===\n")
|
||||
total_drift = 0
|
||||
for file_path, commits_behind in sorted(drift.items()):
|
||||
print(f" {file_path}: {commits_behind} commits behind")
|
||||
total_drift = max(total_drift, commits_behind)
|
||||
|
||||
print(f"\n Max drift: {total_drift} commits")
|
||||
|
||||
|
||||
def cmd_show_context(args):
|
||||
"""Show merge context for a task and file."""
|
||||
tracker = get_tracker()
|
||||
task_id = args.task_id
|
||||
file_path = args.file_path
|
||||
|
||||
context = tracker.get_merge_context(task_id, file_path)
|
||||
if not context:
|
||||
print(f"No merge context available for {task_id} -> {file_path}")
|
||||
return
|
||||
|
||||
print(f"\n=== Merge Context for: {task_id} -> {file_path} ===\n")
|
||||
print(f"Task Intent: {context.task_intent.title}")
|
||||
print(f" {context.task_intent.description}")
|
||||
print(f"\nBranch Point: {context.task_branch_point.commit_hash[:8]}")
|
||||
print(f"Current Main: {context.current_main_commit[:8]}")
|
||||
print(f"Commits Behind: {context.total_commits_behind}")
|
||||
print(f"Other Pending Tasks: {context.total_pending_tasks}")
|
||||
|
||||
if context.other_pending_tasks:
|
||||
print("\n--- Other Pending Tasks ---")
|
||||
for task in context.other_pending_tasks:
|
||||
print(f" {task['task_id']}: {task['intent'][:50]}...")
|
||||
|
||||
print(f"\n--- Main Evolution ({len(context.main_evolution)} events) ---")
|
||||
for event in context.main_evolution:
|
||||
print(f" {event.commit_hash[:8]} ({event.source}): {event.commit_message[:50]}...")
|
||||
|
||||
|
||||
def cmd_list_files(args):
|
||||
"""List all tracked files."""
|
||||
tracker = get_tracker()
|
||||
|
||||
print("\n=== Tracked Files ===\n")
|
||||
|
||||
# Access internal _timelines
|
||||
if not tracker._timelines:
|
||||
print("No files currently tracked.")
|
||||
return
|
||||
|
||||
for file_path in sorted(tracker._timelines.keys()):
|
||||
timeline = tracker._timelines[file_path]
|
||||
active_tasks = len([tv for tv in timeline.task_views.values() if tv.status == 'active'])
|
||||
main_events = len(timeline.main_branch_history)
|
||||
print(f" {file_path}: {active_tasks} active tasks, {main_events} main events")
|
||||
|
||||
|
||||
def cmd_init_from_worktree(args):
|
||||
"""Initialize tracking from an existing worktree."""
|
||||
tracker = get_tracker()
|
||||
task_id = args.task_id
|
||||
worktree_path = Path(args.worktree_path).resolve()
|
||||
|
||||
if not worktree_path.exists():
|
||||
print(f"Worktree path does not exist: {worktree_path}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Initializing tracking for {task_id} from {worktree_path}")
|
||||
tracker.initialize_from_worktree(
|
||||
task_id=task_id,
|
||||
worktree_path=worktree_path,
|
||||
task_intent=args.intent or "",
|
||||
task_title=args.title or task_id,
|
||||
)
|
||||
print("Done.")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="FileTimelineTracker CLI",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
||||
|
||||
# notify-commit
|
||||
notify_parser = subparsers.add_parser(
|
||||
"notify-commit",
|
||||
help="Notify tracker of a new commit (called by git post-commit hook)"
|
||||
)
|
||||
notify_parser.add_argument("commit_hash", help="The commit hash")
|
||||
notify_parser.set_defaults(func=cmd_notify_commit)
|
||||
|
||||
# show-timeline
|
||||
timeline_parser = subparsers.add_parser(
|
||||
"show-timeline",
|
||||
help="Show the timeline for a file"
|
||||
)
|
||||
timeline_parser.add_argument("file_path", help="The file path (relative to project)")
|
||||
timeline_parser.set_defaults(func=cmd_show_timeline)
|
||||
|
||||
# show-drift
|
||||
drift_parser = subparsers.add_parser(
|
||||
"show-drift",
|
||||
help="Show commits-behind-main for a task"
|
||||
)
|
||||
drift_parser.add_argument("task_id", help="The task ID")
|
||||
drift_parser.set_defaults(func=cmd_show_drift)
|
||||
|
||||
# show-context
|
||||
context_parser = subparsers.add_parser(
|
||||
"show-context",
|
||||
help="Show merge context for a task and file"
|
||||
)
|
||||
context_parser.add_argument("task_id", help="The task ID")
|
||||
context_parser.add_argument("file_path", help="The file path")
|
||||
context_parser.set_defaults(func=cmd_show_context)
|
||||
|
||||
# list-files
|
||||
list_parser = subparsers.add_parser(
|
||||
"list-files",
|
||||
help="List all tracked files"
|
||||
)
|
||||
list_parser.set_defaults(func=cmd_list_files)
|
||||
|
||||
# init-from-worktree
|
||||
init_parser = subparsers.add_parser(
|
||||
"init-from-worktree",
|
||||
help="Initialize tracking from an existing worktree"
|
||||
)
|
||||
init_parser.add_argument("task_id", help="The task ID")
|
||||
init_parser.add_argument("worktree_path", help="Path to the worktree")
|
||||
init_parser.add_argument("--intent", help="Task intent description")
|
||||
init_parser.add_argument("--title", help="Task title")
|
||||
init_parser.set_defaults(func=cmd_init_from_worktree)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+222
-77
@@ -64,8 +64,12 @@ from merge import (
|
||||
MergeDecision,
|
||||
ConflictSeverity,
|
||||
FileEvolutionTracker,
|
||||
FileTimelineTracker,
|
||||
)
|
||||
|
||||
# Track if we've already tried to install the git hook this session
|
||||
_git_hook_check_done = False
|
||||
|
||||
MODULE = "workspace"
|
||||
|
||||
|
||||
@@ -291,6 +295,9 @@ def setup_workspace(
|
||||
print()
|
||||
print_status("Setting up separate workspace...", "progress")
|
||||
|
||||
# Ensure timeline tracking hook is installed (once per session)
|
||||
_ensure_timeline_hook_installed(project_dir)
|
||||
|
||||
manager = WorktreeManager(project_dir)
|
||||
manager.setup()
|
||||
|
||||
@@ -308,9 +315,132 @@ def setup_workspace(
|
||||
print_status(f"Workspace ready: {worktree_info.path.name}", "success")
|
||||
print()
|
||||
|
||||
# Initialize FileTimelineTracker for this task
|
||||
_initialize_timeline_tracking(
|
||||
project_dir=project_dir,
|
||||
spec_name=spec_name,
|
||||
worktree_path=worktree_info.path,
|
||||
source_spec_dir=localized_spec_dir or source_spec_dir,
|
||||
)
|
||||
|
||||
return worktree_info.path, manager, localized_spec_dir
|
||||
|
||||
|
||||
def _ensure_timeline_hook_installed(project_dir: Path) -> None:
|
||||
"""
|
||||
Ensure the FileTimelineTracker git post-commit hook is installed.
|
||||
|
||||
This enables tracking human commits to main branch for drift detection.
|
||||
Called once per session during first workspace setup.
|
||||
"""
|
||||
global _git_hook_check_done
|
||||
if _git_hook_check_done:
|
||||
return
|
||||
|
||||
_git_hook_check_done = True
|
||||
|
||||
try:
|
||||
git_dir = project_dir / ".git"
|
||||
if not git_dir.exists():
|
||||
return # Not a git repo
|
||||
|
||||
# Handle worktrees (where .git is a file, not directory)
|
||||
if git_dir.is_file():
|
||||
content = git_dir.read_text().strip()
|
||||
if content.startswith("gitdir:"):
|
||||
git_dir = Path(content.split(":", 1)[1].strip())
|
||||
else:
|
||||
return
|
||||
|
||||
hook_path = git_dir / "hooks" / "post-commit"
|
||||
|
||||
# Check if hook already installed
|
||||
if hook_path.exists():
|
||||
if "FileTimelineTracker" in hook_path.read_text():
|
||||
debug(MODULE, "FileTimelineTracker hook already installed")
|
||||
return
|
||||
|
||||
# Auto-install the hook (silent, non-intrusive)
|
||||
from merge.install_hook import install_hook
|
||||
install_hook(project_dir)
|
||||
debug(MODULE, "Auto-installed FileTimelineTracker git hook")
|
||||
|
||||
except Exception as e:
|
||||
# Non-fatal - hook installation is optional
|
||||
debug_warning(MODULE, f"Could not auto-install timeline hook: {e}")
|
||||
|
||||
|
||||
def _initialize_timeline_tracking(
|
||||
project_dir: Path,
|
||||
spec_name: str,
|
||||
worktree_path: Path,
|
||||
source_spec_dir: Path | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize FileTimelineTracker for a new task.
|
||||
|
||||
This registers the task's branch point and the files it intends to modify,
|
||||
enabling intent-aware merge conflict resolution later.
|
||||
"""
|
||||
try:
|
||||
tracker = FileTimelineTracker(project_dir)
|
||||
|
||||
# Get task intent from implementation plan
|
||||
task_intent = ""
|
||||
task_title = spec_name
|
||||
files_to_modify = []
|
||||
|
||||
if source_spec_dir:
|
||||
plan_path = source_spec_dir / "implementation_plan.json"
|
||||
if plan_path.exists():
|
||||
import json
|
||||
with open(plan_path) as f:
|
||||
plan = json.load(f)
|
||||
task_title = plan.get("title", spec_name)
|
||||
task_intent = plan.get("description", "")
|
||||
|
||||
# Extract files from phases/subtasks
|
||||
for phase in plan.get("phases", []):
|
||||
for subtask in phase.get("subtasks", []):
|
||||
files_to_modify.extend(subtask.get("files", []))
|
||||
|
||||
# Get the current branch point commit
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
branch_point = result.stdout.strip() if result.returncode == 0 else None
|
||||
|
||||
if files_to_modify and branch_point:
|
||||
# Register the task with known files
|
||||
tracker.on_task_start(
|
||||
task_id=spec_name,
|
||||
files_to_modify=list(set(files_to_modify)), # Dedupe
|
||||
branch_point_commit=branch_point,
|
||||
task_intent=task_intent,
|
||||
task_title=task_title,
|
||||
)
|
||||
debug(MODULE, f"Timeline tracking initialized for {spec_name}",
|
||||
files_tracked=len(files_to_modify),
|
||||
branch_point=branch_point[:8] if branch_point else None)
|
||||
else:
|
||||
# Initialize retroactively from worktree if no plan
|
||||
tracker.initialize_from_worktree(
|
||||
task_id=spec_name,
|
||||
worktree_path=worktree_path,
|
||||
task_intent=task_intent,
|
||||
task_title=task_title,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Non-fatal - timeline tracking is supplementary
|
||||
debug_warning(MODULE, f"Could not initialize timeline tracking: {e}")
|
||||
print(muted(f" Note: Timeline tracking could not be initialized: {e}"))
|
||||
|
||||
|
||||
def show_build_summary(manager: WorktreeManager, spec_name: str) -> None:
|
||||
"""Show a summary of what was built."""
|
||||
summary = manager.get_change_summary(spec_name)
|
||||
@@ -766,6 +896,14 @@ def _try_smart_merge_inner(
|
||||
try:
|
||||
print(muted(" Analyzing changes with intent-aware merge..."))
|
||||
|
||||
# Capture worktree state in FileTimelineTracker before merge
|
||||
try:
|
||||
timeline_tracker = FileTimelineTracker(project_dir)
|
||||
timeline_tracker.capture_worktree_state(spec_name, worktree_path)
|
||||
debug(MODULE, "Captured worktree state for timeline tracking")
|
||||
except Exception as e:
|
||||
debug_warning(MODULE, f"Could not capture worktree state: {e}")
|
||||
|
||||
# Initialize the orchestrator
|
||||
debug(MODULE, "Initializing MergeOrchestrator",
|
||||
project_dir=str(project_dir),
|
||||
@@ -1286,9 +1424,10 @@ def _record_merge_completion(
|
||||
spec_name: str,
|
||||
merged_files: list[str],
|
||||
task_intent: str = "",
|
||||
merge_commit: str = "",
|
||||
) -> None:
|
||||
"""
|
||||
Record completed merge in the Evolution Tracker.
|
||||
Record completed merge in both Evolution Tracker and FileTimelineTracker.
|
||||
|
||||
This enables future AI merges to understand the history of file changes,
|
||||
creating a knowledge chain for intelligent conflict resolution.
|
||||
@@ -1298,16 +1437,18 @@ def _record_merge_completion(
|
||||
spec_name: The task/spec that was merged
|
||||
merged_files: List of file paths that were merged
|
||||
task_intent: Description of what the task accomplished
|
||||
merge_commit: The commit hash of the merge (for timeline tracking)
|
||||
"""
|
||||
# Get intent from implementation plan if not provided
|
||||
if not task_intent:
|
||||
intent_data = _get_task_intent(project_dir, spec_name)
|
||||
if intent_data:
|
||||
task_intent = intent_data.get("description", "") or intent_data.get("title", spec_name)
|
||||
|
||||
# Track in FileEvolutionTracker (legacy system)
|
||||
try:
|
||||
tracker = FileEvolutionTracker(project_dir)
|
||||
|
||||
# Get intent from implementation plan if not provided
|
||||
if not task_intent:
|
||||
intent_data = _get_task_intent(project_dir, spec_name)
|
||||
if intent_data:
|
||||
task_intent = intent_data.get("description", "") or intent_data.get("title", spec_name)
|
||||
|
||||
# Mark the task as completed for all its tracked files
|
||||
tracker.mark_task_completed(spec_name)
|
||||
|
||||
@@ -1324,11 +1465,38 @@ def _record_merge_completion(
|
||||
# Save updates
|
||||
tracker._save_evolutions()
|
||||
|
||||
debug(MODULE, f"Recorded merge in FileEvolutionTracker",
|
||||
spec_name=spec_name, files=len(merged_files))
|
||||
|
||||
except Exception as e:
|
||||
debug_warning(MODULE, f"Could not record in FileEvolutionTracker: {e}")
|
||||
|
||||
# Track in FileTimelineTracker (new intent-aware system)
|
||||
try:
|
||||
timeline_tracker = FileTimelineTracker(project_dir)
|
||||
|
||||
# Get merge commit if not provided
|
||||
if not merge_commit:
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
merge_commit = result.stdout.strip() if result.returncode == 0 else "unknown"
|
||||
|
||||
# Mark task as merged in timeline tracker
|
||||
timeline_tracker.on_task_merged(spec_name, merge_commit)
|
||||
|
||||
debug(MODULE, f"Recorded merge in FileTimelineTracker",
|
||||
spec_name=spec_name, merge_commit=merge_commit[:8])
|
||||
print(muted(f" Recorded merge completion for {len(merged_files)} files"))
|
||||
|
||||
except Exception as e:
|
||||
# Non-fatal - this is supplementary tracking
|
||||
print(muted(f" Could not record merge completion: {e}"))
|
||||
debug_warning(MODULE, f"Could not record in FileTimelineTracker: {e}")
|
||||
print(muted(f" Note: Could not record merge completion: {e}"))
|
||||
|
||||
|
||||
def _get_task_intent(project_dir: Path, spec_name: str) -> Optional[dict]:
|
||||
@@ -1553,6 +1721,7 @@ def _merge_file_with_ai(
|
||||
Use AI to merge a conflicting file.
|
||||
|
||||
This enhanced version includes:
|
||||
- FileTimelineTracker context for full situational awareness
|
||||
- Task intent from implementation_plan.json
|
||||
- Binary file detection
|
||||
- File size limits
|
||||
@@ -1561,6 +1730,7 @@ def _merge_file_with_ai(
|
||||
Returns merged content, or None if AI couldn't resolve.
|
||||
"""
|
||||
from merge import create_claude_resolver
|
||||
from merge.prompts import build_timeline_merge_prompt, build_simple_merge_prompt
|
||||
|
||||
debug(MODULE, f"AI merge starting for: {file_path}",
|
||||
spec_name=spec_name,
|
||||
@@ -1599,85 +1769,60 @@ def _merge_file_with_ai(
|
||||
print(muted(f" AI not available, trying heuristic merge..."))
|
||||
return _heuristic_merge(main_content, worktree_content, base_content)
|
||||
|
||||
# Try to get timeline context for richer merge prompt
|
||||
timeline_context = None
|
||||
if project_dir:
|
||||
try:
|
||||
tracker = FileTimelineTracker(project_dir)
|
||||
timeline_context = tracker.get_merge_context(spec_name, file_path)
|
||||
if timeline_context:
|
||||
debug(MODULE, "Using FileTimelineTracker context",
|
||||
commits_behind=timeline_context.total_commits_behind,
|
||||
pending_tasks=timeline_context.total_pending_tasks,
|
||||
main_events=len(timeline_context.main_evolution))
|
||||
except Exception as e:
|
||||
debug_warning(MODULE, f"Could not get timeline context: {e}")
|
||||
|
||||
# Determine language
|
||||
language = resolver._infer_language(file_path)
|
||||
debug(MODULE, "Detected language", language=language)
|
||||
|
||||
# Quick Win 1: Get task intent for richer context
|
||||
task_intent = None
|
||||
if project_dir:
|
||||
task_intent = _get_task_intent(project_dir, spec_name)
|
||||
if task_intent:
|
||||
debug(MODULE, "Loaded task intent",
|
||||
title=task_intent.get('title'),
|
||||
num_subtasks=len(task_intent.get('subtasks', [])))
|
||||
# Build prompt - use timeline context if available, fallback to simple prompt
|
||||
if timeline_context and timeline_context.total_commits_behind > 0:
|
||||
# Use the rich timeline-based prompt with full situational awareness
|
||||
debug(MODULE, "Building timeline-based merge prompt",
|
||||
commits_behind=timeline_context.total_commits_behind,
|
||||
main_events=len(timeline_context.main_evolution),
|
||||
pending_tasks=timeline_context.total_pending_tasks)
|
||||
print(muted(f" Using timeline context ({timeline_context.total_commits_behind} commits behind, {timeline_context.total_pending_tasks} pending tasks)"))
|
||||
prompt = build_timeline_merge_prompt(timeline_context)
|
||||
else:
|
||||
# Fallback to simple three-way merge prompt
|
||||
debug(MODULE, "Building simple merge prompt (no timeline context)")
|
||||
|
||||
# Build intent context string
|
||||
intent_context = ""
|
||||
if task_intent:
|
||||
intent_context = f"""
|
||||
=== FEATURE BRANCH INTENT ({spec_name}) ===
|
||||
Task: {task_intent.get('title', spec_name)}
|
||||
Description: {task_intent.get('description', 'No description')}
|
||||
"""
|
||||
if task_intent.get('spec_summary'):
|
||||
intent_context += f"Summary: {task_intent['spec_summary']}\n"
|
||||
# Get task intent for basic context
|
||||
task_intent = None
|
||||
if project_dir:
|
||||
task_intent = _get_task_intent(project_dir, spec_name)
|
||||
if task_intent:
|
||||
debug(MODULE, "Loaded task intent",
|
||||
title=task_intent.get('title'),
|
||||
num_subtasks=len(task_intent.get('subtasks', [])))
|
||||
|
||||
# Add relevant subtasks
|
||||
relevant_subtasks = [
|
||||
st for st in task_intent.get('subtasks', [])
|
||||
if st.get('status') in ('completed', 'in_progress')
|
||||
]
|
||||
if relevant_subtasks:
|
||||
intent_context += "Completed work:\n"
|
||||
for st in relevant_subtasks[:5]: # Limit to 5
|
||||
intent_context += f" - {st.get('title', 'Unknown')}\n"
|
||||
|
||||
# Get context about recent merges to this file (for v2)
|
||||
recent_merges_context = ""
|
||||
if project_dir:
|
||||
recent_merges = _get_recent_merges_context(project_dir, file_path)
|
||||
if recent_merges:
|
||||
recent_merges_context = "\n=== RECENT CHANGES TO THIS FILE ===\n"
|
||||
for merge in recent_merges:
|
||||
recent_merges_context += f"- Task '{merge['task_id']}': {merge['intent']}\n"
|
||||
|
||||
# Build a focused prompt for three-way merge with intent
|
||||
prompt = f'''You are a code merge expert. Merge the following conflicting versions of a file.
|
||||
|
||||
FILE: {file_path}
|
||||
|
||||
The file was modified in both the main branch and in the "{spec_name}" feature branch.
|
||||
Your task is to produce a merged version that incorporates ALL changes from both branches.
|
||||
{intent_context}{recent_merges_context}
|
||||
=== COMMON ANCESTOR (base) ===
|
||||
{base_content if base_content else "(File did not exist in common ancestor)"}
|
||||
|
||||
=== MAIN BRANCH VERSION ===
|
||||
{main_content}
|
||||
|
||||
=== FEATURE BRANCH VERSION ({spec_name}) ===
|
||||
{worktree_content}
|
||||
|
||||
MERGE RULES:
|
||||
1. Keep ALL imports from both versions
|
||||
2. Keep ALL new functions/components from both versions
|
||||
3. If the same function was modified differently, combine the changes logically
|
||||
4. Preserve the intent of BOTH branches - main's changes are important too
|
||||
5. If there's a genuine semantic conflict (same thing done differently), prefer the feature branch version but include main's additions
|
||||
6. The merged code MUST be syntactically valid {language}
|
||||
|
||||
Output ONLY the merged code, wrapped in triple backticks:
|
||||
```{language}
|
||||
merged code here
|
||||
```
|
||||
'''
|
||||
prompt = build_simple_merge_prompt(
|
||||
file_path=file_path,
|
||||
main_content=main_content,
|
||||
worktree_content=worktree_content,
|
||||
base_content=base_content,
|
||||
spec_name=spec_name,
|
||||
language=language,
|
||||
task_intent=task_intent,
|
||||
)
|
||||
|
||||
try:
|
||||
debug(MODULE, "Calling AI for merge",
|
||||
file_path=file_path,
|
||||
has_intent_context=bool(intent_context),
|
||||
has_recent_merges=bool(recent_merges_context))
|
||||
has_timeline_context=timeline_context is not None)
|
||||
|
||||
response = resolver.ai_call_fn(
|
||||
"You are an expert code merge assistant. Output only the merged code. The code MUST be syntactically valid.",
|
||||
|
||||
Reference in New Issue
Block a user