#!/usr/bin/env bash
# scripts/inflight — a live table of every worktree + branch in flight on THIS machine.
#
# Everything here is DERIVED from git at call time (never a stored cache), so it
# cannot drift: worktrees appear/vanish between ships and the table is still
# correct the instant you run it. The one thing git can't derive — what a
# random-codenamed worktree is actually *about* — is read from a per-worktree
# `.claude/purpose` one-liner (gitignored; self-cleaning, dies with the worktree).
#
# Columns:
#   WORKTREE   basename ( * = the one you're in; "main" = the primary checkout )
#   SESSION    live#<pid>  — a Claude session holds the worktree lock and is alive
#              dead-lock#<pid> — locked by a pid that is gone (safe to reap)
#              —           — no lock
#   DIRTY      count of tracked-modified files (+N = untracked), or "clean"
#   VS-MAIN    merged        — tip is an ancestor of main (shipped & reset, or ff)
#              in-main↓B     — all commits already in main via squash; B behind
#              ↑U↓B          — U commits unique to the branch, B behind main
#   PURPOSE    .claude/purpose  →  else newest branch-unique commit subject
#              →  else "(editing <files>…)"  →  else "(no purpose set)"
#   LAST       relative date + subject of the worktree's HEAD commit
#
# The footer lists REMOVABLE candidates (merged + clean + no live session) — it
# only *reports* them; removal stays a deliberate, confirmed act (a locked live
# session, uncommitted WIP, or salvageable docs must never be auto-nuked).
#
# Usage:
#   scripts/inflight            # the table
#   scripts/inflight --for-hook # same, with a one-line intro; always exits 0
#                               # (wired as a SessionStart hook in .claude/settings.json)
#   scripts/inflight --json     # structured form for /workspace-cleanup: each
#                               # worktree gets a `bucket` verdict —
#                               #   base            the primary branch, not a candidate
#                               #   self            the worktree you're calling from
#                               #   live_session    a claude session still holds the lock
#                               #   needs_judgment  dirty tree — includes a capped diffstat
#                               #   safe_remove     clean + fully merged (ancestor or squash-absorbed)
#                               #   has_unmerged_work  real commits ahead of base, informational
#                               # plus a repo-wide stashCount (stash is one ref shared by every
#                               # worktree, so it's reported once, not per-row).
#
# See: CLAUDE.md / "Session workflow" (the .claude/purpose protocol).
set -uo pipefail

FOR_HOOK=0
JSON_MODE=0
[ "${1:-}" = "--for-hook" ] && FOR_HOOK=1
[ "${1:-}" = "--json" ] && JSON_MODE=1

# Not a git repo (or git absent) → say nothing, never block a session start.
command -v git >/dev/null 2>&1 || exit 0
git rev-parse --git-dir >/dev/null 2>&1 || exit 0

# Pick the primary branch: prefer main, fall back to master.
BASE=main
git show-ref --verify --quiet refs/heads/main || BASE=master

HERE=$(git rev-parse --show-toplevel 2>/dev/null || echo "")
# The PRIMARY (main) working tree is the first `git worktree list` entry.
# Identify it by POSITION, never by its branch: a primary drifted onto a
# feature branch is still the primary and must never be bucketed removable
# (git refuses to `worktree remove` it anyway, but the reaper/cleanup must not
# even try — and its dry-run must not list it).
PRIMARY=$(git worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}')

trunc() { # trunc <string> <max>
  local s=$1 n=$2
  if [ "${#s}" -gt "$n" ]; then printf '%s…' "${s:0:$((n-1))}"; else printf '%s' "$s"; fi
}

session_field() { # session_field <locked-reason>
  local locked=$1 pid=""
  [[ "$locked" =~ pid[[:space:]]+([0-9]+) ]] && pid="${BASH_REMATCH[1]}"
  if [ -n "$pid" ]; then
    if kill -0 "$pid" 2>/dev/null; then printf 'live#%s' "$pid"; else printf 'dead-lock#%s' "$pid"; fi
  elif [ -n "$locked" ]; then printf 'locked'
  else printf '—'; fi
}

