🐈

Gitはどのようにファイルを管理しているのか?

に公開

GA technologiesでソフトウェアエンジニアをしている中坂です。

現代ではGitを聞いたことがないソフトウェアエンジニアはほぼいないと思われますが、Gitの内部ではどのようにファイルが管理されているのか?を知っているエンジニアは実はそこまで多くはないのではないでしょうか。今回は普段当たり前のように使っているGitがどのような仕組みでファイルを扱っているのかを実際にRubyによる実装を交えながら深掘っていきます。

※前提としてGitの基本操作や用語は知っているものとして進めます。

Content Addressable Filesystem

Pro GitのGit Internalsの章を読んでいくとまずContent Addressableというものが出てきます。

https://git-scm.com/book/ja/v2

Content Addressableとは何か?の前にまずは通称Location Addressableという構造について説明する方がイメージしやすいと思います。これはみなさんにはお馴染みのいわゆるUnixのディレクトリ構造のようなもので、Location Addressable Filesystemといえばツリー表現で管理されるファイルシステムのことです。例えばこんな感じのやつです↓。

.
├── Gemfile
├── Gemfile.lock
├── README.md
├── Rakefile
├── bin
│   └── vi
├── lib
│   ├── editor.rb
│   ├── escape_code.rb
│   ├── input.rb
│   ├── main.rb
│   └── screen.rb
└── test.txt

ではContent Addressable Filesystemとは何かというとコンテンツベースでファイルを管理するタイプのファイルシステムのことです。コンテンツベースでファイルを管理というのは、ファイルの中身に対してハッシュ化処理を行い、ユニークなキーを生成して、それをもってコンテンツとその場所をキー・バリューストアのように一意に特定できるようにして管理するという仕組みです。

$ tree
.
└── 3fa0d4b98289a95a7cd3a45c9545e622718f8d2b
└── 4f6f465ef44675504555cdfc6bd58152f69ec9e1

Gitや分散型ファイルシステム(一昔前のWeb3文脈でよく話題になっていたIPFSなど)のようなシステム全体での一意性に重きを置いたようなシステムではContent AddressableなFilesystemが使われていたりします。

git initしてみる

何はともあれひとまずgit initを実行してみて挙動を確認してみましょう。

$ git init test
Initialized empty Git repository in /test/.git/
$ cd test
$ find .git/objects
.git/objects
.git/objects/info
.git/objects/pack

カレントディレクトリには.gitディレクトリが作成されます。そして.gitの中にはobjects/infoobjects/packというディレクトリが作成されるはずです。

このobjectsというのは何でしょうか?

Blob Object

ひとまず適当なファイルを作成してみましょう。

$ echo "Hello, World" > hello.txt

そしてgit hash-objectコマンドを実行してみましょう。

$ git hash-object -w hello.txt
3fa0d4b98289a95a7cd3a45c9545e622718f8d2b

git hash-objectはファイルの内容(+ヘッダ)をSHA-1でハッシュ値に変換しそれをファイル名とし、ファイルの中身に関してはファイルの内容(+ヘッダ)をzlibで圧縮して保存します。そうして出来たファイルを.git/objectsディレクトリに配置します。

$ find .git/objects
.git/objects
.git/objects/info
.git/objects/pack
.git/objects/3f
.git/objects/3f/a0d4b98289a95a7cd3a45c9545e622718f8d2b

先ほど出力されたハッシュ値の先頭2文字の3fがディレクトリ名になっています。そしてその中にハッシュ値の残りの部分がファイル名となったファイルとして格納されています。

git cat-fileを使って中身を確認してみましょう。git cat-fileコマンドはハッシュ値を元にオブジェクトの内容を復元して表示してくれるコマンドです。

$ git cat-file -p 3fa0d4b98289a95a7cd3a45c9545e622718f8d2b
Hello, World

先ほど作成したファイルの中身が出力されました。さらに以下も実行してみます。

$ git cat-file -t 3fa0d4b98289a95a7cd3a45c9545e622718f8d2b
blob

git cat-file -tコマンドはハッシュ値を元にオブジェクトのタイプを表示してくれるコマンドです。実行結果としてはblobと表示されるはずです。

このようにGitはファイルの中身全体(+ヘッダ)に対してハッシュ化をして得られたハッシュ値をファイル名としBlob Objectを作成して.git/objectsディレクトリに保存していることがわかりました。

