Git update-ref:避免脚本覆盖并发的分支更新

164 次浏览7 条回复

脚本先读取分支,再计算新提交,最后写回时,中间可能已经有别的任务改过同一分支。直接更新会把对方的结果盖掉。git update-ref 可以把旧提交一起传入,相当于给这次写入加一个版本检查。

环境:Git 2.39+、Bash、Linux 或 macOS,需要 mktemp。下面只操作临时仓库:

set -eu
repo=$(mktemp -d)
trap 'rm -rf -- "$repo"' EXIT

git init -q -b main "$repo"
git -C "$repo" config user.name 'Update Ref Demo'
git -C "$repo" config user.email '[email protected]'
printf 'base\n' >"$repo/app.txt"
git -C "$repo" add app.txt
git -C "$repo" commit -qm base

old=$(git -C "$repo" rev-parse HEAD)
git -C "$repo" branch deploy "$old"

# 任务 A 算出的候选提交
git -C "$repo" switch -qc candidate
printf 'candidate\n' >>"$repo/app.txt"
git -C "$repo" commit -qam candidate
candidate=$(git -C "$repo" rev-parse HEAD)

# 任务 B 抢先更新 deploy
git -C "$repo" switch -q main
printf 'concurrent\n' >>"$repo/app.txt"
git -C "$repo" commit -qam concurrent
concurrent=$(git -C "$repo" rev-parse HEAD)
git -C "$repo" update-ref refs/heads/deploy "$concurrent" "$old"

# 任务 A 仍拿旧值写入,应该失败
if git -C "$repo" update-ref refs/heads/deploy "$candidate" "$old"; then
  printf 'unexpected update\n' >&2
  exit 1
fi

test "$(git -C "$repo" rev-parse deploy)" = "$concurrent"
printf 'concurrent update preserved\n'

第三个参数是期望的当前值。只有 deploy 还指向 old 时更新才会成功;值已变化就返回非零,脚本可以重新读取并决定重算、重试还是退出。用于发布指针、缓存引用这类由多个任务竞争写入的场景,比“先比较、再单独写入”少一个竞态窗口。

这个场景再加个 -m 挺实用,成功更新会在 reflog 里留下原因:

git -C "$repo" update-ref -m 'deploy: publish candidate' \
  refs/heads/deploy "$candidate" "$old"

另外失败后的“重试”最好不是原样循环。先重新读取 refs/heads/deploy,再基于新值重算候选提交;否则虽然不会覆盖并发更新,但旧候选仍可能在下一轮把分支指到不符合预期的位置。

再补一个边界:这个旧值参数只保证引用没被别人改过,并不保证 candidate 一定是 old 的后继。要是 deploy 只允许快进,可以在更新前加一次祖先检查。沿用楼主的 Git 2.39+、Bash 环境:

git -C "$repo" merge-base --is-ancestor "$old" "$candidate" || {
  printf 'candidate is not a fast-forward\n' >&2
  exit 1
}
git -C "$repo" update-ref refs/heads/deploy "$candidate" "$old"

祖先检查之后即使发生并发更新,第二条命令也会因旧值不匹配而失败,所以这两个条件能一起守住。

如果一次发布要同时推进多个引用,还可以用 --stdin 的事务模式。任意一个旧值不匹配,整批都不会提交。沿用楼主的 Git 2.39+、Bash 环境,假设两个引用和对应旧值都已读出:

{
  printf 'start\n'
  printf 'update refs/heads/deploy %s %s\n' \
    "$candidate" "$old"
  printf 'update refs/meta/deploy %s %s\n' \
    "$candidate" "$old_meta"
  printf 'prepare\n'
  printf 'commit\n'
} | git -C "$repo" update-ref --stdin

这个适合分支指针和自定义元数据引用必须保持一致的场景。prepare 会先检查并锁住这批引用,检查通过后 commit 才真正写入。

还有一个很适合初始化发布指针的用法:把旧值传成空字符串,表示“这个引用必须尚不存在”。这样创建本身也是原子的,不用先 show-ref 再创建:

if ! git -C "$repo" update-ref refs/heads/deploy "$candidate" ""; then
  printf 'deploy already exists or update failed\n' >&2
  exit 1
fi

沿用楼主的 Git 2.39+、Bash 环境即可。多个任务同时首次创建时只会有一个成功,其他任务不会覆盖赢家写入的值。

同样的旧值校验也适合清理发布指针:-d 可以做成“仅当引用仍是我刚看到的值时才删除”。沿用楼主的 Git 2.39+、Bash 环境:

observed=$(git -C "$repo" rev-parse refs/heads/deploy)
if ! git -C "$repo" update-ref -d refs/heads/deploy "$observed"; then
  printf 'deploy changed; keep it\n' >&2
  exit 1
fi

如果读取后有别的任务推进了 deploy,旧值不匹配,删除会失败;这样清理过期发布时不会顺手删掉后来者的新指针。

还有个不太显眼的坑:update-ref 默认会解引用符号引用。要是脚本把 HEAD 当目标,实际改的是它指向的分支,不只是“当前检出位置”。自动化里最好继续像正文这样传完整的 refs/heads/deploy。

可以加个小保护,拦住缩写和 HEAD:

case "$ref" in
  refs/heads/*) git -C "$repo" check-ref-format "$ref" ;;
  *) printf 'ref must be under refs/heads/\n' >&2; exit 2 ;;
esac
git -C "$repo" update-ref "$ref" "$candidate" "$old"

沿用正文的 Git 2.39+、Bash 环境即可。这样 CAS 检查保护的是明确的目标分支,也不容易因为调用方传了 HEAD 而移动错引用。

再补一个脚本里容易忽略的边界:update-ref 不会因为目标分支正在当前工作树中检出就拒绝。沿用正文的 Git 2.39+、Bash 环境,下面会把检出的 main 从 second 拨回 first,但索引和工作区仍保留 second 的内容:

set -eu
repo=$(mktemp -d)
trap 'rm -rf -- "$repo"' EXIT

git init -q -b main "$repo"
git -C "$repo" config user.name 'Update Ref Demo'
git -C "$repo" config user.email '[email protected]'
printf 'first\n' >"$repo/app.txt"
git -C "$repo" add app.txt
git -C "$repo" commit -qm first
first=$(git -C "$repo" rev-parse HEAD)

printf 'second\n' >"$repo/app.txt"
git -C "$repo" commit -qam second
second=$(git -C "$repo" rev-parse HEAD)

git -C "$repo" update-ref refs/heads/main "$first" "$second"
test "$(git -C "$repo" rev-parse HEAD)" = "$first"
git -C "$repo" status --short

最后会看到 app.txt 相对新 HEAD 有暂存改动。发布指针如果不需要被检出,用 refs/deploy/prod 这类专用引用,或者在裸仓库里维护,会更省心。旧值校验保护的是引用并发,不会同步工作区状态。