|
#!/usr/bin/env bash |
|
set -euo pipefail |
|
|
|
REMOTE="${AISHIP_REMOTE:-origin}" |
|
MAX_DIFF_LINES="${AISHIP_MAX_DIFF_LINES:-80}" |
|
TIMEOUT="${AISHIP_AI_TIMEOUT_SECONDS:-45}" |
|
AUTO_YES="${AISHIP_YES:-1}" |
|
NO_PUSH="${AISHIP_NO_PUSH:-0}" |
|
FALLBACK="${AISHIP_FALLBACK:-1}" |
|
|
|
WORKDIR="$(mktemp -d)" |
|
trap 'rm -rf "$WORKDIR"' EXIT |
|
|
|
die() { echo "error: $*" >&2; exit 1; } |
|
info() { echo "==> $*" >&2; } |
|
warn() { echo "warning: $*" >&2; } |
|
have() { command -v "$1" >/dev/null 2>&1; } |
|
|
|
# Print the raw error, then a one-line diagnosis. Network vs auth vs transient. |
|
report() { |
|
[ -s "$1" ] && cat "$1" >&2 |
|
local t; t="$(tr 'A-Z' 'a-z' < "$1")" |
|
case "$t" in |
|
*"could not resolve"*|*"getaddrinfo"*|*"name or service not known"*) |
|
warn "diagnosis: DNS resolution failed — check network/DNS/VPN." ;; |
|
*"timed out"*|*"timeout"*) |
|
warn "diagnosis: connection timed out — server unreachable or network slow." ;; |
|
*"connection refused"*|*"network is unreachable"*|*"failed to connect"*|*"could not connect"*|*"unable to access"*|*"no route to host"*) |
|
warn "diagnosis: could not reach the server — network/firewall/proxy issue." ;; |
|
*"ssl"*|*"tls"*|*"certificate"*) |
|
warn "diagnosis: SSL/TLS error while connecting." ;; |
|
*"rpc failed"*|*"early eof"*|*"remote end hung up"*|*"connection reset"*) |
|
warn "diagnosis: transfer interrupted — connection dropped (often transient)." ;; |
|
*"authentication failed"*|*"permission denied"*|*"403"*|*"401"*|*"unauthorized"*|*"invalid api key"*) |
|
warn "diagnosis: auth/permission error (not network) — check token/SSH key/API key." ;; |
|
*"rate limit"*|*"429"*|*"quota"*|*"overloaded"*) |
|
warn "diagnosis: AI provider rate-limited or overloaded — retry later." ;; |
|
*"502"*|*"503"*|*"504"*|*"bad gateway"*|*"service unavailable"*|*"fetch failed"*) |
|
warn "diagnosis: upstream server error (5xx) — likely transient." ;; |
|
esac |
|
} |
|
|
|
ai_command() { |
|
if [ -n "${AISHIP_MSG_CMD:-}" ]; then echo "$AISHIP_MSG_CMD"; return 0; fi |
|
have claude && { echo "claude -p"; return 0; } |
|
have codex && { echo "codex exec --quiet"; return 0; } |
|
return 1 |
|
} |
|
|
|
# Deterministic, network-free fallback subject. |
|
fallback_subject() { |
|
local n first |
|
n="$(git diff --cached --name-only | wc -l | tr -d ' ')" |
|
if [ "$n" = "1" ]; then |
|
first="$(git diff --cached --name-only)" |
|
echo "chore: update ${first##*/}" |
|
else |
|
echo "chore: update $n files" |
|
fi |
|
} |
|
|
|
# --- preflight --- |
|
have python3 || die "python3 is required (used to parse the AI's JSON output)" |
|
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || die "not inside a git repository" |
|
cd "$(git rev-parse --show-toplevel)" |
|
|
|
LOCK="$(git rev-parse --git-dir)/index.lock" |
|
[ -e "$LOCK" ] && die "git index lock exists: $LOCK (remove it if no git process is running)" |
|
|
|
BRANCH="$(git symbolic-ref --short -q HEAD)" || die "detached HEAD; refusing to commit" |
|
|
|
[ "$(git status --porcelain | wc -l | tr -d ' ')" = "0" ] && { info "no changes; nothing to commit"; exit 0; } |
|
|
|
info "staging all changes on '$BRANCH'" |
|
git add -A -- . |
|
git diff --cached --quiet && { info "no staged changes; nothing to commit"; exit 0; } |
|
|
|
# --- prompt --- |
|
PROMPT="$WORKDIR/prompt" |
|
{ |
|
cat <<'EOF' |
|
Generate a git commit message for the staged changes. |
|
Return ONLY a JSON object: {"subject": "type(scope): summary"} |
|
Rules: one line, under 72 chars, Conventional Commits style; no markdown, |
|
no explanation. Types: feat, fix, refactor, chore, docs, test, style, build, ci. |
|
|
|
Changed files: |
|
EOF |
|
git diff --cached --name-status |
|
echo; echo "Diff sample:" |
|
git diff --cached --no-ext-diff --unified=0 | head -n "$MAX_DIFF_LINES" || true |
|
} > "$PROMPT" |
|
|
|
# --- generate subject (AI, else fallback) --- |
|
SUBJECT="" |
|
AI="$(ai_command || true)" |
|
OUT="$WORKDIR/out"; ERR="$WORKDIR/err" |
|
|
|
if [ -n "$AI" ]; then |
|
info "generating commit message via: $AI" |
|
rc=0 |
|
if have timeout; then |
|
timeout -k 5 "$TIMEOUT" sh -lc "$AI" < "$PROMPT" >"$OUT" 2>"$ERR" || rc=$? |
|
else |
|
sh -lc "$AI" < "$PROMPT" >"$OUT" 2>"$ERR" || rc=$? |
|
fi |
|
|
|
if [ "$rc" -eq 0 ]; then |
|
SUBJECT="$(python3 - "$OUT" <<'PY' |
|
import json, re, sys |
|
t = open(sys.argv[1], encoding="utf-8", errors="replace").read().strip() |
|
m = re.fullmatch(r"```(?:json)?\s*(.*?)\s*```", t, re.DOTALL | re.I) |
|
if m: t = m.group(1).strip() |
|
try: |
|
s = json.loads(t).get("subject", "") or "" |
|
except Exception: |
|
s = "" |
|
print(" ".join(str(s).split())[:72]) |
|
PY |
|
)" |
|
[ -z "$SUBJECT" ] && { warn "AI did not return a usable JSON subject:"; cat "$OUT" >&2; } |
|
else |
|
[ "$rc" -eq 124 ] && warn "AI command timed out after ${TIMEOUT}s" || warn "AI command failed (status $rc)" |
|
report "$ERR" |
|
fi |
|
fi |
|
|
|
if [ -z "$SUBJECT" ]; then |
|
[ "$FALLBACK" = "1" ] || die "could not generate a commit message (set AISHIP_FALLBACK=1 to allow a generic one)" |
|
[ -n "$AI" ] && warn "using deterministic fallback message" |
|
SUBJECT="$(fallback_subject)" |
|
fi |
|
|
|
printf '\n==> commit message:\n%s\n\n' "$SUBJECT" >&2 |
|
|
|
if [ "$AUTO_YES" != "1" ]; then |
|
printf "Commit and push? [y/N] " >&2; read -r a |
|
case "$a" in y|Y|yes|YES) ;; *) die "aborted";; esac |
|
fi |
|
|
|
# --- commit --- |
|
rc=0; git commit -m "$SUBJECT" 2>"$ERR" || rc=$? |
|
[ -s "$ERR" ] && cat "$ERR" >&2 |
|
[ "$rc" -eq 0 ] || die "git commit failed (status $rc) — see output above (e.g. a pre-commit hook)" |
|
info "committed on '$BRANCH'" |
|
|
|
# --- push --- |
|
[ "$NO_PUSH" = "1" ] && { info "push disabled"; exit 0; } |
|
git remote get-url "$REMOTE" >/dev/null 2>&1 || { warn "remote '$REMOTE' missing; commit saved locally"; exit 0; } |
|
|
|
info "pushing to $REMOTE $BRANCH" |
|
rc=0; git push "$REMOTE" "$BRANCH" 2>"$ERR" || rc=$? |
|
if [ "$rc" -ne 0 ]; then |
|
report "$ERR" |
|
warn "push failed — commit is saved locally; retry with: git push \"$REMOTE\" \"$BRANCH\"" |
|
exit 1 |
|
fi |
|
info "done" |