Git履歴をgit resetとgit rebaseで管理する(翻訳)
私は全般的に、どちらかといえば規則にうるさい方ですが、自分のプロジェクトでgit履歴を管理するときはこの性格が役に立ちます。以前の私はGitHubの"squash & merge"方式をしばらく使っていましたが、その後Chris Mooreからいくつかのコツを教わりました。
私は"squash & merge"方式が好きになれません。どんなに巨大なプルリクエストでも、全体を丸ごと1個のコミットにsquash
してしまうからです。つまり、1個のコミットに含まれた巨大な変更が今後も残り続ける可能性があるのです。
私は、コミット履歴は脱線のない直線的なストーリーを語るべきだと信じています(気取った物言いに聞こえるかもしれませんが)。
訳注: このツイートは現在参照できないため、原文の画像より引用
左上: Squash&Merge
右下: 自分が心を込めて書き上げたコミットメッセージ
コミット履歴がきれいな形になっていれば、コードレビュアーの仕事も楽になります。コミット履歴を見れば変更の移り変わりを一目で把握できるようになり、diffを見れば個別のコミットを論理的に理解できるようになるので、プルリクエストでコードレビュアーが熟読すべき変更範囲も分割されて小さくなります。
長期的には、プロジェクトの履歴を精査して、どの変更がどんな理由で重要かを確認するときにも有用です。git履歴が慎重に管理されていれば、これが可能になるのです。
私がgitのコミット履歴を管理するときは、gitで主に以下の2つを使っています。
git reset --mixed
git rebase -i
(インタラクティブrebase
)
🔗 1: git reset
たとえば、mainブランチをベースにしたfeature/dashboardブランチで作業中だとしましょう。私がここで何らかの機能を構築している間、手元のgit履歴の見出しは以下のようにまるでいい加減なものになっています。
- WIP
- 何かする
- こいつを修正
- ダッシュボードできた
このままではとても人に見せられないので、以下を実行します。
$ git fetch
$ git reset --mixed origin/main
これでブランチの状態がmain
にリセットされて、しかも自分の変更は作業用コピーにすべて保存されます(ただし実行前にrebase
かmerge
でmainブランチを最新にしておく必要があります)。続いて、以下のように論理的な順序を保ってコミットします。
- 新しいダッシュボードのレイアウトを追加
- 新しいアセットを追加
- ドロワーアニメーション用Stimulusコントローラ
- ダッシュボードのコントローラアクションとビューを作成
これで改善されました。レビュアーはコミットメッセージを見るだけで変更内容を即座に把握できるようになり、コミット同士のdiffもまともになります。git履歴が書き換えられたので、以下のコマンドでブランチを強制プッシュする必要があります。
$ git push --force
私はこの時点で、プロジェクトで使われているRuboCopなどのlinterを必ずと言っていいほど実行し忘れてしまいます。linter修正のコミットを追加すると、せっかく作り上げた直線的なストーリーが台無しになってしまいます。そんなときに使うのが、次のインタラクティブrebase
です。
🔗 2: インタラクティブrebase
インタラクティブrebase
を使えば、どのコミットをブランチに残すかを選択し、かつ複数のコミットをsquash
できます。git rebase -i origin/main
を実行すると、以下のように(mainではない)カレントのブランチに存在するコミットリストがテキストエディタで開きます。
pick 6246a52ba 新しいダッシュボードのレイアウトを追加
pick 0fd58314b 新しいアセットを追加
pick 25d5bafa3 ドロワーアニメーション用Stimulusコントローラ
pick 1432fc34a ダッシュボードのコントローラアクションとビューを作成
pick cbf35b39f RubocopとESlintを実行
# Rebase e88d9298b..cbf35b39f onto e83d9294b (3 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup [-C | -c] <commit> = like "squash" but keep only the previous
# commit's log message, unless -C is used, in which case
# keep only this commit's message; -c is same as -C but
# opens the editor
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <label> = reset HEAD to a label
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
# create a merge commit using the original merge commit's
# message (or the oneline, if no original merge commit was
# specified); use -c <commit> to reword the commit message
# u, update-ref <ref> = track a placeholder for the <ref> to be updated
# to this position in the new commits. The <ref> is
# updated at the end of the rebase
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.
#
この編集画面で利用可能なすべてのコマンドは、このファイル内のコメントに記載されています。この場合は、fixup
コマンドを使ってlinter修正コミットを他の適切なコミットにsquash
します。エディタでファイルを閉じるとrebase
が完了します。
この場合も履歴が書き換えられているので、git push --force
のように強制的にプッシュする必要があります。
🔗 インタラクティブrebase
の他の使い道
私は契約開発者として、メインプロジェクトのforkで作業したり、クライアントの要求によってはmain以外のブランチで作業したりすることがよくあります。つまり、場合によっては自分が作業しているベースブランチがrebase
されることもあるわけです。
たとえば、私がintegrationブランチで作業していて、mainと同期するためにrebase
した場合は、以下のようにローカルのintegrationブランチもリモートのintegrationブランチと同期する必要があります。
$ git fetch
$ git checkout integration
$ git reset --hard origin/integration
この操作は、必ずローカルのintegrationブランチを何も変更していない状態で行うこと。さもないと変更内容が失われてしまいます。
次に、自分のフィーチャーブランチをrebase
してintegrationブランチと同期する必要があります。rebase
すると履歴が書き換えられてしまうので、integrationブランチがrebase
された後でハッシュが変わり、重複コミットがフィーチャーブランチに紛れ込む可能性があります。フィーチャーブランチには機能に関係する変更だけを含めるべきなので、以下のようにフィーチャーブランチから重複を取り除く必要があります。
$ git fetch
$ git checkout feature/dashboard
$ git rebase -i integration
これでコミットリストは以下のようになります。
pick 2342bcaf3 CIを修正
pick 6246a52ba 新しいダッシュボードのレイアウトを追加
pick 0fd58314b 新しいアセットを追加
pick 25d5bafa3 ドロワーアニメーション用Stimulusコントローラ
pick 1432fc34a ダッシュボードのコントローラアクションとビューを作成
pick cbf35b39f RubocopとESlintを実行
# Rebase e88d9298b..cbf35b39f onto e83d9294b (3 commands)
#
# ...
おっと、このCIを修正
コミットはこの作業と関係ありませんね。このコミットが紛れ込んだ理由は、これがintegrationブランチにあって、rebase
中にハッシュが変更されたからです。このコミットを除外するには、この行をエディタで削除してからファイルを閉じれば、ブランチが最新の状態になり、期待通りの変更内容になります。これも強制プッシュする必要があります。
🔗 まとめ
git履歴の書き換えにはリスクがつきものです。自分がやっていることを十分理解できていないうちは、破壊的変更を試みる前に必ずブランチのコピーを作成しておくこと。
チーム全員が使っているブランチへの強制プッシュは、この操作に十分習熟して正しく対処できるようになるまでは絶対に行ってはいけません。フィーチャーブランチで他の人も作業している場合は、強制プッシュを行うことを事前に周知したうえで他の作業者にきちんと了解を取り、自分や他の人の作業結果を手違いで吹っ飛ばさないよう十分に注意すること。
私はもう10年以上gitを使い続けており、特にインタラクティブrebase
はかれこれ3年も使っているにもかかわらず、未だに正しく理解できた気がしません。私はとにかく使い倒すことで感覚を養ってきました。
皆さんがこのあたりで少々混乱することがあっても気に病む必要はありません。私も最初からそうでしたし、現在もややましになっただけで基本的に変わっていませんが、それでも練習を重ねれば身に付けられます。これは使いこなせば素晴らしい武器になるツールなのです。
概要
原著者の許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。