A plaintext .env slips past your ignore rules and lands in the Git index, exposing credentials in history. This page gives a deterministic, CLI-driven workflow to detect tracked secret files, purge them, and inject decrypted values into the shell session without persisting plaintext — sitting under local secret vaults and rotation and the wider environment sync and CI parity baseline.

Diagnostic

Check whether a secret file is actually ignored, and whether any landed in recent history:

#!/usr/bin/env bash
set -euo pipefail

git check-ignore -v .env.local || echo "NOT IGNORED: .env.local is tracked or untracked-but-not-ignored"
git ls-tree -r HEAD --name-only | grep -E '\.(env|secret|key)$' || echo "No secret files in HEAD"

Expected BAD output — the file is committed and others are tracked:

NOT IGNORED: .env.local is tracked or untracked-but-not-ignored
config/.env.production
src/services/auth/.env.local

Root cause

Git tracks files by the staging index, not the working directory. When a .env file is git add-ed before ignore rules exist — or when a monorepo workspace override conflicts with the root .gitignore — the index keeps the file even after you add an ignore pattern. The ignore rule only prevents new untracked files from being staged; it does nothing for a path already in the index, which is why the secret stays committed while everything looks safe.

Resolution

  1. Remove the file from the index while keeping it on disk:
    #!/usr/bin/env bash
    set -euo pipefail
    git rm --cached .env .env.local 2>/dev/null || true
  2. Enforce strict ignore rules:
    #!/usr/bin/env bash
    set -euo pipefail
    { echo '.env'; echo '.env.*'; echo '!.env.example'; } >> .gitignore
  3. Inject decrypted values into the shell without writing plaintext to a tracked path (SOPS + age):
    #!/usr/bin/env bash
    set -euo pipefail
    export SOPS_AGE_KEY_FILE="${HOME}/.config/sops/age/keys.txt"
    sops decrypt .env.local.sops > .env.local
  4. Confirm the variables are present in the session:
    #!/usr/bin/env bash
    set -euo pipefail
    env | grep -E 'DB_|API_|SECRET_' || echo "No matching vars exported"

Expected output

rm '.env.local'
DB_HOST=localhost
DB_PASS=decrypted_value
API_KEY=sk_live_redacted

Prevention

  • Add gitleaks or detect-secrets to a pre-commit hook so staged secrets are blocked before the commit lands:
    #!/usr/bin/env bash
    set -euo pipefail
    gitleaks protect --staged --verbose
  • Distribute a centralized .gitignore template via repository scaffolding so every workspace starts with the same boundaries.
  • Decrypt on shell entry through direnv so no one manually exports — eliminating drift. The vault and key-lifecycle side is covered in local secret vaults and rotation.

WSL2: Keep the age key file inside native ext4 (~/.config/sops/...), not /mnt/c, or SOPS decryption stalls on cross-filesystem reads. macOS / Windows: git filter-branch is slow and error-prone on large repos; prefer git filter-repo or the BFG Repo-Cleaner shown below.

Rollback

If a secret was already committed, purge it from the index and history, then rotate the credential:

#!/usr/bin/env bash
set -euo pipefail

git rm --cached -r --ignore-unmatch '.env*'
git commit -m "chore: remove committed secrets from index"
# Rewrite history if already pushed — coordinate with your security team first:
bfg --delete-files '.env.local'
echo "Rotate the exposed credential now; history removal does not un-leak it"