Git
Chapters ▾ 2nd Edition

7.13 Git のさまざまなツール - Git オブジェクトの置き換え

Git オブジェクトの置き換え

Git オブジェクトは変更できません。その代わりに用意されているのが、Git データベース上のオブジェクトを他のオブジェクトと置き換えたかのように見せる方法です。

replace コマンドを使うと、「このオブジェクトを参照するときは、あたかもあちらを参照してるかのように振る舞え」と Git に指示できます。プロジェクトの歴史のなかで、コミットを別のコミットで置き換えたいときに便利です。

具体的な例として、長い歴史を経たコードベースがあって、それを2つに分割するケースを考えてみましょう。1つは短い歴史で新入りの開発者向け、もう1つは長い歴史でデータマイニングを行いたい人向けです。とある歴史を別の歴史と結びつけるには、新しいほうの歴史の最古のコミットを、古いほうの歴史の最新のコミットと置き換えてやればいいのです。これの利点は、そうしておけば新しいほうの歴史のコミットをすべて書き換える必要がなくなることです。通常であれば、歴史をつなぐにはそうせざるを得ません(コミットの親子関係が算出される SHA-1 に影響するため)。

では、既存のリポジトリを使って実際に試してみましょう。まずは、そのリポジトリを最近のものと過去の経緯を把握するためのものの2つに分割してみます。そのうえで、その2つを結合しつつ前者のリポジトリの SHA-1 を変更せずに済ますために replace を使ってみます。

ここでは、コミットが5つだけある以下のようなリポジトリを使って説明します。

$ git log --oneline
ef989d8 fifth commit
c6e1e95 fourth commit
9c68fdc third commit
945704c second commit
c1822cf first commit

このリポジトリを2つの歴史に分割してみましょう。1つめの歴史はコミット1からコミット4までで、過去の経緯を把握するためのリポジトリです。2つめの歴史はコミット4とコミット5だけで、これは最近の歴史だけのリポジトリになります。

replace1

過去の経緯を把握するための歴史は簡単に取り出せます。過去のコミットを指定してブランチを切り、新たに作成しておいたリモートリポジトリの master としてそのブランチをプッシュすればよいのです。

$ git branch history c6e1e95
$ git log --oneline --decorate
ef989d8 (HEAD, master) fifth commit
c6e1e95 (history) fourth commit
9c68fdc third commit
945704c second commit
c1822cf first commit
replace2

作成した history ブランチを、新規リポジトリの master ブランチにプッシュします。

$ git remote add project-history https://github.com/schacon/project-history
$ git push project-history history:master
Counting objects: 12, done.
Delta compression using up to 2 threads.
Compressing objects: 100% (4/4), done.
Writing objects: 100% (12/12), 907 bytes, done.
Total 12 (delta 0), reused 0 (delta 0)
Unpacking objects: 100% (12/12), done.
To git@github.com:schacon/project-history.git
 * [new branch]      history -> master

これで新たに作った歴史が公開されました。続いて難しいほう、最近の歴史を小さくするための絞り込みです。双方の歴史に重なる部分がないとコミットの置き換え(一方の歴史のコミットをもう一方の歴史の同等のコミットで置き換え)が出来なくなるので、ここでは最近の歴史をコミット4と5だけに絞り込みます(そうすればコミット4が重なることになります)。

$ git log --oneline --decorate
ef989d8 (HEAD, master) fifth commit
c6e1e95 (history) fourth commit
9c68fdc third commit
945704c second commit
c1822cf first commit

こういったケースでは、ベースとなるコミットを作って、歴史を展開するための手順を説明しておくとよいでしょう。絞りこまれた歴史のベースコミットに行き当たって「この先が知りたいのに」となった開発者達が、次に取るべき手順を把握できるからです。実際にどうするかというと、まずは上述した手順を含めたコミットオブジェクト(これが最近の歴史の方の基点となります)を作り、残りのコミット(コミット4と5)をそれにリベースします。

そのためには、どこで分割するかを決める必要があります。この例ではコミット3、SHA でいうと 9c68fdc です。そのコミットの後ろに、ベースとなるコミットを作成します。このベースコミットは commit-tree コマンドで作成できます。ツリーを指定して実行すると、親子関係のない新規のコミットオブジェクト SHA-1 が生成されます。

$ echo 'get history from blah blah blah' | git commit-tree 9c68fdc^{tree}
622e88e9cbfbacfb75b5279245b9fb38dfea10cf
Note

