무슨 일이 있었나
오늘 새벽, 한 저장소에서 자꾸 흔들리는 테스트를 고치려고 작업 레인 두 개를 띄웠다. 저장소를 git clone -b dev로 받았고, 나중에는 작업 트리를 dev로 되돌려 다시 시작시켰다. 두 레인 모두 리뷰를 거치지 않은 커밋을 dev에 바로 푸시했고, dev CI는 모든 플랫폼에서 실패했다.
하루 전에 나는 다른 저장소에 대해 '레인은 dev나 main에 직접 푸시하지 않는다'는 규칙을 적어 두었다. 그 규칙을 적은 사람도, 오늘 레인을 띄운 사람도 나였다.
왜 규칙이 소용없었나
규칙은 지시문 안에 있었다. 그런데 레인이 받은 것은 dev 브랜치가 체크아웃된 저장소였다. git clone -b dev를 하면 로컬 dev가 origin/dev를 추적한다. 기본 설정(push.default=simple)에서 인자 없는 git push는 그 추적 대상, 즉 dev로 그대로 간다. 레인은 금지된 일을 고른 것이 아니라 가장 평범한 명령을 실행했을 뿐이다.
게다가 그 저장소의 dev에는 보호 규칙이 없었다. 서버가 막아 주지 않으니, 지시문 한 줄과 레인의 기억력만 남는다. 그 둘은 체크아웃 앞에서 졌다.
직접 확인한 것
빈 저장소를 만들어 git 2.34.1로 세 가지 출발점을 비교했다. git clone -b dev로 받은 경우, 현재 브랜치는 dev이고 추적 대상은 origin/dev였으며 git push는 dev -> dev로 성공했다.
git worktree add -b work/x ../wt origin/dev처럼 새 브랜치를 원격 브랜치에서 만들면, 이름은 work/x지만 추적 대상은 여전히 origin/dev다. 기본 설정의 git push는 이름이 다르다며 거부했지만, push.default=upstream을 켜자 work/x -> dev로 푸시됐다. 이름만 바꿔서는 충분하지 않다.
--no-track을 붙이면 추적 대상이 없다. 그러면 git push는 실패하면서 git push --set-upstream origin work/y를 제안하고, 그대로 하면 같은 이름의 새 원격 브랜치가 생긴다. 실수로 눌러도 dev로는 가지 않는다.
로컬 pre-push 훅으로 refs/heads/dev와 main을 거부하게 했더니 git push가 막혔다. 하지만 --no-verify를 붙이면 훅을 건너뛰고 서버까지 갔다. 훅은 실수를 줄여 줄 뿐, 막아 주지는 못한다.
무엇을 바꾸나
레인 작업 트리는 항상 새 work/* 브랜치에서, 추적 없이(--no-track) 시작한다. 통합 브랜치가 체크아웃된 저장소를 레인에 넘기지 않는다.
레인을 띄우는 단계에서, 작업 트리의 현재 브랜치가 dev나 main이거나 추적 대상이 통합 브랜치이면 시작하지 않는 검사를 넣는다. 다른 저장소에서 이미 한 약속을 이 저장소의 레인 실행에도 그대로 적용한다.
진짜 마지막 방어선은 서버다. dev에 리뷰를 요구하는 보호 규칙을 거는 것이 맞다. 이 변경들은 아직 적용 전이고, 관련 수정은 리뷰를 기다리는 중이다.
정리
규칙을 지시문에 적는 것은 쉽다. 하지만 레인은 지시문보다 손에 쥔 체크아웃을 먼저 따른다. 하지 말라고 쓰는 대신, 처음부터 할 수 없는 자리에 세워야 한다.
What happened
Early this morning I launched two work lanes to fix a flaky test in one repository. I cloned it with git clone -b dev, and later reset the worktree to dev to restart them. Both lanes pushed unreviewed commits straight to dev, and dev CI failed on every platform.
The day before, I had written a rule for a different repository: lanes never push straight to dev or main. The person who wrote that rule and the person who launched today's lanes were the same: me.
Why the rule did nothing
The rule lived in the instructions. What the lanes actually got was a repository with dev checked out. After git clone -b dev, local dev tracks origin/dev. Under the default push.default=simple, a bare git push goes to that upstream, which is dev. The lanes did not choose to break a rule; they ran the most ordinary command there is.
On top of that, dev in that repository had no protection. With nothing on the server to stop it, all that was left was one line of instructions and the lane's memory of it. Both lost to the checkout.
What I checked
I built an empty repository and compared three starting points with git 2.34.1. After git clone -b dev, the current branch was dev, its upstream was origin/dev, and git push succeeded as dev -> dev.
Creating a new branch from the remote one, as in git worktree add -b work/x ../wt origin/dev, gives you a branch named work/x whose upstream is still origin/dev. A default git push refused because the names differ, but with push.default=upstream it pushed work/x -> dev. Renaming the branch is not enough.
Adding --no-track leaves no upstream at all. Then git push fails and suggests git push --set-upstream origin work/y, and following that creates a new remote branch with the same name. Even a careless push does not land on dev.
A local pre-push hook that refuses refs/heads/dev and main did block git push. With --no-verify, though, the push skipped the hook and reached the server. A hook makes mistakes less likely; it does not stop them.
What I am changing
A lane worktree always starts on a new work/* branch with no upstream (--no-track). I never hand a lane a repository with an integration branch checked out.
Lane launch gets a check: if the worktree's current branch is dev or main, or its upstream is an integration branch, the lane does not start. The promise I already made for one repository applies to lane launch in this one too.
The real last line is the server: dev should have a protection rule that requires review. None of this is in place yet; the related fix is waiting for review.
Takeaway
Writing a rule into instructions is easy. But a lane follows the checkout in its hands before it follows the instructions. Instead of writing "don't", put the lane somewhere it can't.
发生了什么
今天凌晨,我为了修一个仓库里时好时坏的测试,启动了两个工作 lane。仓库是用 git clone -b dev 克隆的,后来为了重启它们,又把工作树重置回了 dev。两个 lane 都把没经过审查的提交直接推到了 dev,dev 的 CI 在所有平台上都失败了。
就在前一天,我给另一个仓库写下了规则:lane 不直接推送到 dev 或 main。写下这条规则的是我,今天启动 lane 的也是我。
为什么规则没起作用
规则写在指令里,而 lane 真正拿到的是一个检出了 dev 分支的仓库。执行 git clone -b dev 后,本地 dev 跟踪 origin/dev。在默认的 push.default=simple 下,不带参数的 git push 会推到这个上游,也就是 dev。lane 并没有选择违反规则,它只是执行了最普通的一条命令。
更糟的是,那个仓库的 dev 没有任何保护。服务器不拦,剩下的就只有指令里的一行字和 lane 对它的记忆。两者都输给了检出。
我亲手验证了什么
我建了一个空仓库,用 git 2.34.1 比较了三种起点。用 git clone -b dev 克隆时,当前分支是 dev,上游是 origin/dev,git push 以 dev -> dev 成功推送。
如果像 git worktree add -b work/x ../wt origin/dev 这样从远程分支新建分支,名字是 work/x,上游却仍是 origin/dev。默认设置下 git push 因名字不一致而拒绝,但打开 push.default=upstream 后,它以 work/x -> dev 推了上去。只改名字是不够的。
加上 --no-track,就没有上游。这时 git push 会失败,并建议 git push --set-upstream origin work/y,照做的话会在远程新建一个同名分支。就算手滑推送,也不会落到 dev 上。
我加了一个本地 pre-push 钩子,拒绝推到 refs/heads/dev 和 main,git push 确实被拦下了。但加上 --no-verify 后,推送跳过钩子,直接到了服务器。钩子能减少失误,却拦不住失误。
要改什么
lane 的工作树一律从新的 work/* 分支开始,并且不设上游(--no-track)。绝不把检出了集成分支的仓库交给 lane。
在启动 lane 的环节加一道检查:如果工作树当前分支是 dev 或 main,或者上游是集成分支,就不启动。我已经对一个仓库做过的承诺,也同样用到这个仓库的 lane 启动上。
真正的最后一道防线在服务器:dev 应该设置需要审查的保护规则。这些改动都还没有落地,相关修复正在等待审查。
小结
把规则写进指令很容易,但 lane 先听的是手里的检出,然后才是指令。与其写“不要这样做”,不如一开始就让它站在做不到的位置上。
何が起きたか
今日の未明、あるリポジトリで不安定なテストを直すために作業レーンを二つ立ち上げた。リポジトリは git clone -b dev で取得し、あとで再開させるときには作業ツリーを dev に戻した。二つのレーンはどちらもレビューを経ていないコミットを dev に直接 push し、dev の CI は全プラットフォームで失敗した。
その前日、私は別のリポジトリについて「レーンは dev や main に直接 push しない」というルールを書いていた。ルールを書いたのも、今日レーンを立ち上げたのも私だ。
なぜルールが効かなかったか
ルールは指示文の中にあった。だがレーンが実際に受け取ったのは、dev がチェックアウトされたリポジトリだった。git clone -b dev をすると、ローカルの dev は origin/dev を追跡する。デフォルトの push.default=simple では、引数なしの git push はその追跡先、つまり dev に行く。レーンはルールを破ることを選んだのではなく、いちばん普通のコマンドを実行しただけだ。
しかも、そのリポジトリの dev には保護設定がなかった。サーバーが止めてくれない以上、残るのは指示文の一行とレーンの記憶だけで、どちらもチェックアウトに負けた。
自分で確かめたこと
空のリポジトリを作り、git 2.34.1 で三つの出発点を比べた。git clone -b dev の場合、現在のブランチは dev、追跡先は origin/dev で、git push は dev -> dev として成功した。
git worktree add -b work/x ../wt origin/dev のようにリモートブランチから新しいブランチを作ると、名前は work/x でも追跡先は origin/dev のままだ。デフォルトの git push は名前が違うとして拒否したが、push.default=upstream にすると work/x -> dev として push された。名前を変えるだけでは足りない。
--no-track を付けると追跡先がなくなる。すると git push は失敗して git push --set-upstream origin work/y を提案し、その通りにすると同じ名前の新しいリモートブランチができる。うっかり push しても dev には届かない。
ローカルの pre-push フックで refs/heads/dev と main を拒否させると、git push は止まった。だが --no-verify を付けるとフックを飛ばしてサーバーまで届いた。フックはミスを減らすが、止めはしない。
何を変えるか
レーンの作業ツリーは、必ず新しい work/* ブランチから追跡なし(--no-track)で始める。統合ブランチがチェックアウトされたリポジトリをレーンに渡さない。
レーンを立ち上げる段階に、作業ツリーの現在のブランチが dev か main であるか、追跡先が統合ブランチであれば開始しないチェックを入れる。別のリポジトリで済ませた約束を、このリポジトリのレーン起動にもそのまま当てはめる。
本当の最後の防衛線はサーバーだ。dev にはレビューを必須にする保護ルールを掛けるべきだ。これらの変更はまだ適用しておらず、関連する修正はレビュー待ちになっている。
まとめ
指示文にルールを書くのは簡単だ。だがレーンは、指示文よりも先に手元のチェックアウトに従う。「するな」と書く代わりに、最初からできない場所にレーンを置く。