🐾

.git配下のディレクトリ構成と挙動を理解する

2024/10/15に公開

はじめに

こんにちは!noruです。
普段何気なく利用しているGitですが、その内部の仕組みについて疑問を抱いたことはありませんか?以前、コミットハッシュについて調べたのですが、時間が経ち忘れてしまいました。そこで、本記事ではGitの内部の仕組みを再確認し、アウトプットしたいと思います。

小話

「エンジニアたるもの、まずは一次情報を確認しないと!」と言うことで、公式ドキュメントとリポジトリを確認しました。すると、READMEの冒頭に面白いことが書いてあったので紹介します。

GIT - the stupid content tracker

翻訳すると「GIT - 愚かなコンテンツトラッカー」みたいですね。スラングの意味は、是非READMEを覗いて確認してみてください。続くGitの説明も結構パンチが効いていて好きですし、開発者Linus Torvalds氏のユーモアセンスが爆発しています。また、Gitは2週間で開発・リリースされたみたいです。Linuxも開発されてますし凄いですね。

事前準備

まずは全体像を把握したいので、ディレクトリ構成から見ていきましょう。
.git配下にGitの管理情報が格納されています。事前準備として、sample-gitというディレクトリを作成&移動し、git initを実行してリポジトリを作成します。

$ mkdir sample-git && cd sample-git

$ git init
Initialized empty Git repository in /Users/noru/project/sample-git/.git/

$ ls -a
.    ..   .git

.gitが生成されました。ここにGit情報が格納されています。
git init実行時に、デフォルトだとリポジトリのGit情報は.gitに格納されますが、--separate-git-dirオプションを使うことで格納パスを変更することもできます。
下記は.gitではなく、.git-1を格納パスに設定しています。

$ git init --separate-git-dir .git-1
Initialized empty Git repository in /Users/noru/project/sample-git/.git-1/

$ ls -a
.      ..     .git   .git-1

また、Git情報が格納されるディレクトリパスはgit rev-parse --git-dirコマンドで確認できます。

$ git rev-parse --git-dir
/Users/noru/project/sample-git/.git

ディレクトリ構成

準備が整いました。
それでは、tree .gitコマンドでディレクトリ構成を見ていきましょう。

$ tree .git
.git
├── HEAD
├── config
├── hooks
│   ├── commit-msg
│   ├── pre-commit
│   └── prepare-commit-msg
├── objects
│   ├── info
│   └── pack
└── refs
    ├── heads
    └── tags

8 directories, 5 files

.git配下にはHEAD, config, hooks, objects, refsの5つがありますね。
1つずつ確認していきたいと思います。

.git配下のディレクトリ・ファイル

.git/HEADファイルにはカレントブランチを指すシンボリック参照を格納します。

cat .git/HEAD
ref: refs/heads/main

ブランチを切り替えると、格納されているシンボリック参照も変更されます。

$ git switch -c branch-1
Switched to a new branch 'branch-1'

$ cat .git/HEAD
ref: refs/heads/branch-1

config

リポジトリ固有の設定ファイルです。

$ cat .git/config
[core]
	repositoryformatversion = 0
	filemode = true
	bare = false
	logallrefupdates = true
	ignorecase = true
	precomposeunicode = true

coreの各設定項目は下記のようになっています。
また、core以外にも、remoteやbranch、pull等の設定もできます。

設定名 概要
repositoryformatversion Gitリポジトリのフォーマットバージョンを表します。通常は0で設定されます。
filemode ファイルのパーミッション管理を表します。実行ビットの変更を追跡 or 無視するか設定します。
bare ベアリポジトリかどうかを表します。ベアリポジトリはワーキングディレクトリを持たず、ノンベアリポジトリはワーキングディレクトリを持っています。
logallrefupdates .git/logs配下に全ての参照の更新を記録するかどうかを表します。
ignorecase ファイルシステム上の大文字と小文字を区別するかどうかを表します。
precomposeunicode MacOSでのファイル名のUnicode正規化をどのように扱うかを表します。

hooks

特定のイベントに基づいて自動でスクリプトを実行できます。
Gitには複数のフックがあり、それぞれがGitワークフローの異なるステージでトリガーされる仕様です。いくつかのフックを紹介します。

フック名 概要
pre-commit コミットメッセージが入力される前に実行されます。 コミットされるスナップショットを検査したり、テストが実行できるか確認したり、何かしらコードを検査する目的で使用されます。
commit-msg コミットを確定する前に実行されます。コミットメッセージがチームの標準に準拠しているかチェックするのに適しています。
prepare-commit-msg コミットメッセージエディターが起動する直前、デフォルトメッセージが生成された直後に実行されます。デフォルトメッセージを、コミットの作者の目に触れる前に編集できます。

