How to Rewrite Git History: Removing Secrets and Fixing Past Commits

·7 min read

You're reviewing a PR. GitHub Copilot flags a line: "Hardcoded API key in RPC_URL exposes sensitive credentials."

You look at the diff. There it is. An Alchemy API key, committed in plain text three commits ago.

How do you fix this without leaving the key visible in git history?

Table of Contents

  1. The Problem: Secrets in Git History
  2. The Scenario
  3. Step 1: Fix the Code
  4. Step 2: Create a Fixup Commit
  5. Step 3: Autosquash with Rebase
  6. Step 4: Force Push
  7. Verify the Result
  8. The Full Command Sequence
  9. Critical: Rotate the Key
  10. Takeaways

The Problem: Secrets in Git History

Git never forgets. Every commit is permanent. When you "delete" a file or change a line, git keeps the old version in history.

If you commit a secret and then remove it in a later commit, the secret is still there. Anyone who clones the repo can run git log -p and see it.

The fix isn't just removing the secret from the current code. You need to rewrite history so the secret was never there in the first place.

The Scenario

Here's a common situation. A monitoring script was committed with a hardcoded API key:

# In scripts/monitor.sh
RPC_URL="https://eth-mainnet.g.alchemy.com/v2/aB3xY7kL9mN2pQ5rS8tU1vW4zC6dE0fG"

The commit history looks like this:

$ git log --oneline -5
f4e2d1a feat: add price refresh job
a1b2c3d feat: add monitoring scripts  # <-- API key is here
9d8e7f6 refactor: extract utils to separate module
5c4b3a2 fix: handle edge case in validation
1a2b3c4 chore: update dependencies

The API key was introduced in commit a1b2c3d. There's one commit after it (f4e2d1a). The goal: rewrite a1b2c3d so it never contained the key.

Step 1: Fix the Code

First, fix the actual code. Replace the hardcoded key with an environment variable:

# Before (bad)
RPC_URL="https://eth-mainnet.g.alchemy.com/v2/aB3xY7kL9mN2pQ5rS8tU1vW4zC6dE0fG"

# After (good)
RPC_URL="${RPC_URL:-https://eth.llamarpc.com}"

Stage the fix:

git add scripts/monitor.sh

Now you have a staged change, but you don't want to create a new commit. You want to merge this fix into the original problematic commit.

Step 2: Create a Fixup Commit

git commit --fixup a1b2c3d

Output:

[main 7e8f9a0] fixup! feat: add monitoring scripts
 1 file changed, 1 insertion(+), 1 deletion(-)

What is --fixup?

The --fixup flag creates a special commit. It automatically:

  1. Prefixes the message with fixup!
  2. Copies the original commit's message

So if the original commit was feat: add monitoring scripts, the fixup commit becomes fixup! feat: add monitoring scripts.

This prefix is a signal. It tells git: "This commit should be squashed into the commit with this message."

The history now looks like:

7e8f9a0 fixup! feat: add monitoring scripts  # <-- new fixup commit
f4e2d1a feat: add price refresh job
a1b2c3d feat: add monitoring scripts          # <-- original with key
9d8e7f6 refactor: extract utils to separate module

The fixup commit is at the top, but it hasn't merged into a1b2c3d yet. That's what the next step does.

Step 3: Autosquash with Rebase

GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash 9d8e7f6

Output:

Successfully rebased and updated refs/heads/feature/monitoring.

This is the magic command. Let's break it down.

What is --autosquash?

When you run git rebase -i (interactive rebase), git opens an editor showing all commits. You can reorder them, squash them, or edit them.

The --autosquash flag automates this. It looks for commits prefixed with fixup! or squash!, then automatically reorders them to appear after the commit they should merge into, and marks them for squashing.

Without --autosquash, you'd manually edit the rebase todo list to move the fixup commit and change pick to fixup.

With --autosquash, git does it for you.

What is GIT_SEQUENCE_EDITOR?

Interactive rebase opens an editor for you to confirm the changes. By setting GIT_SEQUENCE_EDITOR=:, you tell git to use : as the editor.

In shell, : is the "do nothing" command. It succeeds immediately without opening anything.

