Project-Template/docs/architecture/scripts/commit-mine.sh

121 lines
4.3 KiB
Bash
Raw Normal View History

#!/usr/bin/env bash
#
# Commit only the paths you name, when something else is also writing the tree.
#
# ## The failure this catches
#
# Two agents, or an agent and a person, sharing one checkout share one **git
# index**. Staging is global state, and the window between staging a change and
# committing it is however long it takes to write the commit message. Anything
# that runs `git add -A` inside that window takes your files with it.
#
# Two real instances, one afternoon, one repository — both while the rule
# "stage by explicit path, and re-check the index immediately before committing"
# was being followed to the letter:
#
# - A commit about reviving a game carried two unrelated documentation edits.
# - A commit about art carried an entire renderer fix, a new class, its test
# and two documents. Its message had no `closes #N`, so the issue that work
# finished stayed open and had to be closed by hand afterwards.
#
# Nothing was lost either time. The attribution was wrong, and once the tracker
# was wrong with it. **The rule is not the fix, because the danger is the
# window** — so this closes the window instead: the message is written first and
# passed in, staging and scanning and committing happen back to back, and the
# commit itself names its paths.
#
# ## Why a pathspec commit rather than unstaging theirs
#
# `git commit -- <paths>` takes the working-tree content of exactly those paths
# and ignores the rest of the index. The other writer's staged work is neither
# swept into your commit nor removed from their index, so there is no step here
# that can break *their* commit either. What they have staged is reported, so you
# know somebody else is mid-flight before you add to the race.
#
# ## Usage
#
# bash scripts/commit-mine.sh <message-file> <path> [path…]
# bash scripts/commit-mine.sh --push <message-file> <path> [path…]
#
# ## Exit codes
#
# 0 committed (and pushed, with `--push`)
# 1 the secret scan objected, or a push left something unpushed
# 2 nothing was committed: bad arguments, or a path that does not exist.
# **Two is not a pass**
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
SECRETS="$HERE/secrets.sh"
PUSH="no"
if [ "${1:-}" = "--push" ]; then PUSH="yes"; shift; fi
if [ "$#" -lt 2 ]; then
sed -n '2,45p' "$0" >&2
exit 2
fi
MESSAGE_FILE="$1"; shift
[ -f "$MESSAGE_FILE" ] || { echo "commit-mine: no message file: $MESSAGE_FILE" >&2; exit 2; }
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || {
echo "commit-mine: not inside a git repository" >&2
exit 2
}
for path in "$@"; do
[ -e "$path" ] || { echo "commit-mine: no such path: $path" >&2; exit 2; }
done
# Whatever anyone else has in flight. Not an error and not touched — but you
# should see it before committing into the same index.
OTHERS="$(git diff --cached --name-only | grep -vxF -f <(printf '%s\n' "$@") || true)"
if [ -n "$OTHERS" ]; then
echo "commit-mine: NOTE — the index also holds work that is not yours:"
printf ' %s\n' $OTHERS
echo " (left staged, and left out of this commit)"
echo
fi
# Staged only so the scanner sees exactly these paths, and only for as long as
# the scan takes. A credential is the one mistake here that cannot be undone by
# a later commit.
git add -- "$@"
if [ -x "$SECRETS" ] || [ -f "$SECRETS" ]; then
set +e
bash "$SECRETS"
SCAN=$?
set -e
# 2 means it scanned nothing, which the convention in this template treats as
# a failure rather than a pass — a scanner that did not run has not cleared
# anything.
if [ "$SCAN" -ne 0 ]; then
git restore --staged -- "$@"
echo "commit-mine: secret scan exited $SCAN; nothing committed" >&2
exit 1
fi
else
echo "commit-mine: WARNING — $SECRETS not found, committing unscanned" >&2
fi
git commit -F "$MESSAGE_FILE" -- "$@"
echo
echo "commit-mine: committed"
git show --stat --oneline HEAD | head -20
if [ "$PUSH" = "yes" ]; then
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
git push origin "$BRANCH"
# Finished work is pushed work. A local commit is invisible to everything that
# reports on the project, so the push is verified rather than assumed.
UNPUSHED="$(git log --oneline "origin/$BRANCH..HEAD" 2>/dev/null || true)"
if [ -n "$UNPUSHED" ]; then
echo "commit-mine: WARNING — still unpushed after push:" >&2
echo "$UNPUSHED" >&2
exit 1
fi
echo "commit-mine: pushed; origin/$BRANCH..HEAD is empty"
fi