diff --git a/commands/host/sanity-check b/commands/host/sanity-check index bfaa33c..4d4d558 100755 --- a/commands/host/sanity-check +++ b/commands/host/sanity-check @@ -12,7 +12,15 @@ SILENT=false [[ "$*" == *"-s"* ]] && SILENT=true pass() { $SILENT || echo -e "${GREEN} ✓ $1${NC}"; } fail() { echo -e "${RED} ✗ $1${NC}"; ERRORS=$((ERRORS + 1)); } +critical_fail() { echo -e "${RED}${BOLD} ✗ CRITICAL: $1${NC}"; ERRORS=$((ERRORS + 1)); CRITICAL_ERRORS=$((CRITICAL_ERRORS + 1)); } warn() { echo -e "${YELLOW} ! $1${NC}"; WARNINGS=$((WARNINGS + 1)); } +bail_if_critical() { + if [[ $CRITICAL_ERRORS -gt 0 ]]; then + echo "" + echo -e "${RED}${BOLD}── $CRITICAL_ERRORS critical error(s) — subsequent checks skipped, push must be blocked ──${NC}" + exit 2 + fi +} title() { echo -e "\n── $1 ──"; } group() { echo -e "\n${BOLD}$1${NC}"; } @@ -26,6 +34,7 @@ CONFIG_READER_MIN=3 ERRORS=0 WARNINGS=0 +CRITICAL_ERRORS=0 APPROOT="${DDEV_APPROOT:-.}" BEHIND_CDN=false LOCK_FILE="$APPROOT/composer.lock" @@ -114,6 +123,60 @@ else fi fi +# ── Drupal: Safe module uninstall check ───────────────────────────────────── +if [[ -n "$EXT_FILE" ]] && git -C "$APPROOT" rev-parse --git-dir >/dev/null 2>&1; then + # Pre-push hook sets GIT_PUSH_RANGE_BASE/TIP so we diff the exact commits + # being pushed. Otherwise diff the working tree vs HEAD (uncommitted). + if [[ -n "$GIT_PUSH_RANGE_BASE" ]]; then + DIFF_FROM="$GIT_PUSH_RANGE_BASE" + DIFF_TO="${GIT_PUSH_RANGE_TIP:-HEAD}" + else + DIFF_FROM="HEAD" + DIFF_TO="" + fi + + EXT_DIFF=$(git -C "$APPROOT" diff "$DIFF_FROM" $DIFF_TO -- "$EXT_FILE" 2>/dev/null) + REMOVED_MODULES=$(echo "$EXT_DIFF" | grep -E "^-[[:space:]]+[a-z_0-9]+:[[:space:]]*[0-9]+" | sed -E 's/^-[[:space:]]+([a-z_0-9]+):.*/\1/') + + if [[ -n "$REMOVED_MODULES" ]]; then + LOCK_DIFF="" + [[ -f "$LOCK_FILE" ]] && LOCK_DIFF=$(git -C "$APPROOT" diff "$DIFF_FROM" $DIFF_TO -- "$LOCK_FILE" 2>/dev/null) + + SEARCH_DIRS=() + for d in "$APPROOT/web/modules" "$APPROOT/web/profiles" "$APPROOT/modules"; do + [[ -d "$d" ]] && SEARCH_DIRS+=("$d") + done + + UNSAFE=() + while IFS= read -r mod; do + [[ -z "$mod" ]] && continue + + MOD_PATH="" + if [[ ${#SEARCH_DIRS[@]} -gt 0 ]]; then + MOD_PATH=$(find "${SEARCH_DIRS[@]}" -maxdepth 5 -type d -name "$mod" ! -path "*/.git/*" 2>/dev/null | head -1) + fi + + COMPOSER_REMOVED=false + if [[ -n "$LOCK_DIFF" ]] && echo "$LOCK_DIFF" | grep -qE "^-[[:space:]]+\"name\":[[:space:]]+\"drupal/${mod}\""; then + COMPOSER_REMOVED=true + fi + + if [[ -z "$MOD_PATH" ]] || $COMPOSER_REMOVED; then + UNSAFE+=("$mod") + fi + done <<< "$REMOVED_MODULES" + + if [[ ${#UNSAFE[@]} -gt 0 ]]; then + for m in "${UNSAFE[@]}"; do + critical_fail "Module '$m' is being uninstalled AND its code/package removed in the same change — uninstall first (deploy), then remove code in a later change" + done + else + pass "Module uninstallations keep code/package in place (safe)" + fi + fi +fi +bail_if_critical + # ── Drupal: Files view with operations ────────────────────────────────────── FILES_VIEW="$APPROOT/config/sync/views.view.files.yml" @@ -416,7 +479,10 @@ fi # end Upsun checks # ── Summary ───────────────────────────────────────────────────────────────── echo "" -if [[ $ERRORS -gt 0 ]]; then +if [[ $CRITICAL_ERRORS -gt 0 ]]; then + echo -e "${RED}${BOLD}── $CRITICAL_ERRORS critical error(s), $ERRORS total error(s) — push must be blocked ──${NC}" + exit 2 +elif [[ $ERRORS -gt 0 ]]; then echo -e "${RED}── $ERRORS error(s) found ──${NC}" exit 1 elif [[ $WARNINGS -gt 0 ]]; then diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push index 58cb2bc..a0d55dd 100755 --- a/scripts/git-hooks/pre-push +++ b/scripts/git-hooks/pre-push @@ -22,8 +22,36 @@ fi # ── Sanity check ───────────────────────────────────────────────────────────── CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) -ddev sanity-check -s -if [[ $? -ne 0 ]]; then + +# Expose the range being pushed so sanity-check can diff committed changes. +# Pre-push stdin format (per line): +ZERO_SHA="0000000000000000000000000000000000000000" +PUSH_LINE=$(echo "$PUSH_REFS" | grep -v '^$' | head -1) +if [[ -n "$PUSH_LINE" ]]; then + read -r _ PUSH_LOCAL_SHA _ PUSH_REMOTE_SHA <<< "$PUSH_LINE" + if [[ -n "$PUSH_REMOTE_SHA" && "$PUSH_REMOTE_SHA" != "$ZERO_SHA" ]]; then + export GIT_PUSH_RANGE_BASE="$PUSH_REMOTE_SHA" + else + # New branch: base is the merge-base with the default remote branch. + for ref in "origin/HEAD" "origin/main" "origin/master"; do + if git rev-parse --verify --quiet "$ref" >/dev/null 2>&1; then + MERGE_BASE=$(git merge-base "$ref" "$PUSH_LOCAL_SHA" 2>/dev/null) + [[ -n "$MERGE_BASE" ]] && export GIT_PUSH_RANGE_BASE="$MERGE_BASE" + break + fi + done + fi + [[ -n "$PUSH_LOCAL_SHA" && "$PUSH_LOCAL_SHA" != "$ZERO_SHA" ]] && export GIT_PUSH_RANGE_TIP="$PUSH_LOCAL_SHA" +fi + +SANITY_OUTPUT=$(ddev sanity-check -s 2>&1) +SANITY_EXIT=$? +echo "$SANITY_OUTPUT" +if echo "$SANITY_OUTPUT" | grep -q "CRITICAL:"; then + echo "" + echo "🚫 Push blocked: critical sanity-check failure. This cannot be bypassed — fix the issues above and try again." + exit 1 +elif [[ $SANITY_EXIT -ne 0 ]]; then echo "" if [[ "$CURRENT_BRANCH" == "dev" ]]; then echo "⚠️ Sanity-check failed on 'dev' branch — pushing anyway."