Blog / Coding tips

Git: Remove a File From the Repo but Keep It Locally

To remove a file from a Git repository but keep it on your computer, run git rm --cached <file>, add the file to .gitignore, and commit. The --cached flag tells Git to remove the file from the index (what Git tracks) but leave your working copy alone, so the file stays on disk while Git stops tracking it. For a folder such as node_modules, add -r: git rm -r --cached node_modules. Below I run every command on a real throwaway repo and show the exact output, including the two surprises: what happens to your teammates' copies, and why a committed password isn't safe even after this.

The first time I needed this was the classic beginner mistake. I'd committed a .env file with an API key in it, along with the whole node_modules folder. I wanted both out of the repo, but I still needed them locally to run the project. All the commands below were run with Git 2.47 in a script, and the output is pasted straight from it.

Remove one file but keep it on disk

Here's the setup: a repo whose first commit accidentally included .env and node_modules. Then git rm --cached removes .env from Git only:

$ git ls-files
.env
app.js
node_modules/left-pad/index.js

# Remove one file from the repo, keep it locally
$ git rm --cached .env
rm '.env'

$ git status --short
D  .env
?? .env

$ ls -a
.
..
.env
.git
app.js
node_modules

# Stop it coming back
$ echo ".env" >> .gitignore

$ git add .gitignore

$ git commit -q -m "Stop tracking .env"

$ git status --short

Read the git status --short lines carefully, because they explain exactly what --cached does:

  • D .env (in the first column) means the deletion of .env is staged. The next commit will remove it from the repository.
  • ?? .env means the same file is still on disk, and Git now sees it as untracked.

ls -a confirms the file is still there. After adding .env to .gitignore and committing, git status is clean: Git no longer tracks the file and has been told to ignore it from now on.

Without --cached, git rm .env would delete the file from your disk as well. That's the one-word difference between "stop tracking" and "delete", so double-check it before pressing Enter.

Why .gitignore alone doesn't work

This is the part that confuses most people, including me the first time. .gitignore only affects files Git isn't tracking yet. If a file was already committed, adding it to .gitignore does nothing: Git keeps tracking it and keeps showing your changes.

You can see that in this second test repo. debug.log, dist/ and .DS_Store were committed first, then added to .gitignore. git status shows only the new .gitignore, and Git carries on tracking the three files:

# .gitignore is ignored for files Git already tracks
$ printf "*.log\ndist/\n.DS_Store\n" > .gitignore

$ git status --short
?? .gitignore

# Preview with -n (dry run)
$ git ls-files -ci --exclude-standard
.DS_Store
debug.log
dist/app.js

$ git rm -r --cached -n .
rm '.DS_Store'
rm 'debug.log'
rm 'dist/app.js'
rm 'index.php'

That's why you always need both steps: git rm --cached to stop tracking the file now, and .gitignore to stop it being added again by the next git add ..

Remove a whole folder

For a folder, add -r (recursive). Here node_modules comes out of the repo but stays on disk:

# A whole folder
$ git rm -r --cached --quiet node_modules

$ echo "node_modules/" >> .gitignore

$ git add .gitignore

$ git commit -q -m "Stop tracking node_modules"

$ git ls-files
.gitignore
app.js

$ ls node_modules/left-pad
index.js

--quiet just stops Git listing every file it untracks, which for a real node_modules can be thousands of lines. The trailing slash in node_modules/ in .gitignore means "only match a folder with this name".

Untrack everything that .gitignore now covers

When you add a proper .gitignore to an old project, you often want to untrack every file that it covers in one go. A popular answer online is git rm -r --cached . followed by git add .. Look at the dry run (-n) in the output above, though: it would also untrack index.php, a file you want to keep tracking. The git add . puts it back, but that's a lot of churn if something goes wrong in between.

A more precise approach is to ask Git which tracked files are ignored, and remove only those:

# list tracked files that .gitignore now matches
git ls-files -ci --exclude-standard

# untrack exactly those files (they stay on disk)
git ls-files -ci --exclude-standard -z | xargs -0 git rm --cached