では実際に今説明したBlob Objectへの変換とその逆の操作を実装してみます。必要なのは主に下記です。

  • ファイルの中身をGit Objectのフォーマットにすること
  • sha1ハッシュ値を求めること
  • ファイルの圧縮/解凍を行うこと

Git ProのObject StorageによるとGit Objectはこのような感じです。

<オブジェクトのタイプ> <データのサイズ>\0<データの内容>

なので実装としてはこんな感じです(\0はheaderとcontentを区切るためのヌル文字です)。

header = "blob #{@content.bytesize}\0"
header + @content

このheaderとcontentが結合されたものをSHA-1でハッシュ化します。

Digest::SHA1.hexdigest(header + @content)

キーとなるファイル名はこれで生成できるので、あとはファイルの中身の圧縮と保存です。圧縮にはzlibを使います。ここでも先ほど利用したGit Objectのフォーマットに従ってheaderとcontentが結合されたものを圧縮します。

File.open(sha_file_name, 'wb') do |f|
  f.write(Zlib::Deflate.deflate(header + @content))
end

圧縮に関してはこれで完了です。ファイルの復元に関しては今やってきたことの逆を行うだけなので割愛します。全体のコードとしては下記です。.gitに作成すると既存の.gitが壊れるかもしれないので.test_gitというディレクトリを作成してそこに保存しています。

require 'digest/sha1'
require 'zlib'
require 'fileutils'

class BlobObject
  attr_reader :content, :sha

  GIT_DIR = '.test_git'

  def initialize(content)
    @content = content
    @sha = calc_sha
  end

  def write_to_objects
    dir = File.join(GIT_DIR, 'objects', @sha[0..1])
    path = File.join(dir, @sha[2..])

    return if File.exist?(path)

    FileUtils.mkdir_p(dir)

    File.open(path, 'wb') do |f|
      f.write(Zlib::Deflate.deflate(git_object_data))
    end
  end

  def self.read_from_objects(sha)
    dir = File.join(GIT_DIR, 'objects', sha[0..1])
    path = File.join(dir, sha[2..])

    compressed = File.read(path, mode: 'rb')
    decompressed = Zlib::Inflate.inflate(compressed)

    header, content = decompressed.split("\0", 2)

    type, = header.split(' ')
    raise "Invalid object type: #{type}" unless type == 'blob'

    new(content)
  end

  private

  def calc_sha
    Digest::SHA1.hexdigest(git_object_data)
  end

  def git_object_data
    header = "blob #{@content.bytesize}\0"
    header + @content
  end
end

実際に実行してみると下記のようにBlob Objectが作成/復元ができるはずです。

※以下、その他の実装でも挙動の理解を優先するためエラーハンドリングなどは省略しています。

$ irb
require_relative 'blob_object'
blob_object = BlobObject.new('Hello, World')
blob_object.write_to_objects

$ file .test_git/objects/18/56e9be02756984c385482a07e42f42efd5d2f3
.test_git/objects/18/56e9be02756984c385482a07e42f42efd5d2f3: zlib compressed dat

$ irb
require_relative 'blob_object'
object = BlobObject.read_from_objects("1856e9be02756984c385482a07e42f42efd5d2f3")
object.content
=> "Hello, World"

Tree Object

Blob Objectだけだと当たり前ですが、「ハッシュ値」と「圧縮されたコンテンツの中身」のKVSのようになっているだけなので、ディレクトリの構造やファイル名がどこにも管理されていないです。何なら同じファイルの中身で別のファイル名のコンテンツがあった場合に区別がつきません(コンテンツの中身が同じだとハッシュ値も同じなので)。

そこでTree Objectという仕組みが用いられています。

Tree ObjectはまさにUnixのディレクトリ構造のようなものです。下図を見るのがわかりやすいと思います。

Git,「10.2 Git Internals - Git Objects」,https://git-scm.com/book/en/v2/Git-Internals-Git-Objects,2025-08-01

つまりTree Objectにはその子となるBlob Objectや他のTree Objectとの繋がりが保存されています。

Tree Objectを実際に確認するためにはまずステージにファイルを追加する必要があります。具体的には.git/indexにインデックスを保存します。

$ git update-index --add --cacheinfo 100644 3fa0d4b98289a95a7cd3a45c9545e622718f8d2b hello.txt

$ ls -la .git # 色々あって一部出力は省略
-rw-r--r--@ HEAD
-rw-r--r--@ config
-rw-r--r--@ description
drwxr-xr-x@ hooks
-rw-r--r--@ index
drwxr-xr-x@ info
drwxr-xr-x@ objects
drwxr-xr-x@ refs

