| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159 |
- #!/usr/bin/env bash
- # preflight.sh — Full pre-push gate orchestration.
- #
- # Usage: preflight.sh [--cwd <repo-root>] <remote> <branch>
- # Exit codes:
- # 0 all gates passed; ready to push
- # 1 secret hit (gitleaks or regex)
- # 2 forbidden file added
- # 3 dirty working tree
- # 4 non-ff divergence
- # 5 missing dep (gitleaks / rg)
- # 6 bad invocation (missing remote/branch or unknown remote)
- set -euo pipefail
- # Optional --cwd <path> must come before positional args
- REPO_ROOT=""
- if [ "${1:-}" = "--cwd" ]; then
- REPO_ROOT="${2:?"push-gate: --cwd requires a path argument"}"
- shift 2
- fi
- REMOTE="${1:-}"
- BRANCH="${2:-}"
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
- if [ -z "$REMOTE" ] || [ -z "$BRANCH" ]; then
- echo "push-gate: usage: preflight.sh [--cwd <repo-root>] <remote> <branch>" >&2
- exit 6
- fi
- if [ -n "$REPO_ROOT" ]; then
- cd "$REPO_ROOT"
- fi
- divider() { printf '%.0s─' $(seq 1 63); echo; }
- echo "push-gate preflight :: target = ${REMOTE}/${BRANCH}"
- divider
- # ── Step 1–2: verify remote, fetch ────────────────────────────────────────────
- if ! git remote get-url "$REMOTE" >/dev/null 2>&1; then
- echo "STEP 1 FAIL remote '${REMOTE}' not configured"
- echo " configured remotes:"
- git remote -v | sed 's/^/ /'
- exit 6
- fi
- REMOTE_URL="$(git remote get-url "$REMOTE")"
- echo "STEP 1 OK remote '${REMOTE}' = ${REMOTE_URL}"
- # Reject local-path remotes (use `git push . HEAD:main` pattern directly, no gate needed)
- case "$REMOTE_URL" in
- /*|[A-Za-z]:*|\.*|file:*)
- echo "STEP 1 INFO '${REMOTE}' looks local-filesystem; push-gate is for network remotes"
- echo " proceeding anyway (you can skip the gate for local updateInstead pushes)"
- ;;
- esac
- echo "STEP 2 RUN git fetch ${REMOTE}"
- if ! git fetch "$REMOTE" "$BRANCH" 2>&1 | sed 's/^/ /'; then
- echo "STEP 2 WARN fetch failed; proceeding with cached ${REMOTE}/${BRANCH} ref"
- fi
- # ── Step 3: clean working tree ────────────────────────────────────────────────
- DIRTY="$(git status --porcelain)"
- if [ -n "$DIRTY" ]; then
- echo "STEP 3 FAIL working tree dirty:"
- printf '%s\n' "$DIRTY" | head -20 | sed 's/^/ /'
- exit 3
- fi
- echo "STEP 3 OK working tree clean"
- # ── Step 4: pending commits ───────────────────────────────────────────────────
- if ! git rev-parse --verify "${REMOTE}/${BRANCH}" >/dev/null 2>&1; then
- echo "STEP 4 INFO ${REMOTE}/${BRANCH} does not exist yet (new remote branch)"
- COMMIT_COUNT="$(git rev-list --count "$BRANCH")"
- echo " ${COMMIT_COUNT} commits will be pushed (creating the remote branch)"
- else
- RANGE="${REMOTE}/${BRANCH}..${BRANCH}"
- COMMIT_COUNT="$(git rev-list --count "$RANGE")"
- echo "STEP 4 OK ${COMMIT_COUNT} commits pending"
- if [ "$COMMIT_COUNT" -eq 0 ]; then
- echo " nothing to push; exiting cleanly"
- exit 0
- fi
- git log --oneline "$RANGE" | head -20 | sed 's/^/ /'
- if [ "$COMMIT_COUNT" -gt 20 ]; then
- echo " … and $((COMMIT_COUNT - 20)) more"
- fi
- # ── Step 5: divergence ──────────────────────────────────────────────────────
- BEHIND="$(git rev-list --count "${BRANCH}..${REMOTE}/${BRANCH}")"
- if [ "$BEHIND" -gt 0 ]; then
- echo "STEP 5 FAIL non-ff: ${REMOTE}/${BRANCH} has ${BEHIND} commits not in local ${BRANCH}"
- echo " rebase or merge first: git fetch ${REMOTE} && git rebase ${REMOTE}/${BRANCH}"
- exit 4
- fi
- echo "STEP 5 OK clean fast-forward (local is strictly ahead)"
- fi
- divider
- # ── Step 6: secret scan ───────────────────────────────────────────────────────
- SCAN_EXIT=0
- bash "$SCRIPT_DIR/scan-secrets.sh" "$REMOTE" "$BRANCH" || SCAN_EXIT=$?
- if [ "$SCAN_EXIT" -ne 0 ]; then
- echo "STEP 6 FAIL secret scan (exit=$SCAN_EXIT)"
- exit "$SCAN_EXIT"
- fi
- echo "STEP 6 OK secret scan clean"
- # ── Step 7: forbidden files ───────────────────────────────────────────────────
- # Files that should never ship to a remote. Matched against added-file paths.
- # Gitignore-style patterns would be nicer; for now, a small explicit list.
- 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)$'
- if git rev-parse --verify "${REMOTE}/${BRANCH}" >/dev/null 2>&1; then
- ADDED_FILES="$(git diff --name-only --diff-filter=A "${REMOTE}/${BRANCH}..${BRANCH}")"
- else
- ADDED_FILES="$(git ls-tree -r --name-only "$BRANCH")"
- fi
- FORBIDDEN_HITS="$(printf '%s\n' "$ADDED_FILES" | grep -iE "$FORBIDDEN_REGEX" || true)"
- if [ -n "$FORBIDDEN_HITS" ]; then
- echo "STEP 7 FAIL forbidden files in push:"
- printf '%s\n' "$FORBIDDEN_HITS" | sed 's/^/ /'
- echo " if any are genuinely needed on the remote, remove them from"
- echo " the push (git rm --cached) or relax the FORBIDDEN_REGEX in"
- echo " scripts/preflight.sh — the default is intentionally strict."
- exit 2
- fi
- echo "STEP 7 OK no forbidden file paths"
- # ── Step 8: size advisory ─────────────────────────────────────────────────────
- DIFF_BYTES=0
- if git rev-parse --verify "${REMOTE}/${BRANCH}" >/dev/null 2>&1; then
- DIFF_BYTES="$(git diff --stat="10000,10000,10000" "${REMOTE}/${BRANCH}..${BRANCH}" \
- | tail -1 | awk '{print $4 + $6}' 2>/dev/null || echo 0)"
- fi
- if [ "$COMMIT_COUNT" -gt 50 ]; then
- echo "STEP 8 WARN ${COMMIT_COUNT} commits in one push (>50). Consider whether"
- echo " this should be split into logical pushes for reviewability."
- elif [ "$COMMIT_COUNT" -gt 10 ]; then
- echo "STEP 8 INFO ${COMMIT_COUNT} commits (moderate batch)"
- else
- echo "STEP 8 OK ${COMMIT_COUNT} commits"
- fi
- divider
- echo "push-gate: ALL GATES PASSED"
- echo ""
- echo "Ready to push:"
- echo " git push ${REMOTE} ${BRANCH}"
- echo ""
- echo "push-gate does not execute the push itself. Run it explicitly to"
- echo "preserve 'two-human-steps' separation between gate and action."
- exit 0
|