2026-06-03 17:41:35 +02:00
#!/usr/bin/env bash
# Multi-agent worktree manager.
# EXIT: 0 ok, 1 preflight, 2 operation failed.
set -euo pipefail
2026-06-03 18:02:50 +02:00
trap 'echo "agent.sh: failed at line $LINENO (exit $?)" >&2' ERR
2026-06-03 17:41:35 +02:00
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 "
2026-06-03 18:02:50 +02:00
if ! [ [ " $name " = ~ ^[ a-z] [ a-z0-9-] *$ ] ] ; then
prefail " name ' $name ' must match ^[a-z][a-z0-9-]* $"
fi
2026-06-03 17:41:35 +02:00
for r in " ${ RESERVED_NAMES [@] } " ; do
2026-06-03 18:02:50 +02:00
if [ " $name " = " $r " ] ; then
prefail " ' $name ' is a reserved word "
fi
2026-06-03 17:41:35 +02:00
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 " <<EOF
task: $name
branch: task/$name
parent_commit: $parent_commit
created_utc: $( utc_now)
worktree_path: $wt_path
EOF
echo ""
echo " Worktree created: $wt_path "
echo " Branch: task/ $name "
echo ""
echo "── Start Claude Code in this worktree ──────────────────────────────────────"
echo " cd ~/homelab-codex-ws- ${ name } && claude --dangerously-skip-permissions \"Jesteś w worktree task ' ${ name } ' (branch task/ ${ name } ). NAJPIERW przeczytaj .agent-task i .claude/skills/worktree-aware/SKILL.md, dopiero potem zacznij pracę. Commituj wyłącznie na swoją gałąź; nie pushuj origin master.\" "
echo "─────────────────────────────────────────────────────────────────────────────"
}
cmd_list( ) {
local main_path
main_path = $( git rev-parse --show-toplevel)
# fetch to get up-to-date ahead/behind
git fetch origin master --quiet 2>/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 <subcommand> [ args]
agent.sh new <name> Create a new task worktree ( branch task/<name>)
agent.sh list List active task worktrees with status
agent.sh merge <name> Fast-forward merge task/<name> 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