Gitの中身

はじめに

Gitで管理するプロジェクトには.gitというディレクトリがあり、その中にGitの管理情報が入っている。その中には、全てのコミットや、いろんなバージョンのファイル、ブランチ、タグといった情報が格納されている。Gitを操作するにあたり、この中身がどうなっているかを理解する必要はないし、もし中身を覚えたとしても、操作方法は変わらないまま、内部実装だけ変更になる可能性もある。それでも、Gitの仕組み、特に様々な情報が.gitにどのように格納されているかを知っておくのは二つの理由から有用だと考える。

一つ目の理由は、「物が動く仕組み」を知っておくことが教養だからだ。車を運転するのに、アクセルを踏めば進み、ブレーキを踏めば止まり、ハンドルを回せば曲がることを知っていれば十分だ。しかし、シリンダーにガソリンが噴射され、ピストンで圧縮したところで点火し、爆発する力でピストンが押される、という直線的な動きを作り、それを回転運動に変換してタイヤが回っている、ということくらいは(特に理工系の学生なら)ぼんやりとは知っておいて欲しい。さらに、その点火システム(イグニッションコイル)に電磁誘導が使われていると知れば、「なるほど、学校で習った電磁気の性質がこんなところに使われているのか」と思うことであろう。自分でゼロから作れるほど理解する必要はないが、物やツールをブラックボックスにせず、その中身をぼんやりとでも知っておくのは良いことだ。

もう一つの理由は、「機能は、なんらかの方法で実装されている」という感覚を持って欲しいためだ。我々がツールに求めるのは「機能」であるが、同じ機能であっても複数の実現手段がある。例えば、電子レンジとオーブンは、「どちらも食品を加熱する」という機能を持っているが、その実現方法は異なる。同様に、GitもSubversionはどちらもバージョン管理システムであり、どちらにもブランチやタグという概念があるが、その実装方法は全く異なる。Gitはツールである。ツールであるからには何らかの機能を提供している。その機能、例えばコミットによるスナップショットの保存や、ブランチの切り替えなどが、実際にはどのように実現されているかを見てみるのは無駄にはならないであろう。

以下では、Gitの実装、特に.gitディレクトリの中に何がどのように格納されているか紹介する。その詳細を覚える必要は全くない。しかし、「機能の実現には実装が伴う」ということ、また、Gitの実装が非常に素直であることを実感して欲しい。

.gitディレクトリの中身

まず、.gitの中身を見てみよう。適当なリポジトリでls .gitしてみる。

$ ls .git
COMMIT_EDITMSG  HEAD       branches/  description  index  logs/     packed-refs
FETCH_HEAD      ORIG_HEAD  config     hooks/       info/  objects/  refs/

表示されるファイルはリポジトリの状態によって異なるが、概ね上記のようなファイルやディレクトリが含まれている。このうち、主なものを紹介する。

以下、これらの「中身」について触れてみよう。

Gitのオブジェクト

まずは、.git/objectsの中身を見てみよう。ここにはGitが管理する「オブジェクト」が格納されている。Gitのオブジェクトには、以下の4種類がある。

objects

この講義ではタグについては扱わないので、残りの三つ、blobオブジェクト、treeオブジェクト、コミットオブジェクトについて見てみよう。

blobオブジェクト

blob

blob1オブジェクトは、ファイルを保存するためのオブジェクトだ。その実体は、ファイルにblobというテキストと、ファイルサイズをヘッダ情報として付加し、zlibで圧縮したものだ。

blobオブジェクトを実際に作ってみよう。適当なディレクトリでgit initしてから、適当なファイルを作る。

mkdir blob
cd blob
git init
echo -n "Hello Git" > test.txt

改行が含まれないように、echoに-nオプションをつけている。これをgit addすると対応するblobオブジェクトが作られる。

git add test.txt

この時点で、e51ca0d0b8c5b6e02473228bbf876ba000932e96というblobオブジェクトが作られた。見てみよう。

$ git cat-file -t e51ca0d0b8c5b6e02473228bbf876ba000932e96
blob

$ git cat-file -p e51ca0d0b8c5b6e02473228bbf876ba000932e96
Hello Git

