2026-03-16 22:35:31 +02:00
|
|
|
"""
|
|
|
|
|
Kin — Git worktree management for isolated agent execution.
|
|
|
|
|
|
|
|
|
|
Each eligible agent step gets its own worktree in {project_path}/.kin_worktrees/
|
|
|
|
|
to prevent file-write conflicts between parallel or sequential agents.
|
|
|
|
|
|
|
|
|
|
All functions are defensive: never raise, always log warnings on error.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
import shutil
|
|
|
|
|
import subprocess
|
2026-03-21 11:39:10 +02:00
|
|
|
import time
|
2026-03-16 22:35:31 +02:00
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
_logger = logging.getLogger("kin.worktree")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _git(project_path: str) -> str:
|
|
|
|
|
"""Resolve git executable, preferring extended PATH."""
|
|
|
|
|
try:
|
|
|
|
|
from agents.runner import _build_claude_env
|
|
|
|
|
env = _build_claude_env()
|
|
|
|
|
found = shutil.which("git", path=env["PATH"])
|
|
|
|
|
return found or "git"
|
|
|
|
|
except Exception:
|
|
|
|
|
return shutil.which("git") or "git"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def create_worktree(project_path: str, task_id: str, step_name: str = "step") -> str | None:
|
|
|
|
|
"""Create a git worktree for isolated agent execution.
|
|
|
|
|
|
|
|
|
|
Creates: {project_path}/.kin_worktrees/{task_id}-{step_name}
|
|
|
|
|
Branch name equals the worktree directory name.
|
|
|
|
|
|
|
|
|
|
Returns the absolute worktree path, or None on any failure.
|
|
|
|
|
"""
|
|
|
|
|
git = _git(project_path)
|
|
|
|
|
safe_step = step_name.replace("/", "_").replace(" ", "_")
|
|
|
|
|
branch_name = f"{task_id}-{safe_step}"
|
|
|
|
|
worktrees_dir = Path(project_path) / ".kin_worktrees"
|
|
|
|
|
worktree_path = worktrees_dir / branch_name
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
worktrees_dir.mkdir(exist_ok=True)
|
|
|
|
|
r = subprocess.run(
|
|
|
|
|
[git, "worktree", "add", "-b", branch_name, str(worktree_path), "HEAD"],
|
|
|
|
|
cwd=project_path,
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
timeout=30,
|
|
|
|
|
)
|
|
|
|
|
if r.returncode != 0:
|
|
|
|
|
_logger.warning("git worktree add failed for %s: %s", branch_name, r.stderr.strip())
|
|
|
|
|
return None
|
|
|
|
|
_logger.info("Created worktree: %s", worktree_path)
|
|
|
|
|
return str(worktree_path)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
_logger.warning("create_worktree error for %s: %s", branch_name, exc)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-03-21 11:39:10 +02:00
|
|
|
def merge_worktree(worktree_path: str, project_path: str, max_retries: int = 0, retry_delay_s: int = 15) -> dict:
|
2026-03-16 22:35:31 +02:00
|
|
|
"""Merge the worktree branch back into current HEAD of project_path.
|
|
|
|
|
|
|
|
|
|
Branch name is derived from the worktree directory name.
|
|
|
|
|
On conflict: aborts merge and returns success=False with conflict list.
|
|
|
|
|
|
2026-03-21 11:39:10 +02:00
|
|
|
max_retries: number of retry attempts after the first failure (default 0 = no retry).
|
|
|
|
|
retry_delay_s: seconds to wait between retry attempts.
|
|
|
|
|
|
2026-03-16 22:35:31 +02:00
|
|
|
Returns {success: bool, conflicts: list[str], merged_files: list[str]}
|
|
|
|
|
"""
|
|
|
|
|
git = _git(project_path)
|
|
|
|
|
branch_name = Path(worktree_path).name
|
|
|
|
|
|
|
|
|
|
try:
|
2026-03-17 23:31:24 +02:00
|
|
|
# Stage all changes in the worktree before merge
|
|
|
|
|
subprocess.run(
|
|
|
|
|
[git, "-C", worktree_path, "add", "-A"],
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
timeout=30,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Commit staged changes; ignore failure (nothing to commit = returncode 1)
|
|
|
|
|
commit_result = subprocess.run(
|
|
|
|
|
[git, "-C", worktree_path, "commit", "-m", f"kin: {branch_name}"],
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
timeout=30,
|
|
|
|
|
)
|
|
|
|
|
commit_had_changes = commit_result.returncode == 0
|
|
|
|
|
|
2026-03-21 11:39:10 +02:00
|
|
|
for attempt in range(max_retries + 1):
|
|
|
|
|
merge_result = subprocess.run(
|
|
|
|
|
[git, "merge", "--no-ff", branch_name],
|
|
|
|
|
cwd=project_path,
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
timeout=60,
|
|
|
|
|
)
|
|
|
|
|
if merge_result.returncode == 0:
|
|
|
|
|
diff_result = subprocess.run(
|
|
|
|
|
[git, "diff", "HEAD~1", "HEAD", "--name-only"],
|
|
|
|
|
cwd=project_path,
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
timeout=10,
|
|
|
|
|
)
|
|
|
|
|
merged_files = [
|
|
|
|
|
f.strip() for f in diff_result.stdout.splitlines() if f.strip()
|
|
|
|
|
]
|
|
|
|
|
_logger.info("Merged worktree %s: %d files", branch_name, len(merged_files))
|
|
|
|
|
return {"success": True, "conflicts": [], "merged_files": merged_files}
|
|
|
|
|
|
|
|
|
|
# Merge failed — if commit was also empty (nothing to commit), treat as success
|
|
|
|
|
if not commit_had_changes:
|
|
|
|
|
_logger.info("Worktree %s: nothing to commit, skipping merge", branch_name)
|
|
|
|
|
return {"success": True, "conflicts": [], "merged_files": []}
|
|
|
|
|
|
|
|
|
|
# Merge failed — collect conflicts and abort
|
|
|
|
|
conflict_result = subprocess.run(
|
|
|
|
|
[git, "diff", "--name-only", "--diff-filter=U"],
|
2026-03-17 23:31:24 +02:00
|
|
|
cwd=project_path,
|
2026-03-16 22:35:31 +02:00
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
timeout=10,
|
|
|
|
|
)
|
2026-03-21 11:39:10 +02:00
|
|
|
conflicts = [f.strip() for f in conflict_result.stdout.splitlines() if f.strip()]
|
2026-03-16 22:35:31 +02:00
|
|
|
|
2026-03-21 11:39:10 +02:00
|
|
|
subprocess.run(
|
|
|
|
|
[git, "merge", "--abort"],
|
|
|
|
|
cwd=project_path,
|
|
|
|
|
capture_output=True,
|
|
|
|
|
timeout=10,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if attempt < max_retries:
|
|
|
|
|
_logger.warning(
|
|
|
|
|
"KIN-139: merge conflict in %s (attempt %d/%d), retrying in %ds",
|
|
|
|
|
branch_name, attempt + 1, max_retries + 1, retry_delay_s,
|
|
|
|
|
)
|
|
|
|
|
time.sleep(retry_delay_s)
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
_logger.warning("Merge conflict in worktree %s: %s", branch_name, conflicts)
|
|
|
|
|
return {"success": False, "conflicts": conflicts, "merged_files": []}
|
2026-03-16 22:35:31 +02:00
|
|
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
_logger.warning("merge_worktree error for %s: %s", branch_name, exc)
|
|
|
|
|
return {"success": False, "conflicts": [], "merged_files": [], "error": str(exc)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cleanup_worktree(worktree_path: str, project_path: str) -> None:
|
|
|
|
|
"""Remove the git worktree and its branch. Never raises."""
|
|
|
|
|
git = _git(project_path)
|
|
|
|
|
branch_name = Path(worktree_path).name
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
subprocess.run(
|
|
|
|
|
[git, "-C", project_path, "worktree", "remove", "--force", worktree_path],
|
|
|
|
|
capture_output=True,
|
|
|
|
|
timeout=30,
|
|
|
|
|
)
|
|
|
|
|
subprocess.run(
|
|
|
|
|
[git, "-C", project_path, "branch", "-D", branch_name],
|
|
|
|
|
capture_output=True,
|
|
|
|
|
timeout=10,
|
|
|
|
|
)
|
|
|
|
|
_logger.info("Cleaned up worktree: %s", worktree_path)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
_logger.warning("cleanup_worktree error for %s: %s", branch_name, exc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ensure_gitignore(project_path: str) -> None:
|
|
|
|
|
"""Ensure .kin_worktrees/ is in project's .gitignore. Never raises."""
|
|
|
|
|
entry = ".kin_worktrees/"
|
|
|
|
|
gitignore = Path(project_path) / ".gitignore"
|
|
|
|
|
try:
|
|
|
|
|
if gitignore.exists():
|
|
|
|
|
content = gitignore.read_text()
|
|
|
|
|
if entry not in content:
|
|
|
|
|
with gitignore.open("a") as f:
|
|
|
|
|
f.write(f"\n{entry}\n")
|
|
|
|
|
else:
|
|
|
|
|
gitignore.write_text(f"{entry}\n")
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
_logger.warning("ensure_gitignore error: %s", exc)
|