ハトネコエ Web がくしゅうちょう

プログラミングやサーバー・Web制作、チームマネジメントなど得た技術のまとめ

git-delete-squashed を深ぼって git オブジェクトにふれたよ

Squash and Merge されたローカルブランチを削除するCLIツールを読み解いて
gitオブジェクトの概念を理解した話です。

https://github.com/not-an-aardvark/git-delete-squashed

こちらのリポジトリの、
https://github.com/not-an-aardvark/git-delete-squashed/blob/v1.0.4/bin/git-delete-squashed.js
の部分をコードリーディングするために必要な情報を書いています。

勉強会での発表用に最低限の情報を付け加えているだけなので、
この記事だけだとわかりにくいかもしれません。

参考文献

以下で私が「SHA-1ハッシュ値」と「ハッシュ値」と書く場合がありますが、
どちらも同じものを指しています。

git はハッシュ値SHA-1アルゴリズムで作っているので、「SHA-1ハッシュ値」と書くことがあるだけです。

git for-each-ref

https://git-scm.com/docs/git-for-each-ref

git(['for-each-ref', 'refs/heads/', '--format=%(refname:short)'])

まずは git for-each-ref の行。
実際にコマンドを打ってみるとすぐにわかります。

git for-each-ref refs/heads/ --format="%(refname:short)"
develop
develop2
develop3
develop4
develop5
develop6
develop7
main

git branch と似ていますが、現在のブランチを * で表記する部分がないので、CLI的に扱いやすいです。

git branch
  develop
  develop2
  develop3
  develop4
  develop5
  develop6
* develop7
  main

# format を指定すればブランチ名だけも出せます
git branch --format="%(refname:short)"
develop
develop2
develop3
develop4
develop5
develop6
develop7
main

実は、ls コマンドでも似た結果を出せます。

ls .git/refs/heads
.        ..       develop  develop2 develop3 develop4 develop5 develop6 develop7 main
git for-each-ref
469660874e1d77a52c800e9f747c47adb64fba2f commit refs/heads/develop
0f0cd10b7e4e33cbe13085635b423c40401018d5 commit refs/heads/develop2
1e82c36ba65a6cda26be639f7140c47a5f36429c commit refs/heads/develop3
76af1032e7d3b01395a27c5575b14da0ab591d56 commit refs/heads/develop4
f049310f297d6d8e062f528ed92f042ba9014bd6 commit refs/heads/develop5
ec64993b2d6da40d0cab5d5aa5ddd6f7e37a41eb commit refs/heads/develop6
93c3dd596b04211392f1c16caf9530da6f48ea80 commit refs/heads/develop7
8e1f5fcd62b74ff0fff5de6dd00eeef828e4d6da commit refs/heads/main
1e82c36ba65a6cda26be639f7140c47a5f36429c commit refs/remotes/origin/develop3
76af1032e7d3b01395a27c5575b14da0ab591d56 commit refs/remotes/origin/develop4
f049310f297d6d8e062f528ed92f042ba9014bd6 commit refs/remotes/origin/develop5
ec64993b2d6da40d0cab5d5aa5ddd6f7e37a41eb commit refs/remotes/origin/develop6
93c3dd596b04211392f1c16caf9530da6f48ea80 commit refs/remotes/origin/develop7
8e1f5fcd62b74ff0fff5de6dd00eeef828e4d6da commit refs/remotes/origin/main

git for-each-ref refs/heads/
469660874e1d77a52c800e9f747c47adb64fba2f commit refs/heads/develop
0f0cd10b7e4e33cbe13085635b423c40401018d5 commit refs/heads/develop2
1e82c36ba65a6cda26be639f7140c47a5f36429c commit refs/heads/develop3
76af1032e7d3b01395a27c5575b14da0ab591d56 commit refs/heads/develop4
f049310f297d6d8e062f528ed92f042ba9014bd6 commit refs/heads/develop5
ec64993b2d6da40d0cab5d5aa5ddd6f7e37a41eb commit refs/heads/develop6
93c3dd596b04211392f1c16caf9530da6f48ea80 commit refs/heads/develop7
8e1f5fcd62b74ff0fff5de6dd00eeef828e4d6da commit refs/heads/main

git for-each-ref refs/heads/ --format="%(refname:short)"
develop
develop2
develop3
develop4
develop5
develop6
develop7
main

git for-each-ref refs/heads/ --format="%(objectname)"
469660874e1d77a52c800e9f747c47adb64fba2f
0f0cd10b7e4e33cbe13085635b423c40401018d5
1e82c36ba65a6cda26be639f7140c47a5f36429c
76af1032e7d3b01395a27c5575b14da0ab591d56
f049310f297d6d8e062f528ed92f042ba9014bd6
ec64993b2d6da40d0cab5d5aa5ddd6f7e37a41eb
93c3dd596b04211392f1c16caf9530da6f48ea80
8e1f5fcd62b74ff0fff5de6dd00eeef828e4d6da

git merge-base

https://git-scm.com/docs/git-merge-base

git(['merge-base', selectedBranchName, branchName]),