git cat-fileはオブジェクトのハッシュを指定して中身を見るためのコマンドだ。-tはそのオブジェクトのタイプを、-pは中身を表示する。e51ca0d...というオブジェクトはblobオブジェクトであり、中身は「Hello Git」というテキストファイルであることがわかる

このe51ca0d...というオブジェクトの実体は、.git/objectsの中にファイルとして格納されている。

$ ls -1 .git/objects/*/*
.git/objects/e5/1ca0d0b8c5b6e02473228bbf876ba000932e96

Gitはオブジェクトのファイル名の頭二文字をディレクトリにして、残りをその下のファイルとして保存する。したがって、e51ca0d...というオブジェクトは、.git/objects以下のe5ディレクトリの下に、1ca0d...というファイル名で保存される。

このblobオブジェクトのファイル名は、対象となるファイルの頭にblob ファイルサイズ\0をつけたもののSHA-1ハッシュ値だ。ハッシュ値とは、ハッシュ関数にデータを入力した時の出力であり、ハッシュ関数とは、任意の長さのデータから、(多くの場合)固定長の長さの値を得るための操作のことだ。SHAは「Secure Hash Algorithm」の略であり、SHA-1(シャーワン)はSHAシリーズのうちの一つである。

ハッシュとは、以下のような性質を持つものだ。

特に二番目の性質を「強衝突耐性」と呼ぶ。例えばメッセージとハッシュ値を両方展開した時、もしメッセージが改変されていればハッシュ値が変わるから改竄がバレる。しかし、同じハッシュ値を持つ別の入力を作ることができれば、データを改竄してもバレない。

SHA-1の強衝突耐性は既に突破されているため、セキュリティ用途には向かないが、残りの二つの性質が便利であるため、GitではオブジェクトのIDとしてSHA-1ハッシュを用いている。SHA-1は任意の入力に対して160ビットの出力を返す。16進数は0からFまでの16種類の数値で表現され、1桁が4ビットであるから、160ビットを16進数表記すると40桁となる。これがGitのコミットハッシュが0からFまでの16種類の文字を使って40桁となる理由だ。Gitではハッシュ値を全桁指定する必要はなく、他と区別が付く長さだけ指定すれば良い。通常、先頭7桁も取れば十分なので、git log --onelineなどでは7桁だけ表示される。

さて、このハッシュ値を実際に作ってみよう。そのためには、test.txtの冒頭にblob 9\0というヘッダを付けた内容のSHA-1ハッシュ値を求めればよい。なお、blob 9\0blobはblobオブジェクトであること、9はファイルサイズ、\0はヌル文字と呼ばれ、ヘッダと中身の境界を表現している。SHA-1ハッシュを得るにはshasumを用いる。

$ { echo -en 'blob 9\0';cat test.txt;} | shasum
e51ca0d0b8c5b6e02473228bbf876ba000932e96 *-

確かにe51ca0dというコミットハッシュが得られた。なお、Gitにはヘッダを付けてハッシュを得るコマンドgit hash-objectが用意されている。

$ git hash-object test.txt
e51ca0d0b8c5b6e02473228bbf876ba000932e96

同じハッシュ値が得られた。git cat-filegit hash-objectという、普段使わないが、普段使うコマンドの裏で実行されている低レベルなコマンドを 配管コマンド(plumbing commands) と呼ぶ。一方、普段我々が使うgit addgit commitなどのコマンドを 磁器コマンド (porcelain commands) と呼ぶ。これは、磁器とはトイレの便器のことで、Gitをトイレだと思った時、我々が普段使うコマンドが外に出ている便器、普段見ることのない低レベルなコマンドを下水などの配管にたとえたものだ。

細かいことはともかく、「Gitで良く出てくる英数字のIDはSHA-1ハッシュである」とだけ覚えておくと良い。

さて、ファイル名はSHA-1ハッシュであった。中身はファイルをヘッダ込みでzlibで圧縮したものだ。例えばPythonで実装するならこんな感じになる。

import zlib
content = "Hello Git" # ファイルの中身

# ヘッダ付与
store = f"blob {len(content)}\0{content}".encode("utf-8")

data = zlib.compress(store, level=1) # 圧縮
print(bytes.hex(data))      # 中身の表示

Hello Gitという中身を持つファイルに、blob 9\0というヘッダを付与して、zlib.compressで圧縮したバイト列を表示するスクリプトだ。実行してみよう。

$ python3 test.py
78014bcac94f52b064f048cdc9c95770cf2c01002b750531

先ほど作成したblobオブジェクトの中身もダンプしてみよう。

$ od -tx1 .git/objects/e5/1ca0d0b8c5b6e02473228bbf876ba000932e96
0000000 78 01 4b ca c9 4f 52 b0 64 f0 48 cd c9 c9 57 70
0000020 cf 2c 01 00 2b 75 05 31
0000030

完全に一致していることがわかると思う。

まとめると、

である。意外に単純であることが実感できたであろうか?

コミットオブジェクト

コミットオブジェクトは、コミット、すなわちスナップショットを保存するためのものだ。先ほど、git addした状態で止めていたのを、コミットしてみよう。

$ git commit -m "initial commit"
[main (root-commit) ca70291] initial commit
 1 file changed, 1 insertion(+)
 create mode 100644 test.txt

コミットハッシュca70291を持つコミットが作られた。これに対応するオブジェクトがコミットオブジェクトだ。いま、オブジェクトが何個できたか見てみよう。

$ ls -1 .git/objects/*/*
.git/objects/ca/70291031230dde40264d62b6e8d2424e2c9366
.git/objects/dd/1d7ee1e23a241a3597a0d0be5139a997fc29c8
.git/objects/e5/1ca0d0b8c5b6e02473228bbf876ba000932e96

