この記事の想定読者
git で add して commit するくらいはできるし、コミットがツリー状に連なっているという概念も理解しているつもりなのに、なにかいまいち腹オチしていない git 初心者。
(例えば、すっかり今日理解したことを忘れてしまった数ヶ月後の自分)
腑に落ちない2つの理由
- 各gitコマンドを実行した結果、中で実際に何が起こっているのか分からない。
- ので、どんな副作用が起こっているのかよく分からない。
- 一部のコマンドが多機能すぎて、何が起こるのかよく分からない。
- pull が良くやり玉に挙がりますが、checkout や reset もなかなかのもの。
解決策
Git の内部仕様のドキュメントを読もう!
Pro Git 日本語版 10章:Gitの内側 が非常に素晴らしいドキュメントです。
.git/index に関しては Gitユーザマニュアル Chapter7. Gitのコンセプト が参考になります。
読んだ
理解したつもりのことを以下、メモします。間違いがあったらご指摘くださいー。
.git/ 以下には大きく4種類のファイル群がある
オブジェクトデータベース
.git/objects 以下には、Key-Value ストアが広がっております。git では、ファイルの中身から、ファイルリスト、コミットログまで、データの実体は全てここに保存されます。
1つのコミットを表すオブジェクトには、直前のコミットのKeyが含まれています。このKeyを元にオブジェクトデータベースから直前のコミットのオブジェクトを取り出せ・・・としていくことで、コミットの履歴を表すグラフが構成されます。これが git が格納しているコミット履歴の正体です。
git にコミットしたファイルはそれぞれ独立したValueとして保存されており、各コミットではそのKeyの一覧を保持しているだけですので、ファイル内容に変更さえなければ、複数のコミットで同じファイルの実体を共有しています。
オブジェクトDBを生で触ってみる
オブジェクトDBは、データを突っ込むとヘッダを付けたあとの SHA-1 ハッシュを Key として返します。hash-object コマンドで実際に好きなデータを格納してみることが可能です。
$ echo 'hogefuga' | git hash-object -w --stdin
429d80829364acee954c1e28df0bf10384519bee
hogefuga というデータ列が 429d80〜 という Key で格納されました。
.git/objects 以下を見てみると、ハッシュ値のファイル名が出来ています。
$ ls .git/objects/42
9d80829364acee954c1e28df0bf10384519bee
ハッシュの頭2桁でディレクトリが分割がされているのは、おそらく古いファイルシステムへの配慮でしょう。
ファイルの中身は短いヘッダを付けた上でzlibで圧縮されたもの。ちなみに、Pro Git には Ruby で生成するコードまで 載っています 。
cat-file コマンドを使うことで、データタイプの確認と、中身の取り出しが行えます。
$ git cat-file -t 429d80829364acee954c1e28df0bf10384519bee
blob
$ git cat-file -p 429d80829364acee954c1e28df0bf10384519bee
hogefuga
なお、最初はこうした1エントリ1ファイルという素朴なフォーマットで格納されますが、ファイル数が増えたり、git gc を呼んだ場合などには Packfile という、より効率の良い形式に変換されます。
オブジェクトDBへの参照
ブランチやタグなどの実体は、オブジェクトDBに格納されたコミットオブジェクトへの参照でしかありません。
.git/refs/heads/* には各ブランチの最新のコミットオブジェクトへの参照、つまり、オブジェクトDBのKeyとなる16進数40文字のテキストが格納されています。
.git/HEAD には現在チェックアウトしている対象が保存されています。HEAD は通常、refs/heads/master など、他の参照をさらに参照することで、そのブランチを追いかけて行くように設定されています。HEAD が直接コミットオブジェクトを指していると、いわゆる 'detached HEAD' 状態となります。
インデックス
.git/index には、現在 staging されているファイルのリストが保存されています。昔は cache と呼ばれていたそうで、今も幾つかのコマンドで --cached というオプションがあるのはその名残ですね。
注意すべき点は、index に格納されているのは、ファイル名とハッシュ値+αだけということ。ファイルの実体は add したタイミングでオブジェクトDBに追加されています。
ちなみに、+αの情報としては、高速に作業ツリーの更新を判定するためのタイムスタンプ情報などがあるようです。index だけ特殊なデータ構造なのも致し方なし。
インデックスは完全なファイルリストを保持している
私がもんやりしていたポイントとして、stage では git add したファイルだけ管理しているのかな?というものがあったのですが、それは違います。.git/index は、checkout された段階で、HEAD が持っているファイルリストがそのままコピーされてきます。
git status で表示されるのは、HEAD が示すコミットのファイルリストと、.git/index の保持しているファイルリストと、作業ディレクトリの、3者の比較結果だと理解するとよさそうです。
普段は意識することはあまりないですが、git reset --soft <commit> で staging 状態はそのままで他のコミットに HEAD を切り替えた場合などで、index がファイルリストを全て保持していることが分かるかと思います。
ちなみに、rm .git/index と試しにしてみると、staging 状態が全てのファイルが削除された状態となります。git reset で index が HEAD のファイルリストで再生成されます。
各種設定ファイル
.git/config には、リポジトリの各種設定の他、リモートリポジトリの設定なども書き込まれます。
フックスクリプトを置くための .git/hooks は少し特殊ですね。
各gitコマンドが行うこと
基本的には、以下を組み合わせて機能を実現しています。
- オブジェクトDBへのファイルの出し入れ。
- オブジェクトDBへ index から生成したファイルリストの格納。
- オブジェクトDBへのコミットオブジェクトの格納。
- index へのファイル情報の追加・編集。
- refs 以下の参照情報の書換
- HEAD ファイルの書換
git add <file>
- オブジェクトDB ← <file> を追加
- index ← <file> の名前とオブジェクトDB上のKeyのペアを追加/更新
git add したときに、本当にその時のバージョンのファイルがオブジェクトDBに保管されている、というのが理解できて、いろいろと腑に落ちました。
ちなみに、間違って add したファイルも、基本的にはオブジェクトDBには入りっぱなしになっています。ゴミが溜まりすぎると autogc が走ると思いますが、明示的に git prune コマンドを実行することで、どこからも参照のないオブジェクトを掃除することもできます。
git commit
- オブジェクトDB ← index を新しいコミットのファイルリストとして格納
- オブジェクトDB ← 前述のファイルリストに関する commit オブジェクトを作成し、格納
- refs/heads/<HEADが指しているbranch> ← 前述の commit オブジェクトを参照するように更新
HEAD は通常 refs/heads/master などを指していますので、コミットがあると refs/heads/master が書き換えられ、.git/HEAD ファイル自体には変更はありません。
$ cat .git/HEAD
ref: refs/heads/master
$ cat .git/refs/heads/master
d3e17c40e5c6c068c09b19ad845381fb775666c6
$ echo 'modified' >! a.txt && git commit -a -m 'modified a' > /dev/null
$ cat .git/refs/heads/master
5264325f0dbd0c3c89b4456a68db73a1b6f33832
checkout で、 branch ではなく特定の commit を指定すると、HEAD に直接 commit オブジェクトへの Key となる16進数40文字が書き込まれますが、その場合は、次の commit 時には HEAD が直接新しい commit の Key で更新されます。
$ git checkout d3e17c40e5c6c068c09b19ad845381fb775666c6
(detached HEAD の警告メッセージ)
$ cat .git/HEAD
d3e17c40e5c6c068c09b19ad845381fb775666c6
$ echo 'modified' >! a.txt && git commit -a -m 'modified a' > /dev/null
$ cat .git/HEAD
81967569ff8c9f1963e2a6aa43c9569ac62c6d14
しかし、HEAD は checkout が呼ばれるたびに上書きされてしまう儚い存在ですので、HEAD にしか Key 情報が存在していないこの状態は、とても危険。refs/heads/* に早く HEAD をコピーしておかないと! という操作が、後述する checkout -b ですね。
git checkout <branch>
- 作業ツリー ← <branch>が参照しているcommitのファイルリストの通りにオブジェクトDBからファイルを取り出す
- index ← <branch>が参照しているcommitのtreeを取り込み
- HEAD ← refs/heads/<branch> に更新
git checkout <branch> <file>
何故同じ checkout というコマンドに詰め込んだ……。
- 作業ツリー ← <branch>が参照しているcommit内の<file>を取り出します
- index, HEAD ← 変更しません
git checkout -b <branch>
HEAD の省略形とはいえ、前述のものと機能がまた異なります。
- 作業ツリー, index ← 変更しません
- refs/heads/<branch> ← HEADの参照先 のコミットハッシュをコピー
- HEAD ← refs/heads/<branch> に更新
git reset (--soft/--hard) <commit>
git reset も多機能なコマンドです。<commit> を指定する reset は強い効果をもつ操作を行います。
- HEAD の参照先 ← <commit> に更新(ここが強い効果)
- HEAD ← 変更しない(HEAD に参照先が存在しない detached HEAD 状態の時を除く)
- index ← <commit> のファイルツリーで上書き(--softが付いていると上書きしない)
- 作業ツリー ← 変更しない(--hardが付いていると作業ツリーも上書き)
checkout と reset の大きな違いは、.git/HEAD の中身を書き換える checkout と、.git/HEAD の参照先の .git/refs/heads/master などの方を書き換える reset、という点です。
ブランチの参照コミットを書き換えるというのは、普段は commit コマンドで行う操作ですので、<commit> を指定した reset は、commit コマンドと同じくらいの重さのある操作とイメージすべきです。
git reset <file>
移動先の <commit> を指定しない reset は、index を HEAD の状況に戻すかどうかというだけの仕事をしますので、かなりライトです。この使用方法は help では add コマンドの反対、として説明されています。
- index ← HEAD が指すコミットのファイルツリーの内容で上書き
- 作業ツリー, HEAD の参照先, HEAD ← 変更しない
やっぱり、同じコマンド名に押し込める機能ではない気がしますね……。
あと、余りにも意味不明なので書かなかったのですが、git checkout <branch> <file> と同じフォーマットで特定のコミットを指定できたりします。しかし、作業ツリーも変更するならともかく、作業ツリーはそのまま、index だけ他の commit の内容に変更する、という操作が意味不明すぎて、何に使うのか分かりません。。。
git push / fetch
オブジェクトDBは、ローカルとリモートでそのままコピーされます。
(正確には、興味あるブランチに必要なオブジェクトが芋づる式に転送される、はず)
一方で、.git/refs 以下のブランチやタグといったコミットへの参照の情報は、リポジトリ毎に独立して管理されているものであり、手動で名前を指定してコピーする必要があります。
一般的には、リモートとローカルで同じブランチ名にすることが多いため、分かりにくいですが、たまたま同名にしている、くらいの気持ち。
オブジェクトDBの中に存在するコミットグラフ上で、どこに分かりやすい名前の付箋を貼るか、というだけの話であって、その付箋はリポジトリ毎に別々で管理されている、ということをイメージしていれば、混乱は少ないかと思います。
以前に fetch した時に、興味のあるリモートのブランチがどのコミットを指していたかのメモが refs/remotes 以下に保存されているのが話をややこしくしてますが、あれは参照のためにちょっとキャッシュしてるだけのもので、ローカル側での書き換える操作の対象ではありません。
リポジトリで遊ぶときに便利な道具
腹オチするまで理解するには、やはり、自分の手でいろいろ触ってみるのが一番です。
commit の親子関係を全く無視した所にブランチを移動させたりすると、ブランチとは、オブジェクトDBに格納されたコミットグラフへの付箋みたいなものでしかない、という理解が深まるのではないでしょうか。
好きな commit に branch を移動したい
$ git checkout <branch>
$ git reset --hard <commit>
これを使えば、まったくコミット履歴的には関係ない地点にも自由にブランチを持っていくことが出来ます。--hard を指定することで、作業ツリーの内容も飛び先のものになるので、どこに行ったか分かりやすいかと思います。
なお、直接 refs ファイルを書き換える低レベルなコマンドとして、update-ref コマンドも用意されています。
$ git update-ref refs/heads/master <commmit>
などと指定すれば、master ブランチの示すコミットを自由自在に変更可能です。
切れた commit に再び戻りたい
前述の方法を使うと、通常の git log では辿れない、迷子の commit が出て来てしまいますが、全く心配は無用です。
git reflog を使えば、HEAD がこれまでどこを指していたかの履歴を見ることができますので、すぐに戻すことが可能です。ちなみに、git reflog ではなく、reflog の情報を log の形式で出力する git log -g のほうが情報量が多くて参考になるかもしれません。
戻りたい commit のハッシュ値がわかれば、あとは git reset で戻るだけです。
感想
git の内部構造を学ぶと、とても良く設計されていることが分かりました。
オブジェクトDBは、基本的には(git gc を呼ばない限りは)破壊的な操作というものが存在しません。git rebase などで履歴を書き換えまくっても、書き換えた後のコミットが新たに追加されて、ブランチがそこを指すようになっただけで、書き換え前のコミットは(git gc を呼ばない限りは)全てオブジェクトDB上には残っています。
誤操作で消えてしまう可能性があるのは、オブジェクトDB内への参照を保持している .git/refs 以下のブランチやタグだけです。しかも、各参照の更新履歴を保持している reflog (実体は .git/logs/HEAD など)のおかげで、完全に消えてしまうことはほとんどありません。
今後はあまり怖がらずに色々 git 操作を試してみようと思います。
あ、ちなみに、作業ツリーや index の内容はわりと誤操作でさっくりと消えますので、自信のない操作をする前は commit か stash はお忘れ無く……。