무슨 일이 있었나
리뷰하던 PR의 작성자가 브랜치를 리베이스하고 다시 푸시했다. 나는 같은 명령으로 PR을 다시 가져왔다: git fetch origin pull/N/head:pr-N. 그리고 pr-N을 체크아웃해서 리뷰를 이어가려 했다. 그런데 로컬 브랜치의 커밋 SHA가 GitHub이 보여 주는 head와 달랐다. 로컬 브랜치는 리베이스 전 커밋에 그대로 있었다.
왜 갱신되지 않았나
pull/N/head:pr-N은 '원격의 이 ref를 가져와서 로컬 pr-N에 넣어라'라는 refspec이다. 로컬 pr-N이 이미 있으면, git은 새 커밋이 기존 커밋의 후손일 때, 즉 빨리 감기(fast-forward)로 갈 수 있을 때만 브랜치를 옮긴다. 리베이스는 커밋을 새로 만들기 때문에 새 head는 옛 head의 후손이 아니다. 그래서 git은 갱신을 거부한다.
거부 자체는 출력에 ! [rejected] ... (non-fast-forward) 같은 한 줄로 나오고, 종료 코드도 0이 아니다. 하지만 같은 fetch가 다른 ref들은 정상으로 가져오고, 스크립트나 자동화 안에서 출력이 접혀 있으면 이 한 줄은 쉽게 지나간다. 결과적으로 남는 것은 '가져왔다'는 느낌과 예전 커밋이다.
해결: refspec 앞에 +
refspec 앞의 +는 '빨리 감기가 아니어도 덮어써라'라는 뜻이다. git fetch origin +pull/N/head:pr-N으로 바꾸면, 리베이스나 강제 푸시 뒤에도 로컬 pr-N이 원격 head로 옮겨진다. 리뷰용 로컬 브랜치는 내 작업이 아니라 원격의 사본이므로, 덮어쓰는 것이 맞는 동작이다. 같은 이유로 git fetch --force를 써도 된다.
단, 지금 체크아웃되어 있는 브랜치에는 이렇게 가져올 수 없다. git이 작업 트리를 지키려고 거부한다. 리뷰 브랜치에서 빠져나온 뒤 가져오거나, 처음부터 detached 상태로 리뷰하는 편이 깔끔하다.
더 단순한 방법: 브랜치를 만들지 않는다
리뷰만 할 거라면 로컬 브랜치 자체가 필요 없다. git fetch origin pull/N/head를 실행하면 결과가 FETCH_HEAD에 들어가고, git switch --detach FETCH_HEAD로 그 커밋을 바로 볼 수 있다. 덮어쓸 브랜치가 없으니 빨리 감기 문제도 생기지 않는다. gh pr checkout N --force도 같은 목적에 쓸 수 있다.
그래도 SHA는 확인한다
+는 이번 원인을 막지만, '가져온 것이 정말 지금의 PR인가'를 증명해 주지는 않는다. 리뷰를 시작하기 전에 git rev-parse HEAD와 gh pr view N --json headRefOid -q .headRefOid를 비교한다. 둘이 다르면 리뷰를 시작하지 않는다. 리뷰 기록에도 이 SHA를 남긴다. 그러면 나중에 '어느 커밋을 보고 승인했나'를 바로 답할 수 있다.
정리
리베이스된 PR을 다시 가져올 때 + 없는 fetch는 아무것도 바꾸지 않을 수 있다. 리뷰용 사본은 +로 덮어쓰거나 detached로 보고, 리뷰 전에 SHA를 맞춰 본다.
What happened
The author of a PR I was reviewing rebased the branch and pushed it again. I fetched the PR with the same command as before, git fetch origin pull/N/head:pr-N, and checked out pr-N to continue the review. But the commit SHA on my local branch didn't match the head GitHub showed. The local branch was still on the pre-rebase commit.
Why it didn't update
pull/N/head:pr-N is a refspec that says "fetch this remote ref and store it in local pr-N". If pr-N already exists, git only moves it when the new commit descends from the old one, that is, when it's a fast-forward. A rebase creates new commits, so the new head is not a descendant of the old head, and git refuses the update.
The refusal does appear as a line like ! [rejected] ... (non-fast-forward), and the exit code is non-zero. But the same fetch may update other refs normally, and when output is collapsed inside a script or automation, that one line slips past. What's left is the feeling that you fetched, and the old commit.
Fix: put + in front of the refspec
A leading + on a refspec means "overwrite even if it isn't a fast-forward". With git fetch origin +pull/N/head:pr-N, local pr-N moves to the remote head even after a rebase or force-push. A local review branch is a copy of the remote, not your own work, so overwriting it is the right behaviour. git fetch --force does the same for every refspec in the command.
One limit: git won't fetch into the branch you currently have checked out, because it protects your working tree. Switch off the review branch first, or review in detached mode from the start.
Simpler: don't make a branch
If you only need to review, you don't need a local branch at all. git fetch origin pull/N/head puts the result in FETCH_HEAD, and git switch --detach FETCH_HEAD puts you on that commit. There's no branch to overwrite, so the fast-forward problem never comes up. gh pr checkout N --force serves the same purpose.
Still check the SHA
The + prevents this particular cause, but it doesn't prove that what you fetched is the PR as it stands now. Before starting a review, compare git rev-parse HEAD with gh pr view N --json headRefOid -q .headRefOid. If they differ, don't start. Record that SHA in the review too, so "which commit did you approve?" has an immediate answer later.
Takeaway
When you refetch a rebased PR, a fetch without + may change nothing. Overwrite review copies with + or look at them detached, and match the SHA before you review.
发生了什么
我正在审查的一个 PR,作者把分支 rebase 后重新推送了。我用和之前一样的命令重新拉取:git fetch origin pull/N/head:pr-N,然后检出 pr-N 继续审查。可本地分支的提交 SHA 和 GitHub 显示的 head 不一致。本地分支还停在 rebase 之前的提交上。
为什么没有更新
pull/N/head:pr-N 是一个 refspec,意思是“把远端这个 ref 拉下来,存到本地 pr-N”。如果 pr-N 已经存在,git 只有在新提交是旧提交的后代、也就是能快进(fast-forward)时,才会移动它。rebase 会生成新的提交,新 head 不是旧 head 的后代,所以 git 拒绝更新。
这个拒绝确实会在输出里显示成 ! [rejected] ... (non-fast-forward) 这样的一行,退出码也不是 0。但同一次 fetch 里其他 ref 可能都正常更新了,在脚本或自动化里输出被折叠时,这一行很容易被略过。最后留下的,是“已经拉过了”的感觉,和那个旧提交。
解决:在 refspec 前面加 +
refspec 前面的 + 表示“即使不是快进也要覆盖”。改成 git fetch origin +pull/N/head:pr-N 之后,即使经过 rebase 或强制推送,本地 pr-N 也会移到远端的 head。审查用的本地分支是远端的副本,不是你自己的工作,覆盖它是正确的行为。git fetch --force 会对命令里的所有 refspec 起同样的作用。
有一个限制:git 不会把内容拉进当前检出的分支,因为它要保护工作区。先切出审查分支再拉取,或者一开始就用 detached 状态来审查。
更简单的做法:不建分支
如果只是审查,其实根本不需要本地分支。git fetch origin pull/N/head 会把结果放进 FETCH_HEAD,再用 git switch --detach FETCH_HEAD 就能直接停在那个提交上。没有要覆盖的分支,快进的问题也就不会出现。gh pr checkout N --force 也能达到同样的目的。
还是要核对 SHA
+ 能防住这次的原因,但它证明不了“拉到的就是 PR 现在的样子”。开始审查之前,比较 git rev-parse HEAD 和 gh pr view N --json headRefOid -q .headRefOid。两者不同就不要开始。把这个 SHA 也写进审查记录里,以后有人问“你批准的是哪个提交”,就能马上回答。
小结
重新拉取被 rebase 过的 PR 时,不带 + 的 fetch 可能什么都没改。审查副本要么用 + 覆盖,要么用 detached 方式查看,审查前先核对 SHA。
何が起きたか
レビュー中の PR の作者が、ブランチをリベースしてプッシュし直した。私は前と同じコマンド git fetch origin pull/N/head:pr-N で PR を取り直し、pr-N をチェックアウトしてレビューを続けようとした。ところが、ローカルブランチのコミット SHA が GitHub に表示されている head と違っていた。ローカルブランチはリベース前のコミットのままだった。
なぜ更新されなかったか
pull/N/head:pr-N は「リモートのこの ref を取ってきて、ローカルの pr-N に入れる」という refspec だ。pr-N がすでにあると、git は新しいコミットが古いコミットの子孫である場合、つまり早送り(fast-forward)できる場合にしかブランチを動かさない。リベースはコミットを作り直すので、新しい head は古い head の子孫ではない。だから git は更新を拒否する。
拒否そのものは ! [rejected] ... (non-fast-forward) のような一行として出力され、終了コードも 0 ではない。ただ、同じ fetch でほかの ref は普通に更新されることもあり、スクリプトや自動化の中で出力が畳まれていると、この一行は簡単に見過ごされる。残るのは「取ってきた」という感覚と、古いコミットだ。
対処:refspec の前に +
refspec の先頭の + は「早送りでなくても上書きする」という意味だ。git fetch origin +pull/N/head:pr-N に変えれば、リベースや強制プッシュの後でもローカルの pr-N がリモートの head に移る。レビュー用のローカルブランチは自分の作業ではなくリモートの写しなので、上書きするのが正しい振る舞いだ。git fetch --force を使うと、コマンド内のすべての refspec に同じ効果がある。
一つ制限がある。いまチェックアウトしているブランチには、git は取り込まない。作業ツリーを守るためだ。レビュー用ブランチから一度離れてから取ってくるか、最初から detached 状態でレビューするほうがすっきりする。
もっと単純に:ブランチを作らない
レビューするだけなら、ローカルブランチはそもそも要らない。git fetch origin pull/N/head を実行すると結果が FETCH_HEAD に入り、git switch --detach FETCH_HEAD でそのコミットにそのまま移れる。上書きするブランチがないので、早送りの問題も起きない。gh pr checkout N --force も同じ目的で使える。
それでも SHA は確かめる
+ は今回の原因を防ぐが、「取ってきたものが本当にいまの PR なのか」までは証明しない。レビューを始める前に、git rev-parse HEAD と gh pr view N --json headRefOid -q .headRefOid を比べる。違っていたら始めない。この SHA はレビューの記録にも残す。そうすれば後で「どのコミットを見て承認したのか」にすぐ答えられる。
まとめ
リベースされた PR を取り直すとき、+ のない fetch は何も変えないことがある。レビュー用の写しは + で上書きするか detached で見て、レビューの前に SHA を合わせる。