チャプター ▾ 第2版

7.6 Gitツール - 履歴の書き換え

履歴の書き換え

Gitで作業していると、ローカルのコミット履歴を修正したい場面が多々あります。Gitの素晴らしい点の1つは、ぎりぎりのタイミングで意思決定ができることです。ステージングエリアを使えば、コミット直前にどのファイルをどのコミットに入れるかを決められますし、git stashを使えば、まだ作業したくないことを中断できます。そして、すでに発生したコミットを、まるで別の方法で発生したかのように見せるために書き換えることもできます。これには、コミットの順序を変更したり、メッセージを変更したり、コミット内のファイルを修正したり、コミットをまとめたり分割したり、あるいはコミットを完全に削除したりといったことが含まれます。これらすべては、作業を他の人と共有する前に行えます。

このセクションでは、これらのタスクを実行する方法を学び、作業を他の人と共有する前にコミット履歴を思い通りにする方法を見ていきます。

注記
満足するまではプッシュしない

Gitの最も重要なルールの1つは、作業の大部分がローカルのクローン内で行われるため、ローカルで履歴を自由に書き換えられるということです。しかし、一度プッシュしてしまうと話は全く別になり、正当な理由がない限り、プッシュされた作業は最終的なものと考えるべきです。要するに、作業に満足し、世界と共有する準備ができるまでは、プッシュを避けるべきです。

最後のコミットを変更する

最新のコミットを変更することは、おそらく最もよく行う履歴の書き換えでしょう。最後のコミットに対しては、主に2つの基本的なことを行いたいと思うはずです。単純にコミットメッセージを変更するか、あるいはファイルを追加、削除、修正することでコミットの実際のコンテンツを変更するかです。

最後のコミットメッセージだけを修正したい場合は、簡単です。

$ git commit --amend

上記のコマンドは、前のコミットメッセージをエディターセッションにロードします。そこでメッセージに変更を加え、それらの変更を保存して終了できます。エディターを保存して閉じると、エディターはその更新されたコミットメッセージを含む新しいコミットを書き込み、それを新しい最後のコミットとします。

一方、最後のコミットの実際のコンテンツを変更したい場合も、プロセスは基本的に同じです。まず、忘れていたと思われる変更を加え、それらの変更をステージングし、その後のgit commit --amendコマンドが、その最後のコミットを新しい改良されたコミットに置き換えます

このテクニックを使用する際には注意が必要です。なぜなら、amendはコミットのSHA-1を変更するからです。これは非常に小さなリベースのようなものです。すでにプッシュした最後のコミットをamendしないようにしてください。

ヒント
amendされたコミットには、amendされたコミットメッセージが必要な場合と不要な場合がある

コミットをamendする際、コミットメッセージとコミットのコンテンツの両方を変更する機会があります。コミットのコンテンツを大幅にamendする場合は、ほぼ間違いなくコミットメッセージもそのamendされたコンテンツを反映するように更新すべきです。

一方、amendが十分に些細な場合(些細なタイプミスを修正したり、ステージし忘れたファイルを追加したりなど)で、以前のコミットメッセージで問題ない場合は、単純に変更を加え、ステージングし、不要なエディターセッションを完全に避けることができます。

$ git commit --amend --no-edit

複数のコミットメッセージを変更する

履歴のさらに奥にあるコミットを修正するには、より複雑なツールに移行する必要があります。Gitには履歴を修正する直接的なツールはありませんが、リベースツールを使って一連のコミットを、元々それらが基づいていたHEADにリベースすることで、別のHEADに移動させるのではなく、元の位置で変更できます。インタラクティブなリベースツールを使用すると、変更したい各コミットの後に停止し、メッセージを変更したり、ファイルを追加したり、好きなことを何でもできます。git rebase-iオプションを追加することで、インタラクティブにリベースを実行できます。コミットをどれだけ遡って書き換えたいかを、どのコミットをリベースの基点にするかをコマンドに指示することで指定する必要があります。

