status.sh 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  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. # --- Output -----------------------------------------------------------------
  85. echo "repo: $REPO_ROOT"
  86. echo "branch: $BRANCH"
  87. echo "HEAD: $HEAD_INFO"
  88. echo "sync: $SYNC_LINE"
  89. TREE_LINE="$STAGED staged / $UNSTAGED unstaged / $UNTRACKED untracked / $STASHES stashes"
  90. if [ -n "$SHORTSTAT" ]; then
  91. TREE_LINE="$TREE_LINE ($SHORTSTAT)"
  92. fi
  93. echo "tree: $TREE_LINE"
  94. # Only show worktrees line if there are multiple registered OR .claude/worktrees exists
  95. if [ "$WT_REGISTERED" -gt 1 ] || [ "$WT_FS" -gt 0 ]; then
  96. echo "trees: $WT_REGISTERED registered / $WT_FS in .claude/worktrees"
  97. fi
  98. echo "branch: $BR_LOCAL local / $BR_REMOTE remote"
  99. if [ -n "$PR_LINE" ]; then
  100. echo "pr: $PR_LINE"
  101. fi
  102. # Fetch failure warning
  103. if [ "$FETCH_OK" = false ]; then
  104. if [ "$fetch_age" -ge 0 ]; then
  105. if [ "$fetch_age" -gt 86400 ]; then age_display="$((fetch_age / 86400))d ago"
  106. elif [ "$fetch_age" -gt 3600 ]; then age_display="$((fetch_age / 3600))h ago"
  107. elif [ "$fetch_age" -gt 60 ]; then age_display="$((fetch_age / 60))m ago"
  108. else age_display="${fetch_age}s ago"
  109. fi
  110. else
  111. age_display="unknown"
  112. fi
  113. echo "fetch: FAILED (last successful: $age_display)"
  114. fi
  115. # --- Verdict ----------------------------------------------------------------
  116. echo ""
  117. if [ "$AHEAD" -eq 0 ] && [ "$BEHIND" -eq 0 ] && \
  118. [ "$STAGED" -eq 0 ] && [ "$UNSTAGED" -eq 0 ] && \
  119. [ "$UNTRACKED" -eq 0 ] && [ "$STASHES" -eq 0 ]; then
  120. echo "verdict: CLEAN"
  121. exit 0
  122. fi
  123. FLAGS=""
  124. [ "$AHEAD" -gt 0 ] && FLAGS="$FLAGS ahead"
  125. [ "$BEHIND" -gt 0 ] && FLAGS="$FLAGS behind"
  126. [ "$STAGED" -gt 0 ] && FLAGS="$FLAGS staged"
  127. [ "$UNSTAGED" -gt 0 ] && FLAGS="$FLAGS unstaged"
  128. [ "$UNTRACKED" -gt 0 ] && FLAGS="$FLAGS untracked"
  129. [ "$STASHES" -gt 0 ] && FLAGS="$FLAGS stashes"
  130. echo "verdict: NON-CLEAN —${FLAGS}"
  131. exit 1