はじめに — 無視しろと書いたのに上がり続けます
無視リストに1行書き、ファイルを確認し、タイプミスもありません。それなのに状態確認には相変わらずそのファイルが引っかかります。ここでたいていの人は規則を疑い、アスタリスクを1つ足したりスラッシュをあちこち動かしたりします。
問題はほとんど常に規則ではありません。無視リストは、Gitがまだ追跡していないパスにだけ適用されます。一度でもコミットされたファイルは、規則をどれだけ精密に書いても上がり続けます。この記事はその原因の確認から始め、そのあとで実際にパターンが間違っている場合を扱います。
原因1位 — すでに追跡中のファイルには適用されない
まず診断からです。追跡中でありながら同時に無視規則に引っかかっているファイルの一覧を出す命令があります。検索ではあまり出てきませんが、この問題の答えに最も近い1行です。
$ git ls-files -i -c --exclude-standard
config/local.env
build/out.js
node_modules/.package-lock.json
ここに名前が出てきたなら規則は正常であり、そのファイルがインデックスに入っていることが原因です。インデックスから外すだけで済みます。
$ git rm --cached config/local.env
rm 'config/local.env'
$ git status --short
D config/local.env
?? config/local.env
ディスク上のファイルはそのままで、インデックスからだけ外れました。ところが状態表示の1行目をよく見る必要があります。この操作は削除をステージングします。コミットして上げると、そのブランチを取得する同僚の作業ディレクトリから当該ファイルが実際に消えます。無視リストに入っていても同じです。
設定ファイルにこの命令を使う場合が特に危険です。同僚のローカル接続情報が予告なく消え、サービスが立ち上がらず、原因を探すのに半日かかります。実務では2つを一緒にやる必要があります。1つは例示ファイルを別途追跡することであり、もう1つはコミットメッセージと告知に明記することです。
$ cp config/local.env config/local.env.example # 値は消してキーだけ残した状態で
$ git add config/local.env.example
$ git commit -m "chore: 로컬 환경 파일 추적 해제, 예시 파일 추가"
ディレクトリ全体を外す必要があるなら再帰オプションを使います。
$ git rm -r --cached node_modules
rm 'node_modules/.package-lock.json'
rm 'node_modules/.bin/tsc'
...
無視規則を大々的に手直ししたなら、インデックスを丸ごと作り直す方法もあります。ただしこの方法は改行設定やファイルモードの変更まで一緒に出てくる場合があるので、コミット前に必ず変更一覧を確認してください。
$ git rm -r --cached .
$ git add .
$ git status --short | head
ここでよく見かける誤った助言を1つ指摘します。追跡中のファイルのローカル修正だけを隠したいとき、下の命令がよく勧められます。
$ git update-index --assume-unchanged config/local.env
$ git update-index --skip-worktree config/local.env
前者は無視の機能ではなく性能の最適化です。このファイルは変わらないから確認しなくてよいとGitに約束するものであり、ブランチを変えたり取得したりする過程でGitがそのファイルを上書きしても何の警告もありません。後者は少し保守的ですが、そのパスを必ず更新しなければならない作業に出会うと分からないエラーで止まります。どちらも他人には伝わりません。追跡を維持したまま無視する方法はありません。追跡を切って例示ファイルを置くのが、唯一メンテナンスされる答えです。
どの規則が犯人なのかGitに直接尋ねる
パターンを目で読んで推理する必要はありません。どのファイルの何行目がこのパスを捕まえたのかを教えてくれる命令があります。
$ git check-ignore -v build/out.js
.gitignore:2:build/ build/out.js
ファイル名、行番号、規則、対象パスの順です。ところがここに罠があります。
$ git check-ignore -v config/local.env
$ echo $?
1
何も出力しません。規則が間違っているように見えますが、そうではありません。この命令は基本的にインデックスを参照し、追跡中のパスは無視の対象ではないと判断して静かに素通りします。規則そのものを試すには、インデックスを無視するよう伝える必要があります。
$ git check-ignore -v --no-index config/local.env
.gitignore:5:*.env config/local.env
規則は最初から正常でした。この2つの出力の差こそ、原因1位を確認する最も速い方法でもあります。インデックスを無視したときだけ規則が出てくるなら、そのファイルは追跡中です。
全体像を見たいときは状態確認にオプションを付けます。
$ git status --ignored --short
M src/app.ts
?? refund.ts
!! build/
!! .env
パターン文法の精読 — スラッシュの位置と否定パターンが無力化される規則
規則が本当に間違っている場合に移ります。無視パターンでの混乱のほとんどは、スラッシュがどこにあるかから生まれます。
| パターン | 意味 | 引っかかる例 | 引っかからない例 |
|---|---|---|---|
logs | 名前がlogsのファイルとディレクトリを、あらゆる深さで | logs, src/logs | logs.txt |
logs/ | ディレクトリだけを、あらゆる深さで | logs/, src/logs/ | 同じ名前の通常ファイル |
/logs | この規則ファイルと同じ位置のlogsだけ | logs | src/logs |
doc/*.txt | スラッシュがあるので位置固定、1段だけ | doc/note.txt | doc/api/note.txt |
build/* | build内の項目、build自体は除外対象ではない | build/out.js | build |
!keep.log | 先行する規則を取り消す | 上位が除外されていないときだけ | 上位ディレクトリが除外されている場合 |
表の3行目と4行目が核心です。パターンのなかにスラッシュが1つでもあれば、そのパターンは規則ファイルのある位置を基準に固定され、スラッシュがまったくなければどの深さでも引っかかります。4行目のアスタリスクはスラッシュを越えられないので1段だけマッチします。複数の段を飛び越えるには二重のアスタリスクが必要です。
# 規則ファイルの位置を基準に、doc の下なら何段でも引っかかります
doc/**/*.txt
# あらゆる深さの build ディレクトリ (スラッシュのないパターンと同じ意味)
**/build
# build の下のすべて (build ディレクトリ自体は対象ではない)
build/**
# コメントはシャープで始まります。ファイル名がシャープで始まるならバックスラッシュで退避します
\#important.txt
# 末尾に付いた空白は無視されます。生かすならバックスラッシュを付けます
trailing\
否定パターンが無力化される1つの規則
空のディレクトリを保つために下のように書く場合が非常によくあります。
$ cat .gitignore
build/
!build/keep/.gitkeep
$ git check-ignore -v build/keep/.gitkeep
.gitignore:1:build/ build/keep/.gitkeep
否定パターンが下の行にあるのに、上の規則が勝ちました。順序の問題ではありません。Gitは除外されたディレクトリの中へそもそも降りていきません。ディレクトリの段階でふるい落とすので、その中のファイルに対する規則は読まれもしません。文書にも、上位ディレクトリが除外されたファイルは再び含められないと明記されています。
解決は、ディレクトリではなく中身を除外し、生かしたいパスを段ごとに開けてやることです。
$ cat .gitignore
build/*
!build/keep/
build/keep/*
!build/keep/.gitkeep
$ git check-ignore -v build/keep/.gitkeep
.gitignore:4:!build/keep/.gitkeep build/keep/.gitkeep
$ git status --short --ignored
?? build/keep/.gitkeep
!! build/out.js
同じ理由で下の形もよく失敗します。あとの規則が勝つのは正しいのですが、上位がディレクトリごと塞がれていると、その規則に到達できません。
$ cat .gitignore
logs/
!logs/app.log
$ git check-ignore -v logs/app.log
.gitignore:1:logs/ logs/app.log
無視規則の優先順位の階層
規則は1つのファイルだけにあるわけではありません。Gitは複数の出所を順に確認し、前で決着がつけばあとは見ません。高いほうからこうなっています。
1つ目はコマンドラインで与えたパターンです。整理の命令やファイル一覧の照会に付ける除外オプションがこれにあたります。2つ目は対象パスと同じディレクトリの規則ファイルであり、なければ上位へ上がります。より近いディレクトリの規則ファイルが常に勝ちます。3つ目はリポジトリの中だけにあって共有されないローカルの除外ファイルであり、最後がユーザーの全体設定です。
$ git check-ignore -v logs/app.log
.git/info/exclude:1:logs/app.log logs/app.log
この階層を知っていれば、何をどこに書くかは自動的に決まります。
# リポジトリの全員に共有する規則
$ cat .gitignore
# 自分だけが使う一時ディレクトリのように、コミットしてはいけない個人の規則
$ cat .git/info/exclude
# アカウント全体に適用するエディタとオペレーティングシステムの副産物
$ git config --global core.excludesFile ~/.config/git/ignore
$ cat ~/.config/git/ignore
.DS_Store
.idea/
*.swp
実務の規則を1つだけ決めるならこれです。エディタとオペレーティングシステムが作るファイルはプロジェクトの規則ファイルに入れません。自分が使うエディタの名前が他人のリポジトリにコミットされる理由はなく、その一覧は人によって違います。全体設定に置けば、すべてのプロジェクトで一度に片づきます。
大文字小文字を区別しないファイルシステムが生む幽霊変更
macOSとWindowsの既定のファイルシステムは名前の大文字小文字を区別しません。Gitはリポジトリを作るときにこの事実を検知し、設定を自動的に有効にします。
$ git config core.ignorecase
true
ここで生じる症状はこうです。ファイル名を大文字から小文字に変えたのに、状態確認には何も引っかかりません。ローカルではうまく動くのに、LinuxのCIでだけモジュールが見つからないと言って失敗します。リポジトリのなかには相変わらず以前の大文字小文字の名前が入っているからです。
名前の変更をGitに確実に認識させるには、2段階に分けます。
$ git mv Utils.ts utils-tmp.ts
$ git mv utils-tmp.ts utils.ts
$ git status --short
R Utils.ts -> utils.ts
強制オプション1回で済む場合も多いのですが、ファイルシステムによっては失敗するので2段階の方式が確実です。
$ git mv --force Utils.ts utils.ts
無視規則にも同じ問題が付いてきます。拡張子を大文字で書いたファイルと小文字で書いた規則が、あるマシンでは引っかかり別のマシンでは引っかからない、という状況が起こります。規則を書くときは大文字小文字に依存しないほうが安全です。必要なら両方書いておいてください。
似たように人を苦しめる幽霊変更がもう1つあります。改行文字です。こちらの正解は人ごとに設定を合わせることではなく、リポジトリに規則をコミットすることです。
$ cat .gitattributes
* text=auto eol=lf
*.png binary
すでにコミットされたシークレットは無視リストでは消えない
最後が最も重要です。アクセスキーを誤ってコミットしたあと、無視リストに追加して次のコミットでファイルを消すという対応をよく見かけます。そのキーは相変わらず履歴のなかにそのまま残っています。無視の規則はこれから追加されるファイルに対する規則にすぎず、過去を変えません。
$ git log --all --full-history --oneline -- config/local.env
5b21c4e chore: 로컬 환경 파일 추적 해제
9a03d17 feat: 결제 게이트웨이 연동
$ git log -S 'AKIA' --oneline --all
9a03d17 feat: 결제 게이트웨이 연동
順序を正確に守る必要があります。1つ目は履歴の整理ではなくキーの失効と再発行です。履歴を書き直す作業はチーム全体の調整が必要で早くても数時間かかりますが、公開リポジトリをなめる自動収集器は数秒でキーを持っていきます。順序を変えれば、数時間のあいだ有効なキーを世界に開けておくことになります。
キーを失効させたら、その次が履歴の整理です。
$ git filter-repo --path config/local.env --invert-paths
Parsed 9134 commits
New history written in 11.42 seconds; now repacking/cleaning...
ここにも限界がはっきりあります。書き直しは自分のリポジトリの履歴を変えるだけであり、ホスティングサービスにはPRのリファレンスとフォークが古いコミットをつかんでいる場合が多いので、別途の整理依頼が必要です。すでに誰かがクローンしていったなら回収は不可能です。履歴の書き直しは漏洩の取り消しではなく、拡散の抑制にすぎません。書き直しが払う代償はリポジトリが遅くなったときの回の最終節に詳しくまとめました。
予防のほうがはるかに安く済みます。値の入った環境ファイルは最初から追跡せず、例示ファイルだけをコミットします。コミットフックにシークレット検知器を仕掛け、ホスティングサービスのプッシュ遮断機能を有効にします。この3つを仕掛けるのにかかる時間は、キーを1つ失効させてローテーションする時間より短いのです。
おわりに — 規則を疑う前に状態を確認してください
無視リストが効かないときの順序は常に同じです。まず追跡中かを確認し、次にどの規則が捕まえているのかをGitに尋ね、最後にようやくパターンを直します。この順序を守るだけで、検索に使う時間のほとんどが消えます。
覚えておく文は1つです。無視リストは追跡されていないファイルに対する規則であり、すでにインデックスにあるファイルには何の影響も与えません。そしてシークレットが引っかかったなら、無視リストはそもそも対応手段ではありません。そのときの最初の命令は、Gitではなくキー発行コンソールで実行されます。
현재 단락 (1/131)
無視リストに1行書き、ファイルを確認し、タイプミスもありません。それなのに状態確認には相変わらずそのファイルが引っかかります。ここでたいていの人は規則を疑い、アスタリスクを1つ足したりスラッシュをあち...