squash_absorbed() { # squash_absorbed <head> — true (0) when every change
  # <head> ever made is already present in $BASE's tree, even though `git
  # cherry`'s per-commit patch-id matching can't see it. That per-commit
  # matching is blind to a worktree with MULTIPLE local commits whose
  # cumulative diff was squash-merged as a single commit on $BASE: none of
  # the individual commits' patch-ids ever match the one squash commit, so
  # `git cherry` reports them unmerged forever (gr331378).
  #
  # `git merge-tree --write-tree $BASE <head>` performs that merge without
  # touching the index/worktree and prints the resulting tree oid as its
  # first line; if that tree is bit-for-bit identical to $BASE's own tree,
  # <head> contributes nothing beyond what $BASE already has — content
  # equality, not per-commit history. Requires git >= 2.38 (the
  # --write-tree mode); an older git errors on the flag, and any conflict
  # (exit 1) or hard error (exit 2) both fall through the same way — verdict
  # stays exactly what `git cherry` already decided, unchanged from before.
  local head=$1 merged_tree base_tree
  merged_tree=$(git merge-tree --write-tree "$BASE" "$head" 2>/dev/null) || return 1
  merged_tree=${merged_tree%%$'\n'*}   # a conflicted merge appends more sections after a blank line
  base_tree=$(git rev-parse "$BASE^{tree}" 2>/dev/null) || return 1
  [ -n "$merged_tree" ] && [ "$merged_tree" = "$base_tree" ]
}

verdict_field() { # verdict_field <head> <branch>
  local head=$1 branch=$2
  [ "$branch" = "$BASE" ] && { printf '—'; return; }
  if git merge-base --is-ancestor "$head" "$BASE" 2>/dev/null; then printf 'merged'; return; fi
  local behind unmerged
  behind=$(git rev-list --count "$head".."$BASE" 2>/dev/null || echo '?')
  unmerged=$(git cherry "$BASE" "$branch" 2>/dev/null | grep -c '^+')
  # git cherry says unmerged commits remain — before believing it, check
  # whether the branch is squash-absorbed by content (see squash_absorbed).
  if [ "$unmerged" != 0 ] && squash_absorbed "$head"; then unmerged=0; fi
  if [ "$unmerged" = 0 ]; then printf 'in-main↓%s' "$behind"; else printf '↑%s↓%s' "$unmerged" "$behind"; fi
}

purpose_field() { # purpose_field <path> <head> <branch>
  local path=$1 head=$2 branch=$3 pf line s files
  pf="$path/.claude/purpose"
  if [ -s "$pf" ]; then
    line=$(grep -m1 -v '^[[:space:]]*$' "$pf" 2>/dev/null)
    [ -n "$line" ] && { trunc "$line" 58; return; }
  fi
  if [ "$branch" != "$BASE" ] && ! git merge-base --is-ancestor "$head" "$BASE" 2>/dev/null; then
    s=$(git log "$BASE".."$branch" --no-merges --format='%s' 2>/dev/null | head -1)
    [ -n "$s" ] && { trunc "$s" 58; return; }
  fi
  files=$(git -C "$path" status --porcelain --untracked-files=no 2>/dev/null \
            | awk '{print $NF}' | while read -r f; do basename "$f"; done | head -2 | paste -sd, -)
  [ -n "$files" ] && { trunc "(editing ${files}…)" 58; return; }
  [ "$branch" = "$BASE" ] && { printf '—'; return; }
  printf '(no purpose set)'
}

ROWS=()          # emitted, |-delimited
REMOVABLE=()     # names safe to reap (merged + clean + no live session)
JSONROWS=()      # --json only: \x1f-delimited records, joined \x1e at render time

