git-sync: 新增一键 git 同步技能(status/sync/pull-ff/resolve + 冲突决策流程)

This commit is contained in:
2026-09-14 18:44:13 +08:00
parent 4a87701f8b
commit 3b762b4c25
2 changed files with 252 additions and 0 deletions

78
git-sync/SKILL.md Normal file
View File

@@ -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 <repo> status
```
脚本会 fetch 远端并输出:ahead/behind 计数、未提交变更清单、diff --stat、未跟踪文件 —— 既是拟提交说明的依据,也用于判断是否分叉。
2. **全流程同步**(拟好提交说明后):
```bash
bash ~/.hermes/skills/custom/git-sync/scripts/git-sync.sh <repo> 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 <repo> status` | fetch + 状态 + 变更摘要(拟提交说明用) |
| `git-sync.sh <repo> sync -m "msg"` | 全流程同步,成功打印提交信息 |
| `git-sync.sh <repo> pull-ff` | 仅 fast-forward pull |
| `git-sync.sh <repo> 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 复用时同样适用,别只当本技能专用

174
git-sync/scripts/git-sync.sh Executable file
View File

@@ -0,0 +1,174 @@
#!/usr/bin/env bash
# git-sync.sh — 对指定 git 仓库做同步:pull → commit → push(分叉/冲突时中断并交回决策)
#
# 用法:
# git-sync.sh <repo> status # fetch + 状态 + 变更摘要(拟提交说明用)
# git-sync.sh <repo> sync -m "<提交说明>" # 全流程同步
# git-sync.sh <repo> pull-ff # 仅 fast-forward pull
# git-sync.sh <repo> 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 <repo> <status|sync|pull-ff|resolve> [...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 <repo> 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