preflight.sh 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. #!/usr/bin/env bash
  2. # preflight.sh — Full pre-push gate orchestration.
  3. #
  4. # Usage: preflight.sh <remote> <branch>
  5. # Exit codes:
  6. # 0 all gates passed; ready to push
  7. # 1 secret hit (gitleaks or regex)
  8. # 2 forbidden file added
  9. # 3 dirty working tree
  10. # 4 non-ff divergence
  11. # 5 missing dep (gitleaks / rg)
  12. # 6 bad invocation (missing remote/branch or unknown remote)
  13. set -euo pipefail
  14. REMOTE="${1:-}"
  15. BRANCH="${2:-}"
  16. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  17. if [ -z "$REMOTE" ] || [ -z "$BRANCH" ]; then
  18. echo "push-gate: usage: preflight.sh <remote> <branch>" >&2
  19. exit 6
  20. fi
  21. divider() { printf '%.0s─' $(seq 1 63); echo; }
  22. echo "push-gate preflight :: target = ${REMOTE}/${BRANCH}"
  23. divider
  24. # ── Step 1–2: verify remote, fetch ────────────────────────────────────────────
  25. if ! git remote get-url "$REMOTE" >/dev/null 2>&1; then
  26. echo "STEP 1 FAIL remote '${REMOTE}' not configured"
  27. echo " configured remotes:"
  28. git remote -v | sed 's/^/ /'
  29. exit 6
  30. fi
  31. REMOTE_URL="$(git remote get-url "$REMOTE")"
  32. echo "STEP 1 OK remote '${REMOTE}' = ${REMOTE_URL}"
  33. # Reject local-path remotes (use `git push . HEAD:main` pattern directly, no gate needed)
  34. case "$REMOTE_URL" in
  35. /*|[A-Za-z]:*|\.*|file:*)
  36. echo "STEP 1 INFO '${REMOTE}' looks local-filesystem; push-gate is for network remotes"
  37. echo " proceeding anyway (you can skip the gate for local updateInstead pushes)"
  38. ;;
  39. esac
  40. echo "STEP 2 RUN git fetch ${REMOTE}"
  41. if ! git fetch "$REMOTE" "$BRANCH" 2>&1 | sed 's/^/ /'; then
  42. echo "STEP 2 WARN fetch failed; proceeding with cached ${REMOTE}/${BRANCH} ref"
  43. fi
  44. # ── Step 3: clean working tree ────────────────────────────────────────────────
  45. DIRTY="$(git status --porcelain)"
  46. if [ -n "$DIRTY" ]; then
  47. echo "STEP 3 FAIL working tree dirty:"
  48. printf '%s\n' "$DIRTY" | head -20 | sed 's/^/ /'
  49. exit 3
  50. fi
  51. echo "STEP 3 OK working tree clean"
  52. # ── Step 4: pending commits ───────────────────────────────────────────────────
  53. if ! git rev-parse --verify "${REMOTE}/${BRANCH}" >/dev/null 2>&1; then
  54. echo "STEP 4 INFO ${REMOTE}/${BRANCH} does not exist yet (new remote branch)"
  55. COMMIT_COUNT="$(git rev-list --count "$BRANCH")"
  56. echo " ${COMMIT_COUNT} commits will be pushed (creating the remote branch)"
  57. else
  58. RANGE="${REMOTE}/${BRANCH}..${BRANCH}"
  59. COMMIT_COUNT="$(git rev-list --count "$RANGE")"
  60. echo "STEP 4 OK ${COMMIT_COUNT} commits pending"
  61. if [ "$COMMIT_COUNT" -eq 0 ]; then
  62. echo " nothing to push; exiting cleanly"
  63. exit 0
  64. fi
  65. git log --oneline "$RANGE" | head -20 | sed 's/^/ /'
  66. if [ "$COMMIT_COUNT" -gt 20 ]; then
  67. echo " … and $((COMMIT_COUNT - 20)) more"
  68. fi
  69. # ── Step 5: divergence ──────────────────────────────────────────────────────
  70. BEHIND="$(git rev-list --count "${BRANCH}..${REMOTE}/${BRANCH}")"
  71. if [ "$BEHIND" -gt 0 ]; then
  72. echo "STEP 5 FAIL non-ff: ${REMOTE}/${BRANCH} has ${BEHIND} commits not in local ${BRANCH}"
  73. echo " rebase or merge first: git fetch ${REMOTE} && git rebase ${REMOTE}/${BRANCH}"
  74. exit 4
  75. fi
  76. echo "STEP 5 OK clean fast-forward (local is strictly ahead)"
  77. fi
  78. divider
  79. # ── Step 6: secret scan ───────────────────────────────────────────────────────
  80. SCAN_EXIT=0
  81. bash "$SCRIPT_DIR/scan-secrets.sh" "$REMOTE" "$BRANCH" || SCAN_EXIT=$?
  82. if [ "$SCAN_EXIT" -ne 0 ]; then
  83. echo "STEP 6 FAIL secret scan (exit=$SCAN_EXIT)"
  84. exit "$SCAN_EXIT"
  85. fi
  86. echo "STEP 6 OK secret scan clean"
  87. # ── Step 7: forbidden files ───────────────────────────────────────────────────
  88. # Files that should never ship to a remote. Matched against added-file paths.
  89. # Gitignore-style patterns would be nicer; for now, a small explicit list.
  90. FORBIDDEN_REGEX='(^|/)\.env(\.|$)|(^|/)\.env\.(local|development|production|test)$|\.(pem|key|pfx|p12|asc|ppk|id_rsa|id_ed25519|id_ecdsa|id_dsa)$|(^|/)\.aws/credentials$|(^|/)\.ssh/(id_|config)|(^|/)\.claude/worktrees/|(^|/)secrets?\.(json|ya?ml|toml|ini)$'
  91. if git rev-parse --verify "${REMOTE}/${BRANCH}" >/dev/null 2>&1; then
  92. ADDED_FILES="$(git diff --name-only --diff-filter=A "${REMOTE}/${BRANCH}..${BRANCH}")"
  93. else
  94. ADDED_FILES="$(git ls-tree -r --name-only "$BRANCH")"
  95. fi
  96. FORBIDDEN_HITS="$(printf '%s\n' "$ADDED_FILES" | grep -iE "$FORBIDDEN_REGEX" || true)"
  97. if [ -n "$FORBIDDEN_HITS" ]; then
  98. echo "STEP 7 FAIL forbidden files in push:"
  99. printf '%s\n' "$FORBIDDEN_HITS" | sed 's/^/ /'
  100. echo " if any are genuinely needed on the remote, remove them from"
  101. echo " the push (git rm --cached) or relax the FORBIDDEN_REGEX in"
  102. echo " scripts/preflight.sh — the default is intentionally strict."
  103. exit 2
  104. fi
  105. echo "STEP 7 OK no forbidden file paths"
  106. # ── Step 8: size advisory ─────────────────────────────────────────────────────
  107. DIFF_BYTES=0
  108. if git rev-parse --verify "${REMOTE}/${BRANCH}" >/dev/null 2>&1; then
  109. DIFF_BYTES="$(git diff --stat="10000,10000,10000" "${REMOTE}/${BRANCH}..${BRANCH}" \
  110. | tail -1 | awk '{print $4 + $6}' 2>/dev/null || echo 0)"
  111. fi
  112. if [ "$COMMIT_COUNT" -gt 50 ]; then
  113. echo "STEP 8 WARN ${COMMIT_COUNT} commits in one push (>50). Consider whether"
  114. echo " this should be split into logical pushes for reviewability."
  115. elif [ "$COMMIT_COUNT" -gt 10 ]; then
  116. echo "STEP 8 INFO ${COMMIT_COUNT} commits (moderate batch)"
  117. else
  118. echo "STEP 8 OK ${COMMIT_COUNT} commits"
  119. fi
  120. divider
  121. echo "push-gate: ALL GATES PASSED"
  122. echo ""
  123. echo "Ready to push:"
  124. echo " git push ${REMOTE} ${BRANCH}"
  125. echo ""
  126. echo "push-gate does not execute the push itself. Run it explicitly to"
  127. echo "preserve 'two-human-steps' separation between gate and action."
  128. exit 0