章 ▾ 第2版

10.7 Gitの内側 - メンテナンスとデータ復旧

メンテナンスとデータ復旧

時には、クリーンアップを行う必要があるかもしれません。リポジトリをよりコンパクトにしたり、インポートしたリポジトリを整理したり、失われた作業を復旧したりする作業です。このセクションでは、これらのシナリオのいくつかについて説明します。

メンテナンス

Gitは時々「auto gc」というコマンドを自動的に実行します。ほとんどの場合、このコマンドは何もしません。しかし、loose object(packfileにないオブジェクト)が多すぎる場合や、packfileが多すぎる場合、Gitは本格的なgit gcコマンドを起動します。「gc」はガベージコレクションの略で、このコマンドはいくつかのことを行います。すべてのloose objectを収集してpackfileに入れ、複数のpackfileを1つの大きなpackfileに統合し、どのコミットからも到達できず、数ヶ月経過したオブジェクトを削除します。

auto gcを手動で実行することもできます。

$ git gc --auto

繰り返しになりますが、これは通常何も行いません。Gitが実際にgcコマンドを起動するには、約7,000個以上のloose object、または50個以上のpackfileがある必要があります。これらの制限は、それぞれgc.autogc.autopacklimitのconfig設定で変更できます。

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ファイルにある可能性が高いです。

ファイルの最後の行が^で始まることに注目してください。これは、直前のタグがannotated tagであり、その行がannotated tagが指すコミットであることを意味します。

データ復旧

Gitの利用中に、誤ってコミットを失うことがあるかもしれません。これは通常、作業中のブランチを強制的に削除してしまい、後でそのブランチが必要だったと判明した場合や、ブランチをハードリセットして、そこから何かが必要だったコミットを放棄してしまった場合に起こります。このような事態が発生した場合、どのようにしてコミットを取り戻せるのでしょうか?

ここでは、テストリポジトリのmasterブランチを古いコミットにハードリセットし、その後失われたコミットを復旧する例を示します。まず、この時点でのリポジトリの状態を確認しましょう。

$ git log --pretty=oneline
ab1afef80fac8e34258ff41fc1b867c702daa24b Modify repo.rb a bit
484a59275031909e19aadb7c92262719cfcdf19a Create 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というツールを使うことです。作業中、GitはHEADが変更されるたびに、その状態を密かに記録しています。コミットしたりブランチを変更したりするたびに、reflogが更新されます。reflogはgit update-refコマンドによっても更新されます。これは、Git参照で説明したように、単にSHA-1値を参照ファイルに書き込むのではなく、このコマンドを使用するもう1つの理由です。git reflogを実行することで、いつでもどこにいたかを確認できます。

$ git reflog
1a410ef HEAD@{0}: reset: moving to 1a410ef
ab1afef HEAD@{1}: commit: Modify repo.rb a bit
484a592 HEAD@{2}: commit: Create 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

       Modify repo.rb a bit

どうやら一番下のコミットが失われたもののようですので、そのコミットに新しいブランチを作成して復旧できます。たとえば、そのコミット(ab1afef)でrecover-branchという名前のブランチを開始できます。

$ git branch recover-branch ab1afef
$ git log --pretty=oneline recover-branch
ab1afef80fac8e34258ff41fc1b867c702daa24b Modify repo.rb a bit
484a59275031909e19aadb7c92262719cfcdf19a Create 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には素晴らしい点がたくさんありますが、問題を引き起こす可能性のある機能の1つは、git cloneがプロジェクトの履歴全体(すべてのファイルのすべてのバージョンを含む)をダウンロードするという事実です。全体がソースコードであれば問題ありません。なぜなら、Gitはそのデータを効率的に圧縮するために高度に最適化されているからです。しかし、プロジェクトの履歴のどこかの時点で誰かが単一の巨大なファイルを追加した場合、たとえそのファイルが直後のコミットでプロジェクトから削除されたとしても、それ以降のすべてのクローンはその大きなファイルをダウンロードすることを強制されます。履歴から到達可能であるため、それは常にそこに存在し続けます。

これは、SubversionやPerforceのリポジトリをGitに変換する際に大きな問題となることがあります。これらのシステムでは履歴全体をダウンロードしないため、この種の追加による影響は少ないです。もし別のシステムからインポートした場合や、リポジトリが本来あるべきよりもはるかに大きいことに気づいた場合、大きなオブジェクトを見つけて削除する方法を以下に示します。

警告:この手法はコミット履歴を破壊します。 これは、大きなファイル参照を削除するために変更する必要がある最も古いツリー以降のすべてのコミットオブジェクトを書き換えます。インポート直後で、まだ誰もそのコミットに基づいて作業を開始していないのであれば問題ありません。そうでない場合は、すべての貢献者に、新しいコミットに作業をリベースする必要があることを通知しなければなりません。

実演するために、テストリポジトリに大きなファイルを追加し、次のコミットで削除し、見つけて、リポジトリから完全に削除します。まず、履歴に大きなオブジェクトを追加します。

$ curl -L 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

しまった — プロジェクトに巨大なtarballを追加したくなかった。削除した方がいいだろう。

$ git rm git.tgz
rm 'git.tgz'
$ git commit -m 'Oops - remove large tarball'
[master dadf725] Oops - remove 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エントリはパックファイルのサイズをキロバイト単位で示しており、約5MBを使用しています。最後のコミットの前は2KBに近かったので、前のコミットからファイルを削除しても履歴からは削除されていないことが明らかです。誰かがこのリポジトリをクローンするたびに、誤って大きなファイルを追加したせいで、この小さなプロジェクトを得るためだけに5MBすべてをクローンしなければなりません。これを削除しましょう。

まずそれを見つけなければなりません。このケースでは、どのファイルであるかはすでにわかっています。しかし、わからなかったとしたらどうでしょう?どのファイルがそんなに多くのスペースを占めているかをどうやって特定しますか?git gcを実行すると、すべてのオブジェクトはパックファイルに入ります。大きなオブジェクトを特定するには、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コマンドを使います。rev-list--objectsを渡すと、すべてのコミットSHA-1と、それに関連付けられたファイルパスを持つblob SHA-1がリストされます。これを使ってblobの名前を見つけることができます。

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

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

$ git log --oneline --branches -- git.tgz
dadf725 Oops - remove 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-branch7b30847コミット以降の履歴のみを書き換えるように依頼します。なぜなら、問題がそこで始まったことを知っているからです。そうしないと、最初から始まり、不必要に時間がかかります。

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

$ 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よりもはるかに良くなりました。サイズ値から、大きなオブジェクトがまだloose objectに残っていることがわかります。つまり、完全には削除されていません。しかし、プッシュやその後のクローンでは転送されません。これが重要な点です。もし本当に完全に削除したいのであれば、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
scroll-to-top