무슨 일이 있었나
어제 열려 있는 PR에 작은 수정을 올리려고 임시 디렉터리에 저장소를 새로 클론했다. PR 브랜치로 전환하는 명령이 실패했지만 출력은 지나갔고, 다음 줄의 커밋과 push가 그대로 실행됐다. 나는 main 위에 있었고, 내 자격 증명은 main 보호 규칙을 건너뛸 수 있었다. 그래서 리뷰 없이 관계없는 변경 묶음이 main에 들어갔다. 되돌리기는 했지만 두 커밋은 기록에 남았다.
재현: 실패한 전환 뒤의 다음 줄
git 2.34.1로 로컬 bare 저장소를 원격 삼아 시험했다. set -e 없는 스크립트에서 git switch fix-x; echo b >> f; git commit -qam change; git push -q를 돌리자, 첫 줄이 fatal: invalid reference: fix-x로 실패했다. 그런데 커밋은 main에 만들어졌고, 인자 없는 git push가 그 커밋을 원격 main에 올렸다. 스크립트의 종료 코드는 0이었다.
전환은 다른 이유로도 실패한다. 작업 트리에 덮어써질 변경이 있으면 git switch는 Please commit your changes or stash them과 함께 1로 끝나고, 현재 브랜치는 main 그대로였다. 브랜치 이름 오타, 원격에 아직 없는 브랜치, 더러운 작업 트리 모두 같은 결과로 이어진다.
set -e는 반만 막는다
같은 스크립트에 set -e를 넣으면 전환 실패에서 128로 멈추고 원격 main은 그대로였다. 하지만 그 명령이 함수 안에 있고 함수를 f || echo fallback처럼 부르면, bash는 함수 안에서 set -e를 적용하지 않는다. 시험에서도 전환이 실패한 뒤 함수의 다음 줄이 그대로 실행됐다. set -e만으로는 안심할 수 없다.
어떤 push 형태가 막아 주나
git push origin fix-x처럼 보낼 로컬 브랜치 이름을 적으면, 그 브랜치가 없을 때 error: src refspec fix-x does not match any로 실패하고 아무것도 올라가지 않았다. 전환이 실패해 로컬 브랜치가 생기지 않은 상황을 push가 대신 잡아 준다.
git push origin HEAD:fix-x는 main을 지켜 준다. 시험에서 main 위의 커밋은 원격 main이 아니라 새 fix-x 브랜치로 갔다. 하지만 HEAD가 무엇이든 그 브랜치로 보내므로, 엉뚱한 기반 위의 커밋이 PR 브랜치에 올라갈 수 있다. main은 안전하지만, 내가 맞는 브랜치에 있었다는 증명은 아니다.
인자 없는 git push가 가장 위험하다. 현재 브랜치가 무엇이든 그 추적 대상으로 보내기 때문에, 전환이 실패하면 목적지가 조용히 main이 된다.
push 직전에 확인하기
push 바로 앞에 현재 브랜치를 확인하고 다르면 멈춘다. b=$(git branch --show-current); [ "$b" = fix-x ] || { echo "on $b, not fix-x" >&2; exit 1; } 시험에서 이 줄은 전환 실패 뒤 on main, not fix-x를 찍고 1로 끝났다. 분리된 HEAD에서는 git branch --show-current가 빈 문자열을, git rev-parse --abbrev-ref HEAD는 HEAD를 돌려주므로 둘 다 이 검사에 걸린다.
확인 다음에는 명시적인 refspec으로 보낸다. 검사는 내가 어디에 있는지를, refspec은 어디로 보내는지를 고정한다. 둘 중 하나만으로는 위의 실패 중 하나가 남는다.
서버 보호를 마지막 줄로 두지 않기
브랜치 보호는 대부분의 실수를 막지만, 보호를 건너뛸 수 있는 자격 증명 앞에서는 아무 소리 없이 비켜선다. 수정 작업에 그런 자격 증명을 쓰지 않거나, 써야 한다면 위의 로컬 검사가 유일한 방어선이라고 생각하고 스크립트를 짠다.
정리
실패한 전환은 다음 줄을 멈추지 않는다. push 직전에 현재 브랜치를 확인하고, 보낼 곳을 HEAD:<브랜치>로 적고, 보호를 건너뛸 수 있는 자격 증명이 있다면 그 두 줄이 전부라고 생각하자.
What happened
Yesterday I cloned a repository fresh into a temporary directory to push a small fix to an open PR. The command that switched to the PR branch failed, the error scrolled past, and the commit and push on the next lines ran anyway. I was on main, and my credential could bypass main's protection rules. So a batch of unrelated changes reached main with no review. I reverted it, but two commits remain in the history.
Reproducing it: the line after a failed switch
I tested with git 2.34.1 against a local bare repository as the remote. In a script without set -e, git switch fix-x; echo b >> f; git commit -qam change; git push -q failed on the first line with fatal: invalid reference: fix-x. The commit was still made on main, and the bare git push sent it to the remote main. The script exited 0.
A switch can fail for other reasons too. With local changes that would be overwritten, git switch exited 1 with Please commit your changes or stash them, and the current branch stayed main. A typo in the branch name, a branch that does not exist on the remote yet, and a dirty working tree all lead to the same place.
set -e only covers half of it
Adding set -e to the same script stopped it at the failed switch with exit 128, and the remote main was untouched. But if the commands live in a function and the function is called as f || echo fallback, bash does not apply set -e inside that function. In my test, the line after the failed switch ran anyway. set -e alone is not a guarantee.
Which push forms stop it
Naming the local branch to send, as in git push origin fix-x, failed with error: src refspec fix-x does not match any when that branch did not exist, and nothing was pushed. When the switch fails and the local branch is never created, the push catches it for you.
git push origin HEAD:fix-x protects main. In my test, the commit made on main went to a new fix-x branch instead of the remote main. But it sends whatever HEAD is, so a commit on the wrong base can still land on the PR branch. Main is safe, but it does not prove you were on the right branch.
A bare git push is the riskiest. It sends the current branch to whatever that branch tracks, so when the switch fails, the destination quietly becomes main.
Check right before the push
Right before pushing, check the current branch and stop if it is wrong: b=$(git branch --show-current); [ "$b" = fix-x ] || { echo "on $b, not fix-x" >&2; exit 1; }. In my test this printed on main, not fix-x after the failed switch and exited 1. On a detached HEAD, git branch --show-current returns an empty string and git rev-parse --abbrev-ref HEAD returns HEAD, so either one fails this check.
After the check, push with an explicit refspec. The check pins where you are; the refspec pins where the commit goes. With only one of them, one of the failures above remains possible.
Do not make server protection your last line
Branch protection stops most mistakes, but it steps aside silently for a credential that is allowed to bypass it. Either do not use such a credential for fix-up work, or, if you must, write the script as if the local checks above were your only defense.
Summary
A failed switch does not stop the next line. Check the current branch right before the push, name the destination as HEAD:<branch>, and if your credential can bypass protection, treat those two lines as everything standing between you and main.
发生了什么
昨天我为了给一个开着的 PR 推一个小修补,把仓库重新克隆到了一个临时目录。切换到 PR 分支的命令失败了,错误信息一闪而过,下面几行的提交和推送照样执行了。我当时在 main 上,而我的凭据可以绕过 main 的保护规则。于是一批无关的改动没有经过评审就进了 main。我做了回滚,但那两个提交还留在历史里。
重现:切换失败之后的下一行
我用 git 2.34.1,以本地的 bare 仓库作为远端来测试。在没有 set -e 的脚本里运行 git switch fix-x; echo b >> f; git commit -qam change; git push -q,第一行以 fatal: invalid reference: fix-x 失败。可是提交依然在 main 上生成了,不带参数的 git push 把它推到了远端 main。脚本的退出码是 0。
切换失败还有别的原因。工作区里有会被覆盖的改动时,git switch 会提示 Please commit your changes or stash them 并以 1 退出,当前分支仍然是 main。分支名打错、远端还没有这个分支、工作区不干净,结果都一样。
set -e 只能挡住一半
在同一个脚本里加上 set -e,它在切换失败时以 128 停下,远端 main 没有变化。但如果这些命令写在函数里,而函数以 f || echo fallback 的形式调用,bash 就不会在这个函数里执行 set -e。我测试时,切换失败后函数的下一行照样运行了。只靠 set -e 是不够的。
哪些 push 写法能拦住
像 git push origin fix-x 这样写明要推送的本地分支名,当这个分支不存在时,会以 error: src refspec fix-x does not match any 失败,什么都不会推上去。切换失败、本地分支根本没有建立起来的时候,push 会替你把问题拦下。
git push origin HEAD:fix-x 能保护 main。测试中,在 main 上做的提交去了新的 fix-x 分支,而不是远端 main。但它会把 HEAD 当前指向的东西原样推过去,所以基于错误基础的提交仍然可能进入 PR 分支。main 安全了,却不能证明你当时在正确的分支上。
不带参数的 git push 风险最大。它把当前分支推到它所跟踪的目标,切换一旦失败,目的地就悄悄变成了 main。
推送之前先确认
在推送之前确认当前分支,不对就停下:b=$(git branch --show-current); [ "$b" = fix-x ] || { echo "on $b, not fix-x" >&2; exit 1; }。测试中,切换失败后这一行打印了 on main, not fix-x 并以 1 退出。在分离的 HEAD 上,git branch --show-current 返回空字符串,git rev-parse --abbrev-ref HEAD 返回 HEAD,两者都会让这个检查失败。
确认之后,用明确的 refspec 推送。检查固定的是你在哪里,refspec 固定的是提交去哪里。只有其中一个,上面的某一种失败就仍然可能发生。
别把服务端保护当成最后一道线
分支保护能挡住大多数失误,但在被允许绕过它的凭据面前,它会一声不响地让开。修补工作要么不用这样的凭据;如果非用不可,就按照“上面的本地检查是唯一防线”的前提来写脚本。
小结
切换失败不会让下一行停下来。推送之前确认当前分支,用 HEAD:<分支> 写明目的地;如果你的凭据能绕过保护,就把这两行当作你和 main 之间仅有的屏障。
何が起きたか
昨日、開いている PR に小さな修正を載せるために、一時ディレクトリにリポジトリを新しくクローンした。PR ブランチに切り替えるコマンドは失敗したが、出力は流れていき、次の行のコミットと push がそのまま実行された。私は main の上にいて、私の認証情報は main の保護ルールを迂回できた。そのため、関係のない変更のまとまりがレビューなしで main に入った。取り消しはしたが、二つのコミットは履歴に残っている。
再現:切り替えに失敗したあとの次の行
git 2.34.1 で、ローカルの bare リポジトリをリモートに見立てて試した。set -e のないスクリプトで git switch fix-x; echo b >> f; git commit -qam change; git push -q を実行すると、最初の行が fatal: invalid reference: fix-x で失敗した。それでもコミットは main 上に作られ、引数なしの git push がそれをリモートの main に送った。スクリプトの終了コードは 0 だった。
切り替えはほかの理由でも失敗する。上書きされるローカルの変更があると、git switch は Please commit your changes or stash them を出して 1 で終わり、現在のブランチは main のままだった。ブランチ名のタイプミス、リモートにまだないブランチ、汚れた作業ツリー、どれも同じ結果になる。
set -e が防ぐのは半分だけ
同じスクリプトに set -e を入れると、切り替えの失敗で 128 で止まり、リモートの main は変わらなかった。しかしコマンドが関数の中にあり、その関数を f || echo fallback のように呼ぶと、bash はその関数の中で set -e を効かせない。試したところ、切り替えに失敗したあとも関数の次の行が実行された。set -e だけでは安心できない。
どの push の書き方が止めてくれるか
git push origin fix-x のように送るローカルブランチの名前を書くと、そのブランチがないときは error: src refspec fix-x does not match any で失敗し、何も送られなかった。切り替えに失敗してローカルブランチができていない状況を、push が代わりに捕まえてくれる。
git push origin HEAD:fix-x は main を守る。試すと、main 上で作ったコミットはリモートの main ではなく、新しい fix-x ブランチに送られた。ただし HEAD が何であってもそのブランチに送るので、間違った土台のコミットが PR ブランチに載ることはありうる。main は安全だが、正しいブランチにいたことの証明にはならない。
引数なしの git push がいちばん危ない。現在のブランチをその追跡先へ送るので、切り替えに失敗すると、送り先が黙って main になる。
push の直前に確かめる
push の直前に現在のブランチを確かめ、違えば止める。b=$(git branch --show-current); [ "$b" = fix-x ] || { echo "on $b, not fix-x" >&2; exit 1; } 試したところ、この行は切り替えの失敗のあとで on main, not fix-x を出して 1 で終わった。HEAD が切り離された状態では、git branch --show-current は空文字列を、git rev-parse --abbrev-ref HEAD は HEAD を返すので、どちらでもこの検査に引っかかる。
確認のあとは、明示的な refspec で送る。検査は自分がどこにいるかを、refspec はコミットをどこへ送るかを固定する。片方だけでは、上の失敗のどれかが残る。
サーバー側の保護を最後の砦にしない
ブランチ保護はたいていのミスを止めるが、迂回を許された認証情報の前では何も言わずに道を譲る。修正作業にはそうした認証情報を使わないか、どうしても使うなら、上のローカルの検査が唯一の防御だと思ってスクリプトを書く。
まとめ
切り替えの失敗は次の行を止めない。push の直前に現在のブランチを確かめ、送り先を HEAD:<ブランチ> と書き、認証情報が保護を迂回できるなら、その二行が main との間にあるすべてだと考えよう。