MarkdownとBullet Journal

いわゆるプログラマーのつぶやき

【Git】実はファイルを差分でも保存する

Gitのファイル管理の続き

前回の記事でGitは作業ディレクトリ内のGit追跡fileを、staging及びcommit履歴に"blob"と呼ばれる圧縮独自フォーマットのfileで全て保存していることを説明した。

programmingforever.hatenablog.com

またその各file(blob)はsnapshot、つまり以前のfileとの変更点の差分保存ではなく、各fileの内容のまま(blobに形を変えた上で)全て保存していると説明した。

しかしそうなると、例えば1万行あるfileのたった1行だけを修正しても、非常によく似た大きなfile(blob)が2つ保存されてしまう。Git が最初のfile(blob)を保存するのは当然として、2つ目以降のfile(blob)は最初との差分(delta)のみを格納すれば効率良く保存出来るのにと、Gitが全てのfile(blob)をsnapshotとして保存するやり方に無駄を感じるのはごく自然だと思う。

Gitは条件成立時に差分保存を行う

実はその心配は無用だ。Gitはfileの差分保存を実に上手いやり方で実装している。編集作業中に行うgit addgit commitなどのコマンドを受けたGit がfile(blob)を保存する時は、確かに一つ一つのblobをsnapshotとして個々に保存している。これを緩いオブジェクトフォーマット(loose object format)と呼ぶ。

それに対して、規定または指定の条件が成立するとGitのgcガベージコレクション)が発動し、 Git はblobやtree,commitオブジェクトをpackfileと呼ばれるバイナリfileに差分単位で詰め込む作業を自動実行する(*設定による)。あるいは手動でgit gc コマンドを打つと実行する。

つまり、直近作業のfile(blob)は差分ではなくsnapshotの形で全て保存されるが、規定または指定の条件成立時にfile(blob)は差分管理で保存される、ハイブリッド制御が行われることを示す。

発動条件は色々あり、指定やパラメータ調整も出来る(設定fileの gc.auto と gc.autopacklimitなど)。なおgcの別機能である絶縁commitや不要なblobを自動削除する発動期間の設定とは別である。

では実際にgit gcコマンドを使って実証してみる。例題として前記事の通りの作業ディレクトリを新たに用意する。

ここで差分保存の効果を確認出来る様にa.shを100kバイト超の大きなテキストfileとし、No.3のcommitで1行だけ変更する。またb.sh, c.sh, d.shは全て空fileとしたため3fileのblob IDが同じになる点に注意(file内容が同一であれば誰がどこで作成しようとも必ず同じIDになる)

f:id:ProgrammingForEver:20210915192442j:plain

最初のcommit

最初にa.sh,b,sh,c,sh,d,shの4fileを用意して、a.shのみstaging&commitを行う。

$ ls -a
.       ..      .git    a.sh    b.sh    c.sh    d.sh
$ find .git/objects -type f
$ git add a.sh
$ git commit -m "最初のcommit"
[master (root-commit) ef11425] 最初のcommit
 1 file changed, 1775 insertions(+)
 create mode 100644 a.sh

ここでGitレポジトリのobject folderを覗くコマンド、及びツリーを見るコマンドでblob objectの SHA-1ハッシュ値が確認出来る。下の例ではa.shが.git/objects/57/9da06-----である事が分かる。

$ find .git/objects -type f
.git/objects/57/9da06934bd26a1a2f791021a989b95dfcd8a39
.git/objects/fc/0010737a9457dd224c2f778d430b3c293c2e89
.git/objects/ef/11425c7169155b5c167dcfc3837c250613d12f

$ git cat-file -p master^{tree}
100644 blob 579da06934bd26a1a2f791021a989b95dfcd8a39    a.sh

そして、a.shつまり.git/objects/57の容量を確認すると88kバイトになっている事が分かる。

$ du .git/objects
88      .git/objects/57      # - これがa.sh
8       .git/objects/fc
0       .git/objects/pack
0       .git/objects/info
8       .git/objects/ef
104     .git/objects

$ 

以上でa.shをblobに格納されており、容量は88kバイトであることが確認出来た。]

2回目のcommit

次にb.shを加える2回目のcommitを行う。

$ git add b.sh
$ git commit -m "2番目のcommit"
[master 1999418] 2番目のcommit
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 b.sh
$ find .git/objects -type f
.git/objects/57/9da06934bd26a1a2f791021a989b95dfcd8a39
.git/objects/fc/0010737a9457dd224c2f778d430b3c293c2e89
.git/objects/19/99418d81ddda2f848a3e91cdd65d7c211963a0
.git/objects/ef/11425c7169155b5c167dcfc3837c250613d12f
.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
.git/objects/4a/e759d0ac93c9f59dc682cf3c606bef2f5ee25f

$ git cat-file -p master^{tree}
100644 blob 579da06934bd26a1a2f791021a989b95dfcd8a39    a.sh
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391    b.sh

$ du .git/objects
88      .git/objects/57
8       .git/objects/fc
0       .git/objects/pack
8       .git/objects/19
0       .git/objects/info
8       .git/objects/ef
8       .git/objects/e6
8       .git/objects/4a
128     .git/objects

同様にb.shが追加されている事がわかる。なおb.shは空fileだがblobフォーマットの関係などで8kバイトの容量になる。

3回目のcommit

3回目のcommitでは、a.shの一行目に一文字だけの僅かな変更を加えた上で、a.shとc.shをcommitする

