#!/usr/bin/env bash # Multi-agent worktree manager. # EXIT: 0 ok, 1 preflight, 2 operation failed. set -euo pipefail trap 'echo "agent.sh: failed at line $LINENO (exit $?)" >&2' ERR RESERVED_NAMES=(master main HEAD list merge clean new) MAX_WORKTREES=4 die() { echo "ERROR: $*" >&2; exit "${2:-2}"; } prefail(){ echo "PREFLIGHT: $*" >&2; exit 1; } # ── helpers ────────────────────────────────────────────────────────────────── is_main_checkout() { local git_dir common_dir git_dir=$(git rev-parse --git-dir 2>/dev/null) || return 1 common_dir=$(git rev-parse --git-common-dir 2>/dev/null) || return 1 [ "$git_dir" = "$common_dir" ] } require_main_checkout() { is_main_checkout || prefail "must run from the main checkout, not a worktree" } require_master_branch() { local branch branch=$(git rev-parse --abbrev-ref HEAD) [ "$branch" = "master" ] || prefail "must be on master (currently on '$branch')" } require_clean_tree() { local dirty dirty=$(git status --porcelain) [ -z "$dirty" ] || prefail "working tree is not clean — stash or commit first" } worktree_paths() { # list worktree paths (excluding main); || true prevents grep exit-1 when empty local main_path main_path=$(git rev-parse --show-toplevel) git worktree list --porcelain \ | awk '/^worktree /{p=$2} /^$/{print p}' \ | grep -v "^${main_path}$" \ || true } worktree_count() { worktree_paths | wc -l } branch_exists_local() { git show-ref --verify --quiet "refs/heads/$1"; } branch_exists_remote() { git ls-remote --exit-code origin "$1" >/dev/null 2>&1; } utc_now() { date -u +"%Y-%m-%dT%H:%M:%SZ"; } age_str() { local created_utc="$1" local now_ts created_ts diff_s now_ts=$(date -u +%s) # strip Z, replace T with space for `date -d` created_ts=$(date -u -d "${created_utc//T/ }" +%s 2>/dev/null) || { echo "?"; return; } diff_s=$(( now_ts - created_ts )) if (( diff_s < 60 )); then echo "${diff_s}s" elif (( diff_s < 3600 )); then echo "$(( diff_s/60 ))m" elif (( diff_s < 86400 )); then echo "$(( diff_s/3600 ))h" else echo "$(( diff_s/86400 ))d" fi } validate_name() { local name="$1" if ! [[ "$name" =~ ^[a-z][a-z0-9-]*$ ]]; then prefail "name '$name' must match ^[a-z][a-z0-9-]*$" fi for r in "${RESERVED_NAMES[@]}"; do if [ "$name" = "$r" ]; then prefail "'$name' is a reserved word" fi done } # ── subcommands ─────────────────────────────────────────────────────────────── cmd_new() { local name="${1:-}" [ -n "$name" ] || { usage; exit 1; } validate_name "$name" require_main_checkout require_master_branch require_clean_tree # worktree limit local count count=$(worktree_count) if (( count >= MAX_WORKTREES )); then echo "ERROR: already at maximum of $MAX_WORKTREES active worktrees:" >&2 cmd_list exit 1 fi # branch collision if branch_exists_local "task/$name"; then prefail "branch task/$name already exists locally" fi git fetch origin master --quiet if branch_exists_remote "refs/heads/task/$name"; then prefail "branch task/$name already exists on origin" fi # directory collision local main_path wt_path main_path=$(git rev-parse --show-toplevel) wt_path="$(dirname "$main_path")/homelab-codex-ws-${name}" [ ! -e "$wt_path" ] || prefail "directory $wt_path already exists" # create worktree git worktree add -b "task/$name" "$wt_path" origin/master \ || die "git worktree add failed" # write marker local parent_commit parent_commit=$(git rev-parse origin/master) cat > "$wt_path/.agent-task" </dev/null || true local paths paths=$(worktree_paths) if [ -z "$paths" ]; then echo "(no active task worktrees)" return fi printf "%-20s %-25s %-10s %-8s %-8s %-7s %s\n" \ "NAME" "BRANCH" "CREATED" "AGE" "STATUS" "A/B" "PARENT" while IFS= read -r wt_path; do [ -z "$wt_path" ] && continue local marker="$wt_path/.agent-task" local task_name branch parent_commit created_utc if [ -f "$marker" ]; then task_name=$( grep '^task:' "$marker" | awk '{print $2}') branch=$( grep '^branch:' "$marker" | awk '{print $2}') parent_commit=$(grep '^parent_commit:' "$marker" | awk '{print $2}') created_utc=$(grep '^created_utc:' "$marker" | awk '{print $2}') else task_name="(no marker)" branch=$(git -C "$wt_path" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "?") parent_commit="?" created_utc="" fi local status="clean" local dirty dirty=$(git -C "$wt_path" status --porcelain 2>/dev/null || echo "?") [ -n "$dirty" ] && status="dirty" local ahead behind ab ahead=$(git -C "$wt_path" rev-list --count "origin/master..${branch}" 2>/dev/null || echo "?") behind=$(git -C "$wt_path" rev-list --count "${branch}..origin/master" 2>/dev/null || echo "?") ab="+${ahead}/-${behind}" local age="" [ -n "$created_utc" ] && age=$(age_str "$created_utc") local short_parent="${parent_commit:0:7}" local short_created="${created_utc:0:10}" printf "%-20s %-25s %-10s %-8s %-8s %-7s %s\n" \ "$task_name" "$branch" "$short_created" "$age" "$status" "$ab" "$short_parent" done <<< "$paths" } cmd_merge() { local name="${1:-}" [ -n "$name" ] || { usage; exit 1; } require_main_checkout require_master_branch require_clean_tree git fetch origin --quiet branch_exists_local "task/$name" || die "branch task/$name not found locally" 1 local main_path wt_path main_path=$(git rev-parse --show-toplevel) wt_path="$(dirname "$main_path")/homelab-codex-ws-${name}" # attempt ff-only merge local merge_failed=0 git merge --ff-only "task/$name" || merge_failed=1 if (( merge_failed )); then # abort any partial merge state git merge --abort 2>/dev/null || true echo "" echo "ERROR: task/$name cannot be fast-forwarded into master." >&2 echo " The branch has likely diverged from master." >&2 echo "" >&2 echo "Diagnose with:" >&2 echo " git log master..task/$name # commits only on task branch" >&2 echo " git log task/$name..master # commits master has that task doesn't" >&2 echo "" >&2 echo "Then decide: rebase task/$name onto master, or merge manually." >&2 echo "Worktree and branch are preserved — no changes made." >&2 exit 2 fi echo "Merged task/$name into master (fast-forward)." git push origin master || die "git push origin master failed" echo "Pushed master to origin." if [ -d "$wt_path" ]; then git worktree remove "$wt_path" || die "git worktree remove $wt_path failed" echo "Removed worktree: $wt_path" else echo "(worktree directory $wt_path not found — skipping worktree remove)" fi git branch -d "task/$name" || die "git branch -d task/$name failed" echo "Deleted local branch task/$name." git push origin --delete "task/$name" 2>/dev/null \ && echo "Deleted remote branch task/$name." \ || echo "(remote branch task/$name not found — nothing to delete)" echo "" echo "Done. task/$name merged and cleaned up." } cmd_clean() { local main_path main_path=$(git rev-parse --show-toplevel) git fetch origin --quiet 2>/dev/null || true local to_remove=() # orphaned registered worktrees: branch deleted or fully merged into master local paths paths=$(worktree_paths) while IFS= read -r wt_path; do [ -z "$wt_path" ] && continue local branch branch=$(git -C "$wt_path" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") [ -z "$branch" ] && { to_remove+=("worktree:$wt_path (unreadable branch)"); continue; } # branch gone locally? if ! branch_exists_local "$branch"; then to_remove+=("worktree:$wt_path (branch $branch no longer exists)") continue fi # branch fully merged into master? local ahead ahead=$(git rev-list --count "origin/master..${branch}" 2>/dev/null || echo "1") if [ "$ahead" = "0" ]; then to_remove+=("worktree:$wt_path (branch $branch fully merged into origin/master)") fi done <<< "$paths" # dangling directories: ../homelab-codex-ws-* not registered local registered_paths registered_paths=$(git worktree list --porcelain | awk '/^worktree /{print $2}') local parent_dir parent_dir=$(dirname "$main_path") while IFS= read -r candidate; do [ -d "$candidate" ] || continue if ! echo "$registered_paths" | grep -qF "$candidate"; then to_remove+=("dangling:$candidate") fi done < <(find "$parent_dir" -maxdepth 1 -name "homelab-codex-ws-*" -type d 2>/dev/null) if [ ${#to_remove[@]} -eq 0 ]; then echo "Nothing to clean." return 0 fi echo "Found ${#to_remove[@]} item(s) to clean:" for entry in "${to_remove[@]}"; do echo " $entry" done echo "" local overall_rc=0 for entry in "${to_remove[@]}"; do local kind="${entry%%:*}" local path="${entry#*:}" # strip trailing annotation in parens local raw_path raw_path="${path%% (*}" local confirm read -r -p "Remove $kind '$raw_path'? [y/N] " confirm if [[ "$confirm" =~ ^[Yy]$ ]]; then if [ "$kind" = "worktree" ]; then git worktree remove --force "$raw_path" 2>/dev/null \ || { echo " WARNING: git worktree remove failed, trying rm -rf"; rm -rf "$raw_path" || true; } else rm -rf "$raw_path" fi echo " Removed." else echo " Skipped." fi done return $overall_rc } usage() { cat <<'EOF' Usage: agent.sh [args] agent.sh new Create a new task worktree (branch task/) agent.sh list List active task worktrees with status agent.sh merge Fast-forward merge task/ into master and clean up agent.sh clean Remove orphaned or dangling worktrees (interactive) EXIT: 0 ok, 1 preflight, 2 operation failed. EOF } # ── dispatch ────────────────────────────────────────────────────────────────── SUBCOMMAND="${1:-}" shift || true case "$SUBCOMMAND" in new) cmd_new "$@" ;; list) cmd_list "$@" ;; merge) cmd_merge "$@" ;; clean) cmd_clean "$@" ;; *) usage; exit 1 ;; esac