Project-Template/docs/architecture/githooks/post-commit

92 lines
3.2 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Every commit goes to Forgejo, without anybody having to remember the push.
#
# ## Why a hook rather than a habit
#
# The commit that added `pre-commit` sat unpushed for a day. Nothing was wrong
# with it; it just never got the second command. That is the whole failure this
# closes — work that exists on one laptop and nowhere else is work that is one
# disk away from gone, and it is invisible to anybody reading the tracker.
#
# ## It runs after the commit, and cannot undo one
#
# git ignores this hook's exit code, which is the right shape for the job: a
# network that is down must not cost somebody a commit they already made. So a
# failed push is **reported loudly and left in place** — the commit stands, the
# branch is simply still ahead, and the next commit tries again.
#
# What it will never do is force. A rejected push means the remote has something
# this checkout has not seen, and the fix for that is a human running a pull, not
# a hook overwriting the difference.
#
# ## When it deliberately stays out of the way
#
# - mid-rebase, mid-cherry-pick, mid-am: every step of a rebase fires this
# hook, and pushing an intermediate commit publishes a history that is about
# to be rewritten. Wait for the rebase to finish.
# - detached HEAD: there is no branch to push, and guessing one is worse than
# doing nothing.
# - no `origin`: a clone with no remote is a legitimate state, not an error.
#
# SKIP_PUSH=1 git commit ... commits without publishing, loudly
#
# The counterpart to `SKIP_GUARDS` in the pre-commit hook, and loud for the same
# reason: an exception that leaves no trace becomes a habit.
set -uo pipefail
cd "$(git rev-parse --show-toplevel)" || exit 0
say() { printf '\033[1mpost-commit:\033[0m %s\n' "$*" >&2; }
if [ -n "${SKIP_PUSH:-}" ]; then
say "SKIP_PUSH set — this commit was NOT pushed."
exit 0
fi
git_dir=$(git rev-parse --git-dir)
# A rebase, cherry-pick or `git am` fires this hook once per replayed commit.
# Those commits are provisional by definition.
for marker in rebase-merge rebase-apply CHERRY_PICK_HEAD; do
if [ -e "$git_dir/$marker" ]; then
say "a rebase or cherry-pick is in progress — not pushing until it finishes."
exit 0
fi
done
branch=$(git symbolic-ref --quiet --short HEAD) || {
say "detached HEAD — no branch to push."
exit 0
}
if ! git remote get-url origin >/dev/null 2>&1; then
say "no 'origin' remote — nothing to push to."
exit 0
fi
say "pushing $branch to origin…"
# --porcelain keeps the output to one parseable line per ref rather than the
# usual banner, and the timeout is here because an unreachable SSH host
# otherwise hangs the terminal well past the point of being useful.
if timeout 60 git push --porcelain origin "$branch"; then
exit 0
fi
status=$?
if [ "$status" -eq 124 ]; then
say "push timed out after 60s. The commit is safe locally; push when the"
say " remote is reachable."
else
say "push was refused. The commit is safe locally and the branch is now ahead."
say " If this is a non-fast-forward, pull and reconcile — this hook will"
say " not force, and should not."
fi
# Deliberately zero. git ignores it either way, and returning non-zero here reads
# as though the commit failed when it did not.
exit 0