工具
smart-autopush.sh
智能自动推送脚本。分析 git diff 生成描述性 commit message,支持 debounce、跨平台文件锁、gh-api fallback。
#!/bin/bash
# Smart Auto-Push: 分析 git diff 生成描述性 commit message
# 支持 debounce 合并 + 智能 scope + 跨平台文件锁 + 任意 repo gh-api fallback
# 用法:
# smart-autopush.sh [repo_dir] ["commit message"] [status]
# smart-autopush.sh /path/to/repo "feat(ui): 添加暗黑模式切换" wip
set -e
REPO_DIR="${1:-$(pwd)}"
INTENT_MSG_ARG="${2:-}"
INTENT_STATUS_ARG="${3:-wip}"
AMEND_MODE=false
FORCE_SAME_MSG=false
# Parse flags from any position
for arg in "$@"; do
case "$arg" in
--amend) AMEND_MODE=true ;;
--force-same-message) FORCE_SAME_MSG=true ;;
esac
done
DEBOUNCE_FILE="$HOME/.autopush.debounce.$(echo "$REPO_DIR" | tr '/' '_')"
LOCK_DIR="$HOME/.autopush.lock.$(echo "$REPO_DIR" | tr '/' '_')"
DEBOUNCE_SEC=5
cd "$REPO_DIR" 2>/dev/null || { echo "❌ 目录不存在: $REPO_DIR"; exit 1; }
# ============ Cross-platform Lock ============
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
echo "⏳ Another autopush is running for $REPO_DIR, skipping"
exit 0
fi
# Cleanup lock and debounce on exit
cleanup() {
rmdir "$LOCK_DIR" 2>/dev/null || true
rm -f "$DEBOUNCE_FILE"
}
trap cleanup EXIT
# ============ Debounce ============
check_debounce() {
local now
now=$(date +%s)
local last_run=0
if [ -f "$DEBOUNCE_FILE" ]; then
last_run=$(cat "$DEBOUNCE_FILE" 2>/dev/null || echo 0)
fi
local elapsed=$((now - last_run))
if [ $elapsed -lt $DEBOUNCE_SEC ]; then
echo "⏳ Debounce: ${elapsed}s/${DEBOUNCE_SEC}s,等待中..."
sleep $((DEBOUNCE_SEC - elapsed))
fi
date +%s > "$DEBOUNCE_FILE"
}
# ============ Intent File Support ============
INTENT_FILE="$REPO_DIR/.autopush-intent"
USE_INTENT=false
INTENT_MSG=""
INTENT_STATUS=""
if [ -n "$INTENT_MSG_ARG" ]; then
USE_INTENT=true
INTENT_MSG="$INTENT_MSG_ARG"
INTENT_STATUS=$(echo "$INTENT_STATUS_ARG" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | tr '[:upper:]' '[:lower:]')
elif [ -f "$INTENT_FILE" ]; then
USE_INTENT=true
INTENT_MSG=$(sed -n '1p' "$INTENT_FILE" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
INTENT_STATUS=$(sed -n '2p' "$INTENT_FILE" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | tr '[:upper:]' '[:lower:]')
fi
# ============ 0. 过滤 ============
check_debounce
IGNORE_PATTERNS='\.omc/state/|\.omc/sessions/|\.json\.wakatime|node_modules/|\.DS_Store|\.env$|\.cache/|session-stats\.json|usage-cache\.json|projects/.*\.jsonl|history\.jsonl|\.autopush\.lock|\.autopush\.debounce\.'
ALL_CHANGES=$(git status --porcelain | grep -Ev "$IGNORE_PATTERNS" || true)
if [ -z "$ALL_CHANGES" ]; then
echo "⏭️ 无有效改动,跳过"
exit 0
fi
CHANGED_FILES=$(echo "$ALL_CHANGES" | grep "^.M" | awk '{print $2}' || true)
NEW_FILES=$(echo "$ALL_CHANGES" | grep "^??" | awk '{print $2}' || true)
DELETED_FILES=$(echo "$ALL_CHANGES" | grep "^.D" | awk '{print $2}' || true)
RENAMED_FILES=$(echo "$ALL_CHANGES" | grep "^.R" | awk '{print $4}' || true)
ALL_FILES="$CHANGED_FILES $NEW_FILES $DELETED_FILES $RENAMED_FILES"
ALL_FILES=$(echo "$ALL_FILES" | tr ' ' '\n' | grep -v '^$' | tr '\n' ' ' | sed 's/ $//')
# ============ Diff-aware keyword detection ============
diff_keywords() {
local diff_output
diff_output=$(git diff --cached 2>/dev/null || git diff 2>/dev/null || true)
if [ -z "$diff_output" ]; then
echo ""
return
fi
local keywords=""
if echo "$diff_output" | grep -qiE '\b(fix|bug|patch|hotfix)\b'; then
keywords+="fix "
fi
if echo "$diff_output" | grep -qiE '\b(test|spec|describe|it\(|expect\()'; then
keywords+="test "
fi
if echo "$diff_output" | grep -qiE '\b(refactor|extract|rename|move|clean|dead|remove|delete)\b'; then
keywords+="refactor "
fi
if echo "$diff_output" | grep -qiE '\b(feat|add|implement|support|enable)\b'; then
keywords+="feat "
fi
if echo "$diff_output" | grep -qiE '\b(docs|comment|readme)\b'; then
keywords+="docs "
fi
if echo "$diff_output" | grep -qiE '\b(style|css|ui|layout|font|color|tailwind)\b'; then
keywords+="style "
fi
echo "$keywords"
}
# ============ 1. 智能 Scope ============
infer_scope() {
local files="$1"
local scope=""
local priority=0
for f in $files; do
case "$f" in
# .claude specific paths (high priority)
skills/*) scope="skills"; priority=70 ;;
hooks/*) [ $priority -lt 65 ] && { scope="hooks"; priority=65; } ;;
scripts/*) [ $priority -lt 65 ] && { scope="scripts"; priority=65; } ;;
rules/*) [ $priority -lt 60 ] && { scope="rules"; priority=60; } ;;
memory/*) [ $priority -lt 60 ] && { scope="memory"; priority=60; } ;;
knowledge/*) [ $priority -lt 60 ] && { scope="knowledge"; priority=60; } ;;
plugins/*) [ $priority -lt 55 ] && { scope="plugins"; priority=55; } ;;
# Astro/web paths
cv/*|*/cv/*) [ $priority -lt 50 ] && { scope="cv"; priority=50; } ;;
zh/*|*/zh/*) [ $priority -lt 45 ] && { scope="cv"; priority=45; } ;;
en/*|*/en/*) [ $priority -lt 45 ] && { scope="cv"; priority=45; } ;;
components/*) [ $priority -lt 45 ] && { scope="components"; priority=45; } ;;
layouts/*) [ $priority -lt 45 ] && { scope="layouts"; priority=45; } ;;
pages/*) [ $priority -lt 45 ] && { scope="pages"; priority=45; } ;;
# Generic
src/*) [ $priority -lt 30 ] && { scope="src"; priority=30; } ;;
public/*) [ $priority -lt 30 ] && { scope="public"; priority=30; } ;;
tests/*|test/*|__tests__/*) [ $priority -lt 30 ] && { scope="tests"; priority=30; } ;;
esac
done
[ -z "$scope" ] && scope="misc"
echo "$scope"
}
# ============ 2. Type ============
infer_type() {
local changed="$1"
local new="$2"
local deleted="$3"
local keywords="$4"
# Diff keywords override heuristics
case "$keywords" in
*fix*) echo "fix"; return ;;
*test*) echo "test"; return ;;
*refactor*) echo "refactor"; return ;;
*feat*) echo "feat"; return ;;
*docs*) echo "docs"; return ;;
*style*) echo "style"; return ;;
esac
if [ -n "$deleted" ] && [ -z "$changed" ] && [ -z "$new" ]; then
echo "chore"
return
fi
if [ -n "$new" ]; then
echo "$new" | grep -qE '\.(ts|tsx|js|jsx|astro|py|go|rs)$' && echo "feat" || echo "chore"
elif echo "$changed" | grep -qE 'package\.json|\.config|pnpm-lock|yarn\.lock|package-lock'; then
echo "chore"
else
echo "chore"
fi
}
# ============ 3. Description ============
generate_description() {
local changed="$1"
local new="$2"
local deleted="$3"
local keywords="$4"
local descriptions=()
# Helper: classify file action
classify_file() {
local f="$1"
local action="$2"
local bname ext
bname=$(basename "$f")
ext="${bname##*.}"
case "$ext" in
astro) echo "$action $bname" ;;
ts|tsx|js|jsx|py|go|rs)
echo "$action $bname" ;;
css|scss) echo "$action styles" ;;
md|mdx) echo "$action docs" ;;
sh) echo "$action scripts" ;;
json) echo "$action config" ;;
yaml|yml) echo "$action config" ;;
*) echo "$action $bname" ;;
esac
}
# Handle new files
for f in $new; do
[ -z "$f" ] && continue
descriptions+=("$(classify_file "$f" "add")")
done
# Handle deleted files
for f in $deleted; do
[ -z "$f" ] && continue
descriptions+=("$(classify_file "$f" "remove")")
done
# Handle changed files
for f in $changed; do
[ -z "$f" ] && continue
descriptions+=("$(classify_file "$f" "update")")
done
# Keyword-based overrides for clearer messages
case "$keywords" in
*fix*) descriptions=("fix issues") ;;
*refactor*) descriptions=("refactor code") ;;
*test*) descriptions=("update tests") ;;
*docs*) descriptions=("update docs") ;;
*style*) descriptions=("update styles") ;;
esac
printf '%s\n' "${descriptions[@]}" 2>/dev/null | sort -u | head -3 | tr '\n' ' ' | sed 's/ $//'
}
# Check if message is low-quality (generic filename-only)
is_low_quality_msg() {
local msg="$1"
# Patterns like "update filename", "add filename", "remove filename", "update config/styles"
if echo "$msg" | grep -qE '^(update|add|remove) [A-Za-z0-9_./-]+( (update|add|remove) [A-Za-z0-9_./-]+)*$'; then
echo "true"
elif echo "$msg" | grep -qE '^(update (config|styles|scripts|docs))$'; then
echo "true"
else
echo "false"
fi
}
# ============ Amend Safety Check ============
# When amending, verify the commit message still describes the actual changes
# Also generates a semantic suggestion based on actual diff content
check_amend_message_match() {
local msg="$1"
local force="$2"
# Get previous commit message
local prev_msg
prev_msg=$(git log -1 --format=%s 2>/dev/null || echo "")
# If message is identical to previous, warn
if [ "$msg" = "$prev_msg" ]; then
# Check if there are new changes beyond the previous commit
local prev_tree current_tree
prev_tree=$(git rev-parse HEAD^{tree} 2>/dev/null || echo "")
current_tree=$(git write-tree 2>/dev/null || echo "")
if [ "$prev_tree" != "$current_tree" ]; then
# Generate semantic suggestion from actual diff
local suggestion
suggestion=$(generate_amend_suggestion)
echo ""
echo "🚨 AMEND SAFETY CHECK FAILED"
echo " You are amending with the SAME message as the previous commit."
echo " But the tree has changed — new files or modifications were added."
echo ""
echo " Previous message: $prev_msg"
echo ""
echo " 💡 Suggested message based on actual changes:"
echo " $suggestion"
echo ""
echo " Options:"
echo " A. Use suggested message: ap-intent '$suggestion'"
echo " B. Write your own: ap-intent 'feat(x): your description'"
echo " C. Force anyway: smart-autopush.sh . \"$msg\" --amend --force-same-message"
echo ""
if [ "$force" != "true" ]; then
return 1
else
echo " ⚠️ --force-same-message passed, proceeding despite mismatch..."
fi
fi
fi
return 0
}
# Generate a semantic commit suggestion from the actual diff content
# This analyzes the current staged+unstaged changes (the delta since last commit)
generate_amend_suggestion() {
# Get the delta: diff between HEAD and current working tree
local diff_output
diff_output=$(git diff HEAD 2>/dev/null || true)
if [ -z "$diff_output" ]; then
echo "chore(misc): update"
return
fi
# Analyze diff for semantic keywords
local keywords=""
if echo "$diff_output" | grep -qiE '\b(fix|bug|patch|hotfix|error|crash|fail)\b'; then
keywords+="fix "
fi
if echo "$diff_output" | grep -qiE '\b(feat|add|implement|support|enable|new)\b'; then
keywords+="feat "
fi
if echo "$diff_output" | grep -qiE '\b(refactor|extract|rename|move|clean|dead|remove|delete|simplify)\b'; then
keywords+="refactor "
fi
if echo "$diff_output" | grep -qiE '\b(test|spec|describe|it\(|expect\()'; then
keywords+="test "
fi
if echo "$diff_output" | grep -qiE '\b(docs|comment|readme|guide)\b'; then
keywords+="docs "
fi
if echo "$diff_output" | grep -qiE '\b(style|css|ui|layout|font|color|tailwind|design)\b'; then
keywords+="style "
fi
if echo "$diff_output" | grep -qiE '\b(perf|optimize|speed|fast|slow|cache|memory|lazy)\b'; then
keywords+="perf "
fi
# Determine type from keywords
local type="chore"
case "$keywords" in
*fix*) type="fix" ;;
*feat*) type="feat" ;;
*refactor*) type="refactor" ;;
*test*) type="test" ;;
*docs*) type="docs" ;;
*style*) type="style" ;;
*perf*) type="perf" ;;
esac
# Analyze which files changed (the delta, not the whole commit)
local delta_files
delta_files=$(git diff --name-only HEAD 2>/dev/null || true)
local scope="misc"
local file_count=0
for f in $delta_files; do
file_count=$((file_count + 1))
case "$f" in
src/components/*) scope="components" ;;
src/layouts/*) scope="layouts" ;;
src/pages/*) scope="pages" ;;
src/script/*|src/scripts/*) scope="scripts" ;;
src/styles/*) scope="styles" ;;
public/*) scope="assets" ;;
scripts/*) scope="scripts" ;;
*.config.*|package.json) scope="config" ;;
esac
done
# Generate description from diff content
local description=""
# Check for specific patterns
if echo "$diff_output" | grep -qiE '\blocalStorage\b.*\bgetItem\b'; then
description="add localStorage persistence"
elif echo "$diff_output" | grep -qiE '\blocalStorage\b.*\bsetItem\b'; then
description="persist state to localStorage"
elif echo "$diff_output" | grep -qiE '\bfetch\b.*\bjson\b|\bmanifest\b'; then
description="add dynamic manifest fetching"
elif echo "$diff_output" | grep -qiE '\bdefault\b.*\bfavicon\b|\bfavicon.*default\b'; then
description="set default favicon"
elif echo "$diff_output" | grep -qiE '\brandom\b.*\bMath\b|\bMath\.random\b'; then
description="add random selection"
elif echo "$diff_output" | grep -qiE '\bfilter\b|\bexclude\b|\bskip\b|\ballowed\b'; then
description="filter available options"
elif echo "$diff_output" | grep -qiE '\bgitSha\b|\bcdnBase\b|\bicon-manifest\b'; then
description="update manifest generation"
elif [ "$file_count" -eq 1 ]; then
description="update $(basename "$delta_files")"
else
description="update $scope"
fi
# If there are added lines that look like new features
local added_lines
added_lines=$(echo "$diff_output" | grep -c '^+' || echo "0")
local removed_lines
removed_lines=$(echo "$diff_output" | grep -c '^-' || echo "0")
if [ "$added_lines" -gt "$removed_lines" ] && [ "$added_lines" -gt 5 ]; then
case "$type" in
fix) description="${description} and improve handling" ;;
feat) description="${description}" ;;
*) description="${description}" ;;
esac
fi
echo "${type}(${scope}): ${description}"
}
# ============ 4. 组装 ============
if [ "$USE_INTENT" = true ] && [ -n "$INTENT_MSG" ]; then
case "$INTENT_STATUS" in
done|已实现|完成)
INTENT_STATUS="done" ;;
wip|进行中|未实现|未完成|todo)
INTENT_STATUS="wip" ;;
*)
INTENT_STATUS="" ;;
esac
if [ -n "$INTENT_STATUS" ]; then
COMMIT_MSG="${INTENT_MSG} (${INTENT_STATUS})"
else
COMMIT_MSG="$INTENT_MSG"
fi
else
KEYWORDS=$(diff_keywords)
SCOPE=$(infer_scope "$ALL_FILES")
TYPE=$(infer_type "$CHANGED_FILES" "$NEW_FILES" "$DELETED_FILES" "$KEYWORDS")
DESCRIPTION=$(generate_description "$CHANGED_FILES" "$NEW_FILES" "$DELETED_FILES" "$KEYWORDS")
if [ -n "$DESCRIPTION" ]; then
COMMIT_MSG="${TYPE}(${SCOPE}): ${DESCRIPTION}"
else
FIRST_FILE=$(echo "$ALL_FILES" | awk '{print $1}')
if [ -n "$FIRST_FILE" ]; then
COMMIT_MSG="${TYPE}(${SCOPE}): update $(basename "$FIRST_FILE")"
else
echo "⏭️ 无有效文件"
exit 0
fi
fi
fi
# Warn about low-quality auto-generated messages
LOW_QUALITY=false
if [ "$USE_INTENT" = false ]; then
if [ "$(is_low_quality_msg "${COMMIT_MSG##*: }")" = "true" ]; then
LOW_QUALITY=true
fi
fi
# ============ 4.5 Agent Review Gate ============
# Count only files that will actually be committed
if git diff --cached --quiet; then
# Nothing staged yet; commit will include all changes
TOTAL_FILES=$(echo "$ALL_CHANGES" | awk '{print ($1=="R"?$4:$2)}' | sort -u | grep -v '^$' | grep -c .)
else
# Files already staged; commit will only include staged files
TOTAL_FILES=$(git diff --cached --name-only | grep -c .)
fi
SKIP_REVIEW_FILE="$REPO_DIR/.autopush-skip-review"
if [ "$TOTAL_FILES" -gt 3 ] && [ ! -f "$SKIP_REVIEW_FILE" ]; then
echo "❌ Commit rejected: $TOTAL_FILES files will be committed."
echo " behavioral-execution-discipline.md requires code-reviewer for >3 files."
echo ""
echo "Options:"
echo " 1. Run code-reviewer agent, then create skip marker:"
echo " touch \"$SKIP_REVIEW_FILE\""
echo " smart-autopush.sh \"$REPO_DIR\" \"...\""
echo " 2. Break into smaller commits (<4 files each)"
echo " 3. If reviewed already, pass --skip-review flag"
exit 1
fi
rm -f "$SKIP_REVIEW_FILE"
# ============ 5. 执行 ============
if [ "$LOW_QUALITY" = true ]; then
echo "❌ Commit rejected: message is too generic."
echo " Generated: $COMMIT_MSG"
echo ""
echo "You must provide intent. Choose one:"
echo " ap-intent 'feat(scope): 你在干什么' [done|wip]"
echo " smart-autopush.sh /repo \"feat(scope): description\" [done|wip]"
exit 1
fi
echo "📝 Commit: $COMMIT_MSG"
# Only git add -A if nothing is already staged
if git diff --cached --quiet; then
git add -A
fi
# Amend safety check
if [ "$AMEND_MODE" = true ]; then
if ! check_amend_message_match "$COMMIT_MSG" "$FORCE_SAME_MSG"; then
exit 1
fi
git commit --amend -m "$COMMIT_MSG"
else
git commit -m "$COMMIT_MSG"
fi
if git remote get-url origin &>/dev/null; then
GIT_PUSH_OUT=$(git push origin HEAD 2>&1)
GIT_PUSH_CODE=$?
echo "$GIT_PUSH_OUT" | head -3
if [ $GIT_PUSH_CODE -eq 0 ]; then
echo "✅ Pushed"
if [ "$USE_INTENT" = true ]; then
rm -f "$INTENT_FILE"
echo "🗑️ Removed .autopush-intent"
fi
else
# Determine if gh-api fallback should be used
USE_GH_FALLBACK=false
if [ "$REPO_DIR" = "$HOME/.claude" ]; then
USE_GH_FALLBACK=true
elif git config --get claude.ghApiFallback >/dev/null 2>&1; then
USE_GH_FALLBACK=true
elif [ -n "$GH_API_FALLBACK_REPOS" ] && echo "$GH_API_FALLBACK_REPOS" | grep -qF "$REPO_DIR"; then
USE_GH_FALLBACK=true
fi
if [ "$USE_GH_FALLBACK" = true ] && gh auth status &>/dev/null; then
echo "⚠️ git push failed, trying gh api fallback..."
bash "$HOME/.claude/scripts/gh-api-push.sh"
if [ "$USE_INTENT" = true ]; then
rm -f "$INTENT_FILE"
echo "🗑️ Removed .autopush-intent"
fi
else
# TLS/SSL errors in stash proxy mode → exit 0 fast, don't retry
if echo "$GIT_PUSH_OUT" | grep -qiE '(SSL_ERROR_SYSCALL|LibreSSL|connection reset|certificate|fatal: unable to access|GnuTLS)'; then
echo "⚠️ TLS/SSL error (stash proxy). Push skipped silently."
echo " (GitHub remote may be unreachable via git; try gh CLI or verify proxy settings)"
# Exit 0 — this is expected network condition, not a real failure
exit 0
fi
echo "❌ Push failed"
exit 1
fi
fi
else
echo "✅ Committed (no remote)"
if [ "$USE_INTENT" = true ]; then
rm -f "$INTENT_FILE"
echo "🗑️ Removed .autopush-intent"
fi
fi
ap-intent.sh
快速创建 .autopush-intent 文件,用于声明本次提交的语义化信息。
#!/bin/bash
# ap-intent: 快速创建 .autopush-intent 文件
# 用法:
# ap-intent "feat(ui): 添加暗黑模式切换" done
# ap-intent "fix(nav): 修复移动端菜单展开问题" wip
# ap-intent "feat(auth): 添加登录" done --skip-review
set -e
SKIP_REVIEW=false
args=()
for arg in "$@"; do
case "$arg" in
--skip-review)
SKIP_REVIEW=true
;;
*)
args+=("$arg")
;;
esac
done
MSG="${args[0]:-}"
STATUS="${args[1]:-wip}"
REPO_DIR="${args[2]:-$(pwd)}"
INTENT_FILE="$REPO_DIR/.autopush-intent"
SKIP_REVIEW_FILE="$REPO_DIR/.autopush-skip-review"
if [ -z "$MSG" ] || [ "${MSG#--}" != "$MSG" ]; then
echo "Usage: ap-intent \"type(scope): description\" [done|wip] [repo_dir] [--skip-review]"
echo ""
echo "Examples:"
echo ' ap-intent "feat(auth): 添加 GitHub OAuth 登录支持" wip'
echo ' ap-intent "fix(layout): 修复 BaseLayout 的 FOUC 问题" done'
echo ' ap-intent "refactor(cv): 抽离 CVLayout 组件" wip /path/to/repo'
echo ' ap-intent "feat(auth): 添加登录" done --skip-review'
exit 1
fi
printf '%s\n%s\n' "$MSG" "$STATUS" > "$INTENT_FILE"
if [ "$SKIP_REVIEW" = true ]; then
touch "$SKIP_REVIEW_FILE"
echo "🔓 Review skip marker created"
fi
echo "📝 Intent saved: $MSG ($STATUS)"
echo " 文件: $INTENT_FILE"
gh-api-push.sh
GitHub Git Data API fallback。当 git push 失败时(通常是 TLS/代理问题),通过 gh CLI 直接调用 GitHub API 完成推送。
#!/bin/bash
# gh-api-push.sh: Atomic multi-file push via GitHub Git Data API
# Fallback when git push fails (e.g., Stash fake-ip / LibreSSL TLS issues)
# Uses /git/blobs, /git/trees, /git/commits, /git/refs for atomic single-commit
# or multi-commit push. Auto-infers owner/repo from git remote.
#
# Usage: gh-api-push.sh [owner/repo] [branch]
set -e
REPO_SLUG="${1:-}"
BRANCH="${2:-$(git rev-parse --abbrev-ref HEAD)}"
cd "$(git rev-parse --show-toplevel)" 2>/dev/null || { echo "❌ Not a git repo"; exit 1; }
# Infer repo slug from remote if not provided
if [ -z "$REPO_SLUG" ]; then
REMOTE_URL=$(git remote get-url origin 2>/dev/null || echo "")
if [[ "$REMOTE_URL" =~ github\.com[^:/]*[:/]([^/]+)/([^/]+)(\.git)?$ ]]; then
REPO_SLUG="${BASH_REMATCH[1]}/${BASH_REMATCH[2]%.git}"
else
echo "❌ Cannot infer repo slug from remote: $REMOTE_URL"
exit 1
fi
fi
if ! gh auth status &>/dev/null; then
echo "❌ gh CLI not authenticated"
exit 1
fi
REMOTE_BRANCH="origin/${BRANCH}"
COMMITS=$(git rev-list --reverse "${REMOTE_BRANCH}..HEAD" 2>/dev/null || true)
if [ -z "$COMMITS" ]; then
echo "⏭️ No unpushed commits"
exit 0
fi
COMMIT_COUNT=$(echo "$COMMITS" | wc -l | awk '{print $1}')
echo "🔄 git push failed, using Git Data API fallback for ${REPO_SLUG}@${BRANCH}..."
echo " Unpushed commits: ${COMMIT_COUNT}"
# Get remote latest commit SHA (parent of first unpushed commit)
PARENT_SHA=$(gh api "repos/${REPO_SLUG}/git/ref/heads/${BRANCH}" --jq '.object.sha' 2>/dev/null || echo "")
if [ -z "$PARENT_SHA" ]; then
echo "❌ Failed to get remote ref for ${BRANCH}"
exit 1
fi
CURRENT_PARENT="$PARENT_SHA"
# Helper: build tree JSON and create tree on GitHub
create_tree_for_commit() {
local commit_sha="$1"
local base_tree="$2"
local repo="$3"
local changed_files
changed_files=$(git diff-tree --no-commit-id --name-status -r "$commit_sha")
if [ -z "$changed_files" ]; then
echo "$base_tree"
return
fi
local tmpdir
tmpdir=$(mktemp -d)
# trap cleanup in caller to avoid nested trap issues
while IFS= read -r line; do
[ -z "$line" ] && continue
local status file
status=$(echo "$line" | cut -f1)
file=$(echo "$line" | cut -f2-)
case "$status" in
A|M)
if [ -f "$file" ]; then
local content blob_sha
content=$(base64 -i "$file" | tr -d '\n')
blob_sha=$(gh api "repos/${repo}/git/blobs" --method POST \
-f content="$content" -f encoding="base64" --jq '.sha')
printf '%s\t%s\n' "$file" "$blob_sha" >> "$tmpdir/blobs.map"
fi
;;
D)
printf '%s\tDELETE\n' "$file" >> "$tmpdir/blobs.map"
;;
esac
done <<< "$changed_files"
local tree_json
tree_json=$(python3 -c "
import json, sys
base = sys.argv[1]
entries = []
with open(sys.argv[2], 'r') as f:
for line in f:
line = line.rstrip('\n')
if '\t' not in line:
continue
path, sha = line.split('\t', 1)
if sha == 'DELETE':
entries.append({'path': path, 'mode': '100644', 'type': 'blob', 'sha': None})
else:
entries.append({'path': path, 'mode': '100644', 'type': 'blob', 'sha': sha})
print(json.dumps({'tree': entries, 'base_tree': base}))
" "$base_tree" "$tmpdir/blobs.map")
gh api "repos/${repo}/git/trees" --method POST --input - --jq '.sha' <<< "$tree_json"
rm -rf "$tmpdir"
}
# Helper: create commit with preserved authorship
create_commit() {
local msg="$1"
local tree_sha="$2"
local parent="$3"
local repo="$4"
local commit_sha="$5"
local author_name author_email author_date committer_name committer_email committer_date
author_name=$(git log -1 --pretty=format:%an "$commit_sha")
author_email=$(git log -1 --pretty=format:%ae "$commit_sha")
author_date=$(git log -1 --pretty=format:%aI "$commit_sha")
committer_name=$(git log -1 --pretty=format:%cn "$commit_sha")
committer_email=$(git log -1 --pretty=format:%ce "$commit_sha")
committer_date=$(git log -1 --pretty=format:%cI "$commit_sha")
local commit_json
commit_json=$(python3 -c "
import json, sys
print(json.dumps({
'message': sys.argv[1],
'tree': sys.argv[2],
'parents': [sys.argv[3]],
'author': {'name': sys.argv[4], 'email': sys.argv[5], 'date': sys.argv[6]},
'committer': {'name': sys.argv[7], 'email': sys.argv[8], 'date': sys.argv[9]}
}))
" "$msg" "$tree_sha" "$parent" "$author_name" "$author_email" "$author_date" \
"$committer_name" "$committer_email" "$committer_date")
gh api "repos/${repo}/git/commits" --method POST --input - --jq '.sha' <<< "$commit_json"
}
# Process each unpushed commit in order
while IFS= read -r COMMIT_SHA; do
[ -z "$COMMIT_SHA" ] && continue
commit_msg=$(git log -1 --pretty=format:%B "$COMMIT_SHA")
base_tree=""
tree_sha=""
new_commit_sha=""
# Determine base tree: for first commit use remote parent's tree,
# for subsequent commits use the previously created commit's tree.
if [ "$CURRENT_PARENT" = "$PARENT_SHA" ]; then
base_tree=$(gh api "repos/${REPO_SLUG}/git/commits/${PARENT_SHA}" --jq '.tree.sha')
else
base_tree=$(gh api "repos/${REPO_SLUG}/git/commits/${CURRENT_PARENT}" --jq '.tree.sha')
fi
tree_sha=$(create_tree_for_commit "$COMMIT_SHA" "$base_tree" "$REPO_SLUG")
new_commit_sha=$(create_commit "$commit_msg" "$tree_sha" "$CURRENT_PARENT" "$REPO_SLUG" "$COMMIT_SHA")
CURRENT_PARENT="$new_commit_sha"
echo " ✅ Created commit ${new_commit_sha:0:7}: $(echo "$commit_msg" | head -1)"
done <<< "$COMMITS"
# Update branch reference
gh api "repos/${REPO_SLUG}/git/refs/heads/${BRANCH}" --method PATCH -f sha="$CURRENT_PARENT" >/dev/null
echo "🎉 Atomic fallback push complete (${COMMIT_COUNT} commit(s))"