From 3b762b4c252bdb7d011218bc0951cfe6fb28e521 Mon Sep 17 00:00:00 2001 From: weishen Date: Mon, 14 Sep 2026 18:44:13 +0800 Subject: [PATCH] =?UTF-8?q?git-sync:=20=E6=96=B0=E5=A2=9E=E4=B8=80?= =?UTF-8?q?=E9=94=AE=20git=20=E5=90=8C=E6=AD=A5=E6=8A=80=E8=83=BD=EF=BC=88?= =?UTF-8?q?status/sync/pull-ff/resolve=20+=20=E5=86=B2=E7=AA=81=E5=86=B3?= =?UTF-8?q?=E7=AD=96=E6=B5=81=E7=A8=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- git-sync/SKILL.md | 78 ++++++++++++++++ git-sync/scripts/git-sync.sh | 174 +++++++++++++++++++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 git-sync/SKILL.md create mode 100755 git-sync/scripts/git-sync.sh diff --git a/git-sync/SKILL.md b/git-sync/SKILL.md new file mode 100644 index 0000000..9e034f1 --- /dev/null +++ b/git-sync/SKILL.md @@ -0,0 +1,78 @@ +--- +name: git-sync +description: Use when 用户说"对xxx目录做一次 git 同步/提交推送/同步一下xxx"。对指定 git 仓库执行 pull→冲突检查→拟提交说明→commit→push 全流程。 +category: custom +metadata: + author: Hermes Agent + last_updated: "2026-09-14" +--- + +# Git Sync — 指定仓库一键同步 + +触发器:**"对 xxx 目录做一次 git 同步"** 或等效说法("同步一下 xxx 目录 / 提交推送 xxx")。目录无需是仓库根目录,脚本自动定位。 + +## 工作流 + +1. **分析现状(必先做)**: + + ```bash + bash ~/.hermes/skills/custom/git-sync/scripts/git-sync.sh status + ``` + + 脚本会 fetch 远端并输出:ahead/behind 计数、未提交变更清单、diff --stat、未跟踪文件 —— 既是拟提交说明的依据,也用于判断是否分叉。 + +2. **全流程同步**(拟好提交说明后): + + ```bash + bash ~/.hermes/skills/custom/git-sync/scripts/git-sync.sh sync -m "<提交说明>" + ``` + + 自动执行:fetch → 仅落后则 `pull --ff-only` → `add -A` → `commit` → `push` → 打印提交 hash / 标题 / 文件统计 / 远端确认。 + +3. **按退出码分支处理**: + + - `3` **DIVERGED**(本地与远端分叉)→ 必须用 clarify 问用户选哪边: + - **merge 合并**:`resolve merge`(产生合并提交,两边内容都保留) + - **以远程为主**:`resolve remote`(丢弃本地提交,破坏性) + - **以本地为主**:`resolve local`(force push 覆盖远端,破坏性) + - `4` **CONFLICT**:先跑 `git status` 看冲突文件,向用户报告是"pull 时本地改动与远端更新冲突"还是"merge 后的 unmerged paths";按用户指示解决后(编辑文件 / 选边 / `git add` + `git commit`)再重跑 sync + - `5` **NO_CHANGES**:如实报告"工作区干净、无变更",不硬造提交 + +4. **汇报结果**:提交短 hash(附长 hash)、标题、`N files changed, X insertions(+), Y deletions(-)`、push 远端确认行。 + +## 命令 + +| 命令 | 作用 | +|---|---| +| `git-sync.sh status` | fetch + 状态 + 变更摘要(拟提交说明用) | +| `git-sync.sh sync -m "msg"` | 全流程同步,成功打印提交信息 | +| `git-sync.sh pull-ff` | 仅 fast-forward pull | +| `git-sync.sh resolve merge\|remote\|local` | 分叉后的处理;remote/local 破坏性操作需 `GIT_SYNC_CONFIRM=yes` | + +## 退出码 + +| 码 | 含义 | +|---|---| +| 0 | 成功 | +| 2 | 参数错误 / 目录不存在 | +| 3 | 分叉,需用户决策(或 push 被拒需重新决策) | +| 4 | 冲突(pull 被拒 / 存在 unmerged paths) | +| 5 | 无变更可提交 | +| 6 | 非 git 仓库 / 分支无 upstream / 无远端 | +| 7 | sync 缺 `-m` 提交说明 | +| 8 | 破坏性操作缺 `GIT_SYNC_CONFIRM=yes` | + +## 提交说明写法 + +- 依据 `status` 输出的 diff --stat 与文件清单写,句式:`<动词+对象>: <做了什么/为什么>`,如 `feat(blogwatcher): 新增企业 RSS 源并修正去重逻辑` +- 多文件混合改动时突出主体内容,不写 "update" 空话 +- 有破坏性变更或格式整体变化时在说明中点明 + +## 坑 + +- **先 `status` 再 `sync`**:提交说明必须基于真实 diff;直接盲跑 sync 无法拟说明 +- 本地有未提交改动且落后于远端时,ff pull 会失败(本地改动将被覆盖)→ 属冲突路径(码 4),去问用户,不要 stash 后盲推 +- sync 检测到 unmerged paths(`git ls-files -u`)会直接拒绝提交,先解决冲突 +- `resolve remote`(`git reset --hard`)与 `resolve local`(force push)都会丢内容,必须先经 clarify 拿到用户明确选择,脚本侧再加 `GIT_SYNC_CONFIRM=yes` +- push 失败(码 3)通常是 pull 后又有人推了东西:重新 `status`,再走分叉决策 +- 脚本由其他技能/agent 复用时同样适用,别只当本技能专用 \ No newline at end of file diff --git a/git-sync/scripts/git-sync.sh b/git-sync/scripts/git-sync.sh new file mode 100755 index 0000000..f3de971 --- /dev/null +++ b/git-sync/scripts/git-sync.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# git-sync.sh — 对指定 git 仓库做同步:pull → commit → push(分叉/冲突时中断并交回决策) +# +# 用法: +# git-sync.sh status # fetch + 状态 + 变更摘要(拟提交说明用) +# git-sync.sh sync -m "<提交说明>" # 全流程同步 +# git-sync.sh pull-ff # 仅 fast-forward pull +# git-sync.sh resolve merge|remote|local # 分叉后处理;remote/local 需 GIT_SYNC_CONFIRM=yes +# +# 退出码: 0=成功 2=参数错 3=分叉/push被拒需决策 4=冲突 5=无变更 +# 6=非git仓库或无upstream 7=缺提交说明 8=破坏性操作缺确认 + +set -uo pipefail + +die() { echo "ERROR: $*" >&2; exit "${2:-2}"; } + +[ $# -lt 2 ] && { echo "usage: git-sync.sh [...args]" >&2; exit 2; } +REPO="$1"; CMD="$2"; shift 2 + +[ -d "$REPO" ] || die "目录不存在: $REPO" 2 +REPO="$(cd "$REPO" && pwd)" +cd "$REPO" +git rev-parse --is-inside-work-tree >/dev/null 2>&1 || die "不是 git 仓库: $REPO" 6 +BRANCH="$(git symbolic-ref --short HEAD 2>/dev/null || true)" +UPSTREAM="$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null || true)" +[ -n "$UPSTREAM" ] || die "分支 $BRANCH 无 upstream(先 git push -u origin $BRANCH)" 6 + +ahead_behind() { + local L R + read -r L R < <(git rev-list --left-right --count HEAD..."$UPSTREAM" 2>/dev/null) + echo "${L:-0} ${R:-0}" +} + +status_cmd() { + git fetch origin >/dev/null 2>&1 || true + local AHEAD BEHIND + read -r AHEAD BEHIND < <(ahead_behind) + echo "repo: $REPO" + echo "branch: $BRANCH (upstream: $UPSTREAM)" + echo "ahead: $AHEAD behind: $BEHIND" + [ "$AHEAD" -gt 0 ] && [ "$BEHIND" -gt 0 ] && echo "STATE: DIVERGED — 需用户选择 merge / 以远程为主 / 以本地为主" + + local PORCELAIN UNMERGED + PORCELAIN="$(git status --porcelain)" + UNMERGED="$(git ls-files -u | head -1)" + if [ -z "$PORCELAIN" ]; then + echo "changes: 无(工作区干净)" + else + echo "changes: $(printf '%s\n' "$PORCELAIN" | wc -l | tr -d ' ') 项未提交" + [ -n "$UNMERGED" ] && echo "STATE: UNMERGED_PATHS — 存在未解决冲突,sync 会拒绝提交" + echo "--- git status --short ---" + printf '%s\n' "$PORCELAIN" + echo "--- diff --stat(已跟踪改动) ---" + git diff --stat HEAD 2>/dev/null | tail -n +1 + echo "--- 未跟踪文件 ---" + git ls-files --others --exclude-standard + fi + exit 0 +} + +pull_ff() { + local AHEAD BEHIND + read -r AHEAD BEHIND < <(ahead_behind) + [ "$AHEAD" -eq 0 ] && [ "$BEHIND" -eq 0 ] && { echo "already up to date"; exit 0; } + if [ "$AHEAD" -gt 0 ] && [ "$BEHIND" -gt 0 ]; then + echo "DIVERGED: ahead=$AHEAD behind=$BEHIND — 需用户决策后再处理(resolve)" >&2 + exit 3 + fi + echo "==> git pull --ff-only origin $BRANCH" + if ! git pull --ff-only origin "$BRANCH" 2>&1; then + echo "CONFLICT: pull 失败 — 本地未提交改动与远端更新冲突,或需要 merge" >&2 + exit 4 + fi + git log -1 --format='pulled: %h %s' + exit 0 +} + +sync_cmd() { + local MSG + [ "${1:-}" = "-m" ] && shift + MSG="${1:-}" + [ -n "$MSG" ] || die "sync 需要 -m 提供提交说明: git-sync.sh sync -m \"...\"" 7 + + # 未解决冲突禁止提交 + git ls-files -u | grep -q . \ + && { echo "CONFLICT: 存在未解决冲突(unmerged paths),先解决再 sync" >&2; exit 4; } + + git fetch origin >/dev/null 2>&1 || true + local AHEAD BEHIND PORCELAIN PUSH_OUT PUSH_OK + read -r AHEAD BEHIND < <(ahead_behind) + + if [ "$AHEAD" -gt 0 ] && [ "$BEHIND" -gt 0 ]; then + echo "DIVERGED: ahead=$AHEAD behind=$BEHIND — 需用户选择 merge / 以远程为主 / 以本地为主" >&2 + exit 3 + fi + if [ "$BEHIND" -gt 0 ]; then + echo "==> pull --ff-only origin $BRANCH" + if ! git pull --ff-only origin "$BRANCH" 2>&1; then + echo "CONFLICT: pull 失败 — 本地未提交改动与远端更新冲突" >&2 + exit 4 + fi + fi + + PORCELAIN="$(git status --porcelain)" + if [ -z "$PORCELAIN" ]; then + echo "NO_CHANGES: 工作区干净,无需提交" + exit 5 + fi + + echo "==> git add -A" + git add -A + echo "==> git commit -m \"$MSG\"" + git commit -m "$MSG" >/dev/null || die "commit 失败" 2 + + echo "==> git push origin $BRANCH" + PUSH_OUT="$(git push origin "$BRANCH" 2>&1)" + PUSH_OK=$? + if [ $PUSH_OK -ne 0 ]; then + echo "PUSH_FAILED: 远端可能有新提交,重新 status 后走决策流程" >&2 + printf '%s\n' "$PUSH_OUT" >&2 + exit 3 + fi + + echo "---" + echo "SYNC_OK" + echo "repo: $REPO" + echo "branch: $BRANCH" + git log -1 --format='commit: %h (%H)' + git log -1 --format='title: %s' + git log -1 --format='author: %an <%ae> date: %ci' + git show --stat --oneline HEAD | tail -1 | sed 's/^/stats: /' + printf 'push: %s\n' "$(printf '%s\n' "$PUSH_OUT" | grep -v '^$' | tail -1)" + exit 0 +} + +resolve_cmd() { + local SIDE="${1:-}" CONFIRM="${GIT_SYNC_CONFIRM:-}" + case "$SIDE" in + merge) + echo "==> git pull --no-rebase origin $BRANCH (合并远端,两边内容都保留)" + if ! git pull --no-rebase origin "$BRANCH" 2>&1; then + echo "CONFLICT: 合并产生冲突 — 查看 git status 的 unmerged paths,解决后 git add + git commit,再重跑 sync" >&2 + exit 4 + fi + git log -1 --format='merged: %h %s' + ;; + remote) + [ "$CONFIRM" = "yes" ] || { echo "DESTRUCTIVE: 以远程为主将丢弃本地提交(git reset --hard @{u}),需 GIT_SYNC_CONFIRM=yes" >&2; exit 8; } + git reset --hard "$UPSTREAM" + echo "reset to $UPSTREAM: $(git log -1 --format='%h %s')" + ;; + local) + [ "$CONFIRM" = "yes" ] || { echo "DESTRUCTIVE: 以本地为主将强制推送覆盖远端(push --force-with-lease),需 GIT_SYNC_CONFIRM=yes" >&2; exit 8; } + local PUSH_OUT PUSH_OK + PUSH_OUT="$(git push --force-with-lease origin "$BRANCH" 2>&1)" + PUSH_OK=$? + if [ $PUSH_OK -ne 0 ]; then + printf '%s\n' "$PUSH_OUT" >&2 + die "force push 失败" 3 + fi + printf '%s\n' "$PUSH_OUT" | grep -v '^$' | tail -1 + ;; + *) die "resolve 选项: merge|remote|local" 2 ;; + esac + exit 0 +} + +case "$CMD" in + status) status_cmd ;; + sync) sync_cmd "$@" ;; + pull-ff) pull_ff ;; + resolve) resolve_cmd "${1:-}" ;; + *) die "未知命令: $CMD(可用 status|sync|pull-ff|resolve)" 2 ;; +esac \ No newline at end of file