- はじめに
- そもそもなぜ細かくコミットするのか
- コミットが細かすぎるとどうなるか
- squashとは何か
- git rebase -iでコミットをまとめる方法
- まとめた後の履歴
- 他の整理方法との違い
- GitHubのプルリクエスト画面で使えるsquash機能
- まとめる際に意識したいポイント
- 注意点
- まとめ
はじめに
開発を進めていると、「やっぱりこっちのロジックにする」「タイポを修正」のような小さなコミットが増えていきやすいものです。こうした履歴をそのままにしておくと、後から見返したときに変更の意図が伝わりにくくなってしまいます。そこで、今回は、コミットを整理して意味のある単位にまとめる方法について、調べた内容を備忘録としてまとめます。
そもそもなぜ細かくコミットするのか
作業中は細かくコミットしておく方がよいとされている理由には、いくつかの背景があります。
例えば、商品検索機能を実装している途中で、絞り込み条件のロジックを試行錯誤しているとします。1つのやり方を試してコミットし、うまく動かなかったので別のやり方に変えてまたコミットしておけば、git reset(直前のコミットの状態に戻すコマンド)を使って、いつでも試す前の状態に戻ることができます。もし変更をすべて1つの大きな作業のまま溜め込んでいたら、途中の特定の部分だけを取り消すことが難しくなってしまいます。
一方で、この「作業中の記録としてのコミット」と「他の人に見せるための履歴」は、本来役割が異なるものです。作業中は自分のための記録として細かく残しておき、他の人に見せる段階になったら読みやすい形に整理し直すという2段階の考え方を持っておくと、履歴の管理がしやすくなります。この整理の段階で使われるのがsquashという操作です。
コミットが細かすぎるとどうなるか
作業中のコミットをそのままの形でプルリクエストとして提出してしまうと、レビューする側にとっては読みにくいものになってしまいます。例えば、以下のようなコミット履歴を考えてみます。
9f3c1a0 feat: 商品検索機能のベースを追加
7d2e8b4 fix: 検索条件の絞り込み方法を変更
6c1a9f2 WIP(絞り込みロジックの調整中)
5b8d3e7 fix: 検索結果の表示崩れを修正
4a7c2f1 feat: ページネーションの追加漏れを修正
なお、コミットメッセージの先頭に付けているfeat:やfix:は、変更の種類を表す接頭辞(コミットメッセージの先頭に付ける短い分類ラベル)です。featは新機能の追加、fixは不具合の修正を表しており、こうした接頭辞を統一しておくと、履歴を一覧したときにどんな種類の変更なのかを判断しやすくなります。
この履歴からは、作者がどのような試行錯誤をたどったのかがそのまま伝わってきます。ただ、レビューする側が本当に知りたいのは、試行錯誤の過程そのものではなく「結局どんな機能が実装されたのか」という結果です。1つの機能に対して5つものコミットが並んでいると、差分を1つずつ開きながら意図を推測する必要があり、レビューにかかる時間が余計に増えてしまいます。
さらに、この履歴は将来的にも影響を残します。例えば数か月後に不具合が発生し、git logでその機能に関わるコミットを調べようとしたとき、細切れのコミットが並んでいると、どこからどこまでが1つの変更のまとまりなのかを判断しにくくなります。「WIP(絞り込みロジックの調整中)」というメッセージだけが残っていても、それが結局どんな意味を持つ変更だったのかは、差分を開いて中身を確認しないと分かりません。
チーム開発では、この履歴の読みにくさがそのままレビューの遅れや調査の手間につながることもあります。レビューする人数が増えるほど、1人あたりが履歴を読み解くのにかけている時間の合計は大きくなっていきます。プルリクエスト1件あたりのレビュー時間が数分伸びるだけでも、チーム全体で積み重なると無視できない時間になってしまいます。
squashとは何か
squash(スカッシュ:複数のコミットを1つにまとめる操作)は、Gitでコミット履歴を整理するときによく使われる仕組みです。英語のsquashには「押しつぶす」という意味があり、複数の小さなコミットを1つの大きなコミットに押しつぶして統合するイメージで捉えると分かりやすいです。
先ほどの5つのコミットをsquashでまとめると、以下のような1つのコミットに整理できます。
9f3c1a0 feat: 商品検索機能の実装
まとめた後は、それぞれのコミットで行われていた変更の内容そのものが失われるわけではありません。ファイルに加えられた差分は、すべて統合後の1つのコミットの中に含まれています。変わるのは、その差分が「何回のコミットに分かれて記録されているか」という履歴の見た目の部分です。
履歴が1つにまとまっていると、差分の中身を1つずつ追わなくても、コミットメッセージを見るだけで全体像がつかめるようになります。レビューする側は「商品検索機能が実装された」という1文を読めば、その後は実際の差分だけを確認すればよく、途中経過を追う手間がなくなります。
squashを行う方法はいくつかありますが、代表的なものとしてgit rebase(リベース:コミット履歴を編集し直すコマンド)を使う方法があります。
内容を整理すると、squashは複数のコミットを1つに統合する操作であり、統合後も変更内容そのものは保たれたまま、履歴の見た目だけが整理されるという点がポイントです。
git rebase -iでコミットをまとめる方法
git rebase -iは、直近のコミット履歴を対話形式で編集できるコマンドです。-iは「インタラクティブ(interactive:対話的)」の略で、このオプションを付けることで、コミットを1つずつ選びながら操作を指定できるようになります。
コミット履歴の書き換えに関する注意
git rebase -iは、コミットの位置に新しい内容を上書きするのではなく、対象範囲のコミットをいったん取り消してから作り直す操作です。そのため、すでにpush(リモートリポジトリへの反映)を済ませたコミットに対して行うと、他のメンバーとの間で履歴の食い違いが発生することがあります。詳しくは注意点で解説しています。
まとめたいコミットが直近5つの場合、以下のコマンドを実行します。
まとめる範囲を決める前に、git log --oneline(コミット履歴を1行ずつ簡潔に表示するコマンド)で対象のコミットをあらかじめ確認しておくと、まとめる範囲を間違えにくくなります。今回の例であれば、直近5件のコミットがすべて同じ機能に関するものであることをgit log --oneline -5で確認したうえで作業を進めています。
$ git rebase -i HEAD~5
HEADは現在作業しているコミットの位置を指す言葉で、HEAD~5は「現在の位置から5つ前のコミットまで」を指定する書き方です。今回の例では、まとめたいコミットは5つですが、そのうち最初の1つは残す起点として使うため、対象範囲としては5つ分をさかのぼって指定しています。
コマンドを実行すると、エディタが自動的に開き、対象のコミット一覧が表示されます。このとき開かれるエディタは、Gitの初期設定ではvi(ターミナル上で動作するテキストエディタ)になっていることが多く、操作に慣れていない場合は戸惑いやすい部分です。git config --global core.editor "code --wait"のように設定しておくと、普段使い慣れているエディタ(この例ではVisual Studio Code)を開くように変更できます。
主なエディタの設定コマンド例
普段使っているエディタに応じて、以下のように書き換えるとよさそうです。
# サクラエディタ
$ git config --global core.editor "'C:/Program Files (x86)/sakura/sakura.exe' -CODE=4"
# nano
$ git config --global core.editor nano
サクラエディタの-CODE=4は文字コードをUTF-8に指定するオプションです。これを付けずに保存すると、環境によっては文字コードがShift-JISになり、GitHubなどで日本語のコミットメッセージが文字化けしてしまうことがあります。
pick 9f3c1a0 feat: 商品検索機能のベースを追加
pick 7d2e8b4 fix: 検索条件の絞り込み方法を変更
pick 6c1a9f2 WIP(絞り込みロジックの調整中)
pick 5b8d3e7 fix: 検索結果の表示崩れを修正
pick 4a7c2f1 feat: ページネーションの追加漏れを修正
ここで、先頭に書かれているpickという単語が操作の種類を表しています。pickはそのコミットをそのまま残すという意味で、初期状態ではすべてのコミットにpickが指定されています。上から古い順に並んでいる点も、通常のgit logの表示順とは逆になっているため、最初は戸惑いやすいところです。
このpickをsquash(または省略形のs)に書き換えると、そのコミットを1つ前のコミットに統合できます。先頭の1行だけはpickのまま残しておく必要があります。まとめの起点になるコミットが必要だからです。
pick 9f3c1a0 feat: 商品検索機能のベースを追加
squash 7d2e8b4 fix: 検索条件の絞り込み方法を変更
squash 6c1a9f2 WIP(絞り込みロジックの調整中)
squash 5b8d3e7 fix: 検索結果の表示崩れを修正
squash 4a7c2f1 feat: ページネーションの追加漏れを修正
書き換えて保存すると、続けてコミットメッセージを編集する画面が開きます。ここには、統合される5つのコミットのメッセージがすべて並んで表示されます。
# This is a combination of 5 commits.
feat: 商品検索機能のベースを追加
fix: 検索条件の絞り込み方法を変更
WIP(絞り込みロジックの調整中)
fix: 検索結果の表示崩れを修正
feat: ページネーションの追加漏れを修正
この画面で、統合後のコミット全体に対する新しいメッセージを入力し直します。先ほどの例であれば、以下のように機能全体を表す1文だけを残し、残りの行は削除しておきます。
feat: 商品検索機能の実装
保存してエディタを閉じると、リベースの処理が実行され、5つに分かれていたコミットが1つにまとまります。処理の途中で、同じ箇所を複数回書き換えるような変更が含まれていると、コンフリクト(競合:変更内容が重なって自動統合できない状態)が発生することがあります。その場合はGitの指示に従ってファイルを修正し、git addで変更を反映したあとにgit rebase --continueを実行すると、まとめの作業を続けられます。
もし途中で操作を間違えてしまった場合は、git rebase --abortを実行すると、リベースを開始する前の状態にそのまま戻すことができます。慣れないうちは、一度squashを試してみてうまくいかなければ中断するという使い方をしても問題ありません。
なお、squashとよく似た操作としてfixup(または省略形のf)もあります。squashは統合後にコミットメッセージを編集する画面が開くのに対して、fixupはそのコミットのメッセージをそのまま捨てて、1つ前のコミットのメッセージだけを残す点が異なります。「タイポ修正」のように、それ自体には残す価値のないメッセージを統合する場合は、fixupを使うとメッセージの編集を省略できて手早く整理できます。
このgit rebase -iの内部では、対象範囲のコミットをいったん取り消し、指定した操作(pickやsquash)の順に沿って1つずつ新しいコミットとして作り直すという処理が行われています。そのため、squashでまとめた後のコミットは、元のコミットとは異なる新しいコミットとして扱われ、コミットを識別するハッシュ値(コミットごとに割り当てられる一意な文字列)も変わります。この「新しく作り直される」という性質は、公開済みのコミットを書き換えてはいけない理由にもつながっています。
まとめた後の履歴
squashを行った後の履歴は、以下のようにすっきりとしたものになります。
9f3c1a0 feat: 商品検索機能の実装
試行錯誤の過程は履歴から見えなくなりますが、その代わりに「1つの機能に対して1つのコミット」という分かりやすい単位が残ります。レビューする側は、コミットメッセージを見るだけでどんな変更が加えられたのかを把握しやすくなります。
また、この整理は将来自分自身のためにもなります。数か月後に同じ機能に関する不具合が見つかったとき、git logやgit blame(変更履歴を行単位でたどるコマンド)で調べる際にも、1つのコミットに意図がまとまっていると原因を特定しやすくなります。git blameはファイルの各行がどのコミットで追加されたかを表示する機能なので、コミット単位の意味が明確であるほど、調査にかかる時間は短くなります。
なお、squashを行った後にgit diff(コミット間の差分を表示するコマンド)で変更内容を確認しても、まとめる前とまとめた後で表示される差分の中身自体は変わりません。変わるのはコミットの区切り方だけなので、実装した内容を後から見返す際にも安心して整理を行うことができます。
他の整理方法との違い
squashを行う方法としては、git rebase -i以外にgit merge --squashというコマンドを使う方法もあります。こちらはブランチをマージする際に、そのブランチのすべてのコミットを1つにまとめてから取り込む方法です。
【git rebase -iとgit merge --squashの比較】
- git rebase -i: 同一ブランチ内の直近のコミットを、まとめたい範囲だけ選んで整理する
- git merge --squash: 別ブランチ(機能ブランチなど)の変更をまとめて1つのコミットとして取り込む
git merge --squashは、以下のように取り込みたいブランチ名を指定して実行します。
$ git merge --squash feature/search
$ git commit -m "feat: 商品検索機能の実装"
このコマンドを実行すると、feature/searchブランチに含まれるすべての変更が作業ディレクトリ(現在の作業内容が反映されている場所)に取り込まれた状態になります。その後git commitを実行して初めて、1つのコミットとして記録されます。git rebase -iとは異なり、コミットを個別に選ぶ操作が不要な分、ブランチ単位でまとめて統合したいときには手早く整理できます。
同じブランチ内で作業中のコミットを整理したい場合はgit rebase -i、機能ブランチの作業をまとめて本流のブランチに取り込みたい場合はgit merge --squashというように、状況に応じて使い分けるとよさそうです。
似たような操作としてgit commit --amend(直前のコミットを修正するコマンド)もありますが、こちらは直前の1つのコミットだけを対象にした修正で、複数のコミットをまとめる用途には向いていません。「1つ前のコミットのタイポだけ直したい」といった場合には--amend、「複数回に分けてしまったコミットをまとめて整理したい」といった場合にはsquashというように、対象とするコミットの数によって使い分けると考えやすいです。
なお、すべてのコミットを必ず1つにまとめるべきというわけではありません。例えば「検索APIの呼び出し部分を実装した部分」と「その結果を画面に表示する部分」のように、それぞれが独立した意味を持つ変更であれば、あえて2つのコミットとして残しておく方が、後から見返したときに分かりやすいこともあります。squashを使う目的は、コミット数を減らすことそのものではなく、意味のある単位に整理し直すことにあるという点を意識しておくとよいです。
GitHubのプルリクエスト画面で使えるsquash機能
ここまで紹介してきたgit rebase -iは、自分の手元でコミットをまとめてからプルリクエストを提出する方法でした。一方、GitHubを使っている場合は、プルリクエストをマージする段階でも似たような整理を行うことができます。
GitHubのプルリクエスト画面には、マージ方法を選ぶボタンが用意されており、その中に「Squash and merge」という選択肢があります。これを選ぶと、プルリクエストに含まれるすべてのコミットが、マージのタイミングで自動的に1つのコミットにまとめられます。
この機能を使うと、開発者自身が事前にgit rebase -iでまとめておかなくても、最終的な本流のブランチには1つのコミットとして記録されます。ただし、プルリクエストの画面上では、まとめられる前の細かいコミットがそのまま表示され続けるため、レビューする側が差分を確認する段階では、コミットが整理されていた方が読みやすいことに変わりはありません。
【手元でのsquashとGitHubのSquash and mergeの比較】
- 手元でのsquash: プルリクエストを提出する前に、レビューしやすい単位へ自分で整理しておく
- GitHubのSquash and merge: マージのタイミングで自動的に1つのコミットへまとめられる
チームによっては、プルリクエスト単位で1つのコミットになれば十分という考え方から、GitHubのSquash and merge機能だけに任せている場合もあります。一方で、レビュー段階から読みやすい履歴を保ちたい場合は、手元でgit rebase -iを使って事前に整理しておく方法が向いています。どちらを採用するかは、チームのレビュー方針に合わせて決めるとよさそうです。
どちらの方法も、目的は同じく「意味のある単位でコミットを残す」ことにあります。手元で整理する習慣を身につけておくと、GitHub以外のプラットフォームを使っている場合や、squash機能を持たないマージ方法を採用しているチームでも、同じ考え方をそのまま活かすことができます。
まとめる際に意識したいポイント
squashを活用するうえで、意識しておくとよいポイントをまとめます。
- タイミング: プルリクエストを作成する前にまとめておくと、レビューする側の負担が減る
- 粒度: 1つの機能や1つの修正で1つのコミットになるよう意識する
- メッセージ: まとめた後のコミットメッセージは、変更内容ではなく変更の意図が伝わる表現にする
- 範囲: 関係のない複数の機能を1つのコミットにまとめてしまわないよう、まとめる対象の範囲を機能単位で区切る
これらを意識しておくと、チーム開発において履歴を読みやすい状態に保ちやすくなります。履歴がきれいに保たれていると、後から不具合の原因を探す際にも、どのコミットが影響しているのか特定しやすくなります。
また、squashを行うタイミングについては、コミットのたびに毎回まとめる必要はありません。作業中はこまめにコミットを残しておき、プルリクエストとして提出する直前にまとめてrebaseするという流れにすると、作業中の安心感と履歴の読みやすさを両立しやすくなります。
粒度の判断に迷ったときは、「このコミットメッセージだけを読んで、変更の内容が想像できるか」を基準にしてみると考えやすいです。「WIP(絞り込みロジックの調整中)」のようなメッセージからは変更内容が想像できませんが、「商品検索機能の実装」であれば、差分を見なくてもおおよその内容が伝わります。
これらのポイントは、個人の判断だけに任せてしまうと、人によって基準がばらついてしまうこともあります。チームで開発している場合は、「プルリクエスト提出前にはsquashでまとめる」「コミットメッセージの接頭辞はfeatとfixを基本にする」といったルールをあらかじめ決めておくと、履歴全体の読みやすさが安定しやすくなります。新しく加わったメンバーにとっても、共通のルールが決まっていることは、履歴の読み方を覚えるうえでの助けになります。
注意点
squashを行う際に、1つだけ注意しておきたい点があります。それは、すでにpush(リモートリポジトリへの反映)を済ませたコミットは書き換えないという点です。
すでに公開されたコミットへの注意
git rebase -iは、コミットの履歴そのものを書き換える操作です。自分の手元だけで作業しているコミットであれば問題ありませんが、すでにチームの他のメンバーが取得済みのコミットを書き換えてしまうと、履歴の食い違いが発生してしまいます。
その結果、他のメンバーの環境でエラーが発生したり、余計なコンフリクトの解決作業が必要になったりすることがあります。これは、git rebase -iでコミットをまとめる方法で触れたとおり、squash後のコミットはハッシュ値が変わる新しいコミットとして扱われるためです。他のメンバーが古いハッシュ値のコミットを元に作業を続けていると、そのコミットが履歴上から消えてしまったように見えてしまいます。squashは、あくまでpushする前の、自分だけが把握している履歴に対して行うとトラブルを避けやすいです。
もし誤ってすでにpush済みのコミットをまとめてしまった場合は、git push --force(強制的にリモートの履歴を上書きするコマンド)が必要になりますが、これは他のメンバーの作業に影響を与える可能性がある操作です。チームで作業しているリポジトリでは、force pushを行う前にチームメンバーへの確認を挟んでおくと安心です。
自分だけが使っている作業用のブランチであれば、force pushによる影響を受けるのは自分だけなので、比較的気軽に試しやすいです。squashに慣れるまでは、まず個人の作業ブランチで練習しておくと、安心して操作を覚えられます。
なお、force pushを行う場合は、単純な--forceよりも--force-with-lease(安全な強制上書きを行うオプション)を使う方法もあります。こちらは、自分が把握していない間にリモート側のブランチが更新されていた場合に、上書きを止めてくれる仕組みを持っています。他のメンバーが同じブランチを使っている可能性がある場合は、--force-with-leaseを選んでおくと、意図しない上書きを防ぎやすくなります。
まとめ
コミットの粒度が細かすぎると履歴が読みにくくなってしまいますが、git rebase -iによるsquashを活用することで、意味のある単位に整理し直すことができます。