fleet.sh 22 KB

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