Gitの内部表現から理解するSquash
2024/03/25コミットはスナップショットであり差分ではないとすると、Squashは何をしているのでしょうか。
コミット(スナップショット)を「まとめる」とは具体的にどのような操作を指すのか、詳しく見ていきます。
おさらい:Gitの内部表現
Gitの内部表現についてざっくり思い出します。Gitオブジェクトは以下の4種類です。
- コミット:ある時点におけるリポジトリ内のコンテンツのスナップショット
- ツリー:ディレクトリ
- ブロブ:ファイル
- タグ:今回は関係なし
まず、以下のコマンドでサンプルリポジトリを作成します。
mkdir sample-repo
cd sample-repo
git init
echo "sample 1" > sample.txt
git add .
git commit -m "sample commit 1"
sleep 1
echo "sample 2" >> sample.txt
git add .
git commit -m "sample commit 2"
sleep 1
echo "sample 3" >> sample.txt
git add .
git commit -m "sample commit 3"
sleep 1
練習1:最新のsample.txt
を取り出す
ここから、Gitのコマンドを使用せずに最新のsample.txt
を取り出してみます。事前にzlibコマンドをインストールしておきます。
# masterブランチの最新のコミットのハッシュを得る
$ cat .git/refs/heads/master
74baa8be92503f4f88a4bd8edf2dd821b580e2ad
# コミットを表示
$ <.git/objects/74/baa8be92503f4f88a4bd8edf2dd821b580e2ad zlib -d
commit 1099tree ed176cbfe8f1eb764d312dcb9c7cee15b07a2a9f
parent 6650da2873ce75b26e00e012d0c870756f487b4d
# ...省略...
sample commit 3
# 上記のコミットが指すツリー(tree ed176cbf...)を表示。これはリポジトリのルートディレクトリ
$ <.git/objects/ed/176cbfe8f1eb764d312dcb9c7cee15b07a2a9f zlib -d | xxd
00000000: 7472 6565 2033 3800 3130 3036 3434 2073 tree 38.100644 s
00000010: 616d 706c 652e 7478 7400 c5c9 c6c1 c47c ample.txt......|
00000020: 6545 ef2d cf29 e736 40d3 8608 2790 eE.-.).6@...\'.
# sample.txtのブロブ(上記のsample.txt\0のあとに続くc5c9 c6c1...)を表示
$ <.git/objects/c5/c9c6c1c47c6545ef2dcf29e73640d386082790 zlib -d
blob 27sample 1
sample 2
sample 3
はい、取り出せました!それぞれのオブジェクトを図に表すと以下のようになります。
ちなみに、それぞれのオブジェクトのハッシュは、そのsha1sumになっています。
# コミット
$ <.git/objects/74/baa8be92503f4f88a4bd8edf2dd821b580e2ad zlib -d | sha1sum
74baa8be92503f4f88a4bd8edf2dd821b580e2ad -
# ツリー
$ <.git/objects/ed/176cbfe8f1eb764d312dcb9c7cee15b07a2a9f zlib -d | sha1sum
ed176cbfe8f1eb764d312dcb9c7cee15b07a2a9f -
# ブロブ
$ <.git/objects/c5/c9c6c1c47c6545ef2dcf29e73640d386082790 zlib -d | sha1sum
c5c9c6c1c47c6545ef2dcf29e73640d386082790 -
練習2:最古のsample.txt
を取り出す
同じように、最も古いsample.txt
を取り出してみます。まず最も古いコミットを見つけ出し、そこから同じようにツリー、ブロブへとたどっていきます。
# masterブランチの最新のコミットのハッシュを得る
$ cat .git/refs/heads/master
74baa8be92503f4f88a4bd8edf2dd821b580e2ad
# 最新のコミットを表示
$ <.git/objects/74/baa8be92503f4f88a4bd8edf2dd821b580e2ad zlib -d
commit 1099tree ed176cbfe8f1eb764d312dcb9c7cee15b07a2a9f
parent 6650da2873ce75b26e00e012d0c870756f487b4d
# ...省略...
sample commit 3
# 上記の親のコミット(parent 6650da28...)を表示
$ <.git/objects/66/50da2873ce75b26e00e012d0c870756f487b4d zlib -d
commit 1099tree 73fd41472a3ba54da4d31f20ae22b8cf1c5460ea
parent 96cf46184f9cd93945a0a0a4fefec137bf071fcc
# ...
sample commit 2
# 上記の親のコミット(parent 96cf4618...)を表示
$ <.git/objects/96/cf46184f9cd93945a0a0a4fefec137bf071fcc zlib -d
commit 1051tree 5dbf6b90bea3fd0c027fd0b1373fb131b49dc796
# 最初のコミットなのでparentの行が無い
# ...
sample commit 1
# 上記のコミットが指すツリー(tree 5dbf6b90...)を表示
$ <.git/objects/5d/bf6b90bea3fd0c027fd0b1373fb131b49dc796 zlib -d | xxd
00000000: 7472 6565 2033 3800 3130 3036 3434 2073 tree 38.100644 s
00000010: 616d 706c 652e 7478 7400 dccd 42c5 8140 ample.txt...B..@
00000020: 3fec 2ecf d3c3 e75c fff6 a744 667d ?......\...Df}
# sample.txtのブロブ(上記のsample.txt\0のあとに続くdccd 42c5...)を表示
$ <.git/objects/dc/cd42c581403fec2ecfd3c3e75cfff6a744667d zlib -d
blob 9sample 1
よし、取り出せました。
各オブジェクトの参照の関係を図に表すと以下のようになります。
Squashしてみる
それではSquashしてみます。以下を実行します。
git rebase -i HEAD^^
するとエディタが開くので以下のようにして保存し閉じます。
pick 6650da2 sample commit 2
squash 74baa8b sample commit 3
Squashによって新たに作成されるコミットのメッセージを編集するエディタが開くので、よしなに編集して保存して閉じます。
git log
すると、たしかにsample commit 2
とsample commit 3
がまとめられているように見えます。
commit a7e076b007b00e903f9ddac603770995fdd94923 (HEAD -> master)
Author: Takuya Shizukuishi <sititou70@gmail.com>
Date: Mon Mar 25 16:43:44 2024 +0900
sample commit 2
sample commit 3
commit 96cf46184f9cd93945a0a0a4fefec137bf071fcc
Author: Takuya Shizukuishi <sititou70@gmail.com>
Date: Mon Mar 25 16:43:40 2024 +0900
sample commit 1
新しく作成された最新のコミットの中身を見てみます。
<.git/objects/a7/e076b007b00e903f9ddac603770995fdd94923 zlib -d
commit 1116tree ed176cbfe8f1eb764d312dcb9c7cee15b07a2a9f
parent 96cf46184f9cd93945a0a0a4fefec137bf071fcc
# ...
sample commit 2
sample commit 3
tree ed176cbf
には見覚えがありますね。今回のSquash前後のオブジェクトを図に表すと以下のようになります。
Squash前:
Squash後:
唯一変化した、右上のa7e076bというコミットオブジェクトに注目します。parentは、Squash前は「sample commit 2」のコミットを指していましたが、Squash後は1つ前の「sample commit 1」を指しています。これは、単方向リストから要素を削除する操作に似ています。
また、コミットメッセージがSquash対象のメッセージをつなげたものに変化しています。親との差分を考えると妥当ですね。
逆に言うと、Squashによって変わったのはこの2点だけです。例えば、ed176cbのツリーはSquash前後で変化していません。Squash前後でリポジトリ内のコンテンツが変化していないため当たり前です。
Squashマージ
以下のスクリプトで通常のマージとSquashマージを比較します。
mkdir sample-repo
cd sample-repo
git init
echo "initial" > master.txt
git add .
git commit -m "initial commit"
sleep 1
git switch -c feature
echo "feature 1" > feature.txt
git add .
git commit -m "feature 1"
sleep 1
echo "feature 2" >> feature.txt
git add .
git commit -m "feature 2"
sleep 1
git switch master
echo "master 1" >> master.txt
git add .
git commit -m "master 1"
sleep 1
cd ..
cp -r sample-repo sample-repo-merge
cd sample-repo-merge
git merge --no-edit feature
cd ..
cp -r sample-repo sample-repo-squash-merge
cd sample-repo-squash-merge
git merge --squash feature
git commit --no-edit
通常のマージを行った方の最新のコミットを見てみます。
$ cat .git/refs/heads/master
6f6015e406a8e6df565442b40ff251df37daad6b
$ <.git/objects/6f/6015e406a8e6df565442b40ff251df37daad6b zlib -d
commit 1154tree 9aaa8e3177b4053645ebb3971fcf719cfd577f91
parent 2a87a08640d13b2433d07af6411c61974385dc3f
parent d8e2caec977741c2112313a0cd871ad20a34e500
# ...
Merge branch 'feature'
Squashマージの方の最新のコミットを見てみます。
$ cat .git/refs/heads/master
d230284bac79100b48c4635dd38087ad494f4b2c
$ <.git/objects/d2/30284bac79100b48c4635dd38087ad494f4b2c zlib -d
commit 1421tree 9aaa8e3177b4053645ebb3971fcf719cfd577f91
parent 2a87a08640d13b2433d07af6411c61974385dc3f
# ...
Squashed commit of the following:
# ...コミットメッセージが続く...
先ほどと同様、両者の違いはparentとコミットメッセージのみです。通常マージの方はparentが2つ記録されていますが、Squashマージの方は1つしか記録されていません。
図に表すと以下のようになります。
通常のマージコミットには両方のparentが記録されるため、そこからgit log
するとfeatureブランチのコミットである「feature 2」や「feature 1」が表示されます。
一方、Squashマージではfeature側のparentが記録されないため、そこからgit log
するとmaster側のコミットしか見えません。ログの見え方がスッキリするという利点がありますが、親への情報が失われてしまうという欠点があります。
通常マージとSquashマージで、マージコミットが指すツリー(9aaa8e3)は同じです。通常マージでもSquashマージでも、マージ結果であるリポジトリのコンテンツに違いは無いからです。
まとめ
Gitの内部表現からSquashを考えてみました。インタラクティブリベースのSquashは、単方向リストからの要素削除に似ていました。Squashマージは、一方のparentを記録しないことに対応するようでした。
もちろん、Gitの実装が、実際に今回のような操作によってSquashを処理しているとは限りません。ちょっと実装を追ってみた感じだと、例えばインタラクティブリベースのSquashは、各コミットと親の差分を計算して、それを適用していくことで動作しているようでした。
しかし、少なくとも内部表現がどのように変化するかを理解しておくことで、より自信をもってGitを使えるようになれそうです。
ありがとうございました。