Skip to content

Instantly share code, notes, and snippets.

@TMDevel89
Forked from vielhuber/!README.MD
Last active April 29, 2026 20:53
Show Gist options
  • Select an option

  • Save TMDevel89/109621f7730575b569a35a4dbb52bae2 to your computer and use it in GitHub Desktop.

Select an option

Save TMDevel89/109621f7730575b569a35a4dbb52bae2 to your computer and use it in GitHub Desktop.
git: auto commit messages #git
modify git message
install
  • mkdir -p ~/git-template/hooks
  • nano ~/git-template/hooks/prepare-commit-msg
  • chmod +x ~/git-template/hooks/prepare-commit-msg
  • git config --global core.hooksPath ~/git-template/hooks
uninstall
  • git config --global --unset core.hooksPath
#!/usr/bin/env bash
#DEBUG=true
# -----------------------------------------------------------------------------
# Git AI Commit Message Hook
# -----------------------------------------------------------------------------
# Template / Source:
# Based on:
# - https://gist.github.com/vielhuber/0d311d3e3743f65bd1f7fa370c3b3848
# - https://vielhuber.de/blog/git-commit-messages-mit-chatgpt/
#
# Automatically generates commit messages using an AI model based on staged
# git diff output. Filters noise (node_modules, vendor, bundles), builds a
# structured prompt (including commit format rules in user.intro), sends it
# to an OpenAI-compatible /chat/completions API, and writes the generated
# commit message back to the Git commit file.
#
# Configuration (via git config):
# git config --global openai.base-url "https://api.openai.com/v1"
# git config --global openai.model "gpt-4o"
# git config --global openai.api-key "YOUR_API_KEY"
# git config --global --type=bool openai.autocommit
#
# Supported /chat/completions APIs:
# https://api.openai.com/v1/chat/completions
# https://api.llm7.io/v1/chat/completions
# https://hermes.ai.unturf.com/v1/chat/completions
# https://openrouter.ai/api/v1/chat/completions
# Custom OpenAI-compatible endpoints (self-hosted or proxy services)
#
# Output:
# - Generates an AI-powered commit message from staged changes
# - Writes the message into the Git commit message file
# - Appends a structured summary of staged changes
# - Opens the commit message editor after generation for final review/editing
#
# Safety Notes:
# - Excludes large/generated directories (node_modules, vendor, bundles)
# - Avoids sending overly large diffs to the API
# - Logs request/response to /tmp for debugging purposes
# - Handles API, curl, and network failures gracefully
# -----------------------------------------------------------------------------
declare -A prompts=(
["system"]="
You are a professional code reviewer.
"
["user.intro"]="
Please review a pull request by summarizing the changes made
with the help of a bullet list of items that have been changed.
Instructions:
- Review the given output of \"git diff\" for the pull request
- Describe only the specific code change that was made, not the code above/below it
- If you cannot identify any changes in the diff, return that no changes have been made
- Lines beginning with ‘-’ have been DELETED.
- Lines beginning with ‘+’ have been ADDED.
- Lines without ‘-’ or ‘+’ are just CONTEXT and have NOT been changed.
- Create a concise commit message that describes ONLY the actual changes.
- Describe only the actual changes (deleted and added lines). Ignore context lines that remain unchanged.
- If only a slight change has been made, don't miss that change
- Use the imperative mood in every bullet list item
- Return always a single bullet list
- Don't mention any sensitive information like passwords
- Finish every bullet list item with a \".\"
- Summarize the overview of the changes made
- Don't mention file names
- Create a bullet list of different items
- The response sentences are no longer than 16 words each
- Keep the response sentences as short as possible
- Don't write any headlines and start with the bullet list
If you have prepared the list and it has more than 3 entries,
then merge the entries so that they add up to exactly 3 entries.
If fewer than 3 items were found, list only 1 or 2.
"
["user.pre"]="
This is the output of \"git diff\":
"
)
# Path to the temporary file where Git expects the commit message
COMMIT_MSG_FILE="$1"
# Commit source/mode passed by Git
# Examples: message, template, merge, squash, etc.
COMMIT_MODE="$2"
# Current content of the commit message file
EXISTING_MSG=$(<"$COMMIT_MSG_FILE")
# Debug log files
DATA_LOG="/tmp/prepare-commit-msg-data.log" # request payload
OUTPUT_LOG="/tmp/prepare-commit-msg-output.log" # API response
DIFF_LOG="/tmp/prepare-commit-msg-diff.log" # staged diff content
CURL_LOG="/tmp/prepare-commit-msg-curl.log" # logs full curl command with arguments
# Network timeout settings
CONNECTION_TIMEOUT=10 # Maximum time to establish connection
REQUEST_TIMEOUT=10 # Total request lifetime (connect + send + receive)
# Path where stderr will be logged in non-debug mode
STDERR_LOG="/tmp/prepare-commit-msg-stderr.log"
# Metadata section line (n '#')
HASHES_LINE="$(printf '%*s' 50 '' | tr ' ' '#')"
# Internal marker used to locate metadata during cleanup operations
META_MARKER=$'\u200b\u200d\u200b'
# Enable strict mode:
# -E : ERR trap is inherited by functions/subshells
# -e : exit immediately on command failure
# -u : treat unset variables as errors
# -o pipefail : pipeline fails if any command fails
#set -Eeuo pipefail
set -Euo pipefail
# On any command error, print a formatted error message with line number and failing command
#trap 'printf "%b" "\n❌ Error at line $LINENO: $BASH_COMMAND"' ERR
# Always run cleanup function on script exit (success or failure)
trap 'stderr_tap_stop' EXIT
# If DEBUG is not set or is explicitly "false"
if [[ "${DEBUG:-false}" == false ]]; then
# Redirect FD 3 to append to the log file
# (FD 3 is an extra file descriptor we use as a reference point)
exec 3>> "$STDERR_LOG"
# Redirect stderr (FD 2) to FD 3
# → all error output goes into /tmp/stderr.log
exec 2>&3
fi
# Load OpenAI git config, validate required values
function get_config() {
printf "%-*s" $STEP_DESCRIPTION_LEN "Read git config"
AI_BASEURL="$(git config --get openai.base-url)"
AI_MODEL="$(git config --get openai.model)"
AI_API_KEY="$(git config --get openai.api-key || echo '')"
if [ -z "$AI_BASEURL" ] || [ -z "$AI_MODEL" ]; then
return 1
fi
}
# Fetch the staged git diff with unified context of 10 lines, no color and strip out all lines longer than 1000 chars
function get_staged_diffs() {
printf "%-*s" $STEP_DESCRIPTION_LEN "Extract staged code changes"
diff=$(
set -o pipefail
git diff \
--unified=10 \
--staged \
--no-color | \
sed \
-e '/^diff --git.*node_modules/,/^diff --git/d' \
-e '/^diff --git.*vendor/,/^diff --git/d' \
-e '/^diff --git.*bundle\.\(js\|css\)/,/^diff --git/d' \
-e '/.\{1000\}./d'
)
rc=$?
# Log the diff for reference
echo "$diff" > "$DIFF_LOG"
# Add diff to prompts array
prompts["user.ai"]="$diff"
return "$rc"
}
function prepare_json_payload() {
printf "%-*s" $STEP_DESCRIPTION_LEN "Prepare paylod"
# Escape special characters for JSON formatting
for k in "${!prompts[@]}"; do
prompts[$k]=$(sed -r 's/\\/\\\\/g' <<< "${prompts[$k]}") # \
prompts[$k]=$(sed -r 's/"/\\\"/g' <<< "${prompts[$k]}") # "
prompts[$k]=$(sed ':a;N;$!ba;s/\n/\\n/g' <<< "${prompts[$k]}") # \n
prompts[$k]=$(sed 's/\t/ /g' <<< "${prompts[$k]}") # tabs
done
# Trim strings
for k in "${!prompts[@]}"; do
if [[ "$k" != "user.ai" ]]; then
prompts[$k]=$(sed -r 's/\\n( +)/\\n/g' <<< "${prompts[$k]}") # Remove blank space
prompts[$k]=$(sed -r 's/^\\n//g' <<< "${prompts[$k]}") # Remove first line break
prompts[$k]=$(sed -r 's/\\n$//g' <<< "${prompts[$k]}") # Remove last line break
fi
done
for k in "${!prompts[@]}"; do
prompts[$k]=$(sed -r '/^[[:space:]]*$/d' <<< "${prompts[$k]}") # Remove blank lines
prompts[$k]=$(sed -e 's/^[[:space:]]*//' -e '/^$/d' <<< "${prompts[$k]}") # Remove blank lines
done
# Prepare the payload for the API request
payload="{
\"model\": \"$AI_MODEL\",
\"messages\": [
{ \"role\": \"system\", \"content\": \"${prompts["system"]}\" },
{ \"role\": \"user\", \"content\": \"${prompts["user.intro"]}\" },
{ \"role\": \"user\", \"content\": \"${prompts["user.pre"]}\" },
{ \"role\": \"user\", \"content\": \"${prompts["user.ai"]}\" }
]
}"
# Save payload to log for debugging purposes
echo "$payload" > "$DATA_LOG" || return 1
}
function do_request() {
printf "%-*s" $STEP_DESCRIPTION_LEN "Execute request"
# Create a unique temp file for response body
tmpfile=$(mktemp) || return 1
# Make the API call using curl and capture the response
AI_BASEURL="$(sed -E 's#(/chat/completions)+$#/chat/completions#' <<< "$AI_BASEURL")"
# Build curl command for AI chat completions request
# (ensures each argument is passed exactly as intended)
curl_args=(
--silent
--output "$tmpfile"
--write-out "%{http_code}"
"$AI_BASEURL/chat/completions"
-H "Content-Type: application/json"
${AI_API_KEY:+-H "Authorization: Bearer $AI_API_KEY"}
--retry 1
--connect-timeout "$CONNECTION_TIMEOUT"
--max-time "$REQUEST_TIMEOUT"
--data-binary "@$DATA_LOG"
)
# Write a human-readable debug version of the command for troubleshooting
# Uses shell-escaped arguments so it can be copy-pasted and re-run
{
echo "curl "
for arg in "${curl_args[@]}"; do
printf ' %q \n' "$arg"
done
} > "$CURL_LOG"
# Execute request to AI chat completions endpoint and capture HTTP status code via curl --write-out
http_code=$(curl "${curl_args[@]}")
#http_code=$(curl --silent --output "$tmpfile" --write-out "%{http_code}" \
# "$AI_BASEURL/chat/completions" \
# -H "Content-Type: application/json" \
# ${ai_api_key:+-H "Authorization: Bearer $AI_API_KEY"} \
# --retry 1 \
# --connect-timeout $CONNECTION_TIMEOUT \
# --max-time $REQUEST_TIMEOUT \
# --data-binary "@$DATA_LOG")
# Capture curl's exit status (0 = success, non-zero = error)
curl_exit=$?
# Read response and delete temp file
response=$(<"$tmpfile") && rm -f "$tmpfile"
# Log the raw response for debugging
echo "$response" > "$OUTPUT_LOG"
# Exit if response contains errors
if [[ $curl_exit -ne 0 || $http_code -ne 200 || $response == *"\"error\": {"* ]]; then
printf "${RED}${RESET} Error calling api (HTTP %s, curl %s)\n" "${http_code:-unknown}" "${curl_exit:-unknown}"
# Prefer curl_exit if it failed, otherwise HTTP code, fallback to 1
rc=$curl_exit
if [[ $rc -eq 0 ]]; then
if [[ $http_code != 200 && $http_code != 000 ]]; then
# Shell return codes should be 0–255 (HTTP code like 404 becomes 404 % 256 = 148)
#rc=$http_code
rc=2
else
rc=1
fi
fi
# Use exit to prevent success or failure status from main
exit ${rc:-1}
fi
}
# Extract the content from the response (handles potential formatting issues)
function extract_response_content() {
printf "%-*s" $STEP_DESCRIPTION_LEN "Extract response"
response_created=$(sed -nr 's/.*"created":[[:space:]]*([0-9]+).*/\1/p' <<< "$response")
response_created=$(date -d @"$response_created" "+%Y-%m-%dT%H:%M:%S%z")
response_object=$(sed -nr 's/.*"object":[[:space:]]*"(([^"\\]|\\.)*)".*/\1/p' <<< "$response")
response_model=$(sed -nr 's/.*"model":[[:space:]]*"(([^"\\]|\\.)*)".*/\1/p' <<< "$response")
response=$(sed -nr 's/.*"content":[[:space:]]*"(([^"\\]|\\.)*)".*/\1/p' <<< "$response")
response=$(sed -r 's/\\n/\n/g' <<< "$response")
response=$(sed -r 's/\\"/"/g' <<< "$response")
response=$(sed -r 's/\\\\/\\/g' <<< "$response")
if [[ -z "$response_created" || -z "$response_object" || -z "$response_model" || -z "$response" ]]; then
return 1
fi
}
if [[ "${DEBUG:-false}" == false ]]; then
# Define cleanup function only when DEBUG is disabled.
# In DEBUG mode, the function is not defined at all.
function delete_debug_files() {
printf "%-*s" $STEP_DESCRIPTION_LEN "Delete debug files"
# Delete debug files
rm -f "$DATA_LOG"
rm -f "$OUTPUT_LOG"
rm -f "$DIFF_LOG"
}
fi
function write_commit() {
printf "%-*s" $STEP_DESCRIPTION_LEN "Write response to ${COMMIT_MSG_FILE#.git/}"
commit_meta=()
commit_meta+=("$HASHES_LINE")
commit_meta+=("Generated with AI - $response_created")
commit_meta+=("API : $AI_BASEURL")
commit_meta+=("Endpoint : $response_object")
commit_meta+=("AI model : $response_model")
commit_meta+=("$HASHES_LINE")
process_commit_meta commit_meta && commit_msg=("${commit_meta[@]}")
commit_msg+=("")
commit_msg+=("$response")
# If commit mode is 'message' => `git commit -m '…'`
if [[ "$COMMIT_MODE" = "message" ]]; then
# If commit message file contains only a '.'
if [[ "$EXISTING_MSG" = "." ]]; then
printf "%b\n" "${commit_msg[@]}" > "$COMMIT_MSG_FILE" || return 1
if [ "$(git config --type=bool --get openai.autocommit 2>/dev/null || echo false)" = "false" ]; then
new_steps=()
for step in "${STEPS[@]}"; do
if [[ "$step" == finalize_commit_msg ]]; then
new_steps+=(open_editor)
fi
new_steps+=("$step")
done
STEPS=("${new_steps[@]}")
fi
fi
# If commit mode is 'commit' => `git commit`
else
# If the first line is empty, then user didn’t use `git commit --amend`
first_line=$(head -n1 "$COMMIT_MSG_FILE")
if [[ -z "$first_line" ]]; then
printf "%b\n" "${commit_msg[@]}" > "$COMMIT_MSG_FILE" || return 1
fi
fi
}
function append_informations() {
EXTENDED=true # include insertions(+) and deletions(-)
printf "%-*s" $STEP_DESCRIPTION_LEN "Append informations to ${COMMIT_MSG_FILE#.git/}"
declare -A states=([M]=modified [A]=added [D]=deleted [R]=renamed [C]=copied)
if [[ "$EXTENDED" == "true" ]]; then
diff=$(
set -o pipefail
git diff \
--numstat \
--staged \
--no-color \
':(exclude)node_modules/*' \
':(exclude)vendor/*' \
':(exclude)*bundle.js' \
':(exclude)*bundle.css' | \
while read add del file; do
status=$(git diff --staged --name-status -- "$file" | awk '{print $1}')
local rc=$?
(( rc != 0 )) && return $rc
status="${states[$status]}"
printf "%-12s +%-4s -%-4s %s\n" "$status" "$add" "$del" "$file"
done
)
rc=$?
else
diff=$(
set -o pipefail
git diff \
--name-status \
--staged \
--no-color \
':(exclude)node_modules/*' \
':(exclude)vendor/*' \
':(exclude)*bundle.js' \
':(exclude)*bundle.css' | \
while read status file; do
status="${states[$status]}:"
printf "%-12s%s\n" "$status" "$file"
done
)
rc=$?
fi
(( rc != 0 )) && return $rc
commit_meta=()
commit_meta+=("$HASHES_LINE")
commit_meta+=("Please check the commit message for your changes. Lines starting")
commit_meta+=("with '#' will be ignored, and an empty message aborts the commit.")
commit_meta+=("")
commit_meta+=("On branch $(git branch --show-current)")
commit_meta+=("Changes to be committed:")
while IFS= read -r line; do
commit_meta+=($'\t'"$line")
done <<< "$diff"
commit_meta+=("")
commit_msg=("")
process_commit_meta commit_meta && commit_msg+=("${commit_meta[@]}")
#commit_meta+=("${info[@]}")
#for i in "${!info[@]}"; do
# [[ -z ${info[$i]} || ${info[$i]:0:1} =~ [[:blank:]] ]] && s='' || s=' '
# info[$i]="\u200b#$s${info[$i]}"
#done
#echo "" >> "$COMMIT_MSG_FILE" || return 1
printf "%b\n" "${commit_msg[@]}" >> "$COMMIT_MSG_FILE" || return 1
}
function finalize_commit_msg() {
printf "%-*s" $STEP_DESCRIPTION_LEN "Finalize ${COMMIT_MSG_FILE#.git/}"
# Delete any line where META_MARKER appears at the beginning (allow leading spaces/tabs)
sed -i "/^[[:space:]]*${META_MARKER}/d" "$COMMIT_MSG_FILE" || return 1
# Remove leading blank lines at the top of the file
sed -i '/./,$!d' "$COMMIT_MSG_FILE" || return 1
# Remove trailing blank lines at the end of the file
sed -i ':a; /^\s*$/{$d; N; ba}' "$COMMIT_MSG_FILE" || return 1
}
if [[ -t 1 && -z "${CI:-}" ]]; then
# Only define open_editor in interactive sessions (TTY) and non-CI environments.
# In CI or non-interactive shells, the function is not defined.
function open_editor() {
# only run in interactive terminal, else return exits with status 0 (success)
[[ -t 1 ]] || return 0
printf "%-*s" $STEP_DESCRIPTION_LEN "Edit ${COMMIT_MSG_FILE#.git/}"
_GIT_EDITOR="$(git var GIT_EDITOR 2>/dev/null)"
[[ "$_GIT_EDITOR" == ":" ]] && _GIT_EDITOR=""
_GIT_EDITOR="${_GIT_EDITOR:-$(git config --get core.editor)}"
_GIT_EDITOR="${_GIT_EDITOR:-$VISUAL}"
_GIT_EDITOR="${_GIT_EDITOR:-$EDITOR}"
_GIT_EDITOR="${_GIT_EDITOR:-vim}"
tput sc # save cursor position
echo -ne "hint: Waiting for your editor to close the file..."
## Problem with this approach
## If GIT_EDITOR contains anything unexpected, e.g `GIT_EDITOR='rm -rf /'`
## -> you now have a shell injection vulnerability
## Even if you "trust" the environment, hooks often run in mixed contexts (IDE, CI, templates).
#( eval "set -- $GIT_EDITOR"; "$@" "$COMMIT_MSG_FILE" )
# Better version (safe + simple)
sh -c "$_GIT_EDITOR \"\$@\"" _ "$COMMIT_MSG_FILE" #2>$STDERR
rc=$?
tput rc # restore cursor position
tput el # clear to end of line
return $rc
}
fi
# Prefix commit meta items with:
# - META_MARKER + optional '#' + optional space based on content rules
function process_commit_meta() {
local -n arr="$1"
for i in "${!arr[@]}"; do
[[ ${arr[$i]} == [![:space:]#]* ]] && s=' ' || s=''
[[ ${arr[$i]} != \#* ]] && c='#' || c=''
arr[$i]="$META_MARKER$c$s${arr[$i]}"
done
}
# Start stderr capture (tap stderr into a log file silently)
# FD2 → file
# FD3 → original terminal stderr backup
function stderr_tap_start() {
# Save original stderr (FD 2 → FD 3)
exec 3>&2
# Init log file if missing
: "${STDERR_FILE:=$(mktemp)}"
# Redirect stderr → terminal + file
#exec 2> >(tee "$STDERR_FILE" >/dev/null)
# Redirect stderr → file (no terminal output)
exec 2> "$STDERR_FILE"
}
# Stop stderr capture and cleanup
# FD2 → restored terminal
# FD3 → closed
function stderr_tap_stop() {
# Restore original stderr
if { true >&3; } 2>/dev/null; then
exec 2>&3
exec 3>&-
fi
# Print captured log
stderr_print
# Cleanup
[[ -s "${STDERR_FILE:-}" ]] && rm -f "$STDERR_FILE"
}
# Print captured stderr log (formatted or raw)
function stderr_print() {
mode="${1:-formatted}"
local RED=$'\e[31m'
local BOLD=$'\e[1m'
local RESET=$'\e[0m'
# Only proceed if log file exists and is not empty
if [[ -s "${STDERR_FILE:-}" ]]; then
# Raw dump of full log
if [[ "$mode" == "raw" ]]; then
cat "$STDERR_FILE" >&2
# Formatted output per line
else
while IFS= read -r line; do
printf "${BOLD}${RED}[ ERROR ]${RESET} %s\n" "$line" >&2
done < "$STDERR_FILE"
fi
fi
}
# ANSI colors for terminal output
GREEN="\e[32m"
RED="\e[31m"
YELLOW="\e[33m"
RESET="\e[0m"
# Fixed width for step description formatting
STEP_DESCRIPTION_LEN=40
# Stop execution if a commit message already exists in "message" mode
if [[ "$COMMIT_MODE" == "message" && "$EXISTING_MSG" != "." ]]; then
exit
fi
# Inform the user that commit generation has started
printf "${YELLOW}${RESET} Automatically generating git commit message... ${YELLOW}${RESET}\n"
# Ordered list of functions to execute
declare -a STEPS
STEPS=(
get_config
get_staged_diffs
prepare_json_payload
do_request
extract_response_content
delete_debug_files
write_commit
append_informations
finalize_commit_msg
)
# Filter STEPS to include only existing functions
function check_functions() {
valid_steps=()
for step in "${STEPS[@]}"; do
declare -f "$step" >/dev/null && valid_steps+=("$step")
done
STEPS=("${valid_steps[@]}")
unset valid_steps
}
cur_step=0
# Run each function in sequence
while (( cur_step < ${#STEPS[@]} )); do
# ensure STEPS contains only valid functions before executing next step
check_functions
step="${STEPS[cur_step++]}"
# Start stderr capture for this step
stderr_tap_start
# Print current step label
printf "➤ %s %2d of %2d:\t" "Step" $cur_step ${#STEPS[@]}
# Execute the step
"$step" #2>/dev/null
rc=$?
# Show success or failure status
[[ ${rc:-1} -eq 0 ]] \
&& printf "%b✔%b done\n" "$GREEN" "$RESET" \
|| printf "%b✖%b failed\n" "$RED" "$RESET"
# Stop stderr capture + print log
stderr_tap_stop
# Exit on error
[[ $rc -ne 0 ]] && exit "$rc"
done
echo ""
cat "$COMMIT_MSG_FILE"
exit "$rc"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment