121 lines
4.3 KiB
Bash
Executable File
121 lines
4.3 KiB
Bash
Executable File
#!/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
|