.git/objects以下に3つオブジェクトができている。このうち、e51ca0dtest.txtに対応するblobオブジェクト、ca70291は今作ったコミットオブジェクト、もう一つのdd1d7eeは後述するtreeオブジェクトであり、コミットが保持するスナップショットを表現する 。blobオブジェクトやtreeオブジェクトは、同じ中身であれば同じハッシュ値を持つ。一方、コミットオブジェクトのハッシュはぶつかっては困るので、毎回異なるものになる。

さっき作ったコミットオブジェクトca70291のタイプを見てみよう。ちなみに、先ほど述べたように、ハッシュ値は他と区別が付けば40桁全てを指定する必要はない。

$ git cat-file -t ca70291
commit

確かにコミットオブジェクトになっている。この表示からca70291はコミットオブジェクトであることがわかる。コミットオブジェクトは、以下の情報をまとめたものだ。

中身を見てみよう。

$ git cat-file -p ca70291
tree dd1d7ee1e23a241a3597a0d0be5139a997fc29c8
author H. Watanabe <kaityo256@example.com> 1632060650 +0900
committer H. Watanabe <kaityo256@example.com> 1632060650 +0900

initial commit

dd1d7eeというtreeオブジェクト、作成者、コミットメッセージを含んでいることがわかる。なお、これはroot commitなので、親コミットの情報は持っていない。適当に修正してコミットしてみよう。

$ echo "Hello commit object" >> test.txt
$ git commit -am "update"
[main 1f620eb] update
 1 file changed, 1 insertion(+), 1 deletion(-)

新しく1f620ebというコミットができた。中身を見てみよう。

$ git cat-file -p 1f620eb
tree 55e11d02569af14b5d29fe56fd44c1cc32c55e72
parent ca70291031230dde40264d62b6e8d2424e2c9366
author H. Watanabe <kaityo256@example.com> 1630738892  +0900
committer H. Watanabe <kaityo256@example.com> 1630738892 +0900


update
commit.png

スナップショットを表すtreeオブジェクトがdd1d7eeから55e11d0に更新され、新たに親コミットとして、先ほどのca70291が保存されている。

マージにより作られたマージコミットの場合は、二つの親コミットの情報を含んでいる。いま、こんな歴史を持つリポジトリを考えよう。

$ git log --graph --pretty=oneline
*   f4baa057ce89467a2faced36229da02799c9e394 (HEAD -> main) Merge branch 'branch'
|\
| * 6aecd68aa423651edda9d22e20925314ff3e8386 (branch) update
* | 953cb6056e5f0437f0d4e102f232d8eb705f6428 adds test2.txt
|/
* 6db4350c6ebd75338ac4bc2eb2a2924895a0c73b initial commit

