🤖

Gitのブランチの実装

2021/08/31に公開

はじめに

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

HEADとブランチの実体

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

HEADrefs/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 addgit 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と、HEADmasterを指していることが明示されています。

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

先ほどと異なり、HEADmasterの間の矢印が消えました。HEADの中身を見てみましょう。

$ cat .git/HEAD
c9503326279796b24be86bdf9beb01c1af2d2b95

HEADが直接コミットを指していることがわかります。

detached_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

HEADbranchを指し、branchmasterc950332を指している状態です。ファイルの中身も確認しましょう。

$ 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が増え、masterbranchと同じコミットを指していることが表示されました。すなわち、gitgit 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 addgit 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/mastermasterorigin/masterと同じコミットハッシュを指します。

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

なので、git logorigin2/masterも出てきます。

c950332 (HEAD -> master, origin2/master, origin/master) initial commit

まとめ

Gitのブランチの実装を調べてみました。ブランチはファイルとして実装され、ブランチの作成はファイルのコピー、削除はファイルの削除になっています。また、origin/masterみたいなリモートブランチは、originはディレクトリとして実装されています。上流ブランチなどの情報は.git/configにあり、git configで表示できる情報は、そのまま.git/config内のファイルの構造に対応しています。なんというか、すごく「そのまま」実装されている印象ですね。

参考文献

GitHubで編集を提案

Discussion