Git
Chapters ▾ 2nd Edition

10.7 Gitの内側 - メンテナンスとデータリカバリ

メンテナンスとデータリカバリ

たまには、ちょっとしたお掃除 – リポジトリを圧縮したり、インポートしたリポジトリをクリーンアップしたり、失われた成果物をもとに戻したり – が必要になるかもしれません。 このセクションではこれらのシナリオのいくつかについて取り上げます。

メンテナンス

Gitは時々 “auto gc” と呼ばれるコマンドを自動的に実行します。 大抵の場合、このコマンドは何もしません。 ですが、緩いオブジェクト(packfileの中に入っていないオブジェクト)やpackfileがあまりに多い場合は、Gitは完全な(full-fledged)git gc コマンドを起動します。 “gc” はガベージコレクト(garbage collect)を意味します。このコマンドは幾つものことを行います。すべての緩いオブジェクトを集めてpackfileに入れ、複数のpackfileをひとつの大きなpackfileに統合し、さらにどのコミットからも到達が不可能かつ数ヶ月間更新がないオブジェクトを削除します。

次のように手動でauto gcを実行することもできます。

$ git gc --auto

繰り返しますが、これは通常は何も行いません。 約7,000個もの緩いオブジェクトがあるか、または50以上のpackfileがある場合でないと、Gitは実際にgcコマンドを開始しません。 これらのリミットはそれぞれ設定ファイルの gc.autogc.autopacklimit で変更できます。

その他に gc が行うこととしては、複数の参照を1つのファイルにパックすることが挙げられます。 リポジトリに、次のようなブランチとタグが含まれているとしましょう。

$ find .git/refs -type f
.git/refs/heads/experiment
.git/refs/heads/master
.git/refs/tags/v1.0
.git/refs/tags/v1.1

git gc を実行すると、これらのファイルは refs ディレクトリからなくなります。 効率化のため、Gitはそれらのファイルの内容を、以下のような .git/packed-refs という名前のファイルに移します。

$ cat .git/packed-refs
# pack-refs with: peeled fully-peeled
cac0cab538b970a37ea1e769cbbde608743bc96d refs/heads/experiment
ab1afef80fac8e34258ff41fc1b867c702daa24b refs/heads/master
cac0cab538b970a37ea1e769cbbde608743bc96d refs/tags/v1.0
9585191f37f7b0fb9444f35a9bf50de191beadc2 refs/tags/v1.1
^1a410efbd13591db07496601ebc7a059dd55cfe9

ただ、ここで参照を更新しても、Gitはこのファイルを編集せず、その代わりに refs/heads に新しいファイルを書き込みます。 とある参照に対する適切なSHA-1ハッシュを得るために、Gitは refs ディレクトリ内でその参照をチェックした上で、見つからなかった場合の代替として packed-refs ファイルをチェックします。 一方、 refs ディレクトリ内で参照が見つけられない場合は、それはおそらく packed-refs ファイル内にあります。

ファイルの最後の行に注意してください。 ^ という文字で始まっています。 これは、この行のすぐ上にあるタグは注釈付き版のタグであり、この行はそのタグが指しているコミットであるということを意味しています。

データリカバリ

Gitを使っていく過程のある時点で、誤ってコミットを失ってしまうことがあるかもしれません。 このようなことが起こりがちなのは、成果物が入っていたブランチをforce-deleteしたけれど、その後結局そのブランチが必要になったときか、あるいはブランチをhard-resetしたために、何か必要なものが入っているコミットがそのブランチから切り離されてしまったときです。 このようなことが起きたとして、どうやったらコミットを取り戻せるでしょうか?

以下に示す例では、testリポジトリ内のmasterブランチを古いコミットにhard-resetして、それから失ったコミットを復元します。 まず、今の時点でリポジトリがどのような状況にあるのか調べてみましょう。

$ git log --pretty=oneline
ab1afef80fac8e34258ff41fc1b867c702daa24b modified repo a bit
484a59275031909e19aadb7c92262719cfcdf19a added repo.rb
1a410efbd13591db07496601ebc7a059dd55cfe9 third commit
cac0cab538b970a37ea1e769cbbde608743bc96d second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit

ここで、master ブランチを真ん中のコミットの時点まで戻します。