$ file .git/index
.git/index: Git index, version 2, 1 entries

実は、このようにファイルからBlob Objectを作成し.git/objectsに保存し、.git/indexにインデックスを追加更新することがgit addコマンドの内部で行われていることになります。

ちなみにGitのindexは作業ディレクトリとリポジトリの間に位置する重要な中間層(いわゆるステージエリアと呼ばれるやつ)で、次のコミットに含める変更を管理しています。indexが存在することで、変更の一部だけを選択してコミットできたり(例えば作業ディレクトリの一部の変更ファイルのみgit add hoge.txtしたりなど)、パフォーマンスの最適化やマージの衝突解決を可能にしてくれます。indexの中身のフォーマットは少々複雑なバイナリ形式で、ファイルパス、モード、Blob Objectのハッシュ値、タイムスタンプなどのメタデータを保存しています。

今回はGit Objectに焦点を当てたいので解説を省略しますが、その辺りの話はGit index formatを読むとわかりやすいです。

試しに下記を実行してみましょう。上記までで行ってきたことと同じ状態になることがわかるかと思います。

$ git init test2
$ cd test2
$ echo "Hello, World" > hello.txt
$ git add hello.txt

git ls-files --stageコマンドを実行してみましょう。このコマンドはステージされているインデックスの内容を表示してくれます(--debugオプションをつけるとさらに詳細な情報も表示されます)。

$ git ls-files --stage
100644 3fa0d4b98289a95a7cd3a45c9545e622718f8d2b 0    hello.txt

上記で行ったのと同じ状態になりました。

ファイルをステージに追加しインデックスの作成が済んだのでTree Objectに戻ります。git write-treeコマンドを実行してみましょう。このコマンドを実行することでステージ状態をTree Objectに書き出せるようになります。

$ git write-tree
8481e2030a0f0a0d7af594e8ec5b278989877b62

$ find .git/objects/84
.git/objects/84
.git/objects/84/81e2030a0f0a0d7af594e8ec5b278989877b62

新しくTree Objectが.git/objectsに作成されていることがわかります。この中身も見てみましょう。

$ git cat-file -p 8481e2030a0f0a0d7af594e8ec5b278989877b62
100644 blob 3fa0d4b98289a95a7cd3a45c9545e622718f8d2b    hello.txt

$ git cat-file -t 8481e2030a0f0a0d7af594e8ec5b278989877b62
tree

ステージされたBlob Objectのハッシュ値とファイル名やモード情報が保存されています。そしてこのファイルのタイプはtreeであることが確認できました。

ではTree ObjectもBlob Objectと同じように実装してみます。必要なのは主に下記です。

  • ファイル名とモードとハッシュ値を管理すること
  • それらのファイルをまとめてGit Objectのフォーマットにすること
  • Git Objectを圧縮して保存すること

Objectのフォーマットはこんな感じです。

tree <データのサイズ>\0<データの内容>

データの内容としてはBlob Objectの時とは少し異なり、ファイル名とモードとハッシュ値をまとめて一つにする必要があります。そのあたりに気を付けつつBlobObjectクラスと同様に実装したものが下記のTreeObjectクラスです。

require 'digest/sha1'
require 'zlib'
require 'fileutils'

class TreeObject
  attr_reader :entries, :sha

  GIT_DIR = '.test_git'

  def initialize
    @entries = []
  end

  def add_blob(name, sha, mode = '100644')
    @entries << Entry.new(mode, name, sha)
    @entries.sort_by!(&:name)
  end

  def add_tree(name, sha)
    @entries << Entry.new('40000', name, sha)
    @entries.sort_by!(&:name)
  end

  def write_to_objects
    object_data = git_object_data

    dir = File.join(GIT_DIR, 'objects', @sha[0..1])
    path = File.join(dir, @sha[2..])

    return if File.exist?(path)

    FileUtils.mkdir_p(dir)
    File.open(path, 'wb') do |f|
      f.write(Zlib::Deflate.deflate(object_data))
    end
  end

  private

  def calculate_sha(content)
    Digest::SHA1.hexdigest("tree #{content.bytesize}\0#{content}")
  end

  def git_object_data
    content = @entries.map(&:to_s).join
    @sha = calculate_sha(content)
    "tree #{content.bytesize}\0#{content}"
  end

  class Entry
    attr_reader :mode, :name, :sha

    def initialize(mode, name, sha)
      @mode = mode
      @name = name
      @sha = sha
    end

    def to_s
      "#{mode} #{name}\0#{[sha].pack('H*')}"
    end
  end
