tj.git-extras/bin/git-commitiq
2026-09-04 23:59:33 +05:30

1423 lines
46 KiB
Bash

#!/usr/bin/env bash
#
# git-commitiq — semantic commit summaries as a real git subcommand.
#
# Works via git's native plugin resolution: any executable named
# `git-<word>` found on $PATH becomes callable as `git <word>`. No fork
# of git, no core changes, no special registration required.
#
# Flow: `git commitiq -m "..."` runs the real `git commit` first, then
# asks the configured LLM for a structured JSON summary of the diff and
# stores it as a git note on the commit (refs/notes/commits). The first
# commit in a repo silently runs `notes-enable` so git push/fetch also
# sync the notes refs - no manual setup step needed.
set -euo pipefail
CONFIG_DIR="$HOME/.commitiq"
CONFIG_FILE="$CONFIG_DIR/config"
STRICT_RETRY=0
# ---------------------------------------------------------------------------
# Shared utilities
# ---------------------------------------------------------------------------
usage() {
cat <<'EOF'
git commitiq — semantic commit summaries
Usage:
git commitiq [git commit args...] Same as `git commitiq commit ...`
git commitiq commit [git commit args] Run a real git commit, then attach an LLM summary as a git note
git commitiq setup Interactive setup wizard (provider/model/API key)
git commitiq setup --provider <p> --api-key <key> [--model <m>]
Non-interactive setup
git commitiq config get <key> Show a config value (provider|model|api_key)
git commitiq config set <key> <val> Change a config value anytime
git commitiq config unset <key> Remove a config value
git commitiq config list Show current config (API key masked)
git commitiq notes-enable [remote] Configure this repo so 'git push'/'git fetch' also sync git notes
git commitiq notes-enable --quiet Same, but silent (used automatically on your first commit in a repo)
git commitiq push [git push args] Push like 'git push', also syncing git notes (refs/notes/*)
git commitiq show <sha> Print the stored JSON summary from git notes (sha or prefix)
git commitiq log List commits that have a stored summary
git commitiq help Show this message
Examples:
git commitiq -m "fix login bug"
git commitiq commit -am "refactor auth module"
git commitiq setup --provider anthropic --api-key sk-ant-... --model claude-3-5-sonnet-latest
git commitiq config set provider openai
git commitiq notes-enable
git commitiq push origin main
git commitiq show a1b2c3
EOF
}
ensure_git_repo() {
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "fatal: not a git repository (or any of the parent directories): .git" >&2
echo "commitiq: an initialized git repository is required to run this command." >&2
exit 128
fi
}
load_config() {
CFG_PROVIDER=""
CFG_MODEL=""
CFG_API_KEY=""
CFG_ENDPOINT=""
CFG_COMMAND=""
if [ -f "$CONFIG_FILE" ]; then
while IFS= read -r line || [ -n "$line" ]; do
line="$(echo "$line" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
[[ -z "$line" || "$line" =~ ^# ]] && continue
if [[ "$line" =~ ^([^=]+)=(.*)$ ]]; then
key="${BASH_REMATCH[1]}"
val="${BASH_REMATCH[2]}"
key="$(echo "$key" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
val="$(echo "$val" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
case "$key" in
provider) CFG_PROVIDER="$val" ;;
model) CFG_MODEL="$val" ;;
api_key|key|apikey) CFG_API_KEY="$val" ;;
endpoint|endpoint_url) CFG_ENDPOINT="$val" ;;
command|cli_cmd) CFG_COMMAND="$val" ;;
esac
fi
done < "$CONFIG_FILE"
fi
}
save_config() {
mkdir -p "$CONFIG_DIR"
{
[ -n "${CFG_PROVIDER:-}" ] && echo "provider=$CFG_PROVIDER"
[ -n "${CFG_MODEL:-}" ] && echo "model=$CFG_MODEL"
[ -n "${CFG_API_KEY:-}" ] && echo "api_key=$CFG_API_KEY"
[ -n "${CFG_ENDPOINT:-}" ] && echo "endpoint=$CFG_ENDPOINT"
[ -n "${CFG_COMMAND:-}" ] && echo "command=$CFG_COMMAND"
} > "$CONFIG_FILE"
chmod 600 "$CONFIG_FILE" 2>/dev/null || true
}
check_bin_on_path() {
local bin="$1"
[ -z "$bin" ] && return 1
# Fast bash builtin check
if command -v "$bin" >/dev/null 2>&1 \
|| command -v "${bin}.exe" >/dev/null 2>&1 \
|| command -v "${bin}.cmd" >/dev/null 2>&1 \
|| command -v "${bin}.bat" >/dev/null 2>&1; then
return 0
fi
# Well-known AppData / local bin paths
if [ -f "$HOME/AppData/Local/$bin/bin/${bin}.exe" ] \
|| [ -f "$HOME/AppData/Local/$bin/bin/$bin" ] \
|| [ -f "$HOME/.local/bin/$bin" ]; then
return 0
fi
# Pure bash PATH scanner (splits by : or ;)
local path_var="${PATH:-}"
local dir
local save_ifs="$IFS"
IFS=':;'
for dir in $path_var; do
IFS="$save_ifs"
[ -z "$dir" ] && continue
dir="${dir//\\\\//}"
if [ -f "$dir/$bin" ] \
|| [ -f "$dir/${bin}.exe" ] \
|| [ -f "$dir/${bin}.cmd" ] \
|| [ -f "$dir/${bin}.bat" ]; then
return 0
fi
done
IFS="$save_ifs"
return 1
}
mask() {
local val="$1"
if [ -z "$val" ] || [ "$val" = "none" ] || [ "$val" = "not-needed" ]; then
echo "(not needed)"
elif [ "${#val}" -le 8 ]; then
printf '%.0s*' $(seq 1 "${#val}")
echo ""
else
local prefix="${val:0:4}"
local suffix="${val: -4}"
echo "${prefix}...${suffix}"
fi
}
# ---------------------------------------------------------------------------
# LLM integration (reads diff from stdin, outputs JSON summary on stdout)
# ---------------------------------------------------------------------------
resolve_credentials() {
load_config
local forced="${COMMITIQ_PROVIDER:-}"
forced="$(echo "$forced" | tr '[:upper:]' '[:lower:]')"
if [ -n "$forced" ]; then
PROVIDER="$forced"
elif [ -n "${ANTHROPIC_API_KEY:-}" ]; then
PROVIDER="anthropic"
elif [ -n "${OPENAI_API_KEY:-}" ]; then
PROVIDER="openai"
elif [ -n "${GEMINI_API_KEY:-}" ] || [ -n "${GOOGLE_API_KEY:-}" ]; then
PROVIDER="gemini"
elif [ -n "${CFG_PROVIDER:-}" ]; then
PROVIDER="$CFG_PROVIDER"
else
# Auto-detect local CLI tools or local servers
if check_bin_on_path "agy"; then
PROVIDER="cli"
COMMAND="agy --print"
elif check_bin_on_path "antigravity"; then
PROVIDER="cli"
COMMAND="antigravity prompt"
elif check_bin_on_path "claude"; then
PROVIDER="cli"
COMMAND="claude -p"
elif check_bin_on_path "aichat"; then
PROVIDER="cli"
COMMAND="aichat"
elif check_bin_on_path "llm"; then
PROVIDER="cli"
COMMAND="llm"
elif curl -s -m 1 "http://localhost:11434/api/tags" >/dev/null 2>&1; then
PROVIDER="ollama"
else
PROVIDER=""
API_KEY=""
MODEL=""
ENDPOINT=""
COMMAND=""
return 0
fi
fi
case "$PROVIDER" in
anthropic)
API_KEY="${ANTHROPIC_API_KEY:-${CFG_API_KEY:-}}"
MODEL="${COMMITIQ_ANTHROPIC_MODEL:-${CFG_MODEL:-claude-3-5-sonnet-latest}}"
ENDPOINT=""
COMMAND=""
;;
openai)
API_KEY="${OPENAI_API_KEY:-${CFG_API_KEY:-}}"
MODEL="${COMMITIQ_OPENAI_MODEL:-${CFG_MODEL:-gpt-4o-mini}}"
ENDPOINT=""
COMMAND=""
;;
gemini)
API_KEY="${GEMINI_API_KEY:-${GOOGLE_API_KEY:-${CFG_API_KEY:-}}}"
MODEL="${COMMITIQ_GEMINI_MODEL:-${CFG_MODEL:-gemini-2.0-flash}}"
ENDPOINT=""
COMMAND=""
;;
ollama)
API_KEY="none"
MODEL="${CFG_MODEL:-llama3.2}"
ENDPOINT="${CFG_ENDPOINT:-http://localhost:11434}"
COMMAND=""
;;
local)
API_KEY="${CFG_API_KEY:-not-needed}"
MODEL="${CFG_MODEL:-local-model}"
ENDPOINT="${CFG_ENDPOINT:-http://localhost:1234/v1}"
COMMAND=""
;;
cli)
API_KEY="none"
MODEL="${CFG_MODEL:-cli-tool}"
ENDPOINT=""
COMMAND="${CFG_COMMAND:-${COMMAND:-agy --print}}"
;;
*)
API_KEY=""
MODEL=""
ENDPOINT=""
COMMAND=""
;;
esac
}
json_escape() {
awk '
BEGIN { first = 1 }
{
gsub(/\\/, "\\\\")
gsub(/"/, "\\\"")
gsub(/\r/, "")
gsub(/\t/, "\\t")
gsub(/\f/, "\\f")
gsub(/\b/, "\\b")
if (!first) { printf "\\n" }
printf "%s", $0
first = 0
}
'
}
extract_json_text() {
local target_field="$1"
local input
input="$(cat)"
if command -v jq >/dev/null 2>&1; then
local res
res="$(printf '%s' "$input" | jq -r ".${target_field} // empty" 2>/dev/null || true)"
if [ -n "$res" ] && [ "$res" != "null" ]; then
printf '%s\n' "$res"
return 0
fi
fi
if command -v python >/dev/null 2>&1; then
local res
res="$(printf '%s' "$input" | python -c '
import sys, json
try:
data = json.load(sys.stdin)
field = sys.argv[1]
if isinstance(data, dict):
if field in data and data[field] is not None:
v = data[field]
print(v if isinstance(v, str) else json.dumps(v))
sys.exit(0)
choices = data.get("choices", [])
if choices and "message" in choices[0] and "content" in choices[0]["message"]:
print(choices[0]["message"]["content"])
sys.exit(0)
candidates = data.get("candidates", [])
if candidates and "content" in candidates[0]:
parts = candidates[0]["content"].get("parts", [])
if parts and "text" in parts[0]:
print(parts[0]["text"])
sys.exit(0)
if "response" in data:
print(data["response"])
sys.exit(0)
except Exception:
pass
' "$target_field" 2>/dev/null || true)"
if [ -n "$res" ]; then
printf '%s\n' "$res"
return 0
fi
fi
# Fallback to AWK in slurp mode
printf '%s' "$input" | awk -v field="$target_field" '
{ buf = buf (NR>1 ? "\n" : "") $0 }
END {
regex = "\"" field "\"[[:space:]]*:[[:space:]]*\""
if (match(buf, regex)) {
start = RSTART + RLENGTH
rest = substr(buf, start)
val = ""
escaped = 0
for (i = 1; i <= length(rest); i++) {
c = substr(rest, i, 1)
if (escaped) {
if (c == "n") val = val "\n"
else if (c == "r") val = val "\r"
else if (c == "t") val = val "\t"
else if (c == "\"") val = val "\""
else if (c == "\\") val = val "\\"
else val = val c
escaped = 0
} else if (c == "\\") {
escaped = 1
} else if (c == "\"") {
break
} else {
val = val c
}
}
print val
}
}
'
}
# Shared prompt header: expert role, exact schema, field rules, and a
# worked example. Kept in one place so the initial prompt, the strict
# retry prompt, and the CLI path all use identical instructions.
read -r -d '' PROMPT_INTRO <<'COMMITIQ_PROMPT_EOF' || true
You are an expert software engineer writing a Conventional Commit summary for a changelog. You are given a git diff. Reply with ONLY a single valid JSON object and nothing else - no markdown, no code fences, no prose before or after.
EXACT OUTPUT SCHEMA (all keys required):
{"type":"feat|fix|refactor|docs|chore|test|perf|build|ci|revert|style","scope":"optional short scope or empty string","summary":"imperative summary under 60 characters","description":"2-4 sentences on what changed and why it matters","changed_files":["exact file paths from the diff"],"breaking_change":true|false,"review_notes":"anything a reviewer must know, or empty string"}
RULES:
1. Output exactly one JSON object. No markdown fences, no "Here is", no trailing commentary.
2. type: pick the single best conventional-commit type:
- feat: new user-facing feature or capability
- fix: a bug fix
- perf: a measurable performance improvement
- docs: documentation-only change
- refactor: internal change that fixes no bug and adds no feature
- style: formatting, whitespace, or lint-only changes
- test: tests-only change
- build: build system or dependency changes
- ci: CI configuration changes
- chore: maintenance, tooling, or dependency updates
- revert: reverts an earlier change
3. scope: short noun for the affected area (e.g. "auth", "parser"), or "" when none.
4. summary: imperative mood, present tense, under 60 characters, no trailing period. Do not start with "Updated" or "Changed" - start with a verb such as Add, Fix, Refactor, Remove, Improve, Handle, Migrate. Say WHAT, not HOW.
5. description: 2-4 sentences. What changed and why it matters; name the exact files or areas touched, using the paths exactly as they appear in the diff.
6. changed_files: the exact file paths from the diff (full paths as git prints them). Never invent or rename files.
7. breaking_change: true only if existing callers or behavior would break; otherwise false.
8. review_notes: anything a reviewer must know (risks, follow-ups, related work), or "".
9. The object must be valid JSON that jq can parse: escape quotes and backslashes, no trailing commas, no single quotes as string delimiters.
WORKED EXAMPLE
Diff:
--- a/src/login.js
+++ b/src/login.js
@@ -12,6 +12,8 @@
- if (token.expired) { throw new Error("expired"); }
+ if (token.expired) { return { error: "session expired" }; }
+ await refreshSession(token);
Correct response:
{"type":"fix","scope":"auth","summary":"Handle expired session tokens gracefully","description":"Login no longer throws when a session token has expired; it now returns a clear error and refreshes the session. Touches src/login.js.","changed_files":["src/login.js"],"breaking_change":false,"review_notes":"Callers that caught the old exception should handle the new error return value."}
The diff to summarize follows:
COMMITIQ_PROMPT_EOF
build_prompt() {
local diff_text="$1"
if [ "$STRICT_RETRY" = "1" ]; then
printf '%s\n\nYour previous response was not a valid JSON object. Respond AGAIN with ONLY a single JSON object matching the EXACT OUTPUT SCHEMA above - all keys present, valid JSON, no markdown.\n\n%s' "$PROMPT_INTRO" "$diff_text"
else
printf '%s\n\n%s' "$PROMPT_INTRO" "$diff_text"
fi
}
# Keeps only the JSON object: strips markdown fences and any prose the
# model may have wrapped around the object, then normalizes to one line.
normalize_json() {
local raw="$1"
[ -z "$raw" ] && return 1
# strip CR (Windows CLI tools may emit CRLF) - breaks jq and strict parsers
raw="$(printf '%s' "$raw" | tr -d '\r')"
# strip ```json / ``` fences
raw="$(printf '%s' "$raw" | sed -e 's/^[[:space:]]*```[a-zA-Z0-9]*//' -e 's/```[[:space:]]*$//')"
# keep only from the first '{' to the last '}'
raw="$(printf '%s' "$raw" | awk '
{ buf = buf $0 "\n" }
END {
f = index(buf, "{")
l = 0
for (i = length(buf); i >= 1; i--) {
if (substr(buf, i, 1) == "}") { l = i; break }
}
if (f > 0 && l > f) print substr(buf, f, l - f + 1)
}
')"
printf '%s' "$raw"
}
is_valid_json() {
local s="$1"
[ -z "$s" ] && return 1
case "$s" in
"{"*"}" ) ;;
*) return 1 ;;
esac
if command -v jq >/dev/null 2>&1; then
printf '%s' "$s" | jq -e '
type == "object"
and (["feat", "fix", "refactor", "docs", "chore", "test", "perf", "build", "ci", "revert", "style"] | index(.type) != null)
and (has("summary") and has("description") and has("changed_files") and has("breaking_change") and has("review_notes"))
and (.changed_files | type == "array")
' >/dev/null 2>&1
elif command -v python >/dev/null 2>&1; then
printf '%s' "$s" | python -c '
import sys, json
try:
d = json.load(sys.stdin)
valid_types = {"feat", "fix", "refactor", "docs", "chore", "test", "perf", "build", "ci", "revert", "style"}
req_keys = {"type", "summary", "description", "changed_files", "breaking_change", "review_notes"}
if isinstance(d, dict) and req_keys.issubset(d.keys()) and d.get("type") in valid_types and isinstance(d.get("changed_files"), list):
sys.exit(0)
sys.exit(1)
except Exception:
sys.exit(1)
' >/dev/null 2>&1
else
printf '%s' "$s" | grep -q '"type"' || return 1
printf '%s' "$s" | grep -q '"summary"' || return 1
printf '%s' "$s" | grep -q '"description"' || return 1
printf '%s' "$s" | grep -Eq '"(feat|fix|refactor|docs|chore|test|perf|build|ci|revert|style)"' || return 1
fi
}
call_anthropic() {
local raw_diff="$1"
local api_key="$2"
local model="$3"
local prompt prompt_escaped payload response status_code body
prompt="$(build_prompt "$raw_diff")"
prompt_escaped="$(printf '%s' "$prompt" | json_escape)"
payload="{\"model\":\"${model}\",\"max_tokens\":500,\"messages\":[{\"role\":\"user\",\"content\":\"${prompt_escaped}\"}]}"
response="$(curl -s -w "\nHTTP_STATUS:%{http_code}" -X POST "https://api.anthropic.com/v1/messages" \
-H "content-type: application/json" \
-H "x-api-key: ${api_key}" \
-H "anthropic-version: 2023-06-01" \
-d "$payload")"
status_code="$(echo "$response" | grep "HTTP_STATUS:" | cut -d':' -f2)"
body="$(echo "$response" | sed '/HTTP_STATUS:/d')"
if [ "$status_code" -ne 200 ]; then
echo "commitiq: anthropic API error $status_code: $(echo "$body" | head -n 5)" >&2
exit 1
fi
echo "$body" | extract_json_text "text"
}
call_openai() {
local raw_diff="$1"
local api_key="$2"
local model="$3"
local prompt prompt_escaped payload response status_code body
prompt="$(build_prompt "$raw_diff")"
prompt_escaped="$(printf '%s' "$prompt" | json_escape)"
payload="{\"model\":\"${model}\",\"max_tokens\":500,\"messages\":[{\"role\":\"user\",\"content\":\"${prompt_escaped}\"}]}"
response="$(curl -s -w "\nHTTP_STATUS:%{http_code}" -X POST "https://api.openai.com/v1/chat/completions" \
-H "content-type: application/json" \
-H "authorization: Bearer ${api_key}" \
-d "$payload")"
status_code="$(echo "$response" | grep "HTTP_STATUS:" | cut -d':' -f2)"
body="$(echo "$response" | sed '/HTTP_STATUS:/d')"
if [ "$status_code" -ne 200 ]; then
echo "commitiq: openai API error $status_code: $(echo "$body" | head -n 5)" >&2
exit 1
fi
echo "$body" | extract_json_text "content"
}
call_gemini() {
local raw_diff="$1"
local api_key="$2"
local model="$3"
local prompt prompt_escaped payload response status_code body
prompt="$(build_prompt "$raw_diff")"
prompt_escaped="$(printf '%s' "$prompt" | json_escape)"
payload="{\"contents\":[{\"parts\":[{\"text\":\"${prompt_escaped}\"}]}]}"
response="$(curl -s -w "\nHTTP_STATUS:%{http_code}" -X POST "https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${api_key}" \
-H "content-type: application/json" \
-d "$payload")"
status_code="$(echo "$response" | grep "HTTP_STATUS:" | cut -d':' -f2)"
body="$(echo "$response" | sed '/HTTP_STATUS:/d')"
if [ "$status_code" -ne 200 ]; then
echo "commitiq: gemini API error $status_code: $(echo "$body" | head -n 5)" >&2
exit 1
fi
echo "$body" | extract_json_text "text"
}
call_ollama() {
local raw_diff="$1"
local model="$2"
local endpoint="${3:-http://localhost:11434}"
local prompt prompt_escaped payload response status_code body
prompt="$(build_prompt "$raw_diff")"
prompt_escaped="$(printf '%s' "$prompt" | json_escape)"
payload="{\"model\":\"${model}\",\"prompt\":\"${prompt_escaped}\",\"stream\":false}"
response="$(curl -s -w "\nHTTP_STATUS:%{http_code}" -X POST "${endpoint}/api/generate" \
-H "content-type: application/json" \
-d "$payload")"
status_code="$(echo "$response" | grep "HTTP_STATUS:" | cut -d':' -f2)"
body="$(echo "$response" | sed '/HTTP_STATUS:/d')"
if [ "$status_code" -ne 200 ]; then
echo "commitiq: ollama API error $status_code: $(echo "$body" | head -n 5)" >&2
exit 1
fi
echo "$body" | extract_json_text "response"
}
call_local() {
local raw_diff="$1"
local api_key="$2"
local model="$3"
local endpoint="${4:-http://localhost:1234/v1}"
local prompt prompt_escaped payload response status_code body
prompt="$(build_prompt "$raw_diff")"
prompt_escaped="$(printf '%s' "$prompt" | json_escape)"
payload="{\"model\":\"${model}\",\"max_tokens\":500,\"messages\":[{\"role\":\"user\",\"content\":\"${prompt_escaped}\"}]}"
response="$(curl -s -w "\nHTTP_STATUS:%{http_code}" -X POST "${endpoint}/chat/completions" \
-H "content-type: application/json" \
-H "authorization: Bearer ${api_key:-not-needed}" \
-d "$payload")"
status_code="$(echo "$response" | grep "HTTP_STATUS:" | cut -d':' -f2)"
body="$(echo "$response" | sed '/HTTP_STATUS:/d')"
if [ "$status_code" -ne 200 ]; then
echo "commitiq: local API error $status_code: $(echo "$body" | head -n 5)" >&2
exit 1
fi
echo "$body" | extract_json_text "content"
}
call_cli() {
local raw_diff="$1"
local cli_cmd="${2:-agy --print}"
local bin_name
bin_name="$(echo "$cli_cmd" | awk '{print $1}')"
# Alias 'antigravity' to 'agy' if 'antigravity' doesn't exist but 'agy' does
if [ "$bin_name" = "antigravity" ] && ! check_bin_on_path "antigravity" && check_bin_on_path "agy"; then
cli_cmd="agy $(echo "$cli_cmd" | cut -d' ' -f2-)"
fi
local prompt err_tmp res status
prompt="$(build_prompt "$raw_diff")"
err_tmp="$(mktemp 2>/dev/null || echo "/tmp/commitiq_cli_err_$$")"
# Export prompt in environment variable so eval can pass it cleanly as an argument
export COMMITIQ_PROMPT="$prompt"
# If command ends with a flag/subcommand expecting a prompt argument (e.g., --print, -p, prompt), pass it as argument
if [[ "$cli_cmd" =~ (--print|-p|prompt)[[:space:]]*$ ]]; then
res="$(eval "$cli_cmd \"\$COMMITIQ_PROMPT\"" 2>"$err_tmp")" || status=$?
else
# Try piping via stdin first
res="$(printf '%s\n' "$prompt" | eval "$cli_cmd" 2>"$err_tmp")" || status=$?
# Fallback to passing as argument if stdin fails or returns empty
if [ "${status:-0}" -ne 0 ] || [ -z "$res" ]; then
status=0
res="$(eval "$cli_cmd \"\$COMMITIQ_PROMPT\"" 2>"$err_tmp")" || status=$?
fi
fi
status="${status:-0}"
if [ "$status" -ne 0 ] || [ -z "$res" ]; then
if [ -s "$err_tmp" ]; then
echo "commitiq: CLI command '$cli_cmd' failed (exit code $status):" >&2
cat "$err_tmp" >&2
fi
fi
rm -f "$err_tmp" 2>/dev/null || true
unset COMMITIQ_PROMPT 2>/dev/null || true
printf '%s' "$res"
}
# Reads diff from stdin, outputs JSON summary on stdout.
summarize_diff() {
PROVIDER=""
API_KEY=""
MODEL=""
ENDPOINT=""
COMMAND=""
resolve_credentials
if [ -z "$PROVIDER" ]; then
echo "commitiq: no provider configured and no local CLI tool / LLM found. Run 'git commitiq setup'." >&2
exit 1
fi
local raw_diff
raw_diff="$(cat)"
if [ -z "$(echo "$raw_diff" | tr -d '[:space:]')" ]; then
echo "commitiq: empty diff, nothing to summarize" >&2
exit 1
fi
STRICT_RETRY=0
local attempt=1
while [ "$attempt" -le 2 ]; do
local summary=""
if [ "$PROVIDER" = "cli" ]; then
summary="$(call_cli "$raw_diff" "$COMMAND")" || summary=""
else
case "$PROVIDER" in
anthropic) summary="$(call_anthropic "$raw_diff" "$API_KEY" "$MODEL")" || summary="" ;;
openai) summary="$(call_openai "$raw_diff" "$API_KEY" "$MODEL")" || summary="" ;;
gemini) summary="$(call_gemini "$raw_diff" "$API_KEY" "$MODEL")" || summary="" ;;
ollama) summary="$(call_ollama "$raw_diff" "$MODEL" "$ENDPOINT")" || summary="" ;;
local) summary="$(call_local "$raw_diff" "$API_KEY" "$MODEL" "$ENDPOINT")" || summary="" ;;
*)
echo "commitiq: unknown provider '$PROVIDER'" >&2
exit 1
;;
esac
fi
if [ -z "$summary" ]; then
# empty output = transport/API error (or empty model response) -
# retrying would just repeat the same failing call, so stop here.
break
fi
summary="$(normalize_json "$summary" || true)"
if is_valid_json "$summary"; then
echo "$summary"
exit 0
fi
# Non-empty but not valid JSON (model returned prose) - retry once
# with stricter instructions.
if [ "$attempt" -eq 1 ]; then
STRICT_RETRY=1
echo "commitiq: $PROVIDER response was not valid JSON - retrying once with stricter instructions" >&2
fi
attempt=$((attempt + 1))
done
echo "commitiq: $PROVIDER did not produce a valid JSON summary" >&2
exit 1
}
# ---------------------------------------------------------------------------
# Config management
# ---------------------------------------------------------------------------
validate_credentials() {
local provider="$1"
local api_key="$2"
local model="$3"
local endpoint="${4:-}"
local cli_cmd="${5:-}"
echo "[commitiq] validating configuration for '$provider'..." >&2
local response=""
local payload=""
case "$provider" in
anthropic)
payload="{\"model\":\"${model}\",\"max_tokens\":1,\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}]}"
response="$(curl -s -w "\nHTTP_STATUS:%{http_code}" -X POST "https://api.anthropic.com/v1/messages" \
-H "content-type: application/json" \
-H "x-api-key: ${api_key}" \
-H "anthropic-version: 2023-06-01" \
-d "$payload")"
;;
openai)
payload="{\"model\":\"${model}\",\"max_tokens\":1,\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}]}"
response="$(curl -s -w "\nHTTP_STATUS:%{http_code}" -X POST "https://api.openai.com/v1/chat/completions" \
-H "content-type: application/json" \
-H "authorization: Bearer ${api_key}" \
-d "$payload")"
;;
gemini)
payload="{\"contents\":[{\"parts\":[{\"text\":\"ping\"}]}]}"
response="$(curl -s -w "\nHTTP_STATUS:%{http_code}" -X POST "https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${api_key}" \
-H "content-type: application/json" \
-d "$payload")"
;;
ollama)
local host="${endpoint:-http://localhost:11434}"
payload="{\"model\":\"${model}\",\"prompt\":\"ping\",\"stream\":false}"
response="$(curl -s -w "\nHTTP_STATUS:%{http_code}" -X POST "${host}/api/generate" \
-H "content-type: application/json" \
-d "$payload")"
;;
local)
local host="${endpoint:-http://localhost:1234/v1}"
payload="{\"model\":\"${model}\",\"max_tokens\":1,\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}]}"
response="$(curl -s -w "\nHTTP_STATUS:%{http_code}" -X POST "${host}/chat/completions" \
-H "content-type: application/json" \
-H "authorization: Bearer ${api_key:-not-needed}" \
-d "$payload")"
;;
cli)
local bin_name
bin_name="$(echo "$cli_cmd" | awk '{print $1}')"
if [ -z "$bin_name" ]; then
echo "[commitiq] validation failed: no CLI command provided" >&2
return 1
fi
if check_bin_on_path "$bin_name"; then
echo "[commitiq] CLI tool '$bin_name' found on system PATH!" >&2
elif [ "$bin_name" = "antigravity" ] && check_bin_on_path "agy"; then
echo "[commitiq] Antigravity CLI binary 'agy' found on system PATH!" >&2
cli_cmd="agy $(echo "$cli_cmd" | cut -d' ' -f2-)"
elif [ "$bin_name" = "agy" ] && check_bin_on_path "antigravity"; then
echo "[commitiq] Antigravity CLI binary 'antigravity' found on system PATH!" >&2
else
echo "[commitiq] validation failed: CLI command '$bin_name' not found on PATH" >&2
return 1
fi
echo "[commitiq] validation successful for '$cli_cmd'" >&2
return 0
;;
*)
echo "commitiq: unknown provider '$provider'" >&2
return 1
;;
esac
local status_code
status_code="$(echo "$response" | grep "HTTP_STATUS:" | cut -d':' -f2)"
local body
body="$(echo "$response" | sed '/HTTP_STATUS:/d')"
if [ "$status_code" -ne 200 ]; then
echo "[commitiq] validation failed (HTTP status $status_code)" >&2
local err_snippet
err_snippet="$(echo "$body" | head -n 3)"
if [ -n "$err_snippet" ]; then
echo "$err_snippet" >&2
fi
return 1
fi
echo "[commitiq] connection validated successfully!" >&2
return 0
}
cmd_setup() {
load_config
local provider=""
local api_key=""
local model=""
local endpoint=""
local cli_cmd=""
local skip_verify=0
while [[ $# -gt 0 ]]; do
case "$1" in
--provider)
provider="$2"
shift 2
;;
--api-key)
api_key="$2"
shift 2
;;
--model)
model="$2"
shift 2
;;
--endpoint|--endpoint-url)
endpoint="$2"
shift 2
;;
--command|--cli-cmd)
cli_cmd="$2"
shift 2
;;
--skip-verify|--no-verify)
skip_verify=1
shift 1
;;
*)
echo "commitiq: unknown setup flag '$1'" >&2
exit 1
;;
esac
done
local interactive=0
if [ -z "$provider" ] && [ -z "$api_key" ]; then
interactive=1
fi
if [ -z "$provider" ]; then
echo "commitiq setup — choose a provider:"
echo " 1) anthropic"
echo " 2) openai"
echo " 3) gemini"
echo " 4) ollama (local LLM server - no API key needed)"
echo " 5) local (custom local endpoint, e.g. LM Studio / LocalAI)"
echo " 6) cli (installed local CLI tool, e.g. agy / antigravity, claude, aichat, llm)"
read -r -p "Provider [1-6]: " choice < /dev/tty || choice=""
case "$choice" in
1) provider="anthropic" ;;
2) provider="openai" ;;
3) provider="gemini" ;;
4) provider="ollama" ;;
5) provider="local" ;;
6) provider="cli" ;;
*) provider="$(echo "$choice" | tr '[:upper:]' '[:lower:]')" ;;
esac
fi
case "$provider" in
anthropic|openai|gemini|ollama|local|cli) ;;
*)
echo "commitiq: unknown provider '$provider' (expected anthropic, gemini, openai, ollama, local, cli)" >&2
exit 1
;;
esac
if [ "$provider" = "ollama" ]; then
api_key="${api_key:-none}"
endpoint="${endpoint:-http://localhost:11434}"
elif [ "$provider" = "local" ]; then
api_key="${api_key:-not-needed}"
if [ -z "$endpoint" ] && [ "$interactive" -eq 1 ]; then
read -r -p "Local server endpoint [http://localhost:1234/v1]: " entered_ep < /dev/tty || entered_ep=""
endpoint="${entered_ep:-http://localhost:1234/v1}"
else
endpoint="${endpoint:-http://localhost:1234/v1}"
fi
elif [ "$provider" = "cli" ]; then
api_key="${api_key:-not-needed}"
if [ -z "$cli_cmd" ] && [ "$interactive" -eq 1 ]; then
local default_cmd="agy --print"
if check_bin_on_path "agy"; then
default_cmd="agy --print"
elif check_bin_on_path "antigravity"; then
default_cmd="antigravity prompt"
elif check_bin_on_path "claude"; then
default_cmd="claude -p"
fi
read -r -p "CLI Command [$default_cmd]: " entered_cmd < /dev/tty || entered_cmd=""
cli_cmd="${entered_cmd:-$default_cmd}"
else
cli_cmd="${cli_cmd:-agy --print}"
fi
# Automatically replace 'antigravity' with 'agy' if 'antigravity' binary doesn't exist but 'agy' does
local bin_name
bin_name="$(echo "$cli_cmd" | awk '{print $1}')"
if [ "$bin_name" = "antigravity" ] && ! check_bin_on_path "antigravity" && check_bin_on_path "agy"; then
cli_cmd="agy $(echo "$cli_cmd" | cut -d' ' -f2-)"
fi
model="${model:-cli-tool}"
else
if [ -z "$api_key" ]; then
read -r -s -p "$provider API key (input hidden): " api_key < /dev/tty || api_key=""
echo "" >&2
fi
if [ -z "$api_key" ]; then
echo "commitiq: no API key given, aborting setup" >&2
exit 1
fi
fi
if [ -z "$model" ]; then
local default_model=""
case "$provider" in
anthropic) default_model="claude-3-5-sonnet-latest" ;;
openai) default_model="gpt-4o-mini" ;;
gemini) default_model="gemini-2.0-flash" ;;
ollama) default_model="llama3.2" ;;
local) default_model="local-model" ;;
cli) default_model="cli-tool" ;;
*) default_model="default-model" ;;
esac
if [ "$interactive" -eq 1 ]; then
read -r -p "Model [$default_model]: " entered < /dev/tty || entered=""
model="${entered:-$default_model}"
else
model="$default_model"
fi
fi
if [ "$skip_verify" -eq 0 ]; then
if ! validate_credentials "$provider" "$api_key" "$model" "$endpoint" "$cli_cmd"; then
echo "commitiq: validation failed — configuration not saved." >&2
echo "commitiq: check your configuration, or run with --skip-verify to force save." >&2
exit 1
fi
fi
CFG_PROVIDER="$provider"
CFG_API_KEY="$api_key"
CFG_MODEL="$model"
CFG_ENDPOINT="$endpoint"
CFG_COMMAND="$cli_cmd"
save_config
echo "commitiq: saved config to $CONFIG_FILE (provider=$provider, model=$model)"
}
cmd_get() {
if [ $# -lt 1 ]; then
echo "usage: git commitiq config get <provider|model|api_key|endpoint|command>" >&2
exit 1
fi
load_config
local key="$1"
case "$key" in
provider) echo "${CFG_PROVIDER:-"(not set)"}" ;;
model) echo "${CFG_MODEL:-"(not set)"}" ;;
api_key|key|apikey) mask "${CFG_API_KEY:-}" ;;
endpoint|endpoint_url) echo "${CFG_ENDPOINT:-"(not set)"}" ;;
command|cli_cmd) echo "${CFG_COMMAND:-"(not set)"}" ;;
*)
echo "commitiq: unknown config key '$key'" >&2
exit 1
;;
esac
}
cmd_set() {
if [ $# -lt 2 ]; then
echo "usage: git commitiq config set <key> <value>" >&2
exit 1
fi
load_config
local key="$1"
shift
local value="$*"
case "$key" in
provider)
case "$value" in
anthropic|openai|gemini|ollama|local|cli) ;;
*)
echo "commitiq: unknown provider '$value' (expected anthropic, gemini, openai, ollama, local, cli)" >&2
exit 1
;;
esac
CFG_PROVIDER="$value"
;;
model)
CFG_MODEL="$value"
;;
api_key|key|apikey)
CFG_API_KEY="$value"
key="api_key"
;;
endpoint|endpoint_url)
CFG_ENDPOINT="$value"
key="endpoint"
;;
command|cli_cmd)
CFG_COMMAND="$value"
key="command"
;;
*)
echo "commitiq: unknown config key '$key'" >&2
exit 1
;;
esac
save_config
echo "commitiq: $key updated"
}
cmd_unset() {
if [ $# -lt 1 ]; then
echo "usage: git commitiq config unset <provider|model|api_key|endpoint|command>" >&2
exit 1
fi
load_config
local key="$1"
case "$key" in
provider) CFG_PROVIDER="" ;;
model) CFG_MODEL="" ;;
api_key|key|apikey) CFG_API_KEY="" ;;
endpoint|endpoint_url) CFG_ENDPOINT="" ;;
command|cli_cmd) CFG_COMMAND="" ;;
*)
echo "commitiq: unknown config key '$key'" >&2
exit 1
;;
esac
save_config
echo "commitiq: $key unset"
}
cmd_list() {
load_config
echo "provider = ${CFG_PROVIDER:-"(not set)"}"
echo "model = ${CFG_MODEL:-"(not set)"}"
echo "api_key = $(mask "${CFG_API_KEY:-}")"
[ -n "${CFG_ENDPOINT:-}" ] && echo "endpoint = $CFG_ENDPOINT"
[ -n "${CFG_COMMAND:-}" ] && echo "command = $CFG_COMMAND"
echo "(config file: $CONFIG_FILE)"
}
config_main() {
if [ $# -lt 1 ]; then
echo "usage: git commitiq config <get|set|unset|list> ..." >&2
exit 1
fi
local action="$1"
shift
case "$action" in
setup) cmd_setup "$@" ;;
get) cmd_get "$@" ;;
set) cmd_set "$@" ;;
unset) cmd_unset "$@" ;;
list) cmd_list "$@" ;;
*)
echo "commitiq: unknown config action '$action'" >&2
exit 1
;;
esac
}
# ---------------------------------------------------------------------------
# Git operations
# ---------------------------------------------------------------------------
do_commit() {
ensure_git_repo
# Run the real commit first. If it fails (nothing staged, conflict,
# rejected by a pre-commit hook, etc.), we stop here via `set -e` -
# no LLM call, no note, identical behavior to plain `git commit`.
git commit "$@"
# First commitiq commit in this repo: silently configure notes
# push/fetch sync (same as `git commitiq notes-enable`) so notes
# travel with 'git push'/'git fetch'. Idempotent and non-fatal - a
# repo without a remote is skipped, and nothing breaks if it fails.
do_notes_enable --quiet || true
SHA="$(git rev-parse HEAD)"
REPO_ROOT="$(git rev-parse --show-toplevel)"
OUT_DIR="$REPO_ROOT/.commitiq"
mkdir -p "$OUT_DIR"
if git rev-parse -q --verify HEAD^ >/dev/null; then
FULL_DIFF="$(git diff HEAD^ HEAD)"
else
FULL_DIFF="$(git show --format='' HEAD)"
fi
# Truncate diffs larger than 50KB (~1500 lines) to prevent exceeding LLM context limits
local max_diff_bytes=50000
if [ "${#FULL_DIFF}" -gt "$max_diff_bytes" ]; then
FULL_DIFF="$(printf '%s' "$FULL_DIFF" | head -c "$max_diff_bytes")"$'\n\n[... diff truncated due to 50KB size limit ...]'
fi
SUMMARY=""
SUMMARY="$(printf '%s' "$FULL_DIFF" | summarize_diff 2>>"$OUT_DIR/.commitiq.log" || true)"
if [ -n "$SUMMARY" ]; then
if printf '%s\n' "$SUMMARY" | git notes add -f -F - 2>>"$OUT_DIR/.commitiq.log"; then
echo "[commitiq] JSON summary attached to ${SHA:0:7} via git notes"
# Print formatted commit summary and description to terminal
local stype sscope ssum sdesc
stype="$(printf '%s' "$SUMMARY" | extract_json_text "type" || true)"
sscope="$(printf '%s' "$SUMMARY" | extract_json_text "scope" || true)"
ssum="$(printf '%s' "$SUMMARY" | extract_json_text "summary" || true)"
sdesc="$(printf '%s' "$SUMMARY" | extract_json_text "description" || true)"
if [ -n "$ssum" ]; then
echo ""
if [ -n "$sscope" ]; then
echo " Summary: ${stype}(${sscope}): ${ssum}"
else
echo " Summary: ${stype}: ${ssum}"
fi
[ -n "$sdesc" ] && echo " Description: ${sdesc}"
echo ""
fi
else
echo "[commitiq] warning: commit succeeded but the note could not be attached to $SHA (see $OUT_DIR/.commitiq.log)" >&2
fi
else
if printf '%s\n' "commitiq: no semantic summary available (no provider configured, or the LLM request failed). See $OUT_DIR/.commitiq.log. Run 'git commitiq setup'." \
| git notes add -f -F - 2>>"$OUT_DIR/.commitiq.log"; then
echo "[commitiq] no summary generated - placeholder note attached to $SHA (see $OUT_DIR/.commitiq.log for details)"
else
echo "[commitiq] warning: could not attach placeholder note to $SHA (see $OUT_DIR/.commitiq.log)" >&2
fi
fi
}
do_show() {
ensure_git_repo
local query="${1:-}"
if [ -z "$query" ]; then
echo "usage: git commitiq show <sha-or-prefix>" >&2
exit 1
fi
local full_sha
if ! full_sha="$(git rev-parse --verify "$query^{commit}" 2>/dev/null)"; then
echo "No commit matching '$query'" >&2
exit 1
fi
if ! git notes show "$full_sha" 2>/dev/null; then
echo "No commitiq note found for $full_sha" >&2
exit 1
fi
}
do_log() {
ensure_git_repo
local found=0
local _note_sha annotated_sha
while read -r _note_sha annotated_sha; do
git log -1 --format="%h %ad %s" --date=short "$annotated_sha"
found=1
done < <(git notes list 2>/dev/null)
if [ "$found" -eq 0 ]; then
echo "No stored summaries yet."
fi
}
is_remote_or_url() {
local arg="$1"
if git remote | grep -Fqx "$arg"; then
return 0
fi
case "$arg" in
*://*|*@*:*|*.git) return 0 ;;
esac
return 1
}
do_push() {
ensure_git_repo
# No args: behave exactly like a bare `git push` - the refspecs
# configured by `notes-enable` already push branches + notes.
if [ $# -eq 0 ]; then
git push
return $?
fi
# Modes where appending a notes refspec would be wrong or destructive:
# --delete/-d would DELETE the remote notes ref too; --mirror already
# mirrors every ref under refs/, notes included; --all can't be
# combined with refspecs at all (git refuses); -u/--set-upstream
# sets up tracking for named branches; an explicit :refs/notes/...
# delete must be forwarded untouched (not re-pushed).
local a
local plain=0
for a in "$@"; do
case "$a" in
--delete|-d|--mirror|--all|-u|--set-upstream) plain=1 ;;
:*refs/notes/*) plain=1 ;;
esac
done
if [ "$plain" -eq 1 ]; then
git push "$@"
return $?
fi
# Count non-flag args, skipping the value of -o/--push-option (which
# would otherwise be miscounted as a refspec). A lone remote/URL means
# "push per configured refspecs" (which already include notes) -
# appending would override them and push notes ONLY. An explicit
# refspec on the command line means "push exactly this", so the notes
# refspec must be appended.
local skip_next=0 non_flags=0 last_non_flag=""
for a in "$@"; do
if [ "$skip_next" -eq 1 ]; then
skip_next=0
continue
fi
case "$a" in
-o|--push-option) skip_next=1 ;;
-*) ;;
*)
non_flags=$((non_flags + 1))
last_non_flag="$a"
;;
esac
done
local append_notes=0
if [ "$non_flags" -gt 1 ]; then
append_notes=1
elif [ "$non_flags" -eq 1 ] && ! is_remote_or_url "$last_non_flag"; then
append_notes=1
fi
# Only append if the local notes ref exists - pushing a refspec whose
# src doesn't exist makes git abort the whole push.
if [ "$append_notes" -eq 1 ] && git rev-parse -q --verify refs/notes/commits >/dev/null 2>&1; then
git push "$@" "refs/notes/*:refs/notes/*"
else
git push "$@"
fi
}
do_notes_enable() {
ensure_git_repo
# --quiet: no output at all unless something was actually configured
# (then exactly one line). Used by `do_commit` on a repo's first
# commitiq commit, so setup is invisible but still discoverable.
local quiet=0
local remote=""
while [[ $# -gt 0 ]]; do
case "$1" in
--quiet|-q)
quiet=1
shift
;;
--remote)
remote="$2"
shift 2
;;
-h|--help)
echo "usage: git commitiq notes-enable [remote-name | --remote <name>] [--quiet]" >&2
exit 0
;;
*)
remote="$1"
shift
;;
esac
done
if [ -z "$remote" ]; then
remote="$(git remote | head -n 1)"
fi
if [ -z "$remote" ]; then
if [ "$quiet" -eq 1 ]; then
return 0
fi
echo "commitiq: no git remote configured. Add one (e.g. 'git remote add origin <url>') and re-run." >&2
exit 1
fi
if [ -z "$(git config --get "remote.$remote.url")" ]; then
if [ "$quiet" -eq 1 ]; then
return 0
fi
echo "commitiq: remote '$remote' not found." >&2
exit 1
fi
local changed=0
local say
say() {
[ "$quiet" -eq 1 ] || printf '%s\n' "$@"
}
# Fetch: pull remote notes down (keeps any existing fetch refspecs).
if ! git config --get-all "remote.$remote.fetch" | grep -Fqx "+refs/notes/*:refs/notes/*" \
&& ! git config --get-all "remote.$remote.fetch" | grep -Fqx "refs/notes/*:refs/notes/*"; then
git config --add "remote.$remote.fetch" "+refs/notes/*:refs/notes/*"
say "[commitiq] added fetch refspec: +refs/notes/*:refs/notes/*"
changed=1
fi
# Push: git notes are NOT pushed by default, and defining a push
# refspec disables git's implicit branch pushing - so we always add
# the branch refspec alongside the notes refspec.
local existing_push
existing_push="$(git config --get-all "remote.$remote.push" || true)"
if [ -z "$existing_push" ]; then
git config --add "remote.$remote.push" "refs/heads/*:refs/heads/*"
say "[commitiq] added push refspec: refs/heads/*:refs/heads/*"
git config --add "remote.$remote.push" "refs/notes/*:refs/notes/*"
say "[commitiq] added push refspec: refs/notes/*:refs/notes/*"
changed=1
elif ! printf '%s\n' "$existing_push" | grep -Fqx "refs/notes/*:refs/notes/*"; then
git config --add "remote.$remote.push" "refs/notes/*:refs/notes/*"
say "[commitiq] added push refspec: refs/notes/*:refs/notes/*"
say "[commitiq] NOTE: this remote already had custom push refspecs - make sure one of them covers refs/heads/*"
say "[commitiq] or plain 'git push' will not push branches. See: git config --get-all remote.$remote.push"
changed=1
else
say "[commitiq] notes push refspec already configured."
fi
# Show notes in `git log` by default, and carry notes across rebase.
if ! git config --get-all notes.displayRef | grep -Fqx "refs/notes/commits"; then
git config --add notes.displayRef "refs/notes/commits"
say "[commitiq] notes.displayRef = refs/notes/commits (git log will show notes)"
changed=1
fi
local existing_rewrite
existing_rewrite="$(git config --get notes.rewriteRef || true)"
if [ -n "$existing_rewrite" ] && [ "$existing_rewrite" != "refs/notes/commits" ]; then
say "[commitiq] NOTE: notes.rewriteRef already set to '$existing_rewrite' - leaving it untouched."
elif [ "$existing_rewrite" != "refs/notes/commits" ]; then
git config notes.rewriteRef "refs/notes/commits" 2>/dev/null || true
say "[commitiq] notes.rewriteRef = refs/notes/commits (notes survive rebase)"
changed=1
fi
if [ "$quiet" -eq 1 ]; then
if [ "$changed" -eq 1 ]; then
echo "[commitiq] notes sync enabled for this repo ('git push'/'git fetch' will include notes)"
fi
return 0
fi
echo ""
echo "[commitiq] done. 'git push' now sends branches + notes; 'git fetch'/'git pull' brings notes back."
echo "[commitiq] tip: use bare 'git push' - an explicit push like 'git push origin main' skips notes."
echo "[commitiq] caveat: GitHub/GitLab store notes but do not render them in their web UI."
echo "[commitiq] view them with: git log --show-notes / git commitiq show <sha>"
}
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
case "${1:-}" in
setup)
shift
cmd_setup "$@"
;;
config)
shift
config_main "$@"
;;
notes-enable|enable-notes|notes)
shift
do_notes_enable "$@"
;;
push)
shift
do_push "$@"
;;
show)
shift
do_show "$@"
;;
log)
do_log
;;
help|--help|-h)
usage
;;
commit)
shift
do_commit "$@"
;;
*)
# No recognized commitiq keyword as the first arg - treat everything
# (including things like `-m "msg"`) as `git commit` arguments.
do_commit "$@"
;;
esac