#!/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 -- ` 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 [path…] # bash scripts/commit-mine.sh --push [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