目次
tmp/ や uploads/ といった空のディレクトリを作ってコミットしたのに、別マシンで git clone したらそのディレクトリだけ消えていました。アプリ側はそのパスがある前提で動くので、起動した瞬間にエラーになります。最初にこれを踏んだとき、自分のコミット漏れを疑ってログを何度も見返しましたが、コミットはちゃんと入っていました。原因は Git の仕様で、対処法が .gitkeep という見慣れないファイルでした。
TL;DR
- Git はファイルを記録する仕組みで、ディレクトリ単体を管理単位として持たない。だから中身が空のディレクトリは記録されず、クローン先に現れない
- 空ディレクトリにダミーのファイルを1個置けば、そのファイル経由でディレクトリごと追跡される。
touch path/to/dir/.gitkeepで作る .gitkeepは Git の公式機能ではなく、ただの慣習。名前は.keepでも何でもよく、Git が特別扱いしているわけではない
Git は何を記録しているのか
Git は「リポジトリの状態をファイル単位のスナップショットで記録する」仕組みです。コミットの中身を辿ると、最終的に記録されているのはファイルのパスと内容であって、ディレクトリそのものは独立した管理単位として持っていません。ディレクトリは「そこに属するファイルのパスの一部」として表現されるだけです。
src/main.py というファイルをコミットすれば、結果として src/ が存在するように見えます。でもそれは src/ を記録したからではなく、src/main.py というパスを記録した副作用にすぎません。src/ の中身を全部消してファイルが1つも無くなれば、Git にとって src/ は記録すべきものがゼロになります。
これが空ディレクトリが追跡されない理由の核心です。中身が空ということは、記録対象のファイルパスが1つも無いということで、Git から見れば最初から存在しないのと変わりません。だから git add してもステージに何も乗らないし、コミットにも入らず、当然クローン先にも現れません。
手元で確かめると分かりやすいです。空の emptydir を作って git add してみても、ステータスには何も出てきません。
$ mkdir emptydir
$ git add emptydir
$ git status --porcelain
(何も表示されない)
エラーも警告も出ないので、追跡できたつもりになってしまいます。自分が最初にハマったのもここで、git add が成功した(ように見えた)ことでコミットに入っていると思い込んでいました。
空ファイルを1つ置いて追跡させる
仕組みが分かれば対処は単純で、空ディレクトリにファイルを1つ置けばいいだけです。ファイルが1個でもあれば、Git はそのパス経由でディレクトリを認識して追跡します。慣習的に置かれるのが .gitkeep という空ファイルです。
$ touch emptydir/.gitkeep
$ git add emptydir/.gitkeep
$ git status --porcelain
A emptydir/.gitkeep
さっきは何も出なかったステータスに A emptydir/.gitkeep(追加されたファイル)が出ます。これでコミットすれば、クローン先にも emptydir/ がちゃんと現れます。ディレクトリを追跡したというより、中のファイルを追跡した結果としてディレクトリが付いてきた、という形です。
.gitkeep は Git の公式機能ではない
ここが一番の誤解ポイントだと思います。.gitkeep という名前を見ると、.gitignore や .gitattributes の仲間で Git が特別扱いするファイルに見えます。でも違います。
.gitignore は Git が名前で認識して「ここに書かれたパターンは無視する」と振る舞う、Git に組み込まれた仕組みです。一方 .gitkeep には、Git 側に何の特別な処理もありません。Git からすれば名前のたまたま .gitkeep だっただけの、ただの空ファイルでしかありません。「ディレクトリを keep する」みたいな機能が発動しているわけではなく、単に「ファイルが1個あるからディレクトリが追跡対象になった」だけです。
git-tower のFAQ もこの点をはっきり書いています[1]。
Unlike the
.gitignorefile or the.gitfolder, the.gitkeepfile is not part of the Git tool, the Git standard or any Git API.
.gitignore や .git フォルダと違って、.gitkeep は Git というツールにも Git の標準にも、いかなる Git の API にも含まれていない、ということです。
だからファイル名は本当に何でも構いません。.keep でも placeholder.txt でも、中身が空の何かであれば同じように機能します。それでも .gitkeep がデファクトで広まったのは、.git という接頭辞が「これは Git の都合で置いてあるファイルで、アプリのデータではない」という意図を他の開発者に伝えやすいからでしょう。Rails をはじめ各種フレームワークが空ディレクトリの保持に .gitkeep を使ってきた文化的な経緯も大きいです。機能ではなく、チーム内で意図が伝わる名札としての慣習、という理解でいます。
.gitignore と組み合わせて「箱だけ残す」
実務でよく出てくるのが、logs/ や uploads/ のように ディレクトリ自体は欲しいが、中に溜まるファイルは追跡したくない ケースです。ログ置き場やアップロード先は、空の入れ物だけリポジトリに含めておいて、実際に生成されるファイルは Git の管理外にしたいわけです。
このとき .gitkeep と .gitignore を組み合わせます。ポイントは .gitignore の否定パターン(! で始まる行)で、.gitkeep だけを無視対象から除外することです。
# logs ディレクトリの中身は全部無視するが、
# 箱を残すための .gitkeep だけは追跡する
logs/*
!logs/.gitkeep
logs/* で logs/ 配下を全部無視し、!logs/.gitkeep でそのうち .gitkeep だけ無視を打ち消します。こうすると、ディレクトリの存在を保つ .gitkeep は追跡され、実際のログファイルは無視されます。手元で確かめるとこうなります。
$ touch logs/.gitkeep logs/app.log
$ git add -A
$ git status --porcelain
A .gitignore
A logs/.gitkeep
logs/app.log はステージに乗らず、.gitkeep だけが追跡されます。狙いどおり「箱だけ残してログは無視」になっています。
ちなみに .gitkeep を使わず、プレースホルダ自体を .gitignore にしてしまう手もあります。空ディレクトリに .gitignore を1つ置いて、中にこう書きます。
*
!.gitignore
* で同じディレクトリ内を全部無視し、!.gitignore でこのファイル自身だけ追跡します。これだと「ディレクトリを残す」のと「中身を無視する」を1ファイルで兼ねられます。実は公式の Git FAQ もプレースホルダには .gitkeep ではなくこの .gitignore を置くやり方を案内しています[2]。Git の流儀に寄せるなら .gitignore、チーム内の意図の伝わりやすさを取るなら .gitkeep、くらいの温度差です。
自分は、中身を無視したいディレクトリ(logs/ 等)はディレクトリ内 .gitignore 方式、純粋に空の箱を残したいだけ(tmp/ の初期化用など)のときは .gitkeep 方式、と使い分けに落ち着きました。どちらか一方に揃えないと混乱しそうに見えて、意図が「無視も兼ねるか/箱だけか」で分かれるので、結果的にこの2系統で迷わなくなりました。
最初に消えたディレクトリを見たときは Git のバグを疑いましたが、ファイルしか記録しないという一点を知っていれば当然の挙動でした。空の箱を残したくなったら .gitkeep を1個放り込む、それだけ覚えておけば足ります。
