3年前に書きかけの記事があったので公開しておきます。
ブランチ名がmasterだったり、SHA-256への移行の言及がなかったりと古さを感じさせるところがあります。
古い記事ですがベースの考え方は今でも通用すると思います。
第3章は未完のままです。ご容赦ください。
目的
gitのコマンドラインツール(以下CLI)を日常的に使っているけれど内部の仕組みについてはよく知らないという開発者にやlibgit2を使ってみたい開発者に対して、gitの仕組みを解説します。
公式ドキュメントであるPro Gitとlibgit2のドキュメントを読む前に、読者にGitの仕組みのメンタルモデルを提供します。
第1章ではGitの基盤となっているファイルシステム上のデータベースについて解説します。
第2章ではデータベース以外のGitの基盤を構成する要素について解説します。
第3章ではgit2go/libgit2で公式CLIのふるまいを再現する方法を解説します。
第1章 データベースとしてのGit
Gitはバージョン管理ツールですが、その基盤はファイルシステム上に構築されたKey-Value型(辞書型)のデータベースです。
このデータベースをGitではオブジェクトデータベース(odbと略されることがあります)と呼びます(以下データベース)。
どのようなファイルでもデータベースに登録することができます。
登録されたファイルに対してGitはSHA1文字列をオブジェクト名として振り出します。
git commit git log をはじめとしたGitが提供するさまざまなサービスの背景で、このデータベースが使われています。
データベースの仕組みを理解することで、Gitが提供するサービスの理解だけでなくGitで遭遇するトラブルの解決にも役立ちます。
オブジェクト
Gitのデータベースの管理対象はオブジェクトです。
Gitのデータベースはバージョン管理機能だけにとらわれない汎用的なものなので、どのようなファイルでもオブジェクトとしてデータベースに追加・利用することができます。
ただし、Gitのサービスが使用するのはファイル情報(Blobオブジェクト)、ディレクトリ情報(Treeオブジェクト)、コミット情報(Commitオブジェクト)、タグ情報(Tagオブジェクト)の4種類です。Tagオブジェクトを除いた3種類のオブジェクトについて解説していきます。
なお、後述するPackfileの文脈においては、パックされていない(ガベージコレクションが実行されていない状態)オブジェクトはLooseオブジェクトと呼ばれます。
オブジェクトの配置
データベースにオブジェクトを追加すると、GitはSHA1文字列を識別子として与えてファイルとして .git/objects/ 以下に配置します。
なお、Blob、Tree、Commitといったオブジェクトの種類に関係なく、同じように識別子が与えられて、 .git/objects/ に配置されます。以後そのSHA1をキーとしてデータベースからオブジェクトを取得できるようになります。
しかし、Gitのオブジェクトはイミュータブルなのでオブジェクトを変更することはできません。したがって、コミットメッセージを変更するなどコミット履歴に変更を加えた場合には、変更のあったコミットの親コミットを起点として、リベース(本章で後述)を行う必要があります。
Blobオブジェクト
テキストやバイナリといったファイルの中身を保持するオブジェクトです。
ファイル名はBlobオブジェクトではなく、親Treeオブジェクトで管理されます。
Treeオブジェクト
1つのディレクトリを表すオブジェクトです。
ディレクトリに含まれる子ディレクトリやファイルへの参照を保持しています。
このディレクトリの名前は、このTreeオブジェクトではなく、親Treeオブジェクトで管理されます。
Commitオブジェクト
1つのコミットを表すオブジェクトです。
コミットメッセージ、著者の情報(名前、メールアドレス、投稿日時)、コミッターの情報(名前、メールアドレス、コミット日時)、リポジトリ最上位のディレクトリに対応するTreeオブジェクトへの参照からなります。
オブジェクトのつながり
データベースの機能ではありませんが、オブジェクトは関連する他のオブジェクトへの参照を保持することでオブジェクト同士のつながりを表現します。
Blobオブジェクトは木構造でいうところのリーフにあたり、TreeオブジェクトはBlobまたはTreeへの参照を保持するノードにあたります。
また、Commitオブジェクトも親コミットへの参照を通じてコミット履歴を有向グラフとして表現されます。
たとえば、リポジトリを初期化した直後に fig 0517253aのファイル構造の状態をコミットすると、 .git/objects の様子は fig 114b8ed7 のようになります。ただし、ここではわかりやすさを重視して .git/objects/ を実際より簡潔に表現しています。
/(リポ最上位ディレクトリ)
┗ docs/
┣README.md
┣TODO.md
┗ imgs/
┗ foo.png
git add .
git commit -m 'コミットその1'
fig 114b8ed7 1回目のコミット時のデータベースの簡易的表現
@startuml
package ".git/objects/" {
file c1 [
ba3ed
----
オブジェクトの種類 commit
著者情報
コミッター情報
コミットメッセージ: コミットその1
子ディレクトリの名前とツリーへの参照 -> / (2698c)
]
file t1 [
2698c
----
オブジェクトの種類 tree
ディレクトリ名 / または top (リポジトリ最上位のディレクトリはtopというマジックワードで呼ばれます)
子ディレクトリの名前とツリーへの参照 -> docs/ (115aa)
]
file t2 [
115aa
----
オブジェクトの種類 tree
ディレクトリ名 docs/
ファイル名とファイルの中身への参照 -> README.md(669db)
ファイル名とファイルの中身への参照 -> TODO.md(9b18a)
子ディレクトリの名前とツリーへの参照 -> imgs/ (2d3ce)
]
file f2_1 [
669db
----
オブジェクトの種類 blob
README.mdの中身
]
file f2_2 [
9b18a
----
オブジェクトの種類 blob
TODO.mdの中身
]
file t3 [
2d3ce
----
オブジェクトの種類 tree
ディレクトリ名 docs/imgs/
ファイル名とファイルの中身への参照 foo.png(4f54d)
]
file f3_1 [
4f54d
----
オブジェクトの種類 blob
foo.pngの中身"
]
c1 ..> t1
t1 ..> t2
t2 .> f2_1
t2 .> f2_2
t2 ..> t3
t3 .> f3_1
}
@enduml
このファイル構造の例では、3つのファイル(README.md, TODO.md, foo.png)と3つのディレクトリ(リポ最上位のディレクトリ, docs/, docs/imgs)が存在するので、それぞれに対応する3つのBlobオブジェクトと3つのTreeオブジェクトが生成されます。
さらに、コミット表すためのCommitオブジェクトも1つ生成されます。
オブジェクトのイミュータビリティ:コミットするとデータベースはどう変化するか?
前項では、3つのファイルと3つのディレクトリに対応する6つのオブジェクトと、コミットに対応する1つのオブジェクトが生成されました。
ここで docs/README.md に適当な文字列を加えてコミットすると、 .git/objects/ に存在するファイルの数は11に増えます。
つまり、1つのファイルに1行加えただけの追加コミットで新たに4つのオブジェクトが追加されたことになります。
たった1つのファイルに1行加えただけで、オブジェクトが4つも増えてしまうのはなぜでしょうか。
この疑問を解く鍵は、オブジェクトはイミュータブルであるという点です。
もしオブジェクトの中身を書き換え可能にしてしまうと、**「過去のコミットが壊れる」**という致命的な問題が発生します。 例えば、Commit A が Blob 1 (ver1) を参照している状態で、ファイルを修正して Blob 1 の中身を直接 ver2 に書き換えてしまうと、過去の Commit A の時点のファイル内容まで ver2 に変わってしまいます。 「過去のあらゆる時点の状態を完全に復元できる」というバージョン管理の根幹を守るために、一度データベースに書いたものは絶対に上書きせず、常に新しい名前(SHA1)で保存する必要があります。
リポジトリの健全性を保つためにオブジェクトはイミュータブルでなければならず、既存のオブジェクトを変更することができないことを意味します。
新しく増えたオブジェクトの正体は、新しいCommitオブジェクトと、新しいREADME.mdの内容を完全に保持したBlobオブジェクトです。
さらに、リポジトリ最上位のディレクトリに対応するTreeオブジェクトと、docs/ディレクトリに対応するTreeオブジェクトです。
オブジェクトはイミュータブルなので、GitはCommitオブジェクトに加えて、1つのBlobオブジェクトと2つのTreeオブジェクトも新しく作成する必要がありました。
この点はGitを理解する上で重要な部分なので敷衍します。
Gitは新しいREADME.mdに対応するBlobオブジェクトを作成しSHA1を振り出します。古いREADME.mdに対応するBlobオブジェクトも引き続きデータベースに存在し続けます。
さらに新しいコミットに対応するCommitオブジェクトを作成しSHA1を振り出します。古いコミットに対応するCommitオブジェクトも引き続きデータベースに存在し続けます。
コミットの処理をここで完了させてしまうと、新しいCommitオブジェクトは新しいREADME.mdのBlobオブジェクトを指し示すことができません。
リポ最上位に対応する既存のTreeオブジェクトは変更することができず、新しいCommitオブジェクトを作成しなければ、2回目のコミットオブジェクトが参照するリポ最上位のTreeがが存在しないからです。
リポジトリの健全性を保つためにオブジェクトはイミュータブルでなければならないことを鑑みると、Blob(README.md)を新しく作成すると、README.mdを含むdocs/ディレクトリのTreeオブジェクトも新しいものに差し替えなければなりません。同様に、リポ最上位のディレクトリに対応するTreeオブジェクトも新しいものに差し替える必要があります。
このように、Treeオブジェクトを必要最低限だけ作成することで変更の範囲が狭くなるように配慮しつつも、古いコミットやツリーのオブジェクトは削除されずデータベースに存在しつづけ、また、古いREADME.mdと新しいREADME.mdの中身は完全に保持されたままになっています。
ここで注意が必要なのは、Gitはそのままでは変更の差分を保存する仕組みにはなっていない点です。この設計が引き起こす問題の対処法については LooseオブジェクトとPackfileで解説します。
ガベージコレクション:LooseオブジェクトとPackfile
これまで述べたように、Gitは編集されたファイルがコミットされるたびに新しいBlobオブジェクトを作成し、しかも、新しいBlobオブジェクトも古いBlobオブジェクトもファイルの中身を完全な状態で保持していました。このように作成されたCommit、Tree、BlobオブジェクトはLooseオブジェクトと呼びます。
しかし、リポジトリへの変更を続けていけば、Looseオブジェクトは再現なく増えていくので、いつかはファイルシステムの限界に到達します。
そうでなくとも、古いLooseオブジェクトをずっとそのまま維持し続けるのはファイルシステムのリソースを効率的に使用しているとはいえません。
この問題を解決するための手段としてGitは git gc コマンドで古いオブジェクトファイルを削除する方法を提供しています。
古いオブジェクトを削除するだけでは、過去のコミットをたどることができず、その詳細を得ることもできません。
ここでGitはPackfileというオブジェクトファイルを生成して、差分のみを保持できるようにし、ファイルシステムのリソースを開放します。
なお、Looseオブジェクトの数が約7000個またはPackfileの数が50個を超えると、Gitは自動的にガベージコレクションを実行します。
git gc は通常、参照されなくなったオブジェクト(dangling objects)をすぐには削除せず、一定期間(デフォルト2週間)保持します(間違って消したときの救済措置のため)。 git prune は、これらの「どのコミットやタグからも到達できない孤立したオブジェクト」を即座に完全に削除するコマンドです。通常は git gc の内部で呼び出されますが、手動で不要オブジェクトを掃除したい場合に利用します。
まとめ
Gitのバージョン管理機能はここで述べたデータベースの仕組みの上に成り立っています。
第2章 データベース以外のGitの基盤の構成要素
Gitはあるコミットの状態を起点として、複数のブランチに分岐させたり過去のコミットの状態に戻って作業を分岐させることができます。
いずれの場合においても、チェックアウトすることでワーキングツリー、インデクス、最新のコミットのSHA1(HEADリファレンス)も変化します。
この章ではGitのバージョン管理の機能に必要な機能のうち、すでに解説を終えたデータベース以外の要素を解説します。
ワーキングツリー
ユーザが作業中のリポジトリの状態をワーキングツリーと呼びます。
変更を加えたワーキングツリーの内容はステージングおよびコミットを実行するまではリポジトリのデータベースに影響を与えません。
ブランチを切り替えたり、過去のコミットにチェックアウトすると、データベースの内容にあわせてワーキングツリーの状態も変化します。
インデクスとステージ
コミットを作成するためには、まず、コミットに含めるファイルを指定して、そのファイル(Blob)やディレクトリ(Tree)に対応するオブジェクトを生成しなければなりません。
コミットに含めるオブジェクトを登録しておく場所をインデクス(または古い呼び名でキャッシュとも)とよび、ファイルをインデクスに追加する作業をステージングと呼びます。
なお、インデクスは、次回のコミットの準備をするためのもので、実体は .git/index というバイナリファイルです。CLIを使ううえでは意識することはありませんが、libgit2でインデクスに変更を加える場合に、意図せぬ振る舞いを避けるために、このインデクスがメモリにロードされた状態も把握しておく必要があります。メモリとファイルシステムを明示的に同期する必要があるからです。
リファレンス
特定のコミットオブジェクトへの参照をリファレンスと呼びます。
リファレンスは参照先のオブジェクト名(SHA1)を保持しているので、リファレンスを使うことでデータベースのオブジェクトをSHA1を使わずに参照することができます。
おもに、ブランチ名のリファレンスと HEAD リファレンスが利用されます。
ブランチ名のリファレンス .git/refs/heads/{ブランチ名}
ブランチの先端コミットへの参照を保持するリファレンスです。
なお、この種類のリファレンスはヘッドと呼ばれますが、次項で解説するHEADとは異なる点に注意してください。
ブランチ名のリファレンスは生成されると .git/refs/heads に配置されます。
たとえば、 main ブランチがどののコミットオブジェクトを指し示しているか確認する場合は、.git/refs/heads/main ファイルに記録されているオブジェクト名(SHA1)で判断できます。
HEAD リファレンス .git/HEAD
HEAD はユーザが現在作業中のブランチの最新のコミットを表します。
あるいは、ユーザが現在変更を加えているワーキングツリーがどのコミットから派生したのか、その派生元のコミットを表します。
HEAD は.git/HEAD に配置され、コミットやチェックアウトするたびに HEAD の参照先も自動的に変更されます。
たとえば、mainブランチにチェックアウトしている場合、HEAD の参照先は main ブランチ名のリファレンスであり、さらに、mainブランチのリファレンスが参照しているコミットオブジェクトのSHA1になります。このようにリファレンスを参照するリファレンスをシンボリックリファレンスと呼びます。
また、新たにコミットを実施した場合、 HEAD の参照先は新しいコミットのオブジェクト名(SHA1)に変更されます。
デタッチされたHEAD
ヘッド以外のコミットにチェックアウトした場合、 HEAD はシンボリックリファレンスではなくなり、指定されたコミットのオブジェクト名を直接参照します。この状態をデタッチされたHEADといいます。
この状態では HEAD が参照すべきヘッドのリファレンスが存在しないため、Gitのいくつかの機能が正常に機能しなくなります。この状態を解決するためには、新しいブランチにチェックアウトして、HEAD があたらしいブランチのヘッドを参照するようにします。
HEAD の遷移履歴
前項のとおり、 HEAD はリポジトリへの変更が発生するたびに変更されます。
git log -g コマンドを使えば、HEAD の参照先がどのような変遷をたどったか確認することができます。
パックされたリファレンス
ガベージコレクションが実施された場合、リファレンスは .git/refs/ から .git/packed-refs ファイルに移動されます。
git/refs/ 以下に目的のリファレンスが存在しない場合には、 .git/packed-refs ファイルに記録されています。
任意のコミットを指定する
Gitの操作では、過去のコミットを指定するケースがあります。
目的のコミットのオブジェクト名を直接入力することもできますが、SHA1を毎回入力することは煩雑です。
なお、SHA1は他に重複するSHA1が存在しなければ、先頭から最低4文字を入力することでオブジェクトを指定することができます。
通常のコミットには親コミットが1つ存在します。
たとえば、HEADの祖先を遡って指定する場合:
HEAD~ でHEADが直接派生している親コミットを指定できます。
HEAD~n で HEADからn番目の祖先コミットを指定できます。
マージコミットには親コミットが複数存在します。
たとえば、HEADの祖先を遡って指定する場合:
この場合、 HEAD^親コミットの順番 を指定することで親コミットを指定することができます。
HEAD^ または HEAD^1 で1番目の親コミットを指定します。
HEAD^2 で2番目の親コミットを指定します。
HEAD^{tree} とすることでHEADの参照しているツリーを直接指定できます。
まとめ
データベースの他にも、リファレンス、ワーキングツリー、インデクスという構成要素があることを学びました。
第3章 git2go/libgit2 でCLIの振る舞いを再現する
TODO リポジトリへのリンク
付録 便利なコマンド
-
git log --pretty=oneline- コミットの履歴を1行1コミットで表示します。
-
git log --graph- コミット履歴のグラフを表示します。
-
git checkoutgit resetgit reset --softgit reset --hard -
git refloggit log -g- HEADが参照するオブジェクトの履歴を確認できます。
-
git cat-file -p SHA1- データベースのオブジェクトの内容を確認できます。
-
-sオプションでサイズを確認できます。
-
git count-objects -vH- データベースが保持しているオブジェクトの数、サイズを確認できます。
-
git fsck --full- どのコミットからも到達できないオブジェクトを見つけ出せます。
-
git verify-pack- Packfileの状態を確認できます。
-
git rev-list