.gitignore ファイルとは
.gitignore ファイルは、どのファイルをバージョン管理の対象外にするかを Git に伝えるテキストファイルです。各行がパターンで — *.log、node_modules/、.env など — パターンに一致する未追跡のパスは git status から消え、git add . でも飛ばされ、コミットされることはありません。
この説明の意味をほぼ一手に担っているのが 未追跡 という言葉です。そして「gitignore が効かない」という事例は、パターンの書き間違いすべてを合わせたよりも、この一語だけで説明できるものの方が多いのです。Git が .gitignore を参照するのは、まだ追跡していないファイルに対してだけです。いったんコミットされたファイルは、あとから .gitignore に何を書き足しても、Git がずっと追跡し続けます。
Gitignore Debugger でできること
ファイルパスと .gitignore の内容を貼り付けてください。この .gitignore デバッガーは、Git と同じ判定 — 無視される / 無視されない — を返し、その判定を下した正確なルールと行番号も示します。さらに、そのパスに一致するすべてのルールを Git が評価する順に並べ、実際に決着をつけたルールを指し示します。
この最後の部分が、単なる gitignore チェッカーには欠けているところです。ファイルが無視されていること自体は、めったに難所になりません。難しいのは、40 行あるルールの どれ がそうしたのか、そして期待していたルールがなぜ負けたのかを知ることです。これはどちらの向きでも役に立ちます。コミットしたいのに Git が無視してしまう理由も、消したいのに無視されない理由も、同じように調べられます。
エンジンは Git のパターン照合規則を再実装しています。ベース名で照合するパターンとアンカーされたパターンの違い、* が / を絶対に越えないこと、** の 3 つの形、ディレクトリ限定を意味する末尾の /、文字クラス、エスケープされた末尾の空白まで扱います。その挙動は git check-ignore の実際の出力と突き合わせて検証しており、推測ではありません。
使い方
- パスはリポジトリ内での表記どおりに、ルートからの相対パスで入力してください —
src/config/local.jsonのように書き、C:\projects\app\src\config\local.jsonのようには書きません。区切りはスラッシュを使ってください。Windows のバックスラッシュはこちらで変換します。 .gitignoreは 全体 を貼り付けてください。怪しいと思っている 1 行だけに削らないこと。本当の原因はたいていルール間の優先順位にあり、ファイルを削ればその情報こそが失われます。- まず判定を読み、その下のルールの連鎖を追ってください。効かない gitignore ルールが謎でなくなるのはここです。
ファイルではなくディレクトリに対して gitignore のルールを試すときは、パスの末尾をスラッシュにしてください — build/ のように。この区別は重要です。末尾が / のルールはディレクトリにしか一致しません。
gitignore のルールが効かないことがある理由
だいたい多い順に並べた原因はこちらです。
- そのファイルがすでに追跡されている。
.gitignoreは遡って効くものではありません。ルールを書く前にコミットされていたファイルには、ルールがまるごと無視されます。git rm --cached path/to/fileを実行してコミットしてください。ファイルはディスクに残り、Git は追跡をやめます。 - あとのルールに上書きされた。Git は最初に一致したパターンで止まりません。
- 親ディレクトリが無視されている。ファイルのために書いたルールには、そもそも到達しません。
- 自由に一致すると思っていたパターンが、実はアンカーされている。末尾以外の位置にスラッシュを含むパターンは、その
.gitignoreがあるディレクトリにアンカーされます。つまりsrc/*.logはsrc/debug.logに一致しますが、それより深いものには一致しません。スラッシュをまったく含まないパターンはベース名で任意の深さに一致するので、*.logはdebug.logもsrc/debug.logもa/b/c/debug.logも捕まえます。 - パターンが不正な形をしている。
[abcのように閉じていない文字クラスは、リテラルの[として扱われることはありません。Git は照合を諦め、そのルールは何にも一致しないまま黙って終わります — 書かれていないルールと区別がつきません。そのためデバッガーでも、一致したルールの一覧にはけっして現れません。
gitignore のルールの優先順位: 最後に一致したものが勝つ
これが誰もが引っかかるところです。同じ .gitignore の中では、あるパスに一致した 最後 のパターンがその運命を決めます。最初のパターンではありません。次の 2 つのファイルは同じルールを含んでいますが、important.log に対して正反対の結果になります。
*.log
!important.log → important.log はコミットされる!important.log
*.log → important.log は無視されるですから、広く効くルールを先に、例外をあとに書いてください。ファイル同士の関係では、サブディレクトリにある .gitignore が、その配下のパスについては上位のものに勝ちます。Git はさらに、リポジトリ固有でコミットされない .git/info/exclude と、グローバルな core.excludesFile も読みます。どの .gitignore を探しても見つからないルールは、この 2 か所のどちらかに隠れていることがよくあります — git check-ignore -v なら、決着をつけたファイルと行がわかります。
否定ルールと、! がよく失敗する理由
パターンの先頭に ! を付けると、前のルールが無視したものを再び含められます。これには 2 つの限界があり、2 つ目が「gitignore の否定が効かない」というケースのほぼすべてを説明します。
1 つ目は、それより前で何かがそのパスを無視していなければならないこと。!important.log だけを書いても何も起きません。上書きすべき対象がもともと存在しないからです。
2 つ目は、はるかに意外な点です。無視されたディレクトリの中にあるファイルは、再び含めることができません。次の書き方は効かず、順序を入れ替えても直りません。
build/
!build/keep.txtbuild/ が無視されていると、Git はそこへ入りません。build/keep.txt を調べないので、2 行目にはけっして到達しません。代わりにディレクトリの 中身 を無視してください。そうすればディレクトリ自体はたどれるままになります。
build/*
!build/keep.txtもっと深いところにあるものを救い出したいときは、途中のディレクトリもすべてたどれるままにしておく必要があります — build/*、次に !build/assets/、次に !build/assets/logo.svg のように。
無視された親ディレクトリ
Git のディレクトリ走査は、最初に出会った無視対象のディレクトリで止まり、その配下はすべて中身を調べられることなく判定を受け継ぎます。だからこそ、無視された親ディレクトリは、ファイル自体に対して書いたどんなルールにも勝ちます。どれほど具体的に見えるルールであっても同じです。
その場合デバッガーは、単に「無視されています」と言うのではなく、走査を止めた親ディレクトリの名前を示します。そのうえで、ファイルのために書いた否定ルールも到達不能として表示します。Git がそこまで降りてこないため、検討さえされないからです。まずディレクトリを直せば、その配下のルールは自然と効くようになります。
.gitignore でよくある間違い
- ファイルをコミットしたあとにルールを足す。ルール自体は正しく、そして何の効果もありません。上の
git rm --cachedを参照してください。 - 行末コメント。
#がコメントを開始するのは行頭だけです。したがって*.log # debug outputは 1 本の長いパターンであり、手元のどのファイルにも一致しません。 - 区切りにバックスラッシュを使う。
.gitignoreの中では\はエスケープ文字で、パスの区切りではありません。src\configはsrcconfigというリテラルな名前を意味します。つねにsrc/configと書いてください。 *がディレクトリを越えると思い込む。*はスラッシュで止まるので、src/*/test.jsは1 階層しか降りません。任意の深さに一致させるにはsrc/**/test.jsを使います。- 末尾のスラッシュを忘れる。
logsはlogsという名前のファイルにもディレクトリにも一致します。logs/はディレクトリだけに一致します。 - 無視すれば履歴が消えると考える。ルールが止められるのはこの先のコミットだけです。すでにプッシュした秘密情報は消えません — 資格情報を差し替えてください。
実例をいくつか
このルールに対して src/logs/debug.log は無視されません。否定が最後の一致であり、走査を止める親ディレクトリもないので、否定に到達して勝ちます。
*.log
!src/logs/debug.logこのルールに対して build/keep.txt は無視されます。1 行目の build/ が親ディレクトリに一致するためです。2 行目はけっして発動しません。
build/
!build/keep.txtこのルールに対して docs/api/notes.md は無視されません。パターンがスラッシュによってアンカーされており、* は api/notes.md のスラッシュを越えないからです。意図していたのは docs/**/*.md でしょう。
docs/*.md上の gitignore テスターにこれらの例を貼り付ければ、ルールの連鎖をまるごと確認できます。ターミナルに戻れば、git check-ignore -v path/to/file が組み込みの同等機能です。決着をつけたソースファイル、行番号、パターンを教えてくれるので、覚えておく価値があります。