coding_agent

工具

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))"