This means: "Accept whatever --autosquash set up, don't ask me to confirm."

It's the difference between:

  • Without GIT_SEQUENCE_EDITOR: Git opens vim/nano, you review the todo list, save and quit
  • With GIT_SEQUENCE_EDITOR=:: Git proceeds automatically

Use this when you're confident --autosquash will do the right thing.

Why 9d8e7f6?

The rebase target (9d8e7f6) is the commit before the one you want to modify.

Git rebase rewrites all commits from the target to HEAD. You need to include a1b2c3d in the rebase, so you target its parent.

If you targeted a1b2c3d itself, it wouldn't be included in the rebase and nothing would change.

Step 4: Force Push

git push --force-with-lease origin feature/monitoring

Rewriting history changes commit hashes. The old a1b2c3d is now b2c3d4e. The remote still has the old commits.

A normal git push will fail because your local branch has diverged from remote. You need to force push.

--force vs --force-with-lease

Both overwrite the remote branch. The difference is safety.

--force (dangerous):

git push --force origin feature/monitoring

This says: "Replace the remote branch with my local branch. I don't care what's on remote."

If a teammate pushed commits after your last pull, --force will delete their work. No warning. No recovery.

--force-with-lease (safer):

git push --force-with-lease origin feature/monitoring

This says: "Replace the remote branch with my local branch, but only if remote hasn't changed since I last fetched."

If someone else pushed commits, the command fails:

! [rejected] feature/monitoring -> feature/monitoring (stale info)
error: failed to push some refs

You can then fetch, review their changes, and decide how to proceed.

Always use --force-with-lease. There's no good reason to use --force unless you explicitly want to overwrite someone else's work.

Verify the Result

Check the new history:

$ git log --oneline -5
c3d4e5f feat: add price refresh job
b2c3d4e feat: add monitoring scripts  # <-- new hash, no API key
9d8e7f6 refactor: extract utils to separate module
5c4b3a2 fix: handle edge case in validation
1a2b3c4 chore: update dependencies

Notice:

  • a1b2c3d became b2c3d4e (hash changed because content changed)
  • f4e2d1a became c3d4e5f (hash changed because its parent changed)
  • Commits before 9d8e7f6 are unchanged

Verify the fix is in the right commit:

$ git show b2c3d4e --stat
# Should show the monitoring script with the env var, not the API key

The Full Command Sequence

Here's everything in order:

# 1. Fix the code (replace hardcoded key with env var)
# ... edit the file ...

# 2. Stage the fix
git add scripts/monitor.sh

# 3. Create fixup commit targeting the problematic commit
git commit --fixup a1b2c3d

# 4. Autosquash the fixup into the original commit
GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash 9d8e7f6

# 5. Force push to overwrite remote history
git push --force-with-lease origin feature/monitoring

Five commands. The secret is gone from history.

Critical: Rotate the Key

Removing the secret from git history does not make it safe.

The moment you pushed the original commit, the secret was exposed. Anyone who:

  • Cloned the repo
  • Forked the repo
  • Has access to GitHub's internal caches
  • Used any git mirroring service

...still has the old commits with the secret.

Always rotate exposed credentials. Go to Alchemy (or whatever service), revoke the old key, and generate a new one. This is non-negotiable.

Rewriting history prevents future exposure. Rotating the key prevents current exploitation.

Takeaways

Git history is permanent until you rewrite it. Deleting a secret in a new commit doesn't remove it from history. You need to rewrite the original commit.

--fixup and --autosquash are the clean way. Create a fixup commit, rebase with autosquash, done. No manual editor wrangling.

GIT_SEQUENCE_EDITOR=: skips the editor. Use it when you trust autosquash to do the right thing and don't want to confirm manually.

Always use --force-with-lease. It's --force with a safety check. If someone else pushed, it fails instead of destroying their work.

Rotate exposed credentials immediately. Rewriting history is damage control, not a fix. The secret was exposed. Assume it's compromised. Generate new credentials.

Automate secret detection. Use tools like gitleaks, trufflehog, or GitHub's secret scanning to catch secrets before they're pushed. Prevention beats cleanup.