- ブラウザURL入力
- DNS名前→IP
- ルータ経路選択
- ロードバランサ振り分け
- Nginx受け口
- アプリ処理今ここ
- DB保存
このコースの芯は「『壊れていない』は、何と比べたかでしか言えない」でした。前回は手元ときれいな機械を比べました。今回比べるのは、ブランチと、それが分かれた基点です。
まず結論
ブランチのテストが緑でも、それは分かれた元のコミット(基点)にそのブランチの変更だけを載せた版の結果です。
2本のブランチがそれぞれ緑で、Git のマージが衝突なく終わっても、合わせた版は赤になりえます。合わせた版は、まだ誰も比べていないからです。
同じコミットから分かれた2本のブランチがあり、どちらもテストは緑です。片方でもう片方を git merge すると、衝突なく終わりました。合わせた版でテストを走らせると、どうなりうるでしょうか。
当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。
緑は、どの版に対する判定か
ブランチでテストを走らせるとき、テストされているのは「基点 + 自分の変更」という版です。基点とは、そのブランチが分かれた元のコミットのことです。
2本のブランチを並べると、テストされた版は2つあります。
- A 側: 基点 + A の変更
- B 側: 基点 + B の変更
マージで生まれるのは「基点 + A + B」という3つ目の版です。この版は、どちらのブランチのテストも見ていません。 2つの緑を足しても、3つ目の版の緑にはなりません。
では、マージするときに Git は何を確かめているのか。図解コース『Gitの内部構造から理解する』のコンフリクトの回で見たとおり、Git は基点・自分側・相手側の3点を比べ、変更箇所が離れていれば同じファイルでも自動でマージします。言い換えると、Git が確かめているのはテキストの重なりだけで、合わせたプログラムが動くかは見ていません。
Fowler はこの隙間に名前を付けています。
By a semantic conflict I mean a situation where Jez and I make changes which can be safely merged on a textual level but cause the program to behave differently.
テキストとしては安全にマージできるのに、振る舞いが変わる。本回ではこれを意味の衝突と呼びます。
- ・緑は「どの版に対して」を添えて初めて意味を持つ
- ・マージの成功は、3つ目の版の緑を意味しない
改名と、旧名の呼び出し
Fowler が挙げる最も単純な例は関数名の変更です。
Say I think that the method clcBl would be easier to work with if it were called calculateBill.
The problem appears, however, if Jez adds more calls to this method on his feature branch. When the two get merged, the textual merge will work fine, but the program will not run the same way.
これを JavaScript で再現しました(macOS 26.5.2、git 2.50.1、Node v24.21.0)。基点には次の bill.js と、それを呼ぶ report.js があります。
function clcBl(yen) { return Math.floor(yen * 110 / 100); }module.exports = { clcBl };
- rename ブランチ:
clcBlをcalculateBillに改名し、呼び出し側(report.js)もすべて直す - newcall ブランチ: 改名を知らずに、旧名
b.clcBl(200)を呼ぶ summary.js を足す
テストは、bill.js 以外の各 .js を node で実行し、1つでも落ちたら RED を出すだけのものです。2回実行し、出力は完全に一致しました。
== rename ブランチ: ./test.shreport: 110GREENexit=0== newcall ブランチ: ./test.shreport: 110summary: 220GREENexit=0== git merge newcall (rename ブランチ上で)Merge made by the 'ort' strategy.summary.js | 2 ++1 file changed, 2 insertions(+)create mode 100644 summary.jsgit merge exit=0== git status --short(空=衝突なし)== git log --oneline --graph* e64787a Merge branch 'newcall' into rename|\| * 77340c1 add summary using clcBl* | 37bfcb8 rename clcBl to calculateBill|/* 6582873 base
両ブランチとも GREEN、マージの終了コードは0、git status --short は空です。グラフの最下行から、2本とも 6582873 base から分かれたことが読めます。つまり2つの緑は、どちらも「base に載せたとき」の結果です。
合わせた版でテストを走らせます。
== grep -n clcBl *.jssummary.js:2:console.log("summary:", b.clcBl(200));== マージ後: ./test.shreport: 110/private<tmp>/r/summary.js:2console.log("summary:", b.clcBl(200));^TypeError: b.clcBl is not a functionNode.js v24.21.0RED (summary.js)exit=1
旧名の呼び出しは summary.js に1箇所だけ残り、そこで TypeError が出て赤になりました。
Fowler 自身は、関数名の変更は easy to find in a statically typed language as it will fail to compile と書いています。この実演は Fowler の例を動的型付けの言語で再現したもので、だから実行時まで落ちませんでした。CI の記事も in a dynamic language we get no such help と書いています。
実演の rename ブランチで出た GREEN が保証しているのはどれですか。
比べる相手を、合わせた版にする
原因が「合わせた版を誰も比べていない」なら、対策は比べる相手を変えることです。
Fowler の処方は2つあります。1つは自分でテストを持つコード(SelfTestingCode)。ただし Tests can never be perfect, but they catch lots of semantic conflicts in practice. と限定付きです。もう1つは、もっと頻繁にマージすること。何日も後ではなく数時間で相手の変更に気づけば、古い意味の上にコードを積み上げずに済む、という理屈です。
実演でもテストが意味の衝突を捕まえたのは、マージ後の版でテストを走らせたからです。テスト自体は各ブランチで走らせたものと同じでした。変わったのは比べた版だけです。
これを仕組みにしたのが bors です。Graydon Hoare は、かつての同僚 Ben Elliston らが作った仕組みの考え方を、Ben の言葉 "not rocket science" とともに紹介し、自分で規則として書き直しています。
The Not Rocket Science Rule Of Software Engineering: automatically maintain a repository of code that always passes all the tests
bors の手順は、承認された変更ごとに統合ブランチへ載せた一時的な版を作ってテストし、通ったときだけ統合ブランチを進める、というものです。
It tests that temporary revision and advances your integration branch to point to the integration revision if and only if the tests pass.
GitHub の merge queue も同じ考え方を文書に書いています(when applied to the latest version of the target branch and any pull requests already in the queue)。
代償もあります。Graydon は、統合の周期がテストの周期に縛られ、変更によっては何度か試さないと統合できないと書いています。比べる相手を正しくすると、待ち時間を払うことになります。
Git が止まる衝突と、止まらない衝突
対比のために、Git が止まる場合を置きます。両方のブランチが基点の同じ箇所を別々に書き換えると、Git はマージを完了せず、作業ツリーに両方の候補をマーカー付きで書いて人に委ねます。Fowler の CI の記事は、こちらを textual conflicts, often called “merge conflicts” と呼び、Version-control tools detect these easily と書いています。
同じ記事は続けて、関数名の変更については the version-control system can't help us と書きます。さらに、関数の中身が微妙に変わる場合は even statically-typed compilation doesn't help us です。
どちらも「同じ基点から別々に加えた変更を合わせる」場面です。違うのは、合わせた結果を確かめる仕組みが、テキストについては Git に組み込まれていて、振る舞いについては組み込まれていないことです。
git merge が終了コード0で終わり、git status --short が空だったとき、言えることはどれですか。
テキストの衝突(同じ箇所を両方が変更)
Git は基点と両側の3点を比べ、両方が同じ箇所を別々に変えていれば自動では合わせません。マーカーを書いて止まり、人に判断を委ねます。気づく手続きが道具の中にあります。
比べたもの 結果基点 と 自分側 同じ箇所を変更基点 と 相手側 同じ箇所を変更Git の判定 競合。マーカーを書いて止まる
意味の衝突(改名と旧名の呼び出し)
変更箇所が離れているので Git は自動でマージし、終了コードは0でした。赤に気づけたのは、合わせた版でテストを走らせたからです。その手続きは Git の外にあります。
比べたもの 結果base と rename bill.js と report.js を変更、GREENbase と newcall summary.js を追加、GREENGit の判定 自動マージ(exit=0)合わせた版の ./test.sh RED (summary.js)
Git が止まった場合と、止まらなかった場合。2つに共通する構造は何でしょうか。
各ブランチが何と何を比べたか、合わせた版を誰が比べたかを、両方で書き出してみてください。
この章のまとめ
- ブランチの緑は 「基点 + 自分の変更」に対する結果。合わせた版の緑ではない。
- Git の自動マージが確かめるのは テキストの重なりだけ。終了コード0は「動く」を意味しない。
- テキストは安全に合わさるのに振る舞いが変わる状態を、Fowler は semantic conflict と呼ぶ。本回の実演は Fowler の例を動的型付けの言語で再現したもので、静的型付けならコンパイルで落ちると Fowler 自身が書いている。
- 対策は 比べる相手を合わせた版にすること。頻繁にマージする、統合後の版をテストしてから本流を進める(bors、merge queue)。
- "not rocket science" は Ben Elliston の言葉、規則として名付けて書き直したのは Graydon Hoare。
- 代償は待ち時間。統合の周期はテストの周期に縛られる。
自分の現場で確かめる
読んだだけでは身につきません。手元の環境で実際に見てみてください。
理解度チェック
読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。
「not rocket science」という言葉と、The Not Rocket Science Rule という規則の帰属として正しいのはどれですか。
用語集(4語)
- 緑 / 赤
- テストがすべて通った状態 / どれかが落ちた状態。本回では、どの版に対する結果かを必ず添えて読む。
- 基点
- ブランチが分かれた元のコミット。3-way merge では共通祖先(base)として比較の起点になる。
- 意味の衝突 (semantic conflict)
- テキストとしては安全にマージできるのに、合わせるとプログラムの振る舞いが変わる状態。Fowler の定義による。Git は検出しない。
- merge queue
- GitHub の機能。変更を本流の最新版と先に並んだ変更に載せたときに、必須のチェックを通ることを確かめる。