root commitである6db4350から6aecd68953cb60が分岐し、マージされてf4baa05になっている。

merge.png

この最後のマージコミットf4baa05の中身を見てみよう。

$ git cat-file -p f4baa05
tree 706a1741c1d94977ba496449d80ab848ca945e14
parent 953cb6056e5f0437f0d4e102f232d8eb705f6428
parent 6aecd68aa423651edda9d22e20925314ff3e8386
author H. Watanabe <kaityo256@example.com> 1630743012 +0900
committer H. Watanabe <kaityo256@example.com> 1630743012 +0900

Merge branch 'branch'

スナップショットを保存するtreeオブジェクト706a174の他に、二つの親コミット953cb606aecd68が保存されていることがわかる。

treeオブジェクト

treeオブジェクトは、ディレクトリに対応するオブジェクトだ。先ほどのblobオブジェクトの作り方を見てわかるように、blobオブジェクトはファイル名を保存していない。blobオブジェクトとファイル名を対応させるのもtreeオブジェクトの役目だ。また、コミットオブジェクトが格納するのは、スナップショット全体を表現するtreeオブジェクトである。

treeオブジェクトがディレクトリに対応することを見るため、適当にディレクトリを含むリポジトリを作ってみよう。

mkdir tree
cd tree
git init
mkdir dir1 dir2
echo "file1" > dir1/file1.txt
echo "file2" > dir2/file2.txt
echo "README" > README.md
git add README.md dir1 dir2

コミットしてみる。

$ git commit -m "initial commit"
[main (root-commit) 662458a] initial commit
 3 files changed, 3 insertions(+)
 create mode 100644 README.md
 create mode 100644 dir1/file1.txt
 create mode 100644 dir2/file2.txt

これで、コミットオブジェクト(662458a)が作られた。中身を見てみよう。

$ git cat-file -p 662458a
tree 193fea0500b331a7ccb536aa691d8eb7df8afd13
author H. Watanabe <kaityo256@example.com> 1630737694 +0900
committer H. Watanabe <kaityo256@example.com> 1630737694 +0900

initial commit

treeオブジェクトとコミットメッセージ等の情報を含んでいることがわかる。root commitなので、親コミットの情報はない。同じ手順を踏めば、コミットハッシュは異なっても、同じtreeオブジェクトができているはずだ。treeオブジェクト193fea0は、このコミットのスナップショットを保存している。見てみよう。

$ git cat-file -p 193fea0
100644 blob e845566c06f9bf557d35e8292c37cf05d97a9769    README.md
040000 tree 0b9f291245f6c596fd30bee925fe94fe0cbadd60    dir1
040000 tree 345699cffb47ac20257e0ce4cebcbfc4b2a7f9e3    dir2

ファイルREADME.mdに対応するblobオブジェクトと、ディレクトリdir1dir2に対応するtreeオブジェクトが含まれている。二つのtreeオブジェクトも見てみよう。

$ git cat-file -p 0b9f291
100644 blob e2129701f1a4d54dc44f03c93bca0a2aec7c5449    file1.txt
$ git cat-file -p 345699c
100644 blob 6c493ff740f9380390d5c9ddef4af18697ac9375    file2.txt

ファイル構造とオブジェクトの構造を図示すると以下のようになる。

tree.png

さて、blobオブジェクトやtreeオブジェクトにはファイル名、ディレクトリ名は含まれておらず、treeオブジェクトは、自分が管理するオブジェクトと名前の対応を管理している。

また、blobオブジェクトのハッシュは、ファイルサイズと中身だけで決まり、ファイル名は関係ない。したがって、Gitは「同じ中身だけど、異なるファイル名」を、同じblobオブジェクトで管理する。これを確認してみよう。

mkdir synonym
cd synonym
git init
echo "Hello" > file1.txt
cp file1.txt file2.txt
git add file1.txt file2.txt

これで、中身が同じファイルfile1.txtfile2.txtがステージングされた。コミットしてみる。