end

<モード> <ファイル名>\0<ハッシュ値>のようなフォーマットにしてEntryを複数追加し、それらを結合したものをtreeタイプのGit Objectとして保存しました。

Commit Object

Commit ObjectはGitにはなくてはならないバージョン管理を実現するためのオブジェクトです。Blob ObjectとTree Objectがあれば、ファイルの中身やファイルの場所やメタ情報をひと繋ぎにして関係性を管理することは可能です。しかし、そこで作成したTree Objectや過去のCommit Object達の前後関係は現状だとわかりません。Commit ObjectはそれらのObject達の前後関係の順序を管理するためのオブジェクトです。

大体予想がつくと思いますが、Commit ObjectはTreeObjectのハッシュ値とその直前のCommit Objectのハッシュ値を元に作成されます。実際に作ってみます。

$ echo 'first commit' | git commit-tree 8481e2030a0f0a0d7af594e8ec5b278989877b62
01fd440b5c3c8377cfb1b44a1fba96ad42d14040

中身を確認してみます。

$ git cat-file -p 01fd440b5c3c8377cfb1b44a1fba96ad42d14040
tree 8481e2030a0f0a0d7af594e8ec5b278989877b62
author Yuhei Nakasaka 1754049861 +0900
committer Yuhei Nakasaka 1754049861 +0900

first commit

なんとなく見たことのあるようなフォーマットですね。

$ git log --stat 01fd440b5c3c8377cfb1b44a1fba96ad42d14040
commit 01fd440b5c3c8377cfb1b44a1fba96ad42d14040
Author: Yuhei Nakasaka
Date:   Fri Aug 1 00:00:00 2025 +0900

    first commit

 hello.txt | 1 +
 1 file changed, 1 insertion(+)

git logコマンドを実行した時に表示される中身と似てますね。commit-treeの中身に書かれているトップレベルのTree Objectのハッシュ値と、Author名、Committer名、コミットメッセージがgit logで表示されているようです。

実は.git/indexに保存されたインデックスを元にTreeObjectの作成とCommit Objectの作成がgit commitコマンドの内部で行われていることになります。

ではCommit Objectも実装してみます。Git ObjectのフォーマットにAuthor名、Committer名、コミットメッセージ、親のハッシュ値を追加したものになる以外はBlob ObjectとTree Objectと同じです。

require 'digest/sha1'
require 'zlib'
require 'fileutils'

class CommitObject
  attr_reader :tree_sha, :parent_sha, :author, :committer, :message, :sha

  GIT_DIR = '.test_git'

  def initialize(tree_sha:, author:, message:, parent_sha: nil)
    @tree_sha = tree_sha
    @parent_sha = parent_sha
    @author = author
    @committer = author
    @message = message
    @timestamp = Time.now
  end

  def git_object_data
    content = "tree #{@tree_sha}\n"
    content += "parent #{@parent_sha}\n" if @parent_sha
    content += "author #{format_user_time(@author, @timestamp)}\n"
    content += "committer #{format_user_time(@committer, @timestamp)}\n"
    content += "\n#{@message}\n"

    @sha = calculate_sha(content)
    "commit #{content.bytesize}\0#{content}"
  end

  def write_to_objects
    object_data = git_object_data

    dir = File.join(GIT_DIR, 'objects', @sha[0..1])
    path = File.join(dir, @sha[2..])

    return if File.exist?(path)

    FileUtils.mkdir_p(dir)
    File.open(path, 'wb') do |f|
      f.write(Zlib::Deflate.deflate(object_data))
    end
  end

  private

  def format_user_time(user, time)
    "#{user[:name]} <#{user[:email]}> #{time.to_i} +0900"
  end

  def calculate_sha(content)
    Digest::SHA1.hexdigest("commit #{content.bytesize}\0#{content}")
  end
end

時間周りの実装はやや雑ですが概ねこれで機能するはずです。

全てを合わせて実行してみる

Blob Object、Tree Object、Commit Objectを実装したので、これらを組み合わせて一気に確認できるスクリプトを作ってみたのが下記です。

require_relative 'blob_object'
require_relative 'tree_object'
require_relative 'commit_object'
require 'fileutils'

# Blob Objectを適当に作成
code_blob = BlobObject.new('Hello, World')
code_blob.write_to_objects

# 上記のBlob ObjectをTree Objectにまとめる
root_tree = TreeObject.new
root_tree.add_blob('hello.txt', code_blob.sha)
root_tree.write_to_objects

