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

はい、取り出せました!それぞれのオブジェクトを図に表すと以下のようになります。

矩形が縦に3つ並んでいる。一番上の矩形には「[commit(74baa8b)] tree ed176cb parent 6650da2 sample commit 3」と書かれている。そこから1つ下の矩形に矢印が伸びており、その矩形には「[tree(ed176cb)] sample.txt c5c9c6c」と書かれている。そこからさらに下の矩形へ矢印が伸びており、その矩形には「[blob(c5c9c6c)] sample 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

よし、取り出せました。

各オブジェクトの参照の関係を図に表すと以下のようになります。

矩形が3×3のグリッド状に並んでいる。一番右上の矩形には「[commit(74baa8b)] tree ed176cb parent 6650da2 sample commit 3」と書かれている。そこから1つ下の矩形に矢印が伸びており、その矩形には「[tree(ed176cb)] sample.txt c5c9c6c」と書かれている。そこからさらに下の矩形へ矢印が伸びており、その矩形には「[blob(c5c9c6c)] sample 1 sample 2 sample 3」と書かれている。一番右上の矩形から1つ左の矩形に矢印が伸びており、「[commit(6650da2)] tree 73fd414 parent 96cf461 sample commit 2」と書かれている。そこから1つ下の矩形に矢印が伸びており、その矩形には「[tree(73fd414)] sample.txt 89db1e9」と書かれている。そこからさらに下の矩形へ矢印が伸びており、その矩形には「[blob(89db1e9)] sample 1 sample 2」と書かれている。右から2つ目のコミットからは、1つ左の矩形に矢印が伸びており、「[commit(96cf461)] tree 5dbf6b9 sample commit 1」と書かれている。そこから1つ下の矩形に矢印が伸びており、その矩形には「[tree(5dbf6b9)] sample.txt dccd42c」と書かれている。そこからさらに下の矩形へ矢印が伸びており、その矩形には「[blob(dccd42c)] sample 1」と書かれている。

Squashしてみる

それではSquashしてみます。以下を実行します。

git rebase -i HEAD^^

するとエディタが開くので以下のようにして保存し閉じます。

pick 6650da2 sample commit 2
squash 74baa8b sample commit 3

Squashによって新たに作成されるコミットのメッセージを編集するエディタが開くので、よしなに編集して保存して閉じます。

git logすると、たしかにsample commit 2sample 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前:

練習2で示した図と同様

Squash後:

一番右上には矩形があり、「[commit(a7e076b)] tree ed176cb parent 96cf461 sample commit 3 sample commit 2」と書かれている。そこから下に矢印が出て「[tree(ed176cb)] sample.txt c5c9c6c」と書かれた矩形を指している。その矩形から更に下へ矢印が出て「[blob(c5c9c6c)] sample 1 sample 2 sample 3」と書かれた矩形を指している。右上の矩形から左に矢印が出て「[commit(96cf461)] tree 5dbf6b9 sample commit 1」と書かれた矩形を指している。その矩形から下に矢印が出て「[tree(5dbf6b9)] sample.txt dccd42c」と書かれた矩形を指している。その矩形から更に下家矢印が出て「[blob(dccd42c)] sample 1」と書かれた矩形を指している。

唯一変化した、右上の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つしか記録されていません。

図に表すと以下のようになります。

右上に矩形があり、「[commit(6f6015e)] tree 9aaa8e3 parent 2a87a08 parent d8e2cae Merge branch ‘feature’」と書かれている。その矩形から下へ矢印が伸び、「[tree(9aaa8e3)] ...」と書かれた矩形を指している。右上の矩形から左方向に矢印が2本伸びている。1本は「[commit(2a87a08)] tree ... parent ... master 1」と書かれた矩形を指している。そこからさらに左方向へ矢印が伸び、「[commit(...)] tree ... parent ... initial commit」と書かれた矩形を指している。もう1本は「[commit(d8e2cae)] tree ... parent ... feature 2」と書かれた矩形を指している。そこから更に左方向へ矢印が伸び、「[commit(...)] tree ... parent ... feature 1」と書かれた矩形を指している。そこから更に左方向へ矢印が伸び、「[commit(...)] tree ... parent ... initial commit」と書かれた矩形を指している。右上の矩形から左に伸びる矢印のうち、2本目に吹き出しが付いていて、「Squashマージではこの参照が記録されない」と書かれている。

通常のマージコミットには両方の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を使えるようになれそうです。

ありがとうございました。

続けて読む…

「クロージャは関数と環境のペア」とは?(JavaScript)

2023/12/02

Advent of Code 2021攻略ガイド

2021/12/28

シングルユーザーモードのUbuntuにSSHする(SSH to Single User Mode Ubuntu 18.04)

2020/10/25

物の数は数え方によらないことを確認する【鳩の巣原理】

2024/10/20

【SQL】JOINが覚えられないのでアルゴリズムを想像してみる

2024/10/20

Blenderと魚

2017/06/13

書いた人

sititou70のアイコン画像
sititou70

都内の社会人エンジニア4年生。Web技術、3DCG、映像制作が好き。