bonar note

京都のエンジニア bonar の技術的なことや技術的でない日常のブログです。

git における stage, commit, branch

git は難しいと良く言われます。僕もそう思います。個人的には Pro Git Pro Git 日本語版電子書籍公開サイト の説明が最高に分かり易いのでとてもオススメです。

コマンド体系であったりトラブルシューティングであったり色々な視点があると思うのですが、どちらかというと git 特有の世界観が掴めるとぐっと理解が進むような部分があるなと感じていました。初歩的な内容ではありますが、特に、stage, commit, branch に関して「新しく git に入ってきた人が、これを知っておくとスムーズ」と思った物を書いてみます。
(変な所があったらツッコミをお願いします)

stage と local repository

svn 等と同じく git でも多くの場合チーム全員で共有する中央レポジトリがあります。ただ、そこに修正を送り込む前に stage と local repository があるというのが git の特徴的な所です。git に触れ始めた頃はわけもわからず git commit -a していたのですが、stage と local repository をうまく使う事でコミットを整える事が出来るようになります。

以下は、origin という remtote repository が登録された状態で master ブランチを編集しているという想定の図です。

f:id:bonar:20140826002818p:plain

stage は次にコミットするものを一時的にのせておく発射台です。手元(Local change)でファイルを編集し、git add でファイルを stage に追加します。やっぱりやめたという時は git reset で stage から降ろします。修正の一時的な置き場である stage がある事で、修正を意味のある単位に纏める事が出来るのがとても便利です。コミットログも書き易くなります(「今日の作業分」よさらば)。

ちなみに stage, local repository 間の差分は以下のコマンドで確認します。

git diff # 手元の修正と stage の差分
git diff --cached # stage と local repository の差分 (= --staged)

git commit すると stage にある内容が local repository にコミットされます。まだこの時点では全ては自分のマシンの中の出来事なので好きに修正する事が出来ます。コミットした内容を取り消したい場合は戻したい地点のコミットハッシュ(一つ前のコミット)を指定して、

git checkout master
git reset --soft {commit hash}

で local repository の master の状態が指定した地点まで戻ります。--soft はローカルのファイルの状態は変えません(git status で差分が出る)が、--hard だとローカルのファイルもその地点の状態に更新します(変更が失われる)。

この後 git push origin master すると、local repository 内の master ブランチが remote repository の master に送信されます。svn だと local の修正からいきなり remote repository に行く感じなので大分違いますね。

git fetch すると remote repository の内容が local repository の origin/master(repository名/ブランチ名)ブランチに落ちてきます。いきなり local repository の master ブランチに入らないところが特徴的です。この状態で

git diff master..origin/master

することで local/remote repository の master の差を確認する事が出来ます。これを確認した上で

git checkout master
git merge origin/master

すると remote repository の master の内容を local repository に取り込む事が出来ます。この fetch & merge を一度に行うのが git pull です。

コマンドは少しややこしいのですが、どちらかというと上記のような登場人物がいるという事と、その位置関係をイメージとして知っておく事が重要なのかなと思います。

コミットはスナップッショット

特に svn から git に来ると、「svn は revision というスナップショットの集合で歴史が出来るけど、git はコミットという差分の集合で歴史が出来ている」という理解に至りがちな気がしますが、これは正確な理解では無いと思います。git のコミットもスナップショットだからです。

実際にコミットオブジェクトの中身を見るのが分かり易いです。例えば以下のように、README.txt に line1, line2, line3 というテキストを一行ずつコミットしていくようなシチュエーションを考えます。

$ mkdir gitsample
$ cd gitsample
$ git init
Initialized empty Git repository in /Users/bonar/git/gitsample/.git/
$ echo "line 1" >> README.txt; git add README.txt; git commit -m "line 1"
[master (root-commit) c5d5c04] line 1
 1 file changed, 1 insertion(+)
 create mode 100644 README.txt
$ echo "line 2" >> README.txt; git add README.txt; git commit -m "line 2"
[master 8a61b67] line 2
 1 file changed, 1 insertion(+)
$ echo "line 3" >> README.txt; git add README.txt; git commit -m "line 3"
[master 6c946b8] line 3
 1 file changed, 1 insertion(+)
$ git log
commit 6c946b8e1bc1f988b7d795517be3cb62aa963bdb
Author: bonar <bonamonchy@gmail.com>
Date:   Wed Sep 3 22:39:58 2014 +0900

    line 3

commit 8a61b6756863c292d94791e87b8765fd788d18c8
Author: bonar <bonamonchy@gmail.com>
Date:   Wed Sep 3 22:39:51 2014 +0900

    line 2

commit c5d5c04376593fdb76754996d0322dd0005cd797
Author: bonar <bonamonchy@gmail.com>
Date:   Wed Sep 3 22:39:42 2014 +0900

    line 1

例えば line3 のコミットオブジェクトは具体的には .git/objects/6c/946b8e1bc1f988b7d795517be3cb62aa963bdb に保存されています。コミットオブジェクトの中身を詳しく見るには、git cat-file -p でコミットハッシュを指定します。

