Files
Aperant/auto-claude/merge/file_timeline.py
T
2025-12-16 12:48:36 +01:00

993 lines
34 KiB
Python

"""
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}")