$ git commit -m "initial commit"
[main (root-commit) 75470e6] initial commit
 2 files changed, 2 insertions(+)
 create mode 100644 file1.txt
 create mode 100644 file2.txt

コミットオブジェクト75470e6ができたので、中身を見てみよう。

$ git cat-file -p 75470e6
tree e79a5d99a8e5cd5da0260866b85df60052fd045e
author H. Watanabe <kaityo256@example.com> 1630745015 +0900
committer H. Watanabe <kaityo256@example.com> 1630745015 +0900

initial commit

treeオブジェクトe79a5d9ができている。中身を見てみよう。

$ git cat-file -p e79a5d9
100644 blob e965047ad7c57865823c7d992b1d046ea66edf78    file1.txt
100644 blob e965047ad7c57865823c7d992b1d046ea66edf78    file2.txt

全く同じblobオブジェクトに別名を与えていることがわかる。

Gitの参照

Gitオブジェクトの次は、Gitの参照を見てみよう。参照はブランチやタグなどで.git/refs以下に格納されている。

HEADとブランチの実体

head

通常、GitではHEADがブランチを、ブランチがコミットを指している。HEADの実体は.git/HEADというファイルだ。ブランチはgit/refsにブランチ名と同名のファイルとして保存されている。例えばmainブランチの実体は.git/refs/heads/mainというファイルだ。この関係を見てみよう。

適当なディレクトリtestを作って、その中でgit initする。

mkdir test
cd test
git init

この時点で.gitが作られ、その中にHEADが作られた。ファイルの中身を見てみよう。

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

このref: refs/heads/mainは、「HEADは今refs/heads/mainを指しているよ」という意味だ。しかし、git init直後はまだこのファイルは存在しない。

$ cat .git/refs/heads/main
cat: .git/refs/heads/main: そのようなファイルやディレクトリはありません

さて、適当なファイルを作って、git addgit commitしてみよう。

$ echo "Hello" > hello.txt
$ git add hello.txt
$ git commit -m "initial commit"
[main (root-commit) c950332] initial commit
 1 file changed, 1 insertion(+)
 create mode 100644 hello.txt

初めてgit commitした時点で、mainブランチの実体が作られる。

$ cat .git/refs/heads/main
c9503326279796b24be86bdf9beb01c1af2d2b95

mainブランチの実体であるmainというファイルには、コミットオブジェクトのハッシュが入っている。今回のケースでは、先ほど作られたコミットオブジェクトc950332が保存されている。このように、通常はHEADはブランチのファイルの場所を指し、ブランチのファイルはコミットオブジェクトのハッシュを保存している。git logで見てみよう。

$ git log --oneline
c950332 (HEAD -> main) initial commit

HEAD -> mainと、HEADmainを指していることが明示されている。

Detached HEAD状態

さて、直接コミットハッシュを指定してgit checkoutしてみよう。

$ git checkout c950332
Note: switching to 'c950332'.

You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by switching back to a branch.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -c with the switch command. Example:

  git switch -c <new-branch-name>

Or undo this operation with:

  git switch -

Turn off this advice by setting config variable advice.detachedHead to false

HEAD is now at c950332 initial commit

これで、HEADがブランチを介してではなく、直接コミットを指している状態、いわゆる頭が取れた(detached HEAD)状態になった。この状態でgit logを見てみる。

$ git log --oneline
c950332 (HEAD, main) initial commit

先ほどと異なり、HEADmainの間の矢印が消えた。HEADファイルの中身を見てみよう。

$ cat .git/HEAD
c9503326279796b24be86bdf9beb01c1af2d2b95

先ほどはref: refs/heads/mainと、mainブランチの実体ファイルへのパスが格納されていたが、今はHEADが直接コミットを指していることを反映して、そのコミットハッシュが保存されている。

detached_head

mainブランチに戻ろう。

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

.git/HEADの中身がブランチへの参照に戻っている。

ブランチの作成と削除

mainブランチから、もう一つブランチを生やしてみよう。

git switch -c branch

これで、branchブランチが作られ、mainの指すコミットと同じコミットを指しているはずだ。まずはgit logで見てみよう。

$ git log --oneline
c950332 (HEAD -> branch, main) initial commit

