무슨 일이 있었나
어제 한 PR이 리뷰어의 공식 승인만 기다리고 있었다. 내용은 이미 리뷰에서 통과했고, 내가 띄운 자동 작업은 그 브랜치를 지켜보기만 하면 됐다. 그런데 그 작업이 브랜치에 push를 했고, 그 안에는 관계없는 파일 52개를 다시 포매팅한 변경이 들어 있었다. 저장소가 새 커밋이 들어오면 기존 승인을 무효로 하도록 설정돼 있었다면, 승인 과정이 처음부터 다시 시작될 수 있었다.
재현: 한 줄 수정과 전체 포매터
git 2.34.1로 파일 12개짜리 저장소를 만들고 fix 브랜치를 원격에 올렸다. 의도한 수정은 파일 하나의 한 줄이었다. 그 뒤 저장소 전체에 포매터를 돌린 것처럼 12개 파일 모두에서 쉼표 뒤에 공백을 넣고 커밋했다. 작업 트리는 깨끗했고, git status만 보면 문제가 없어 보였다.
push가 싣고 갈 커밋
git log --oneline @{u}..HEAD는 추적 중인 원격 브랜치에 아직 없는 커밋을 보여 준다. 재현에서는 의도한 fix m3와 함께 format 커밋이 나왔다. 한 커밋을 올리려던 push가 두 커밋을 싣고 있었다.
push가 싣고 갈 파일
git diff --stat @{u}..HEAD의 마지막 줄은 12 files changed였다. 한 파일을 고치려던 push에서 이 숫자는 그 자체로 멈출 이유다. git log --stat @{u}..HEAD로 커밋별로 나누면 fix m3는 1개, format은 12개 파일을 바꿨다는 것이 바로 보였다.
포매팅만 바뀐 파일 가려내기
git diff -w --stat @{u}..HEAD는 공백 차이를 무시한다. 재현에서는 m3.py만 남았고, 나머지 11개는 공백만 바뀌었다는 뜻이다.
함정이 하나 있다. 같은 -w를 --name-only와 함께 쓰면 12개 파일이 모두 그대로 나왔다. 파일 목록만 뽑는 스크립트로 "실제 변경"을 세면 공백 무시가 적용되지 않는다. 이 용도에는 --stat이나 실제 diff를 봐야 한다.
사람이 잊어도 막기: pre-push 훅
push 직전에 원격과 비교해 바뀐 파일 수를 세고 기준을 넘으면 거절하는 .git/hooks/pre-push를 만들었다. 훅은 표준 입력으로 로컬 sha와 원격 sha를 받으므로 git diff --name-only "$rsha" "$lsha" | wc -l로 셀 수 있다. 기준을 10으로 두자 push는 pre-push: 12 files changed vs remote; refusing과 함께 실패했고, 원격 브랜치는 그대로였다.
git push --dry-run도 이 훅을 실행해서 같은 이유로 실패했다. 실제로 보내기 전에 시험해 볼 수 있다는 뜻이다. 반대로 --no-verify를 붙이면 훅을 건너뛰고 dry-run이 통과했다. 훅은 실수를 막는 장치이지, 막으려고 작정한 작업을 막는 장치가 아니다.
이미 섞였다면
포매팅 커밋이 따로 있다면 push 전에 git reset --hard HEAD~1처럼 그 커밋만 떼어 내고 다시 확인한다. 재현에서 되돌린 뒤 git diff --name-only @{u}..HEAD는 m3.py 하나만 보여 줬고, 그때 push가 통과했다. 포매팅이 의도한 수정과 같은 커밋에 섞여 있다면, 공백을 무시한 diff를 기준으로 수정만 다시 만드는 편이 안전하다.
자동 작업에 맡길 때
지켜보기만 하면 되는 작업에는 push 권한을 주지 않는 것이 먼저다. push가 필요한 작업이라면 지시문에 "push 전에 git diff --stat @{u}..HEAD를 보고, 예상보다 파일이 많으면 멈춘다"를 적고, 저장소에는 위 훅을 둔다.
정리
push는 지금 고친 것이 아니라 원격에 없는 모든 것을 싣고 간다. 보내기 직전에 커밋과 파일 수를 읽고, 공백만 바뀐 파일은 --stat으로 가려내고, 잊어도 막아 주는 훅을 하나 둔다.
What happened
Yesterday a pull request was waiting only for a reviewer's formal approval. Its content had already passed review, and the automated job I had started only needed to watch the branch. Instead, it pushed to the branch, and the push included 52 unrelated files it had reformatted. If the repository was set to dismiss existing approvals when new commits arrive, the whole approval cycle could have restarted.
Reproducing it: a one-line fix and a repo-wide formatter
With git 2.34.1, I made a repository of 12 files and pushed a fix branch. The intended change was one line in one file. Then, as if a formatter had run over the whole repository, I added a space after the comma in all 12 files and committed. The working tree was clean, and git status alone made everything look fine.
The commits the push will carry
git log --oneline @{u}..HEAD lists the commits that the tracked remote branch does not have yet. In the repro it showed the intended fix m3 and a format commit as well. A push meant to send one commit was carrying two.
The files the push will carry
The last line of git diff --stat @{u}..HEAD said 12 files changed. In a push meant to fix one file, that number alone is a reason to stop. Splitting it by commit with git log --stat @{u}..HEAD showed at once that fix m3 touched 1 file and format touched 12.
Separating formatting-only files
git diff -w --stat @{u}..HEAD ignores whitespace differences. In the repro only m3.py was left, meaning the other 11 files changed nothing but whitespace.
There is a trap. The same -w combined with --name-only still listed all 12 files. A script that counts "real changes" from a file list does not get the whitespace filtering. For this purpose, look at --stat or the actual diff.
Stopping it when nobody remembers: a pre-push hook
I wrote a .git/hooks/pre-push that counts how many files differ from the remote right before a push and refuses above a threshold. The hook receives the local and remote sha on standard input, so git diff --name-only "$rsha" "$lsha" | wc -l gives the count. With the threshold at 10, the push failed with pre-push: 12 files changed vs remote; refusing, and the remote branch did not move.
git push --dry-run also ran the hook and failed for the same reason, so you can test it before sending anything. On the other hand, adding --no-verify skipped the hook and the dry run passed. A hook catches mistakes; it does not stop a job that is determined to push.
If it is already mixed in
If the formatting is in its own commit, drop just that commit before pushing, for example with git reset --hard HEAD~1, and check again. In the repro, after resetting, git diff --name-only @{u}..HEAD showed only m3.py, and the push went through. If the formatting is mixed into the same commit as the fix, it is safer to rebuild only the fix, using the whitespace-ignoring diff as the guide.
When an automated job does the work
First, a job that only needs to watch should not get push rights. If a job does need to push, write in its instructions: "before pushing, read git diff --stat @{u}..HEAD, and stop if there are more files than expected", and keep the hook above in the repository.
Summary
A push carries everything the remote does not have, not just what you meant to fix. Read the commit and file counts right before sending, use --stat to separate whitespace-only files, and keep a hook that catches it when you forget.
发生了什么
昨天,一个 PR 只在等评审者的正式批准,内容已经通过了评审,我启动的自动任务只需要盯着这个分支。结果它往分支上推送了内容,里面包含 52 个与修复无关、被重新格式化的文件。如果仓库设置了“有新提交时作废已有批准”,整个批准流程就可能从头再来。
重现:一行修复加一次全仓库格式化
我用 git 2.34.1 建了一个 12 个文件的仓库,并把 fix 分支推到远端。本来要改的只是一个文件里的一行。随后,就像对整个仓库跑了一次格式化工具,我在全部 12 个文件的逗号后面加了空格并提交。工作区是干净的,只看 git status 一切正常。
这次 push 会带走的提交
git log --oneline @{u}..HEAD 会列出所跟踪的远端分支上还没有的提交。在重现中,它除了想要的 fix m3,还列出了一个 format 提交。本想推一个提交,实际带着两个。
这次 push 会带走的文件
git diff --stat @{u}..HEAD 的最后一行是 12 files changed。一次只想修一个文件的 push,光这个数字就足以让人停下来。用 git log --stat @{u}..HEAD 按提交拆开看,马上就能看到 fix m3 改了 1 个文件,format 改了 12 个。
区分只改了格式的文件
git diff -w --stat @{u}..HEAD 会忽略空白差异。在重现中只剩下 m3.py,说明另外 11 个文件只改了空白。
这里有个陷阱。同样的 -w 和 --name-only 一起用时,12 个文件依然全部列出。用文件列表来统计“真正改动”的脚本,拿不到忽略空白的效果。这种场合要看 --stat 或者实际的 diff。
没人记得也能拦住:pre-push 钩子
我写了一个 .git/hooks/pre-push,在推送前统计与远端相比改动了多少文件,超过阈值就拒绝。钩子会从标准输入收到本地和远端的 sha,所以可以用 git diff --name-only "$rsha" "$lsha" | wc -l 来计数。阈值设为 10 时,推送以 pre-push: 12 files changed vs remote; refusing 失败,远端分支没有变化。
git push --dry-run 也会执行这个钩子,并因为同样的原因失败,所以可以在真正发送之前先试一下。反过来,加上 --no-verify 就会跳过钩子,dry-run 也就通过了。钩子拦的是失误,拦不住一个铁了心要推送的任务。
如果已经混进去了
如果格式化是单独的一个提交,推送前就把它单独去掉,比如用 git reset --hard HEAD~1,然后再检查一遍。在重现中,回退之后 git diff --name-only @{u}..HEAD 只显示 m3.py,这时推送就通过了。如果格式化和修复混在同一个提交里,更稳妥的做法是以忽略空白的 diff 为参照,只把修复重新做一遍。
交给自动任务时
首先,只需要盯着的任务,不应该拿到推送权限。如果任务确实需要推送,就在指令里写明:“推送前查看 git diff --stat @{u}..HEAD,文件数比预期多就停下”,并在仓库里放上面的钩子。
小结
push 带走的是远端没有的一切,而不只是你想修的那一处。发送前读一遍提交数和文件数,用 --stat 把只改了空白的文件分出来,再放一个忘了检查时也能拦住的钩子。
何が起きたか
昨日、ある PR はレビュアーの正式な承認だけを待っていた。内容はすでにレビューを通っていて、私が立ち上げた自動ジョブはそのブランチを見張っていればよかった。ところがそのジョブはブランチに push し、そこには関係のない 52 個のファイルを再フォーマットした変更が入っていた。リポジトリが新しいコミットで既存の承認を無効にする設定だったら、承認のやり直しになっていたかもしれない。
再現:一行の修正とリポジトリ全体のフォーマッター
git 2.34.1 で 12 ファイルのリポジトリを作り、fix ブランチをリモートに上げた。意図した変更は一つのファイルの一行だった。そのあと、リポジトリ全体にフォーマッターをかけたつもりで、12 ファイルすべてのカンマの後ろに空白を入れてコミットした。作業ツリーはきれいで、git status だけを見れば問題はなさそうだった。
push が運ぶコミット
git log --oneline @{u}..HEAD は、追跡しているリモートブランチにまだないコミットを表示する。再現では、意図した fix m3 に加えて format コミットも出てきた。一つのコミットを送るつもりの push が、二つを運んでいた。
push が運ぶファイル
git diff --stat @{u}..HEAD の最後の行は 12 files changed だった。一つのファイルを直すための push なら、この数字だけで止まる理由になる。git log --stat @{u}..HEAD でコミットごとに分けると、fix m3 は 1 ファイル、format は 12 ファイルを変えていたことがすぐ分かった。
フォーマットだけが変わったファイルを見分ける
git diff -w --stat @{u}..HEAD は空白の違いを無視する。再現では m3.py だけが残り、残りの 11 ファイルは空白しか変わっていないことが分かった。
落とし穴が一つある。同じ -w を --name-only と組み合わせると、12 ファイルがそのまますべて表示された。ファイル一覧から「本当の変更」を数えるスクリプトでは、空白の無視が効かない。この用途では --stat か実際の diff を見る必要がある。
誰も覚えていなくても止める:pre-push フック
push の直前にリモートと比べて変わったファイル数を数え、しきい値を超えたら拒否する .git/hooks/pre-push を作った。フックは標準入力でローカルとリモートの sha を受け取るので、git diff --name-only "$rsha" "$lsha" | wc -l で数えられる。しきい値を 10 にすると、push は pre-push: 12 files changed vs remote; refusing で失敗し、リモートのブランチは動かなかった。
git push --dry-run もこのフックを実行し、同じ理由で失敗した。実際に送る前に試せるということだ。逆に --no-verify を付けるとフックは飛ばされ、dry-run は通った。フックはミスを止める仕組みであって、どうしても push しようとするジョブを止める仕組みではない。
すでに混ざってしまったら
フォーマットが別のコミットになっているなら、push の前にそのコミットだけを外す。たとえば git reset --hard HEAD~1 を使い、もう一度確かめる。再現では、戻したあとの git diff --name-only @{u}..HEAD は m3.py だけを示し、そこで push が通った。フォーマットが修正と同じコミットに混ざっているなら、空白を無視した diff を手がかりに、修正だけを作り直すほうが安全だ。
自動ジョブに任せるとき
まず、見張るだけのジョブには push 権限を与えない。push が必要なジョブなら、指示文に「push の前に git diff --stat @{u}..HEAD を読み、想定よりファイルが多ければ止まる」と書き、リポジトリには上のフックを置く。
まとめ
push が運ぶのは、直したつもりのものではなく、リモートにないものすべてだ。送る直前にコミット数とファイル数を読み、空白だけのファイルは --stat で見分け、忘れたときにも止めてくれるフックを一つ置いておく。