disinto/lib/worktree.sh

157 lines
5.8 KiB
Bash
Raw Permalink Normal View History

#!/usr/bin/env bash
# worktree.sh — Reusable git worktree management for agents
#
# Functions:
# worktree_create PATH BRANCH [BASE_REF] — create worktree, checkout base, fetch submodules
# worktree_recover ISSUE_NUMBER PROJECT_NAME — detect existing PR/branch, reuse or recreate worktree
# worktree_cleanup PATH — remove worktree + Claude Code project cache
# worktree_cleanup_stale [MAX_AGE_HOURS] — prune orphaned /tmp worktrees older than threshold
# worktree_preserve PATH REASON — mark worktree as preserved (skip cleanup on exit)
#
# Requires: lib/env.sh sourced (for FACTORY_ROOT, PROJECT_REPO_ROOT, log()).
# Globals set by callers: FORGE_REMOTE (git remote name, default "origin").
# --- Internal: clear Claude Code project cache for a worktree path ---
_worktree_clear_claude_cache() {
local wt_path="$1"
local claude_project_dir
claude_project_dir="$HOME/.claude/projects/$(echo "$wt_path" | sed 's|/|-|g; s|^-||')"
rm -rf "$claude_project_dir" 2>/dev/null || true
}
# worktree_create PATH BRANCH [BASE_REF]
# Creates a git worktree at PATH on BRANCH, based on BASE_REF (default: FORGE_REMOTE/PRIMARY_BRANCH).
# Fetches submodules after creation. Cleans up any stale worktree at PATH first.
# Must be called from PROJECT_REPO_ROOT (or a repo directory).
# Returns 0 on success, 1 on failure.
worktree_create() {
local wt_path="$1"
local branch="$2"
local base_ref="${3:-${FORGE_REMOTE:-origin}/${PRIMARY_BRANCH:-main}}"
# Clean up any prior worktree at this path
worktree_cleanup "$wt_path"
if ! git worktree add "$wt_path" "$base_ref" -B "$branch" 2>&1; then
return 1
fi
cd "$wt_path" || return 1
git checkout -B "$branch" "$base_ref" 2>/dev/null || true
git submodule update --init --recursive 2>/dev/null || true
return 0
}
# worktree_recover WORKTREE_PATH BRANCH FORGE_REMOTE
# Detects an existing worktree at WORKTREE_PATH. If it exists and is on the
# right BRANCH, reuses it (fast-forward pull). Otherwise, cleans and recreates.
# Sets _WORKTREE_REUSED=true if the existing worktree was reused.
# Must be called from PROJECT_REPO_ROOT (or a repo directory).
# Returns 0 on success, 1 on failure.
worktree_recover() {
local wt_path="$1"
local branch="$2"
local remote="${3:-${FORGE_REMOTE:-origin}}"
_WORKTREE_REUSED=false
git fetch "$remote" "$branch" 2>/dev/null || true
# Reuse existing worktree if on the right branch
if [ -d "$wt_path/.git" ] || [ -f "$wt_path/.git" ]; then
local wt_branch
wt_branch=$(cd "$wt_path" && git rev-parse --abbrev-ref HEAD 2>/dev/null) || true
if [ "$wt_branch" = "$branch" ]; then
cd "$wt_path" || return 1
git pull --ff-only "$remote" "$branch" 2>/dev/null || git reset --hard "${remote}/${branch}" 2>/dev/null || true
_WORKTREE_REUSED=true
return 0
fi
fi
# Clean and recreate
worktree_cleanup "$wt_path"
if ! git worktree add "$wt_path" "${remote}/${branch}" -B "$branch" 2>&1; then
return 1
fi
cd "$wt_path" || return 1
git submodule update --init --recursive 2>/dev/null || true
return 0
}
# worktree_cleanup PATH
# Removes a git worktree and clears the Claude Code project cache for it.
# Safe to call multiple times or on non-existent paths.
# Requires: PROJECT_REPO_ROOT (falls back to current directory).
worktree_cleanup() {
local wt_path="$1"
local repo_root="${PROJECT_REPO_ROOT:-$(pwd)}"
cd "$repo_root" 2>/dev/null || true
git worktree remove "$wt_path" --force 2>/dev/null || true
rm -rf "$wt_path"
_worktree_clear_claude_cache "$wt_path"
}
# worktree_cleanup_stale [MAX_AGE_HOURS]
# Scans /tmp for orphaned worktrees older than MAX_AGE_HOURS (default 24).
# Skips worktrees that have active tmux panes or are marked as preserved.
# Prunes dangling worktree references after cleanup.
# Requires: PROJECT_REPO_ROOT.
worktree_cleanup_stale() {
local max_age_hours="${1:-24}"
local max_age_seconds=$((max_age_hours * 3600))
local now
now=$(date +%s)
local cleaned=0
# Collect active tmux pane working directories for safety check
local active_dirs=""
active_dirs=$(tmux list-panes -a -F '#{pane_current_path}' 2>/dev/null || true)
local wt_dir
for wt_dir in /tmp/*-worktree-* /tmp/action-*-[0-9]* /tmp/disinto-*; do
[ -d "$wt_dir" ] || continue
# Must be a git worktree (has .git file or directory)
[ -f "$wt_dir/.git" ] || [ -d "$wt_dir/.git" ] || continue
# Skip preserved worktrees
[ -f "$wt_dir/.worktree-preserved" ] && continue
# Check age (use directory mtime)
local dir_mtime
dir_mtime=$(stat -c %Y "$wt_dir" 2>/dev/null || echo "$now")
local age=$((now - dir_mtime))
[ "$age" -lt "$max_age_seconds" ] && continue
# Skip if an active tmux pane is using this worktree
if [ -n "$active_dirs" ] && echo "$active_dirs" | grep -qF "$wt_dir"; then
continue
fi
# Remove the worktree and its Claude cache
local repo_root="${PROJECT_REPO_ROOT:-$(pwd)}"
git -C "$repo_root" worktree remove "$wt_dir" --force 2>/dev/null || rm -rf "$wt_dir"
_worktree_clear_claude_cache "$wt_dir"
log "cleaned stale worktree: ${wt_dir} (age: $((age / 3600))h)"
cleaned=$((cleaned + 1))
done
# Prune any dangling worktree references
git -C "${PROJECT_REPO_ROOT:-$(pwd)}" worktree prune 2>/dev/null || true
[ "$cleaned" -gt 0 ] && log "cleaned ${cleaned} stale worktree(s)"
}
# worktree_preserve PATH REASON
# Marks a worktree as preserved for debugging. Preserved worktrees are skipped
# by worktree_cleanup_stale. The reason is written to a marker file inside
# the worktree directory.
worktree_preserve() {
local wt_path="$1"
local reason="${2:-unspecified}"
if [ -d "$wt_path" ]; then
printf '%s\n' "$reason" > "$wt_path/.worktree-preserved"
log "PRESERVED worktree for debugging: ${wt_path} (reason: ${reason})"
fi
}