HEADbranchを指し、branchmainc950332を指している状態になっている。ファイルの中身も確認しよう。

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

$ cat .git/refs/heads/main
c9503326279796b24be86bdf9beb01c1af2d2b95

$ cat .git/refs/heads/branch
c9503326279796b24be86bdf9beb01c1af2d2b95

.git/refs/heads/mainと同じ内容の.git/refs/heads/branchが作成されている。

ここで、人為的に.git/refs/heads/にもう一つファイルを作ってみよう。

$ cp .git/refs/heads/main .git/refs/heads/branch2
$ ls .git/refs/heads
branch  branch2  main

.git/refs/heads内に、branch2というファイルが作成された。git logを見てみると、

$ git log --oneline
c950332 (HEAD -> branch, main, branch2) initial commit

branch2が増え、mainbranchと同じコミットを指していることが表示された。すなわち、gitgit logが叩かれた時、全てのブランチがどのコミットを指しているか調べていることがわかる。また、ブランチの作成が、単にファイルのコピーで実装されていることもわかった。

作ったbranch2をgitを使って消してみよう。

$ git branch -d branch2
Deleted branch branch2 (was c950332).

$ ls .git/refs/heads
branch  main

問題なく消せた。.git/refs/headsにあったブランチの実体も消えた。つまり、ブランチの削除は単にファイルの削除として実装されている。

リモートブランチと上流ブランチ

リモートブランチも、普通にブランチと同じようにファイルで実装されている。まずは一つ上のディレクトリにリモートブランチ用のベアリポジトリを作ろう。

git init --bare ../test.git

ベアリポジトリは、.gitの中身がそのままディレクトリに展開された内容になっている。

$ tree ../test.git
../test.git
├── HEAD
├── branches
├── config
├── description
├── hooks
│   ├── applypatch-msg.sample
│   ├── commit-msg.sample
│   ├── fsmonitor-watchman.sample
│   ├── post-update.sample
│   ├── pre-applypatch.sample
│   ├── pre-commit.sample
│   ├── pre-merge-commit.sample
│   ├── pre-push.sample
│   ├── pre-rebase.sample
│   ├── pre-receive.sample
│   ├── prepare-commit-msg.sample
│   └── update.sample
├── info
│   └── exclude
├── objects
│   ├── info
│   └── pack
└── refs
    ├── heads
    └── tags

9 directories, 16 files

git init直後の.gitディレクトリと同じ中身であることがわかる。

さて、このリポジトリをリモートリポジトリoriginとして登録し、上流ブランチをorigin/mainにしてpushしよう。

$ git remote add origin ../test.git
$ git push -u origin main
Enumerating objects: 3, done.
Counting objects: 100% (3/3), done.
Writing objects: 100% (3/3), 227 bytes | 227.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0)
To ../test.git
 * [new branch]      main -> main
Branch 'main' set up to track remote branch 'main' from 'origin'.

これで、origin/mainブランチが作成され、mainの上流ブランチとして登録された。git branchで見てみよう。

$ git branch -vva
  branch                a35d7e4 updates hello.txt
* main                c950332 [origin/main] initial commit
  remotes/origin/main c950332 initial commit

remotes/origin/mainブランチが作成され、mainブランチの上流がorigin/mainになっていることがわかる。さて、mainブランチの実体は.git/refs/mainというファイルだった。同様に、remotes/origin/mainの実体は、.git/refs/remotes/origin/mainにある。ブランチの名前を(ディレクトリも含めて)そのまま.git/refに展開したような形になっている。.git/refs/remotes/origin/mainの中身は、単にコミットハッシュが記録されているだけだ。

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

また、mainの実体も同じコミットハッシュを指しているだけで、ここに上流ブランチの情報はない

$ cat .git/refs/heads/main
c9503326279796b24be86bdf9beb01c1af2d2b95

mainの上流ブランチは、ブランチの実体ファイルではなく、.git/configというファイルに保存されている。中身を見てみよう。

$ cat .git/config
[core]
        repositoryformatversion = 0
        filemode = true
        bare = false
        logallrefupdates = true