$ git reset --hard 1a410efbd13591db07496601ebc7a059dd55cfe9
HEAD is now at 1a410ef third commit
$ git log --pretty=oneline
1a410efbd13591db07496601ebc7a059dd55cfe9 third commit
cac0cab538b970a37ea1e769cbbde608743bc96d second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit

これで、一番上にあった2つのコミットは、事実上失われたことになります。これらのコミットに辿り着けるブランチがないためです。 そのため、最後のコミットのSHA-1ハッシュを調べた上で、そこを指すブランチを追加する必要があります。 ここでポイントとなるのは、最後のコミットのSHA-1ハッシュを見つける方法です。ハッシュ値を記憶してます、なんてことはないですよね?

大抵の場合、最も手っ取り早いのは、git reflog というツールを使う方法です。 あなたが作業をしている間、HEADを変更する度に、HEADがどこを指しているかをGitは裏で記録しています。 コミットをしたり、ブランチを変更したりする度に、reflogは更新されます。 また、reflogは git update-ref コマンドによっても更新されます。refファイルに書かれたSHA-1ハッシュ値を直に編集せずに、このコマンドを使って編集すべき理由の1つがこれです(詳しくは Gitの参照 で取り上げました)。 git reflog を実行することで、ある時点で自分がどこにいたのかを知ることができます。

$ git reflog
1a410ef HEAD@{0}: reset: moving to 1a410ef
ab1afef HEAD@{1}: commit: modified repo.rb a bit
484a592 HEAD@{2}: commit: added repo.rb

このとおり、チェックアウトした2つのコミットが見つかりました。ですが、それ以上の情報は表示されていません。 同じ情報をもっと有用な形式で表示するには git log -g を実行します。これはreflogを通常のログ出力と同じ形式で出力してくれます。

$ git log -g
commit 1a410efbd13591db07496601ebc7a059dd55cfe9
Reflog: HEAD@{0} (Scott Chacon <schacon@gmail.com>)
Reflog message: updating HEAD
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri May 22 18:22:37 2009 -0700

		third commit

commit ab1afef80fac8e34258ff41fc1b867c702daa24b
Reflog: HEAD@{1} (Scott Chacon <schacon@gmail.com>)
Reflog message: updating HEAD
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri May 22 18:15:24 2009 -0700

       modified repo.rb a bit

一番下にあるコミットが、失われたコミットのようです。そこから新しいブランチを作成すれば、失ったコミットを取り戻せます。 例えば、そのコミット(ab1afef)を起点に recover-branch という名前のブランチを作成できます。

$ git branch recover-branch ab1afef
$ git log --pretty=oneline recover-branch
ab1afef80fac8e34258ff41fc1b867c702daa24b modified repo a bit
484a59275031909e19aadb7c92262719cfcdf19a added repo.rb
1a410efbd13591db07496601ebc7a059dd55cfe9 third commit
cac0cab538b970a37ea1e769cbbde608743bc96d second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit

やった! – master ブランチがかつて存在した場所に、 recover-branch という名前のブランチが作られて、最初の2つのコミットは再び到達可能になりました。 さて次は、失われたコミットが何らかの理由でreflogの中にもなかった場合を考えましょう – recover-branch を取り除き、reflogを削除することによって、擬似的にその状況を作り出すことができます。 これで、最初の2つのコミットは、今どこからも到達不能になりました。

$ git branch -D recover-branch
$ rm -Rf .git/logs/

reflogのデータは .git/logs/ ディレクトリに保存されるため、これでreflogは事実上なくなりました。 この時点で、どうしたら失われたコミットを復元できるでしょうか? ひとつの方法として、 git fsck ユーティリティーを使用してデータベースの完全性をチェックする方法があります。 --full オプションを付けて実行すると、他のどのオブジェクトからも指されていないオブジェクトをすべて表示します。

$ git fsck --full
Checking object directories: 100% (256/256), done.
Checking objects: 100% (18/18), done.
dangling blob d670460b4b4aece5915caf5c68d12f560a9fe3e4
dangling commit ab1afef80fac8e34258ff41fc1b867c702daa24b
dangling tree aea790b9a58f6cf6f2804eeac9f0abbe9631e4c9
dangling blob 7108f7ecb345ee9d0084193f147cdad4d2998293

このケースでは、 “dangling commit” という文字列の後に失われたコミットが表示されています。 前と同様にこのSHA-1ハッシュを指すブランチを作成すれば、失われたコミットを取り戻せます。