commit-tree コマンドは、「配管」コマンドと呼ばれているコマンド群のうちの1つです。元々は直接呼び出すために作られたコマンドではなく、他の Git コマンドから呼び出して細かい処理をするためのものです。とはいえ、ここで説明しているような一風変わった作業をする際に使うと、低レベルの処理が出来るようになります。ただし、普段使うためのものではありません。配管コマンドの詳細は、配管(Plumbing)と磁器(Porcelain) に目を通してみてください。

replace3

これでベースとなるコミットができたので、git rebase --onto を使って残りの歴史をリベースしましょう。--onto オプションの引数は先ほど実行した commit-tree コマンドの返り値、リベースの始点はコミット3(保持しておきたい1つめのコミットの親にあたるコミット。9c68fdc)です。。

$ git rebase --onto 622e88 9c68fdc
First, rewinding head to replay your work on top of it...
Applying: fourth commit
Applying: fifth commit
replace4

以上で、仮で作ったベースコミットのうえに最近の歴史をリベースできました。ベースコミットには、必要であれば全歴史を組み直すための手順が含まれた状態です。この歴史を新しいプロジェクトとしてプッシュしておきましょう。もしそのリポジトリがクローンされると、直近のコミット2つとベースコミット(手順含む)だけが取得されます。

では次に、プロジェクトをクローンする側の動きを見ていきましょう。初回のクローンで、全歴史を必要としているとします。 絞りこまれたリポジトリをクローンした状態で全歴史を取得するには、過去の経緯を把握するためのリポジトリをリモートとして追加してフェッチします。

$ git clone https://github.com/schacon/project
$ cd project

$ git log --oneline master
e146b5f fifth commit
81a708d fourth commit
622e88e get history from blah blah blah

$ git remote add project-history https://github.com/schacon/project-history
$ git fetch project-history
From https://github.com/schacon/project-history
 * [new branch]      master     -> project-history/master

こうすると、master ブランチを見れば最近のコミットがわかり、project-history/master ブランチを見れば過去のコミットがわかるようになります。

$ git log --oneline master
e146b5f fifth commit
81a708d fourth commit
622e88e get history from blah blah blah

$ git log --oneline project-history/master
c6e1e95 fourth commit
9c68fdc third commit
945704c second commit
c1822cf first commit

ここで git replace を実行すると、これら2つをつなぐことができます。置き換えられるコミット、置き換えるコミットの順に指定して実行しましょう。この例では、master ブランチのコミット4を、project-history/master ブランチのコミット4で置き換えることになります。

$ git replace 81a708d c6e1e95

では、 master ブランチの歴史を確認してみましょう。以下のようになっているはずです。

$ git log --oneline master
e146b5f fifth commit
81a708d fourth commit
9c68fdc third commit
945704c second commit
c1822cf first commit

ね、これいいでしょ?上流の SHA-1 をすべて書き換えることなく、歴史上のコミット1つをまったく別のコミットと置き換えることができました。他の Git ツール(bisectblame など)も、期待通りに動作してくれます。

replace5

1つ気になるのが、表示されている SHA-1 が 81a708d のまま、という点です。実際に使われているデータは、置き換えるのに使ったコミット c6e1e95 のものなのですが……仮に cat-file のようなコマンドを実行しても、置き換え後のデータが返ってきます。

$ git cat-file -p 81a708d
tree 7bc544cf438903b65ca9104a1e30345eee6c083d
parent 9c68fdceee073230f19ebb8b5e7fc71b479c0252
author Scott Chacon <schacon@gmail.com> 1268712581 -0700
committer Scott Chacon <schacon@gmail.com> 1268712581 -0700

fourth commit

振り返ってみればわかるように、81a708d の本当の親は仮のコミット(622e88e)であって、このコマンド出力にある 9c68fdce ではありません。

もう1つ注目したいのが、参照のなかに保持されているデータです。

$ git for-each-ref
e146b5f14e79d4935160c0e83fb9ebe526b8da0d commit	refs/heads/master
c6e1e95051d41771a649f3145423f8809d1a74d4 commit	refs/remotes/history/master
e146b5f14e79d4935160c0e83fb9ebe526b8da0d commit	refs/remotes/origin/HEAD
e146b5f14e79d4935160c0e83fb9ebe526b8da0d commit	refs/remotes/origin/master
c6e1e95051d41771a649f3145423f8809d1a74d4 commit	refs/replace/81a708dd0e167a3f691541c7a6463343bc457040

これはつまり、置き換えの内容を簡単に共有できるということです。サーバーにプッシュできるデータですし、ダウンロードするのも簡単です。この節で説明したように歴史を結びつける場合には、この方法は役に立ちません(というのも、全員が両方の歴史をダウンロードしてしまうからです。そうであれば、わざわざ分割する必要はないですよね)。とはいえ、これが役に立つケースもあるでしょう。