#!/usr/bin/env bash set -euo pipefail # Disable glob expansion to handle brackets in file paths set -f usage() { printf 'Usage: %s [--force] "commit message" "file" ["file" ...]\n' "$(basename "$0")" >&2 exit 2 } if [ "$#" -lt 2 ]; then usage fi force_delete_lock=false if [ "${1:-}" = "--force" ]; then force_delete_lock=true shift fi if [ "$#" -lt 2 ]; then usage fi commit_message=$1 shift if [[ "$commit_message" != *[![:space:]]* ]]; then printf 'Error: commit message must not be empty\n' >&2 exit 1 fi if [ -e "$commit_message" ]; then printf 'Error: first argument looks like a file path ("%s"); provide the commit message first\n' "$commit_message" >&2 exit 1 fi if [ "$#" -eq 0 ]; then usage fi path_exists_or_tracked() { local candidate=$1 [ -e "$candidate" ] || git ls-files --error-unmatch -- "$candidate" >/dev/null 2>&1 } append_normalized_file_arg() { local raw=$1 if path_exists_or_tracked "$raw"; then files+=("$raw") return fi if [[ "$raw" == *$'\n'* || "$raw" == *$'\r'* ]]; then local normalized=${raw//$'\r'/} while IFS= read -r line; do if [[ "$line" == *[![:space:]]* ]]; then files+=("$line") fi done <<< "$normalized" return fi if [[ "$raw" == *[[:space:]]* ]]; then local split_paths=() # Intentional IFS split for callers that pass a single shell-expanded path blob. # shellcheck disable=SC2206 split_paths=($raw) if [ "${#split_paths[@]}" -gt 1 ]; then files+=("${split_paths[@]}") return fi fi files+=("$raw") } files=() for raw_arg in "$@"; do append_normalized_file_arg "$raw_arg" done # Disallow "." because it stages the entire repository and defeats the helper's safety guardrails. for file in "${files[@]}"; do if [ "$file" = "." ]; then printf 'Error: "." is not allowed; list specific paths instead\n' >&2 exit 1 fi done # Prevent staging node_modules even if a path is forced. for file in "${files[@]}"; do case "$file" in *node_modules* | */node_modules | */node_modules/* | node_modules) printf 'Error: node_modules paths are not allowed: %s\n' "$file" >&2 exit 1 ;; esac done last_commit_error='' run_git_command() { local stderr_log stderr_log=$(mktemp) if "$@" 2> >(tee "$stderr_log" >&2); then rm -f "$stderr_log" last_commit_error='' return 0 fi last_commit_error=$(cat "$stderr_log") rm -f "$stderr_log" return 1 } is_git_lock_error() { printf '%s\n' "$last_commit_error" | grep -Eq \ "Another git process seems to be running|Unable to create '.*\\.git/[^']+\\.lock'" } extract_git_lock_path() { printf '%s\n' "$last_commit_error" | sed -n "s/.*'\(.*\.git\/[^']*\.lock\)'.*/\1/p" | head -n 1 } run_git_with_lock_retry() { local label=$1 shift local deadline=$((SECONDS + 5)) local announced_retry=false while true; do if run_git_command "$@"; then return 0 fi if ! is_git_lock_error; then return 1 fi if [ "$SECONDS" -ge "$deadline" ]; then break fi if [ "$announced_retry" = false ]; then printf 'Git lock during %s; retrying for up to 5 seconds...\n' "$label" >&2 announced_retry=true fi sleep 0.5 done if [ "$force_delete_lock" = true ]; then local lock_path lock_path=$(extract_git_lock_path) if [ -n "$lock_path" ] && [ -e "$lock_path" ]; then rm -f "$lock_path" printf 'Removed stale git lock: %s\n' "$lock_path" >&2 run_git_command "$@" return $? fi fi return 1 } for file in "${files[@]}"; do if ! path_exists_or_tracked "$file"; then printf 'Error: file not found: %s\n' "$file" >&2 exit 1 fi done run_git_with_lock_retry "unstaging files" git restore --staged :/ run_git_with_lock_retry "staging files" git add --force -- "${files[@]}" if git diff --staged --quiet; then printf 'Warning: no staged changes detected for: %s\n' "${files[*]}" >&2 exit 1 fi committed=false if run_git_with_lock_retry "commit" git commit -m "$commit_message" -- "${files[@]}"; then committed=true fi if [ "$committed" = false ]; then exit 1 fi printf 'Committed "%s" with %d files\n' "$commit_message" "${#files[@]}"