mirror of
https://github.com/tj/git-extras.git
synced 2026-09-10 07:26:17 -04:00
1422 lines
45 KiB
Bash
1422 lines
45 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 COMMITIQ_PROMPT="$prompt"
|
|
|
|
# For agy/antigravity/claude CLI tools expecting prompt as flag argument:
|
|
if [[ "$cli_cmd" =~ (--print|-p|prompt)[[:space:]]*$ ]]; then
|
|
res="$(eval "$cli_cmd \"\$COMMITIQ_PROMPT\"" 2>"$err_tmp")" || status=$?
|
|
else
|
|
# Try stdin piping first
|
|
res="$(printf '%s\n' "$prompt" | eval "$cli_cmd" 2>"$err_tmp")" || status=$?
|
|
# Fallback to argument passing if stdin piping returned 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 15KB (~500 lines) to prevent exceeding LLM context limits and OS command line argument limits
|
|
local max_diff_bytes=15000
|
|
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 15KB 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
|