はじめに
前回の記事でブランチとは何かを学びました。
ここまで理解した段階で、「じゃあマージって何なの?」という疑問が浮かびました。
その謎を解くために、Gitコマンドはどんな動きをしているのかを調べたところ、興味深い内容があったため、今回はそちらについてまとめたいと思います。
Gitコマンドは2種類ある
普段Gitを使う上で、「git add」や「git commit」というコマンドを使うと思います。
実はこれはユーザーが使いやすいように配慮されたコマンドなのです。
Git公式にはこのように記載されています。
Gitには低レベルの処理を行うためのコマンドも沢山あります。(省略)
これらのコマンドは、通常 “配管(plumbing)” コマンドと呼ばれています。対して、よりユーザフレンドリーなコマンドは “磁器(porcelain)” コマンドと呼ばれています。
『低レベル』という言葉の意味がイメージと直結しなかったので、私は
普段使ってるのが磁器コマンド、より根本に近いのが配管コマンドなんだな!
と理解しました。
この辺は概念なので自分の理解しやすいように覚えてもらえたら大丈夫です!
ネットで調べると「磁器コマンド=トイレ、配管コマンド=水道管」みたいな例えもあって面白いですよ!
今回は配管コマンドを使ってコミットをしてみたいと思います!
配管コマンドを使ってみる
今回もgit init
直後のクリーンな環境を用意しました。
前回の記事でもお話したように、コミットをすると「.git/objects」というフォルダにファイルができるので、今回はこのフォルダの中身の変化を追っていきたいと思います。
学習で躓かないために
始める前に、これからいろいろなコマンドを打っていきます。
先がわからない状態で眺めていてもよくわからず挫折してしまうかもしれませんので、事前にこの後の内容をざっくり確認しておこうと思います。
今回学ぶコマンドはこちらです。
今の段階で覚えておく必要はありませんが、「さっき見た気がするけどどういうコマンドだっけ?」と思ったときにはメニューから「学習で躓かないために」へ戻ってきてもらえるとスムーズに学習できると思います。
$ git hash-object #データに対応するキー値を出力する
$ git cat-file -p #Gitオブジェクトの中身を見る
$ git cat-file -t #Gitオブジェクトのタイプを知る
$ git update-index #インデックスに登録する
$ git ls-files -s #インデックスに登録されたファイルを確認する
$ git write-tree #treeオブジェクトを作成する
$ git read-tree #treeオブジェクトを読み込む
$ git commit-tree #コミットする
また、今回はGitのオブジェクトを追うので40桁のキー値があらゆるところで出てきます。
混乱するかもしれませんが、基本的に各工程ごとに環境をリセットしてGitオブジェクトを作り直すようにしているので、「これは何のIDだっけ?」と思ったら直近で実行したコマンドを振り返れば見つけることができます。
では、配管コマンドの学習を始めましょう。
キーとバリューを格納する
Gitはキー・バリュー型データストアなので、データに対して一意の名前を付けてあげなければいけません。
それがgit hash-object
というコマンドです。
このコマンドでバリュー(データ)に対するキー(名前)を教えてくれます。
オプションに-w
をつけると、ついでに「.git/objects」の中にキーとバリューを保管してくれます。
とりあえず簡単なテキストファイルを作って試してみましょう。
$ echo "hello" > test1.txt
$ git hash-object -w test1.txt
ce013625030ba8dba906f756967f9e9ca394464a
test1.txt
にはce013625030ba8dba906f756967f9e9ca394464a
という名前がついたようです。
「.git/objects」の中を見てみましょう。
「ce」というフォルダの中に013625030ba8dba906f756967f9e9ca394464a
というファイルができていますね。
ちなみにこちらのファイル、メモ帳で見るとこんな感じです。
このままでは読めないので、Gitではオブジェクトの中身やタイプを教えてくれる便利なコマンドがあります。
それがgit cat-file
です。早速使ってみましょう。
$ git cat-file -p ce013
hello
オプションに-p
をつけることによって、オブジェクトのタイプを判別してわかりやすい形で表示してくれます。
「.git/objects」にできたファイルには、test1.txtの中身(コンテンツ)が入っていることが確認できました。
では次にtest1.txt
の中身を書き換えて全く同じコマンドを実行してみましょう。
$ echo "good night" > test1.txt
$ git hash-object -w test1.txt
d55621b2ec8f692d43654ae0ec7b2c51b4e9e927
$ git cat-file -p d5562
good night
同じtest1.txt
という名前のファイルですが、中身を変えると別の名前が付けられました。
「.git/objects」にも新しいファイルが増えています。
ここで1つ実験をしたいと思います。
もしこの状態でtest1.txt
の中身を"hello"に書き換えたらどうなるでしょうか?
「戻す」のではなく「書き換える」ので、名前が変わるのか変わらないのか。
実行結果はこのようになりました。
$ git hash-object -w test1.txt
ce013625030ba8dba906f756967f9e9ca394464a
前回の名前はce013625030ba8dba906f756967f9e9ca394464a
でしたので、全く同じ名前が付けられました。
1つのフォルダの中に同じ名前のファイルは存在しえないので、新しくファイルが作成されることもありません。
名前を付けるときには乱数ではなく、「SHA-1」というハッシュ関数を使っているので、渡しているデータが同じなら返ってくるデータも同じということですね!
また、git cat-file -p
の出力がファイルの中に記述したテキストだけであることからわかるように、ハッシュ関数から出力されたキー値が紐付いているのはデータの中身なので、別のファイル名で実行したとしても同じ名前が付けられます。
$ echo "hello" > test1.txt
$ echo "hello" > test2.txt
$ echo "hello" > test3.txt
$ git hash-object -w test1.txt
ce013625030ba8dba906f756967f9e9ca394464a
$ git hash-object -w test2.txt
ce013625030ba8dba906f756967f9e9ca394464a
$ git hash-object -w test3.txt
ce013625030ba8dba906f756967f9e9ca394464a
ちなみに、今回は単語レベルのとても短い内容でしたので直接書き換えることができましたが、これが1000文字を超えるような長文だったらどうでしょうか?
一言一句完璧に再現するのは不可能ですよね。
そこで、下記のようにすることで書き戻してあげることができます。
$ git cat-file -p d5562 > test1.txt
$ cat test1.txt
good night
2行目のcat
はLinuxのコマンドで、ファイルの中身を見るなどの使い道があります。
test1.txt
が復元されているのがわかりました。
これがGitを使うメリットです。
前回の記事でもお話したようにGitはスナップショットを保存することでいつでも過去のある時点を再現できるようにしています。
今回作成された013625030ba8dba906f756967f9e9ca394464a
や5621b2ec8f692d43654ae0ec7b2c51b4e9e927
というファイルがスナップショットということですね。
これでデータに名前を付けることに成功しました。
インデックスにステージング(登録)する
次は、コミットの準備をするために配管コマンドでインデックスにステージングをしていきたいと思います。
これまでの作業を確実に影響させないために、環境をリセットしてgit init
しました。
では、まずはtest1.txt
を作ります。
$ echo "hello" > test1.txt
次に、変更したファイルをインデックスに登録したいと思います。
配管コマンドではこのようになります。
$ git update-index --add test1.txt
インデックスに登録したことがないファイルはオプションで--add
をつける必要があります。
これまでは明示的にhash-object
コマンドで名前を付けていましたが、Gitではインデックスに登録する際に名前も決めてくれます。
そのため、「.git/objects」には下記のファイルが作成されました。
また、「.git」直下に新しくindex
というファイルが作成されました。
このファイルの中身はメモ帳では文字化けするので、配管コマンドを使ってみてみましょう。
$ git ls-files -s
100644 ce013625030ba8dba906f756967f9e9ca394464a 0 test1.txt
これはインデックスの状態を確認するコマンドで、オプション次第で様々な使い道があるようですが、今回はインデックスにステージングされたファイルの情報を知りたいので-s
で実行します。
ce013625030ba8dba906f756967f9e9ca394464a
というのは先ほど「.git/objects」にできたファイルと同じ名前ですね。
これで磁器コマンドのgit add
を行った状態になります。
次はgit commit
ですが、配管コマンドではgit commit-tree
というコマンドを使用します。
早速やってみましょう。コミットメッセージはecho
で入れます。
$ echo "test_commit1" | git commit-tree ce013
fatal: ce013625030ba8dba906f756967f9e9ca394464a is not a valid 'tree' object
エラーが出てしまいました。「これ、tree
じゃないじゃん!」といわれています。
そんなこと言われても、「tree
ってなんだよ!」と思いますよね。
次項ではGitオブジェクトについて確認していきます
blobとtree
ここでまたGit公式を見てみます。
すべてのコンテンツはツリーオブジェクトまたはブロブオブジェクトとして格納されます。
コンテンツとは「情報の中身」という意味です。ここまでの例でいうのであれば「hello」などのファイルの中身のデータです。
こういったデータたちはすべてblob(ブロブ)かtree(ツリー)と呼ばれるオブジェクトになるよということです。
(※後述しますが、オブジェクトの種類としてはほかにもコミットオブジェクトというものも存在します。コミット時に「.git/objects」内に作られます。)
先ほど「treeじゃないじゃん!」と怒られたce013625030ba8dba906f756967f9e9ca394464a
はblobということになりますが、ちゃんとコマンドで確認してみましょう。今回はgit cat-file
にオプション-t
をつけます。
$ git cat-file -t ce013
blob
blob
と出力されました。これでこのオブジェクトのタイプはblobだと判明しました。
git commit-tree
というコマンド通り、コミットをするときはtreeオブジェクトでなくてはいけません。
ではtreeオブジェクトとはいったい何なのでしょうか?
わかりやすくするためにもう2つファイルを作成し、インデックスにステージングします。
$ echo "hello hello" > test2.txt
$ echo "hello hello hello" > test3.txt
$ git update-index --add test2.txt
$ git update-index --add test3.txt
$ git ls-files -s
100644 ce013625030ba8dba906f756967f9e9ca394464a 0 test1.txt
100644 dddc24282fdcf9dba59eb013fe74db78dc9209d0 0 test2.txt
100644 0fc15fb53e69cc136d1e2389fac8ac371ee92887 0 test3.txt
この後、test1.txt
もtest2.txt
もtest3.txt
も変更したとします。
けどやっぱり全部元に戻したい!と思ったとき、どうしますか?
$ git cat-file -p ce013 > test1.txt
$ git cat-file -p dddc2 > test2.txt
$ git cat-file -p 0fc15 > test3.txt
こんな風にファイルの数だけコマンドを打っていたら、本番の開発環境で何十個ものファイルを戻さなければいけないとき、気が遠のいてしまいますよね。
こんな時のために、treeオブジェクトは存在します。
では実際にtreeオブジェクトを作成してみましょう。git write-tree
コマンドで、インデックスの情報をもとにtreeオブジェクトが作成されます。
$ git write-tree
9633220b7d766c116875ca4eece0a196fe91a861
$ git cat-file -t 9633
tree
treeオブジェクトができました。
では中身を確認してみましょう。
$ git cat-file -p 9633
100644 blob ce013625030ba8dba906f756967f9e9ca394464a test1.txt
100644 blob dddc24282fdcf9dba59eb013fe74db78dc9209d0 test2.txt
100644 blob 0fc15fb53e69cc136d1e2389fac8ac371ee92887 test3.txt
ステージングしてあったblobオブジェクトが1つにまとまっています。
これなら上記のようにcat-file
コマンドでtreeオブジェクトの名前を呼び出せば3つのファイルをまとめて見れますよね。
また、このtreeオブジェクトにはblobオブジェクトだけでなく、さらにtreeオブジェクト(サブツリー)を入れることができます。
図にするとこんなイメージです。treeオブジェクトが木の枝で、blobがりんごです。
試しにサブツリーが入っているtreeオブジェクトを作成してみましょう。
ついでに新しいファイルもステージングしてみます。
#新しいファイルをステージング
$ echo "hello hello hello hello" > test4.txt
$ git update-index --add test4.txt
#既存のtreeオブジェクトをステージング
$ git read-tree --prefix=bak 9633
#インデックスの中身を確認
$ git ls-files -s
100644 ce013625030ba8dba906f756967f9e9ca394464a 0 bak/test1.txt
100644 dddc24282fdcf9dba59eb013fe74db78dc9209d0 0 bak/test2.txt
100644 0fc15fb53e69cc136d1e2389fac8ac371ee92887 0 bak/test3.txt
100644 ce013625030ba8dba906f756967f9e9ca394464a 0 test1.txt
100644 dddc24282fdcf9dba59eb013fe74db78dc9209d0 0 test2.txt
100644 0fc15fb53e69cc136d1e2389fac8ac371ee92887 0 test3.txt
100644 38df49a8e0e8b97dcb77e25c48043be4c3b0ae7d 0 test4.txt
#treeオブジェクトを作成
$ git write-tree
bb2d4464be69414afb1c87fd69f75f7d59afe4e5
#作成されたtreeオブジェクトの中身を確認
$ git cat-file -p bb2d
040000 tree 9633220b7d766c116875ca4eece0a196fe91a861 bak
100644 blob ce013625030ba8dba906f756967f9e9ca394464a test1.txt
100644 blob dddc24282fdcf9dba59eb013fe74db78dc9209d0 test2.txt
100644 blob 0fc15fb53e69cc136d1e2389fac8ac371ee92887 test3.txt
100644 blob 38df49a8e0e8b97dcb77e25c48043be4c3b0ae7d test4.txt
すでにインデックスに登録されているファイルと新しくインデックスに登録したファイル、そしてサブツリーが存在していることが確認できました。
コミットする
では、もう一度配管コマンドでコミットを実行してみましょう。
一度環境はクリアな状態にリセットしたので、ファイルの作成から行っていきます。
せっかくtreeオブジェクトを覚えたので、3つのファイルをコミットしましょう。
$ echo "hello1" > test1.txt
$ echo "hello2" > test2.txt
$ echo "hello3" > test3.txt
$ git update-index --add test1.txt
$ git update-index --add test2.txt
$ git update-index --add test3.txt
$ git write-tree
e381ec4700f5e3d202fbb05d6ef3cd903c5ef713
treeオブジェクトができました。コミットはgit commit-tree
でしたね。
$ echo "test_commit1" | git commit-tree e381ec
0d22c44cfa27077facd4964ff43aebb677ad4398
$ git log --stat 0d22c44
commit 0d22c44cfa27077facd4964ff43aebb677ad4398
Author: pukuku <xxx@gmail.com>
Date: Sun Sep 26 20:20:28 2021 +0900
test_commit1
test1.txt | 1 +
test2.txt | 1 +
test3.txt | 1 +
3 files changed, 3 insertions(+)
git log
で表示されたのでコミットは成功です!
では次に、前回の記事で「コミットは直前のコミットとつながっている」とお話したので、それも再現してみましょう。
test2.txtのファイルを書き換えるので、コミットログで挿入と削除が1行ずつある状態が理想です。
直前のコミットをつなげるにはgit commit-tree ツリーオブジェクト -p 親コミット
を実行します。
$ echo "change2" > test2.txt
$ git update-index test2.txt
$ git write-tree
675c8bad24f340e86e90a8b97e2ad3a6a7e481a9
$ echo "test_commit2" | git commit-tree 675c8ba -p 0d22c4
07ece9dc5a6dca3bc49b6784744443486ee09729
$ git log --stat 07ece9d
commit 07ece9dc5a6dca3bc49b6784744443486ee09729
Author: pukuku <xxx@gmail.com>
Date: Sun Sep 26 20:25:18 2021 +0900
test_commit2
test2.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
commit 0d22c44cfa27077facd4964ff43aebb677ad4398
Author: pukuku <xxx@gmail.com>
Date: Sun Sep 26 20:20:28 2021 +0900
test_commit1
test1.txt | 1 +
test2.txt | 1 +
test3.txt | 1 +
3 files changed, 3 insertions(+)
ログに2つのコミットが表示されました!
最後に、「最新のコミットさえわかれば最初のデータまでさかのぼれる」というのを実証してみようと思います。
#"test_commit2"の中身を確認
$ git cat-file -p 07ece
tree 675c8bad24f340e86e90a8b97e2ad3a6a7e481a9
parent 0d22c44cfa27077facd4964ff43aebb677ad4398
author pukuku <xxx@gmail.com>
committer pukuku <xxx@gmail.com>
test_commit2
#"test_commit2"の親(parent)中身 = "test_commit1"の中身を確認
$ git cat-file -p 0d22c44
tree e381ec4700f5e3d202fbb05d6ef3cd903c5ef713
author pukuku <xxx@gmail.com>
committer pukuku <xxx@gmail.com>
test_commit1
#"test_commit1"の中のtreeオブジェクトの中身を確認
$ git cat-file -p e381ec
100644 blob 15b8f2a8ffc8a7789b65fdcf2505f23ea9e4dde0 test1.txt
100644 blob 14be0d41c639d701e0fe23e835b5fe9524b4459d test2.txt
100644 blob 1b069b17859aab168b854a15fd61ba8f83771796 test3.txt
#treeオブジェクトの中にあるtest2.txtのblobオブジェクトの中身を確認
$ git cat-file -p 14be0d4
hello2
test2.txtの中身は先ほど「change2」に書き換えたはずですが、しっかりと最初の「hello2」が出力されました。
ちなみに、今の「.git/objects」の中身はこちらになります。
$ find .git/objects -type f
.git/objects/07/ece9dc5a6dca3bc49b6784744443486ee09729 #test_commit2
.git/objects/0d/22c44cfa27077facd4964ff43aebb677ad4398 #test_commit1
.git/objects/14/be0d41c639d701e0fe23e835b5fe9524b4459d #hello2
.git/objects/15/b8f2a8ffc8a7789b65fdcf2505f23ea9e4dde0 #hello1
.git/objects/1b/069b17859aab168b854a15fd61ba8f83771796 #hello3
.git/objects/39/c5733494077ae8bc45c45c15a708ffe9871966 #change2
.git/objects/67/5c8bad24f340e86e90a8b97e2ad3a6a7e481a9 #treeオブジェクト(2回目に作成したもの)
.git/objects/e3/81ec4700f5e3d202fbb05d6ef3cd903c5ef713 #treeオブジェクト(1回目に作成したもの)
今回はわかりやすくするために常に3個ほどのファイルで検証していましたが、1つのファイルをコミットするのにも
- blobオブジェクト
- treeオブジェクト
- commitオブジェクト
の3つは作成されます。データがあって、ツリーがあって、コミットされる、という流れですね。
前回の記事でも触れた謎がこれで解明されました!
おわりに
以上、磁器コマンドの中身を見ていきました。
Git公式ではRubyでblobオブジェクトを作る手順も教えてくれています。
今回は磁器コマンドを配管コマンドで実現するというのをゴールにしていましたので割愛しますが、実際に手を動かしてみると理解が深まるのでぜひ公式の方も確認してみてください。
次回はいよいよマージについて書きたいと思います。
少し期間は空いてしまうかもしれませんが、ぜひ最後までお付き合いいただければ幸いです。