objects

リポジトリに関連付けられたオブジェクトを格納する格納領域 (オブジェクトストア) です。
Gitのコア部分がオブジェクトで、シンプルなキー・バリュー型データストアで構成されています。
合計4種類 (blob, tree, commit, tag) 存在し、キーはSHA-1ハッシュで採番されます。.git/objects配下にblob, tree, commitオブジェクト、.git/refs/tags配下にtagオブジェクトが格納されます。まずは、.git/objects配下の3オブジェクトを紹介します。

オブジェクト名 概要
blob ファイルを表すオブジェクトです。ファイル内容のみ格納し、ファイル名は格納されません。ファイル内容が異なる場合は、新規のblobオブジェクトが生成されます。対象のファイル名が異なっていてもファイル内容が同じであれば、同じハッシュ値が採番され、効率が良いファイル管理を実現しています。
tree ディレクトリを表すオブジェクトです。blobオブジェクトや他のtreeオブジェクトのポインタ・ファイル名・ディレクトリ名を格納します。これらを使用することでディレクトリツリーを実現します。
commit コミットを表すオブジェクトです。変更時のメタデータを格納します。コミットの日付、ログメッセージ、コミッター等のメタデータとディレクトリ・ファイル情報を紐づけるため、treeオブジェクトへの参照を格納します。

refs

特定のcommitオブジェクトを指すポインタを管理しています。
.git/refs配下のディレクトリ構成は下記になっていて、tagオブジェクトと各branch (ローカルブランチ、リモートブランチ等) が格納されています。順に見ていきましょう。

$ tree .git/refs/
.git/refs/
├── heads
│   └── main
├── remotes
│   └── origin
│       ├── HEAD
│       └── main
└── tags

5 directories, 3 files

tag

まずは、4つ目のオブジェクトであるtagを紹介します。

オブジェクト名 概要
tag コメント付きタグを表すオブジェクトです。タグ名やコメント等の情報とオブジェクトへのポインタを格納します。

branch

次にbranchを見ていきます。
branchはローカルとリモートで別々に管理されています。

ディレクトリ名 概要
heads ローカルブランチを管理します。
remotes リモートブランチを管理します。

中身を確認するとcommitハッシュ値が格納されています。
heads, remotesどちらも各ブランチ名のファイルが存在し、その中に最新のcommitハッシュ値が格納されます。

$ cat .git/refs/heads/main
8ab0b23f892bf0d9582f6d434acb1de9e2fc2f8d # ローカルブランチの最新commitハッシュ値

$ cat .git/refs/remotes/origin/main
8ab0b23f892bf0d9582f6d434acb1de9e2fc2f8d # リモートブランチの最新commitハッシュ値

$ git log -n 1
commit 8ab0b23f892bf0d9582f6d434acb1de9e2fc2f8d (HEAD -> main, origin/main, origin/HEAD) # 同様のcommitハッシュ値を差している
Author: noru <sample@exsample.com>
Date:   Wed Oct 9 20:00:00 2024 +0900

    README追加

また、.git/refs/remotes/origin/HEADにはリモートブランチのシンボリック参照が格納されています。

$ cat .git/refs/remotes/origin/HEAD
ref: refs/remotes/origin/main

Gitコマンド実行時の挙動確認

.git配下の構造がある程度把握できたので、実際にgitコマンドを実行して挙動を確認していきましょう。git init実行後のまっさらな状態から差分を見ていきます。

$ tree .git/
.git/
├── FETCH_HEAD
├── HEAD
├── config
├── hooks
│   ├── commit-msg
│   ├── pre-commit
│   └── prepare-commit-msg
├── objects
│   ├── info
│   └── pack
└── refs
    ├── heads
    └── tags

8 directories, 6 files

git add

実行すると下記が行われます。

  • blobオブジェクトの生成
  • indexファイルの生成・更新

README.mdを作成&ステージングに上げて確認してみましょう。

$ git add .

$ git status
On branch main

No commits yet

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)
	new file:   README.md

$ tree .git/
.git/
├── FETCH_HEAD
├── HEAD
├── config
├── hooks
│   ├── commit-msg
│   ├── pre-commit
│   └── prepare-commit-msg
├── index # git addでinxexが生成された
├── objects
│   ├── 1a
│   │   └── ca66460224ce78bc701c2cdaebdf2fb6941c73 # git addでblobオブジェクトが生成された
│   ├── info
│   └── pack
└── refs
    ├── heads
    └── tags

9 directories, 8 files

blobオブジェクトの生成

blobオブジェクトを確認してみます。
-tオプションでオブジェクトの種類、-pオプションで中身を出力できます。

$ git cat-file -t 1aca66460224ce78bc701c2cdaebdf2fb6941c73
blob