オブジェクトの削除

Gitには素晴らしい点がたくさんありますが、問題となり得る特徴がひとつあります。それは、 git clone がすべてのファイルのすべてのバージョンを含んだプロジェクトの歴史全体をダウンロードしてしまうということです。 保存されているのがソースコードだけなら、特に問題はありません。なぜなら、Gitはそのようなデータを効率良く圧縮することに高度に最適化されているからです。 しかし、もし誰かがある時点でプロジェクトの歴史に非常に大きなファイルを1つ加えると、以降のクローンではすべて、その大きなファイルのダウンロードを強いられることになります。これは、直後のコミットでそのファイルをプロジェクトから削除したとしても変わりません。 なぜなら、そのファイルは履歴から到達可能であり、常にそこに存在し続けるためです。

SubversionやPerforceのリポジトリをGitに変換するときに、これは大きな問題になり得ます。 なぜなら、それらのシステムではすべての履歴をダウンロードする必要がないため、非常に大きなファイルを追加してもほとんど悪影響がないからです。 別のシステムからリポジトリをインポートした場合や、リポジトリがあるべき状態よりもずっと大きくなっている場合に、大きなオブジェクトを見つけて取り除く方法を以下に示します。

注意: この操作はコミット履歴を破壊的に変更します。 この操作では、大きなファイルへの参照を取り除くため、修正が必要な一番古いツリーから、以降すべてのコミットオブジェクトを再書き込みします。 インポートの直後、そのコミットをベースとして誰かが作業を始める前にこの操作を行った場合は問題ありません。そうでない場合は、作業中の内容を新しいコミットにリベースしなければならないことを、すべての関係者に知らせる必要があります。

実演のため、testリポジトリに大きなファイルを追加して、次のコミットでそれを取り除いた上で、リポジトリからそのファイルを探し出し、そしてリポジトリからそれを完全に削除します。 まず、あなたの歴史に大きなオブジェクトを追加します。

$ curl https://www.kernel.org/pub/software/scm/git/git-2.1.0.tar.gz > git.tgz
$ git add git.tgz
$ git commit -m 'add git tarball'
[master 7b30847] add git tarball
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 git.tgz

おっと、誤ってプロジェクトに非常に大きなtarボールを追加してしまいました。取り除いたほうがいいでしょう。

$ git rm git.tgz
rm 'git.tgz'
$ git commit -m 'oops - removed large tarball'
[master dadf725] oops - removed large tarball
 1 file changed, 0 insertions(+), 0 deletions(-)
 delete mode 100644 git.tgz

次に、データベースに対して gc を実行します。その後、どれくらいのスペースを使用しているのかを見てみましょう。

$ git gc
Counting objects: 17, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (13/13), done.
Writing objects: 100% (17/17), done.
Total 17 (delta 1), reused 10 (delta 0)

count-objects コマンドを実行すると、どれくらいのスペースを使用しているのかをすぐに見ることができます。

$ git count-objects -v
count: 7
size: 32
in-pack: 17
packs: 1
size-pack: 4868
prune-packable: 0
garbage: 0
size-garbage: 0

size-pack エントリにはpackfileのサイズがキロバイト単位で表示されていて、約5MB使用していることがわかります。 大きなファイルを追加するコミットの前に使用していたのは、2KB程度でした – 明らかに、直近のコミットで行ったファイルの削除では、歴史からファイルが削除されていません。 誤って大きなファイルを追加してしまったがために、誰かがこのリポジトリをクローンするたび、この小さなプロジェクトを取得するだけのために5MBすべてをクローンしなければならなくなってしまいました。 この大きなファイルを削除しましょう。

最初に、その大きなファイルを見つけなければなりません。 この例では、どのファイルがそれかは既に分かっています。 しかし、それが分からない場合、どうやって多くのスペースを占めているファイルを特定するのでしょうか? git gc を実行すると、すべてのオブジェクトがpackfileに格納されます。 そのため、別の配管コマンド git verify-pack を実行し、その出力を3つ目のフィールド(ファイルサイズ)でソートすれば、大きなオブジェクトを特定できます。 関心の対象になるのは最も大きなファイル数個だけなので、その出力をパイプで tail コマンドに通してもよいでしょう。

$ git verify-pack -v .git/objects/pack/pack-29…69.idx \
  | sort -k 3 -n \
  | tail -3
