#!/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-` found on $PATH becomes callable as `git `. 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 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" LIB_DIR="$(cd "$SCRIPT_DIR/../lib" && pwd)" 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

--api-key [--model ] Non-interactive setup git commitiq config get Show a config value (provider|model|api_key) git commitiq config set Change a config value anytime git commitiq config unset 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 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 } 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="" if [ -f "$LIB_DIR/commitiq_llm.sh" ]; then SUMMARY="$(printf '%s' "$FULL_DIFF" | bash "$LIB_DIR/commitiq_llm.sh" 2>>"$OUT_DIR/.commitiq.log" || true)" fi 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 via git notes" 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" 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 " >&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 ] [--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 ') 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 " } case "${1:-}" in setup) shift bash "$LIB_DIR/commitiq_config.sh" setup "$@" ;; config) shift bash "$LIB_DIR/commitiq_config.sh" "$@" ;; 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