Skip to main content
DevOps Insightsguides & answers

CI/CD · why it works that way

Why can't you just remove a secret from Git after committing it?

Short answer

Because git is built to remember. A commit is content-addressed and immutable, so deleting the file adds a new commit that removes it — while the original stays reachable, in your history and in every clone anybody already made.

Reproduced on Git 2.50.1, . Where the text says “reproduced”, the behaviour was run on that version, not taken from documentation.

First published

On this page · 7 sections

The constraint behind it

Git stores content as objects addressed by their own hash, and commits point at them. That immutability is the entire basis of the guarantees people rely on: that a hash identifies exactly one tree, that history can be verified, that two clones with the same hash are the same code.

Deleting a file therefore cannot mean "unwrite the past". It means adding a commit whose tree omits the file. The blob containing your secret is still there, still reachable from the earlier commit, and still exactly one git show away.

Rewriting history can remove it from your branches, but Git keeps the old objects until you tell it otherwise, and every full clone is a complete copy. Anyone who pulled before the rewrite keeps the original, and rewriting shared history has its own consequences.

Words used on this page

object
Anything Git stores, such as file contents, a directory listing or a commit, filed under the hash of its own content.
blob
The object holding one version of a file's contents. Deleting the file later does not change or delete the blob.
commit
A snapshot that names its parent commit and the tree of files it contains. Its ID is a hash of all of that, so it cannot change without becoming a different commit.
reachable
Findable by following parent links back from a branch, tag or other ref. Git keeps reachable objects; unreachable ones stay until garbage collection removes them.
clone
A copy of a repository. A normal clone holds the full history, every blob included; shallow and partial clones hold less.
fork
A hosted copy of a repository under another account. It keeps the history it was created with, whatever you do to yours.
history rewrite
Replacing commits with altered copies that have new IDs, with a tool such as git filter-repo, then force-pushing them over the old branch.
revoke and rotate
Make the leaked credential stop working, then issue and deploy a new one. This is what actually removes the risk.
garbage collection
git gc: Git's cleanup that deletes unreachable objects once they are old enough and nothing refers to them.
pickaxe
Git's name for git log -S: a search for commits whose changes add or remove a given string.

If it were the other way

For a version control system to genuinely forget, history would have to be mutable — a commit's content could change after the fact.

Which would remove the thing that makes git trustworthy. A commit hash would stop identifying specific content, so you could no longer verify that what you have is what was reviewed, signed or built. Reproducible builds depend on the opposite of forgetting.

This is a real trade and git chose deliberately: perfect memory, and a burden on the person committing. Every system that can forget gives up verifiability to do it.

What it buys and costs you

What it buys you

  • Verifiability. A hash pins exact content, which is what makes signed commits, reproducible builds and artifact provenance possible at all.
  • Distribution without trust. A full clone is complete, so no central server has to be believed.
  • History as evidence. You can prove what a file contained at any point, which is the whole point of an audit trail.

What it costs you

  • Every mistake is permanent by default. Not just secrets — large binaries, personal data, anything committed once.
  • Revocation becomes the only real remedy. Rotating the credential is the fix; removing the file is housekeeping.
  • The window is hard to measure. Some hosts log clones: GitHub Enterprise Cloud records git.clone events, but keeps Git events for seven days. Without such logs you cannot tell who cloned the repository before you noticed, so "we caught it quickly" is not a measurement you can make.

Where it bites in practice

In how reassuring the cleanup feels. A git rm, a commit, and a green build all confirm that the file is gone — and every one of those signals is about the working tree, not about history. The credential is still valid, still recoverable, and the team has stopped worrying about it.

Try it yourself

Before you start

  • Git installed (git --version). Everything on this page was reproduced on Git 2.50.1.
  • A scratch repository whose remote is a local bare repository, and a made-up value that cannot pass for a real credential, such as EXAMPLE-NOT-A-REAL-TOKEN. Never practise with a real secret, even a revoked one.
  • If a real secret has leaked: access to the system that issued it, such as the cloud console, API provider or database, so you can revoke and rotate it first. That step does not depend on Git at all.
  • The exact leaked string, so you can search for it. git log --oneline --all -S <string> lists every commit, on any branch or tag, that added or removed it.
  • Before any history rewrite: admin rights on the hosted repository, since protected branches block the force push it needs, and a list of everyone who has a clone or fork.

Deleting a committed secret and finding it again

Reproduced on Git 2.50.1 (Apple Git-155) in a scratch repository whose remote was a local bare repository, with global and system config switched off and the time zone set to UTC. Two commits had been pushed: app.conf, then app.env holding the made-up value API_TOKEN=EXAMPLE-NOT-A-REAL-TOKEN. The author is a placeholder and the repository path is replaced with <path>.

Command