Here's the rest of the test run, using that command, followed by an accidental git rm --cached that I undo:

# Untrack only the ignored files
$ git ls-files -ci --exclude-standard -z | xargs -0 git rm --cached
rm '.DS_Store'
rm 'debug.log'
rm 'dist/app.js'

$ git add .gitignore

$ git status --short
D  .DS_Store
A  .gitignore
D  debug.log
D  dist/app.js

# Unstage a mistaken git rm --cached
$ git rm --cached keep.txt
rm 'keep.txt'

$ git restore --staged keep.txt

$ git status --short

The list was .DS_Store, debug.log and dist/app.js, and after removing them git status shows exactly those three deletions plus the new .gitignore, ready to commit. index.php is untouched. The -z and -0 pair handles filenames with spaces. On Windows, run it in Git Bash.

Undo a git rm --cached

If you haven't committed yet, unstage the removal with git restore --staged <file>. That puts the file back into the index as if nothing happened (the end of the output above shows a clean status after it). If you've already committed, use git revert on that commit, or check the file in again with git add -f <file> if it's in .gitignore. My post on how to undo the last Git commit but keep your changes covers the commit side of this in detail.

The catch for teammates

git rm --cached keeps the file on your disk. Everyone else who pulls your commit gets a deletion, and Git deletes the file from their working copy. I tested this with a second clone acting as a teammate:

# in the teammate's clone
$ ls
app.js
config.env

$ git pull -q

$ ls
app.js

config.env simply disappears from the teammate's folder after git pull. For a generated folder like node_modules, that's harmless; they run npm install again. For a local config file, it can break their setup. Warn people before you push, and ask them to back the file up, or recover it afterwards with git show HEAD~1:config.env > config.env, which writes the old copy back without staging it (the new .gitignore then keeps it untracked).

A committed secret is still in history

The last line of the first test is the important one:

$ git show HEAD~2:.env
API_KEY=secret123

git rm --cached removes the file from future commits, not from old ones. Anyone with a copy of the repo can still read the key from history, and if the repo was ever public on GitHub, assume bots have already scanned it. So the order is:

  1. Revoke or rotate the key first in the service's dashboard. This is the step that actually protects you.
  2. Then untrack the file with git rm --cached and .gitignore.
  3. If you must remove it from history too, use a tool such as git filter-repo, then force-push and ask everyone to re-clone. It rewrites every commit hash, so treat it as a last resort.

The best fix is not committing secrets at all. My post on adding a Gemini API key to Next.js and Vercel shows how I keep keys in environment variables, and how I kept my AI API key off the browser covers keeping them away from the front end.

Other options: assume-unchanged and skip-worktree

You'll see git update-index --assume-unchanged and --skip-worktree suggested for "keep a file locally". They're different: the file stays in the repo, and Git just stops noticing your local edits. That's useful for a shared config file you tweak locally but never want to commit. It isn't a way to remove a file from the repository, and the flags are easy to forget about, so I only use git rm --cached for files that shouldn't be in Git at all.

FAQ

How do I remove a file from Git without deleting it?

Run git rm --cached path/to/file, add the path to .gitignore, then commit. The file stays on your disk and Git stops tracking it.

What is the difference between git rm and git rm --cached?

git rm removes the file from the repository and deletes it from your disk. git rm --cached only removes it from the repository (the index) and leaves your local file untouched.

Why is my file still tracked after adding it to .gitignore?

Because .gitignore only applies to untracked files. Run git rm --cached on the file once, commit, and from then on .gitignore keeps it out.

Will git rm --cached delete the file for other people?

Yes. When teammates pull the commit, Git deletes the file from their working copies. Tell them first, and they can restore it from the previous commit if they need it.

Does git rm --cached remove the file from history?

No. Old commits still contain it. If it held a password or API key, rotate the key, and use git filter-repo only if the history itself must be cleaned.

The official git rm documentation explains every flag.

// note

How to read this note.

This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.

If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.

Related notes