mirror of
https://github.com/tj/git-extras.git
synced 2026-09-10 07:26:17 -04:00
bash file created
This commit is contained in:
parent
54945a5eec
commit
766a2e506b
371
bin/git-commitiq
Normal file
371
bin/git-commitiq
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
#!/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
|
||||
|
||||
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 <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
|
||||
}
|
||||
|
||||
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 <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>"
|
||||
}
|
||||
|
||||
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
|
||||
Loading…
Reference in a new issue