fleet.sh 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658
  1. #!/usr/bin/env bash
  2. # fleet-ops — landing queue manager for concurrent Claude sessions
  3. # Status: experimental
  4. set -euo pipefail
  5. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  6. REPO_ROOT=""
  7. # Resolve repo root via git, so fleet works from any worktree.
  8. # cd to it once so all relative paths below resolve correctly.
  9. if GIT_COMMON_DIR=$(git rev-parse --git-common-dir 2>/dev/null); then
  10. REPO_ROOT="$(cd "$GIT_COMMON_DIR/.." && pwd)"
  11. cd "$REPO_ROOT"
  12. fi
  13. FLEET_DIR=".claude/fleet"
  14. LANES_DIR="$FLEET_DIR/lanes"
  15. LOG="$FLEET_DIR/activity.log"
  16. CONFIG="$FLEET_DIR/config"
  17. PID_FILE="$FLEET_DIR/daemon.pid"
  18. # Shared terminal-output helpers (see docs/TERMINAL-DESIGN.md).
  19. # shellcheck source=../../_lib/term.sh
  20. . "$SCRIPT_DIR/../../_lib/term.sh"
  21. # Honor legacy FLEET_ASCII alongside TERM_ASCII.
  22. [[ "${FLEET_ASCII:-}" == "1" || "${icons:-}" == "ascii" ]] && export TERM_ASCII=1
  23. term_init
  24. # defaults (overridable via .claude/fleet/config: key=value, no quotes)
  25. MODE="auto"
  26. # Default worktree root sits at repo top, NOT under .claude/. Claude Code's
  27. # headless mode (--dangerously-skip-permissions) bypasses prompts but still
  28. # enforces the global .claude/ sensitive-file guard, so worktrees nested
  29. # under .claude/ can't be written to by lane sessions. See SKILL.md
  30. # "Headless agent compatibility".
  31. WORKTREE_ROOT=".fleet-worktrees"
  32. TEST_CMD=""
  33. FORBIDDEN_PATTERN="TODO_SCRUB|XXX[^a-z]|FIXME_BEFORE_LAND"
  34. BASE_BRANCH="main"
  35. POLL_INTERVAL=5
  36. [[ -f "$CONFIG" ]] && source "$CONFIG" 2>/dev/null || true
  37. # Icons resolved through the shared term lib (term_state_icon).
  38. ICON_RUNNING="$(term_state_icon RUNNING)"
  39. ICON_READY="$(term_state_icon READY)"
  40. ICON_LANDED="$(term_state_icon LANDED)"
  41. ICON_FAILED="$(term_state_icon FAILED)"
  42. ICON_CONFLICT="$(term_state_icon CONFLICT)"
  43. ICON_UNKNOWN="?"
  44. # Cross-platform mtime: GNU stat (Linux/Git Bash) vs BSD stat (macOS)
  45. file_mtime() {
  46. stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || date +%s
  47. }
  48. log() { echo "[$(date '+%H:%M:%S')] $*" | tee -a "$LOG" >&2; }
  49. maybe_commit_gitignore() {
  50. # Auto-commit the .gitignore append from ensure_fleet_dir, but only when
  51. # safe: must be on BASE_BRANCH and .gitignore must be the only change in
  52. # the tree. Otherwise warn loudly — the daemon's land step will refuse
  53. # otherwise with "main has uncommitted tracked changes".
  54. local current
  55. current=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
  56. if [[ "$current" != "$BASE_BRANCH" ]]; then
  57. log "ACTION REQUIRED: .gitignore updated for fleet-ops runtime paths."
  58. log " You're on '$current', not '$BASE_BRANCH'. Switch to"
  59. log " '$BASE_BRANCH' and commit .gitignore before 'fleet start',"
  60. log " or the daemon will refuse to land with"
  61. log " 'uncommitted tracked changes — clean before landing'."
  62. return 0
  63. fi
  64. local other_changes
  65. other_changes=$(git status --porcelain 2>/dev/null | grep -vE '^.. \.gitignore$' || true)
  66. if [[ -n "$other_changes" ]]; then
  67. log "ACTION REQUIRED: .gitignore updated for fleet-ops runtime paths,"
  68. log " but other uncommitted changes exist on $BASE_BRANCH."
  69. log " Commit .gitignore yourself before 'fleet start' or"
  70. log " the daemon will refuse to land. Suggested:"
  71. log " git add .gitignore && git commit -m 'chore: gitignore fleet-ops runtime state'"
  72. return 0
  73. fi
  74. git add .gitignore 2>/dev/null || { log "WARN: git add .gitignore failed"; return 0; }
  75. if git commit -m "chore: gitignore fleet-ops runtime state" -- .gitignore >/dev/null 2>&1; then
  76. log "auto-committed .gitignore (fleet-ops runtime paths: .claude/fleet/, .fleet-worktrees/)"
  77. else
  78. log "WARN: auto-commit of .gitignore failed — commit it manually before 'fleet start'"
  79. fi
  80. }
  81. ensure_fleet_dir() {
  82. mkdir -p "$LANES_DIR"
  83. [[ -f "$FLEET_DIR/signal.sh" ]] || cp "$SCRIPT_DIR/signal.sh" "$FLEET_DIR/signal.sh"
  84. chmod +x "$FLEET_DIR/signal.sh" 2>/dev/null || true
  85. # Auto-ignore fleet-ops runtime state in git so it doesn't show as "dirty"
  86. # or get committed. Two paths:
  87. # .claude/fleet/ — lanes/, daemon.pid, activity.log, signal.sh, config
  88. # .fleet-worktrees/ — default worktree root (top-level so headless
  89. # Claude lane sessions can write there)
  90. if git rev-parse --git-dir >/dev/null 2>&1; then
  91. [[ -f .gitignore ]] || touch .gitignore
  92. local appended=0
  93. if ! grep -qxF '.claude/fleet/' .gitignore 2>/dev/null; then
  94. echo '.claude/fleet/' >> .gitignore
  95. appended=1
  96. fi
  97. if ! grep -qxF '.fleet-worktrees/' .gitignore 2>/dev/null; then
  98. echo '.fleet-worktrees/' >> .gitignore
  99. appended=1
  100. fi
  101. [[ $appended -eq 1 ]] && maybe_commit_gitignore
  102. fi
  103. }
  104. is_dirty_tracked() {
  105. # True only if tracked files have uncommitted changes (ignores untracked files)
  106. ! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null
  107. }
  108. lane_state() { [[ -f "$LANES_DIR/$1" ]] && head -n1 "$LANES_DIR/$1" || echo "MISSING"; }
  109. set_lane_state() {
  110. local l=$1 s=$2
  111. shift 2
  112. if [[ $# -gt 0 ]]; then
  113. printf '%s\n%s\n' "$s" "$*" > "$LANES_DIR/$l"
  114. else
  115. printf '%s\n' "$s" > "$LANES_DIR/$l"
  116. fi
  117. }
  118. scrub_diff() {
  119. # echoes hits (one per line) for given branch's diff vs base. Empty = clean.
  120. local branch=$1
  121. git diff "$BASE_BRANCH"..."$branch" 2>/dev/null | grep -nE "$FORBIDDEN_PATTERN" || true
  122. }
  123. refuse_if_shared_tree() {
  124. local trees lane_count
  125. trees=$(git worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2}' | sort -u | wc -l)
  126. lane_count=$(ls -1 "$LANES_DIR" 2>/dev/null | wc -l)
  127. if [[ "$lane_count" -gt 1 && "$trees" -le 1 && "$MODE" != "branch" ]]; then
  128. log "ERROR: $lane_count lanes but only $trees worktree — sessions will collide"
  129. log " Use worktrees, separate clones, or set mode=branch in $CONFIG to override"
  130. return 1
  131. fi
  132. }
  133. cmd_init() {
  134. ensure_fleet_dir
  135. [[ $# -eq 0 ]] && { echo "usage: fleet init <name>..." >&2; exit 1; }
  136. local mode="$MODE"
  137. [[ "$mode" == "auto" ]] && mode="worktree" # default: worktree if git allows it
  138. for name in "$@"; do
  139. if git rev-parse --verify "$name" >/dev/null 2>&1; then
  140. log "skip branch (exists): $name"
  141. else
  142. git branch "$name" "$BASE_BRANCH"
  143. log "created branch: $name"
  144. fi
  145. if [[ "$mode" == "worktree" ]]; then
  146. local wt="$WORKTREE_ROOT/$name"
  147. if [[ -d "$wt" ]]; then
  148. log "skip worktree (exists): $wt"
  149. else
  150. mkdir -p "$WORKTREE_ROOT"
  151. git worktree add "$wt" "$name"
  152. log "created worktree: $wt"
  153. fi
  154. fi
  155. set_lane_state "$name" "RUNNING"
  156. done
  157. echo ""
  158. echo "Fleet initialized. Hand each session the prompt template:"
  159. echo " $SCRIPT_DIR/../references/session-prompt.md"
  160. echo "Then: bash $0 start"
  161. }
  162. format_age() {
  163. local secs=$1
  164. if [[ $secs -lt 60 ]]; then printf '%ds' "$secs"
  165. elif [[ $secs -lt 3600 ]]; then printf '%dm' "$((secs/60))"
  166. else printf '%dh%dm' "$((secs/3600))" "$(( (secs%3600)/60 ))"
  167. fi
  168. }
  169. icon_for_state() {
  170. case "$1" in
  171. RUNNING) echo "$ICON_RUNNING" ;;
  172. READY) echo "$ICON_READY" ;;
  173. LANDED) echo "$ICON_LANDED" ;;
  174. FAILED) echo "$ICON_FAILED" ;;
  175. CONFLICT) echo "$ICON_CONFLICT" ;;
  176. *) echo "$ICON_UNKNOWN" ;;
  177. esac
  178. }
  179. # Bucket lanes by state into parallel arrays. Sets:
  180. # total, active — globals
  181. # state_buckets[0..4] — newline-joined "branch|age|meta"
  182. # state_counts[0..4] — count per state
  183. # Order: 0=RUNNING 1=READY 2=CONFLICT 3=FAILED 4=LANDED
  184. __fleet_bucket() {
  185. total=0; active=0
  186. state_buckets=("" "" "" "" "")
  187. state_counts=(0 0 0 0 0)
  188. local now=$(date +%s)
  189. for f in "$LANES_DIR"/*; do
  190. [[ -f "$f" ]] || continue
  191. total=$((total+1))
  192. local branch state meta mtime secs age idx
  193. branch=$(basename "$f")
  194. state=$(head -n1 "$f")
  195. meta=$(sed -n '2p' "$f")
  196. mtime=$(file_mtime "$f")
  197. secs=$((now - mtime))
  198. age=$(format_age "$secs")
  199. [[ "$state" != "LANDED" && "$state" != "FAILED" ]] && active=$((active+1))
  200. idx=-1
  201. case "$state" in
  202. RUNNING) idx=0 ;;
  203. READY) idx=1 ;;
  204. CONFLICT) idx=2 ;;
  205. FAILED) idx=3 ;;
  206. LANDED) idx=4 ;;
  207. esac
  208. [[ $idx -lt 0 ]] && continue
  209. state_counts[$idx]=$(( state_counts[idx] + 1 ))
  210. state_buckets[$idx]="${state_buckets[$idx]}${branch}|${age}|${meta}"$'\n'
  211. done
  212. }
  213. # Daemon health → "healthy" or "busted"
  214. __fleet_daemon_state() {
  215. if [[ -f "$PID_FILE" ]]; then
  216. local pid
  217. pid=$(cat "$PID_FILE" 2>/dev/null || echo "")
  218. if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
  219. printf 'healthy'
  220. return
  221. fi
  222. fi
  223. printf 'busted'
  224. }
  225. # Footer composition shared by all panel views.
  226. __fleet_footer() {
  227. local active=$1 daemon_state=$2
  228. local hotkeys
  229. hotkeys="$(term_hotkey R refresh) · $(term_hotkey L land) · $(term_hotkey '?' help)"
  230. local healths
  231. healths="$(term_health "$daemon_state" "daemon")"
  232. [[ "$active" -gt 0 ]] && healths="$healths $(term_health pending "$active active")"
  233. term_panel_close "$hotkeys" "$healths"
  234. }
  235. # Default panel view — design-system grouped tree
  236. fleet_view_panel() {
  237. ensure_fleet_dir
  238. local order=(RUNNING READY CONFLICT FAILED LANDED)
  239. local total active
  240. local state_buckets state_counts
  241. __fleet_bucket
  242. local daemon_state
  243. daemon_state=$(__fleet_daemon_state)
  244. echo ""
  245. term_panel_open fleet fleet "$TERM_GLYPH_BRANCH $BASE_BRANCH"
  246. if [[ $total -eq 0 ]]; then
  247. term_panel_vert
  248. term_panel_vert
  249. printf '%s %s\n' "$(term_color dim "$TERM_TREE_VERT")" "no lanes yet"
  250. term_panel_vert
  251. term_panel_vert
  252. printf '%s %s %s\n' "$(term_color dim "$TERM_TREE_VERT")" "$TERM_GLYPH_TIP" "to get started:"
  253. term_panel_vert
  254. printf '%s 1. fleet init <name>...\n' "$(term_color dim "$TERM_TREE_VERT")"
  255. printf '%s 2. (work in each lane)\n' "$(term_color dim "$TERM_TREE_VERT")"
  256. printf '%s 3. fleet start\n' "$(term_color dim "$TERM_TREE_VERT")"
  257. term_panel_vert
  258. term_panel_vert
  259. term_panel_close "$(term_hotkey '?' help)" "$(term_health unknown "v2.4.9")"
  260. echo ""
  261. return
  262. fi
  263. term_panel_vert
  264. term_summary_line "$total $([ "$total" -eq 1 ] && echo lane || echo lanes) · $active active"
  265. term_panel_vert
  266. local i
  267. for i in 0 1 2 3 4; do
  268. local n=${state_counts[$i]}
  269. [[ $n -eq 0 ]] && continue
  270. local state=${order[$i]}
  271. term_section "$state" "$state" "$n"
  272. local lines="${state_buckets[$i]}"
  273. local c_idx=0 c_last=$((n - 1))
  274. local branch age meta
  275. while IFS='|' read -r branch age meta; do
  276. [[ -z "$branch" ]] && continue
  277. local c_conn
  278. if [[ $c_idx -eq $c_last ]]; then c_conn="$TERM_TREE_LAST"; else c_conn="$TERM_TREE_BRANCH"; fi
  279. # Build the rail glyph from this lane's commits-ahead and state.
  280. local ahead head_kind rail
  281. ahead=$(git rev-list --count "${BASE_BRANCH}..${branch}" 2>/dev/null || echo 0)
  282. head_kind="HEAD"
  283. [[ "$state" == "CONFLICT" || "$state" == "FAILED" ]] && head_kind="CONFLICT"
  284. rail=$(term_rail "$ahead" "$head_kind")
  285. term_leaf_line "$c_conn" "$branch" "$rail" "${meta:-}" "$age"
  286. c_idx=$((c_idx+1))
  287. done <<< "$lines"
  288. term_panel_vert
  289. done
  290. __fleet_footer "$active" "$daemon_state"
  291. echo ""
  292. }
  293. # Verbose view — per-lane detail blocks rendered in panel grammar.
  294. # Each lane gets a header row + sub-rows for worktree, commits, and note.
  295. fleet_view_verbose() {
  296. ensure_fleet_dir
  297. local total active
  298. local state_buckets state_counts
  299. __fleet_bucket
  300. local daemon_state
  301. daemon_state=$(__fleet_daemon_state)
  302. local now=$(date +%s)
  303. echo ""
  304. term_panel_open fleet "fleet · verbose" "$TERM_GLYPH_BRANCH $BASE_BRANCH"
  305. if [[ $total -eq 0 ]]; then
  306. term_panel_vert
  307. printf '%s no lanes yet\n' "$(term_color dim "$TERM_TREE_VERT")"
  308. term_panel_vert
  309. term_panel_close "$(term_hotkey '?' help)" "$(term_health unknown "v2.4.9")"
  310. echo ""
  311. return
  312. fi
  313. term_panel_vert
  314. term_summary_line "$total $([ "$total" -eq 1 ] && echo lane || echo lanes) · $active active"
  315. term_panel_vert
  316. for f in "$LANES_DIR"/*; do
  317. [[ -f "$f" ]] || continue
  318. local branch state meta mtime age secs wt commits color label_state
  319. branch=$(basename "$f")
  320. state=$(head -n1 "$f")
  321. meta=$(sed -n '2p' "$f")
  322. mtime=$(file_mtime "$f")
  323. secs=$((now - mtime))
  324. age=$(format_age "$secs")
  325. wt=$(worktree_path_for "$branch" 2>/dev/null || echo "")
  326. commits=$(git rev-list --count "$BASE_BRANCH..$branch" 2>/dev/null || echo "?")
  327. color=""
  328. case "$state" in
  329. RUNNING|PENDING|CONFLICT|WARN) color="yellow" ;;
  330. READY|LANDED|DONE|OK) color="green" ;;
  331. FAILED|ERROR) color="red" ;;
  332. esac
  333. label_state="$state"
  334. [[ -n "$color" ]] && label_state=$(term_color "$color" "$state")
  335. # Lane header row
  336. printf '%s%s %-30s %-10s %s\n' \
  337. "$(term_color dim "$TERM_TREE_VERT")" \
  338. "$(term_color dim "$TERM_TREE_BRANCH$TERM_PANEL_HRULE")" \
  339. "$branch" \
  340. "$label_state" \
  341. "$(term_color dim "$age")"
  342. # Detail sub-rows (under the lane's │ continuation)
  343. if [[ -n "$wt" ]]; then
  344. local wt_short="$wt" repo_root="${REPO_ROOT:-}"
  345. [[ -n "$repo_root" ]] && wt_short="${wt#$repo_root/}"
  346. if [[ "$wt_short" == "$wt" && -n "$repo_root" ]]; then
  347. local repo_native
  348. repo_native=$(cygpath -m "$repo_root" 2>/dev/null || echo "$repo_root")
  349. wt_short="${wt#$repo_native/}"
  350. fi
  351. printf '%s %s worktree: %s\n' \
  352. "$(term_color dim "$TERM_TREE_VERT")" \
  353. "$(term_color dim "$TERM_TREE_VERT")" \
  354. "$(term_color dim "$wt_short")"
  355. fi
  356. if [[ "$commits" != "?" && "$commits" != "0" ]]; then
  357. printf '%s %s commits: %s ahead of %s\n' \
  358. "$(term_color dim "$TERM_TREE_VERT")" \
  359. "$(term_color dim "$TERM_TREE_VERT")" \
  360. "$(term_color dim "$commits")" \
  361. "$(term_color dim "$BASE_BRANCH")"
  362. fi
  363. if [[ -n "$meta" ]]; then
  364. printf '%s %s note: %s\n' \
  365. "$(term_color dim "$TERM_TREE_VERT")" \
  366. "$(term_color dim "$TERM_TREE_VERT")" \
  367. "$(term_color dim "$meta")"
  368. fi
  369. term_panel_vert
  370. done
  371. __fleet_footer "$active" "$daemon_state"
  372. echo ""
  373. }
  374. cmd_fleet() {
  375. local mode="panel"
  376. while [[ $# -gt 0 ]]; do
  377. case "$1" in
  378. -v|--verbose) mode="verbose"; shift ;;
  379. -g|--grouped) mode="panel"; shift ;;
  380. *) shift ;;
  381. esac
  382. done
  383. case "$mode" in
  384. verbose) fleet_view_verbose ;;
  385. *) fleet_view_panel ;;
  386. esac
  387. }
  388. cmd_scrub_check() {
  389. local branch=${1:-}
  390. [[ -z "$branch" ]] && { echo "usage: fleet scrub-check <branch>" >&2; exit 1; }
  391. local hits
  392. hits=$(scrub_diff "$branch")
  393. if [[ -n "$hits" ]]; then
  394. echo "FORBIDDEN PATTERNS in $branch:"
  395. echo "$hits" | head -20
  396. return 1
  397. fi
  398. echo "OK: $branch (no forbidden patterns)"
  399. }
  400. land_one() {
  401. local branch=$1
  402. local hits
  403. hits=$(scrub_diff "$branch")
  404. if [[ -n "$hits" ]]; then
  405. log "REFUSE LAND: $branch failed scrub-check"
  406. echo "$hits" | head -10 | tee -a "$LOG"
  407. set_lane_state "$branch" "CONFLICT" "scrub-check failed"
  408. return 1
  409. fi
  410. if is_dirty_tracked; then
  411. log "REFUSE LAND: $BASE_BRANCH has uncommitted tracked changes — clean before landing"
  412. return 1
  413. fi
  414. log "LANDING: $branch"
  415. git checkout "$BASE_BRANCH"
  416. if git merge "$branch" --no-ff -m "merge: $branch"; then
  417. if [[ -n "$TEST_CMD" ]]; then
  418. log "running tests: $TEST_CMD"
  419. if eval "$TEST_CMD" >>"$LOG" 2>&1; then
  420. log "PASS: $branch landed ✓"
  421. else
  422. log "FAIL: tests failed — reverting $branch"
  423. git reset --hard HEAD^
  424. set_lane_state "$branch" "FAILED" "tests failed post-merge"
  425. return 1
  426. fi
  427. else
  428. log "no test_cmd set — trusting signal.sh's log gate"
  429. fi
  430. set_lane_state "$branch" "LANDED"
  431. git branch -d "$branch" 2>/dev/null || git branch -D "$branch" 2>/dev/null || true
  432. return 0
  433. else
  434. log "MERGE CONFLICT: $branch"
  435. git merge --abort 2>/dev/null || true
  436. set_lane_state "$branch" "CONFLICT" "merge conflict with $BASE_BRANCH"
  437. return 1
  438. fi
  439. }
  440. worktree_path_for() {
  441. # Echo the worktree path for branch $1, or empty if branch isn't in a worktree
  442. local branch=$1
  443. git worktree list --porcelain 2>/dev/null | awk -v want="refs/heads/$branch" '
  444. /^worktree /{p=$2}
  445. /^branch /{ if ($2==want) print p }
  446. '
  447. }
  448. rebase_others() {
  449. local landed=$1
  450. for f in "$LANES_DIR"/*; do
  451. local b state wt
  452. b=$(basename "$f")
  453. [[ "$b" == "$landed" ]] && continue
  454. state=$(lane_state "$b")
  455. [[ "$state" == "LANDED" || "$state" == "FAILED" ]] && continue
  456. git rev-parse --verify "$b" >/dev/null 2>&1 || continue
  457. log "rebase: $b onto $BASE_BRANCH"
  458. wt=$(worktree_path_for "$b")
  459. if [[ -n "$wt" ]]; then
  460. # Branch is checked out in a worktree — run rebase from there
  461. if git -C "$wt" rebase "$BASE_BRANCH" 2>>"$LOG"; then
  462. log "rebase OK: $b (in worktree $wt)"
  463. else
  464. log "rebase CONFLICT: $b"
  465. git -C "$wt" rebase --abort 2>/dev/null || true
  466. set_lane_state "$b" "CONFLICT" "rebase against $BASE_BRANCH failed"
  467. fi
  468. else
  469. # Plain branch (no worktree) — rebase via the main repo
  470. if git rebase "$BASE_BRANCH" "$b" 2>>"$LOG"; then
  471. log "rebase OK: $b"
  472. else
  473. log "rebase CONFLICT: $b"
  474. git rebase --abort 2>/dev/null || true
  475. set_lane_state "$b" "CONFLICT" "rebase against $BASE_BRANCH failed"
  476. fi
  477. fi
  478. done
  479. git checkout "$BASE_BRANCH" 2>/dev/null || true
  480. }
  481. cmd_land() {
  482. local branch=${1:-}
  483. [[ -z "$branch" ]] && { echo "usage: fleet land <branch>" >&2; exit 1; }
  484. land_one "$branch" && rebase_others "$branch"
  485. }
  486. cmd_stop() {
  487. if [[ ! -f "$PID_FILE" ]]; then
  488. echo "no daemon running (no $PID_FILE)" >&2
  489. return 0
  490. fi
  491. local pid
  492. pid=$(cat "$PID_FILE")
  493. if ! kill -0 "$pid" 2>/dev/null; then
  494. log "stale PID file (pid $pid not alive) — clearing"
  495. rm -f "$PID_FILE"
  496. return 0
  497. fi
  498. log "sending SIGTERM to daemon (pid $pid)"
  499. kill -TERM "$pid" 2>/dev/null || true
  500. # Wait up to 5s for graceful exit
  501. local i
  502. for i in 1 2 3 4 5; do
  503. sleep 1
  504. kill -0 "$pid" 2>/dev/null || { log "daemon stopped"; return 0; }
  505. done
  506. log "daemon didn't exit on SIGTERM, sending SIGKILL"
  507. kill -KILL "$pid" 2>/dev/null || true
  508. rm -f "$PID_FILE"
  509. }
  510. cmd_revert() {
  511. local branch=${1:-}
  512. [[ -z "$branch" ]] && { echo "usage: fleet revert <branch>" >&2; exit 1; }
  513. local sha
  514. sha=$(git log "$BASE_BRANCH" --merges --grep="merge: $branch" -n1 --format=%H)
  515. [[ -z "$sha" ]] && { log "ERROR: no merge commit found for $branch on $BASE_BRANCH"; exit 1; }
  516. log "reverting merge $sha (was: $branch)"
  517. git checkout "$BASE_BRANCH"
  518. git revert -m 1 "$sha" --no-edit
  519. log "reverted: $branch"
  520. }
  521. daemon_cleanup() {
  522. log "daemon stopping (pid $$)"
  523. rm -f "$PID_FILE"
  524. }
  525. cmd_start() {
  526. ensure_fleet_dir
  527. refuse_if_shared_tree || exit 1
  528. # Refuse if a daemon is already running
  529. if [[ -f "$PID_FILE" ]]; then
  530. local existing_pid
  531. existing_pid=$(cat "$PID_FILE" 2>/dev/null || echo "")
  532. if [[ -n "$existing_pid" ]] && kill -0 "$existing_pid" 2>/dev/null; then
  533. log "ERROR: daemon already running (pid $existing_pid). Run: fleet stop"
  534. exit 1
  535. else
  536. log "stale PID file (pid $existing_pid not alive) — clearing"
  537. rm -f "$PID_FILE"
  538. fi
  539. fi
  540. echo "$$" > "$PID_FILE"
  541. trap daemon_cleanup EXIT INT TERM HUP
  542. log "daemon start (pid $$, poll: ${POLL_INTERVAL}s, test_cmd: ${TEST_CMD:-<none>})"
  543. while true; do
  544. local ready=()
  545. for f in "$LANES_DIR"/*; do
  546. [[ -f "$f" && "$(head -n1 "$f")" == "READY" ]] && ready+=("$(basename "$f")")
  547. done
  548. if [[ ${#ready[@]} -gt 0 ]]; then
  549. for branch in "${ready[@]}"; do
  550. if land_one "$branch"; then
  551. rebase_others "$branch"
  552. fi
  553. done
  554. cmd_fleet
  555. fi
  556. local active=0
  557. for f in "$LANES_DIR"/*; do
  558. [[ -f "$f" ]] || continue
  559. local s
  560. s=$(head -n1 "$f")
  561. [[ "$s" != "LANDED" && "$s" != "FAILED" ]] && active=$((active+1))
  562. done
  563. if [[ $active -eq 0 ]]; then
  564. log "all lanes terminal — daemon exiting"
  565. cmd_fleet
  566. break
  567. fi
  568. sleep "$POLL_INTERVAL"
  569. done
  570. }
  571. case "${1:-}" in
  572. init) shift; cmd_init "$@" ;;
  573. start) shift; cmd_start "$@" ;;
  574. stop) cmd_stop ;;
  575. status|fleet) shift; cmd_fleet "$@" ;;
  576. land) shift; cmd_land "$@" ;;
  577. revert) shift; cmd_revert "$@" ;;
  578. scrub-check) shift; cmd_scrub_check "$@" ;;
  579. ""|-h|--help)
  580. cat <<EOF
  581. fleet-ops — landing queue for concurrent Claude sessions (experimental)
  582. Usage:
  583. fleet init <name>... Create branch + worktree per name
  584. fleet start Run the daemon (writes pid to $PID_FILE)
  585. fleet stop Signal the running daemon to exit cleanly
  586. fleet status One-shot status view
  587. fleet land <branch> Manual land + rebase others
  588. fleet revert <branch> Revert merge commit on $BASE_BRANCH
  589. fleet scrub-check <branch> Dry-run forbidden-pattern check
  590. Config (optional): $CONFIG
  591. EOF
  592. ;;
  593. *) echo "unknown subcommand: $1" >&2; exit 1 ;;
  594. esac