| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658 |
- #!/usr/bin/env bash
- # fleet-ops — landing queue manager for concurrent Claude sessions
- # Status: experimental
- set -euo pipefail
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
- REPO_ROOT=""
- # Resolve repo root via git, so fleet works from any worktree.
- # cd to it once so all relative paths below resolve correctly.
- if GIT_COMMON_DIR=$(git rev-parse --git-common-dir 2>/dev/null); then
- REPO_ROOT="$(cd "$GIT_COMMON_DIR/.." && pwd)"
- cd "$REPO_ROOT"
- fi
- FLEET_DIR=".claude/fleet"
- LANES_DIR="$FLEET_DIR/lanes"
- LOG="$FLEET_DIR/activity.log"
- CONFIG="$FLEET_DIR/config"
- PID_FILE="$FLEET_DIR/daemon.pid"
- # Shared terminal-output helpers (see docs/TERMINAL-DESIGN.md).
- # shellcheck source=../../_lib/term.sh
- . "$SCRIPT_DIR/../../_lib/term.sh"
- # Honor legacy FLEET_ASCII alongside TERM_ASCII.
- [[ "${FLEET_ASCII:-}" == "1" || "${icons:-}" == "ascii" ]] && export TERM_ASCII=1
- term_init
- # defaults (overridable via .claude/fleet/config: key=value, no quotes)
- MODE="auto"
- # Default worktree root sits at repo top, NOT under .claude/. Claude Code's
- # headless mode (--dangerously-skip-permissions) bypasses prompts but still
- # enforces the global .claude/ sensitive-file guard, so worktrees nested
- # under .claude/ can't be written to by lane sessions. See SKILL.md
- # "Headless agent compatibility".
- WORKTREE_ROOT=".fleet-worktrees"
- TEST_CMD=""
- FORBIDDEN_PATTERN="TODO_SCRUB|XXX[^a-z]|FIXME_BEFORE_LAND"
- BASE_BRANCH="main"
- POLL_INTERVAL=5
- [[ -f "$CONFIG" ]] && source "$CONFIG" 2>/dev/null || true
- # Icons resolved through the shared term lib (term_state_icon).
- ICON_RUNNING="$(term_state_icon RUNNING)"
- ICON_READY="$(term_state_icon READY)"
- ICON_LANDED="$(term_state_icon LANDED)"
- ICON_FAILED="$(term_state_icon FAILED)"
- ICON_CONFLICT="$(term_state_icon CONFLICT)"
- ICON_UNKNOWN="?"
- # Cross-platform mtime: GNU stat (Linux/Git Bash) vs BSD stat (macOS)
- file_mtime() {
- stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || date +%s
- }
- log() { echo "[$(date '+%H:%M:%S')] $*" | tee -a "$LOG" >&2; }
- maybe_commit_gitignore() {
- # Auto-commit the .gitignore append from ensure_fleet_dir, but only when
- # safe: must be on BASE_BRANCH and .gitignore must be the only change in
- # the tree. Otherwise warn loudly — the daemon's land step will refuse
- # otherwise with "main has uncommitted tracked changes".
- local current
- current=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
- if [[ "$current" != "$BASE_BRANCH" ]]; then
- log "ACTION REQUIRED: .gitignore updated for fleet-ops runtime paths."
- log " You're on '$current', not '$BASE_BRANCH'. Switch to"
- log " '$BASE_BRANCH' and commit .gitignore before 'fleet start',"
- log " or the daemon will refuse to land with"
- log " 'uncommitted tracked changes — clean before landing'."
- return 0
- fi
- local other_changes
- other_changes=$(git status --porcelain 2>/dev/null | grep -vE '^.. \.gitignore$' || true)
- if [[ -n "$other_changes" ]]; then
- log "ACTION REQUIRED: .gitignore updated for fleet-ops runtime paths,"
- log " but other uncommitted changes exist on $BASE_BRANCH."
- log " Commit .gitignore yourself before 'fleet start' or"
- log " the daemon will refuse to land. Suggested:"
- log " git add .gitignore && git commit -m 'chore: gitignore fleet-ops runtime state'"
- return 0
- fi
- git add .gitignore 2>/dev/null || { log "WARN: git add .gitignore failed"; return 0; }
- if git commit -m "chore: gitignore fleet-ops runtime state" -- .gitignore >/dev/null 2>&1; then
- log "auto-committed .gitignore (fleet-ops runtime paths: .claude/fleet/, .fleet-worktrees/)"
- else
- log "WARN: auto-commit of .gitignore failed — commit it manually before 'fleet start'"
- fi
- }
- ensure_fleet_dir() {
- mkdir -p "$LANES_DIR"
- [[ -f "$FLEET_DIR/signal.sh" ]] || cp "$SCRIPT_DIR/signal.sh" "$FLEET_DIR/signal.sh"
- chmod +x "$FLEET_DIR/signal.sh" 2>/dev/null || true
- # Auto-ignore fleet-ops runtime state in git so it doesn't show as "dirty"
- # or get committed. Two paths:
- # .claude/fleet/ — lanes/, daemon.pid, activity.log, signal.sh, config
- # .fleet-worktrees/ — default worktree root (top-level so headless
- # Claude lane sessions can write there)
- if git rev-parse --git-dir >/dev/null 2>&1; then
- [[ -f .gitignore ]] || touch .gitignore
- local appended=0
- if ! grep -qxF '.claude/fleet/' .gitignore 2>/dev/null; then
- echo '.claude/fleet/' >> .gitignore
- appended=1
- fi
- if ! grep -qxF '.fleet-worktrees/' .gitignore 2>/dev/null; then
- echo '.fleet-worktrees/' >> .gitignore
- appended=1
- fi
- [[ $appended -eq 1 ]] && maybe_commit_gitignore
- fi
- }
- is_dirty_tracked() {
- # True only if tracked files have uncommitted changes (ignores untracked files)
- ! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null
- }
- lane_state() { [[ -f "$LANES_DIR/$1" ]] && head -n1 "$LANES_DIR/$1" || echo "MISSING"; }
- set_lane_state() {
- local l=$1 s=$2
- shift 2
- if [[ $# -gt 0 ]]; then
- printf '%s\n%s\n' "$s" "$*" > "$LANES_DIR/$l"
- else
- printf '%s\n' "$s" > "$LANES_DIR/$l"
- fi
- }
- scrub_diff() {
- # echoes hits (one per line) for given branch's diff vs base. Empty = clean.
- local branch=$1
- git diff "$BASE_BRANCH"..."$branch" 2>/dev/null | grep -nE "$FORBIDDEN_PATTERN" || true
- }
- refuse_if_shared_tree() {
- local trees lane_count
- trees=$(git worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2}' | sort -u | wc -l)
- lane_count=$(ls -1 "$LANES_DIR" 2>/dev/null | wc -l)
- if [[ "$lane_count" -gt 1 && "$trees" -le 1 && "$MODE" != "branch" ]]; then
- log "ERROR: $lane_count lanes but only $trees worktree — sessions will collide"
- log " Use worktrees, separate clones, or set mode=branch in $CONFIG to override"
- return 1
- fi
- }
- cmd_init() {
- ensure_fleet_dir
- [[ $# -eq 0 ]] && { echo "usage: fleet init <name>..." >&2; exit 1; }
- local mode="$MODE"
- [[ "$mode" == "auto" ]] && mode="worktree" # default: worktree if git allows it
- for name in "$@"; do
- if git rev-parse --verify "$name" >/dev/null 2>&1; then
- log "skip branch (exists): $name"
- else
- git branch "$name" "$BASE_BRANCH"
- log "created branch: $name"
- fi
- if [[ "$mode" == "worktree" ]]; then
- local wt="$WORKTREE_ROOT/$name"
- if [[ -d "$wt" ]]; then
- log "skip worktree (exists): $wt"
- else
- mkdir -p "$WORKTREE_ROOT"
- git worktree add "$wt" "$name"
- log "created worktree: $wt"
- fi
- fi
- set_lane_state "$name" "RUNNING"
- done
- echo ""
- echo "Fleet initialized. Hand each session the prompt template:"
- echo " $SCRIPT_DIR/../references/session-prompt.md"
- echo "Then: bash $0 start"
- }
- format_age() {
- local secs=$1
- if [[ $secs -lt 60 ]]; then printf '%ds' "$secs"
- elif [[ $secs -lt 3600 ]]; then printf '%dm' "$((secs/60))"
- else printf '%dh%dm' "$((secs/3600))" "$(( (secs%3600)/60 ))"
- fi
- }
- icon_for_state() {
- case "$1" in
- RUNNING) echo "$ICON_RUNNING" ;;
- READY) echo "$ICON_READY" ;;
- LANDED) echo "$ICON_LANDED" ;;
- FAILED) echo "$ICON_FAILED" ;;
- CONFLICT) echo "$ICON_CONFLICT" ;;
- *) echo "$ICON_UNKNOWN" ;;
- esac
- }
- # Bucket lanes by state into parallel arrays. Sets:
- # total, active — globals
- # state_buckets[0..4] — newline-joined "branch|age|meta"
- # state_counts[0..4] — count per state
- # Order: 0=RUNNING 1=READY 2=CONFLICT 3=FAILED 4=LANDED
- __fleet_bucket() {
- total=0; active=0
- state_buckets=("" "" "" "" "")
- state_counts=(0 0 0 0 0)
- local now=$(date +%s)
- for f in "$LANES_DIR"/*; do
- [[ -f "$f" ]] || continue
- total=$((total+1))
- local branch state meta mtime secs age idx
- branch=$(basename "$f")
- state=$(head -n1 "$f")
- meta=$(sed -n '2p' "$f")
- mtime=$(file_mtime "$f")
- secs=$((now - mtime))
- age=$(format_age "$secs")
- [[ "$state" != "LANDED" && "$state" != "FAILED" ]] && active=$((active+1))
- idx=-1
- case "$state" in
- RUNNING) idx=0 ;;
- READY) idx=1 ;;
- CONFLICT) idx=2 ;;
- FAILED) idx=3 ;;
- LANDED) idx=4 ;;
- esac
- [[ $idx -lt 0 ]] && continue
- state_counts[$idx]=$(( state_counts[idx] + 1 ))
- state_buckets[$idx]="${state_buckets[$idx]}${branch}|${age}|${meta}"$'\n'
- done
- }
- # Daemon health → "healthy" or "busted"
- __fleet_daemon_state() {
- if [[ -f "$PID_FILE" ]]; then
- local pid
- pid=$(cat "$PID_FILE" 2>/dev/null || echo "")
- if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
- printf 'healthy'
- return
- fi
- fi
- printf 'busted'
- }
- # Footer composition shared by all panel views.
- __fleet_footer() {
- local active=$1 daemon_state=$2
- local hotkeys
- hotkeys="$(term_hotkey R refresh) · $(term_hotkey L land) · $(term_hotkey '?' help)"
- local healths
- healths="$(term_health "$daemon_state" "daemon")"
- [[ "$active" -gt 0 ]] && healths="$healths $(term_health pending "$active active")"
- term_panel_close "$hotkeys" "$healths"
- }
- # Default panel view — design-system grouped tree
- fleet_view_panel() {
- ensure_fleet_dir
- local order=(RUNNING READY CONFLICT FAILED LANDED)
- local total active
- local state_buckets state_counts
- __fleet_bucket
- local daemon_state
- daemon_state=$(__fleet_daemon_state)
- echo ""
- term_panel_open fleet fleet "$TERM_GLYPH_BRANCH $BASE_BRANCH"
- if [[ $total -eq 0 ]]; then
- term_panel_vert
- term_panel_vert
- printf '%s %s\n' "$(term_color dim "$TERM_TREE_VERT")" "no lanes yet"
- term_panel_vert
- term_panel_vert
- printf '%s %s %s\n' "$(term_color dim "$TERM_TREE_VERT")" "$TERM_GLYPH_TIP" "to get started:"
- term_panel_vert
- printf '%s 1. fleet init <name>...\n' "$(term_color dim "$TERM_TREE_VERT")"
- printf '%s 2. (work in each lane)\n' "$(term_color dim "$TERM_TREE_VERT")"
- printf '%s 3. fleet start\n' "$(term_color dim "$TERM_TREE_VERT")"
- term_panel_vert
- term_panel_vert
- term_panel_close "$(term_hotkey '?' help)" "$(term_health unknown "v2.4.9")"
- echo ""
- return
- fi
- term_panel_vert
- term_summary_line "$total $([ "$total" -eq 1 ] && echo lane || echo lanes) · $active active"
- term_panel_vert
- local i
- for i in 0 1 2 3 4; do
- local n=${state_counts[$i]}
- [[ $n -eq 0 ]] && continue
- local state=${order[$i]}
- term_section "$state" "$state" "$n"
- local lines="${state_buckets[$i]}"
- local c_idx=0 c_last=$((n - 1))
- local branch age meta
- while IFS='|' read -r branch age meta; do
- [[ -z "$branch" ]] && continue
- local c_conn
- if [[ $c_idx -eq $c_last ]]; then c_conn="$TERM_TREE_LAST"; else c_conn="$TERM_TREE_BRANCH"; fi
- # Build the rail glyph from this lane's commits-ahead and state.
- local ahead head_kind rail
- ahead=$(git rev-list --count "${BASE_BRANCH}..${branch}" 2>/dev/null || echo 0)
- head_kind="HEAD"
- [[ "$state" == "CONFLICT" || "$state" == "FAILED" ]] && head_kind="CONFLICT"
- rail=$(term_rail "$ahead" "$head_kind")
- term_leaf_line "$c_conn" "$branch" "$rail" "${meta:-}" "$age"
- c_idx=$((c_idx+1))
- done <<< "$lines"
- term_panel_vert
- done
- __fleet_footer "$active" "$daemon_state"
- echo ""
- }
- # Verbose view — per-lane detail blocks rendered in panel grammar.
- # Each lane gets a header row + sub-rows for worktree, commits, and note.
- fleet_view_verbose() {
- ensure_fleet_dir
- local total active
- local state_buckets state_counts
- __fleet_bucket
- local daemon_state
- daemon_state=$(__fleet_daemon_state)
- local now=$(date +%s)
- echo ""
- term_panel_open fleet "fleet · verbose" "$TERM_GLYPH_BRANCH $BASE_BRANCH"
- if [[ $total -eq 0 ]]; then
- term_panel_vert
- printf '%s no lanes yet\n' "$(term_color dim "$TERM_TREE_VERT")"
- term_panel_vert
- term_panel_close "$(term_hotkey '?' help)" "$(term_health unknown "v2.4.9")"
- echo ""
- return
- fi
- term_panel_vert
- term_summary_line "$total $([ "$total" -eq 1 ] && echo lane || echo lanes) · $active active"
- term_panel_vert
- for f in "$LANES_DIR"/*; do
- [[ -f "$f" ]] || continue
- local branch state meta mtime age secs wt commits color label_state
- branch=$(basename "$f")
- state=$(head -n1 "$f")
- meta=$(sed -n '2p' "$f")
- mtime=$(file_mtime "$f")
- secs=$((now - mtime))
- age=$(format_age "$secs")
- wt=$(worktree_path_for "$branch" 2>/dev/null || echo "")
- commits=$(git rev-list --count "$BASE_BRANCH..$branch" 2>/dev/null || echo "?")
- color=""
- case "$state" in
- RUNNING|PENDING|CONFLICT|WARN) color="yellow" ;;
- READY|LANDED|DONE|OK) color="green" ;;
- FAILED|ERROR) color="red" ;;
- esac
- label_state="$state"
- [[ -n "$color" ]] && label_state=$(term_color "$color" "$state")
- # Lane header row
- printf '%s%s %-30s %-10s %s\n' \
- "$(term_color dim "$TERM_TREE_VERT")" \
- "$(term_color dim "$TERM_TREE_BRANCH$TERM_PANEL_HRULE")" \
- "$branch" \
- "$label_state" \
- "$(term_color dim "$age")"
- # Detail sub-rows (under the lane's │ continuation)
- if [[ -n "$wt" ]]; then
- local wt_short="$wt" repo_root="${REPO_ROOT:-}"
- [[ -n "$repo_root" ]] && wt_short="${wt#$repo_root/}"
- if [[ "$wt_short" == "$wt" && -n "$repo_root" ]]; then
- local repo_native
- repo_native=$(cygpath -m "$repo_root" 2>/dev/null || echo "$repo_root")
- wt_short="${wt#$repo_native/}"
- fi
- printf '%s %s worktree: %s\n' \
- "$(term_color dim "$TERM_TREE_VERT")" \
- "$(term_color dim "$TERM_TREE_VERT")" \
- "$(term_color dim "$wt_short")"
- fi
- if [[ "$commits" != "?" && "$commits" != "0" ]]; then
- printf '%s %s commits: %s ahead of %s\n' \
- "$(term_color dim "$TERM_TREE_VERT")" \
- "$(term_color dim "$TERM_TREE_VERT")" \
- "$(term_color dim "$commits")" \
- "$(term_color dim "$BASE_BRANCH")"
- fi
- if [[ -n "$meta" ]]; then
- printf '%s %s note: %s\n' \
- "$(term_color dim "$TERM_TREE_VERT")" \
- "$(term_color dim "$TERM_TREE_VERT")" \
- "$(term_color dim "$meta")"
- fi
- term_panel_vert
- done
- __fleet_footer "$active" "$daemon_state"
- echo ""
- }
- cmd_fleet() {
- local mode="panel"
- while [[ $# -gt 0 ]]; do
- case "$1" in
- -v|--verbose) mode="verbose"; shift ;;
- -g|--grouped) mode="panel"; shift ;;
- *) shift ;;
- esac
- done
- case "$mode" in
- verbose) fleet_view_verbose ;;
- *) fleet_view_panel ;;
- esac
- }
- cmd_scrub_check() {
- local branch=${1:-}
- [[ -z "$branch" ]] && { echo "usage: fleet scrub-check <branch>" >&2; exit 1; }
- local hits
- hits=$(scrub_diff "$branch")
- if [[ -n "$hits" ]]; then
- echo "FORBIDDEN PATTERNS in $branch:"
- echo "$hits" | head -20
- return 1
- fi
- echo "OK: $branch (no forbidden patterns)"
- }
- land_one() {
- local branch=$1
- local hits
- hits=$(scrub_diff "$branch")
- if [[ -n "$hits" ]]; then
- log "REFUSE LAND: $branch failed scrub-check"
- echo "$hits" | head -10 | tee -a "$LOG"
- set_lane_state "$branch" "CONFLICT" "scrub-check failed"
- return 1
- fi
- if is_dirty_tracked; then
- log "REFUSE LAND: $BASE_BRANCH has uncommitted tracked changes — clean before landing"
- return 1
- fi
- log "LANDING: $branch"
- git checkout "$BASE_BRANCH"
- if git merge "$branch" --no-ff -m "merge: $branch"; then
- if [[ -n "$TEST_CMD" ]]; then
- log "running tests: $TEST_CMD"
- if eval "$TEST_CMD" >>"$LOG" 2>&1; then
- log "PASS: $branch landed ✓"
- else
- log "FAIL: tests failed — reverting $branch"
- git reset --hard HEAD^
- set_lane_state "$branch" "FAILED" "tests failed post-merge"
- return 1
- fi
- else
- log "no test_cmd set — trusting signal.sh's log gate"
- fi
- set_lane_state "$branch" "LANDED"
- git branch -d "$branch" 2>/dev/null || git branch -D "$branch" 2>/dev/null || true
- return 0
- else
- log "MERGE CONFLICT: $branch"
- git merge --abort 2>/dev/null || true
- set_lane_state "$branch" "CONFLICT" "merge conflict with $BASE_BRANCH"
- return 1
- fi
- }
- worktree_path_for() {
- # Echo the worktree path for branch $1, or empty if branch isn't in a worktree
- local branch=$1
- git worktree list --porcelain 2>/dev/null | awk -v want="refs/heads/$branch" '
- /^worktree /{p=$2}
- /^branch /{ if ($2==want) print p }
- '
- }
- rebase_others() {
- local landed=$1
- for f in "$LANES_DIR"/*; do
- local b state wt
- b=$(basename "$f")
- [[ "$b" == "$landed" ]] && continue
- state=$(lane_state "$b")
- [[ "$state" == "LANDED" || "$state" == "FAILED" ]] && continue
- git rev-parse --verify "$b" >/dev/null 2>&1 || continue
- log "rebase: $b onto $BASE_BRANCH"
- wt=$(worktree_path_for "$b")
- if [[ -n "$wt" ]]; then
- # Branch is checked out in a worktree — run rebase from there
- if git -C "$wt" rebase "$BASE_BRANCH" 2>>"$LOG"; then
- log "rebase OK: $b (in worktree $wt)"
- else
- log "rebase CONFLICT: $b"
- git -C "$wt" rebase --abort 2>/dev/null || true
- set_lane_state "$b" "CONFLICT" "rebase against $BASE_BRANCH failed"
- fi
- else
- # Plain branch (no worktree) — rebase via the main repo
- if git rebase "$BASE_BRANCH" "$b" 2>>"$LOG"; then
- log "rebase OK: $b"
- else
- log "rebase CONFLICT: $b"
- git rebase --abort 2>/dev/null || true
- set_lane_state "$b" "CONFLICT" "rebase against $BASE_BRANCH failed"
- fi
- fi
- done
- git checkout "$BASE_BRANCH" 2>/dev/null || true
- }
- cmd_land() {
- local branch=${1:-}
- [[ -z "$branch" ]] && { echo "usage: fleet land <branch>" >&2; exit 1; }
- land_one "$branch" && rebase_others "$branch"
- }
- cmd_stop() {
- if [[ ! -f "$PID_FILE" ]]; then
- echo "no daemon running (no $PID_FILE)" >&2
- return 0
- fi
- local pid
- pid=$(cat "$PID_FILE")
- if ! kill -0 "$pid" 2>/dev/null; then
- log "stale PID file (pid $pid not alive) — clearing"
- rm -f "$PID_FILE"
- return 0
- fi
- log "sending SIGTERM to daemon (pid $pid)"
- kill -TERM "$pid" 2>/dev/null || true
- # Wait up to 5s for graceful exit
- local i
- for i in 1 2 3 4 5; do
- sleep 1
- kill -0 "$pid" 2>/dev/null || { log "daemon stopped"; return 0; }
- done
- log "daemon didn't exit on SIGTERM, sending SIGKILL"
- kill -KILL "$pid" 2>/dev/null || true
- rm -f "$PID_FILE"
- }
- cmd_revert() {
- local branch=${1:-}
- [[ -z "$branch" ]] && { echo "usage: fleet revert <branch>" >&2; exit 1; }
- local sha
- sha=$(git log "$BASE_BRANCH" --merges --grep="merge: $branch" -n1 --format=%H)
- [[ -z "$sha" ]] && { log "ERROR: no merge commit found for $branch on $BASE_BRANCH"; exit 1; }
- log "reverting merge $sha (was: $branch)"
- git checkout "$BASE_BRANCH"
- git revert -m 1 "$sha" --no-edit
- log "reverted: $branch"
- }
- daemon_cleanup() {
- log "daemon stopping (pid $$)"
- rm -f "$PID_FILE"
- }
- cmd_start() {
- ensure_fleet_dir
- refuse_if_shared_tree || exit 1
- # Refuse if a daemon is already running
- if [[ -f "$PID_FILE" ]]; then
- local existing_pid
- existing_pid=$(cat "$PID_FILE" 2>/dev/null || echo "")
- if [[ -n "$existing_pid" ]] && kill -0 "$existing_pid" 2>/dev/null; then
- log "ERROR: daemon already running (pid $existing_pid). Run: fleet stop"
- exit 1
- else
- log "stale PID file (pid $existing_pid not alive) — clearing"
- rm -f "$PID_FILE"
- fi
- fi
- echo "$$" > "$PID_FILE"
- trap daemon_cleanup EXIT INT TERM HUP
- log "daemon start (pid $$, poll: ${POLL_INTERVAL}s, test_cmd: ${TEST_CMD:-<none>})"
- while true; do
- local ready=()
- for f in "$LANES_DIR"/*; do
- [[ -f "$f" && "$(head -n1 "$f")" == "READY" ]] && ready+=("$(basename "$f")")
- done
- if [[ ${#ready[@]} -gt 0 ]]; then
- for branch in "${ready[@]}"; do
- if land_one "$branch"; then
- rebase_others "$branch"
- fi
- done
- cmd_fleet
- fi
- local active=0
- for f in "$LANES_DIR"/*; do
- [[ -f "$f" ]] || continue
- local s
- s=$(head -n1 "$f")
- [[ "$s" != "LANDED" && "$s" != "FAILED" ]] && active=$((active+1))
- done
- if [[ $active -eq 0 ]]; then
- log "all lanes terminal — daemon exiting"
- cmd_fleet
- break
- fi
- sleep "$POLL_INTERVAL"
- done
- }
- case "${1:-}" in
- init) shift; cmd_init "$@" ;;
- start) shift; cmd_start "$@" ;;
- stop) cmd_stop ;;
- status|fleet) shift; cmd_fleet "$@" ;;
- land) shift; cmd_land "$@" ;;
- revert) shift; cmd_revert "$@" ;;
- scrub-check) shift; cmd_scrub_check "$@" ;;
- ""|-h|--help)
- cat <<EOF
- fleet-ops — landing queue for concurrent Claude sessions (experimental)
- Usage:
- fleet init <name>... Create branch + worktree per name
- fleet start Run the daemon (writes pid to $PID_FILE)
- fleet stop Signal the running daemon to exit cleanly
- fleet status One-shot status view
- fleet land <branch> Manual land + rebase others
- fleet revert <branch> Revert merge commit on $BASE_BRANCH
- fleet scrub-check <branch> Dry-run forbidden-pattern check
- Config (optional): $CONFIG
- EOF
- ;;
- *) echo "unknown subcommand: $1" >&2; exit 1 ;;
- esac
|