Skip to content

필사 모드: When .gitignore is not working — the number one cause and a close reading of the pattern rules

English
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

Introduction — I told it to ignore this and it keeps showing up

You wrote one line in the ignore list, you checked the file, and there is no typo. Yet the status check still picks the file up. At this point most people start doubting the rule, adding another asterisk or shuffling a slash around.

The problem is almost never the rule. The ignore list only applies to paths Git is not tracking yet. A file that has been committed even once will keep showing up no matter how carefully you write the rule. This post starts by confirming that cause, and only then deals with the cases where the pattern really is wrong.

Number one cause — it does not apply to a file that is already tracked

Start with the diagnosis. There is a command that lists files which are tracked and matched by an ignore rule at the same time. It does not surface easily in search, but it is the single line closest to the answer for this problem.

$ git ls-files -i -c --exclude-standard
config/local.env
build/out.js
node_modules/.package-lock.json

If a name shows up here, the rule is fine and the cause is that the file is in the index. All you have to do is take it out of the index.

$ git rm --cached config/local.env
rm 'config/local.env'

$ git status --short
D  config/local.env
?? config/local.env

The file on disk is untouched and only the index entry is gone. But look carefully at the first line of the status output. This operation stages a deletion. Commit and push it, and the file is really deleted from the working directory of every colleague who pulls that branch. Being in the ignore list makes no difference.

Using this command on a configuration file is especially dangerous. A colleague local connection settings disappear without warning, the service will not start, and half a day goes into finding out why. In practice you have to do two things together. One is to track a separate example file, the other is to state it explicitly in the commit message and in your announcement.

$ cp config/local.env config/local.env.example   # with the values stripped, keys only
$ git add config/local.env.example
$ git commit -m "chore: 로컬 환경 파일 추적 해제, 예시 파일 추가"

If you need to take out a whole directory, use the recursive option.

$ git rm -r --cached node_modules
rm 'node_modules/.package-lock.json'
rm 'node_modules/.bin/tsc'
...

If you have overhauled the ignore rules, there is also the option of rebuilding the index wholesale. Be aware that this approach sometimes drags along line-ending settings or file mode changes as well, so you must check the change list before committing.

$ git rm -r --cached .
$ git add .
$ git status --short | head

Let me point out one piece of bad advice that shows up often here. When you only want to hide local edits to a tracked file, the commands below get recommended a lot.

$ git update-index --assume-unchanged config/local.env
$ git update-index --skip-worktree config/local.env

The first one is not an ignore feature, it is a performance optimization. It is a promise to Git that this file will not change so it need not check, and if Git overwrites that file while you switch branches or pull, there is no warning at all. The second is a little more conservative, but it stops with a cryptic error the moment it meets an operation that must update that path. Neither one is communicated to anyone else. There is no way to ignore a file while keeping it tracked. Cutting the tracking and leaving an example file is the only answer that stays maintainable.

Ask Git directly which rule is the culprit

You do not need to read patterns with your eyes and deduce. There is a command that tells you which line of which file caught this path.

$ git check-ignore -v build/out.js
.gitignore:2:build/	build/out.js

File name, line number, rule, target path, in that order. But there is a trap here.

$ git check-ignore -v config/local.env
$ echo $?
1

It prints nothing. It looks like the rule is wrong, but it is not. By default this command consults the index and decides that a tracked path is not an ignore candidate, so it quietly passes over it. To test the rule itself you have to tell it to ignore the index.

$ git check-ignore -v --no-index config/local.env
.gitignore:5:*.env	config/local.env

The rule was fine from the start. The difference between these two outputs is also the fastest way to confirm the number one cause. If the rule only appears when the index is ignored, that file is tracked.

When you want the whole picture, add an option to the status check.

$ git status --ignored --short
 M src/app.ts
?? refund.ts
!! build/
!! .env

A close reading of the pattern syntax — slash position and the rule that disables negation

Now to the cases where the rule really is wrong. Most of the confusion in ignore patterns comes from where the slash sits.

PatternMeaningMatchesDoes not match
logsA file or directory named logs, at any depthlogs, src/logslogs.txt
logs/Directories only, at any depthlogs/, src/logs/A regular file of the same name
/logsOnly the logs next to this rule filelogssrc/logs
doc/*.txtHas a slash, so anchored, and only one leveldoc/note.txtdoc/api/note.txt
build/*The entries inside build, build itself is not excludedbuild/out.jsbuild
!keep.logCancels an earlier ruleOnly when the parent is not excludedWhen a parent directory is excluded

The third and fourth rows of the table are the key. If a pattern contains even one slash, that pattern is anchored relative to the location of the rule file, and if it contains no slash at all it matches at any depth. The asterisk in the fourth row cannot cross a slash, so it matches only one level. Skipping several levels requires a double asterisk.

# Matches any number of levels under doc, relative to the rule file location
doc/**/*.txt

# A build directory at any depth (same meaning as a pattern without a slash)
**/build