$ git cat-file -p 1aca66460224ce78bc701c2cdaebdf2fb6941c73
# READMEの中身です%                         

indexファイルの生成・更新

ステージングされたファイルは、.git/indexに置かれます。中身を確認してみましょう。

$ git ls-files --stage
100644 1aca66460224ce78bc701c2cdaebdf2fb6941c73 0	README.md

1aca66460224ce78bc701c2cdaebdf2fb6941c73の部分が生成されたblobオブジェクトのハッシュ値です。.git/objects配下を確認すると生成されたディレクトリとファイル名が1aca66460224ce78bc701c2cdaebdf2fb6941c73と一致します。
検索効率向上のために、先頭2文字の1aがディレクトリとして生成され、その中に残りのca66460224ce78bc701c2cdaebdf2fb6941c73がファイルとして置かれます。

git commit

実行すると下記が行われます。

  • COMMIT_EDITMSGにコミットメッセージを格納
  • オブジェクト生成
    • tree
    • commit
  • refs配下のコミットハッシュを更新
  • logs生成・変更履歴を更新

実際にcommitして差分を確認してみましょう。

$ git commit -m "first commit"
[main (root-commit) b30cc67] first commit
 1 file changed, 1 insertion(+)
 create mode 100644 README.md

$ tree .git/
.git/
├── COMMIT_EDITMSG # git commitで生成された
├── FETCH_HEAD
├── HEAD
├── config
├── hooks
│   ├── commit-msg
│   ├── pre-commit
│   └── prepare-commit-msg
├── index
├── logs # git commitで生成された
│   ├── HEAD
│   └── refs
│       └── heads
│           └── main
├── objects
│   ├── 03
│   │   └── e7ce4d1727e3355c89e7407f301606d499e726
│   ├── 91
│   │   └── 9442a4e2b40579c4fecf5b2680f59de861579e # git commitで生成された
│   ├── b3
│   │   └── 0cc67360f2f1854fa973d71c2b633929e1da92 # git commitで生成された
│   ├── info
│   └── pack
└── refs
    ├── heads
    │   └── main
    └── tags

14 directories, 14 files

COMMIT_EDITMSGにコミットメッセージを格納

最新のコミットメッセージが格納されます。
コミットメッセージを書き換えると、.git/COMMIT_EDITMSGの中身も更新されます。

$ cat .git/COMMIT_EDITMSG
first commit

オブジェクト生成

生成された.git/objectsのオブジェクトタイプを確認してみましょう。
コミットハッシュ値は、919442a4e2b40579c4fecf5b2680f59de861579eb30cc67360f2f1854fa973d71c2b633929e1da92です。

$ git cat-file -t 919442a4e2b40579c4fecf5b2680f59de861579e
tree

$ git cat-file -t b30cc67360f2f1854fa973d71c2b633929e1da92
commit

treeオブジェクトとcommitオブジェクトが生成されていますね。
今度は各オブジェクトの中身を確認します。まずはtreeオブジェクトから見てみましょう。

$ git cat-file -p 919442a4e2b40579c4fecf5b2680f59de861579e
100644 blob 03e7ce4d1727e3355c89e7407f301606d499e726	README.md

blobオブジェクトへの参照を持っていることが分かります。
treeオブジェクトは、blobオブジェクトとtreeオブジェクトの参照を持っており、ツリー構造を表現します。また、100644の部分はパーミッションモードを意味しています。

モード名 概要
100644 実行できないファイル
100755 実行可能なファイル
040000 ディレクトリ
120000 シンボリックリンク
160000 Gitlink

次はcommitオブジェクトを見ていきましょう。

$ git cat-file -p b30cc67360f2f1854fa973d71c2b633929e1da92
tree 919442a4e2b40579c4fecf5b2680f59de861579e
author noru <sample@exsample.com> 1728623994 +0900
committer noru <sample@exsample.com> 1728623994 +0900

first commit

treeオブジェクト (919442a4e2b40579c4fecf5b2680f59de861579e) と紐づいていることが分かります。commitするとリポジトリ内の全てのtreeオブジェクトとblobオブジェクトを自動で生成してスナップショットを撮っています。そのため、git checkoutを実行したときにコード状態を迅速に戻すことができます。他にもコミットメッセージやユーザー情報等が格納されています。

refs配下のコミットハッシュを更新

.git/refs配下のheadsはローカルブランチ、remotosにはリモートブランチが入り、それぞれ最新のコミットハッシュが格納されていると解説しました。commitされたので、.git/refs/heads配下のコミットハッシュも更新されます。

$ cat .git/refs/heads/main
b30cc67360f2f1854fa973d71c2b633929e1da92

logs配下の変更履歴を更新

