preflight.sh 6.6 KB

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