$ vi a.sh                      # - 一文字だけ加える
$ git add a.sh c.sh
$ git commit -m "3番目のcommit"
[master 8a20ac3] 3番目のcommit
 2 files changed, 1 insertion(+), 1 deletion(-)
 create mode 100644 c.sh

$ find .git/objects -type f
.git/objects/57/9da06934bd26a1a2f791021a989b95dfcd8a39
.git/objects/d7/754295c833a6a8147c6bd22122d145d2f09b47
.git/objects/fc/0010737a9457dd224c2f778d430b3c293c2e89
.git/objects/8a/20ac30c95d90fc1c92aaa7b433e91ffe7f8d8f
.git/objects/19/99418d81ddda2f848a3e91cdd65d7c211963a0
.git/objects/6e/9ab2c475344fa2243fc0b3a7a683143fe1eeb4
.git/objects/ef/11425c7169155b5c167dcfc3837c250613d12f
.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
.git/objects/4a/e759d0ac93c9f59dc682cf3c606bef2f5ee25f

$ git cat-file -p master^{tree}
100644 blob d7754295c833a6a8147c6bd22122d145d2f09b47    a.sh
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391    b.sh
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391    c.sh

$ du .git/objects
88      .git/objects/57     # - 最初のa.sh
88      .git/objects/d7     # - 一文字だけ修正したa.sh
8       .git/objects/fc
0       .git/objects/pack
8       .git/objects/8a
8       .git/objects/19
8       .git/objects/6e
0       .git/objects/info
8       .git/objects/ef
8       .git/objects/e6
8       .git/objects/4a
232     .git/objects

上のgit/objects/57が最初のa.sh、.git/objects/d7が改訂したa.shで、共に88kバイトの容量で保存されている事がわかる。

4回目のcommit

続いて最後のcommitを行う。

$ git add d.sh
$ git commit -m "4番目のcommit"
[master cac41d4] 4番目のcommit
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 d.sh
$ find .git/objects -type f
.git/objects/57/9da06934bd26a1a2f791021a989b95dfcd8a39
.git/objects/d7/754295c833a6a8147c6bd22122d145d2f09b47
.git/objects/fc/0010737a9457dd224c2f778d430b3c293c2e89
.git/objects/ca/c41d483660425387f0488f0551df45f9c9e186
.git/objects/8a/20ac30c95d90fc1c92aaa7b433e91ffe7f8d8f
.git/objects/19/99418d81ddda2f848a3e91cdd65d7c211963a0
.git/objects/6e/9ab2c475344fa2243fc0b3a7a683143fe1eeb4
.git/objects/55/250e90d114ed645ba6a8ef4d3d5b8b8c99fd10
.git/objects/ef/11425c7169155b5c167dcfc3837c250613d12f
.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
.git/objects/4a/e759d0ac93c9f59dc682cf3c606bef2f5ee25f

$ git cat-file -p master^{tree}
100644 blob d7754295c833a6a8147c6bd22122d145d2f09b47    a.sh
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391    b.sh
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391    c.sh
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391    d.sh

$ du .git/objects
88      .git/objects/57
88      .git/objects/d7
8       .git/objects/fc
8       .git/objects/ca
0       .git/objects/pack
8       .git/objects/8a
8       .git/objects/19
8       .git/objects/6e
0       .git/objects/info
8       .git/objects/55
8       .git/objects/ef
8       .git/objects/e6
8       .git/objects/4a
248     .git/objects

これまでの4つのcommit の結果、合計11オブジェクト、容量はa.shの新旧2つ分の176kバイトにb.sh〜d.shの24kバイトを足した200kバイトになった。

git gcコマンド実行

以上を確認したら、手動でgit gcを実行する。

$ git gc
Enumerating objects: 11, done.
Counting objects: 100% (11/11), done.
Delta compression using up to 8 threads
Compressing objects: 100% (9/9), done.
Writing objects: 100% (11/11), done.
Total 11 (delta 2), reused 0 (delta 0)
Computing commit graph generation numbers: 100% (4/4), done.

$ find .git/objects -type f
.git/objects/pack/pack-a4e0422cb92e03407ff3d178f35cd04ce7c568d3.idx
.git/objects/pack/pack-a4e0422cb92e03407ff3d178f35cd04ce7c568d3.pack
.git/objects/info/commit-graph
.git/objects/info/packs

上の通りpackfileにまとめられ、11個あったオブジェクトが4個に減少している。もちろん下記の様にfileとしていつでも取り出せる状態だ。

$ git cat-file -p master^{tree}
100644 blob d7754295c833a6a8147c6bd22122d145d2f09b47    a.sh
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391    b.sh  # - 以下3fileは
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391    c.sh  # - 同一内容なので
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391    d.sh # - 同じIDになる

ここで容量を確認してみよう。すると.git/objects/packにまとめられたfileの容量はa.shの新旧2つ分の176kバイトにb.sh〜d.shの24kバイトを足した200kバイトから80kバイトに減少したのが分かる。

$ du .git/objects
80      .git/objects/pack
16      .git/objects/info
96      .git/objects
  • a.shの新旧file(blob)の重複内容が差分保存になり大幅に削減された
  • b.sh, c.sh, d.shの各fileは空fileなので単純にフォーマット用の8kバイトが削除された

まとめ

以上実証した通り、Gitは追跡対象のfile(blob)をsnapshotで保存するが、ある程度期間が経過したfile(blob)を自動で差分保存することでディスクの容量削減を実現する、優れたハイブリッド制御を行っている。