[remote "origin"]
        url = ../test.git
        fetch = +refs/heads/*:refs/remotes/origin/*
[branch "main"]
        remote = origin
        merge = refs/heads/main

このファイルの階層構造はgit configでそのままたどることができる。

$ git config branch.main.remote
origin

$ git config remote.origin.url
url = ../test.git

また、git logは、リモートブランチも調べてくれる。

$ git log --oneline
c950332 (HEAD -> main, origin/main) initial commit

origin/mainが、mainと同じブランチを指していることがわかる。

もう一つリモートリポジトリを増やしてみよう。

git init --bare ../test2.git
git remote add origin2 ../test2.git

これで、.git/configにはorigin2の情報が追加される。

$ cat .git/config
[core]
        repositoryformatversion = 0
        filemode = true
        bare = false
        logallrefupdates = true
[remote "origin"]
        url = ../test.git
        fetch = +refs/heads/*:refs/remotes/origin/*
[branch "main"]
        remote = origin
        merge = refs/heads/main
[remote "origin2"]
        url = ../test2.git
        fetch = +refs/heads/*:refs/remotes/origin2/*

しかし、まだorigin2の実体は作られていません。

$ tree .git/refs/remotes
.git/refs/remotes
└── origin
    └── main

1 directory, 1 file

originの実体がディレクトリで、その下にmainファイルがあるが、origin2というディレクトリはまだ存在しないことがわかる。

ここで、mainブランチの上流ブランチをorigin2/mainにしてpushしてみる。

$ git push -u origin2
Enumerating objects: 3, done.
Counting objects: 100% (3/3), done.
Writing objects: 100% (3/3), 227 bytes | 227.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0)
To ../test2.git
 * [new branch]      main -> main
Branch 'main' set up to track remote branch 'main' from 'origin2'.

このタイミングでorigin2/mainの実体が作られる。

$ tree .git/refs/remotes
.git/refs/remotes
├── origin
│   └── main
└── origin2
    └── main

2 directories, 2 files

そして、origin2/mainmainorigin/mainと同じコミットハッシュを指す。

$ cat .git/refs/remotes/origin2/main
c9503326279796b24be86bdf9beb01c1af2d2b95

したがって、git logorigin2/mainも表示されるようになる

$ git log --oneline
c950332 (HEAD -> main, origin2/main, origin/main) initial commit

インデックス

ワーキングツリーとリポジトリの間に「インデックス」を挟み、コミットの前にステージングを行うのがGitの特徴だ。このインデックスの実体は.git/indexという一つのファイルだ。この中身もちょっと覗いてみよう。

インデックスの実体と中身

適当なディレクトリを掘って、そこにファイルを作り、git initしてみる。

mkdir index_test
cd index_test
echo "My first file" > test.txt
git init

さて、git initした直後は、まだindexは作られていない。

$ ls .git/index
ls: cannot access '.git/index': No such file or directory

しかし、git addするとindexが作られる。

$ git add test.txt
$ ls .git/index
.git/index

また、git add test.txtしたことで、test.txtに対応するblobオブジェクトも作られている。

$ ls -1 .git/objects/*/*
.git/objects/36/3d8b784900d74b3159e8e93a651c0db42629ef

git addは、ファイルをインデックスに登録するコマンドであった。したがって、いまtest.txtがインデックスに登録されたはずだ。インデックスの中身は、git ls-files --stageで見ることができる。

$ git ls-files --stage
100644 363d8b784900d74b3159e8e93a651c0db42629ef 0    test.txt

確かにtest.txtというファイルに対応するblobオブジェクトができている。そのハッシュは363d8b784900d74b3159e8e93a651c0db42629efであり、先ほど.git/objectsに作成されたものだ。

つまり、git add test.txtをした時、Gitは

という作業をしている。

ブランチ切り替えとインデックス

ブランチを切り替えると、インデックスがどうなるか見てみよう。

まずはブランチbranch_aを作り、そこにfile_a.txtを追加、コミットする。

$ git switch -c branch_a
Switched to a new branch 'branch_a'
$ echo "This is A" > file_a.txt
$ git add file_a.txt
$ git commit -m "adds file_a.txt"
[branch_a 41e4b52] adds file_a.txt
 1 file changed, 1 insertion(+)
 create mode 100644 file_a.txt

