こんにちは@a_suenamiと申します。
これはGit Advent Calendar 2012の17日目の記事になります。
前日はid:akiyokoさんのGitコマンド総選挙でした。
Gitの内部構造
みなさんはGitがどういう風にデータを管理しているか意識したことはありますか?
コマンドの使い方に関するTipsはよく見かけるのですが、なかなかデータ構造に着目した解説は少ないのが実情かと思います。
そこで本日は僕が以前社内で行ったGit勉強会の話をもとにして、Gitの内部でどのようなデータがやりとりされているのかという話をしたいと思います。以下が以前僕が社内で勉強会をしたときの資料です。
http://www.slideshare.net/asuenami/git-15199548
タイトルの通り、非プログラマ向けの内容なのですが、PART2ではGitの内部構造を擬人化して説明するというちょっとした工夫をしてみました。しかし、この記事をご覧になるような方は大変優秀なプログラマの方々だと思いますので、もう少し具体的なお話をさせていただきます。
Gitはコミットがすべてである
いきなり結論を書きますが、Gitはコミットがすべてです。多少大げさな表現かも知れませんが、それぐらい重要な概念だと思うので、あえて挑戦的な表現にしています。
Gitではgit commit
コマンドによりコミットを作成することができ、各コミットには40桁の一意なハッシュ値がつけられます。
このハッシュ値はいろいろな場面で使われますが一番簡単に確認する方法は以下のようなgit show
コマンドを実行することでしょう。
% mkdir git_demo
% cd git_demo
% git init
Initialized empty Git repository in /extra/home/suenami/public_html/git_demo/.git/
% echo "this is test file." >> test.txt
% git add test.txt
% git commit -m "Add test.txt."
[master (root-commit) a4f4581] Add test.txt.
1 files changed, 1 insertions(+), 0 deletions(-)
create mode 100644 test.txt
% git show
commit a4f4581b5a8f5e1134450b338af4afcd60d5fffb
Author: Akira Suenami <a.suenami@gmail.com>
Date: Mon Dec 17 20:38:56 2012 +0900
Add test.txt.
diff --git a/test.txt b/test.txt
new file mode 100644
index 0000000..5e23ba8
--- /dev/null
+++ b/test.txt
@@ -0,0 +1 @@
+this is test file.
ここで表示されたa4f4581b5a8f5e1134450b338af4afcd60d5fffb
がこのコミットを一意に表すハッシュ値になります。
動詞ではあると同時に名詞でもある「コミット」
コミットは40桁のハッシュ値であらわされるオブジェクトです。git commit
というコマンドがあるので動詞としてはよく使われていると思いますが、意外にオブジェクトとしてのコミットに着目してGitを触っている
人はそんなに多くはないのではないでしょうか。Subversionのようにチェンジセットを管理する方式のバージョン管理システムに対して、Gitはgit commit
を実行する都度、その時点でのスナップショットを保存して管理する方式のため、コミット自体が実体を持つオブジェクトとなります。
よくGit初級者に「commitとpushの違いがわかりません」という質問や相談をよく受けますが、「コミットとはオブジェクトである」という内部構造に着目すれば、commitはローカルリポジトリ上に作成するオブジェクト
であるのに対して、pushはそれをリモートリポジトリに転送する動作であり、明確に違うと言えます。厳密にいえば「コミットする」ではなく「コミットを作成する」が正しい表現だといえるでしょう。
コミットの中身
実際にコミットの中身を見てみましょう。
コミットオブジェクトは物理的には.git/objects/
の下に保存されていますが、通常はそこにあるファイルを直接参照することはありません。git cat-file
というコマンドを使うとオブジェクトの情報を参照することができます。
% git cat-file commit a4f4581b5a8f5e1134450b338af4afcd60d5fffb
tree a49c2ec99edb6405197b782a3cf808b784d78b16
author Akira Suenami <a.suenami@gmail.com> 1355744336 +0900
committer Akira Suenami <a.suenami@gmail.com> 1355744336 +0900
Add test.txt.
先ほどのコミットの中身を参照することができました。コミットは基本的にひとつのツリーオブジェクトを持っています。これはそのコミットにおける最上位のディレクトリのオブジェクトです。ツリーオブジェクトはさらに別のツリーオブジェクトとファイルをあらわすblobオブジェクトを持ちます。ツリーオブジェクトを参照するにはgit ls-tree
コマンドを使います。早速見てみましょう。
% git ls-tree a49c2ec99edb6405197b782a3cf808b784d78b16
100644 blob 5e23ba867ac3d1a82b1f9abb7b869403af49c346 test.txt
先ほど追加した"test.txt"を見つけることができました。正しくblobオブジェクトとして保持されているようです。さらにもう一度git cat-file
コマンドを実行すると"test.txt"の中身も参照することができます。
% git cat-file blob 5e23ba867ac3d1a82b1f9abb7b869403af49c346
this is test file.
さらにもう一つコミットを追加して、そのオブジェクトを参照してみます。
% echo "this is test file v2.0." >> test2.txt
% git add test2.txt
% git commit -m "Add test2.txt."
[master 5bee36a] Add test2.txt.
1 files changed, 1 insertions(+), 0 deletions(-)
create mode 100644 test2.txt
% git cat-file commit HEAD
tree a486a6e4dc672926cdbd35703859492f7d344ee9
parent a4f4581b5a8f5e1134450b338af4afcd60d5fffb
author Akira Suenami <a.suenami@gmail.com> 1355752865 +0900
committer Akira Suenami <a.suenami@gmail.com> 1355752865 +0900
Add test2.txt.
先ほどはなかった新しい項目が追加されていることにお気づきでしょうか。"parent"という項目が新たに表示されるようになりました。これはその名の示す通り、そのコミットの親コミット、つまりひとつ前のコミットをあらわします。該当のリポジトリ中のファーストコミット以外は基本的にこの親コミットを保持します。これによってGitはコミットログを過去へさかのぼれるようにしているのです。
似て非なるコミットたち
さて、ここまで話でコミットがどのようなオブジェクトかということはわかっていただけたかと思います。
Gitのコミットオブジェクトは親コミットへの参照こそ保持しているものの、基本的に各コミットは独立であり、40桁のハッシュ値のみがそのコミットを一意に特定するキーになります。したがって、そのハッシュ値がことなればどんなに似通っているコミットでもGitは違うコミットとして扱います。これを理解していないと時々思わぬ落とし穴にはまることがあります。
よくあるのはgit commit --amend
です。例えば、先ほどのコミットに誤りがあったと仮定して、直前のコミットを書き換えてしまいましょう。
% echo "this is test file v2.0.beta." > test2.txt
% git diff test2.txt
diff --git a/test2.txt b/test2.txt
index 3ffcf9b..169f998 100644
--- a/test2.txt
+++ b/test2.txt
@@ -1 +1 @@
-this is test file v2.0.
+this is test file v2.0.beta.
% git add test2.txt
% git commit --amend
% git show
[master 6495bcc] Add test2.txt.
1 files changed, 1 insertions(+), 0 deletions(-)
create mode 100644 test2.txt
commit 6495bccad6321e785c7564ee48fe9a368f0ae67a
Author: Akira Suenami <a.suenami@gmail.com>
Date: Mon Dec 17 23:01:05 2012 +0900
Add test2.txt.
diff --git a/test2.txt b/test2.txt
new file mode 100644
index 0000000..169f998
--- /dev/null
+++ b/test2.txt
@@ -0,0 +1 @@
+this is test file v2.0.beta.
コミットを示すハッシュ値が5bee36a
から6495bcc
になっています。したがって、この両者のコミットは明確に異なります。親コミットは同じですし、親コミットとの差分もほとんど変わらないにも関わらずです。仮にgit commit --amend
する前のコミットをリモートリポジトリにgit push
してしまっている場合には大変なことになります。
ブランチとは「枝そのもの」ではなく「枝の先端」である
さて、もうひとつ、注目したいのは「ブランチ」です。ブランチ管理とマージの容易さに惚れこんでGitを使い続けている人は多いのではないでしょうか。Gitのブランチが使いやすいのも上記で述べたデータの保持のしかたに起因するところがあります。
ブランチとは日本語では「枝」ですが、Gitにおいては「枝そのもの」ではなく「枝の先端」を意味します。もう少し正確な表現をすると「ある名前付けされたGitの履歴の現時点での先頭のコミットの別名」のことを意味するのです。
以下に例を示します。先ほどgit commit --amend
した最新のコミットからnew_branch
という名前のブランチを作成してみます。
% git checkout -b new_branch
Switched to a new branch 'new_branch'
% git rev-parse new_branch
6495bccad6321e785c7564ee48fe9a368f0ae67a
この時点ではnew_branch
は先ほどと同じコミットを参照していますね。(git rev-parse
は別名をつけられたコミットのハッシュ値を返してくれるコマンドです。)
では、ひとつコミットを追加してみましょう。
% echo "I'm on new_branch." >> test_new_branch.txt
% git add test_new_branch.txt
% git commit -m "commit on new_branch"
[new_branch 2db1ca3] commit on new_branch
1 files changed, 1 insertions(+), 0 deletions(-)
create mode 100644 test_new_branch.txt
% git rev-parse new_branch
2db1ca3278cf73210b6fc277d5ef9b97ed8c76f2
new_branch
が参照しているコミットが変わったことがわかるでしょう。このようにあるブランチの先端であるコミットに新たな子孫コミットが作られた場合、ブランチはその新しいコミットの別名となります。
したがって、ブランチとはあるコミットの履歴全体をあらわす概念でありながら、物理的にはあるたったひとつのコミットのことを指示しているに過ぎず、非常に軽量であるため、マージ作業などが容易なのです。
データ構造の美しさこそがGitの真骨頂
Gitのすばらしいところはここで述べたようなデータ構造と、それによって実現されるパフォーマンスのよさであると僕は思っています。また履歴管理の柔軟性もGitの特徴としてよくあげられますが、それもこのデータ保持のしかたがあってこそのものでしょう。
他方、Gitコマンド体系は非難されることが非常に多いように感じます。Gitをエイリアスを設定せずに使っている人はほとんどいないでしょうし、いまだにGUIツールが乏しいのも事実だと思います。そういった非難の対象にされるデメリットをがありながらも多くの人に使われているほど、データ管理の仕組みは優れていますし、そういう視点で日頃使っているコマンドを見直してみるとまた違ったものが見えてくるのではないかと思います。
さて、翌日はrkmathiさんです。よろしくお願いします。