diff --git a/commands/host/_lib/check-drupal-safe-uninstall.sh b/commands/host/_lib/check-drupal-safe-uninstall.sh new file mode 100644 index 0000000..e06d6db --- /dev/null +++ b/commands/host/_lib/check-drupal-safe-uninstall.sh @@ -0,0 +1,53 @@ +#ddev-generated +#annertech-ddev +# Catch modules removed from core.extension.yml and composer.lock in the same push. +if [[ -n "$EXT_FILE" ]] && git -C "$APPROOT" rev-parse --git-dir >/dev/null 2>&1; then + 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 diff --git a/commands/host/sanity-check b/commands/host/sanity-check index d86e248..130a50a 100755 --- a/commands/host/sanity-check +++ b/commands/host/sanity-check @@ -16,7 +16,15 @@ OFFLINE=false [[ "$*" == *"-o"* || "$*" == *"--offline"* ]] && OFFLINE=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}"; } @@ -31,6 +39,7 @@ CONFIG_READER_MIN=3 ERRORS=0 WARNINGS=0 +CRITICAL_ERRORS=0 APPROOT="${DDEV_APPROOT:-.}" BEHIND_CDN=false CDN_NAME="" @@ -54,6 +63,7 @@ group "DRUPAL" . "$LIB_DIR/check-drupal-version.sh" . "$LIB_DIR/check-drupal-performance.sh" . "$LIB_DIR/check-drupal-extensions.sh" +. "$LIB_DIR/check-drupal-safe-uninstall.sh" . "$LIB_DIR/check-drupal-files-view.sh" . "$LIB_DIR/check-drupal-simplei.sh" . "$LIB_DIR/check-drupal-gin.sh" @@ -85,7 +95,10 @@ fi # ── 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 7f90274..34dbbe6 100755 --- a/scripts/git-hooks/pre-push +++ b/scripts/git-hooks/pre-push @@ -29,8 +29,35 @@ fi # ── Sanity check ───────────────────────────────────────────────────────────── CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) -ddev sanity-check -s -if [[ $? -ne 0 ]]; then + +# Expose the push range so sanity-check can diff only the committed changes. +# pre-push stdin format: +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 + 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."