215 lines
7.0 KiB
Bash
215 lines
7.0 KiB
Bash
#!/bin/bash
|
|
set -euo pipefail
|
|
|
|
# Always inspect and update the repository's real index. An inherited alternate
|
|
# index could otherwise make the safety check observe different staged content.
|
|
unset GIT_INDEX_FILE
|
|
|
|
CONVENTIONAL_PATTERN='^(feat|fix|ref|refactor|perf|docs|test|build|ci|chore|style|meta|license|revert)(\([A-Za-z0-9._/-]+\))?(!)?: .+'
|
|
MESSAGE="${1:-chore: update code}"
|
|
shift || true
|
|
|
|
validate_message() {
|
|
local subject=$1
|
|
if [[ ! "$subject" =~ $CONVENTIONAL_PATTERN ]]; then
|
|
echo "Commit message must use the conventional '<type>(<scope>): <subject>' format." >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
validate_message "$MESSAGE"
|
|
|
|
PATH_MODE=false
|
|
if [[ "${1:-}" == "--" ]]; then
|
|
PATH_MODE=true
|
|
shift
|
|
elif [[ "$#" -gt 0 ]]; then
|
|
echo "Pass selected paths after an explicit -- separator." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ "$PATH_MODE" == true && "$#" -eq 0 ]]; then
|
|
echo "The -- separator requires at least one path." >&2
|
|
exit 1
|
|
fi
|
|
|
|
BRANCH=$(git symbolic-ref --quiet --short HEAD) || {
|
|
echo "Refusing to commit from a detached HEAD." >&2
|
|
exit 1
|
|
}
|
|
|
|
REF_FORMAT=$(git rev-parse --show-ref-format 2>/dev/null || true)
|
|
if [[ -z "$REF_FORMAT" || "$REF_FORMAT" == "--show-ref-format" ]]; then
|
|
REF_FORMAT=$(git config --get extensions.refStorage || true)
|
|
REF_FORMAT=${REF_FORMAT:-files}
|
|
fi
|
|
if [[ "$REF_FORMAT" != files ]]; then
|
|
echo "Refusing to run with ref backend '$REF_FORMAT'; safe branch locking currently requires the files backend." >&2
|
|
exit 1
|
|
fi
|
|
|
|
PUSH_REMOTE=$(git config --get "branch.$BRANCH.pushRemote" || true)
|
|
if [[ -z "$PUSH_REMOTE" ]]; then
|
|
PUSH_REMOTE=$(git config --get remote.pushDefault || true)
|
|
fi
|
|
FETCH_REMOTE=$(git config --get "branch.$BRANCH.remote" || true)
|
|
MERGE_REF=$(git config --get "branch.$BRANCH.merge" || true)
|
|
if [[ -z "$PUSH_REMOTE" ]]; then
|
|
PUSH_REMOTE=${FETCH_REMOTE:-origin}
|
|
fi
|
|
|
|
if [[ "$PUSH_REMOTE" == "." ]] || ! git remote get-url --push "$PUSH_REMOTE" >/dev/null 2>&1; then
|
|
echo "Configured push remote '$PUSH_REMOTE' does not exist or is not pushable." >&2
|
|
exit 1
|
|
fi
|
|
|
|
PUSH_BRANCH=$BRANCH
|
|
if [[ "$PUSH_REMOTE" == "$FETCH_REMOTE" && -n "$MERGE_REF" ]]; then
|
|
if [[ "$MERGE_REF" != refs/heads/* ]]; then
|
|
echo "Configured upstream '$MERGE_REF' is not a pushable branch ref." >&2
|
|
exit 1
|
|
fi
|
|
PUSH_BRANCH=${MERGE_REF#refs/heads/}
|
|
fi
|
|
|
|
HAS_UPSTREAM=false
|
|
if git rev-parse --verify --quiet '@{upstream}' >/dev/null; then
|
|
HAS_UPSTREAM=true
|
|
fi
|
|
SET_UPSTREAM=false
|
|
if [[ "$HAS_UPSTREAM" == false && "$PUSH_REMOTE" == origin ]]; then
|
|
SET_UPSTREAM=true
|
|
fi
|
|
|
|
GIT_DIR=$(git rev-parse --absolute-git-dir)
|
|
GIT_COMMON_DIR=$(git rev-parse --path-format=absolute --git-common-dir)
|
|
LIVE_INDEX="$GIT_DIR/index"
|
|
LIVE_INDEX_LOCK="$LIVE_INDEX.lock"
|
|
BRANCH_REF="$GIT_COMMON_DIR/refs/heads/$BRANCH"
|
|
BRANCH_REF_LOCK="$BRANCH_REF.lock"
|
|
TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/smart-commit.XXXXXX")
|
|
TEMP_INDEX="$TEMP_DIR/index"
|
|
MESSAGE_FILE="$TEMP_DIR/COMMIT_EDITMSG"
|
|
LOCK_HELD=false
|
|
BRANCH_LOCK_HELD=false
|
|
|
|
cleanup() {
|
|
rm -rf "$TEMP_DIR"
|
|
if [[ "$LOCK_HELD" == true ]]; then
|
|
rm -f "$LIVE_INDEX_LOCK"
|
|
fi
|
|
if [[ "$BRANCH_LOCK_HELD" == true ]]; then
|
|
rm -f "$BRANCH_REF_LOCK"
|
|
fi
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
if ! (set -o noclobber; : > "$LIVE_INDEX_LOCK") 2>/dev/null; then
|
|
echo "The Git index is busy; refusing to race another staging operation." >&2
|
|
exit 1
|
|
fi
|
|
LOCK_HELD=true
|
|
|
|
if ! git diff --cached --quiet; then
|
|
echo "Refusing to commit because the index already contains staged changes." >&2
|
|
echo "Commit or unstage the existing index, then retry with the intended paths." >&2
|
|
exit 1
|
|
fi
|
|
|
|
PARENT=$(git rev-parse --verify HEAD)
|
|
if [[ -f "$LIVE_INDEX" ]]; then
|
|
cp "$LIVE_INDEX" "$TEMP_INDEX"
|
|
else
|
|
GIT_INDEX_FILE="$TEMP_INDEX" git read-tree "$PARENT"
|
|
fi
|
|
if [[ "$PATH_MODE" == true ]]; then
|
|
GIT_INDEX_FILE="$TEMP_INDEX" git add -- "$@"
|
|
else
|
|
GIT_INDEX_FILE="$TEMP_INDEX" git add -A
|
|
fi
|
|
|
|
if GIT_INDEX_FILE="$TEMP_INDEX" git diff --cached --quiet; then
|
|
echo "No changes staged for commit." >&2
|
|
exit 1
|
|
fi
|
|
|
|
EXPECTED_TREE=$(GIT_INDEX_FILE="$TEMP_INDEX" git write-tree)
|
|
printf '%s\n' "$MESSAGE" > "$MESSAGE_FILE"
|
|
|
|
GIT_INDEX_FILE="$TEMP_INDEX" git hook run --ignore-missing pre-commit
|
|
if [[ $(GIT_INDEX_FILE="$TEMP_INDEX" git write-tree) != "$EXPECTED_TREE" ]]; then
|
|
echo "Pre-commit hooks changed the isolated index; review those changes before retrying." >&2
|
|
exit 1
|
|
fi
|
|
|
|
GIT_INDEX_FILE="$TEMP_INDEX" git hook run --ignore-missing prepare-commit-msg -- "$MESSAGE_FILE" message
|
|
GIT_INDEX_FILE="$TEMP_INDEX" git hook run --ignore-missing commit-msg -- "$MESSAGE_FILE"
|
|
if [[ $(GIT_INDEX_FILE="$TEMP_INDEX" git write-tree) != "$EXPECTED_TREE" ]]; then
|
|
echo "Commit hooks changed the isolated index; review those changes before retrying." >&2
|
|
exit 1
|
|
fi
|
|
validate_message "$(head -n 1 "$MESSAGE_FILE")"
|
|
|
|
if [[ $(git config --bool commit.gpgsign || true) == true ]]; then
|
|
CREATED_COMMIT=$(git commit-tree -S "$EXPECTED_TREE" -p "$PARENT" -F "$MESSAGE_FILE")
|
|
else
|
|
CREATED_COMMIT=$(git commit-tree "$EXPECTED_TREE" -p "$PARENT" -F "$MESSAGE_FILE")
|
|
fi
|
|
|
|
if ! git update-ref "refs/heads/$BRANCH" "$CREATED_COMMIT" "$PARENT"; then
|
|
echo "The branch changed concurrently; refusing to replace or push it." >&2
|
|
exit 1
|
|
fi
|
|
|
|
mkdir -p "$(dirname "$BRANCH_REF_LOCK")"
|
|
if ! (set -o noclobber; : > "$BRANCH_REF_LOCK") 2>/dev/null; then
|
|
CURRENT_BRANCH_COMMIT=$(git rev-parse --verify "refs/heads/$BRANCH")
|
|
if [[ "$CURRENT_BRANCH_COMMIT" != "$CREATED_COMMIT" ]]; then
|
|
GIT_INDEX_FILE="$TEMP_INDEX" git read-tree "$CURRENT_BRANCH_COMMIT"
|
|
fi
|
|
cp "$TEMP_INDEX" "$LIVE_INDEX_LOCK"
|
|
mv -f "$LIVE_INDEX_LOCK" "$LIVE_INDEX"
|
|
LOCK_HELD=false
|
|
echo "The branch ref is busy after commit creation; refusing to continue to hooks or push." >&2
|
|
exit 1
|
|
fi
|
|
BRANCH_LOCK_HELD=true
|
|
CURRENT_BRANCH_COMMIT=$(git rev-parse --verify "refs/heads/$BRANCH")
|
|
if [[ "$CURRENT_BRANCH_COMMIT" != "$CREATED_COMMIT" ]]; then
|
|
GIT_INDEX_FILE="$TEMP_INDEX" git read-tree "$CURRENT_BRANCH_COMMIT"
|
|
cp "$TEMP_INDEX" "$LIVE_INDEX_LOCK"
|
|
mv -f "$LIVE_INDEX_LOCK" "$LIVE_INDEX"
|
|
LOCK_HELD=false
|
|
echo "The branch changed before its ref lock was acquired; refusing to push." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if ! GIT_INDEX_FILE="$TEMP_INDEX" git hook run --ignore-missing post-commit; then
|
|
echo "Warning: post-commit hook failed after the commit was created; continuing with a consistent index." >&2
|
|
fi
|
|
CURRENT_BRANCH_COMMIT=$(git rev-parse --verify "refs/heads/$BRANCH")
|
|
if [[ "$CURRENT_BRANCH_COMMIT" != "$CREATED_COMMIT" ]]; then
|
|
GIT_INDEX_FILE="$TEMP_INDEX" git read-tree "$CURRENT_BRANCH_COMMIT"
|
|
cp "$TEMP_INDEX" "$LIVE_INDEX_LOCK"
|
|
mv -f "$LIVE_INDEX_LOCK" "$LIVE_INDEX"
|
|
LOCK_HELD=false
|
|
echo "The branch changed after commit creation; refusing to push another commit." >&2
|
|
exit 1
|
|
fi
|
|
|
|
cp "$TEMP_INDEX" "$LIVE_INDEX_LOCK"
|
|
mv -f "$LIVE_INDEX_LOCK" "$LIVE_INDEX"
|
|
LOCK_HELD=false
|
|
|
|
PUSH_REFSPEC="$CREATED_COMMIT:refs/heads/$PUSH_BRANCH"
|
|
git push "$PUSH_REMOTE" "$PUSH_REFSPEC"
|
|
if [[ "$SET_UPSTREAM" == true ]]; then
|
|
git config "branch.$BRANCH.remote" "$PUSH_REMOTE"
|
|
git config "branch.$BRANCH.merge" "refs/heads/$PUSH_BRANCH"
|
|
fi
|
|
|
|
rm -f "$BRANCH_REF_LOCK"
|
|
BRANCH_LOCK_HELD=false
|
|
|
|
echo "✅ Successfully pushed $BRANCH to $PUSH_REMOTE/$PUSH_BRANCH"
|