Gitのブランチの実装
はじめに
Gitのブランチがどう実装されているか見てみましょう、という記事です。実装は今後変更される可能性があります。とりあえず以下はWSL2のUbuntuのGit 2.25.1で動作確認したものです。
HEADとブランチの実体

通常、GitではHEADがブランチを、ブランチがコミットを指しています。例えばカレントブランチがmasterである場合を考えましょう。HEADの実体は.git/HEADというファイルで、masterの実体は.git/refs/heads/masterになっています。それを見ていきましょう。
適当なディレクトリtestを作って、その中でgit initしましょう。
mkdir test
cd test
git init
この時点で.gitが作られ、その中にHEADが作られます。見てみましょう。
$ cat .git/HEAD
ref: refs/heads/master
HEADはrefs/heads/masterを指しているよ、とあります。しかし、git init直後はまだこのファイルはありません。
$ cat .git/refs/heads/master
cat: .git/refs/heads/master: そのようなファイルやディレクトリはありません
この状態でgit logしても「歴史が無いよ」と言われます。
$ git log
fatal: your current branch 'master' does not have any commits yet
さて、適当なファイルを作って、git add、git commitしましょう。
$ echo "Hello" > hello.txt
$ git add hello.txt
$ git commit -m "initial commit"
[master (root-commit) c950332] initial commit
1 file changed, 1 insertion(+)
create mode 100644 hello.txt
初めてgit commitした時点で、masterブランチの実体が作られます。
$ cat .git/refs/heads/master
c9503326279796b24be86bdf9beb01c1af2d2b95
先ほど作られたコミットオブジェクトc950332を指していますね。このように、通常はHEADはブランチのファイルの場所を指し、ブランチのファイルはコミットオブジェクトのハッシュを保存しています。git logで見てみましょう。
$ git log --oneline
c950332 (HEAD -> master) initial commit
HEAD -> masterと、HEADがmasterを指していることが明示されています。
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, master) initial commit
先ほどと異なり、HEADとmasterの間の矢印が消えました。HEADの中身を見てみましょう。
$ cat .git/HEAD
c9503326279796b24be86bdf9beb01c1af2d2b95
HEADが直接コミットを指していることがわかります。

