Skip to content

Split View: .gitignore가 작동하지 않을 때 — 원인 1위와 패턴 규칙 정독

✨ Learn with Quiz
|

.gitignore가 작동하지 않을 때 — 원인 1위와 패턴 규칙 정독

들어가며 — 무시하라고 썼는데 계속 올라옵니다

무시 목록에 한 줄을 적었고, 파일을 확인했고, 오타도 없습니다. 그런데 상태 확인에는 여전히 그 파일이 잡힙니다. 여기서 대부분은 규칙을 의심하며 별표를 하나 더 붙이거나 슬래시를 이리저리 옮겨 봅니다.

문제는 거의 언제나 규칙이 아닙니다. 무시 목록은 Git이 아직 추적하지 않는 경로에만 적용됩니다. 한 번이라도 커밋된 파일은 규칙을 아무리 정교하게 써도 계속 올라옵니다. 이 글은 그 원인부터 확인하고, 그다음으로 실제로 패턴이 잘못된 경우를 다룹니다.

원인 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

디스크의 파일은 그대로 있고 인덱스에서만 빠졌습니다. 그런데 상태 표시의 첫 줄을 잘 봐야 합니다. 이 작업은 삭제를 스테이징합니다. 커밋해서 올리면, 그 브랜치를 받아 오는 동료의 작업 디렉터리에서 해당 파일이 실제로 지워집니다. 무시 목록에 들어 있어도 마찬가지입니다.

설정 파일에 이 명령을 쓰는 경우가 특히 위험합니다. 동료의 로컬 접속 정보가 예고 없이 사라지고, 서비스가 뜨지 않고, 원인을 찾는 데 반나절이 갑니다. 실무에서는 두 가지를 같이 해야 합니다. 하나는 예시 파일을 따로 추적하는 것이고, 다른 하나는 커밋 메시지와 공지에 명시하는 것입니다.

$ 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

여기서 흔히 보이는 잘못된 조언을 하나 짚겠습니다. 추적 중인 파일의 로컬 수정만 감추고 싶을 때 아래 명령이 자주 추천됩니다.

$ 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

규칙은 처음부터 정상이었습니다. 이 두 출력의 차이가 바로 원인 1위를 확인하는 가장 빠른 방법이기도 합니다. 인덱스를 무시했을 때만 규칙이 나온다면, 그 파일은 추적 중입니다.

전체 그림을 보고 싶을 때는 상태 확인에 옵션을 붙입니다.

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

패턴 문법 정독 — 슬래시의 위치와 부정 패턴이 무력화되는 규칙

규칙이 정말 잘못된 경우로 넘어갑니다. 무시 패턴에서 대부분의 혼란은 슬래시가 어디에 있느냐에서 나옵니다.

패턴의미걸리는 예걸리지 않는 예
logs이름이 logs인 파일과 디렉터리를 모든 깊이에서logs, src/logslogs.txt
logs/디렉터리만, 모든 깊이에서logs/, src/logs/같은 이름의 일반 파일
/logs이 규칙 파일과 같은 위치의 logs만logssrc/logs
doc/*.txt슬래시가 있으므로 위치 고정, 한 단계만doc/note.txtdoc/api/note.txt
build/*build 안의 항목들, build 자체는 제외 대상 아님build/out.jsbuild
!keep.log앞선 규칙을 취소상위가 제외되지 않았을 때만상위 디렉터리가 제외된 경우

표에서 세 번째 줄과 네 번째 줄이 핵심입니다. 패턴 안에 슬래시가 하나라도 있으면 그 패턴은 규칙 파일이 있는 위치를 기준으로 고정되고, 슬래시가 전혀 없으면 어느 깊이에서든 걸립니다. 마지막 줄의 별표는 슬래시를 넘지 못하므로 한 단계만 매칭합니다. 여러 단계를 건너뛰려면 이중 별표가 필요합니다.

# 규칙 파일 위치 기준으로 doc 아래 몇 단계든 걸립니다
doc/**/*.txt

# 모든 깊이의 build 디렉터리 (슬래시 없는 패턴과 같은 뜻)
**/build

