From 1abe925f65d64eb1e73877542e4f15eb0bf0637d Mon Sep 17 00:00:00 2001 From: Oskar Kapala Date: Wed, 3 Jun 2026 17:41:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(dev):=20scripts/dev/agent.sh=20=E2=80=94?= =?UTF-8?q?=20multi-agent=20worktree=20dispatcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new/list/merge/clean. Decisions: branch task/, sibling worktree ~/homelab-codex-ws-, ff-only auto-merge, cap 4. --- scripts/dev/agent.sh | 359 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100755 scripts/dev/agent.sh diff --git a/scripts/dev/agent.sh b/scripts/dev/agent.sh new file mode 100755 index 0000000..823e8e3 --- /dev/null +++ b/scripts/dev/agent.sh @@ -0,0 +1,359 @@ +#!/usr/bin/env bash +# Multi-agent worktree manager. +# EXIT: 0 ok, 1 preflight, 2 operation failed. +set -euo pipefail + +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_count() { + # count registered worktrees that are NOT the main checkout + local main_path + main_path=$(git rev-parse --show-toplevel) + git worktree list --porcelain \ + | awk '/^worktree /{p=$2} /^$/{print p}' \ + | grep -cv "^${main_path}$" +} + +worktree_paths() { + # list worktree paths (excluding main) + local main_path + main_path=$(git rev-parse --show-toplevel) + git worktree list --porcelain \ + | awk '/^worktree /{p=$2} /^$/{print p}' \ + | grep -v "^${main_path}$" +} + +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" + [[ "$name" =~ ^[a-z][a-z0-9-]*$ ]] || prefail "name '$name' must match ^[a-z][a-z0-9-]*$" + for r in "${RESERVED_NAMES[@]}"; do + [ "$name" = "$r" ] && prefail "'$name' is a reserved word" + 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