- 默认区间模式:基线取 air-cargo 最近的 Synced-From,目标为 main,内容取自提交对象 - 无记录时按内容反推基线(仅预览,apply 须 --base 确认),每次预览做基线校验 - 新增三方校验,列出将覆盖的 air-cargo 侧独立修改;分支/工作区/祖先关系保护 - --worktree 同步未提交变更且不推进同步点;提交信息仅「<Label> <日期>」+ trailer,不 push - 新增 config(main → feature/hefei,air-cargo 位于项目根目录) - 排除清单补充 .mcp.json、.claude.*、CLAUDE.local.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
277 lines
12 KiB
Bash
Executable File
277 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# 将 aerologic-app 的变更以文件级 cp/rm 同步到 air-cargo,并在 air-cargo 本地提交(不 push)。
|
||
# 两个目录是同一份项目源码的独立 git 仓库副本,只做文件级复制/删除,不做任何内容编辑。
|
||
#
|
||
# 同步点记录在 air-cargo 提交信息的 trailer「Synced-From: aerologic-app@<sha>」中:
|
||
# - 区间模式(默认):基线 = air-cargo 当前分支最近一个 Synced-From,目标 = 源仓库 SOURCE_BRANCH 最新提交。
|
||
# 文件内容取自目标提交对象,与源仓库工作区无关;提交时写入新的 Synced-From,推进同步点
|
||
# - 工作区模式(--worktree):同步源仓库未提交的变更;提交时不写 Synced-From,不推进同步点
|
||
# 没有同步记录时按内容反推基线(目标第一父链上与 air-cargo HEAD 差异最少的提交),仅供确认,apply 须显式 --base
|
||
set -euo pipefail
|
||
export GIT_LITERAL_PATHSPECS=1
|
||
|
||
usage() {
|
||
cat <<'EOF'
|
||
用法: sync.sh [preview|apply] [选项]
|
||
preview 只打印同步计划,不做任何改动(默认)
|
||
apply 复制/删除文件并在 air-cargo 本地提交(不 push)
|
||
选项:
|
||
--base <ref> 指定基线:没有 Synced-From 记录(首次同步)或需要纠正时使用
|
||
--to <ref> 指定目标提交,默认 config 中的 SOURCE_BRANCH
|
||
--worktree 工作区模式:同步源仓库未提交的变更,不推进同步点
|
||
--label <word> 提交信息的通用词,默认 Update(可用 Fixed、Optimized 等)
|
||
EOF
|
||
}
|
||
|
||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
# shellcheck source=../config
|
||
. "$SCRIPT_DIR/../config"
|
||
SOURCE_DIR="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||
TARGET_DIR="$SOURCE_DIR/$TARGET_SUBDIR"
|
||
SOURCE_NAME="$(basename "$SOURCE_DIR")"
|
||
|
||
MODE=preview BASE_ARG="" TO_ARG="" WORKTREE=0 LABEL=Update
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
preview|apply) MODE="$1" ;;
|
||
--base) BASE_ARG="${2:?--base 缺少参数}"; shift ;;
|
||
--to) TO_ARG="${2:?--to 缺少参数}"; shift ;;
|
||
--worktree) WORKTREE=1 ;;
|
||
--label) LABEL="${2:?--label 缺少参数}"; shift ;;
|
||
-h|--help) usage; exit 0 ;;
|
||
*) echo "未知参数:$1" >&2; usage >&2; exit 1 ;;
|
||
esac
|
||
shift
|
||
done
|
||
|
||
die() { echo "错误:$*" >&2; exit 1; }
|
||
S() { git -C "$SOURCE_DIR" "$@"; }
|
||
T() { git -C "$TARGET_DIR" "$@"; }
|
||
short() { S rev-parse --short "$1"; }
|
||
|
||
TMP="$(mktemp -d)"
|
||
trap 'rm -rf "$TMP"' EXIT
|
||
|
||
# 只同步工程项目本身的文件,Claude Code 相关的配置/记忆文件(CLAUDE.md、CLAUDE.local.md、.claude/ 目录、根目录 .claude.* 与 .mcp.json,
|
||
# 包括其中的 skills/commands 等)两个仓库各自独立维护,不参与同步。
|
||
is_excluded() {
|
||
case "$1" in
|
||
.claude|.claude/*|.claude.*|.mcp.json) return 0 ;;
|
||
CLAUDE.md|*/CLAUDE.md|CLAUDE.local.md|*/CLAUDE.local.md) return 0 ;;
|
||
esac
|
||
return 1
|
||
}
|
||
|
||
# 列出提交中参与同步的文件「路径<TAB>blob」(已排序),用于按内容比对两个仓库
|
||
filtered_tree() {
|
||
git -C "$1" -c core.quotepath=false ls-tree -r "$2" | awk -F'\t' '
|
||
$2 ~ /^\.claude($|\/|\.)/ || $2 == ".mcp.json" || $2 ~ /(^|\/)CLAUDE(\.local)?\.md$/ { next }
|
||
{ split($1, m, " "); print $2 "\t" m[3] }' | LC_ALL=C sort
|
||
}
|
||
|
||
# 两份 filtered_tree 输出中内容不同的路径(去重)
|
||
diff_paths() {
|
||
LC_ALL=C comm -3 "$1" "$2" | sed $'s/^\t//' | cut -f1 | LC_ALL=C sort -u
|
||
}
|
||
|
||
blob_at() { S rev-parse -q --verify "$1:$2" 2>/dev/null || true; }
|
||
|
||
# ---------- 前置检查 ----------
|
||
[ -d "$SOURCE_DIR/.git" ] || die "$SOURCE_DIR 不是 git 仓库根目录"
|
||
[ -d "$TARGET_DIR/.git" ] || die "目标仓库不存在:$TARGET_DIR"
|
||
cur_branch="$(T branch --show-current)"
|
||
[ "$cur_branch" = "$TARGET_BRANCH" ] ||
|
||
die "air-cargo 当前分支为「${cur_branch:-游离 HEAD}」,应为「${TARGET_BRANCH}」(见 config),请先与用户确认"
|
||
|
||
# ---------- 确定基线与目标 ----------
|
||
BASE="" BASE_FROM="" TO="" INFERRED=0
|
||
if [ "$WORKTREE" -eq 1 ]; then
|
||
[ -z "$BASE_ARG$TO_ARG" ] || die "--worktree 不能与 --base / --to 同时使用"
|
||
BASE="$(S rev-parse HEAD)"
|
||
BASE_FROM="源仓库 HEAD(工作区模式)"
|
||
else
|
||
TO="$(S rev-parse -q --verify "${TO_ARG:-$SOURCE_BRANCH}^{commit}")" || die "源仓库找不到 ${TO_ARG:-$SOURCE_BRANCH}"
|
||
if [ -n "$BASE_ARG" ]; then
|
||
BASE="$(S rev-parse -q --verify "$BASE_ARG^{commit}")" || die "源仓库找不到基线 $BASE_ARG"
|
||
BASE_FROM="--base 参数"
|
||
else
|
||
record="$(T log -1 --grep='^Synced-From: ' --format='%(trailers:key=Synced-From,valueonly)' | sed -n '1p')"
|
||
if [ -n "$record" ]; then
|
||
BASE="$(S rev-parse -q --verify "${record##*@}^{commit}")" ||
|
||
die "air-cargo 记录的同步点 ${record##*@} 在源仓库中不存在(源仓库被 rebase / force-push 过?),请用 --base 指定"
|
||
BASE_FROM="air-cargo 提交记录中的 Synced-From"
|
||
fi
|
||
fi
|
||
|
||
filtered_tree "$TARGET_DIR" HEAD > "$TMP/target.tree"
|
||
if [ -z "$BASE" ]; then
|
||
# 兜底:按内容反推,在目标的第一父链上找与 air-cargo HEAD 差异最少的提交
|
||
best_n=-1
|
||
for c in $(S rev-list --first-parent --max-count=300 "$TO"); do
|
||
filtered_tree "$SOURCE_DIR" "$c" > "$TMP/cand.tree"
|
||
n="$(diff_paths "$TMP/cand.tree" "$TMP/target.tree" | wc -l | tr -d ' ')"
|
||
if [ "$best_n" -lt 0 ] || [ "$n" -lt "$best_n" ]; then BASE="$c"; best_n="$n"; fi
|
||
[ "$n" -eq 0 ] && break
|
||
done
|
||
[ -n "$BASE" ] || die "无法反推基线,请用 --base 指定"
|
||
INFERRED=1
|
||
BASE_FROM="按内容反推(air-cargo 无 Synced-From 记录),需确认后以 --base 传入"
|
||
fi
|
||
S merge-base --is-ancestor "$BASE" "$TO" || die "基线 $(short "$BASE") 不是目标 $(short "$TO") 的祖先"
|
||
fi
|
||
|
||
# ---------- 计算变更清单 ----------
|
||
copy_list=() delete_list=() excluded_list=()
|
||
classify() { # $1=状态 $2=路径 $3=同步后文件是否应存在(1/0)
|
||
[ -z "$2" ] && return
|
||
if is_excluded "$2"; then excluded_list+=("$1|$2")
|
||
elif [ "$3" -eq 1 ]; then copy_list+=("$1|$2")
|
||
else delete_list+=("$1|$2")
|
||
fi
|
||
}
|
||
if [ "$WORKTREE" -eq 1 ]; then
|
||
# --no-renames:rename 拆成「删除旧路径 + 新增新路径」;--untracked-files=all:新目录逐个文件列出
|
||
while IFS= read -r -d '' entry; do
|
||
p="${entry:3}"
|
||
if [ -e "$SOURCE_DIR/$p" ] || [ -L "$SOURCE_DIR/$p" ]; then classify "${entry:0:2}" "$p" 1; else classify "${entry:0:2}" "$p" 0; fi
|
||
done < <(S status --porcelain=v1 -z --no-renames --untracked-files=all)
|
||
else
|
||
# git diff -z 的 name-status 输出为「状态\0路径\0」交替
|
||
while IFS= read -r -d '' st && IFS= read -r -d '' p; do
|
||
if [ "$st" = D ]; then classify "$st" "$p" 0; else classify "$st" "$p" 1; fi
|
||
done < <(S diff --name-status --no-renames -z "$BASE" "$TO")
|
||
fi
|
||
|
||
new_blob() { # 同步后该路径应有的内容
|
||
if [ "$WORKTREE" -eq 1 ]; then S hash-object -- "$1"; else blob_at "$TO" "$1"; fi
|
||
}
|
||
|
||
# 三方校验:air-cargo 现有内容既不等于基线、也不等于同步后的内容,说明 air-cargo 侧有独立修改,会被覆盖
|
||
conflict_list=()
|
||
for item in ${copy_list[@]+"${copy_list[@]}"} ${delete_list[@]+"${delete_list[@]}"}; do
|
||
st="${item%%|*}" p="${item#*|}"
|
||
[ -f "$TARGET_DIR/$p" ] || continue
|
||
tb="$(T hash-object -- "$p")"
|
||
[ "$tb" = "$(blob_at "$BASE" "$p")" ] && continue
|
||
[ "$st" != D ] && [ "$tb" = "$(new_blob "$p")" ] && continue
|
||
conflict_list+=("$p")
|
||
done
|
||
|
||
# air-cargo 工作区只允许存在本次同步范围内的改动(如上次同步未提交,重跑会覆盖为相同内容)
|
||
for item in ${copy_list[@]+"${copy_list[@]}"} ${delete_list[@]+"${delete_list[@]}"}; do
|
||
printf '%s\n' "${item#*|}"
|
||
done | LC_ALL=C sort -u > "$TMP/synced"
|
||
T status --porcelain=v1 -z --no-renames --untracked-files=all | tr '\0' '\n' | cut -c4- | LC_ALL=C sort -u > "$TMP/dirty"
|
||
unrelated="$(LC_ALL=C comm -23 "$TMP/dirty" "$TMP/synced")"
|
||
|
||
# ---------- 提交信息 ----------
|
||
printf '%s %s\n' "$LABEL" "$(date +%Y%m%d)" > "$TMP/msg"
|
||
[ "$WORKTREE" -eq 1 ] || printf '\nSynced-From: %s@%s\n' "$SOURCE_NAME" "$TO" >> "$TMP/msg"
|
||
|
||
# ---------- 打印计划 ----------
|
||
print_list() { # $1=标题 其余=「状态|路径」
|
||
local title="$1"; shift
|
||
echo ""
|
||
echo "=== ${title}($#)==="
|
||
for item in "$@"; do echo " [${item%%|*}] ${item#*|}"; done
|
||
}
|
||
echo "源仓库: $SOURCE_DIR"
|
||
echo "目标仓库: ${TARGET_DIR}(分支 ${TARGET_BRANCH})"
|
||
if [ "$WORKTREE" -eq 1 ]; then
|
||
echo "模式: 工作区模式(同步未提交变更,不推进同步点)"
|
||
else
|
||
echo "模式: 区间模式"
|
||
echo "基线: $(short "$BASE") $(S log -1 --format=%s "$BASE")"
|
||
echo " 来源:$BASE_FROM"
|
||
echo "目标: $(short "$TO") $(S log -1 --format=%s "$TO")(${TO_ARG:-$SOURCE_BRANCH})"
|
||
echo "区间提交数: $(S rev-list --count "$BASE..$TO")"
|
||
[ "$(S rev-parse HEAD)" = "$TO" ] || echo "提示: 源仓库 HEAD 不在目标提交上,同步内容以目标提交为准"
|
||
|
||
# 内容校验:同步点记录正确时,air-cargo HEAD 与基线应只有少量元文件差异
|
||
filtered_tree "$SOURCE_DIR" "$BASE" > "$TMP/base.tree"
|
||
diff_paths "$TMP/base.tree" "$TMP/target.tree" > "$TMP/verify"
|
||
vn="$(wc -l < "$TMP/verify" | tr -d ' ')"
|
||
echo ""
|
||
echo "=== 基线校验:air-cargo HEAD 与基线有 $vn 个文件内容不同 ==="
|
||
sed -n '1,20s/^/ /p' "$TMP/verify"
|
||
[ "$vn" -le 20 ] || echo " ……(其余 $((vn - 20)) 个省略)"
|
||
[ "$vn" -le 50 ] || echo " ⚠ 差异较大:基线可能不对,或 air-cargo 分支落后/不对,请先核实"
|
||
fi
|
||
|
||
if [ "$WORKTREE" -eq 0 ] && [ "$BASE" = "$TO" ]; then
|
||
echo ""
|
||
echo "air-cargo 已同步到 $(short "$TO"),无需同步。"
|
||
exit 0
|
||
fi
|
||
|
||
print_list "待复制/覆盖" ${copy_list[@]+"${copy_list[@]}"}
|
||
print_list "待删除" ${delete_list[@]+"${delete_list[@]}"}
|
||
print_list "已忽略,不参与同步" ${excluded_list[@]+"${excluded_list[@]}"}
|
||
echo ""
|
||
echo "=== ⚠ 将覆盖 air-cargo 侧独立修改(${#conflict_list[@]})==="
|
||
for p in ${conflict_list[@]+"${conflict_list[@]}"}; do echo " $p"; done
|
||
if [ -n "$unrelated" ]; then
|
||
echo ""
|
||
echo "=== ✖ air-cargo 工作区存在与本次同步无关的未提交改动 ==="
|
||
printf '%s\n' "$unrelated" | sed 's/^/ /'
|
||
fi
|
||
echo ""
|
||
echo "=== 提交信息 ==="
|
||
sed 's/^/ /' "$TMP/msg"
|
||
|
||
if [ "$MODE" != apply ]; then
|
||
echo ""
|
||
echo "(预览模式,未执行任何操作)"
|
||
exit 0
|
||
fi
|
||
|
||
# ---------- 执行 ----------
|
||
[ "$INFERRED" -eq 0 ] || die "基线为反推结果,确认无误后请用 --base $(short "$BASE") 重新执行"
|
||
[ -z "$unrelated" ] || die "air-cargo 工作区存在与本次同步无关的未提交改动,请先处理"
|
||
|
||
copied=0 deleted=0
|
||
stage_list=()
|
||
for item in ${copy_list[@]+"${copy_list[@]}"}; do
|
||
p="${item#*|}" dst="$TARGET_DIR/${item#*|}"
|
||
mkdir -p "$(dirname "$dst")"
|
||
rm -f "$dst"
|
||
if [ "$WORKTREE" -eq 1 ]; then
|
||
cp -f "$SOURCE_DIR/$p" "$dst"
|
||
else
|
||
mode="$(S ls-tree "$TO" -- "$p" | cut -d' ' -f1)"
|
||
case "$mode" in
|
||
120000) ln -s "$(S cat-file blob "$TO:$p")" "$dst" ;;
|
||
160000) echo "跳过子模块:$p" >&2; continue ;;
|
||
*) S cat-file blob "$TO:$p" > "$dst"; [ "$mode" != 100755 ] || chmod +x "$dst" ;;
|
||
esac
|
||
fi
|
||
stage_list+=("$p")
|
||
copied=$((copied + 1))
|
||
done
|
||
for item in ${delete_list[@]+"${delete_list[@]}"}; do
|
||
p="${item#*|}"
|
||
if [ -e "$TARGET_DIR/$p" ] || [ -L "$TARGET_DIR/$p" ]; then
|
||
rm -f "$TARGET_DIR/$p"
|
||
deleted=$((deleted + 1))
|
||
fi
|
||
# 只暂存 air-cargo 中已跟踪的路径,否则 git add 会因 pathspec 不匹配而失败
|
||
! T ls-files --error-unmatch -- "$p" > /dev/null 2>&1 || stage_list+=("$p")
|
||
done
|
||
|
||
[ ${#stage_list[@]} -eq 0 ] || printf '%s\0' "${stage_list[@]}" | T add -A --pathspec-from-file=- --pathspec-file-nul
|
||
|
||
echo ""
|
||
echo "同步完成:复制/更新 $copied 个文件,删除 $deleted 个文件。"
|
||
if T diff --cached --quiet; then
|
||
if [ "$WORKTREE" -eq 1 ]; then
|
||
echo "air-cargo 内容已一致,无需提交。"
|
||
exit 0
|
||
fi
|
||
# 区间内只有不参与同步的文件变化时,仍提交一个空提交推进同步点
|
||
T commit -q --allow-empty -F "$TMP/msg"
|
||
else
|
||
T commit -q -F "$TMP/msg"
|
||
fi
|
||
echo "已在 air-cargo 本地提交(未 push):$(T log -1 --format='%h %s')"
|
||
T show --stat --format= HEAD | tail -1
|