Git の内部構造
Git
Git はコンテンツのバージョン管理を実現するためのファイルシステム、API を提供するソフトウェアである。
Git オブジェクト
Git では、すべてのコンテンツは「ツリーオブジェクト」、「ブロブオブジェクト」として .git/objects ディレクトリに格納される。
「コンテンツ」
ファイルの実際の内容、つまりデータ自体を指す。これはファイルのテキストやバイナリデータなどの実際のデータのこと。
「Git オブジェクト」
「Git オブジェクト」は、Git リポジトリ内でコンテンツを表現するためのデータ構造。Git によるコンテンツのバージョン管理を実現するためのデータ構造、データモデルである。
Git オブジェクトには、ファイルのコンテンツやその他のメタデータ(コミット情報、ツリーオブジェクトへの参照など)が含まれる。Git オブジェクトは、バージョン管理システムとしての Git の内部データモデルを構成する要素であり、Git リポジトリ内のデータの保持や管理に使用される。一般的には、Gitリポジトリ内のファイルのコンテンツは Git オブジェクトとして表現される。
- コミットオブジェクト、ツリーオブジェクト
- Git のデータ構造を形成するために使用される
Git オブジェクトには 4 つの主要なオブジェクトタイプがある。
- Blob(ブロブオブジェクト、Binary Large OBject)
- バイナリデータやテキストファイルの内容を表現するオブジェクト。Git リポジトリ内のファイルの実際のコンテンツを保持する。
- Tree(ツリーオブジェクト)
- ディレクトリ構造を表現するオブジェクト。ファイルとサブディレクトリへの参照を持ち、ファイル名と Blob オブジェクトの関連付けを保持する。ツリーオブジェクトは、Git リポジトリ内のディレクトリ構造を再現する。
- Commit(コミットオブジェクト)
- 特定のバージョンのプロジェクトのスナップショットを表現するオブジェクト。コミットには、親コミットへの参照、コミットのメタデータ(作者、コミットメッセージなど)、ツリーオブジェクトへの参照が含まれる。
- Tag(タグオブジェクト)
- コミットを参照するスタティックなポインタとして機能するオブジェクト。タグは、特定のコミットに対して人間が理解しやすい名前(バージョン番号など)を付けるために使用される。
これらのオブジェクトは、Git のデータモデルを構成し、バージョン管理の機能を提供する。Blob オブジェクトがファイルのコンテンツを保持し、Tree オブジェクトがディレクトリ構造を表現し、Commit オブジェクトがプロジェクトのスナップショットを表現する。さらに、Tag オブジェクトはコミットへの参照をラベル付けするために使用される。これらのオブジェクトは、Git の内部的なデータモデルを構成し、バージョン管理の機能を提供する。
Git オブジェクトは、Git のコンテンツ管理の根幹としての役割を果たしている。
まとめ
要約すると、「コンテンツ」とはファイルの実際のデータを指し、「Git オブジェクト」とは Git リポジトリ内でコンテンツを表現するためのデータ構造を指す。
Git の実体
Git は「コンテンツ」=「テキストファイル、バイナリファイル」のバージョン管理システムである。各種コンテンツを Git オブジェクトというデータモデルに従って管理する。
実体は KVS(Key-Value Store)である。Git オブジェクトは、キーがハッシュ値(SHA-1 ハッシュ)、バリューは各種 Git オブジェクトの形式に従った内容になっている。Git オブジェクトは git init コマンドを実行したときに作られる .git/objects ディレクトリ配下に格納される。.git/objects ディレクトリを Git オブジェクトに従って KVS として扱い、操作する。
Git には KVS を操作する API として配管コマンド(Plumbing)、磁器コマンド(Porcelain)を用意している。配管コマンドは KVS を直接操作する git cat-file -p(KVS の read)、git has-object -w(KVS への write)といった低レイヤな API を提供し、陶器コマンドは普段我々が使用している git show(KVS の read)や git commit(KVS への write)などの高レベルな API を提供する。
配管コマンドは低レベルな API で、UNIX ライクな設計思想(シンプルで独立したツールの組み合わせ)で作られており、直接実行されるよりは、新しく VCS を作るためのツールやカスタムスクリプトを作る際の構成要素となることを意図して作られている。
ChatGPT に聞いてみた:「Git の Plumbing は unix ライクに設計されているとありますが、これはどういう意味ですか?」
GitのPlumbing(プラムビング)は、Unixライクな哲学や設計原則に基づいて設計されています。Unixライクの哲学は、Unixオペレーティングシステムの設計原則として知られており、シンプルで独立したツールの組み合わせによる柔軟なシステム構築を重視します。
具体的には、GitのPlumbingレベルのコマンドは、以下のUnixライクな特徴を持っています:
単一の役割: 各Plumbingコマンドは、特定のタスクや機能に特化しています。例えば、git-hash-objectはオブジェクトのハッシュ値を計算するために使用され、git-cat-fileはオブジェクトの内容を表示します。それぞれのコマンドは独立して動作し、必要なタスクを実行するために組み合わせることができます。
テキストベースの入出力: GitのPlumbingコマンドは、テキストベースの入力と出力を使用します。これにより、コマンド間でのデータのパイプラインやリダイレクトが可能になり、他のツールやシェルスクリプトとの統合が容易になります。
小さなツールの組み合わせ: Unixライクの哲学では、小さなツールを組み合わせることで複雑なタスクを達成します。同様に、GitのPlumbingコマンドは、独立して動作する小さなツールとして設計されており、パイプラインやシェルスクリプトを使用して組み合わせることで、高度な操作やカスタマイズが可能です。
Unixライクの設計原則は、シンプルで柔軟なシステム構築を促進し、ツールの再利用性や相互運用性を高めます。GitのPlumbingレベルのコマンドは、この哲学に則って設計されており、システムの一部として使われることも、他のツールやスクリプトと組み合わせて使われることもできます。
Git はスナップショット
ref
Git オブジェクトは下図のような関係を持っている。
-
commitオブジェクトは 1 つのtreeを持っている -
treeオブジェクトはblobまたはtreeを 1 つ以上持っている
commit オブジェクトは、コミット時点でのディレクトリ構造を持っている。これが commit を元にその時点の状態を再現できる理由である。Git は差分ではなく、スナップショットを保存している。
GitHub の Pull Request で Diff が表示されているから「Git は差分を保存しているんだ」と思いがちですが、Git はそのタイミングのスナップショット(変更されたファイルの中身全部)を保存しています
commit オブジェクトの関係性
commit オブジェクトと他の commit オブジェクトは下図のような関係になっている。
commit オブジェクトは、親の commit オブジェクトのハッシュ値を持っている。これにより 1 つ前のコミットを取得できる。commit オブジェクトの parent のハッシュ値を再帰的に追っていけば、すべての編集履歴を追うことができる構造となっている。
git cat-file -p コマンドで commit オブジェクトの中身を覗いてみる。git log で最新の commit オブジェクトのハッシュ値を確認し、git cat-file [-t | -p] で Git オブジェクトの種類、中身を出力している。
$ git branch
* main
$ cat .git/HEAD
ref: refs/heads/main
$ git log -n 1
commit f35eeb1a45d70d624aa63a99d30177ec5036ae57 (HEAD -> main, origin/main)
Author: nukopy <nukopy@gmail.com>
Date: Sun Jun 4 04:39:22 2023 +0900
docs: update
$ git cat-file -t f35e
commit
$ git cat-file -p f35e
tree 152b42bd116e88696d6f69e1a8a65c9cff9a2e0e
parent 39a079db3f71757fbb49c014ee052d76143384be
author nukopy <nukopy@gmail.com> 1685821162 +0900
committer nukopy <nukopy@gmail.com> 1685821162 +0900
docs: update
merge コミットを作成する際は、その commit オブジェクトの parent の値が 2 つある。これにより、merge の様子がわかる。
実際にローカルで develop ブランチを作成し、そのブランチ上でコミットし、main ブランチにマージしてみる。
$ git branch
* main
$ git switch -c develop
Switched to a new branch 'develop'
$ git branch
* develop
main
$ git status
On branch develop
Your branch is up to date with 'origin/develop'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: README.md
no changes added to commit (use "git add" and/or "git commit -a")
$ git add README.md
On branch develop
Your branch is up to date with 'origin/develop'.
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: README.md
$ git commit -m "docs: update in develop"
[develop a081c76] docs: update in develop
1 file changed, 2 insertions(+)
$ git switch main
Switched to branch 'main'
Your branch is up to date with 'origin/main'.
$ git merge --no-ff develop
Merge made by the 'ort' strategy.
README.md | 2 ++
1 file changed, 2 insertions(+)
$ git log -n 1
commit 253181232a948723d998f13d327af7d99714635e (HEAD -> main)
Merge: f35eeb1 a081c76
Author: nukopy <nukopy@gmail.com>
Date: Sun Jun 4 05:20:30 2023 +0900
Merge branch 'develop'
git merge --no-ff develop の --no-ff オプションはどういう意味ですか?
--no-ffは、Gitのマージ操作におけるオプションの1つです。これは「no-fast-forward」の略であり、ファストフォワードマージを禁止し、必ずマージコミットを作成することを指定します。
通常、Gitでは、マージする際にファストフォワードマージが行われます。ファストフォワードマージとは、マージ先のブランチが直接マージ元のブランチのコミットを指すようにポインタを移動することで、追加のマージコミットを作成せずにマージを行う方法です。この場合、マージコミットが作成されず、ブランチの歴史が線形になります。
一方、--no-ffオプションを使用すると、常にマージコミットが作成されます。ファストフォワードマージを禁止し、マージ元のブランチの変更がマージ先のブランチに統合される際に新しいマージコミットが作成されます。これにより、マージの結果としてブランチの変更が明示的に記録され、マージの歴史が視覚的に分かりやすくなります。
--no-ffオプションを使用すると、マージコミットが作成されるため、ブランチの変更の統合が明確になります。また、後からマージしたことが分かるため、変更履歴の追跡や誤って削除することも防ぐことができます。
以上のように、Git のデータは commit、tree、blob の 3 種類の Git オブジェクトのみで構成されている。
また、 tree は blob と tree を子要素として木構造を作り commit は他の commit と単方向リストになっていることが分かる。
index ファイルの中身
index ファイルは、Git の「ステージング」と呼ばれる領域を表すファイル。git init 直後の初めての git add コマンドで作成され、それ以降 git add コマンドを実行すると更新される。
- フォーマット
-
DIRC [version] [entry_num]でメタ情報(ヘッダー) -
[entry]:entry_numの数だけエントリーが並ぶ -
<check-sum>:ヘッダーとエントリーのチェックサム
-
DIRC [version] [entry_num] <entry>... <check-sum>
中身はバイナリになっている。hexdump コマンドで覗いてみる。
$ cat .git/index
DIRCd{��+іyd{��+і��B���!rk=�\be�t�K���H7Ŋ
.gitignored{�� <&d{�� <������iA�����!�4�Pݼ���2 README.mdTREE2 0
|�O��@�ʀ�#���Rԏ0cj�|+�ʕ
5��~�%
$ hexdump -C .git/index | head -n 5
00000000 44 49 52 43 00 00 00 02 00 00 00 02 64 7b 94 f1 |DIRC........d{.�|
00000010 2b d1 96 79 64 7b 94 f1 2b d1 96 79 01 00 00 08 |+�.yd{.�+�.y....|
00000020 04 c1 91 42 00 00 81 a4 00 00 01 f5 00 00 00 14 |.�.B...�...�....|
00000030 00 00 00 21 72 6b 3d 8a 5c 62 65 81 74 9a 4b 8b |...!rk=.\be.t.K.|
00000040 b6 eb a6 1d 48 37 c5 8a 00 0a 2e 67 69 74 69 67 |��.H7�....gitig|
ref
refs について
HEAD と ref の関係は以下の通り。
branch や tag の情報は .git/refs に格納されている。.git/refs の中身は以下の通り。
-
.git/refs/heads- 各ブランチの最新のコミットのハッシュ値が記述されたテキストファイルが格納されている。ブランチに対してコミットが行われる度に更新されるため、常に各ブランチの最新のコミットのハッシュ値を保持する。
- 関連する配管コマンド:
git update-ref- フォーマット:
git update-ref <ref> <hash> - 例:
git update-ref refs/heads/master <hash>
- フォーマット:
-
.git/refs/tags- タグのコミットのハッシュ値が記述されたテキストファイルが格納されている
-
.git/refs/remotes- GitHub などの Git ホスティングサービスにリモートリポジトリを作成している場合、リモートリポジトリの各ブランチの最新のコミットのハッシュ値が記述されたテキストファイルが格納されている
$ ls .git/refs
heads remotes tags
$ ls .git/refs/heads
develop main
$ ls .git/refs/tags
# まだタグがないので空
$ git log -n 1
commit 253181232a948723d998f13d327af7d99714635e (HEAD -> main, origin/main)
Merge: f35eeb1 a081c76
Author: nukopy <nukopy@gmail.com>
Date: Sun Jun 4 05:20:30 2023 +0900
Merge branch 'develop'
$ git tag -a "test" -m "Release test:tada:"
$ git log -n 1
commit 253181232a948723d998f13d327af7d99714635e (HEAD -> main, tag: test, origin/main)
Merge: f35eeb1 a081c76
Author: nukopy <nukopy@gmail.com>
Date: Sun Jun 4 05:20:30 2023 +0900
Merge branch 'develop'
$ git tag
test
$ ls .git/refs/tags
test
$ cat .git/refs/tags/test
81db6e23c04ae7e89ba65b734bdee5a11ffa9be7
$ cat .git/refs/tags/test |git cat-file -t 81db
tag
$ git cat-file -p 81db
# object はタグが付与されたコミットのハッシュを指している
object 253181232a948723d998f13d327af7d99714635e
type commit
tag test
tagger nukopy <nukopy@gmail.com> 1685825523 +0900
Release test:tada:
git tag -d "test"
Deleted tag 'test' (was 81db6e2)
$ ls .git/refs/tags
# タグが削除されたので空に