たとえば、最後の3つのコミットメッセージ、またはそのグループ内のいずれかのコミットメッセージを変更したい場合、git rebase -iの引数として、編集したい最後のコミットの親であるHEAD~2^またはHEAD~3を指定します。最後の3つのコミットを編集しようとしているため、~3の方が覚えやすいかもしれませんが、実際には4つ前のコミット、つまり編集したい最後のコミットの親を指定していることに留意してください。

$ git rebase -i HEAD~3

これはリベースコマンドであるということを再度覚えておいてください。HEAD~3..HEADの範囲内でメッセージが変更されたコミット、およびそのすべての子孫は書き換えられます。すでにセントラルサーバーにプッシュしたコミットは含めないでください。そうすると、他の開発者に同じ変更の代替バージョンを提供することになり、混乱を招きます。

このコマンドを実行すると、テキストエディターに次のようなコミットのリストが表示されます。

pick f7f3f6d Change my name a bit
pick 310154e Update README formatting and add blame
pick a5f4a0d Add cat-file

# Rebase 710f0f8..a5f4a0d onto 710f0f8
#
# 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 <commit> = like "squash", but discard this commit's log message
# 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.
#
# 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.
#
# Note that empty commits are commented out

これらのコミットが、通常logコマンドで表示される順序とは逆の順序でリストされていることに注意することが重要です。logを実行すると、次のような表示になります。

$ git log --pretty=format:"%h %s" HEAD~3..HEAD
a5f4a0d Add cat-file
310154e Update README formatting and add blame
f7f3f6d Change my name a bit

逆順であることに注目してください。インタラクティブなリベースは、実行するスクリプトを提供します。コマンドラインで指定したコミット(HEAD~3)から開始し、これらの各コミットで導入された変更を上から下へと再適用します。最新のものではなく、最も古いものが上部にリストされます。なぜなら、それが最初に再適用されるからです。

編集したいコミットで停止するようにスクリプトを編集する必要があります。そのためには、スクリプトが停止するようにしたい各コミットの「pick」という単語を「edit」という単語に変更します。たとえば、3番目のコミットメッセージのみを修正したい場合は、ファイルを次のように変更します。

edit f7f3f6d Change my name a bit
pick 310154e Update README formatting and add blame
pick a5f4a0d Add cat-file

エディターを保存して終了すると、Gitはリストの最後のコミットまで巻き戻し、次のメッセージを表示してコマンドラインに戻します。

$ git rebase -i HEAD~3
Stopped at f7f3f6d... Change my name a bit
You can amend the commit now, with

       git commit --amend

Once you're satisfied with your changes, run

       git rebase --continue

これらの指示は、何をすべきかを正確に教えてくれます。次のように入力します。

$ git commit --amend

コミットメッセージを変更し、エディターを終了します。その後、次を実行します。

$ git rebase --continue

このコマンドは他の2つのコミットを自動的に適用し、これで完了です。もし、複数の行で`pick`を`edit`に変更した場合、`edit`に変更した各コミットに対してこれらのステップを繰り返すことができます。そのたびにGitは停止し、コミットを修正させ、完了したら続行します。

コミットの並べ替え

インタラクティブなリベースを使って、コミットを並べ替えたり、完全に削除したりすることもできます。もし「Add cat-file」コミットを削除し、他の2つのコミットが導入される順序を変更したい場合、リベーススクリプトを次のように変更できます。

pick f7f3f6d Change my name a bit
pick 310154e Update README formatting and add blame
pick a5f4a0d Add cat-file

これを次のようにします。

pick 310154e Update README formatting and add blame
pick f7f3f6d Change my name a bit

エディターを保存して終了すると、Gitはブランチをこれらのコミットの親まで巻き戻し、310154eを適用し、次にf7f3f6dを適用して停止します。これにより、それらのコミットの順序が効果的に変更され、「Add cat-file」コミットは完全に削除されます。

コミットのスカッシュ

一連のコミットをインタラクティブなリベースツールで単一のコミットにスカッシュすることも可能です。スクリプトはリベースメッセージに役立つ指示を置きます。

#
# 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 <commit> = like "squash", but discard this commit's log message
# 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.
#
# 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.
#
# Note that empty commits are commented out

「pick」や「edit」の代わりに「squash」を指定すると、Gitはその変更と直前の変更の両方を適用し、コミットメッセージをマージさせます。したがって、これら3つのコミットから単一のコミットを作成したい場合、スクリプトを次のようにします。