$ git cat-file -p 6c946b8e1bc1f988b7d795517be3cb62aa963bdb
tree bf3461315080f6ca4e73ab1eb42f3389415758c6
parent 8a61b6756863c292d94791e87b8765fd788d18c8
author bonar <bonamonchy@gmail.com> 1409751598 +0900
committer bonar <bonamonchy@gmail.com> 1409751598 +0900

line 3

tree, parent と、コミットのメタ情報(author, comment)でコミットが構成されていることがわかります。tree というのはその時点でのファイルの一覧とそのハッシュ値が記録されたもので、まさにスナップショットです。この中身も git cat-file で確認する事が出来ます。

$ git cat-file -p bf3461315080f6ca4e73ab1eb42f3389415758c6
100644 blob a92d664bc20a04b1621b1fc893d1196b41182fdf	README.txt
$ git cat-file -p a92d664bc20a04b1621b1fc893d1196b41182fdf
line 1
line 2
line 3

ファイルの中身そのものもハッシュ値と対応する形で blob として .git/object 以下に格納されています。

重要なのは、コミットオブジェクトが「どのファイルがどのように変更されたか」を直接的に記録している訳ではないという点です。例えば git show するとこのコミットでの差分が出ますが、

$ git show 6c946b8e1bc1f988b7d795517be3cb62aa963bdb
commit 6c946b8e1bc1f988b7d795517be3cb62aa963bdb
Author: bonar <bonamonchy@gmail.com>
Date:   Wed Sep 3 22:39:58 2014 +0900

    line 3

diff --git a/README.txt b/README.txt
index 7bba8c8..a92d664 100644
--- a/README.txt
+++ b/README.txt
@@ -1,2 +1,3 @@
 line 1
 line 2
+line 3

これは parent のコミットオブジェクトが持つ tree との比較によって得られます。つまり上記は line 3 の tree と line 2 の tree の比較結果です。この事は line 3 のコミットと同じ tree を持ち、且つ parent が line 1 のコミットオブジェクトを作ってみると分かります。新しいコミットオブジェクトは git commit-tree で作成出来ます。

$ git commit-tree -p c5d5c04376593fdb76754996d0322dd0005cd797 -m "line 3'" bf3461315080f6ca4e73ab1eb42f3389415758c6
446e26265ba55c115dce2f62407a481e44010968
$ git cat-file -p 446e26265ba55c115dce2f62407a481e44010968
tree bf3461315080f6ca4e73ab1eb42f3389415758c6
parent c5d5c04376593fdb76754996d0322dd0005cd797
author bonar <bonamonchy@gmail.com> 1409752954 +0900
committer bonar <bonamonchy@gmail.com> 1409752954 +0900

line 3'

新しく作ったこのコミットを git show すると、同じ tree を持っているにも関わらず line1 と line3 の差分が出ます。

$ git show 446e26265ba55c115dce2f62407a481e44010968
commit 446e26265ba55c115dce2f62407a481e44010968
Author: bonar <bonamonchy@gmail.com>
Date:   Wed Sep 3 23:02:34 2014 +0900

    line 3'

diff --git a/README.txt b/README.txt
index 89b24ec..a92d664 100644
--- a/README.txt
+++ b/README.txt
@@ -1 +1,3 @@
 line 1
+line 2
+line 3

「コミットの差分」というのが parent コミットとの関係で決まる相対的な物であるという事が分かります。これは git のコミットがその時点でのファイル全体のスナップショットであることを表していて、「コミット」という言葉の語幹からはイメージ出来ないものかもしれません。この認識は merge を行う際にとても重要になります。

ブランチはコミットを指すポインタ

ブランチは単に特定のコミットハッシュに名前をつけた物でしかなく、特定のコミット郡に名前をつけてグルーピングしておく入れ物ではありません。

例えば以下のようなブランチ群を考えます。
f:id:bonar:20140904213148p:plain

1 のコミットの後に branch A が切られ、4, 5, 6 と3つのコミットをした後に branch B が切られ、7, 8 というコミットが行われた状態です。この状態において、branch A というのは 6 のコミットを指すポインタで、branch B は 8 のコミットを指すポインタです。

.git/refs/heads を見ると、ブランチの HEAD のコミットハッシュが書かれたファイルがあります。これがブランチの正体です。

$ ls -1 .git/refs/heads
branch_a
branch_b
master
$ cat .git/refs/heads/branch_b
9c15300bc1789e9f3ddbf4fc32f3965813879828

ここで branch B を master に merge しようとした場合、「branch B を checkout した後にこのブランチに対して行った修正である 7, 8 が master に入るのだろう」と思いがちです。しかし実際に起こる事は「branch B というポインタが指しているコミット(=8)の tree を master に merge する」になります。8 の時点でのスナップショットが master に merge されるわけなので、4, 5, 6 の修正も入る事になります(branch B の修正じゃないのに!)。

とても当たり前の事なのですが、「ブランチという箱に差分を入れている」という脳内イメージだと、上記のような勘違いに至りがちです(僕もよくします)。特に merge する際には「ブランチはコミットへのポインタであって、コミットはスナップショットである」という認識に立ち戻ると訳が分からなくなる事が少ないかなと思いました。

次号:「git rebase とは何なのか」に続く