結城です。
OSSプロジェクトへのコントリビュートの「べからず集」記事について、まだ要領を掴めていない人が「自分のしようとしていることもそれにあてはまるのではないか?」と心配になってコントリビュートをためらうことがないように、具体的な例と考え方を紹介するシリーズの5本目です。前回(4回目)、前前回(3回目)、前前前回(2回目)に続き、今回は「べからず」として挙げられている5つ目の点の「squash
で1つのコミットにまとめる」ということについて説明します。
5. 途中の複数のコミットをそのままにしない(squash
で1つのコミットにまとめる)
「プルリクエストを出す前に、コミットを綺麗に整理しておこう」というアドバイスは時々聞かれます。ただ、どのくらい整理すれば綺麗にしたことになるのか、程度が分からず戸惑う人もいるでしょう。
また、「コミットは細かい粒度で行おう」という原則をリーダブルコードや先輩の指摘などで学んだ人だと、「せっかく細かい粒度でコミットを分けたのに、大きな粒度のコミットにまとめてしまって本当にいいのか?」と不安になるかもしれません。
端的な答えとしては、前者は「rebaseしてからsquashすればそれで充分」、後者は「コミットを分けないと意味が取れなくなるような大きな規模の変更は、そもそもいきなりプルリクエストするのには不適切」と言えます。以下、それぞれもう少し詳しく述べてみます。
コミット履歴をなるべく単純にする
プルリクエスト用のコミット履歴を「綺麗にする」場面で使われるのは、Gitであれば以下の2つのコマンドです。
-
git rebase
-
git merge --squash
rebaseについては、Git公式のrebaseの解説(日本語)で図を交えて分かりやすく解説されています。端的には、「元のブランチの最新の状態からブランチを切って作業したことに歴史を改変する」コマンドと言えます。
Gitに限りませんが、あなたがブランチを切って何か作業をしている間に、元のブランチ側で何か別の作業が行われてコミットが行われている、ということはよくあります。その状態のまま作成したプルリクエストがマージされると、変更の履歴が途中で別れて再合流した形になります。
このような変更履歴だと、後から「この変更の前後で何が変わり、どのようにして整合性を保ったのか」ということがわかりにくくなる場合があります。
具体例を挙げて説明しましょう。https://github.com/clear-code/example/
で開発されているプロジェクトについて、デフォルトブランチがmain
で、ファイルの読み込みを行う以下のような関数があったとします(※JavaScriptの構文での擬似コードです)。
function readFile(path) {
const reader = new FileReader();
return reader.readSync(path);
}
ここで、あなたがリポジトリをforkした物をローカルにcloneし、git checkout -b working
で作業ブランチを作成して、この関数に対して「ファイルのエンコーディングを指定できるようにする」変更を行ったとします。このときのコミットの差分は以下の通りです。
-function readFile(path) {
+function readFile(path, { encoding } = {}) {
const reader = new FileReader();
- return reader.readSync(path);
+ return reader.readSync(path, { encoding });
}
その一方で、元プロジェクトのmain
側で「ファイルを非同期に読み込めるようにする」変更が行われたとします。こちらのコミットは以下の通りです。
-function readFile(path) {
+async function readFile(path) {
const reader = new FileReader();
- return reader.readSync(path);
+ return reader.readAsync(path);
}
このままではworking
ブランチからをプルリクエストを作成してもmain
にマージできません。先に、あなたのローカルリポジトリのworking
ブランチにおいて、git pull https://github.com/clear-code/example.git main
のようにして変更をpullし1、衝突を解消することにします。衝突した様子は以下の通りです
<<<<<<< HEAD
function readFile(path, { encoding } = {}) {
const reader = new FileReader();
return reader.readSync(path, { encoding });
=======
async function readFile(path) {
const reader = new FileReader();
return reader.readAsync(path);
>>>>>>> main
}
そして、この衝突を解消してマージを完了すると、マージコミットでの変更は以下のように記録されます。
- async function readFile(path) {
-function readFile(path, { encoding } = {}) {
++async function readFile(path, { encoding } = {}) {
const reader = new FileReader();
- return reader.readAsync(path);
- return reader.readSync(path, { encoding });
++ return reader.readAsync(path, { encoding });
}
さて、この状態で作成したプルリクエストが元プロジェクトにマージされたとします。後からコードを見た人が「このencoding
というパラメーターはいつ導入されたのだろうか?」と思うでしょう。行ごとの変更経緯を調べるためにgit blame path/to/file
を実行してみたとしましょう。……すると、なんとパラメーターを実装したときのコミットではなく、マージコミットでの変更しか表示されないのです! ここから作業ブランチでの個別のコミットを辿るためには、リビジョングラフを見ながらブランチ側のログを辿らなくてはなりません。
この例くらい単純だとまだなんとかなりますが、もっと広い範囲に渡って衝突して、マージコミットが巨大になってしまっていたら? あるいは、マージ対象のブランチが単独の作業ではなく複数の作業を含む物だったら? 変更が行われたコミットを特定するのはどんどん困難になってしまいます。調査の度にこのような状況が頻発してしまっては、開発者の負担増は計り知れません。
こういう問題が起こらないように歴史を改変するのが、rebaseです。
あなたのローカルリポジトリのworking
ブランチにいる状態で、元プロジェクトの最新の状態をpullするときにgit pull --rebase https://github.com/clear-code/example.git main
とすると2、作業ブランチとmain
との間で変更が衝突していた場合は、このタイミングで衝突を解消するようにコミット内容の修正を求められます3。先の例であれば、「ファイルのエンコーディングを指定できるようにする」変更を以下のように手動で修正することになります。
-async function readFile(path) {
+async function readFile(path, { encoding } = {}) {
const reader = new FileReader();
- return reader.readAsync(path);
+ return reader.readAsync(path, { encoding });
}
この変更は「衝突をこのようにして回避した」というコミット(マージコミット)ではなく、main
ブランチの最新の状態から切り直したブランチに対して行われた、「ファイルのエンコーディングを指定できるようにする」変更として記録されます4。つまり、履歴上、衝突は発生しなかったことになります。
作業ブランチの全コミットが、main
ブランチの後に矛盾なく繋がるように修正されれば、rebaseは完了です。この状態からプルリクエストを作成すれば、マージ後の変更履歴を辿りやすくなり、効率よく調査を進められます。
「履歴を綺麗にする」というと漠然としていますが、「衝突を解消するためだけのコミット」が少ない・変更の経緯を一本道で追いやすい状態を維持するということだと考えれば、何をどうすればその状態に近付くか分かりやすいのではないでしょうか。
提案はsquashしても意味を取れる程度の規模にとどめる
squash(スカッシュ)は「押し潰す」という意味の語句5で、Gitにおいては「複数のコミットを1つにまとめる」操作を指します。
例えば、先の「ファイルのエンコーディングを指定できるようにする」変更を、working
ブランチ上で以下の2つのコミットに分けて行ったとしましょう。
// 1コミット目:オプションを追加
-function readFile(path) {
+function readFile(path, { encoding } = {}) {
const reader = new FileReader();
return reader.readSync(path);
}
// 2コミット目:オプションで指定されたエンコーディングを使用
function readFile(path, { encoding } = {}) {
const reader = new FileReader();
- return reader.readSync(path);
+ return reader.readSync(path, { encoding });
}
これらをsquashしてみます。
squashは、そういう名前のコマンドがあるわけではなく、マージの操作時のオプションで指定します。具体的には、以下の手順で行います。
-
作業用の
working
ブランチにいる状態から、git checkout main
で一旦main
に戻る。 -
git checkout -b working-to-pullrequest
でプルリクエスト用のブランチworking-to-pullrequest
を作る。 -
プルリクエスト用のブランチ
working-to-pullrequest
にいる状態でgit merge --squash working
と実行する。
このように操作すると、working
にあった複数のコミットが以下のように1つにまとまったコミットになって、working-to-pullrequest
ブランチの唯一のコミットとして記録されます。
-function readFile(path) {
+function readFile(path, { encoding } = {}) {
const reader = new FileReader();
- return reader.readSync(path);
+ return reader.readSync(path, { encoding });
}
このworking-to-pullrequest
ブランチからプルリクエストを作成すれば、粒度の細かかった複数のコミットが「1つの大きなコミット」にまとまるため、マージ後の変更履歴がよりスッキリした物になるという訳です。
ですが、前述した通り、これは「作業の意味ごとに細かい粒度でコミットする」原則に反しているようにも思えます。「やらない方がいい」とされていることを、わざわざしてしまって本当にいいのでしょうか?
これは筆者の私見ですが、そもそも、squashした結果のコミットがあまりに複雑になるようなら、提案の規模が1つのプルリクエストにするには大きすぎると言えます。
単発のコントリビュートをする場面では、プロジェクトオーナーとまだ十分な信頼関係が築けていないことが多いでしょう。そのような段階で、squashすると訳が分からなくなるような大規模な変更を受け入れてもらうのは難しいです。レビューに長期間を要したり、あるいは、初手の時点で却下されたりしてもおかしくありません。
そのような段階では、いきなり大きなことに着手するのではなく、小さな規模のプルリクエストを積み重ねて、充分な信頼を得ることから始めるのがおすすめです。
広範囲に渡る大きな規模の変更をどうしても提案したいのであれば、変更を可能な限り小さな規模の、個別の変更に分けて提案しましょう。「Windowsの新しいバージョンへの対応に合わせて、機能Aと機能Bと機能Cを追加し、既存の機能Xの不具合も修正する」といったプルリクエストだったなら、「機能A追加」「機能B追加」「機能C追加「機能Xの修正」と複数回に分ける、といった要領です。
まとめ
以上、「途中の複数のコミットをそのままにしない(squash
で1つのコミットにまとめる)」という原則について、実例を挙げて解説してみました。
OSS開発者を増やすことを意図した取り組みであるOSS Gateでは、定期的にワークショップを開催し、「初めてのコントリビュートをしてみたい」人の背中を押す活動をしています。プルリクエストするかどうか迷っているOSSプロジェクトがあるけれども、プルリクエストしていいかどうか自信を持ちきれないという方は、ワークショップに参加してみると、日常的にOSSにコントリビュートしているサポーター参加者からアドバイスをもらえるかも知れません。
OSS Gateでは、新型コロナウィルスの感染拡大防止の観点から、現在は東京地域を主体としたワークショップをオンライン(Discord)で開催しています。次回開催予定は12月12日(土曜)13:00からで、ビギナー(ワークショップで初めてのフィードバックを体験してみたい人)・サポーター(ビギナーにアドバイスする人)のどちらも参加者を募集中です。ご都合の付く方はぜひエントリーしてみて下さい。業務のチームのメンバーや後輩の方にOSSへのコントリビュートをしてもらいたいと思っている方は、自身をサポーターとして、メンバーや後輩の方をビギナーとして参加のお声がけをして頂くのもおすすめです。
また、当社では企業内での研修としてのOSS Gateワークショップの開催も承っています(例:アカツキさまでの事例)。会社としてOSSへの関わりを増やしていきたいとお考えの企業のご担当者さまは、お問い合わせフォームからご連絡頂けましたら幸いです。