この記事はProgate Path コミュニティ Advent Calendar 2025の2日目の記事です。
はじめに
みなさんは、Gitの内部構造について知っていますか?
この記事では、.gitディレクトリのファイルを読み、Gitの内部構造を読み解きながら、簡易版git statusコマンドの実装に挑戦します。
この記事が想定している読者
- 普段使っているGitの内部構造を知りたい人
- コマンドなど、実際に手を動かしながら記事を読みたい人
きっかけ (Progateサマーインターン)
2025年の8月に、株式会社Progateの5daysインターンに参加しました。
Gitの内部構造を理解しながら、自分でgit log, git add, git commitを実装するというテーマで、AsanaやDesign Docを使ったチーム開発を行うという貴重な体験をさせていただきました。
特に、設計だけを行う日がスケジュールとして設定されていることが印象的でした。
チームメンバーとDesign Docを書き、Asanaでタスク分解をしながら作るもののイメージをしていたことで、かなりスムーズに開発をすることができました。
当時TypeScriptに慣れていなかったこともあり、GitHub Copilotに頼り切って実装をしましたが、AIコーディングと今回の設計をしっかり行う方針の相性がよさそうだと実装しながら感じました。
今回は、改めて手を動かしながら学んだことを振り返りたいと思い、当時は実装しなかったgit statusに挑戦します。
Gitの内部構造について
git statusを実装するには
まずはgit statusの挙動を見てみましょう。
$ git status
On branch main
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: hello.txt
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: test.txt
Untracked files:
(use "git add <file>..." to include in what will be committed)
new_file.txt
git statusでは、以下の4つの情報を表示しています。
-
HEADがどこを指しているか
On branch mainで表示されている内容 -
HEADとステージングエリアで差分があるファイルパス
Changes to be committedで表示されている内容 -
ステージングエリアと作業ディレクトリで差分があるファイルパス
Changes not staged for commitで表示されている内容 -
ステージングエリアに存在せず、作業ディレクトリに存在するファイルパス
Untracked filesで表示されている内容
つまり、git statusを実装するには、HEAD, ステージングエリア, 作業ディレクトリの情報を取得し、比較することが必要になりそうです。
では、Gitの内部ではこれらの情報がどのように保存されているかを見ていきます。
.gitディレクトリ
内部構造を見るために、まずは.gitディレクトリを見てみます。
.git
├── HEAD
├── index
├── objects/
│ ├── 07/
│ │ └── 0217db3505746d3214e6a0c47703edac4dbd2e
│ ├── 36/
│ │ └── af755f728166cc71c8b6beac3934b7f84048d2
│ └── ...
├── refs/
│ └── heads/
│ ├── feat/
│ │ └── a
│ └── main
│
├── COMMIT_EDITMSG
├── branches/
├── config
├── description
├── hooks/
├── info/
└── logs/
HEADとrefs
HEADがどこを指すかは、.git/HEADと.git/refs下のファイルを見ると分かります。
$ cat .git/HEAD
ref: refs/heads/main
$ cat .git/refs/heads/main
36af755f728166cc71c8b6beac3934b7f84048d2
ブランチにいるときは、.git/HEAD → .git/refs/head/<ブランチ名>の順に参照することで、HEADが指すコミットのIDが分かります。
※detached HEADにいるときは、.git/HEADにコミットIDが書かれています。
Gitオブジェクト
.git/objects配下の各ディレクトリには、zlib圧縮されたファイルがあります。
ファイル名は、データをSHA-1でハッシュ化して得られた40文字で、最初の2文字がディレクトリ、残りの38文字がファイルパスになっています。
これらはGitオブジェクトと呼ばれます。Gitオブジェクトには、
- commitオブジェクト: コミットの情報
- treeオブジェクト: ディレクトリ構造の情報
- blobオブジェクト: ファイルデータ
- tagオブジェクト (今回は省略)
の4種類があります。
Gitオブジェクトは、格納するコンテンツにヘッダーを加えたデータで構成されています。
ヘッダーはオブジェクトの種類とサイズが書かれており、ヘッダーとオブジェクトはNullバイト\0で区切られています。
commitオブジェクト
例えば、先ほどのコミットIDの場合だと、
$ cat .git/refs/heads/main
36af755f728166cc71c8b6beac3934b7f84048d2
$ ls .git/objects/36/
af755f728166cc71c8b6beac3934b7f84048d2
のように、36という名前のディレクトリに、af755...という名前のファイルでcommitオブジェクトが保存されています。
$ cat .git/objects/36/af755f728166cc71c8b6beac3934b7f84048d2 | zlib-flate -uncompress
commit 219tree c0c17702a7163eeeabc126d5c13f9f5e9210e3e9
parent 070217db3505746d3214e6a0c47703edac4dbd2e
author warisuno <warisuno@example.com> 1762332364 +0900
committer warisuno <warisuno@example.com> 1762332364 +0900
add test
ヘッダーにはcommit 219とオブジェクトの種類とサイズが書かれていますね。
commitオブジェクトは、
-
tree: コミットが作成された時点のスナップショットのトップレベルのtreeオブジェクトのID -
parent: 1つ前のコミットのID -
author,committer: user.name, user.emailの設定とタイムスタンプ - コミットメッセージ (add test)
で構成されています。
treeオブジェクト
commitオブジェクトに書かれていたtreeオブジェクトのIDから、treeオブジェクトの中身を見てみましょう。
# tree c0c17702a7163eeeabc126d5c13f9f5e9210e3e9
$ cat .git/objects/c0/c17702a7163eeeabc126d5c13f9f5e9210e3e9 | zlib-flate -uncompress
tree 73100644 hello.txt�
_�KK0�}B��Xrk`�100644 test.txt�����L�0U��������D��
ヘッダーはtree 73です。肝心のコンテンツデータは一部文字化けしているようですね。これは、バイナリ形式が含まれるためです。
hexdumpコマンドで見てみましょう。
$ cat .git/objects/c0/c17702a7163eeeabc126d5c13f9f5e9210e3e9 | zlib-flate -uncompress | hexdump -C
00000000 74 72 65 65 20 37 33 00 31 30 30 36 34 34 20 68 |tree 73.100644 h|
00000010 65 6c 6c 6f 2e 74 78 74 00 98 0a 0d 5f 19 a6 4b |ello.txt...._..K|
00000020 4b 30 a8 7d 42 06 aa de 58 72 6b 60 e3 31 30 30 |K0.}B...Xrk`.100|
00000030 36 34 34 20 74 65 73 74 2e 74 78 74 00 9d ae af |644 test.txt....|
00000040 b9 86 4c f4 30 55 ae 93 be b0 af d6 c7 d1 44 bf |..L.0U........D.|
00000050 a4 |.|
00000051
git cat-file -p <object_id>というコマンドで、Gitオブジェクトを整形して出力できるので、こちらのコマンドでも見てみます。
$ git cat-file -p c0c17702a7163eeeabc126d5c13f9f5e9210e3e9
100644 blob 980a0d5f19a64b4b30a87d4206aade58726b60e3 hello.txt
100644 blob 9daeafb9864cf43055ae93beb0afd6c7d144bfa4 test.txt
40文字のSHA-1ハッシュを20文字のバイナリ形式に変換しているため、先ほど文字化けが起きていました。
バイナリのこの部分が、SHA-1ハッシュですね
00000010 65 6c 6c 6f 2e 74 78 74 00 98 0a 0d 5f 19 a6 4b |ello.txt...._..K|
00000020 4b 30 a8 7d 42 06 aa de 58 72 6b 60 e3 31 30 30 |K0.}B...Xrk`.100|
treeオブジェクトは、
- mode: 権限を
100644のように数字で表しています - ファイルパス
- オブジェクトのID
で構成されています。
※ blobやtreeは、git cat-file -pの処理でmodeから判断して出力されています。
サブディレクトリがあると、treeオブジェクトの中にtreeオブジェクトが格納されます。
$ git checkout feat/a
Switched to branch 'feat/a'
# feat/aブランチで、featというサブディレクトリを作成しました
$ tree
.
├── feat
│ └── a.txt
├── hello.txt
└── test.txt
2 directories, 3 files
# HEADが指すcommitのオブジェクトから、treeオブジェクトのIDを確認し、実際に出力してみます
$ git cat-file -p fab796ecaedf476dbf453eb1d5f31adf65eb637a
040000 tree a2617a411c9df8cf26bb56781dbd837eb4d09123 feat
100644 blob 980a0d5f19a64b4b30a87d4206aade58726b60e3 hello.txt
100644 blob 9daeafb9864cf43055ae93beb0afd6c7d144bfa4 test.txt
# ↑treeオブジェクトの中にtreeオブジェクトが格納されていますね
blobオブジェクト
treeオブジェクトに書かれていたblobオブジェクトのIDから、blobオブジェクトの中身を見てみましょう。
# 100644 blob 980a0d5f19a64b4b30a87d4206aade58726b60e3 hello.txt
$ cat .git/objects/98/0a0d5f19a64b4b30a87d4206aade58726b60e3 | zlib-flate -uncompress
blob 13Hello World!
ヘッダーがblob 13、その後は、ファイルデータが続きます。実際にファイルを見ると、確かに
$ cat hello.txt
Hello World!
となっています。
index
ステージングエリアの情報は、.git/indexファイルに記載されています。
このファイルもバイナリ形式で、その構造は以下のドキュメントで説明されています。
実際のファイルを確認しながら、構造を見ていきましょう。
$ hexdump -C .git/index
00000000 44 49 52 43 00 00 00 02 00 00 00 02 69 1c 17 18 |DIRC........i...|
00000010 04 51 cc cc 69 1c 17 18 04 51 cc cc 00 00 08 30 |.Q..i....Q.....0|
00000020 00 00 30 dc 00 00 81 a4 00 00 03 e8 00 00 03 e8 |..0.............|
00000030 00 00 00 0d 98 0a 0d 5f 19 a6 4b 4b 30 a8 7d 42 |......._..KK0.}B|
00000040 06 aa de 58 72 6b 60 e3 00 09 68 65 6c 6c 6f 2e |...Xrk`...hello.|
00000050 74 78 74 00 69 1c 17 0e 2c 66 3f 29 69 1c 17 0e |txt.i...,f?)i...|
00000060 2c 66 3f 29 00 00 08 30 00 00 2f f0 00 00 81 a4 |,f?)...0../.....|
00000070 00 00 03 e8 00 00 03 e8 00 00 00 05 9d ae af b9 |................|
00000080 86 4c f4 30 55 ae 93 be b0 af d6 c7 d1 44 bf a4 |.L.0U........D..|
00000090 00 08 74 65 73 74 2e 74 78 74 00 00 54 52 45 45 |..test.txt..TREE|
000000a0 00 00 00 19 00 32 20 30 0a c0 c1 77 02 a7 16 3e |.....2 0...w...>|
000000b0 ee ab c1 26 d5 c1 3f 9f 5e 92 10 e3 e9 e2 d8 9f |...&..?.^.......|
000000c0 00 1a 65 20 02 4e d9 dd fb 81 40 9b 1a 46 7b 38 |..e .N....@..F{8|
000000d0 c2 |.|
000000d1
indexは、ヘッダーとインデックスエントリーから構成されます。
ヘッダーは12バイトで、
- DIRCの文字 (4-byte)
- サポートするバージョン(2, 3, 4) (4-byte)
- インデックスエントリーの個数 (32-bit)
で構成されます。
ヘッダーの後ろに、インデックスエントリーの1つ目が続いています。
インデックスエントリーは、各ファイルの情報が記載されています。
構成としては、
- ファイルのメタデータ (作成・更新時刻や、modeなど)
- オブジェクトのID
- flags (ファイル名の長さなど)
- ファイルパス
- padding: エントリーの長さを8の倍数にするため、Nullバイト
\0を埋める
となっています。
よく見てみると、
00000030 00 00 00 0d 98 0a 0d 5f 19 a6 4b 4b 30 a8 7d 42 |......._..KK0.}B|
00000040 06 aa de 58 72 6b 60 e3 00 09 68 65 6c 6c 6f 2e |...Xrk`...hello.|
と、先ほど見たhello.txtのblobオブジェクトのIDが確かに入っていますね。
※ インデックスエントリーの後ろに、TREEが続いていますが、これはCache Treeという拡張データです。
また、末尾20バイトには、ファイルが壊れていないかを確認するチェックサムがあります。
実装開始!
今回、簡単のため、以下の条件でgit statusの実装を行います
-
.gitignoreを考慮しない - サブディレクトリがないリポジトリ
-
git init,git add,git commitが行われた状態のリポジトリ - HEADがdetached HEADを指していない状態に限定
- Git Index Versionを2に限定
-
git statusで表示される"rename"を実装対象外にする
何が必要か
表示する内容を改めて確認します
- HEADがどこを指しているか
On branch mainで表示されている内容
これは、HEADとrefsを見ればいいですね。
- HEADとステージングエリアで差分があるファイルパス
Changes to be committedで表示されている内容- ステージングエリアと作業ディレクトリで差分があるファイルパス
Changes not staged for commitで表示されている内容- ステージングエリアに存在せず、作業ディレクトリに存在するファイルパス
Untracked filesで表示されている内容
これらは、
- HEADのcommit → tree → 各blobのIDを見る
- indexの各ファイルのblobのIDを見る
- 作業ディレクトリの各ファイルをSHA-1ハッシュ化する
の3つを比較することで実装できそうです。
そこで今回は、HEAD, index, 作業ディレクトリのそれぞれを、ファイルパスをkeyにしたSHA-1ハッシュの辞書型に変換するコードを書く方法を採用してみます。
/**
* ファイルパスをkeyにした、SHA-1ハッシュの辞書型
*/
type FileMap = Map<string, string>;
以下をimportしています。
import { createHash } from "crypto";
import { readdirSync, readFileSync, statSync } from "fs";
import { inflateSync } from "zlib";
HEAD
まずはHEADのcommit IDを取得します。
(対応する説明箇所: HEADとrefs)
/**
* HEADのcommit IDを取得する
*/
const getHEAD = (): string => {
// .git/HEADからrefsを取得
// detached HEADを考慮しない
const headContent = readFileSync(".git/HEAD", "utf8").trim();
const ref = headContent.replace("ref: ", "");
const branchName = ref.slice("refs/heads/".length);
console.log(`On branch ${branchName}`);
// .git/refs/heads下から、HEADのブランチが指すcommit IDを取得
const headCommitId = readFileSync(
`.git/refs/heads/${branchName}`,
"utf8"
).trim();
return headCommitId;
};
commit IDを取得したので、そのcommitオブジェクトを参照 → treeオブジェクトを参照して、目的のFileMapを作成します。
(対応する説明箇所: Gitオブジェクト)
Gitオブジェクトのファイルを取得するコードを実装します。
/**
* SHA-1ハッシュから.git/objects下のファイルを取得する
* zlib圧縮を展開する
* バイナリデータで返却する
*/
const getGitObject = (objectId: string | undefined): Buffer<ArrayBuffer> => {
if (objectId === undefined) {
throw new Error("objectId is undefined");
}
const objectDir = objectId.slice(0, 2);
const objectPath = objectId.slice(2);
const GitObject = readFileSync(`.git/objects/${objectDir}/${objectPath}`);
// zlib展開
const content = inflateSync(Uint8Array.from(GitObject));
return content;
};
このメソッドを使い、実際にGitオブジェクトの中身を処理して、以下のようなコードでFileMapを実装します。
/**
* commitObjectに対応するtreeObjectから、FileMapを作る
*/
const getFileMapHEAD = (commitId: string): FileMap => {
const commitObject = getGitObject(commitId).toString("utf8");
// treeのIDを取得し、対応するオブジェクトを取得
const treeId = commitObject.split("\n")[0]?.split("tree ")[1];
if (treeId === undefined) {
throw new Error("treeId is undefined");
}
const treeObject = getGitObject(treeId);
const fileMap: FileMap = new Map();
let offset = 0;
// 最初のNullバイトは、ヘッダーとコンテンツの境界
const headerNull = treeObject.indexOf(0, offset);
offset = headerNull + 1;
while (offset < treeObject.length) {
// 以降のNullバイトは、ファイルのメタデータとオブジェクトIDの境界
const boundaryNull = treeObject.indexOf(0, offset);
const meta = treeObject.toString("utf8", offset, boundaryNull);
// サブディレクトリを考慮せず、すべてのmodeが100644と仮定
const filePath = meta.slice("100644 ".length);
// 20文字のバイナリ形式に変換されたSHA-1ハッシュを取得
const blobId = treeObject.toString(
"hex",
boundaryNull + 1,
boundaryNull + 21
);
fileMap.set(filePath, blobId);
offset = boundaryNull + 21;
}
return fileMap;
};
これで、HEADの情報を取得できました。
index
次にindexのFileMapを作成します。
(対応する説明箇所: index)
/**
* IndexからFileMapを作る
*/
const getFileMapIndex = (): FileMap => {
const gitIndex = readFileSync(".git/index");
const numberofEntries = gitIndex.readUint32BE(8);
const indexEntries = gitIndex.subarray(12);
const fileMap: FileMap = new Map();
let offset = 0;
for (let i = 0; i < numberofEntries; i++) {
// ファイルのメタデータは固定長 (Git Index Version 2に限定)
const blobIndex = offset + 40;
const filePathIndex = offset + 62;
// ファイルパスの直後にpaddingでNullバイトが入る
const filePathEndIndex = indexEntries.indexOf(0, filePathIndex);
const filePath = indexEntries.toString(
"utf8",
filePathIndex,
filePathEndIndex
);
const blobId = indexEntries.toString("hex", blobIndex, blobIndex + 20);
fileMap.set(filePath, blobId);
// paddingによってエントリー全体の長さは8の倍数
const entryLength = 62 + filePath.length;
const padding = 8 - (entryLength % 8);
offset += entryLength + padding;
}
return fileMap;
};
indexは必要な部分だけを抽出するために、40や62といったマジックナンバーを含むコードになってしまっています。ぜひ説明と見比べて、どこのバイナリを取得しているのか確認してみてください。
indexのFileMapもこれで作成できました。
作業ディレクトリ
最後は作業ディレクトリです。
SHA-1ハッシュは、blobオブジェクトのヘッダー部分も含めてハッシュ化した値であることに注意して実装します。
/**
* working dirからFileMapを作る
*/
const getFileMapWorkingDir = (): FileMap => {
const fileMap: FileMap = new Map();
const files = readdirSync(".").filter((f) => f !== ".git");
for (const file of files) {
if (!statSync(file).isFile()) {
throw new Error(
`Get sub-directory: ${file}, use this script for repository without sub-directory`
);
}
const fileData = readFileSync(file);
const blobHeader = Buffer.from(
`blob ${fileData.length}\0`,
"utf8"
);
const sha1 = createHash("sha1")
.update(Buffer.concat([blobHeader, fileData]))
.digest("hex");
fileMap.set(file, sha1);
}
return fileMap;
};
簡易版git statusの完成
これで3つのFileMapを作成できたので、それぞれ比較した結果を出力すれば、簡易版git statusの完成です。
コードが長くなってしまったので、比較部分の詳細は省略しています。
for (const file of allFiles) {
const headHash = fileMapHEAD.get(file);
const indexHash = fileMapIndex.get(file);
const workHash = fileMapWorkingDir.get(file);
if (!indexHash && workHash) {
// Untracked
} else {
if (indexHash !== workHash) {
// Not staged
}
if (indexHash !== headHash) {
// To be committed
}
}
}
コマンド実行の出力結果
git status
$ git status
On branch main
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: hello.txt
Changes not staged for commit:
(use "git add/rm <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
deleted: test.txt
Untracked files:
(use "git add <file>..." to include in what will be committed)
new_file.txt
- 今回実装したもの
$ npx ts-node ~/project/qiita-git-status/src/main.ts
On branch main
Changes to be committed:
modified: hello.txt
Changes not staged for commit:
deleted: test.txt
Untracked files:
new_file.txt
一部フォーマットが違う部分はありますが、基本的な部分は実装できました。
まとめ
ここまで読んでくださりありがとうございます。
バイナリ関連の実装に苦労しましたが、何とか形として動くものができました。
Gitの基本的な内部構造と、git statusのコア部分について理解が深まっていれば幸いです。
もし余力がある方、もっと知りたい方は、今回省略・簡略化した部分についてもぜひ調べて、実装にチャレンジしてみてください。
参考
この記事を書く際に、参考にした記事です。
この記事はインターン期間中にも参考にさせていただいたものです。
こちらのドキュメントも参考にしました。