commitしたことで.git/logsが生成されました。
Gitリポジトリの参照履歴が格納されます。.git/logs配下のパスは下記となっています。

.git/logs配下のパス 概要
HEAD 現在のHEADの変更履歴を格納します。
refs/heads/ 各ローカルブランチの変更履歴を格納します。
refs/remotes/ 各リモートブランチの変更履歴を格納します。
stash スタッシュの変更履歴を格納します。

生成された.git/logs/refs/heads/mainの中身を覗いてみましょう。

$ cat .git/logs/refs/heads/main
0000000000000000000000000000000000000000 b30cc67360f2f1854fa973d71c2b633929e1da92 noru <sample@exsample.com> 1728623994 +0900	commit (initial): first commit

初回コミットなので、直前のコミット履歴は存在しません。
0000000000000000000000000000000000000000は、直前のコミットが存在しないことを表しています。また、b30cc67360f2f1854fa973d71c2b633929e1da92の部分はcommitハッシュ値を表しています。

git remote add

add, commitとローカルの挙動を確認してきました。
次はリモートにpushしてみたいと思います。ですが、git initでリポジトリを作成したので、リモート設定をしていません。git remote addを実行して.git/configにリモート設定を追加します。

$ git remote add origin git@github.com:noru/sample-git.git

$ cat .git/config
[core]
	repositoryformatversion = 0
	filemode = true
	bare = false
	logallrefupdates = true
	ignorecase = true
	precomposeunicode = true
[remote "origin"] # git remote addで追加された
	url = git@github.com:noru/sample-git.git
	fetch = +refs/heads/*:refs/remotes/origin/*

git push

実行すると下記が行われます。

  • config更新
  • refs/remotes生成・更新
  • logs/refs/remotes生成

実際にpushして差分を確認してみましょう。

$ git push origin main
Enumerating objects: 3, done.
Counting objects: 100% (3/3), done.
Writing objects: 100% (3/3), 238 bytes | 238.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
To github.com:noru/sample-git.git
 * [new branch]      main -> main

$ tree .git/
.git/
├── COMMIT_EDITMSG
├── FETCH_HEAD
├── HEAD
├── config # git pushで更新された
├── hooks
│   ├── commit-msg
│   ├── pre-commit
│   └── prepare-commit-msg
├── index
├── logs
│   ├── HEAD
│   └── refs
│       ├── heads
│       │   └── main
│       └── remotes # git pushで生成された
│           └── origin
│               └── main
├── objects
│   ├── 03
│   │   └── e7ce4d1727e3355c89e7407f301606d499e726
│   ├── 91
│   │   └── 9442a4e2b40579c4fecf5b2680f59de861579e
│   ├── b3
│   │   └── 0cc67360f2f1854fa973d71c2b633929e1da92
│   ├── info
│   └── pack
└── refs
    ├── heads
    │   └── main
    ├── remotes # git pushで生成された
    │   └── origin
    │       └── main
    └── tags

18 directories, 16 files

config更新

.git/configにリモートブランチの情報が追加されていることが確認できます。

$ cat .git/config
[core]
	repositoryformatversion = 0
	filemode = true
	bare = false
	logallrefupdates = true
	ignorecase = true
	precomposeunicode = true
[remote "origin"]
	url = git@github.com:noru/sample-git.git
	fetch = +refs/heads/*:refs/remotes/origin/*
[branch "main"] # git pushで追加された
	remote = origin
	merge = refs/heads/main

refs/remotes生成・更新

リモートにmainブランチをpushしたので、.git/refs/remotes/origin/mainが生成されました。中身を確認してみましょう。

$ cat .git/refs/remotes/origin/main
b30cc67360f2f1854fa973d71c2b633929e1da92

commitしたハッシュ値と同じであることが分かります。

$ git cat-file -p b30cc67360f2f1854fa973d71c2b633929e1da92
tree 919442a4e2b40579c4fecf5b2680f59de861579e
author noru <sample@exsample.com> 1728623994 +0900
committer noru <sample@exsample.com> 1728623994 +0900

first commit

logs/refs/remotes生成・更新

.git/logs配下にリモートブランチのディレクトリとファイルが生成されました。
中身はコミットハッシュ値が格納されています。

$ cat .git/logs/refs/remotes/origin/main
0000000000000000000000000000000000000000 b30cc67360f2f1854fa973d71c2b633929e1da92 noru <sample@exsample.com> 1728804161 +0900	update by push

おわりに

Gitのディレクトリ構成を把握し、実際にコマンドを実行して差分を確認することで、内部の仕組みや挙動の理解度が深まりました。皆さまのGitのトラブルシューティングに役立つと嬉しいです。最後まで読んでいただきありがとうございました!

参考

Discussion