pick f7f3f6d Change my name a bit
squash 310154e Update README formatting and add blame
squash a5f4a0d Add cat-file

エディターを保存して終了すると、Gitは3つの変更すべてを適用し、その後、3つのコミットメッセージをマージするためにエディターに戻ります。

# This is a combination of 3 commits.
# The first commit's message is:
Change my name a bit

# This is the 2nd commit message:

Update README formatting and add blame

# This is the 3rd commit message:

Add cat-file

それを保存すると、以前の3つのコミットの変更をすべて導入する単一のコミットが得られます。

コミットの分割

コミットを分割するには、コミットを取り消し、その後、最終的に必要な数のコミットになるまで部分的にステージングしコミットします。たとえば、3つのコミットの真ん中のコミットを分割したいとします。「Update README formatting and add blame」の代わりに、「Update README formatting」(最初のコミット)と「Add blame」(2番目のコミット)の2つのコミットに分割したいとします。これは、分割したいコミットの指示を「edit」に変更することで、rebase -iスクリプトで行うことができます。

pick f7f3f6d Change my name a bit
edit 310154e Update README formatting and add blame
pick a5f4a0d Add cat-file

その後、スクリプトがコマンドラインに戻ったとき、そのコミットをリセットし、リセットされた変更を取り込み、それらから複数のコミットを作成します。エディターを保存して終了すると、Gitはリストの最初のコミット(f7f3f6d)の親まで巻き戻り、最初のコミット(f7f3f6d)を適用し、2番目のコミット(310154e)を適用し、コンソールに戻ります。そこで、git reset HEAD^でそのコミットをmixedリセットできます。これは事実上そのコミットを取り消し、変更されたファイルをステージされていない状態のままにします。これで、複数のコミットを作成するまでファイルをステージングしてコミットし、完了したらgit rebase --continueを実行できます。

$ git reset HEAD^
$ git add README
$ git commit -m 'Update README formatting'
$ git add lib/simplegit.rb
$ git commit -m 'Add blame'
$ git rebase --continue

Gitはスクリプト内の最後のコミット(a5f4a0d)を適用し、履歴は次のようになります。

$ git log -4 --pretty=format:"%h %s"
1c002dd Add cat-file
9b29157 Add blame
35cfb2b Update README formatting
f7f3f6d Change my name a bit

これにより、リスト内の最も新しい3つのコミットのSHA-1が変更されますので、共有リポジトリにすでにプッシュした変更済みコミットがこのリストに含まれていないことを確認してください。リストの最後のコミット(f7f3f6d)は変更されていないことに注目してください。このコミットはスクリプトに表示されていますが、「pick」とマークされ、リベースの変更が適用される前に適用されたため、Gitはそのコミットを変更しません。

コミットの削除

コミットを削除したい場合は、rebase -iスクリプトを使用して削除できます。コミットのリストで、削除したいコミットの前に「drop」という単語を置くか(またはリベーススクリプトからその行を削除するだけです)

pick 461cb2a This commit is OK
drop 5aecc10 This commit is broken

Gitがコミットオブジェクトを構築する方法のため、コミットを削除または変更すると、それに続くすべてのコミットが書き換えられます。リポジトリの履歴を遡れば遡るほど、より多くのコミットが再作成される必要があります。これは、削除したばかりのコミットに依存するコミットがシーケンスの後半に多数ある場合、多くのマージコンフリクトを引き起こす可能性があります。

このようなリベースの途中で、それが良い考えではないと判断した場合、いつでも停止できます。git rebase --abortと入力すると、リポジトリはリベースを開始する前の状態に戻されます。

リベースを完了し、それが望むものではないと判断した場合は、git reflogを使用して、ブランチの以前のバージョンを回復できます。reflogコマンドの詳細については、データリカバリを参照してください。

注記

Drew DeVault氏は、git rebaseの使い方を学ぶための演習付きの実践的なガイドを作成しました。そちらは、https://git-rebase.io/で参照できます。

最終手段: filter-branch

