はじめに
gitを使い倒している!addしてcommitしてmergeして...という人でも、その仕組みを知らないことも多い。今回はみんなお世話になっているgitの正体を暴いていく...
まずは、そのメカニズムを理解するのに必要なGit Objectについて調べ、その後コマンドがどのようにオブジェクトを操作しているのかを見ていく。
gitとは
言うまでもないだろうが、プログラムのソースコードなどの変更履歴を記録・追跡するための分散型バージョン管理システムである。
GitはOSのファイルシステムを活かして実装されたオブジェクトデータベースです。データベース内に作成される無数のコミットオブジェクトは、自分以外のコミットオブジェクトを参照していて、それを再帰的に辿ることで長い履歴のチェーンが構成されます。いわゆるハッシュツリーの一種。
(Git完全に理解してるひと向けコミットツリーこねこねコマンド一覧)
Git objects
gitは3つの主要なオブジェクトタイプ(blob, tree, commit)で構成され、オブジェクトチェーニングを用いてオブジェクトを連結する。また、commitはtreeにリンクされ、treeはさらにblobにリンクされる。1つずつみていこう。
Blobオブジェクト
この節は、以下の記事をまとめたものである
それは何?
Blobは、サイズやオブジェクトタイプ(ここではblob)と共に保存される、対象ファイルのバイナリデータのことで、簡単に言えば、ファイルの内容をGitの内部表現に直したもの。
ここでいう「サイズ」は単にファイルサイズを指すものではない!
下の「フォーマット」の節で解説。
パス
.git/objects/
フォーマット
以下のようになっている
blob <size-of-blob-in-bytes>\0<file-binary-data>
このフォーマットは、gitオブジェクトタイプから始まる。今回で言えば、blobである。次に来るのが、blobのバイトサイズ(<size-of-blob-in-bytes>)で、これはフォーマット中のオブジェクトタイプやバイトサイズ、区切り文字、バイナリコンテンツを含めて計算される。そして次は\0に代表される, NULバイトが来る。これは、単に空のバイナリ00000000であり、区切り文字としてもふるまう。最後にファイルのバイナリコンテントが格納される。
blobが生成されるとき
gitは次のようなとき、新たなblobを生成する
-
git addによって非追跡対象ファイルをステージングしたとき -
git addによって修正された、追跡対象のファイルをステージングしたとき - 既存ファイルの内容の変更をマージしたとき(すべてのマージが該当するわけではない)
git rebaseのような他のコマンドでも、既存の追跡対象ファイルを変更したときに限って、同様にblobの新規生成が行われる可能性がある。
blobの保存方法
まず、gitは未追跡ファイルをblobに変換し、サイズを計算しながら前節「フォーマット」で示した形式を生成する。
そうしたら、OpenSSL SHA Libraryを使ってSHA-1でblobをハッシュ化する。(これは、the blob hashやもっと一般的にはobject IDと呼ばれる。)
次に、Zlib Libraryを用いてblobを圧縮・デフレートする。
最後に、圧縮されたblobはgitのオブジェクトストア内の新しいファイルに書き込まれ、計算したSHA-1ハッシュを使用して名前が付けられる。
Treeオブジェクト
この節は、以下の記事を参考にしている
それは何?
Treeは、blobを実際のファイルパス/名前および権限に関連付けるものであり、簡単に言うと複数のblobへの参照に名前をつけてまとめて管理するためのものである。
このtreeが無いと、gitはどの追跡対象ファイルがどのblobに対応しているか判別できない。というのも、先述した通りblobは純粋なファイルのバイナリであるので、ファイルシステムでのパスや名前に直接紐づけるための情報を含まないからである。
また、ツリーはディレクトリを表すものとも考えられる。これは、ファイルシステム上の特定のcommit済みフォルダを直接追跡するからではなく、特定の時点におけるファイルblobのリストとそれに対応するパス/名前および権限を紐づけるからである。
パス
.git/objects/
フォーマット
treeはメモリバッファ内でgitによって以下の形式で構築される
tree <size-of-tree-in-bytes>\0
<file-1-mode> <file-1-path>\0<file-1-blob-hash>
<file-2-mode> <file-2-path>\0<file-2-blob-hash>
...
<file-n-mode> <file-n-path>\0<file-n-blob-hash>
このフォーマットは、gitオブジェクトタイプから始まる。今回で言えば、treeである。次いでサイズ(<size-of-tree-in-bytes>)と区切り文字が来る。
次に、以下の3つの要素からなる、一連のcache entriesが来る。
1. ファイルモード(権限)
2. ファイルパス/名前
3. そのファイルのblobのSHA-1ハッシュ
これにより、gitは特定のblobとファイル名を紐づけることができ、treeは作業ディレクトリのサイズに応じて必要な数のblobのマッピングを保持できるようになった。
treeが生成されるとき
git addコマンドを用いて新規ファイルを追跡対象にするときや変更したファイルをステージングするとき、gitはまずblobを生成してローカルリポジトリに配置する。その後、gitはcache entry(実際には、.git/indexというごく普通のファイルだが)を生成する。これらには、ファイル名/パスや権限、blobのハッシュが格納される。
git commitを実行したとき、indexファイルのcache entriesからメモリ上に適切なtreeが構築される。これは、新しいcommitオブジェクトからルートツリーとして参照されることになり、各コミットはその時点での作業ディレクトリの内容のスナップショットであるルートツリーオブジェクトを指すことになる。
treeの保存方法
メモリ上に適切なtreeが構築されると、gitはOpenSSL SHA Libraryを使用してそのツリーのSHA-1ハッシュを計算する。
その後、gitはZlibを用いてツリーを圧縮し、loose objectとして保存する。(そのtreeは自身のSHA-1ハッシュを用いて命名される。)
Commitオブジェクト
この節は、以下の記事を参考にしている
それは何?
Commitは単一のtreeを指し、プロジェクトがその時点でどのような状態だったかをマークするものであり、タイムスタンプ、最後のcommitからの変更の作者、前のcommitへのポインタなどのメタ情報を含む。簡単に言うと、プロジェクトの特定時点における完全なスナップショットを表すオブジェクトである。
commitオブジェクトは、スナップショットを保存した人、いつ保存されたか、なぜ保存されたかという基本的な情報を保存する。
ここでいう「スナップショット」は、Gitが他のSCMシステム(Subversion、CVS、Perforce、Mercurialなど)とは異なり、Delta Storage(差分保存)ではなく、commitのたびにプロジェクトのすべてのファイルがどのように見えるかの完全なスナップショットをtree構造で保存することを指す。
パス
.git/objects/
フォーマット
commitオブジェクトの形式は単純で、その時点でのプロジェクトスナップショットのトップレベルtreeを指定し、author/committer情報(user.nameとuser.email設定とタイムスタンプを使用)、空行、そしてcommitメッセージが含まれる:
commit <size-of-commit-in-bytes>\0
tree <tree-sha>
parent <parent-commit-sha>
author <author-name> <author-email> <author-date-seconds> <author-timezone>
committer <committer-name> <committer-email> <committer-date-seconds> <committer-timezone>
<commit-message>
このフォーマットは、gitオブジェクトタイプから始まる。今回で言えば、commitである。次いでサイズ(<size-of-commit-in-bytes>)と区切り文字(NUL)が来る。
その後、以下の要素が含まれる:
1. treeハッシュ: そのcommitが表すルートtreeオブジェクトのSHA-1ハッシュ
2. parentハッシュ: 親commitのSHA-1ハッシュ(複数ある場合は複数行、初回commitの場合は無し)
3. author情報: 作者の名前、メールアドレス、タイムスタンプ、タイムゾーン
4. committer情報: commitした人の名前、メールアドレス、タイムスタンプ、タイムゾーン
5. 空行: メタデータとcommitメッセージの区切り
6. commitメッセージ: 実際のcommitメッセージ
commitが生成されるとき
commitは通常git commitによって作成され、親は通常現在のHEADで、treeは現在indexに保存されているコンテンツから取得される。
これは本質的にgit addとgit commitコマンドを実行したときにGitが行うことで、変更されたファイルのblobを保存し、indexを更新し、treeを書き出し、トップレベルtreeとその直前のcommitを参照するcommitオブジェクトを書き込む。
具体的なcommit生成プロセス:
- Indexから tree 作成: ステージングエリアの状態からtreeオブジェクトを構築
- 親commitの特定: 現在のHEADが指すcommitを親として設定
- メタデータ収集: author/committer情報とタイムスタンプを取得
- commitオブジェクト作成: 上記の情報とcommitメッセージを組み合わせてcommitオブジェクトを生成
commitの保存方法
treeオブジェクトと同様に、commitオブジェクトもメモリ上で構築された後、以下の手順で保存される:
-
commit-treeを呼び出して単一のtree SHA-1と、もしあれば直接的に先行するcommitオブジェクトを指定してcommitオブジェクトを作成する -
OpenSSL SHA Libraryを使用してcommitのSHA-1ハッシュを計算 -
Zlibを用いてcommitオブジェクトを圧縮 - 計算したSHA-1ハッシュを用いて
.git/objects/内にloose objectとして保存
Git Command Mechanism
Gitのオブジェクト(blob、tree、commit)の仕組みを理解した上で、実際のコマンドがどのようにこれらのオブジェクトを操作しているかを見ていく。
1. git init コマンド(リポジトリ初期化)
git initとは
git initはGitリポジトリを初期化するコマンドで、新しいプロジェクトでGitによるバージョン管理を開始するときに最初に実行する。
内部動作
git initを実行すると、以下のディレクトリ構造が作成される:
.git/
├── HEAD
├── config
├── description
├── hooks/
├── info/
├── objects/
└── refs/
├── heads/
└── tags/
各要素の役割
- .git/objects/: 全てのGitオブジェクト(blob、tree、commit)が保存されるディレクトリ
- .git/refs/: ブランチやタグなどの参照が保存されるディレクトリ
- .git/HEAD: 現在チェックアウトしているブランチを指すポインタ
- .git/config: プロジェクト固有の設定ファイル
- .git/index: ステージングエリアの情報(初期状態では存在しない)
初期状態の確認
# リポジトリの初期化
git init
# .gitディレクトリの確認
ls -la .git/
# HEADファイルの内容確認
cat .git/HEAD
# 出力: ref: refs/heads/master(または main)
2. git add (ステージング)
ステージングエリア(Index)とは
ステージングエリアはラフドラフトスペースのようなもので、次のcommitで保存したいファイルのバージョンや複数のファイルをgit addできる場所。git addコマンドが実際に行うのは、ワーキングディレクトリからステージングエリアへのファイルのバージョンのコピー。
git addの内部動作
先述の通り、blobを作成するプロセスは通常、ステージングエリアに何かを追加するとき、つまりgit addを使用するときに発生する。
具体的な手順
-
ファイル内容の読み取り
- ワーキングディレクトリのファイル内容を読み取る
- ファイル内容のSHA-1ハッシュを計算
-
blobオブジェクトの作成
- git addコマンドを実行すると、gitはファイルの内容が前回のcommitから変更されていなければ新しいblobを作成せず、代わりに既存のblobを次のcommitのためにステージするだけ。これはgitがスペースを節約する方法の一つである。
-
blobの保存
- zlibで圧縮してから
.git/objects/に保存 - ハッシュの最初の2文字をディレクトリ名、残りをファイル名として使用
- zlibで圧縮してから
-
Indexファイルの更新
-
.git/indexファイルにファイルパス、権限、blobハッシュを記録 -
git addコマンドによってファイルの変更がStagingIndexに昇格され、オブジェクトSHAが更新される
-
ステージングの確認
# ステージングエリアの状態確認
git ls-files -s
# 出力例:
# 100644 bab2a0adb8921f504cb0521bc00b8dde22ee92a4 0 myfile.txt
ステージングエリアの利点
ステージングは大きな変更を複数のcommitに分割することを支援し、一度にすべてを巨大なcommitにするのではなく、コードの変更の各側面に焦点を当てたクリーンなcommitに適切に分割できる。
3. git commit コマンド(コミット作成)
git commitの内部動作
ステージングされた情報からtreeオブジェクトを書き出し、そのトップレベルtreeと直前のcommitを参照する新しいcommitオブジェクトを作成する。
具体的な手順
-
ステージングエリア(Index)からTreeオブジェクトを作成
-
.git/indexファイルに記録されているステージング済みのファイル情報を読み取る - この情報(ファイルパス、権限、blobハッシュ)を使ってtreeオブジェクトを構築
- ディレクトリ構造に応じて、必要な数だけtreeオブジェクトを作成(サブディレクトリがあれば子treeも作成)
-
-
ルートTreeオブジェクトの決定
- 作成したtreeオブジェクトの中で、プロジェクトのルートディレクトリを表すtreeを特定
- このルートtreeがcommitオブジェクトから参照される「トップレベルtree」になる
-
Commitオブジェクトの作成
- ルートtreeのハッシュを参照として含める
- 現在のHEADが指すcommitを「親commit」として参照に含める
- author/committer情報、タイムスタンプ、commitメッセージを追加
-
参照の更新
- 新しく作成したcommitオブジェクトのハッシュで現在のブランチを更新
- HEADが指す参照(通常は
refs/heads/<branch-name>)を新しいcommitに移動
実際のcommit例
# ファイル作成とステージング
echo "Hello World" > hello.txt
git add hello.txt
# commitの実行
git commit -m "Initial commit"
# 作成されたオブジェクトの確認
find .git/objects -type f
# 出力例:
# .git/objects/55/7db03de997c86a4a028e1ebd3a1ceb225be238 # blob
# .git/objects/b2/5c15b81fae06e1c55946ac6270bfdb293870e8 # tree
# .git/objects/f7/c3bc1d808e04732adf8be7c0ab7c8e2b13bfff # commit