#!/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