Skip to content

Instantly share code, notes, and snippets.

@discountry
Created June 19, 2026 06:56
Show Gist options
  • Select an option

  • Save discountry/667a15cc9b70934589c74295ea66242e to your computer and use it in GitHub Desktop.

Select an option

Save discountry/667a15cc9b70934589c74295ea66242e to your computer and use it in GitHub Desktop.

aiship 使用说明(macOS)

暂存全部改动 → 用 AI 生成提交信息 → 提交 → 推送。AI 不可用时自动用兜底信息,保证总能提交。

依赖

  • git、python3:装一次 Xcode 命令行工具即可:xcode-select --install
  • AI CLI(可选):脚本自动探测 claudecodex;两者都没有时用兜底信息,或用 AISHIP_MSG_CMD 指定任意命令。
  • timeout(可选):macOS 默认没有,AI 步骤就不设时限(不影响功能)。要启用:brew install coreutils,再把 "$(brew --prefix)/opt/coreutils/libexec/gnubin" 加进 PATH。

安装

chmod +x aiship
mkdir -p ~/bin && mv aiship ~/bin/
echo 'export PATH="$HOME/bin:$PATH"' >> ~/.zshrc && source ~/.zshrc

不装也行,直接跑:bash aiship./aiship

用法

cd /path/to/repo
aiship

选项(环境变量)

变量 默认 作用
AISHIP_NO_PUSH 0 =1 只提交不推送
AISHIP_YES 1 =0 提交前手动确认
AISHIP_REMOTE origin 推送的远端名
AISHIP_MSG_CMD 自定义生成信息的命令(覆盖 claude/codex)
AISHIP_FALLBACK 1 =0 AI 失败时不兜底,直接报错
AISHIP_AI_TIMEOUT_SECONDS 45 AI 超时秒数(需 timeout 才生效)
AISHIP_MAX_DIFF_LINES 80 发给 AI 的 diff 样本行数

例:

AISHIP_NO_PUSH=1 aiship                      # 只提交
AISHIP_YES=0 aiship                          # 确认后再提交
AISHIP_MSG_CMD='ollama run llama3' aiship    # 用本地模型生成信息

说明

  • 出错会打印具体原因(DNS、超时、认证、限流、5xx 等)。
  • 推送失败时提交已存在本地,按提示用 git push 重试即可。
  • 无改动、空仓库锁、detached HEAD 等情况会安全退出。
#!/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"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment