157 lines
5.8 KiB
Bash
157 lines
5.8 KiB
Bash
|
|
#!/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
|
||
|
|
}
|