masterに戻りましょう。
$ git switch master
$ cat .git/HEAD
ref: refs/heads/master
.git/HEADの中身がブランチへの参照に戻ります。
ブランチの作成と削除
masterブランチから、もう一つブランチを生やして見ましょう。
git switch -c branch
これで、branchブランチが作られ、masterの指すコミットと同じコミットを指しているはずです。まずはgit logで見てみましょう。
$ git log --oneline
c950332 (HEAD -> branch, master) initial commit
HEADはbranchを指し、branchもmasterもc950332を指している状態です。ファイルの中身も確認しましょう。
$ cat .git/HEAD
ref: refs/heads/branch
$ cat .git/refs/heads/master
c9503326279796b24be86bdf9beb01c1af2d2b95
$ cat .git/refs/heads/branch
c9503326279796b24be86bdf9beb01c1af2d2b95
.git/refs/heads/masterと同じ内容の.git/refs/heads/branchが作成されています。
では、人為的に.git/refs/heads/にもう一つファイルを作ったらどうなるでしょうか?
$ cp .git/refs/heads/master .git/refs/heads/branch2
$ ls .git/refs/heads
branch branch2 master
.git/refs/heads内に、branch2というファイルが作成されました。git logを見てみましょう。
$ git log --oneline
c950332 (HEAD -> branch, master, branch2) initial commit
branch2が増え、masterやbranchと同じコミットを指していることが表示されました。すなわち、gitはgit logが叩かれた時、全てのブランチがどのコミットを指しているか調べています。また、ブランチの作成が、単にファイルのコピーで実装されていることがわかります。
作ったbranch2をgitを使って消しましょう。
$ git branch -d branch2
Deleted branch branch2 (was c950332).
$ ls .git/refs/heads
branch master
問題なく消せます。.git/refs/headsにあったブランチの実体も消えました。つまり、ブランチの削除は単にファイルの削除です。
歴史の削除
git init直後はブランチの実体ファイルが無く、その状態でgit logをすると「一つもコミットが無いよ」と言われました。それを見てみましょう。
現在、カレントブランチはbranchで、最初のコミットc950332を指しています。
$ git log --oneline
c950332 (HEAD -> branch, master) initial commit
branchの実体を消してしまいましょう。
rm .git/refs/heads/branch
もう一度git logをしてみます。
$ git log
fatal: your current branch 'branch' does not have any commits yet
ブランチが無いので、「歴史がない」と判断されます。しかし、インデックスの実体.git/indexは存在するため、git diffはできます。ちょっとファイルを修正してgit diffしてみましょう。
$ echo "Hi" >> hello.txt
$ git diff
diff --git a/hello.txt b/hello.txt
index e965047..2236327 100644
--- a/hello.txt
+++ b/hello.txt
@@ -1 +1,2 @@
Hello
+Hi
この状態でgit add、git commitすることができます。
$ git add hello.txt
$ git commit -m "updates hello.txt"
[branch (root-commit) a35d7e4] updates hello.txt
1 file changed, 2 insertions(+)
create mode 100644 hello.txt
ブランチの実体がなかったため、これが最初のコミット(root-commit)とみなされ、ここでブランチが作成されます。
$ ls .git/refs/heads
branch master
masterに戻っておきましょう。
git switch master
リモートブランチ
リモートブランチも、普通にブランチと同じようにファイルで実装されています。見てみましょう。
まずはリモートブランチ用のベアリポジトリを作ります。一つの上のディレクトリに掘りましょう。
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/masterにしてpushしてやりましょう。
$ git remote add origin ../test.git
$ git push -u origin master
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] master -> master
Branch 'master' set up to track remote branch 'master' from 'origin'.
これで、origin/masterブランチが作成され、masterの上流ブランチとして登録されました。
$ git branch -vva
branch a35d7e4 updates hello.txt
* master c950332 [origin/master] initial commit
remotes/origin/master c950332 initial commit
remotes/origin/masterブランチが作成され、masterブランチの上流がorigin/masterになっています。
さて、remotes/origin/masterの実体は、.git/refs/remotes/origin/masterにあります。そこには、単にコミットハッシュが記録されているだけです。
$ cat .git/refs/remotes/origin/master
c9503326279796b24be86bdf9beb01c1af2d2b95
また、masterの実体も同じコミットハッシュを指しているだけです。
$ cat .git/refs/heads/master
c9503326279796b24be86bdf9beb01c1af2d2b95
では、masterの上流ブランチはどこで管理されているかというと、.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 "master"]
remote = origin
merge = refs/heads/master
このファイルの階層構造はgit configでそのままたどることができます。
$ git config branch.master.remote
origin
$ git config remote.origin.url
url = ../test.git
また、git logは、リモートブランチも調べてくれます。
$ git log --oneline
c950332 (HEAD -> master, origin/master) initial commit
origin/masterが、masterと同じブランチを指していることがわかります。ちなみに、先ほど作ったbranchは、masterと全く歴史を共有していないので、ここには現れません。
もう一つリモートリポジトリを増やしてみましょう。
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 "master"]
remote = origin
merge = refs/heads/master
[remote "origin2"]
url = ../test2.git
fetch = +refs/heads/*:refs/remotes/origin2/*
しかし、まだorigin2の実体は作られていません。
$ tree .git/refs/remotes
.git/refs/remotes
└── origin
└── master
1 directory, 1 file
originの実体がディレクトリで、その下にmasterファイルがありますが、origin2というディレクトリが無いことがわかります。
さて、masterブランチの上流ブランチをorigin2/masterにして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] master -> master
Branch 'master' set up to track remote branch 'master' from 'origin2'.
これでorigin2/masterの実体が作られます。
$ tree .git/refs/remotes
.git/refs/remotes
├── origin
│ └── master
└── origin2
└── master
2 directories, 2 files
そして、origin2/masterがmasterやorigin/masterと同じコミットハッシュを指します。
$ cat .git/refs/remotes/origin2/master
c9503326279796b24be86bdf9beb01c1af2d2b95
なので、git logにorigin2/masterも出てきます。
c950332 (HEAD -> master, origin2/master, origin/master) initial commit
まとめ
Gitのブランチの実装を調べてみました。ブランチはファイルとして実装され、ブランチの作成はファイルのコピー、削除はファイルの削除になっています。また、origin/masterみたいなリモートブランチは、originはディレクトリとして実装されています。上流ブランチなどの情報は.git/configにあり、git configで表示できる情報は、そのまま.git/config内のファイルの構造に対応しています。なんというか、すごく「そのまま」実装されている印象ですね。
Discussion