dadf7258d699da2c8d89b09ef6670edb7d5f91b4 commit 229 159 12
033b4468fa6b2a9547a70d88d1bbe8bf3f9ed0d5 blob   22044 5792 4977696
82c99a3e86bb1267b236a4b6eff7868d97489af1 blob   4975916 4976258 1438

探していた大きなオブジェクトは、一番下の5MBのものです。 そのオブジェクトが何のファイルなのかを知るには 特定のコミットメッセージ書式の強制 で少し使用した rev-list コマンドを使用します。 --objectsrev-list に渡すと、すべてのコミットのSHA-1ハッシュに加えて、すべてのブロブのSHA-1ハッシュと、そのブロブに関連付けられたファイルのパスを一覧表示します。 これは、ブロブの名前を特定するのに使えます。

$ git rev-list --objects --all | grep 82c99a3
82c99a3e86bb1267b236a4b6eff7868d97489af1 git.tgz

次に、過去のすべてのツリーからこのファイルを削除する必要があります。 このファイルを変更したのがどのコミットかは簡単に分かります。

$ git log --oneline --branches -- git.tgz
dadf725 oops - removed large tarball
7b30847 add git tarball

Gitリポジトリからこのファイルを完全に削除するには、 7b30847 の下流にあるすべてのコミットを修正しなければなりません。 そのためには、 歴史の書き換え で使用した filter-branch を使用します。

$ git filter-branch --index-filter \
  'git rm --ignore-unmatch --cached git.tgz' -- 7b30847^..
Rewrite 7b30847d080183a1ab7d18fb202473b3096e9f34 (1/2)rm 'git.tgz'
Rewrite dadf7258d699da2c8d89b09ef6670edb7d5f91b4 (2/2)
Ref 'refs/heads/master' was rewritten

--index-filter オプションは 歴史の書き換え で使用した --tree-filter オプションに似ていますが、ディスク上のチェックアウトされたファイルを変更するコマンドを渡すのではなく、コミット毎にステージングエリアまたはインデックスを変更する点が異なります。

ここでは、あるファイルを rm file で削除するのではなく、 git rm --cached で削除する必要があります。つまり、ディスクではなくインデックスからファイルを削除しなければなりません。 このようにする理由はスピードです。この場合、Gitがフィルタを実行する前に各リビジョンをディスク上へチェックアウトする必要がないので、プロセスをもっともっと速くすることができます。 お望みなら、同様のタスクは --tree-filter でも行えます。 git rm に渡している --ignore-unmatch オプションは、削除しようとするパターンに合うファイルがない場合に、エラーを出力しないようにします。 最後に、filter-branch に、コミット 7b30847 以降の履歴のみを修正するように伝えています。なぜなら、問題が発生した場所がここだと分かっているからです。 そうでない場合は、歴史の先頭から処理を開始することになり、不必要に長い時間がかかるでしょう。

これで、歴史から大きなファイルへの参照がなくなりました。 しかし、 .git/refs/original の下で filter-branch を行ったときにGitが新しく追加したrefsには、まだ参照が含まれています。reflogについても同様です。それらを削除した上で、データベースを再パックしなければなりません。 再パックの前に、それら古いコミットへのポインタを持つものをすべて削除する必要があります。

$ rm -Rf .git/refs/original
$ rm -Rf .git/logs/
$ git gc
Counting objects: 15, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (11/11), done.
Writing objects: 100% (15/15), done.
Total 15 (delta 1), reused 12 (delta 0)

どれくらいのスペースが節約されたかを見てみましょう。

$ git count-objects -v
count: 11
size: 4904
in-pack: 15
packs: 1
size-pack: 8
prune-packable: 0
garbage: 0
size-garbage: 0

パックされたリポジトリのサイズは8KBに下がり、当初の5MBよりもずっとよくなりました。 サイズの値を見ると、緩いオブジェクトの中には大きなオブジェクトが残っており、無くなったわけではないことが分かります。ですが、プッシュや以降のクローンで転送されることはもうありません。ここが重要な点です。 お望みなら、 git prune--expire オプションを指定すれば、オブジェクトを完全に削除することもできます。

$ git prune --expire now
$ git count-objects -v
count: 0
size: 0
in-pack: 15
packs: 1
size-pack: 8
prune-packable: 0
garbage: 0
size-garbage: 0