I was scrolling through a diff after a deploy when a filename caught my eye.
next.config.ts.bak.
I had handed a config-file fix to an agent, the gates went green, and I pushed. Production was fine. That was exactly the problem — nothing broke, so nothing prompted me to look.
As an indie developer I run these repositories alone, so there is no reviewer to catch what I miss. I only spotted the file a week later, while running ls for something unrelated.
The symptom: things you did not change, sitting in your diff
Ask an agent to run a --fix tool and commit the result, and files like these tend to ride along.
| Artifact | Produced by | Why it hurts |
|---|---|---|
*.bak | In-place fix scripts | Preserves the pre-fix file verbatim, secrets included |
*.orig / *.rej | patch, merge conflicts | Confuses the next merge |
*.tmp / *.swp | Editors, scratch writes | Enough of them and diff review stops working |
__pycache__/ | Running check scripts | Environment-specific bytecode lands in the repo |
In my case it was a single .bak. The build passed. Tests passed. CI stayed green.
Nothing breaking is what makes this one nasty.
Reproduction: "fixing" and "collecting" are two different tools
Three conditions have to line up.
- The fix tool writes a backup before editing.
- It does not remove that backup afterward.
- The staging step before the commit covers the whole worktree.
Condition three pulls the trigger. Ask an agent to "fix this and commit it" and you will usually get git add -A or git add .. The agent understands the change it made. It does not draw a line between the change and the debris the change produced.
# 1. A common portable in-place edit (works with both GNU and BSD sed)
sed -i.bak 's/foo/bar/g' next.config.ts
ls next.config.ts*
# => next.config.ts next.config.ts.bak
# 2. The staging an agent tends to reach for
git add -A
# 3. Commit without looking at what got staged
git commit -m "Fix: config"
# 4. Find out afterward
git show --stat HEAD
# next.config.ts | 2 +-
# next.config.ts.bak | 47 ++++++++++++++ ← not intendedsed -i.bak is the standard portable spelling because macOS ships BSD sed. The artifact exists as a side effect of caring about portability, which is a fairly ordinary thing to care about.
The root cause: .gitignore does not work retroactively
My first suspect was .gitignore. There was no *.bak entry, so one line seemed like the whole answer.
That was half right.
.gitignore only applies to untracked files. Once a file is committed and tracked, adding it to .gitignore changes nothing — you need git rm --cached to get it back out.
Here is the other half. .gitignore is a list of extensions, and anything not on the list walks straight through. I closed .bak and a month later .rej did the same thing.
The real cause is not the extension. It is the scope of what gets staged.
git add -A means "everything that changed in the worktree." In agent-driven work, what changed always contains both the deliverable and the leftovers. As long as the scope is everything, the distinction rests entirely on how fine the .gitignore mesh happens to be.
Fix 1: stage by path, not by wildcard
This was the one that mattered. I removed git add -A from the agent's instructions and named the directories it is allowed to touch.
# ✗ what lands here is decided after the fact
git add -A
# ✓ what can land here is decided in advance
git add content/ src/config/The argument to add becomes a whitelist. Wherever a .bak falls, it stays out unless it falls inside a named directory — and Fix 3 covers that case.
I added one line to my agent conventions: staging is path-scoped, -A and . are not used. Eight weeks in, artifact leakage has been zero.
Fix 2: wrap the fixer so cleanup cannot be skipped
Rather than calling --fix bare, make the edit and the cleanup a single command.
#!/usr/bin/env bash
# scripts/safe-fix.sh — never let the edit and the cleanup drift apart
set -euo pipefail
TARGET="${1:?usage: safe-fix.sh <file>}"
BACKUP="${TARGET}.bak"
TMPDIR="$(mktemp -d)"
trap 'rm -rf "$TMPDIR"' EXIT
sed -i.bak 's/foo/bar/g' "$TARGET"
if [ -f "$BACKUP" ]; then
# Keep it for rollback, but move it out of Git's line of sight
mv "$BACKUP" "$TMPDIR/$(basename "$BACKUP")"
echo "backup moved out of repo: $TMPDIR"
fi
if git diff --quiet -- "$TARGET"; then
echo "no change: $TARGET"
fiThe trap ... EXIT means a mid-script failure leaves nothing behind. The point is that the backup is relocated rather than destroyed: you keep your local rollback, and Git stops seeing it.
Your own scripts you can rewrite. Third-party codemods you cannot. For those, append find . -name '*.bak' -newer <reference> -delete, or lean on Fix 3.
Fix 3: gate staged filenames at commit time
The last line of defense. Look only at the names of staged files and refuse the commit.
#!/usr/bin/env bash
# .git/hooks/pre-commit — stop artifacts mechanically
set -euo pipefail
DENY='\.(bak|orig|rej|tmp|swp)$|(^|/)__pycache__/|(^|/)\.DS_Store$'
# Without -z, non-ASCII paths come back octal-escaped and slip past the check
BAD="$(git diff --cached --name-only -z --diff-filter=ACM \
| tr '\0' '\n' | grep -E "$DENY" || true)"
if [ -n "$BAD" ]; then
echo "Staged artifacts detected:" >&2
echo "$BAD" | sed 's/^/ - /' >&2
echo "" >&2
echo "Fix: git restore --staged <file>" >&2
exit 1
fi--diff-filter=ACM limits the check to additions, copies, and modifications so deletions do not trip it.
The -z is there because I got burned. My first version omitted it, and Git handed back octal-escaped strings for paths containing Japanese characters. The hook checked the wrong names and reported a clean tree.
A gate that lies is worse than no gate. I hit the same trap while working on files an agent writes disappearing into .gitignore, so I now reach for -z reflexively whenever a script walks filenames.
One caveat: .git/hooks/ is not part of the repository. Point git config core.hooksPath .githooks at a tracked directory and fresh clones pick the hook up automatically.
Prevention: one line, placed before the commit
If you only adopt one of the three, adopt Fix 1. With a narrow staging scope, the rest becomes insurance.
Beyond that, one check in the agent's procedure earned its place:
# After add, before commit — put it in front of your eyes once
git diff --cached --name-onlyThat is all. When an agent reads back the list of files it staged, it notices the ones that do not match its intent. Combined with path-scoped add, this caught nearly everything for me.
Agent-driven Git failures tend to arrive wearing a normal face — the same way a push reports success while an unset git identity quietly drops the commit on the floor. Deciding where to insert a mechanical check, rather than relying on noticing, turned out to be the cheaper path.
Cleaning up an artifact you already committed:
# Untrack it; the local file stays
git rm --cached next.config.ts.bak
echo '*.bak' >> .gitignore
git add .gitignore
git commit -m "Remove: backup artifact from repository"Purging it from history needs git filter-repo, but if the file holds no secrets, untracking is enough in practice. I read mine first and decided a history rewrite was not worth it.
Where to go next
Check what your agent's last three commits actually contain.
git log -3 --stat --onelineIf an unfamiliar extension shows up even once, there is a git add -A somewhere in the procedure that produced it. Rewriting that one call to be path-scoped is the fastest place to start.
For gating the content of a commit rather than its filenames, I wrote up stopping agent commits at pre-commit. Thank you for reading.