# Everything under build (the build directory itself is not a target)
build/**

# Comments start with a hash. Escape with a backslash if a file name starts with one
\#important.txt

# Trailing spaces are stripped. Add a backslash to keep one
trailing\ 

The one rule that disables a negation pattern

Writing something like the following to keep an empty directory is extremely common.

$ cat .gitignore
build/
!build/keep/.gitkeep

$ git check-ignore -v build/keep/.gitkeep
.gitignore:1:build/	build/keep/.gitkeep

The negation pattern is on the later line and yet the rule above won. This is not an ordering problem. Git does not descend into an excluded directory at all. It filters at the directory level, so rules about files inside it are never even read. The documentation states explicitly that a file whose parent directory is excluded cannot be re-included.

The fix is to exclude the contents rather than the directory, and to open up the path you want to keep at every level.

$ 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

For the same reason the form below fails often. It is true that a later rule wins, but if the parent is blocked as a whole directory, that rule is never reached.

$ cat .gitignore
logs/
!logs/app.log

$ git check-ignore -v logs/app.log
.gitignore:1:logs/	logs/app.log

The precedence hierarchy of ignore rules

Rules do not live in one file only. Git consults several sources in order, and once a decision is made earlier it never looks at the rest. From highest priority down, it goes like this.

First are patterns given on the command line. The exclude options you attach to the clean command or to file listing belong here. Second is the rule file in the same directory as the target path, and if there is none it walks upward. The rule file in the nearer directory always wins. Third is the local exclude file that lives inside the repository and is never shared, and last is the user-wide setting.

$ git check-ignore -v logs/app.log
.git/info/exclude:1:logs/app.log	logs/app.log

Once you know this hierarchy, what goes where decides itself.

# Rules to share with everyone in the repository
$ cat .gitignore

# Personal rules that must not be committed, like a scratch directory only you use
$ cat .git/info/exclude

# Editor and operating system debris, applied across the whole account
$ git config --global core.excludesFile ~/.config/git/ignore
$ cat ~/.config/git/ignore
.DS_Store
.idea/
*.swp

If you adopt one practical rule, make it this one. Files created by your editor and your operating system do not go in the project rule file. There is no reason for the name of the editor you use to be committed to someone else repository, and that list differs from person to person. Put it in the global setting and it is solved once for every project.

Ghost changes produced by a case-insensitive filesystem

The default filesystems on macOS and Windows do not distinguish case in names. Git detects this when it creates a repository and turns on a setting automatically.

$ git config core.ignorecase
true

The symptom that follows looks like this. You rename a file from uppercase to lowercase and the status check shows nothing. It works fine locally but fails only on Linux CI, complaining that a module cannot be found. That is because the repository still holds the name in its old casing.

To make Git recognize the rename for certain, split it into two steps.

$ git mv Utils.ts utils-tmp.ts
$ git mv utils-tmp.ts utils.ts
$ git status --short
R  Utils.ts -> utils.ts

A single force option often works too, but it fails on some filesystems, so the two-step approach is the reliable one.

$ git mv --force Utils.ts utils.ts

The same problem follows the ignore rules. You end up with a situation where a file with an uppercase extension and a lowercase rule match on one machine and not on another. It is safer not to depend on case when you write rules. Write both forms if you need to.

There is one more ghost change that torments people in a similar way: line endings. The right answer here is not to align settings person by person but to commit the rule to the repository.

$ cat .gitattributes
* text=auto eol=lf
*.png binary

An already committed secret is not erased by the ignore list

The last section is the most important. I often see people respond to accidentally committing an access key by adding it to the ignore list and deleting the file in the next commit. That key is still sitting right there in the history. An ignore rule is only a rule about files to be added in the future, it does not change the past.

$ git log --all --full-history --oneline -- config/local.env
5b21c4e chore: 로컬 환경 파일 추적 해제
9a03d17 feat: 결제 게이트웨이 연동

$ git log -S 'AKIA' --oneline --all
9a03d17 feat: 결제 게이트웨이 연동

The order has to be exactly right. The first step is not history cleanup, it is revoking and reissuing the key. Rewriting history requires coordinating the whole team and takes hours at best, while automated scrapers crawling public repositories take the key within seconds. Invert the order and you leave a valid key open to the world for hours.

Once the key is revoked, history cleanup comes next.

$ git filter-repo --path config/local.env --invert-paths
Parsed 9134 commits
New history written in 11.42 seconds; now repacking/cleaning...

This has clear limits too. A rewrite only changes the history in your repository, and hosting services often keep old commits alive through PR refs and forks, so a separate cleanup request is required. If someone has already cloned it, there is no getting it back. A history rewrite is not an undo of the leak, it is only containment of its spread. The price a rewrite pays is written up in detail in the final section of When a repository gets slow.

Prevention is far cheaper. Never track environment files that contain values in the first place, and commit only the example file. Hook a secret scanner into your commit hooks and turn on push protection at your hosting service. Setting up those three takes less time than revoking and rotating a single key.

Closing — check the state before you doubt the rule

The order for when an ignore list is not working is always the same. Check whether the file is tracked, then ask Git which rule is holding it, and only at the end fix the pattern. Following just this order removes most of the time you spend searching.

There is one sentence to remember. The ignore list is a rule about untracked files, and it has no effect at all on files that are already in the index. And if a secret got caught, the ignore list was never the response in the first place. In that case the first command is run not in Git but in the key issuing console.

현재 단락 (1/131)

You wrote one line in the ignore list, you checked the file, and there is no typo. Yet the status ch...

작성 글자: 0원문 글자: 10,922작성 단락: 0/131