# 上記のTree Objectを元にCommit Objectにまとめる
author = { name: 'Yuhei Nakasaka', email: 'yuheinakasaka@example.com' }
first_commit = CommitObject.new(
  tree_sha: root_tree.sha,
  author: author,
  message: 'Initial commit'
)
first_commit.write_to_objects

puts 'Created objects:'
puts "  hello.txt blob: #{code_blob.sha}"
puts "  Root tree: #{root_tree.sha}"
puts "  First commit: #{first_commit.sha}"

# git statusやgit logコマンドを実行できるようにするためのファイル
FileUtils.touch('.test_git/HEAD')
FileUtils.mkdir_p('.test_git/refs/heads')
FileUtils.touch('.test_git/refs/heads/main')
File.write('.test_git/HEAD', 'ref: refs/heads/main')
File.write('.test_git/refs/heads/main', first_commit.sha)

これを実行すると下記のような結果が出力されます。

$ ruby minigit/test.rb
=== Created objects ===
README.md blob: e9ce103753206470d8dce2d89250f71fe9872d76
hello.rb blob: db1ddb0368b4ea22edb48634e04c65314d1a0065
Root tree: 4f6f465ef44675504555cdfc6bd58152f69ec9e1
First commit: 6ac8e4fd1cd5a80e2ef872dd1b25ff7626b7657c

.test_gitも下記のようになっているはずです。

$ tree .test_git
.test_git/
├── HEAD
├── objects
│   ├── 18
│   │   └── 56e9be02756984c385482a07e42f42efd5d2f3
│   ├── 43
│   │   └── 376aeb4d8005325d5cd77d04ef81321a17ee5e
│   └── 69
│       └── 45f9943ec1400130f411b0f80a59af309c7f4e
└── refs
    └── heads
        └── main

実際にgit logコマンドを実行してみます。

$ git --git-dir=.test_git log -p
commit 6945f9943ec1400130f411b0f80a59af309c7f4e (HEAD -> main)
Author: Yuhei Nakasaka <yuheinakasaka@example.com>
Date:   Mon Aug 4 20:03:07 2025 +0900

    Initial commit

diff --git a/hello.txt b/hello.txt
new file mode 100644
index 0000000..1856e9b
--- /dev/null
+++ b/hello.txt
@@ -0,0 +1 @@
+Hello, World
\ No newline at end of file

Rubyで作成したBlob Object、Tree Object、Commit Objectが実際にgit logコマンドで意図通りに認識されていることが確認できました!

まとめ

  • Blob Objectはファイルのスナップショット
  • Tree ObjectはBlob Objectとそれぞれのファイルのディレクトリ構造やメタデータを紐付ける
  • Commit ObjectはTree Objectと過去のCommit Objectを紐付けてバージョン管理を実現する

といったレイヤー構造で分散バージョン管理を実現していることがわかりました。

最後に

Gitが内部でどのようにファイルを管理しているのかをPro GitのGit Internalsの章を参考に実装しつつ確認しました。

先人によりGitの内部実装についての解説はたくさんなされているので、すでに自分もある程度は知ってはいたのですが、今回実際に実装しながら改めて概観してみるとシンプルながらよくできた仕組みだなと感じました。ファイルの中身全てを圧縮して.git/objectsに保存していくだけというかなり力技の実装だということを初めて知った時はびっくりした記憶があります(push時やgcによりpacksへ差分のみまとめられたり.git配下のコンパクションは常に行われます。Pro Gitの後の章ではその辺りについても解説されています。)。

Linus Torvalds氏はこれを10日で作ったというのですから本当に驚きです。下記のGitHubのスタッフエンジニアとの対談によると、実際のところGitの構想自体には4ヶ月程度かけていたようですが、CVSやSVN主流の当時にこれらのアイデアを構想し形に仕上げる手腕はやはり流石すぎます。BitKeeperから着想を得た話やGitのインターフェース自体の複雑さについて苦笑いしながらLinus本人も認めていたりなど、なかなか面白い対談なのでオススメです。
https://www.youtube.com/watch?v=sCr_gb8rdEI

GA technologiesではこのように普段当たり前のように利用しているツールをただ使うのではなく、内部の仕組みまで理解することが好きなソフトウェアエンジニアを求めています。もし興味があれば下記のリンク、もしくは私のXのアカウントまでご連絡ください。

https://ga-technologies-engineering.notion.site/GA-technologies-9794c78bc56c4011a03f7f3dcf91a18f

リソース

株式会社GA technologies

Discussion