무슨 일이 있었나
어제 한 저장소에서 데이터 모델을 바꾸는 변경을 머지했다. 그 저장소의 JSON 스키마는 모델 정의로부터 생성되는데, 나는 머지 전에 생성 파일 검사를 돌리지 않았다. 스키마는 낡은 채로 들어갔고, 개발 브랜치가 빨갛게 되면서 다른 기여자들의 PR까지 막혔다.
재현: 아무도 다시 돌리지 않은 생성기
작은 저장소에 model.txt를 읽어 schema.json을 쓰는 스크립트를 두었다. model.txt에 필드 하나를 추가해 커밋하고, 스크립트는 다시 돌리지 않았다. 저장소 안의 다른 어떤 것도 schema.json을 읽지 않으니 아무것도 실패하지 않았다. 스키마에는 여전히 필드가 셋이 아니라 둘이었다.
검사: 다시 생성하고, git에게 묻는다
깨끗한 체크아웃에서 생성기를 돌리고, 바뀐 것이 있으면 작업이 실패하게 만든다.
python3 gen.py && git diff --exit-code -- schema.json
낡은 커밋에서는 빠진 "email" 줄을 보여 주며 종료 코드 1로 끝났다. 다시 생성해 커밋한 뒤에는 0으로 끝난다. 생성기에 자체 검사 모드가 있다면 그걸 쓰면 되고, 이 방식은 저장소 안에 파일을 쓰는 생성기라면 어디에나 통한다.
빈틈 1: git diff는 새 파일을 보지 못한다
git diff는 추적 중인 파일만 비교한다. 생성기가 새 파일 하나를 더 쓰게 바꾸자 git diff --exit-code는 0으로 끝났고, git status --porcelain에는 ?? schema-extra.json이 떴다. 그러니 상태를 확인하거나, 새 파일을 먼저 '추가 예정'으로 표시한 뒤 비교한다.
test -z "$(git status --porcelain)"
git add -N . && git diff --exit-code
재현에서는 이 두 명령 모두 추적되지 않은 새 파일 때문에 종료 코드 1을 냈다.
빈틈 2: 매번 다른 결과를 내는 생성기
두 번째 생성기는 출력에 현재 시각을 적었다. 커밋된 출력과 새 출력이 실행할 때마다 달라서, 올바른 커밋에서도 검사가 실패했다. 늘 실패하는 검사는 결국 꺼진다. 검사를 넣기 전에 생성 파일에서 시각, 무작위 ID, 순서가 정해지지 않은 출력을 없앤다.
머지가 일어나는 곳에서 돌린다
검사는 작성자 브랜치만이 아니라 실제로 들어갈 커밋, 즉 머지 결과에서 돌린다. 모델은 머지 상대편에서 바뀌었을 수도 있다. 그리고 필수 검사로 지정해서, 낡은 생성 파일이 그 뒤의 모든 사람 브랜치가 아니라 바로 그 머지를 막게 한다.
정리
생성 파일은 마지막으로 누군가 생성기를 돌린 순간만큼만 최신이다. CI에서 다시 생성하고, 새 파일을 포함한 어떤 차이에도 실패하게 하고, 출력을 결정적으로 유지하고, 실제로 머지되는 커밋에서 돌린다.
What happened
Yesterday I merged a change that edited a data model in a repository whose JSON schema is generated from that model. I did not run the generated-file check before merging. The schema went in stale, the development branch went red, and other contributors' pull requests were blocked behind it.
Reproduction: a generator nobody re-ran
In a scratch repository, a small script reads model.txt and writes schema.json. I added a field to model.txt, committed, and did not rerun the script. Nothing else in the repository reads schema.json, so nothing failed. The schema still listed two fields instead of three.
The check: regenerate, then ask git
Run the generator in a clean checkout and fail the job if anything changed.
python3 gen.py && git diff --exit-code -- schema.json
On the stale commit this printed the missing "email" line and exited 1. After regenerating and committing, it exits 0. If your generator has its own check mode, use it; this pattern works for any generator that writes files into the repository.
Gap 1: git diff cannot see new files
git diff compares tracked files only. When I made the generator write one more file, git diff --exit-code exited 0 while git status --porcelain showed ?? schema-extra.json. Check the status instead, or mark new files as intent-to-add before diffing.
test -z "$(git status --porcelain)"
git add -N . && git diff --exit-code
In the reproduction both of these exited 1 on the untracked file.
Gap 2: a generator that is never the same twice
A second generator wrote the current time into its output. The committed output and the fresh output differed on every run, so the check failed on a correct commit. A check that always fails gets switched off. Remove timestamps, random IDs and unordered output from generated files before adding the check.
Run it where the merge happens
Run the check on the commit that will land, which is the merge result, not only on the author's branch: the model may have changed on the other side of the merge. Make it a required check, so a stale generated file blocks that merge instead of every branch after it.
Summary
A generated file is only as fresh as the last time someone ran the generator. Regenerate in CI, fail on any difference including new files, keep the output deterministic, and run it on what is actually being merged.
发生了什么
昨天我在一个仓库里合并了一个修改数据模型的变更。这个仓库的 JSON schema 是从模型定义生成的,而我在合并前没有运行生成文件的检查。schema 带着旧内容进了分支,开发分支变红,其他贡献者的 PR 也全被堵在后面。
重现:没人重新运行的生成器
我在一个临时仓库里放了一个脚本,读取 model.txt,写出 schema.json。我给 model.txt 加了一个字段并提交,但没有重新运行脚本。仓库里没有别的东西读取 schema.json,所以什么都没失败。schema 里列的仍然是两个字段,而不是三个。
检查:重新生成,然后问 git
在干净的检出里运行生成器,只要有任何变化就让任务失败。
python3 gen.py && git diff --exit-code -- schema.json
在那个过时的提交上,它打印出缺失的 "email" 那一行,并以退出码 1 结束。重新生成并提交之后,它以 0 结束。如果生成器自带检查模式,就用它;这个做法适用于任何把文件写进仓库的生成器。
漏洞一:git diff 看不到新文件
git diff 只比较已跟踪的文件。我让生成器多写一个文件之后,git diff --exit-code 以 0 结束,而 git status --porcelain 显示了 ?? schema-extra.json。所以要么检查状态,要么先把新文件标记为“意图添加”再比较。
test -z "$(git status --porcelain)"
git add -N . && git diff --exit-code
在重现中,这两条命令都因为那个未跟踪的新文件以退出码 1 结束。
漏洞二:每次结果都不一样的生成器
第二个生成器会把当前时间写进输出。已提交的输出和新生成的输出每次都不同,于是在正确的提交上检查也会失败。一个总是失败的检查,最后一定会被关掉。加检查之前,先把生成文件里的时间戳、随机 ID 和顺序不固定的输出去掉。
在合并发生的地方运行
检查要在真正会落地的提交上运行,也就是合并结果,而不只是作者的分支:模型可能在合并的另一边已经变了。再把它设为必需检查,让过时的生成文件挡住这一次合并,而不是挡住之后所有人的分支。
总结
生成文件只和最后一次有人运行生成器时一样新。在 CI 里重新生成,任何差异(包括新文件)都判为失败,保持输出确定,并且在真正要合并的提交上运行。
何が起きたか
昨日、あるリポジトリでデータモデルを変える変更をマージした。そのリポジトリの JSON スキーマはモデル定義から生成されるのに、私はマージ前に生成ファイルの検査を走らせなかった。スキーマは古いまま入り、開発ブランチが赤くなって、ほかの貢献者の PR まで止まった。
再現:誰も走らせ直さなかった生成器
検証用のリポジトリに、model.txt を読んで schema.json を書く小さなスクリプトを置いた。model.txt にフィールドを一つ足してコミットし、スクリプトは走らせ直さなかった。リポジトリのほかのどこも schema.json を読まないので、何も失敗しなかった。スキーマには相変わらず、三つではなく二つのフィールドしかなかった。
検査:生成し直して、git に尋ねる
まっさらなチェックアウトで生成器を走らせ、何か変わっていたらジョブを失敗させる。
python3 gen.py && git diff --exit-code -- schema.json
古いコミットでは、抜けている "email" の行を表示して終了コード 1 で終わった。生成し直してコミットしたあとは 0 で終わる。生成器に専用の検査モードがあるならそれを使えばよく、この方法はリポジトリ内にファイルを書く生成器ならどれにでも使える。
抜け穴その一:git diff は新しいファイルを見ない
git diff が比べるのは追跡中のファイルだけだ。生成器にファイルをもう一つ書かせると、git diff --exit-code は 0 で終わり、git status --porcelain には ?? schema-extra.json が出た。だから状態を確かめるか、新しいファイルを先に「追加予定」として印を付けてから比べる。
test -z "$(git status --porcelain)"
git add -N . && git diff --exit-code
再現では、この二つのコマンドはどちらも追跡されていない新しいファイルのせいで終了コード 1 を返した。
抜け穴その二:毎回違う結果を出す生成器
二つ目の生成器は、出力に現在時刻を書き込んでいた。コミット済みの出力と新しい出力が毎回食い違うので、正しいコミットでも検査が失敗した。いつも失敗する検査は、いずれ止められる。検査を入れる前に、生成ファイルから時刻、ランダムな ID、順序の決まらない出力を取り除く。
マージが起きる場所で走らせる
検査は作者のブランチだけでなく、実際に入るコミット、つまりマージ結果で走らせる。モデルはマージの相手側で変わっているかもしれない。そして必須の検査にして、古い生成ファイルがそのあとの全員のブランチではなく、そのマージ自体を止めるようにする。
まとめ
生成ファイルは、最後に誰かが生成器を走らせた時点の新しさしか持たない。CI で生成し直し、新しいファイルも含めてどんな差分でも失敗させ、出力を決定的に保ち、実際にマージされるコミットで走らせる。