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:
|
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
|
Uses git merge-tree and git diff to detect conflicts in-memory,
|
||||||
worktree branch (e.g., other changes were merged to main since
|
which avoids triggering Vite HMR or other file watchers.
|
||||||
the worktree was created).
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project_dir: Project root directory
|
project_dir: Project root directory
|
||||||
@@ -152,7 +151,7 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
|
|||||||
"""
|
"""
|
||||||
import subprocess
|
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}"
|
spec_branch = f"auto-claude/{spec_name}"
|
||||||
result = {
|
result = {
|
||||||
@@ -175,95 +174,110 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
|
|||||||
if base_result.returncode == 0:
|
if base_result.returncode == 0:
|
||||||
result["base_branch"] = base_result.stdout.strip()
|
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(
|
merge_base_result = subprocess.run(
|
||||||
["git", "merge-base", result["base_branch"], spec_branch],
|
["git", "merge-base", result["base_branch"], spec_branch],
|
||||||
cwd=project_dir,
|
cwd=project_dir,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
if merge_base_result.returncode == 0:
|
if merge_base_result.returncode != 0:
|
||||||
merge_base = merge_base_result.stdout.strip()
|
debug_warning(MODULE, "Could not find merge base")
|
||||||
|
return result
|
||||||
|
|
||||||
# Count commits main is ahead
|
merge_base = merge_base_result.stdout.strip()
|
||||||
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")
|
|
||||||
|
|
||||||
# Try a merge --no-commit --no-ff to see if there would be conflicts
|
# Count commits main is ahead
|
||||||
# First, save current state
|
ahead_result = subprocess.run(
|
||||||
stash_result = subprocess.run(
|
["git", "rev-list", "--count", f"{merge_base}..{result['base_branch']}"],
|
||||||
["git", "stash", "push", "-m", "merge-preview-check"],
|
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,
|
cwd=project_dir,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
if main_commit_result.returncode != 0 or spec_commit_result.returncode != 0:
|
||||||
# Attempt the merge (dry run style)
|
debug_warning(MODULE, "Could not resolve branch commits")
|
||||||
merge_result = subprocess.run(
|
return result
|
||||||
["git", "merge", "--no-commit", "--no-ff", spec_branch],
|
|
||||||
cwd=project_dir,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if merge_result.returncode != 0:
|
main_commit = main_commit_result.stdout.strip()
|
||||||
result["has_conflicts"] = True
|
spec_commit = spec_commit_result.stdout.strip()
|
||||||
debug(MODULE, "Git merge would have conflicts")
|
|
||||||
|
|
||||||
# Parse conflicting files from the output
|
# Use git merge-tree to check for conflicts WITHOUT touching working directory
|
||||||
# Look for "CONFLICT (content): Merge conflict in <file>"
|
# This is a plumbing command that does a 3-way merge in memory
|
||||||
import re
|
merge_tree_result = subprocess.run(
|
||||||
for line in merge_result.stdout.split("\n") + merge_result.stderr.split("\n"):
|
["git", "merge-tree", "--write-tree", "--no-messages", merge_base, main_commit, spec_commit],
|
||||||
# Match patterns like "CONFLICT (content): Merge conflict in path/to/file"
|
cwd=project_dir,
|
||||||
match = re.search(r"CONFLICT.*:\s*(?:Merge conflict in\s+)?(.+)", line)
|
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:
|
if match:
|
||||||
file_path = match.group(1).strip()
|
file_path = match.group(1).strip()
|
||||||
if file_path and file_path not in result["conflicting_files"]:
|
if file_path and file_path not in result["conflicting_files"]:
|
||||||
result["conflicting_files"].append(file_path)
|
result["conflicting_files"].append(file_path)
|
||||||
|
|
||||||
# Also check git status for unmerged files
|
# Fallback: if we didn't parse conflicts, use diff to find files changed in both branches
|
||||||
status_result = subprocess.run(
|
if not result["conflicting_files"]:
|
||||||
["git", "diff", "--name-only", "--diff-filter=U"],
|
# Files changed in main since merge-base
|
||||||
|
main_files_result = subprocess.run(
|
||||||
|
["git", "diff", "--name-only", merge_base, main_commit],
|
||||||
cwd=project_dir,
|
cwd=project_dir,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
if status_result.returncode == 0:
|
main_files = set(main_files_result.stdout.strip().split("\n")) if main_files_result.stdout.strip() else set()
|
||||||
for line in status_result.stdout.strip().split("\n"):
|
|
||||||
if line and line not in result["conflicting_files"]:
|
|
||||||
result["conflicting_files"].append(line)
|
|
||||||
|
|
||||||
debug(MODULE, f"Conflicting files: {result['conflicting_files']}")
|
# Files changed in spec branch since merge-base
|
||||||
else:
|
spec_files_result = subprocess.run(
|
||||||
debug_success(MODULE, "Git merge would succeed without conflicts")
|
["git", "diff", "--name-only", merge_base, spec_commit],
|
||||||
|
|
||||||
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"],
|
|
||||||
cwd=project_dir,
|
cwd=project_dir,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=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:
|
except Exception as e:
|
||||||
debug_error(MODULE, f"Error checking git conflicts: {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 .file_evolution import FileEvolutionTracker
|
||||||
from .ai_resolver import AIResolver, create_claude_resolver
|
from .ai_resolver import AIResolver, create_claude_resolver
|
||||||
from .orchestrator import MergeOrchestrator
|
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__ = [
|
__all__ = [
|
||||||
# Types
|
# Types
|
||||||
@@ -62,4 +77,17 @@ __all__ = [
|
|||||||
"AIResolver",
|
"AIResolver",
|
||||||
"create_claude_resolver",
|
"create_claude_resolver",
|
||||||
"MergeOrchestrator",
|
"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,
|
MergeDecision,
|
||||||
ConflictSeverity,
|
ConflictSeverity,
|
||||||
FileEvolutionTracker,
|
FileEvolutionTracker,
|
||||||
|
FileTimelineTracker,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Track if we've already tried to install the git hook this session
|
||||||
|
_git_hook_check_done = False
|
||||||
|
|
||||||
MODULE = "workspace"
|
MODULE = "workspace"
|
||||||
|
|
||||||
|
|
||||||
@@ -291,6 +295,9 @@ def setup_workspace(
|
|||||||
print()
|
print()
|
||||||
print_status("Setting up separate workspace...", "progress")
|
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 = WorktreeManager(project_dir)
|
||||||
manager.setup()
|
manager.setup()
|
||||||
|
|
||||||
@@ -308,9 +315,132 @@ def setup_workspace(
|
|||||||
print_status(f"Workspace ready: {worktree_info.path.name}", "success")
|
print_status(f"Workspace ready: {worktree_info.path.name}", "success")
|
||||||
print()
|
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
|
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:
|
def show_build_summary(manager: WorktreeManager, spec_name: str) -> None:
|
||||||
"""Show a summary of what was built."""
|
"""Show a summary of what was built."""
|
||||||
summary = manager.get_change_summary(spec_name)
|
summary = manager.get_change_summary(spec_name)
|
||||||
@@ -766,6 +896,14 @@ def _try_smart_merge_inner(
|
|||||||
try:
|
try:
|
||||||
print(muted(" Analyzing changes with intent-aware merge..."))
|
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
|
# Initialize the orchestrator
|
||||||
debug(MODULE, "Initializing MergeOrchestrator",
|
debug(MODULE, "Initializing MergeOrchestrator",
|
||||||
project_dir=str(project_dir),
|
project_dir=str(project_dir),
|
||||||
@@ -1286,9 +1424,10 @@ def _record_merge_completion(
|
|||||||
spec_name: str,
|
spec_name: str,
|
||||||
merged_files: list[str],
|
merged_files: list[str],
|
||||||
task_intent: str = "",
|
task_intent: str = "",
|
||||||
|
merge_commit: str = "",
|
||||||
) -> None:
|
) -> 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,
|
This enables future AI merges to understand the history of file changes,
|
||||||
creating a knowledge chain for intelligent conflict resolution.
|
creating a knowledge chain for intelligent conflict resolution.
|
||||||
@@ -1298,16 +1437,18 @@ def _record_merge_completion(
|
|||||||
spec_name: The task/spec that was merged
|
spec_name: The task/spec that was merged
|
||||||
merged_files: List of file paths that were merged
|
merged_files: List of file paths that were merged
|
||||||
task_intent: Description of what the task accomplished
|
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:
|
try:
|
||||||
tracker = FileEvolutionTracker(project_dir)
|
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
|
# Mark the task as completed for all its tracked files
|
||||||
tracker.mark_task_completed(spec_name)
|
tracker.mark_task_completed(spec_name)
|
||||||
|
|
||||||
@@ -1324,11 +1465,38 @@ def _record_merge_completion(
|
|||||||
# Save updates
|
# Save updates
|
||||||
tracker._save_evolutions()
|
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"))
|
print(muted(f" Recorded merge completion for {len(merged_files)} files"))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Non-fatal - this is supplementary tracking
|
# 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]:
|
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.
|
Use AI to merge a conflicting file.
|
||||||
|
|
||||||
This enhanced version includes:
|
This enhanced version includes:
|
||||||
|
- FileTimelineTracker context for full situational awareness
|
||||||
- Task intent from implementation_plan.json
|
- Task intent from implementation_plan.json
|
||||||
- Binary file detection
|
- Binary file detection
|
||||||
- File size limits
|
- File size limits
|
||||||
@@ -1561,6 +1730,7 @@ def _merge_file_with_ai(
|
|||||||
Returns merged content, or None if AI couldn't resolve.
|
Returns merged content, or None if AI couldn't resolve.
|
||||||
"""
|
"""
|
||||||
from merge import create_claude_resolver
|
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}",
|
debug(MODULE, f"AI merge starting for: {file_path}",
|
||||||
spec_name=spec_name,
|
spec_name=spec_name,
|
||||||
@@ -1599,85 +1769,60 @@ def _merge_file_with_ai(
|
|||||||
print(muted(f" AI not available, trying heuristic merge..."))
|
print(muted(f" AI not available, trying heuristic merge..."))
|
||||||
return _heuristic_merge(main_content, worktree_content, base_content)
|
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
|
# Determine language
|
||||||
language = resolver._infer_language(file_path)
|
language = resolver._infer_language(file_path)
|
||||||
debug(MODULE, "Detected language", language=language)
|
debug(MODULE, "Detected language", language=language)
|
||||||
|
|
||||||
# Quick Win 1: Get task intent for richer context
|
# Build prompt - use timeline context if available, fallback to simple prompt
|
||||||
task_intent = None
|
if timeline_context and timeline_context.total_commits_behind > 0:
|
||||||
if project_dir:
|
# Use the rich timeline-based prompt with full situational awareness
|
||||||
task_intent = _get_task_intent(project_dir, spec_name)
|
debug(MODULE, "Building timeline-based merge prompt",
|
||||||
if task_intent:
|
commits_behind=timeline_context.total_commits_behind,
|
||||||
debug(MODULE, "Loaded task intent",
|
main_events=len(timeline_context.main_evolution),
|
||||||
title=task_intent.get('title'),
|
pending_tasks=timeline_context.total_pending_tasks)
|
||||||
num_subtasks=len(task_intent.get('subtasks', [])))
|
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
|
# Get task intent for basic context
|
||||||
intent_context = ""
|
task_intent = None
|
||||||
if task_intent:
|
if project_dir:
|
||||||
intent_context = f"""
|
task_intent = _get_task_intent(project_dir, spec_name)
|
||||||
=== FEATURE BRANCH INTENT ({spec_name}) ===
|
if task_intent:
|
||||||
Task: {task_intent.get('title', spec_name)}
|
debug(MODULE, "Loaded task intent",
|
||||||
Description: {task_intent.get('description', 'No description')}
|
title=task_intent.get('title'),
|
||||||
"""
|
num_subtasks=len(task_intent.get('subtasks', [])))
|
||||||
if task_intent.get('spec_summary'):
|
|
||||||
intent_context += f"Summary: {task_intent['spec_summary']}\n"
|
|
||||||
|
|
||||||
# Add relevant subtasks
|
prompt = build_simple_merge_prompt(
|
||||||
relevant_subtasks = [
|
file_path=file_path,
|
||||||
st for st in task_intent.get('subtasks', [])
|
main_content=main_content,
|
||||||
if st.get('status') in ('completed', 'in_progress')
|
worktree_content=worktree_content,
|
||||||
]
|
base_content=base_content,
|
||||||
if relevant_subtasks:
|
spec_name=spec_name,
|
||||||
intent_context += "Completed work:\n"
|
language=language,
|
||||||
for st in relevant_subtasks[:5]: # Limit to 5
|
task_intent=task_intent,
|
||||||
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
|
|
||||||
```
|
|
||||||
'''
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
debug(MODULE, "Calling AI for merge",
|
debug(MODULE, "Calling AI for merge",
|
||||||
file_path=file_path,
|
file_path=file_path,
|
||||||
has_intent_context=bool(intent_context),
|
has_timeline_context=timeline_context is not None)
|
||||||
has_recent_merges=bool(recent_merges_context))
|
|
||||||
|
|
||||||
response = resolver.ai_call_fn(
|
response = resolver.ai_call_fn(
|
||||||
"You are an expert code merge assistant. Output only the merged code. The code MUST be syntactically valid.",
|
"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