From c5d33cd8b17099ffc3a327b407c274965cebe582 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Tue, 16 Dec 2025 12:48:36 +0100 Subject: [PATCH] merge logic v0.3 --- auto-claude/cli/workspace_commands.py | 144 ++-- auto-claude/merge/__init__.py | 28 + auto-claude/merge/file_timeline.py | 992 ++++++++++++++++++++++++++ auto-claude/merge/hooks/post-commit | 43 ++ auto-claude/merge/install_hook.py | 185 +++++ auto-claude/merge/prompts.py | 291 ++++++++ auto-claude/merge/tracker_cli.py | 233 ++++++ auto-claude/workspace.py | 299 ++++++-- 8 files changed, 2073 insertions(+), 142 deletions(-) create mode 100644 auto-claude/merge/file_timeline.py create mode 100755 auto-claude/merge/hooks/post-commit create mode 100644 auto-claude/merge/install_hook.py create mode 100644 auto-claude/merge/prompts.py create mode 100644 auto-claude/merge/tracker_cli.py diff --git a/auto-claude/cli/workspace_commands.py b/auto-claude/cli/workspace_commands.py index f2bd4fd0..b8764490 100644 --- a/auto-claude/cli/workspace_commands.py +++ b/auto-claude/cli/workspace_commands.py @@ -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 " - 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}") diff --git a/auto-claude/merge/__init__.py b/auto-claude/merge/__init__.py index d0eb6727..e19f997f 100644 --- a/auto-claude/merge/__init__.py +++ b/auto-claude/merge/__init__.py @@ -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", ] diff --git a/auto-claude/merge/file_timeline.py b/auto-claude/merge/file_timeline.py new file mode 100644 index 00000000..01bcebfa --- /dev/null +++ b/auto-claude/merge/file_timeline.py @@ -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}") diff --git a/auto-claude/merge/hooks/post-commit b/auto-claude/merge/hooks/post-commit new file mode 100755 index 00000000..10278b91 --- /dev/null +++ b/auto-claude/merge/hooks/post-commit @@ -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 diff --git a/auto-claude/merge/install_hook.py b/auto-claude/merge/install_hook.py new file mode 100644 index 00000000..e16e9b8a --- /dev/null +++ b/auto-claude/merge/install_hook.py @@ -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() diff --git a/auto-claude/merge/prompts.py b/auto-claude/merge/prompts.py new file mode 100644 index 00000000..5dd498f5 --- /dev/null +++ b/auto-claude/merge/prompts.py @@ -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 diff --git a/auto-claude/merge/tracker_cli.py b/auto-claude/merge/tracker_cli.py new file mode 100644 index 00000000..76332df8 --- /dev/null +++ b/auto-claude/merge/tracker_cli.py @@ -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 + python -m auto_claude.merge.tracker_cli show-timeline + python -m auto_claude.merge.tracker_cli show-drift +""" + +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() diff --git a/auto-claude/workspace.py b/auto-claude/workspace.py index 656f3ff7..ec1e9136 100644 --- a/auto-claude/workspace.py +++ b/auto-claude/workspace.py @@ -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.",