# build 아래의 모든 것 (build 디렉터리 자체는 대상이 아님)
build/**

# 주석은 샵으로 시작합니다. 파일 이름이 샵으로 시작하면 역슬래시로 탈출합니다
\#important.txt

# 뒤에 붙은 공백은 무시됩니다. 살리려면 역슬래시를 붙입니다
trailing\ 

부정 패턴이 무력화되는 하나의 규칙

빈 디렉터리를 유지하려고 아래처럼 쓰는 경우가 아주 흔합니다.

$ 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

무시 규칙의 우선순위 계층

규칙은 한 파일에만 있지 않습니다. Git은 여러 출처를 순서대로 확인하고, 앞에서 결정이 나면 뒤는 보지 않습니다. 높은 쪽부터 이렇습니다.

첫째는 명령줄에서 준 패턴입니다. 정리 명령이나 파일 목록 조회에 붙이는 제외 옵션이 여기에 해당합니다. 둘째는 대상 경로와 같은 디렉터리의 규칙 파일이고, 없으면 상위로 올라갑니다. 더 가까운 디렉터리의 규칙 파일이 항상 이깁니다. 셋째가 저장소 안에만 있고 공유되지 않는 로컬 제외 파일이며, 마지막이 사용자 전역 설정입니다.

$ 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

실무 규칙 하나만 정한다면 이것입니다. 편집기와 운영체제가 만드는 파일은 프로젝트 규칙 파일에 넣지 않습니다. 내가 쓰는 편집기 이름이 남의 저장소에 커밋될 이유가 없고, 그 목록은 사람마다 다릅니다. 전역 설정에 두면 모든 프로젝트에서 한 번에 해결됩니다.

대소문자를 구분하지 않는 파일시스템이 만드는 유령 변경

macOS와 Windows의 기본 파일시스템은 이름의 대소문자를 구분하지 않습니다. Git은 저장소를 만들 때 이 사실을 감지해 설정을 자동으로 켭니다.

$ git config core.ignorecase
true

여기서 생기는 증상은 이렇습니다. 파일 이름을 대문자에서 소문자로 바꿨는데 상태 확인에 아무것도 잡히지 않습니다. 로컬에서는 잘 돌아가는데 리눅스 CI에서만 모듈을 찾지 못한다고 실패합니다. 저장소 안에는 여전히 예전 대소문자의 이름이 들어 있기 때문입니다.

이름 변경을 Git에 확실히 인식시키려면 두 단계로 나눕니다.

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

강제 옵션 한 번으로 되는 경우도 많지만, 파일시스템에 따라 실패하므로 두 단계 방식이 확실합니다.

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

무시 규칙에도 같은 문제가 따라옵니다. 확장자를 대문자로 쓴 파일과 소문자로 쓴 규칙이 어떤 머신에서는 걸리고 어떤 머신에서는 걸리지 않는 상황이 생깁니다. 규칙을 쓸 때 대소문자에 의존하지 않는 편이 안전합니다. 필요하면 두 가지를 모두 적어 두십시오.

비슷하게 사람을 괴롭히는 유령 변경이 하나 더 있습니다. 줄바꿈 문자입니다. 이쪽의 정답은 사람마다 설정을 맞추는 것이 아니라 저장소에 규칙을 커밋하는 것입니다.

$ 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: 결제 게이트웨이 연동

순서를 정확히 지켜야 합니다. 첫 번째는 이력 정리가 아니라 키 폐기와 재발급입니다. 이력을 다시 쓰는 작업은 팀 전체의 조율이 필요해 빨라야 몇 시간인데, 공개 저장소를 훑는 자동 수집기는 몇 초 만에 키를 가져갑니다. 순서를 바꾸면 몇 시간 동안 유효한 키를 세상에 열어 두게 됩니다.

키를 폐기하고 나면 그다음이 이력 정리입니다.

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

여기에도 한계가 분명합니다. 재작성은 내 저장소의 이력을 바꿀 뿐이고, 호스팅 서비스에는 PR 참조와 포크가 옛 커밋을 붙들고 있는 경우가 많아 별도의 정리 요청이 필요합니다. 이미 누군가 클론해 갔다면 회수는 불가능합니다. 이력 재작성은 유출의 취소가 아니라 확산 억제일 뿐입니다. 재작성이 치르는 대가는 저장소가 느려질 때 편 마지막 절에 자세히 정리했습니다.

예방은 훨씬 쌉니다. 값이 들어 있는 환경 파일은 처음부터 추적하지 않고 예시 파일만 커밋합니다. 커밋 훅에 시크릿 탐지기를 걸고, 호스팅 서비스의 푸시 차단 기능을 켭니다. 이 셋을 걸어 두는 데 걸리는 시간이 키 하나를 폐기하고 회전시키는 시간보다 짧습니다.

마치며 — 규칙을 의심하기 전에 상태를 확인하십시오

무시 목록이 듣지 않을 때의 순서는 언제나 같습니다. 먼저 추적 중인지 확인하고, 그다음 어느 규칙이 잡고 있는지 Git에게 묻고, 마지막에야 패턴을 고칩니다. 이 순서만 지켜도 검색에 쓰는 시간의 대부분이 사라집니다.

기억할 문장은 하나입니다. 무시 목록은 추적되지 않는 파일에 대한 규칙이고, 이미 인덱스에 있는 파일에는 아무 영향도 주지 않습니다. 그리고 시크릿이 걸렸다면 무시 목록은 애초에 대응 수단이 아닙니다. 그때 첫 번째 명령은 Git이 아니라 키 발급 콘솔에서 실행됩니다.

When .gitignore is not working — the number one cause and a close reading of the pattern rules

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.