もし、多数のコミットをスクリプトで書き換える必要がある場合(たとえば、メールアドレスをグローバルに変更したり、すべてのコミットからファイルを削除したりする場合)に利用できる、もう一つの履歴書き換えオプションがあります。そのコマンドはfilter-branchであり、履歴の広範な範囲を書き換えることができます。したがって、プロジェクトがまだ公開されておらず、他の人が書き換えようとしているコミットに基づいて作業を進めていない限り、おそらく使用すべきではありません。しかし、これは非常に役立つ場合があります。その能力の一端を理解してもらうために、いくつかの一般的な使用例を学びます。

注意

git filter-branchには多くの落とし穴があり、もはや履歴を書き換える推奨される方法ではありません。代わりに、通常filter-branchを使うようなほとんどの用途で、より優れた仕事を果たすPythonスクリプトであるgit-filter-repoの使用を検討してください。そのドキュメントとソースコードはhttps://github.com/newren/git-filter-repoで入手できます。

すべてのコミットからファイルを削除する

これは非常に頻繁に発生します。誰かが不注意にもgit add .を使って巨大なバイナリファイルをコミットしてしまい、それをどこからでも削除したい場合。あるいは、誤ってパスワードを含むファイルをコミットしてしまい、プロジェクトをオープンソースにしたい場合。filter-branchは、履歴全体をクリーンアップするために使用したいツールです。履歴全体からpasswords.txtというファイルを削除するには、filter-branch--tree-filterオプションを使用できます。

$ git filter-branch --tree-filter 'rm -f passwords.txt' HEAD
Rewrite 6b9b3cf04e7c5686a9cb838c3f36a8cb6a0fc2bd (21/21)
Ref 'refs/heads/master' was rewritten

--tree-filterオプションは、プロジェクトの各チェックアウト後に指定されたコマンドを実行し、その結果を再度コミットします。この場合、passwords.txtというファイルは、存在するかどうかにかかわらず、すべてのスナップショットから削除されます。誤ってコミットされたエディターのバックアップファイルをすべて削除したい場合は、git filter-branch --tree-filter 'rm -f *~' HEADのようなコマンドを実行できます。

Gitがツリーとコミットを書き換え、最後にブランチポインタを移動する様子を確認できます。通常、これをテストブランチで行い、結果が本当に望むものであると判断した後にmasterブランチをハードリセットするのが良い考えです。すべてのブランチに対してfilter-branchを実行するには、コマンドに--allを渡します。

サブディレクトリを新しいルートにする

別のバージョン管理システムからインポートを行い、意味のないサブディレクトリ(trunktagsなど)がある場合を考えます。もしtrunkサブディレクトリをすべてのコミットの新しいプロジェクトルートにしたい場合、filter-branchもそれができます。

$ git filter-branch --subdirectory-filter trunk HEAD
Rewrite 856f0bf61e41a27326cdae8f09fe708d679f596f (12/12)
Ref 'refs/heads/master' was rewritten

これで、新しいプロジェクトルートは、各時点でのtrunkサブディレクトリの内容になります。Gitは、そのサブディレクトリに影響を与えなかったコミットも自動的に削除します。

メールアドレスをグローバルに変更する

もう1つのよくあるケースは、作業を開始する前にgit configを実行して名前とメールアドレスを設定するのを忘れた場合、あるいは職場のプロジェクトをオープンソース化して、すべての仕事用メールアドレスを個人のアドレスに変更したい場合です。いずれの場合も、filter-branchを使えば、複数のコミットのメールアドレスを一括で変更できます。自分のメールアドレスだけを変更するように注意する必要があるため、--commit-filterを使用します。

$ git filter-branch --commit-filter '
        if [ "$GIT_AUTHOR_EMAIL" = "schacon@localhost" ];
        then
                GIT_AUTHOR_NAME="Scott Chacon";
                GIT_AUTHOR_EMAIL="schacon@example.com";
                git commit-tree "$@";
        else
                git commit-tree "$@";
        fi' HEAD

これにより、すべてのコミットが新しいアドレスを持つように書き換えられます。コミットはその親のSHA-1値を含むため、このコマンドは、一致するメールアドレスを持つコミットだけでなく、履歴内のすべてのコミットのSHA-1を変更します。

scroll-to-top