git log --oneline
git rm app.env
git commit -m "Remove credentials file"
git push origin main
git log --oneline
git show HEAD~1:app.env
git log --oneline -S EXAMPLE-NOT-A-REAL-TOKEN

Output

$ git log --oneline
74a844b Add environment file
f1319ca Add app config

$ git rm app.env
rm 'app.env'

$ git commit -m "Remove credentials file"
[main dc78bfd] Remove credentials file
 1 file changed, 1 deletion(-)
 delete mode 100644 app.env

$ git push origin main
To <path>/origin.git
   74a844b..dc78bfd  main -> main

$ git log --oneline
dc78bfd Remove credentials file
74a844b Add environment file
f1319ca Add app config

$ git show HEAD~1:app.env
API_TOKEN=EXAMPLE-NOT-A-REAL-TOKEN

$ git log --oneline -S EXAMPLE-NOT-A-REAL-TOKEN
dc78bfd Remove credentials file
74a844b Add environment file

How to read it

  • git rm and the commit report delete mode 100644 app.env, and the push is an ordinary fast-forward, 74a844b..dc78bfd. Every signal says the file is gone.
  • The log still holds 74a844b Add environment file. The removal is a new commit on top. The commit that added the file is untouched, because changing it would give it a different ID.
  • git show HEAD~1:app.env prints the token straight out of the commit before the removal. Anyone with a copy of the repository can do the same.
  • git log -S lists the commit that added the string and the one that removed it. It is how you check a repository for a leaked value, and how anyone else would find it.
  • In the same setup, a clone made after the removal printed the token with the same git show, and so did a teammate's older clone after git pull. Removing the file protected no copy, old or new.

If it goes wrong

You added the file to .gitignore, but git status still shows M app.env
.gitignore does not affect files Git already tracks. Run git rm --cached app.env to stop tracking it while keeping it on disk, and commit. Reproduced: the file stopped being tracked, but git log -S still found the value in the earlier commit, so rotate the secret anyway.
WARNING: git-filter-branch has a glut of gotchas generating mangled history when you try to rewrite
Git's own manual recommends against filter-branch and points to git filter-repo. GitHub's removal guide uses git filter-repo 2.47 or later with --sensitive-data-removal. Rotate the secret before you start, whichever tool you use.
After the rewrite, git log --all -S <string> still finds the secret in your own repository
Git keeps old objects until told otherwise. Reproduced, git filter-branch left the old commits under refs/original/. The safer route in its manual is a fresh git clone file:///path/to/repo. The other is to delete the refs under refs/original/, run git reflog expire --expire=now --all, then git gc --prune=now.
fatal: Need to specify how to reconcile divergent branches. on git pull after someone rewrote the branch
Do not choose merge and push. Reproduced, a teammate's git pull --no-rebase then git push put the removed token back on the server's main. Re-clone, or save any work of your own and reset to the rewritten origin/main.

Security considerations

  • Revoke and rotate first. GitHub's removal guide says that once a secret is revoked or rotated "it can no longer be used for access, and that may be sufficient", and OWASP lists revocation and rotation as the containment steps. A secret removed from history but still valid is still a working credential for everyone who copied it.
  • A history rewrite points people at the leak. GitHub warns that users with an existing clone will notice the divergence and can use it to find the data you removed. Rewrite after rotating, never instead of it.
  • Force-pushing does not delete the old objects from the server. Reproduced with a bare repository: after the rewrite and git push --force, git cat-file -p on the old blob still printed the token. On GitHub, old commits stay reachable by SHA in cached views, in pull requests and in forks until Support removes the cached views; forks need their owners.
  • Stale clones put the secret back. Reproduced: a clone from before the rewrite, pulled with merge and pushed, restored the token to main. Tell everyone with a clone to re-clone, and do not lift branch protection for longer than the rewrite itself.
  • Know your exposure window from logs, not hope. GitHub Enterprise Cloud records git.clone and git.fetch events but keeps Git events for seven days, available only through the API, streaming or exports. Stream them to your own storage before you need them.

Sources

The behaviour this page describes, as documented by the projects themselves.

Terms used here (2)

Short definitions for the vocabulary on this page, in case any of it is new. Each links to the full entry.

Secret
A Kubernetes object for storing sensitive data such as passwords, tokens, and TLS certificates. Values in a Secret's data field are base64-encoded, which is an encoding, not encryption: by default Secrets are stored unencrypted in etcd, so encryption at rest and RBAC need to be configured. Secrets can be mounted as files or injected as environment variables, keeping sensitive values out of container images.
Vault
A tool by HashiCorp for securely storing and managing secrets, encryption keys, and certificates. Vault provides dynamic secrets, automatic rotation, and fine-grained access control, making it a central component of enterprise security architectures.

All 59 terms in the glossary

Did this get you to an answer?

No text box on purpose — please do not paste production logs anywhere

Something wrong or out of date? Report it