emit() { # emit <path> <head> <branch> <locked>
  local path=$1 head=$2 branch=$3 locked=$4
  [ -z "$path" ] && return
  local name sess dirty untr dirtyf verd purp last mark
  if [ "$branch" = "$BASE" ]; then name=$BASE; else name=$(basename "$path"); fi
  mark=' '; [ "$path" = "$HERE" ] && mark='*'
  sess=$(session_field "$locked")
  dirty=$(git -C "$path" status --porcelain --untracked-files=no 2>/dev/null | grep -c .)
  untr=$(git -C "$path" ls-files --others --exclude-standard 2>/dev/null | grep -c .)
  if [ "$dirty" = 0 ] && [ "$untr" = 0 ]; then dirtyf='clean'
  else
    dirtyf=''
    [ "$dirty" != 0 ] && dirtyf="$dirty"
    [ "$untr" != 0 ] && dirtyf="${dirtyf}+${untr}u"
  fi
  verd=$(verdict_field "$head" "$branch")
  purp=$(purpose_field "$path" "$head" "$branch")
  last=$(trunc "$(git -C "$path" log -1 --format='%cr — %s' 2>/dev/null)" 46)
  ROWS+=("${mark}${name}|${sess}|${dirtyf}|${verd}|${purp}|${last}")
  # removable = merged into main, clean tree, no live session, not the current wt, not main
  if [ "$verd" = 'merged' ] && [ "$dirtyf" = 'clean' ] \
     && [[ "$sess" != live#* ]] && [ "$path" != "$HERE" ] && [ "$path" != "$PRIMARY" ]; then
    REMOVABLE+=("$name")
  fi

  if [ "$JSON_MODE" = 1 ]; then
    local bucket diffstat
    diffstat=''
    if [ "$path" = "$PRIMARY" ] || [ "$branch" = "$BASE" ]; then bucket='base'
    elif [ "$path" = "$HERE" ]; then bucket='self'
    elif [[ "$sess" == live#* ]]; then bucket='live_session'
    elif [ "$dirtyf" != 'clean' ]; then
      bucket='needs_judgment'
      diffstat=$(git -C "$path" diff --stat 2>/dev/null | head -10)
    elif [ "$verd" = 'merged' ] || [[ "$verd" == in-main* ]]; then bucket='safe_remove'
    else bucket='has_unmerged_work'
    fi
    JSONROWS+=("${name}"$'\x1f'"${path}"$'\x1f'"${branch}"$'\x1f'"${bucket}"$'\x1f'"${sess}"$'\x1f'"${dirtyf}"$'\x1f'"${verd}"$'\x1f'"${diffstat}")
  fi
}

# Walk the porcelain records (blank-line separated).
path='' head='' branch='' locked=''
while IFS= read -r wt_line; do
  key=${wt_line%% *}; rest=${wt_line#* }
  [ "$wt_line" = "$key" ] && rest=''   # keyword-only line (e.g. "locked", "detached")
  case "$key" in
    worktree) path=$rest ;;
    HEAD)     head=$rest ;;
    branch)   branch=${rest#refs/heads/} ;;
    detached) branch='(detached)' ;;
    locked)   locked=$rest ;;
    '')       emit "$path" "$head" "$branch" "$locked"; path='' head='' branch='' locked='' ;;
  esac
done < <(git worktree list --porcelain 2>/dev/null)
emit "$path" "$head" "$branch" "$locked"   # trailing record with no blank after it

# --- render ---------------------------------------------------------------
if [ "$JSON_MODE" = 1 ]; then
  STASH_COUNT=$(git stash list 2>/dev/null | grep -c .)
  BLOB=$(IFS=$'\x1e'; printf '%s' "${JSONROWS[*]:-}")
  printf '%s' "$BLOB" | BASE="$BASE" STASH_COUNT="$STASH_COUNT" python3 -c '
import json
import os
import sys

base = os.environ["BASE"]
stash_count = int(os.environ["STASH_COUNT"])
raw = sys.stdin.read()
worktrees = []
for rec in raw.split("\x1e") if raw else []:
    fields = rec.split("\x1f")
    fields += [""] * (8 - len(fields))
    name, path, branch, bucket, session, dirty, verdict, diffstat = fields[:8]
    entry = {
        "name": name,
        "path": path,
        "branch": branch,
        "bucket": bucket,
        "session": session,
        "dirty": dirty,
        "verdict": verdict,
    }
    if diffstat:
        entry["diffstat"] = diffstat
    worktrees.append(entry)
print(json.dumps({"base": base, "stashCount": stash_count, "worktrees": worktrees}, indent=2))
'
  exit 0
fi

if [ "$FOR_HOOK" = 1 ]; then
  echo "🌳 In-flight worktrees on this machine (scan for overlap with your task;"
  echo "   once your task is clear, write one line to .claude/purpose):"
fi
{
  printf '%s\n' "WORKTREE|SESSION|DIRTY|VS-${BASE}|PURPOSE|LAST"
  printf '%s\n' "${ROWS[@]}"
} | column -t -s '|'

if [ "${#REMOVABLE[@]}" -gt 0 ]; then
  echo
  echo "Removable (merged + clean + no live session): ${REMOVABLE[*]}"
  echo "  → review, then: git worktree remove <name> && git branch -d worktree-<name>"
fi
exit 0
