OSSプロジェクトへのコントリビュートで避けるべき6つのこと:5. squashで1つのコミットにまとめる - 2020-11-20 - ククログ

ククログ

株式会社クリアコード > ククログ > OSSプロジェクトへのコントリビュートで避けるべき6つのこと:5. squashで1つのコミットにまとめる

OSSプロジェクトへのコントリビュートで避けるべき6つのこと:5. squashで1つのコミットにまとめる

結城です。

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は、そういう名前のコマンドがあるわけではなく、マージの操作時のオプションで指定します。具体的には、以下の手順で行います。

  1. 作業用のworkingブランチにいる状態から、git checkout mainで一旦mainに戻る。

  2. git checkout -b working-to-pullrequestでプルリクエスト用のブランチworking-to-pullrequestを作る。

  3. プルリクエスト用のブランチ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への関わりを増やしていきたいとお考えの企業のご担当者さまは、お問い合わせフォームからご連絡頂けましたら幸いです。

  1. あなたの手元のリポジトリ自体が「元プロジェクト」で、ローカルリポジトリ上で作業している場合であれば、workingにおいてgit merge mainを実行します。

  2. あなたの手元のリポジトリ自体が「元プロジェクト」で、ローカルリポジトリ上で作業している場合であれば、workingにおいてgit rebase mainを実行します。

  3. 衝突が無ければ、矛盾がないように自動的に処理されます。

  4. そのため、rebase後のコミットは元々のコミットとは別物として記録されます。

  5. 例えば、飲み物の「レモンスカッシュ」はレモンを押し潰した絞り汁を使うのでそう呼ばれています。