land-all.sh 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. #!/bin/bash
  2. # land-all.sh — read-only survey that classifies every branch/worktree by HOW it
  3. # should land on trunk, so the git-ops "land all" front-door can plan a batch
  4. # and hand the landable set to fleet-ops (sequential, test-gated landing).
  5. #
  6. # NEVER mutates. It inspects worktrees but never writes, moves, or deletes them.
  7. # Respects rules/worktree-boundaries.md. The actual landing is delegated to
  8. # fleet-ops — this script only decides what SHOULD land, park, or prune.
  9. #
  10. # Classification (per local branch, trunk excluded):
  11. # LANDABLE clean, ahead, not merged, recent, no live writer → land it
  12. # STALE clean, ahead, not merged, but old (last commit > → prune/archive
  13. # --recent-days ago) — abandoned branch, not auto-landed or land explicitly
  14. # WIP uncommitted tracked changes → commit first, park
  15. # ACTIVE a session is writing it right now (recent activity) → park, don't touch
  16. # MERGED already an ancestor of trunk (incl. nothing-ahead) → prune candidate
  17. #
  18. # Usage:
  19. # bash land-all.sh # human panel for current repo
  20. # bash land-all.sh --porcelain # TSV for the skill to parse (no header/summary)
  21. # bash land-all.sh <repo-path> [flags]
  22. #
  23. # Flags:
  24. # --porcelain machine-readable TSV, one line per candidate:
  25. # STATUS \t BRANCH \t WORKTREE \t AHEAD \t BEHIND \t AGE \t NOTE
  26. # --active-window N seconds; a worktree touched within N seconds counts as
  27. # ACTIVE (live writer). Default 120. 0 disables detection.
  28. # --recent-days N a branch whose last commit is within N days is current
  29. # work (LANDABLE); older clean branches are STALE. Default 7.
  30. #
  31. # Exit codes:
  32. # 0 nothing landable (no LANDABLE candidates)
  33. # 1 at least one LANDABLE candidate (a batch land is available)
  34. # 2 not a git repo
  35. set -u
  36. REPO="$PWD"
  37. PORCELAIN=0
  38. ACTIVE_WINDOW=120
  39. RECENT_DAYS=7
  40. while [ $# -gt 0 ]; do
  41. case "$1" in
  42. --porcelain) PORCELAIN=1; shift ;;
  43. --active-window) ACTIVE_WINDOW="${2:-120}"; shift 2 ;;
  44. --recent-days) RECENT_DAYS="${2:-7}"; shift 2 ;;
  45. -h|--help) sed -n '2,42p' "$0"; exit 0 ;;
  46. -*) echo "unknown flag: $1" >&2; exit 2 ;;
  47. *) REPO="$1"; shift ;;
  48. esac
  49. done
  50. if ! git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
  51. echo "not-a-repo: $REPO" >&2
  52. exit 2
  53. fi
  54. REPO_ROOT=$(git -C "$REPO" rev-parse --show-toplevel)
  55. cd "$REPO_ROOT" || exit 2
  56. # Detect trunk branch (main preferred, then master).
  57. TRUNK="main"
  58. if ! git rev-parse --verify main >/dev/null 2>&1; then
  59. git rev-parse --verify master >/dev/null 2>&1 && TRUNK="master"
  60. fi
  61. NOW=$(date +%s)
  62. ACTIVE_THRESHOLD=$((NOW - ACTIVE_WINDOW))
  63. RECENT_THRESHOLD=$((NOW - RECENT_DAYS * 86400))
  64. # Which branch is checked out in the MAIN checkout (REPO_ROOT)?
  65. MAIN_BRANCH=$(git -C "$REPO_ROOT" symbolic-ref --short HEAD 2>/dev/null || echo "")
  66. # Map branch -> worktree path from `git worktree list --porcelain`.
  67. # (awk parses porcelain records; "branch refs/heads/" prefix is 18 chars.)
  68. TMP_WT=$(mktemp)
  69. git worktree list --porcelain 2>/dev/null | awk '
  70. /^worktree /{p=substr($0,10); next}
  71. /^branch / {b=substr($0,19); if (b!="") print b"\t"p; next}
  72. ' > "$TMP_WT"
  73. worktree_for() {
  74. # Echo the worktree path checked out on branch $1 (empty if none).
  75. awk -F'\t' -v want="$1" '$1==want{print $2; exit}' "$TMP_WT"
  76. }
  77. # Is a worktree being actively written? True if any tracked-tree file was
  78. # modified within ACTIVE_WINDOW seconds. -print -quit stops at the first hit.
  79. # --active-window 0 disables live-writer detection entirely.
  80. worktree_active() {
  81. local wt=$1
  82. [ "$ACTIVE_WINDOW" -le 0 ] 2>/dev/null && return 1
  83. [ -n "$wt" ] && [ -d "$wt" ] || return 1
  84. local hit
  85. hit=$(find "$wt" -type f -not -path '*/.git/*' -newermt "@$ACTIVE_THRESHOLD" -print -quit 2>/dev/null)
  86. [ -n "$hit" ]
  87. }
  88. # Does a worktree have uncommitted TRACKED changes? (untracked files don't block a
  89. # land — the merge takes committed history — but we surface them in the note.)
  90. worktree_dirty() {
  91. local wt=$1
  92. [ -n "$wt" ] && [ -d "$wt" ] || return 1
  93. ! git -C "$wt" diff --quiet 2>/dev/null || ! git -C "$wt" diff --cached --quiet 2>/dev/null
  94. }
  95. # Counters
  96. N_LANDABLE=0; N_STALE=0; N_WIP=0; N_ACTIVE=0; N_MERGED=0
  97. emit() {
  98. # STATUS BRANCH WORKTREE AHEAD BEHIND AGE NOTE
  99. local status=$1 branch=$2 wt=$3 ahead=$4 behind=$5 age=$6 note=$7
  100. if [ "$PORCELAIN" -eq 1 ]; then
  101. printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$status" "$branch" "$wt" "$ahead" "$behind" "$age" "$note"
  102. else
  103. local disp_wt="$wt"
  104. [ -n "$wt" ] && disp_wt="${wt#$REPO_ROOT/}"
  105. [ -z "$disp_wt" ] && disp_wt="-"
  106. printf '%-10s %-26s %-30s %-9s %-10s %s\n' \
  107. "$status" "${branch:0:26}" "${disp_wt:0:30}" "+${ahead}/-${behind}" "$age" "$note"
  108. fi
  109. }
  110. [ "$PORCELAIN" -eq 0 ] && {
  111. printf '%-10s %-26s %-30s %-9s %-10s %s\n' "STATUS" "BRANCH" "WORKTREE" "A/B" "AGE" "NOTE"
  112. echo "────────────────────────────────────────────────────────────────────────────────────────────────────"
  113. }
  114. # Walk every local branch except trunk.
  115. while IFS= read -r branch; do
  116. [ -z "$branch" ] && continue
  117. [ "$branch" = "$TRUNK" ] && continue
  118. ahead=$(git rev-list --count "${TRUNK}..${branch}" 2>/dev/null || echo 0)
  119. behind=$(git rev-list --count "${branch}..${TRUNK}" 2>/dev/null || echo 0)
  120. age=$(git log -1 --format='%ar' "$branch" 2>/dev/null | sed 's/ ago//')
  121. [ -z "$age" ] && age="?"
  122. last_ct=$(git log -1 --format='%ct' "$branch" 2>/dev/null || echo 0)
  123. recent=false; [ "$last_ct" -ge "$RECENT_THRESHOLD" ] 2>/dev/null && recent=true
  124. wt=$(worktree_for "$branch")
  125. merged=false
  126. if git merge-base --is-ancestor "$branch" "$TRUNK" 2>/dev/null; then
  127. merged=true
  128. fi
  129. dirty=false; worktree_dirty "$wt" && dirty=true
  130. active=false; worktree_active "$wt" && active=true
  131. # Note annotations.
  132. note=""
  133. [ -n "$wt" ] && [ "$wt" = "$REPO_ROOT" ] && note="main checkout"
  134. if [ -n "$wt" ]; then
  135. untracked=$(git -C "$wt" ls-files --others --exclude-standard 2>/dev/null | wc -l | tr -d ' ')
  136. [ "$untracked" -gt 0 ] && note="${note:+$note; }${untracked} untracked"
  137. fi
  138. # A branch far behind trunk will need a big rebase and likely conflicts —
  139. # surface it as a note whichever bucket it lands in.
  140. behind_note=""
  141. [ "$behind" -gt 40 ] && behind_note="far behind (${behind}) — expect conflicts"
  142. # Classify (order matters: merged before active/dirty; recency splits
  143. # clean-and-ahead into current LANDABLE vs abandoned STALE). A branch with
  144. # nothing ahead of trunk is necessarily an ancestor → caught by MERGED.
  145. if [ "$merged" = true ] || [ "$ahead" -eq 0 ]; then
  146. status="MERGED"; note="${note:+$note; }in trunk — prune candidate"; N_MERGED=$((N_MERGED+1))
  147. elif [ "$active" = true ]; then
  148. status="ACTIVE"; note="${note:+$note; }live writer <${ACTIVE_WINDOW}s — park"; N_ACTIVE=$((N_ACTIVE+1))
  149. elif [ "$dirty" = true ]; then
  150. status="WIP"; note="${note:+$note; }uncommitted — commit before landing"; N_WIP=$((N_WIP+1))
  151. elif [ "$recent" = true ]; then
  152. status="LANDABLE"; note="${note:+$note; }${behind_note}"; N_LANDABLE=$((N_LANDABLE+1))
  153. else
  154. status="STALE"; note="${note:+$note; }last commit >${RECENT_DAYS}d — abandoned?${behind_note:+; $behind_note}"; N_STALE=$((N_STALE+1))
  155. fi
  156. emit "$status" "$branch" "$wt" "$ahead" "$behind" "$age" "$note"
  157. done < <(git for-each-ref --format='%(refname:short)' refs/heads/ 2>/dev/null)
  158. rm -f "$TMP_WT"
  159. if [ "$PORCELAIN" -eq 0 ]; then
  160. echo ""
  161. echo "Trunk: $TRUNK Main checkout on: ${MAIN_BRANCH:-<detached>} (recent = ≤${RECENT_DAYS}d)"
  162. echo "Summary: $N_LANDABLE landable · $N_STALE stale · $N_WIP WIP · $N_ACTIVE active · $N_MERGED merged"
  163. echo ""
  164. if [ "$N_LANDABLE" -gt 0 ]; then
  165. echo " → $N_LANDABLE branch(es) ready to land. Hand them to fleet-ops:"
  166. echo " fleet track <landable-branches...> && fleet land --all --running"
  167. else
  168. echo " → nothing current to land right now."
  169. fi
  170. [ "$N_STALE" -gt 0 ] && echo " · $N_STALE stale (clean but >${RECENT_DAYS}d old) — prune/archive, or --recent-days to include."
  171. [ "$N_ACTIVE" -gt 0 ] && echo " ⚠ $N_ACTIVE active (a session is writing) — parked, never auto-landed."
  172. [ "$N_WIP" -gt 0 ] && echo " ⚠ $N_WIP with uncommitted work — commit in-lane first, then re-run."
  173. if [ "$MAIN_BRANCH" != "$TRUNK" ] && [ -n "$MAIN_BRANCH" ]; then
  174. echo " ⚠ main checkout is on '$MAIN_BRANCH', not '$TRUNK' — landing checks out $TRUNK there."
  175. fi
  176. fi
  177. # Exit 1 signals "a batch land is available" (LANDABLE > 0); 0 = nothing to do.
  178. [ "$N_LANDABLE" -gt 0 ] && exit 1
  179. exit 0