これで、ワーキングツリーにはtest.txtfile_a.txtの二つのファイルが含まれるようになった。当然、インデックスにも同じファイルが登録されている。

$ git ls-files --stage
100644 e32836f4cedd87510bfd2f145bc0696861fdb026 0    file_a.txt
100644 363d8b784900d74b3159e8e93a651c0db42629ef 0    test.txt

file_a.txtのblobオブジェクトが増えている。これがfile_a.txtのハッシュであることを確認しておこう。

$ git hash-object file_a.txt
e32836f4cedd87510bfd2f145bc0696861fdb026

この状態で、ブランチを切り替えてみよう。まずはmainに戻る。

$ git switch main
Switched to branch 'main'

インデックスを見てみよう。

$ git ls-files --stage
100644 363d8b784900d74b3159e8e93a651c0db42629ef 0    test.txt

mainブランチにはtest.txtしかないので、インデックスにあるのもtest.txtのblobオブジェクトだけだ。

新たなブランチbranch_bを作り、歴史を分岐させよう。

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

ファイルfile_b.txtを追加し、コミットする。

$ echo "This is B" > file_b.txt
$ git add file_b.txt
$ git commit -m "adds file_b.txt"
[branch_b 81085f2] adds file_b.txt
 1 file changed, 1 insertion(+)
 create mode 100644 file_b.txt

git addの時点でfile_b.txtに対応するblobオブジェクトが作られ、インデックスに登録される。インデックスの中身を見てみよう。

$ git ls-files --stage
100644 6a571f63d9d0bce7995b5c08d218370d7ea719a5 0    file_b.txt
100644 363d8b784900d74b3159e8e93a651c0db42629ef 0    test.txt

test.txtfile_b.txtが入っている。

この状態で、branch_aブランチに切り替えて見よう。

$ git switch branch_a
Switched to branch 'branch_a'

ワーキングツリーのファイルがtest.txtfile_a.txtになる。

$ ls
file_a.txt  test.txt

インデックスの中身も連動する。

$ git ls-files --stage
100644 e32836f4cedd87510bfd2f145bc0696861fdb026 0    file_a.txt
100644 363d8b784900d74b3159e8e93a651c0db42629ef 0    test.txt
index

つまり、ブランチ切り替えの際、ワーキングツリーだけでなく、インデックスも切り替えられている。

まとめ

Gitのオブジェクト、ブランチ、そしてインデックスの実装について見てみた。Gitのオブジェクトは、ファイルがblobオブジェクトに、ディレクトリがtreeオブジェクトに対応し、コミットオブジェクトは、スナップショットを表すtreeオブジェクトと、親コミットのハッシュ、そしてコミットの作者やメッセージの情報をまとめたものだ。オブジェクトの名前はSHA-1ハッシュ値になっており、blobオブジェクトやtreeオブジェクトは中身からハッシュ値が決まるため、同じ内容なら同じハッシュ値となる。

ブランチはファイルとして実装され、ブランチの作成はファイルのコピー、削除はファイルの削除で実装されている。また、origin/mainみたいなリモートブランチは、originはディレクトリとして実装されている。上流ブランチなどの情報は.git/configにあり、git configで表示できる情報は、そのまま.git/config内のファイルの構造に対応している。

インデックスは.git/indexというファイルが実体であり、その中身は「blobオブジェクトの目録」であった。ブランチを切り替えるとインデックスの中身も切り替わる。そしてワーキングツリーがきれいな状態の場合は、ワーキングツリーとインデックスの中身は一致している。

以上を見て、非常に「素直」に実装されていることがわかったと思う。よくわからないコミットハッシュや、.gitディレクトリの中身も、上記の知識を持ってから見てみると「なるほどな」とわかった気になるものだ。

上記のことを完全に理解する必要はない。しかし、自動車のボンネットを開けた時に、これがエンジンで、ここがバッテリーで、ということくらいはわかるであろう。同じくらいの解像度でGitが裏で何をやっているかが、ぼんやりと分かればそれでよい。


  1. blobはBinary Large OBjectsの略らしい。↩︎