template-jj/dev/tools/pre-push-guard.sh

74 lines
3.2 KiB
Bash
Executable file

#!/usr/bin/env bash
# Refuse to push dev/ changes to a configured upstream remote.
#
# Installed at .git/hooks/pre-push (symlink). See dev/tools/install-hooks.sh.
#
# Configure by exporting UPSTREAM_BLOCKED_PATTERN to a substring that matches
# the upstream remote's URL. Example: if your upstream is
# git@github.com:upstream-org/repo.git, set
# export UPSTREAM_BLOCKED_PATTERN='upstream-org/repo'
# in your shell profile or .envrc. If unset, this hook is a no-op.
#
# Git invokes this with: $1 = remote name, $2 = remote URL, and on stdin:
# <local_ref> <local_sha> <remote_ref> <remote_sha>\n ...
# Reference: git help githooks (search for "pre-push").
set -euo pipefail
remote_name="${1:-}"
remote_url="${2:-}"
# If no upstream pattern is configured, this guard is a no-op. Greenfield
# projects without an upstream-leak concern can leave the variable unset.
if [ -z "${UPSTREAM_BLOCKED_PATTERN:-}" ]; then
exit 0
fi
# Only guard pushes whose target remote URL contains the configured pattern.
case "$remote_url" in
*"$UPSTREAM_BLOCKED_PATTERN"*) ;;
*) exit 0 ;;
esac
zero="0000000000000000000000000000000000000000"
violation=0
while read -r local_ref local_sha remote_ref remote_sha; do
# Skip branch deletions.
[ "$local_sha" = "$zero" ] && continue
# Determine the set of commits being pushed. On existing branches this is
# deterministic (remote_sha..local_sha). On a first push of this branch to
# this remote (remote_sha all-zeros), prefer the target remote's default
# branch tip as the base; if that doesn't exist, fall back to commits not
# reachable from ANY remote. We enumerate touched paths via `git log
# --name-only` so transient commits that add then remove dev/ paths are
# caught — a `git diff` on endpoints alone would miss those.
if [ "$remote_sha" != "$zero" ]; then
revs=("${remote_sha}..${local_sha}")
elif base=$(git merge-base "$local_sha" "refs/remotes/${remote_name}/main" 2>/dev/null); then
revs=("${base}..${local_sha}")
elif base=$(git merge-base "$local_sha" "refs/remotes/${remote_name}/master" 2>/dev/null); then
revs=("${base}..${local_sha}")
else
# No remote-tracking ref for this target — fall back to commits not
# reachable from any remote. If no remotes are fetched, this degrades
# to "all ancestry of local_sha" which over-reports, but that's the
# safe direction for a guard.
revs=("$local_sha" "--not" "--remotes")
fi
# Enumerate every path touched by any commit in the pushed set.
touched=$(git log --name-only --pretty=format: "${revs[@]}" 2>/dev/null | sort -u)
if echo "$touched" | grep -qE '^dev/'; then
echo "error: refusing to push 'dev/' changes to a remote matching UPSTREAM_BLOCKED_PATTERN" >&2
echo " remote: $remote_url" >&2
echo " pattern match: $UPSTREAM_BLOCKED_PATTERN" >&2
echo " ref: $local_ref -> $remote_ref" >&2
echo " dev/ content is excluded from pushes to this upstream. Scope the push, switch remotes, or unset UPSTREAM_BLOCKED_PATTERN if this was unintended." >&2
violation=1
fi
done
exit "$violation"