これは簡単。2つのブランチやコミットを比較して、共通の祖先となるコミットIDを出してくれるものです。

例えば git log がこのようになっている場合、

git log main

commit 8e1f5fcd62b74ff0fff5de6dd00eeef828e4d6da (origin/main, main)
Author: ハトネコエ <hato.nekoe@gmail.com>
Date:   Wed Aug 16 15:49:33 2023 +0900

    Develop6 (#4)

    * Add develop6-1.txt

    * Add develop6-2.txt

commit 3f3ab56835a3f6d06beb55ff3bab0074ce465b73
Author: ハトネコエ <hato.nekoe@gmail.com>
Date:   Wed Aug 16 15:47:51 2023 +0900

    Develop5 (#3)

    * Add develop5-1.txt

    * Add develop5-2.txt

commit 8d3b653d418c75fbd67e9163e992a74a04259d2c
Author: ハトネコエ <hato.nekoe@gmail.com>
Date:   Wed Aug 16 14:45:49 2023 +0900

    Add develop4.txt (#2)

git log develop7

commit 93c3dd596b04211392f1c16caf9530da6f48ea80 (HEAD -> develop7, origin/develop7)
Author: ハトネコエ hatonekoe@gmail.com <hato.nekoe@gmail.com>
Date:   Wed Aug 16 15:51:55 2023 +0900

    Add develop7-2.txt

commit bf988536e82301f09bd34462ebc9c4912ccac506
Author: ハトネコエ hatonekoe@gmail.com <hato.nekoe@gmail.com>
Date:   Wed Aug 16 15:51:43 2023 +0900

    Add develop7-1.txt

commit 8d3b653d418c75fbd67e9163e992a74a04259d2c
Author: ハトネコエ <hato.nekoe@gmail.com>
Date:   Wed Aug 16 14:45:49 2023 +0900

    Add develop4.txt (#2)

main と develop7 の共通の部分を見ればわかる通り、

git merge-base main develop7
8d3b653d418c75fbd67e9163e992a74a04259d2c

となります。

git rev-parse

https://git-scm.com/docs/git-rev-parse

git(['rev-parse', `${branchName}^{tree}`]),

のところ、なにしてるんだ、って感じですよね。

git rev-parse develop7
93c3dd596b04211392f1c16caf9530da6f48ea80

git log -1
commit 93c3dd596b04211392f1c16caf9530da6f48ea80 (HEAD -> develop7, origin/develop7)
Author: ハトネコエ hatonekoe@gmail.com <hato.nekoe@gmail.com>
Date:   Wed Aug 16 15:51:55 2023 +0900

    Add develop7-2.txt

単純にブランチを指定すると、最新のコミットIDを出力してくれる様子。

git rev-parse "develop7^{tree}"
44cae64ff170d3960e94847e7527cbd4b5d24d4f

tree ってなに? って感じだと思いますが、
最新のコミットID 93c3dd596b04211392f1c16caf9530da6f48ea80 を解析した結果 (コミットオブジェクトの復号)が以下の通りです。

git cat-file -p 93c3dd596b04211392f1c16caf9530da6f48ea80

tree 44cae64ff170d3960e94847e7527cbd4b5d24d4f
parent bf988536e82301f09bd34462ebc9c4912ccac506
author ハトネコエ hatonekoe@gmail.com <hato.nekoe@gmail.com> 1692168715 +0900
committer ハトネコエ hatonekoe@gmail.com <hato.nekoe@gmail.com> 1692168715 +0900

Add develop7-2.txt

ここの tree と一致していますね。

tree と blob、そして commit オブジェクト

git cat-file -p "main^{tree}"
100644 blob 25774f435e8f577bf50c8323bcb469512f5993de   develop.txt
100644 blob d70c976105b1233b1dda2aad2ff1279cf0696828   develop2.txt
100644 blob 25774f435e8f577bf50c8323bcb469512f5993de   develop3.txt
100644 blob 25774f435e8f577bf50c8323bcb469512f5993de   develop4.txt
100644 blob cdfc83c92efd2487d52ccdd7a1c2a9c952806595   develop5-1.txt
100644 blob 34fe19b9bf3ae5d422facd15c361aa8caf28daf9   develop5-2.txt
100644 blob cdfc83c92efd2487d52ccdd7a1c2a9c952806595   develop6-1.txt
100644 blob 34fe19b9bf3ae5d422facd15c361aa8caf28daf9   develop6-2.txt
040000 tree b4f45310d3284fd3769d816295352ca9dffbb272   images
100644 blob 900875274cf71d972ae579538d8ace267eab2384   test.txt

git cat-file -p b4f45310d3284fd3769d816295352ca9dffbb272
100644 blob 4225cf8bd30461831706badb4221b6433364ce3b   3000x2000.jpg
100644 blob d68b7f224b177bf762dd0bf4518dc7183394f870   green.png
100644 blob d68b7f224b177bf762dd0bf4518dc7183394f870   green2.png

tree に blob が紐づいているのですね。

上をよく見るとおもしろいのが、同様のSHA-1ハッシュ値をもつblobオブジェクトが存在することです。

cat develop*

devdev

devdev2

devdev

devdev

aaa

bbb

aaa

bbb

ファイルの中身が、実は同じ内容なのです。

ls -lh
total 72
drwxr-xr-x@ 13 ryosuke.yokoe  staff   416B Aug 18 01:23 .
drwxr-xr-x  61 ryosuke.yokoe  staff   1.9K Aug 16 14:24 ..
drwxr-xr-x@ 14 ryosuke.yokoe  staff   448B Aug 18 01:26 .git
-rw-r--r--@  1 ryosuke.yokoe  staff     8B Aug 16 14:29 develop.txt
-rw-r--r--@  1 ryosuke.yokoe  staff     9B Aug 16 14:45 develop2.txt
-rw-r--r--@  1 ryosuke.yokoe  staff     8B Aug 16 14:45 develop3.txt
-rw-r--r--@  1 ryosuke.yokoe  staff     8B Aug 16 14:45 develop4.txt
-rw-r--r--@  1 ryosuke.yokoe  staff     5B Aug 18 01:12 develop5-1.txt
-rw-r--r--@  1 ryosuke.yokoe  staff     5B Aug 18 01:12 develop5-2.txt
-rw-r--r--@  1 ryosuke.yokoe  staff     5B Aug 18 01:12 develop6-1.txt
-rw-r--r--@  1 ryosuke.yokoe  staff     5B Aug 18 01:12 develop6-2.txt
drwxr-xr-x@  5 ryosuke.yokoe  staff   160B Aug 18 01:25 images
-rw-r--r--@  1 ryosuke.yokoe  staff     9B Aug 16 14:25 test.txt

ls -lh images
total 232
drwxr-xr-x@  5 ryosuke.yokoe  staff   160B Aug 18 01:25 .
drwxr-xr-x@ 13 ryosuke.yokoe  staff   416B Aug 18 01:23 ..
-rw-r--r--@  1 ryosuke.yokoe  staff    89K Apr  5  2021 3000x2000.jpg
-rw-r--r--@  1 ryosuke.yokoe  staff    12K Aug 18 01:13 green.png
-rw-r--r--@  1 ryosuke.yokoe  staff    12K Aug 18 01:13 green2.png

ls -lh .git/objects/25/774f435e8f577bf50c8323bcb469512f5993de
-r--r--r--@ 1 ryosuke.yokoe  staff    21B Aug 16 14:44 .git/objects/25/774f435e8f577bf50c8323bcb469512f5993de

ls -lh .git/objects/42/25cf8bd30461831706badb4221b6433364ce3b
-r--r--r--@ 1 ryosuke.yokoe  staff    51K Aug 18 01:23 .git/objects/42/25cf8bd30461831706badb4221b6433364ce3b

ls -lh .git/objects/d6/8b7f224b177bf762dd0bf4518dc7183394f870
-r--r--r--@ 1 ryosuke.yokoe  staff   3.8K Aug 18 01:25 .git/objects/d6/8b7f224b177bf762dd0bf4518dc7183394f870

zlib で圧縮されたので元のファイルサイズよりも小さくなりながらも、( develop.txt は逆にファイルサイズが増えちゃってるけど)
元のファイルたちのサイズの関係性は変わっていないことがわかります。

100644 blob 25774f435e8f577bf50c8323bcb469512f5993de  develop.txt
100644 blob 4225cf8bd30461831706badb4221b6433364ce3b   3000x2000.jpg
100644 blob d68b7f224b177bf762dd0bf4518dc7183394f870   green.png

blob オブジェクトがファイルの実体。

tree がそれらをまとめたもの。
ちょうどディレクトリと一致しています。

また、 git cat-file -p "main^{tree}" コマンドで表示されませんでしたが、
すべてをまとめているルートの tree がいるということも、忘れてはいけません。

git rev-parse "main^{tree}"
# これがルートツリーのハッシュ値
9cc4b903c28e99489db4d088adb2b0e49369cd58

ls -lh .git/objects/9c/c4b903c28e99489db4d088adb2b0e49369cd58
# 217バイトのファイルである
-r--r--r--@ 1 ryosuke.yokoe  staff   217B Aug 18 01:25 .git/objects/9c/c4b903c28e99489db4d088adb2b0e49369cd58

cat .git/objects/9c/c4b903c28e99489db4d088adb2b0e49369cd58
# これが実体。zlibで暗号化されている
�*��i>��W�����31QHI-K���I��o��ү���W�׸�U��f��1�2s�S��|LOd����lnc�TS���o*�ZZ�Z\���Ru��ӵ�V�v�S�[��D���%

git cat-file -p 9cc4b903c28e99489db4d088adb2b0e49369cd58
# 実体を復号したもの
100644 blob 25774f435e8f577bf50c8323bcb469512f5993de   develop.txt
100644 blob d70c976105b1233b1dda2aad2ff1279cf0696828   develop2.txt
100644 blob 25774f435e8f577bf50c8323bcb469512f5993de   develop3.txt
100644 blob 25774f435e8f577bf50c8323bcb469512f5993de   develop4.txt
100644 blob cdfc83c92efd2487d52ccdd7a1c2a9c952806595   develop5-1.txt
100644 blob 34fe19b9bf3ae5d422facd15c361aa8caf28daf9   develop5-2.txt
100644 blob cdfc83c92efd2487d52ccdd7a1c2a9c952806595   develop6-1.txt
100644 blob 34fe19b9bf3ae5d422facd15c361aa8caf28daf9   develop6-2.txt
040000 tree b4f45310d3284fd3769d816295352ca9dffbb272   images
100644 blob 900875274cf71d972ae579538d8ace267eab2384   test.txt

git cat-file -p 25774f435e8f577bf50c8323bcb469512f5993de
# blobオブジェクトを復号すると……
devdev

git cat-file -p d68b7f224b177bf762dd0bf4518dc7183394f870
# 画像ファイルのblobオブジェクトを復号すると……
�PNG

IHDR\�{
 %�@z���ĘT�袂kC ProfileH��WXS��[R!�RBo�H (以下略)

blob オブジェクトを束ねた tree オブジェクトがあり、
その上には blob オブジェクトや tree オブジェクトを束ねた tree オブジェクトがあり……
とつながっていって、最終的にはルートの tree オブジェクトがある、という作りです。

で、そのルート tree の情報とかコミットメッセージとか、
親の commit ID がなんだったのかとかを保存しているのが commit オブジェクト。

git rev-parse main
# mainブランチの最新commit ID = commitオブジェクトのSHA-1ハッシュ値
c307d5c6bf20e72257feb279dbae464c3c0e1acb

ls -lh .git/objects/c3/07d5c6bf20e72257feb279dbae464c3c0e1acb
# 実体は 189 バイトのファイル
-r--r--r--@ 1 ryosuke.yokoe  staff   189B Aug 18 01:25 .git/objects/c3/07d5c6bf20e72257feb279dbae464c3c0e1acb

cat .git/objects/c3/07d5c6bf20e72257feb279dbae464c3c0e1acb
# もちろん暗号化されています
x��Mj�0���)���~�(���H��c[ƨ�7ݔ\#�I���ۏ����<OEOe���"�e�B�Rt    C�&�8�q�ְ�R49�2� �(�#t��omH*|�k�t�u���W���S_Cɋ�gy0�γ~���ێ�A���0�:���"�۪.y�$��ci�eT?o�d�%

git cat-file -p c307d5c6bf20e72257feb279dbae464c3c0e1acb
# 復号すると
tree 9cc4b903c28e99489db4d088adb2b0e49369cd58
parent 9451ccc2a7f300909d7acb06eaf904587fb513ad
author ハトネコエ hatonekoe@gmail.com <hato.nekoe@gmail.com> 1692289538 +0900
committer ハトネコエ hatonekoe@gmail.com <hato.nekoe@gmail.com> 1692289538 +0900

Copied green.png

git commit-tree

https://git-scm.com/docs/git-commit-tree

Promise.join(
  git(['merge-base', selectedBranchName, branchName]),
  git(['rev-parse', `${branchName}^{tree}`]),
  (ancestorHash, treeId) => git(['commit-tree', treeId, '-p', ancestorHash, '-m', `Temp commit for ${branchName}`])
)

のラスト、

git(['commit-tree', treeId, '-p', ancestorHash, '-m', `Temp commit for ${branchName}`])

の部分に注目します。

私たちがよく使う git commit が何をやっているかと言うと

の3段階に分かれます。

リポジトリ内の test.txt を変更したあとで、
この3段階のコマンドをそれぞれ試してみました。

git rev-parse "HEAD^{tree}"
# 現在のルート tree のSHA-1ハッシュ値が出る
9cc4b903c28e99489db4d088adb2b0e49369cd58

git write-tree
9cc4b903c28e99489db4d088adb2b0e49369cd58 # 同じ値が出るだけ

git cat-file -p 9cc4b903c28e99489db4d088adb2b0e49369cd58
100644 blob 25774f435e8f577bf50c8323bcb469512f5993de   develop.txt
100644 blob d70c976105b1233b1dda2aad2ff1279cf0696828   develop2.txt
100644 blob 25774f435e8f577bf50c8323bcb469512f5993de   develop3.txt
100644 blob 25774f435e8f577bf50c8323bcb469512f5993de   develop4.txt
100644 blob cdfc83c92efd2487d52ccdd7a1c2a9c952806595   develop5-1.txt
100644 blob 34fe19b9bf3ae5d422facd15c361aa8caf28daf9   develop5-2.txt
100644 blob cdfc83c92efd2487d52ccdd7a1c2a9c952806595   develop6-1.txt
100644 blob 34fe19b9bf3ae5d422facd15c361aa8caf28daf9   develop6-2.txt
040000 tree b4f45310d3284fd3769d816295352ca9dffbb272   images
100644 blob 900875274cf71d972ae579538d8ace267eab2384   test.txt

git add test.txt

git write-tree
# addしたあとだと違う値が出ます
# これが新しいルート tree のハッシュ値です
40d4203d957f7b85fd061f6cfb6a3be2a0ef2931

git cat-file -p 40d4203d957f7b85fd061f6cfb6a3be2a0ef2931
# test.txt の blob オブジェクトのハッシュ値が変わっていることがわかります
100644 blob 25774f435e8f577bf50c8323bcb469512f5993de   develop.txt
100644 blob d70c976105b1233b1dda2aad2ff1279cf0696828   develop2.txt
100644 blob 25774f435e8f577bf50c8323bcb469512f5993de   develop3.txt
100644 blob 25774f435e8f577bf50c8323bcb469512f5993de   develop4.txt
100644 blob cdfc83c92efd2487d52ccdd7a1c2a9c952806595   develop5-1.txt
100644 blob 34fe19b9bf3ae5d422facd15c361aa8caf28daf9   develop5-2.txt
100644 blob cdfc83c92efd2487d52ccdd7a1c2a9c952806595   develop6-1.txt
100644 blob 34fe19b9bf3ae5d422facd15c361aa8caf28daf9   develop6-2.txt
040000 tree b4f45310d3284fd3769d816295352ca9dffbb272   images
100644 blob 70aa7efe711579bcfa5de6f3a6987c1f402513d8   test.txt

git cat-file -p 900875274cf71d972ae579538d8ace267eab2384
Hello!!

git cat-file -p 70aa7efe711579bcfa5de6f3a6987c1f402513d8
Hello!!
World!!

git commit-tree 40d4203d957f7b85fd061f6cfb6a3be2a0ef2931 -m "Update test.txt"
# commitオブジェクトのハッシュ値を返してくれます
2cd0806c0f3b141857f7a74cfe5dcf1518d29e61

git log -1
# この段階ではまだcommitされていません
commit c307d5c6bf20e72257feb279dbae464c3c0e1acb (HEAD -> main, origin/main)
Author: ハトネコエ hatonekoe@gmail.com <hato.nekoe@gmail.com>
Date:   Fri Aug 18 01:25:38 2023 +0900

    Copied green.png

# ただし、treeオブジェクト・commitオブジェクトのファイル自体はすでに作られています
ls -lh .git/objects/40/d4203d957f7b85fd061f6cfb6a3be2a0ef2931
-r--r--r--@ 1 ryosuke.yokoe  staff   217B Aug 18 01:55 .git/objects/40/d4203d957f7b85fd061f6cfb6a3be2a0ef2931

ls -lh .git/objects/2c/d0806c0f3b141857f7a74cfe5dcf1518d29e61
-r--r--r--@ 1 ryosuke.yokoe  staff   155B Aug 18 02:05 .git/objects/2c/d0806c0f3b141857f7a74cfe5dcf1518d29e61

# update-ref をおこなう前にこのファイルを見ておきましょう
ls -lh .git/refs/heads/main
-rw-r--r--@ 1 ryosuke.yokoe  staff    41B Aug 18 01:25 .git/refs/heads/main

cat .git/refs/heads/main
# 現在の最新の commit ID(commitオブジェクトのハッシュ値)が、暗号化されず記載されています
c307d5c6bf20e72257feb279dbae464c3c0e1acb

git update-ref refs/heads/main 2cd0806c0f3b141857f7a74cfe5dcf1518d29e61

git log -3
# あれあれ? git log が1つになってしまいました
commit 2cd0806c0f3b141857f7a74cfe5dcf1518d29e61 (HEAD -> main)
Author: ハトネコエ hatonekoe@gmail.com <hato.nekoe@gmail.com>
Date:   Fri Aug 18 02:05:45 2023 +0900

    Update test.txt

# 実は commit-tree のオプションで parent を指定する必要がありました。
# 親の commit ID を指定してやり直します
git commit-tree 40d4203d957f7b85fd061f6cfb6a3be2a0ef2931 -p c307d5c6bf20e72257feb279dbae464c3c0e1acb -m "Update test.txt"
9edb64bfabbaea2d8e359d9da091cc2a2508c1b2

git update-ref refs/heads/main 9edb64bfabbaea2d8e359d9da091cc2a2508c1b2

git log -3
# ふだんおこなっている commit が終わったときと同じように git log が更新されましたね!
commit 9edb64bfabbaea2d8e359d9da091cc2a2508c1b2 (HEAD -> main)
Author: ハトネコエ hatonekoe@gmail.com <hato.nekoe@gmail.com>
Date:   Fri Aug 18 02:18:06 2023 +0900

    Update test.txt

commit c307d5c6bf20e72257feb279dbae464c3c0e1acb (origin/main)
Author: ハトネコエ hatonekoe@gmail.com <hato.nekoe@gmail.com>
Date:   Fri Aug 18 01:25:38 2023 +0900

    Copied green.png

commit 9451ccc2a7f300909d7acb06eaf904587fb513ad
Author: ハトネコエ hatonekoe@gmail.com <hato.nekoe@gmail.com>
Date:   Fri Aug 18 01:24:05 2023 +0900

    Add 3000x2000.jpg

それを踏まえて改めてコードを読んでみます。

Promise.join(
  git(['merge-base', selectedBranchName, branchName]),
  git(['rev-parse', `${branchName}^{tree}`]),
  (ancestorHash, treeId) => git(['commit-tree', treeId, '-p', ancestorHash, '-m', `Temp commit for ${branchName}`])
)

selectedBranchName は main や develop など、ベースとなるブランチ。
branchName が、消すかもしれないブランチの名前が入るところです。

お互いの共通の祖先の commit オブジェクトのSHA-1ハッシュ値が ancestorHash に入り、
branchName のルート tree オブジェクトのSHA-1ハッシュ値が treeId に入ります。

git commit-tree <対象ブランチのルートtree> -p <共通の祖先のcommit ID> -m <別にこのあとで使わないので、なんか適当なコミットメッセージ>

これにより、親を <共通の祖先のcommit ID> とする、
ファイル群の中身としては対象ブランチの中身と同じ commit オブジェクトが作成されます。

git cherry

https://git-scm.com/docs/git-cherry

この2行に注目します。

.then(danglingCommitId => git(['cherry', selectedBranchName, danglingCommitId]))
.then(output => output.startsWith('-'))

selectedBranchName は main や develop など、ベースとなるブランチ。
danglingCommitId が、さっきの工程で作られた commit オブジェクトのハッシュ値です。

git cherry <selectedBranchName> <danglingCommitId>

git cherry コマンドは、
selectedBranchName と danglingCommitId を比較して、
selectedBranchName にまだ適用されていないコミット一覧を出力するコマンドです。

main ブランチ内だけでの git cherry コマンドの挙動を見てみます。

git log main -3
commit c307d5c6bf20e72257feb279dbae464c3c0e1acb (HEAD -> main, origin/main)
Author: ハトネコエ hatonekoe@gmail.com <hato.nekoe@gmail.com>
Date:   Fri Aug 18 01:25:38 2023 +0900

    Copied green.png

commit 9451ccc2a7f300909d7acb06eaf904587fb513ad
Author: ハトネコエ hatonekoe@gmail.com <hato.nekoe@gmail.com>
Date:   Fri Aug 18 01:24:05 2023 +0900

    Add 3000x2000.jpg

commit a35cd0f3eacd0c5e1c8f097255b7c9789b839e9e
Author: ハトネコエ hatonekoe@gmail.com <hato.nekoe@gmail.com>
Date:   Fri Aug 18 01:17:03 2023 +0900

    Move green.png into images directory

git cherry main a35cd0f3eacd0c5e1c8f097255b7c9789b839e9e
# 何も出力されない

git reset --hard a35cd0f3eacd0c5e1c8f097255b7c9789b839e9e
HEAD is now at a35cd0f Move green.png into images directory

git cherry main c307d5c6bf20e72257feb279dbae464c3c0e1acb
+ 9451ccc2a7f300909d7acb06eaf904587fb513ad
+ c307d5c6bf20e72257feb279dbae464c3c0e1acb

次に main ブランチから派生したブランチとで見てみます。

git log develop6
commit ec64993b2d6da40d0cab5d5aa5ddd6f7e37a41eb (origin/develop6, develop6)
Author: ハトネコエ hatonekoe@gmail.com <hato.nekoe@gmail.com>
Date:   Wed Aug 16 15:49:17 2023 +0900

    Add develop6-2.txt

commit ad5c5a15d1b2d34ddc2158daeb47cf813f5abdcc
Author: ハトネコエ hatonekoe@gmail.com <hato.nekoe@gmail.com>
Date:   Wed Aug 16 15:49:05 2023 +0900

    Add develop6-1.txt

commit 8d3b653d418c75fbd67e9163e992a74a04259d2c
Author: ハトネコエ <hato.nekoe@gmail.com>
Date:   Wed Aug 16 14:45:49 2023 +0900

    Add develop4.txt (#2)

commit c2a91cad496cbc5df835f065cda88e2d133ed6ca
Merge: 89aa220 1e82c36
Author: ハトネコエ <hato.nekoe@gmail.com>
Date:   Wed Aug 16 14:45:34 2023 +0900

    Merge pull request #1 from nekonenene/develop3

    Add develop3.txt

commit 1e82c36ba65a6cda26be639f7140c47a5f36429c (origin/develop3, develop3)
Author: ハトネコエ hatonekoe@gmail.com <hato.nekoe@gmail.com>
Date:   Wed Aug 16 14:44:21 2023 +0900

    Add develop3.txt

git cherry main ec64993b2d6da40d0cab5d5aa5ddd6f7e37a41eb
+ ad5c5a15d1b2d34ddc2158daeb47cf813f5abdcc
+ ec64993b2d6da40d0cab5d5aa5ddd6f7e37a41eb

git cherry main 1e82c36ba65a6cda26be639f7140c47a5f36429c
# 何も出力されない

git log main -1
commit c307d5c6bf20e72257feb279dbae464c3c0e1acb (HEAD -> main, origin/main)
Author: ハトネコエ hatonekoe@gmail.com <hato.nekoe@gmail.com>
Date:   Fri Aug 18 01:25:38 2023 +0900

    Copied green.png

git cherry develop6 c307d5c6bf20e72257feb279dbae464c3c0e1acb
+ 3f3ab56835a3f6d06beb55ff3bab0074ce465b73
+ 8e1f5fcd62b74ff0fff5de6dd00eeef828e4d6da
+ be96afdb68950d891efb8a619268c6fba8648353
+ a35cd0f3eacd0c5e1c8f097255b7c9789b839e9e
+ 9451ccc2a7f300909d7acb06eaf904587fb513ad
+ c307d5c6bf20e72257feb279dbae464c3c0e1acb

今回のコードでは、マイナス記号で始まるものがある場合……となっていました。

git cherry の結果でマイナス記号が返ってくる場合があるのでしょうか……?

.then(danglingCommitId => git(['cherry', selectedBranchName, danglingCommitId]))
.then(output => output.startsWith('-'))
git log main -3
commit c307d5c6bf20e72257feb279dbae464c3c0e1acb (HEAD -> main, origin/main)
Author: ハトネコエ hatonekoe@gmail.com <hato.nekoe@gmail.com>
Date:   Fri Aug 18 01:25:38 2023 +0900

    Copied green.png

commit 9451ccc2a7f300909d7acb06eaf904587fb513ad
Author: ハトネコエ hatonekoe@gmail.com <hato.nekoe@gmail.com>
Date:   Fri Aug 18 01:24:05 2023 +0900

    Add 3000x2000.jpg

commit a35cd0f3eacd0c5e1c8f097255b7c9789b839e9e
Author: ハトネコエ hatonekoe@gmail.com <hato.nekoe@gmail.com>
Date:   Fri Aug 18 01:17:03 2023 +0900

    Move green.png into images directory

git checkout develop6

git cherry-pick 9451ccc2a7f300909d7acb06eaf904587fb513ad
[develop6 8a45a11] Add 3000x2000.jpg
 Date: Fri Aug 18 01:24:05 2023 +0900
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 images/3000x2000.jpg

git log develop6 -3
commit 8a45a11515a7d5d78e9fa021254dc6de37cf701b (HEAD -> develop6)
Author: ハトネコエ hatonekoe@gmail.com <hato.nekoe@gmail.com>
Date:   Fri Aug 18 01:24:05 2023 +0900

    Add 3000x2000.jpg

commit ec64993b2d6da40d0cab5d5aa5ddd6f7e37a41eb (origin/develop6)
Author: ハトネコエ hatonekoe@gmail.com <hato.nekoe@gmail.com>
Date:   Wed Aug 16 15:49:17 2023 +0900

    Add develop6-2.txt

commit ad5c5a15d1b2d34ddc2158daeb47cf813f5abdcc
Author: ハトネコエ hatonekoe@gmail.com <hato.nekoe@gmail.com>
Date:   Wed Aug 16 15:49:05 2023 +0900

    Add develop6-1.txt

git cherry main 8a45a11515a7d5d78e9fa021254dc6de37cf701b
+ ad5c5a15d1b2d34ddc2158daeb47cf813f5abdcc
+ ec64993b2d6da40d0cab5d5aa5ddd6f7e37a41eb
- 8a45a11515a7d5d78e9fa021254dc6de37cf701b # マイナスが生まれた!

ここはまだしっかり理解できていないのですが、
git rebase をするときの挙動にヒントがあるようで、
main ブランチに git rebase develop6 をするときは、
共通の親コミットをお互いのスタート地点に置いて、
develop6 の最新に至るまでの差分コミットを次々に main にコミットしていきます。

その考えのときに、これから適用する必要がある差分コミットと同じ差分を持ったコミットがすでにある(ただしコミットIDは異なる) のなら、
それは - と表示されるのだ、と考えてみてください。

その上で改めてここのコードを考えてみましょう。

.then(danglingCommitId => git(['cherry', selectedBranchName, danglingCommitId]))
.then(output => output.startsWith('-'))

danglingCommitId はこのように作りました。

Promise.join(
  git(['merge-base', selectedBranchName, branchName]),
  git(['rev-parse', `${branchName}^{tree}`]),
  (ancestorHash, treeId) => git(['commit-tree', treeId, '-p', ancestorHash, '-m', `Temp commit for ${branchName}`])
)

parent が共通の祖先で、tree は対象ブランチの最新のルート tree。
そのような指定で commit オブジェクトを作っています。
いわばこの commit オブジェクトは、 develop6 ブランチでした作業をひとまとめにしたコミットです。

そして、Squash and Merge で main ブランチに発生する差分を考えてみましょう。
develop6 ブランチでした作業のひとまとめですね?

ということは、

git cherry <selectedBranchName> <danglingCommitId>

このコマンドでおこなうことは、
selectedBranchName と danglingCommitId の共通の祖先のコミットをお互いのスタート地点に、
danglingCommitId の最新に至るまでの差分コミットをぶつけていく仮定をして、
ぶつける必要のあるコミットID一覧を出力することだったのですから、

  • 出力結果は多くても1行
  • Squash and Merge をすでにしているのなら、同じ差分を持ったコミットが selectedBranchName の最新に至るまでのどこかにすでにあるのだから、 - で始まる結果が返ってくる

ということがわかります。

実際にやってみましょう。

git merge-base main develop6
8d3b653d418c75fbd67e9163e992a74a04259d2c

git rev-parse "develop6^{tree}"
2b86066ec0a8604723c6faceffc82abb1002e7c2

git commit-tree 2b86066ec0a8604723c6faceffc82abb1002e7c2 -p 8d3b653d418c75fbd67e9163e992a74a04259d2c -m "Test"
445626f721a725d0dde9b58bbe16313ed57a7c76

git cherry main 445626f721a725d0dde9b58bbe16313ed57a7c76
- 445626f721a725d0dde9b58bbe16313ed57a7c76

- で始まる1行が返ってきてくれました!

逆に、まだマージされていないブランチでも試してみましょう。

git merge-base main develop7
8d3b653d418c75fbd67e9163e992a74a04259d2c

git rev-parse "develop7^{tree}"
44cae64ff170d3960e94847e7527cbd4b5d24d4f

git commit-tree 44cae64ff170d3960e94847e7527cbd4b5d24d4f -p 8d3b653d418c75fbd67e9163e992a74a04259d2c -m "Test"
a19558dcad2d7be91037edb8c5a670a8c8a2080b

git cherry main a19558dcad2d7be91037edb8c5a670a8c8a2080b
+ a19558dcad2d7be91037edb8c5a670a8c8a2080b

同じ差分コミットの存在がないので、単純にぶつける必要があることを示す + で返ってきましたね。

おまけで、 Squash and Merge じゃない普通のマージがされたブランチでも試してみましょう。
予想としては、複数コミットの差分を足すと danglingCommitId と同じ差分になりますが、コミット単体で見た場合に danglingCommitId と同じ差分のコミットがないので + が返りそうです。

git merge-base main develop8
b94791fb0ba679fd952170a5d1d4e5034f876924

git rev-parse "develop8^{tree}"
0258d3cf847dc3b37cd762d7c60fa0aadce320ba

git commit-tree 0258d3cf847dc3b37cd762d7c60fa0aadce320ba -p b94791fb0ba679fd952170a5d1d4e5034f876924 -m "Test"
65557c84871805e0c771db0e2cb89cb76237eee6

git cherry main 65557c84871805e0c771db0e2cb89cb76237eee6
+ 65557c84871805e0c771db0e2cb89cb76237eee6

予想通り、+ で返ってきましたね。

通常のマージがされたブランチの一覧は git branch --merged で探さないとダメそうですね。

git branch --merged

  develop
  develop2
  develop3
  develop8
* main

その他

こうして、
https://github.com/not-an-aardvark/git-delete-squashed/blob/v1.0.4/bin/git-delete-squashed.js#L30-L49 の部分の挙動が、
ブランチ一覧の中から、Squash and Merge されたブランチをフィルタリングして、
対象となったブランチの削除をおこなっているということがわかりました。

他、 git 以外のいくつかわかりにくい部分に触れておきます。

.tap

http://bluebirdjs.com/docs/api/tap.html

Promise のためのライブラリ bluebird のメソッド。
then に似てるが、渡された値をそのまま返すのが特徴。
(then だと、内部の無名関数でなんらかの return をさせて、それを次のメソッドチェーンに渡すので)

.tap(branchNamesToDelete => branchNamesToDelete.length && git(['checkout', selectedBranchName]))
.mapSeries(branchName => git(['branch', '-D', branchName]))

削除するブランチ名の配列(branchNamesToDelete)の要素数が1以上のときに、
selectedBranchName(main や develop ブランチ)にチェックアウトする。

branchNamesToDelete はそのまま返され、
branchNamesToDelete.mapSeries(branchName => git(['branch', '-D', branchName])) が実行される。

つまり、削除したいブランチを git branch -D <branchName> として順々に削除するわけだ。

.mapSeries

http://bluebirdjs.com/docs/api/promise.mapseries.html

同じく bluebird のメソッド。
なんとなくコードの方を読めばわかると思うので、特には解説せず。

childProcess.spawn

18行目( https://github.com/not-an-aardvark/git-delete-squashed/blob/v1.0.4/bin/git-delete-squashed.js#L18C5-L18C51 )の

const child = childProcess.spawn('git', args);

調べたところ、Node.js でシェルコマンドを実行させたい場合は、このように
Node.js の子プロセスを作ることでやるのが定番っぽい。

(参考: Node.js | シェルコマンドを実行する方法(child_process) - わくわくBank

おわりに

このCLIツールを書いた人の頭の良さに脱帽でした。

git を熟知していることがすごいですし、
仮に私がこの人と同じくらい git を知っていたとしても、
この方法で Squash and merge されたブランチを絞り込めるとは思いつかなさそうです。

次回のブログでは、この人のソースコードを参考に
Go で書いた git-branch-delete-merged というCLIツールについて紹介しようと思います。

github.com