はじめに
42Tokyo Advent Calendar 2023 の24日目を担当する、42Tokyoのtharaです。
昨日はyokawadaさんが__を書いてました!!(プレッシャーをかけていくスタイル😅)
当方、昨年夏から、シリコンバレーのスタートアップでソフトウェアエンジニアをしています。Backendのコンサルタントとして Saša Jurić さんというスーパーエンジニアがチームに入ってくれているのですが、彼が社内で行ったopinionatedなGitの使い方講座が個人的にすごく役立っています。この記事は、その内容を本人に許可を頂いて翻訳+勝手な解釈をしたものです。クレジットは彼に、間違い指摘は私にお願いします。
Sašaさんは Elixir in Action という本の著者でもあるので、よかったらチェックしてみてください(Elixirの内容もいつかまとめたい)
TL;DR
Gitを有効活用するためのアイデアのまとめ。
- コミットは小さく
- 明確なコミットメッセージ
- 余分なコミットは削除
- 共有済みのヒストリーは書き換えない
- mergeコミットを使用
- ノイズはツールで削減
なぜこの記事を書こうと思ったのか?
42Tokyo運営のnopさんがこのような記事を書かれていました。まだ読んだことがない方は面白いのでぜひ。
もちろんこの記事はネタ要素を含んでいると思いますが、誰しもこんなコミットをしたくなった/したことがあると思います。ただ、それをチーム開発する時にもしますか?という話。
そして、一度綺麗なコミットログを持ったPRのレビューをすると、そのしやすさに感動します。このアイデアやメソッドをより多くの人が使うようになったらなと思いこの記事を書きました。
ライブデモの方が明らかに分かり易かったりするので、需要があったらいつか42でやるかも。
コミットログをきれいに保つべき理由
If your git history sucks, the review experience will suck too.
なぜコミットログを綺麗にする必要があるのか。
代表的なものは以下が考えられる。
- レビュー体験の向上
- ソフトウェア考古学
レビュー体験については、レビュワーがコミットログを元にレビューがしやすくなり、それによってより良いフィードバックがもらえるようになるという話。
ソフトウェア考古学は初めて聞いた単語だが、どのような背景でその変更がもたらされたのか考えるということ。この文脈では、綺麗なコミットログを持つことで、当時の開発者がいなくなっても、コードとコミットから変更の理由が明確になる。Sašaさんは、一人で開発をしていてもなぜそのコードを書いたのか覚えていないことは起きるので、役立つと言う。
コミットログをきれいに保つ戦略
具体的な戦略として3つのことを提案している。
- 漸進的変化
- 良いコミットメッセージ
- mergeコミットの使用
これらを一個づつみていくが、3つの目のmergeコミットの使用に関しては、マージ戦略の章でsquahマージなどと比較して検討する。
漸進的変化
With a couple of hundred lines in a single commit, it’s impossible to figure out what goes on.
目指すべきゴールは、1つのPRでの変更を漸進的な、つまり、緩やかに変わっていくようにコミットを作ること。
その理由は、1つのコミットが数百行のコードを含んでいる時、その変更がどの様な経緯でなぜ必要なのかを知るのが難しくなるからだ。それは、精神的な負担にもつながり、より多くの時間と体力をレビューに使うことになる。
個人的にも、直接的には関係のない変更がまとめられたコミットやPRを見るのがとても大変だった記憶がある。
なので、前提の話としてPRを小さくしよう。1つのPRが1つの機能と対応している必要はない。その上で、1つのPRの中でも変更の意図を明確に伝えるために、コミットを分けよう。
Sašaさんは、コミットをコミュニケーションの一種だと言っている。今週あった出来事を思いついた順で話す訳ではなく、出来事が起きた順に背景を明確にして話す方が良いよねと。同じようにどのように最終的なコードに辿り着いたのかをコミットログを使った明確な道筋として示してあげるべきである。
コミット毎に全てのテストが通る必要は無い。PR単位ではもちろんだが。
小さいコミットがいい訳ではない
ここでは注意点として、コミットが小さければ良いということでは無いことに触れている。
具体的な例として、いくつかの外部ライブラリをアップデートする時。それらのアップデートをすべて別々のコミットに分けていては、コミットのページを開くだけで大変だ。
具体的なコミット単位の考え方としては以下を挙げている。
- レビューする立場になった時に、それらの変更がまとまっていた方が見やすいか
- その変更を表現する明確で短いコミットメッセージが思いつくか
また、大きいコミットが許される場合としてはこれらなどがある。
- システム全体での変数名の変更
- コマンドでのコード自動生成
これらのコミットはむしろそれだけで切り出して、その変更を明確に記述することでレビュワーの負担を確実に減らすことが出来る。
個人的には、インデントがずれる変更や関数定義の移動なども、PRレビュー画面のdiffでは比較がしづらいこともあると感じる。レビュワーが注意を払ってみる必要が無いものは切り分けてあげるのはまず出来ることだろう。
良いコミットメッセージとは?
コミットメッセージの書き方としてSašaさんはこちらの記事をお勧めしている。
ぜひ元記事も読んで頂きたいのだが、いくつかアイデアを載せておく。(英語でコミットを書く場合)
- Title
-
If applied, this commit will [commit message]
を埋めるように記述 - 短く意図をまとめる
- 50文字以内
- 大文字で始める
- period(.)を含めない
-
- Body
- より詳細なまとめ
- コードからは明確ではない判断を記述
- コードのコメントに書くべきものは書かない(コードを消すときなど、Bodyを使わないといけない場合もある)
- Bodyを書く前に、コミットを分割することでBodyが無くても変更の意図を明確に出来ないかを考える
ツールを使えば楽になる
ここまで具体的な戦略を見てきたが、実際にこれを実践しようと結構大変だ。もちろん、慣れていないというのもあるだろうが、以下のような事態に出くわすことがよくある。
- 1つのファイルの変更を行レベルで分割したい
- 前のコミットに変更を追加したい
それぞれ(ざっくりと)解決方法を見ていく。
行レベルで変更をコミット
gitコマンドだと git add -p
だが、個人的に使いづらかったので、Git Kraken を使っている。
Sašaさんは SmartGit を使っているそうだ。ここでは、Git Krakenの例を示す。
Git Krakenではこのように変更箇所を確認することが出来るが、黄色の枠で囲まれてるところをクリックすることで、Hunk(複数行のコードのまとまり)か行単位でstageに変更を追加することができる。
これらはVSCodeからも出来るので色々と試してみて欲しい。
前のコミットに変更を追加
直前のコミットに変更を追加したい場合は簡単だ。このコマンドでStageに上がっている変更が直前のコミットに追加される。
git commit --amend --no-edit
3つの前のコミットに追加したいという場合は少しだけ面倒くさい。
例えば、以下のような例を考えて欲しい。コミットが3つあり、1つ目のコミット ( Remove useStateCallback
)と3つ目のコミット ( Remove useStateCallback definition
) をまとめて1つのコミットにしたい状況だ。
-
Start Rebase
ボタンをクリック
このようにして、元々別々であった変更を一つのコミットにすることが出来た。
といっても、rebaseが失敗すると面倒くさかったりする。ここら辺は未だに試行錯誤中。
gitコマンド的には git rebase -i
を用いることで出来る。
余談: git blameを辿る
これは、きれいなコミットログを作るというよりは、そのログを使って、コード変更の理由を探すときの方法。
もちろん、コマンドラインからも出来るのだが、いちいちhash値を取っておくのが面倒くさかったりする。それが、 Githubのファイルページから一瞬で出来る。
ファイルページで Blame
を選択する。そうすると、行毎に最新のコミットが表示されるが、このコミットの横にあるボタンを押すと Blame prior to change 912d547, made on Dec 5, 2023
が出来る。つまり、このコミットが追加される直前のコミットに戻って、もう一回 git blame
をしてくれる。
この機能を用いることで、複数回、変更が行われてるコードに対してもコミットログを遡っていくことが容易に出来る。
最終手段
最終手段として、コミットログを一から作り直すのもありだ。すでに作業が完了したブランチから、新しいブランチに変更をコピーしながら直線的な変化を作る。
パフォーマンス改善のタスクなどは、実験をしながらコーディングをすることになるので、ログがジグザグになりがちだ。それはしょうがない。ただ、そのログをそのままpushせずに、不要なコミットを消してきれいにしてあげよう。
完璧主義に陥るな
You should not aim to make your history perfect. You want to look for the balance of reasonably good history with reasonably low effort.
Sašaさんは、完璧なコミットログを作る必要は無いと言っている。
紹介したツールなどを使いこなすことである程度は楽になるが、それでも完璧にするの大変だ。
これはバランスの問題であり、そこそこの努力でそこそこに良いコミットログを作ることを目指していただきたい。
Reviewされるときの心得
今まで紹介してきた interactive rebaseなどはコミットログを書き換える。つまり、前までのhash値とは違う値が割り振られた別のコミットが新たに作られるということだ。
これをレビュー済みのコードにされるとかなり辛い。
なぜなら、レビュワーの目線では、既にレビューしたコードと新たな変更を組み合わせた新しいコミットが出来てしまい、どこまでが既にレビューしたコードで、どこから新たな変更なのかが分からなくなるからだ。
そのため、原則として、既に共有(レビュー)されたコミットは書き換えないようにしよう。
もちろん、新しい変更の中で複数コミットがあり、そこでinteractive rebaseなどをする分には問題ない。
3種類のマージ戦略
マージする時にもいくつかの方法がある。コミットログを有効活用する点からは、mergeコミットを作成することをSašaさんは勧めている。まずは、3種類のマージ戦略を見てみよう。
このような状況を想像して欲しい。mainブランチにあるaコミットから分岐して、新しいブランチを作成し、bとcを追加。それをmergeしようとしている。
fast forwarding
もし、aコミット以降に変更が足されていないのであれば、fast forwardingマージが出来る。
mergeコミット
aコミット以降に、mainブランチに変更が追加されてる場合や、されていなかったとしてもmergeコミットを作成してマージ出来る。
squahマージ
そして、bコミットとcコミットの内容をsquahし、新しくdコミットを作成してマージするsquahマージ(squah and merge)。
なぜmergeコミットを使うべきなのか
1. PRに存在する2つのコンテキスト
ある1つのPRを考えた時、そこには2種類のコンテキストが存在する。
- PRが表現する大きなコンテキスト
- 一連の変更を通して何を実現しようとしているのか
- commitが表現する小さなコンテキスト
- その具体的なコードの変更をなぜ行う必要があるのか(例えば、その関数がなぜ必要なのか)
先ほどのマージ戦略の図で言えば、青が小さなコンテキストで緑が大きなコンテキストを表す。
つまり、fast forwardingマージは、mergeコミットを持たないため大きな変更のコンテキストを失う。
squashマージは、もともと存在していたコミットをsquahしてしまうため、小さな変更のコンテキストを失う。
mergeコミットを使用したときだけ、この2つのコンテキストを残しておくことが出来るのだ。これがmergeコミットを推奨する1つ目の理由だ。
(Squashマージを使ってもGithubのPRを見に行けば、小さなコンテキストも残っているが、Git上からは消え、その他のGitツールなどを使うことは不可能になる)
2. squashマージの別の問題点
squahマージには他にも使いづらい点がある。
このような状況を想像して欲しい。あるブランチ上で、bとcコミットを作成し、PRでレビューを受ける。そのレビューを待っている間に、新たなブランチを作成して、dとeコミットで新しいPRを作る。
そして、1個目のPRがレビューされたのでマージする。このとき2つ目に作ったPRはどうなるのだろうか?
簡単に状況を再現したレポジトリーを作った。文章で説明するよりレポジトリーを見てもらった方が早いだろう。
一応説明
test.txtというファイルに、1つ面のPRで1を足す。このブランチを元にしたブランチから2を足して、2つめのPRを作る。そして、1つ目のPRをsquahマージする。
git checkout -b 1
echo "1" > test.txt
git add -A
git commit -m 'Add 1'
git checkout -b 2
echo "2" >> test.txt
git add -A
git commit -m 'Add 2'
このとき2つ目のPRはこうなっている。
- 既にマージしたはずの
Add 1
のコミットがこのPRにも表示されている - コンフリクトが起きる変更ではないが、コンフリクトが起きている
この時のコミットの状況はこうだ。squashのせいで正確な差分検知が出来ていないという訳だ。
そして、これをレビューするのは面倒臭い。どこからがこのPRの変更なのかを見分ける必要がある。そして、無駄なconflictの解決もする必要がある。
mergeコミットを使用するとこのようなことは起こらない。これが2つめのmergeコミットの使用を推奨する理由だ。
「ログを”きれい”にしたい」という意見への反論
mergeコミットを使うことへの反論として、ログを”きれい”にしたいという声がある。
意味の無い小さなコミットや作業ブランチにmainブランチを取り込んだ時のmergeコミットによって、ログが汚くなると。
これはUIの問題が寄与するところが大きい。UIの問題なら、UI上で解決しよう。squashマージで、全てのコミットを一つにまとめることによって解決するべき問題ではない。
ここまで読んで頂いた皆さんなら、本当にきれいなコミットログが何であるかは分かっていることであろう。
では、具体的な方法を3つ見ていく。
1. git logのoptionを使う
例えば、 --graph
を使うことで、mergeコミットとそれ以外を分けて表示することが出来る。
git log --graph --pretty=oneline
他にも、 --first-parent
を用いることで、mergeコミットだけを表示することができる。
git log --first-parent --pretty=oneline
2. 無駄なコミットはまとめて、変更の内容を記述する
# 悪い例
7c1a5e80 Add a core operation
85164730 Fix a bug
b701df4a Really fix a bug
4d5c3f17 Actual bug fix
Gei9bo4d Address Sasa's feedback
↓
# 書き換えた例
7c1a5e80 Add a core operation
85164730 Improve typespecs
b701df4a Expand test
4d5c3f17 Fix a typo
コミットメッセージの書き方については、前の章で触れた通りだ。そして、まとめられるコミットはまとめて、無駄なコミットは消そう。
また、レビューで受けたコメントを直すときもAddress Saša’s comments
と書く代わりに Fix a typo
と変更の内容を記述しよう。小さなコンテキストを明確にするということだ。
3. mergeノイズを避ける
作業しているブランチにmainブランチを不必要に取り込むのは止めよう。このmergeコミットは本当に無駄なノイズを作る。
どうしても取り込む必要がある時は、まだPRを作っていないのであれば、rebaseをしよう。
「Review時の心得」の章でも述べたように、既にレビューを受けているのであれば、rebaseは避けて、mergeをしよう。
終わりに
susamiさん、kakibaくん事前レビュー+FeedBackありがとうございました。
記事書くって大変🫠みんな尊敬します。
明日は、sleepyfox97 さんが書いてくれます。お楽しみに。