status.sh 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. #!/bin/bash
  2. # git-status - one-shot read-only repo overview
  3. #
  4. # Usage:
  5. # bash status.sh # survey current directory
  6. # bash status.sh <repo-path> # survey explicit path
  7. #
  8. # Exit codes:
  9. # 0 CLEAN (nothing ahead/behind, tree empty, no stashes)
  10. # 1 NON-CLEAN (at least one signal non-zero)
  11. # 2 Not a git repo
  12. set -u
  13. REPO="${1:-$PWD}"
  14. # Guard: must be inside a git repo
  15. if ! git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
  16. echo "not-a-repo: $REPO"
  17. exit 2
  18. fi
  19. REPO_ROOT=$(git -C "$REPO" rev-parse --show-toplevel)
  20. cd "$REPO_ROOT" || { echo "cannot-cd: $REPO_ROOT"; exit 2; }
  21. # Best-effort fetch — record failure but don't abort
  22. FETCH_OK=true
  23. git fetch --quiet 2>/dev/null || FETCH_OK=false
  24. # Age of last successful fetch (mtime of FETCH_HEAD)
  25. fetch_age=-1
  26. if [ -f .git/FETCH_HEAD ]; then
  27. if fetch_mtime=$(stat -c '%Y' .git/FETCH_HEAD 2>/dev/null); then
  28. :
  29. elif fetch_mtime=$(stat -f '%m' .git/FETCH_HEAD 2>/dev/null); then
  30. :
  31. else
  32. fetch_mtime=""
  33. fi
  34. if [ -n "$fetch_mtime" ]; then
  35. fetch_age=$(( $(date +%s) - fetch_mtime ))
  36. fi
  37. fi
  38. # Branch / HEAD
  39. BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "(detached)")
  40. HEAD_INFO=$(git log -1 --format='%h %s (%ar)' 2>/dev/null || echo "(no commits)")
  41. # Sync with upstream (if configured)
  42. AHEAD=0
  43. BEHIND=0
  44. if [ "$BRANCH" != "(detached)" ] && git rev-parse '@{u}' >/dev/null 2>&1; then
  45. AHEAD=$(git rev-list --count '@{u}..HEAD' 2>/dev/null || echo 0)
  46. BEHIND=$(git rev-list --count 'HEAD..@{u}' 2>/dev/null || echo 0)
  47. SYNC_LINE="$AHEAD ahead / $BEHIND behind"
  48. else
  49. SYNC_LINE="no upstream"
  50. fi
  51. # Working tree
  52. STAGED=$(git diff --cached --name-only | wc -l | tr -d ' ')
  53. UNSTAGED=$(git diff --name-only | wc -l | tr -d ' ')
  54. UNTRACKED=$(git ls-files --others --exclude-standard | wc -l | tr -d ' ')
  55. STASHES=$(git stash list | wc -l | tr -d ' ')
  56. # Shortstat if there's uncommitted change
  57. SHORTSTAT=""
  58. if [ "$STAGED" -gt 0 ] || [ "$UNSTAGED" -gt 0 ]; then
  59. SHORTSTAT=$(git diff HEAD --shortstat 2>/dev/null \
  60. | sed 's/^ *//' \
  61. | sed -E 's/([0-9]+) files? changed, //' \
  62. | sed -E 's/([0-9]+) insertions?\(\+\)/+\1/' \
  63. | sed -E 's/([0-9]+) deletions?\(-\)/-\1/' \
  64. | tr -d '()')
  65. fi
  66. # Worktrees — registered vs filesystem
  67. WT_REGISTERED=$(git worktree list 2>/dev/null | wc -l | tr -d ' ')
  68. WT_FS=0
  69. if [ -d .claude/worktrees ]; then
  70. WT_FS=$(find .claude/worktrees -maxdepth 1 -mindepth 1 -type d 2>/dev/null | wc -l | tr -d ' ')
  71. fi
  72. # Branches
  73. BR_LOCAL=$(git branch 2>/dev/null | wc -l | tr -d ' ')
  74. BR_REMOTE=$(git branch -r 2>/dev/null | wc -l | tr -d ' ')
  75. # Optional PR linkage (graceful if gh absent or no PR)
  76. PR_LINE=""
  77. if command -v gh >/dev/null 2>&1 && [ "$BRANCH" != "(detached)" ]; then
  78. PR_JSON=$(gh pr view --json number,url,state 2>/dev/null || true)
  79. if [ -n "$PR_JSON" ] && command -v jq >/dev/null 2>&1; then
  80. PR_LINE=$(printf '%s' "$PR_JSON" \
  81. | jq -r 'if .number then "PR #\(.number): \(.url) [\(.state)]" else empty end' 2>/dev/null)
  82. fi
  83. fi
  84. # --- Hygiene checks ---------------------------------------------------------
  85. # Detect if we're in the main checkout or a worktree
  86. GIT_DIR_REL=$(git rev-parse --git-dir 2>/dev/null)
  87. IS_WORKTREE=false
  88. case "$GIT_DIR_REL" in
  89. *worktrees*) IS_WORKTREE=true ;;
  90. esac
  91. # Detect the repo's default branch
  92. DEFAULT_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||')
  93. if [ -z "$DEFAULT_BRANCH" ]; then
  94. for b in main master trunk develop; do
  95. if git show-ref --verify --quiet "refs/heads/$b" 2>/dev/null; then
  96. DEFAULT_BRANCH="$b"
  97. break
  98. fi
  99. done
  100. fi
  101. HYGIENE_FLAGS=""
  102. # Check 1: main checkout on a feature branch
  103. if [ "$IS_WORKTREE" = false ] && \
  104. [ -n "$DEFAULT_BRANCH" ] && \
  105. [ "$BRANCH" != "$DEFAULT_BRANCH" ] && \
  106. [ "$BRANCH" != "(detached)" ]; then
  107. HYGIENE_FLAGS="${HYGIENE_FLAGS}HYGIENE[1]: main checkout is on '$BRANCH' (default: '$DEFAULT_BRANCH') — feature work belongs in worktrees\n"
  108. fi
  109. # Check 2: stale merged branches
  110. if [ -n "$DEFAULT_BRANCH" ]; then
  111. MERGED_COUNT=$(git branch --merged "$DEFAULT_BRANCH" 2>/dev/null \
  112. | grep -v "^\*\|^\s*${DEFAULT_BRANCH}$\|^\s*master$\|^\s*main$\|^\s*trunk$" \
  113. | grep -c . 2>/dev/null || echo 0)
  114. if [ "$MERGED_COUNT" -gt 0 ]; then
  115. HYGIENE_FLAGS="${HYGIENE_FLAGS}HYGIENE[2]: $MERGED_COUNT merged branch(es) not yet deleted — run: git branch --merged $DEFAULT_BRANCH\n"
  116. fi
  117. fi
  118. # --- Output -----------------------------------------------------------------
  119. echo "repo: $REPO_ROOT"
  120. echo "branch: $BRANCH"
  121. echo "HEAD: $HEAD_INFO"
  122. echo "sync: $SYNC_LINE"
  123. TREE_LINE="$STAGED staged / $UNSTAGED unstaged / $UNTRACKED untracked / $STASHES stashes"
  124. if [ -n "$SHORTSTAT" ]; then
  125. TREE_LINE="$TREE_LINE ($SHORTSTAT)"
  126. fi
  127. echo "tree: $TREE_LINE"
  128. # Only show worktrees line if there are multiple registered OR .claude/worktrees exists
  129. if [ "$WT_REGISTERED" -gt 1 ] || [ "$WT_FS" -gt 0 ]; then
  130. echo "trees: $WT_REGISTERED registered / $WT_FS in .claude/worktrees"
  131. fi
  132. echo "branch: $BR_LOCAL local / $BR_REMOTE remote"
  133. if [ -n "$PR_LINE" ]; then
  134. echo "pr: $PR_LINE"
  135. fi
  136. # Hygiene warnings
  137. if [ -n "$HYGIENE_FLAGS" ]; then
  138. echo ""
  139. printf "%b" "$HYGIENE_FLAGS" | sed 's/^/⚠ /'
  140. fi
  141. # Fetch failure warning
  142. if [ "$FETCH_OK" = false ]; then
  143. if [ "$fetch_age" -ge 0 ]; then
  144. if [ "$fetch_age" -gt 86400 ]; then age_display="$((fetch_age / 86400))d ago"
  145. elif [ "$fetch_age" -gt 3600 ]; then age_display="$((fetch_age / 3600))h ago"
  146. elif [ "$fetch_age" -gt 60 ]; then age_display="$((fetch_age / 60))m ago"
  147. else age_display="${fetch_age}s ago"
  148. fi
  149. else
  150. age_display="unknown"
  151. fi
  152. echo "fetch: FAILED (last successful: $age_display)"
  153. fi
  154. # --- Verdict ----------------------------------------------------------------
  155. echo ""
  156. if [ "$AHEAD" -eq 0 ] && [ "$BEHIND" -eq 0 ] && \
  157. [ "$STAGED" -eq 0 ] && [ "$UNSTAGED" -eq 0 ] && \
  158. [ "$UNTRACKED" -eq 0 ] && [ "$STASHES" -eq 0 ]; then
  159. echo "verdict: CLEAN"
  160. exit 0
  161. fi
  162. FLAGS=""
  163. [ "$AHEAD" -gt 0 ] && FLAGS="$FLAGS ahead"
  164. [ "$BEHIND" -gt 0 ] && FLAGS="$FLAGS behind"
  165. [ "$STAGED" -gt 0 ] && FLAGS="$FLAGS staged"
  166. [ "$UNSTAGED" -gt 0 ] && FLAGS="$FLAGS unstaged"
  167. [ "$UNTRACKED" -gt 0 ] && FLAGS="$FLAGS untracked"
  168. [ "$STASHES" -gt 0 ] && FLAGS="$FLAGS stashes"
  169. echo "verdict: NON-CLEAN —${FLAGS}"
  170. exit 1