From 2e9ec6d92746494bc3db676390a64ed4a4aa2e98 Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Thu, 15 Oct 2026 14:02:15 -0700 Subject: [PATCH] fix(repo-walker): overwrite moved tags on pull With fetch.pruneTags set, every fetch carries a non-forced refs/tags/*:refs/tags/* refspec. When upstream re-points a tag, as github-workflows does with its floating v3-style tags, the fetch rejects it as "would clobber existing tag" and the whole pull fails, so my-repos reports the repo as failed and leaves it behind. Pass --force to the pull so the fetch may overwrite the local tag. --ff-only still guards the branch: a diverged or rewritten branch is still refused and left where it was. Two walk tests cover it: one moves a tag upstream and checks the walk pulls and updates the tag, the other diverges the branch and checks the pull still fails for that reason and not the tag. Both fail without the fix. Closes smartwatermelon/github-workflows#189 --- lib/repo-walker.sh | 5 +-- tests/repo-walker.bats | 82 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/lib/repo-walker.sh b/lib/repo-walker.sh index 8d2a1ee..080b2be 100644 --- a/lib/repo-walker.sh +++ b/lib/repo-walker.sh @@ -893,10 +893,11 @@ repo_walker_main() { switched=true fi - # Clean tree — attempt pull + # Clean tree — attempt pull. --force lets a moved upstream tag + # overwrite ours; --ff-only still guards the branch. local pull_ok pull_output pull_ok=true - if pull_output="$(git -C "${repo}" pull --ff-only 2>&1)"; then + if pull_output="$(git -C "${repo}" pull --ff-only --force 2>&1)"; then if ! "${REPO_WALKER_QUIET}"; then if grep -q "Already up to date" <<<"${pull_output}"; then echo -e " ${REPO_WALKER_GREEN}✓ up to date${REPO_WALKER_RESET}" diff --git a/tests/repo-walker.bats b/tests/repo-walker.bats index f648a33..454088a 100755 --- a/tests/repo-walker.bats +++ b/tests/repo-walker.bats @@ -252,6 +252,88 @@ teardown() { [[ "${output}" == *"Total:"* ]] } +# Builds a local "upstream" for the moved-tag tests: a bare repo whose path +# contains "smartwatermelon" (so the walker's org filter accepts the remote), +# a scratch clone used to push to it, and a walked clone under walkdir/. +# +# The walked clone sets fetch.pruneTags itself rather than relying on the +# developer's global config. pruneTags adds a non-forced +# refs/tags/*:refs/tags/* refspec to every fetch, which is what turns a +# moved upstream tag into "would clobber existing tag". Without it, plain +# tag auto-follow never updates an existing tag and the bug is invisible. +_mk_tag_upstream() { + up="${TMPDIR_T}/smartwatermelon/tagrepo.git" + pusher="${TMPDIR_T}/pusher" + walked="${TMPDIR_T}/walkdir/tagrepo" + mkdir -p "${up}" "${TMPDIR_T}/walkdir" + _GIT_WRAPPER_ACTIVE=1 git -C "${up}" init -q --bare -b main + _GIT_WRAPPER_ACTIVE=1 git -c core.hooksPath=/dev/null clone -q "${up}" "${pusher}" 2>/dev/null + git -C "${pusher}" config core.hooksPath /dev/null + git -C "${pusher}" config user.email t@t + git -C "${pusher}" config user.name t + git -C "${pusher}" config tag.gpgSign false + git -C "${pusher}" config commit.gpgSign false + echo one >"${pusher}/f" + git -C "${pusher}" add f + git -C "${pusher}" commit -qm one + git -C "${pusher}" tag v3 + git -C "${pusher}" push -q origin main v3 + _GIT_WRAPPER_ACTIVE=1 git -c core.hooksPath=/dev/null clone -q "${up}" "${walked}" + git -C "${walked}" config core.hooksPath /dev/null + git -C "${walked}" config user.email t@t + git -C "${walked}" config user.name t + git -C "${walked}" config commit.gpgSign false + git -C "${walked}" config fetch.pruneTags true +} + +# Moves v3 to a new commit upstream, the way github-workflows re-points its +# floating major tags on each release. +_move_upstream_tag() { + echo two >>"${pusher}/f" + git -C "${pusher}" add f + git -C "${pusher}" commit -qm two + git -C "${pusher}" tag -f v3 >/dev/null + git -C "${pusher}" push -q -f origin main v3 +} + +@test "walk pulls and overwrites a tag that moved upstream" { + _mk_tag_upstream + _move_upstream_tag + want_tag="$(git -C "${up}" rev-parse v3)" + want_head="$(git -C "${up}" rev-parse main)" + old_tag="$(git -C "${walked}" rev-parse v3)" + [ "${old_tag}" != "${want_tag}" ] + + run bash -c "MY_REPOS_DIR='${TMPDIR_T}/walkdir' '${REPO_ROOT}/my-repos.sh'" + [ "${status}" -eq 0 ] + [[ "${output}" != *"would clobber existing tag"* ]] + [[ "${output}" != *"pull failed"* ]] + [[ "${output}" == *"Failed: 0"* ]] + new_tag="$(git -C "${walked}" rev-parse v3)" + new_head="$(git -C "${walked}" rev-parse HEAD)" + [ "${new_tag}" = "${want_tag}" ] + [ "${new_head}" = "${want_head}" ] +} + +@test "walk still refuses a non-fast-forward branch after the tag fix" { + _mk_tag_upstream + # Diverge: a local-only commit, then a different upstream commit. + echo local >"${walked}/g" + git -C "${walked}" add g + git -C "${walked}" commit -qm local-only + before="$(git -C "${walked}" rev-parse HEAD)" + _move_upstream_tag + + run bash -c "MY_REPOS_DIR='${TMPDIR_T}/walkdir' '${REPO_ROOT}/my-repos.sh'" + [ "${status}" -eq 0 ] + [[ "${output}" == *"pull failed"* ]] + # The failure must come from the divergence, not from the tag. + [[ "${output}" != *"would clobber existing tag"* ]] + [[ "${output}" == *"Failed: 1"* ]] + after="$(git -C "${walked}" rev-parse HEAD)" + [ "${after}" = "${before}" ] +} + @test "parse_args accepts --show-dead-branches and --status" { run bash -c "source '${LIB}'; repo_walker_parse_args --status; \ echo \"\${REPO_WALKER_SHOW_PRS} \${REPO_WALKER_SHOW_ISSUES} \${REPO_WALKER_SHOW_DEAD_BRANCHES}\""