The Copy It Made Before Deleting the File

A failed attempt is supposed to leave the working tree exactly as it found it. Mine left it one file short, and the file it removed was the only kind in the repository that nothing anywhere could give back.

The thing doing the removing is a builder I run unattended against my own repositories. It picks an improvement, writes it, runs that repository’s own verification, and if the result is worse than what it started with, it rolls the attempt back and tries something else. The rollback is the entire reason I am willing to walk away while it runs. Without it, the loop is a stranger with write access to my working tree.

Two dispositions for three kinds of file

Every file the loop touches is classified before the write, into one of two buckets. Tracked by git goes in the modified bucket, and rollback restores it with git restore. Everything else goes in the created bucket, and rollback deletes it, because deleting a file you just made is what undoing its creation means.

Here is the code that chose, with its own comment:

const fileExists = existsSync(filePath);
const trackedByGit = fileExists && isFileTrackedByGit(filePath);

if (fileExists && trackedByGit) {
  // Tracked file: backup + mark as modified (rollback via git checkout)
  ...
} else {
  // Untracked or new file: backup content if it exists, mark as created (rollback via delete)

There are three kinds of file, not two. A file git tracks. A file that does not exist yet. And a file that exists on disk and git has never heard of: an environment file, a draft I had written but not added, a scratch script, anything named in .gitignore. The third kind failed the first test, fell into the else, and was recorded as created. When validation then failed, it went through the delete loop with everything the attempt had genuinely made.

So pointing the loop at a working tree holding any uncommitted, unadded file, and having one attempt fail, removed that file. Not reverted to an earlier version. Removed, with no earlier version anywhere, because git had never been given one.

The branch tests for the case it denies

Look at the condition again. trackedByGit is already fileExists && isFileTrackedByGit(...), so the fileExists && in front of it does nothing. The whole test collapses to trackedByGit. The variable that would have separated the three cases is computed, sits in scope, and is not allowed to affect which branch runs.

Two lines later it gets used anyway. The else branch opens with if (fileExists && this.backup), because before overwriting the file it takes a copy of the contents, if there are any contents to copy.

That is a branch labelled created asking whether the file was already there. The comment above it says both halves out loud, in one sentence, in the order they happen: back up the content if it exists, mark it as created, roll back via delete. Every fact needed to catch this is on that line. I wrote the line, and read it several times while working on other things in the same function, and what I saw was a tidy two-way split with a defensive backup in the tail.

There was already a copy

The backup that branch takes is real. It goes into a Map on the feedback subsystem, keyed by path. That subsystem also carries a method that reads the map and writes the contents back to disk, restoring a file after a failed improvement. It has been there the whole time.

The rollback never calls it. It has two loops, one for git restore and one for fs.delete, and neither of them has ever looked in that map. So the loop copied the file, stored the copy, kept a working function for putting the copy back, and deleted the original.

Two details make that worse rather than funnier. The map is a Map<string, string>, so a binary file would not have survived a round trip through it even if something had read it. And the copy is taken inside if (this.feedbackLoop), while the feedback subsystem returns early when it is switched off in config. With that one flag false, the else branch takes no copy at all, and the delete runs identically.

The fix I shipped does not use that map. It names a third disposition, untracked-existing, and the builder holds its own Map<string, Buffer> copy taken before the write, because a guarantee about not destroying someone’s work cannot be conditional on an unrelated subsystem being enabled.

The defense in depth pointed the same way

Inside the rollback there was a second block, under a comment calling itself defense in depth. Its job was to catch untracked paths that had somehow reached the modified bucket, since one untracked path makes the whole git restore batch fail.

It moved them into the created bucket.

That is a second, independent route to the same delete, written deliberately, as a safety measure, by someone who had the word untracked in his head at the time. It caught the right files. It handed them to the wrong loop. A third instance was in the fallback that classifies paths when nothing was recorded: tracked to restore, everything else to delete.

What the rejected attempt left behind

There is a companion to this that I found an hour later, and it inverts the failure. Two auto-fixers run around validation, rewriting whole files from model output with a bare writeFileSync and telling the builder nothing. Nothing recorded those paths in any bucket at all.

So a rolled-back attempt was not rolled back. The pipeline reported rolled_back, the rollback payload counted only the files the application stage had tracked, and the fixer’s rewrite stayed on disk. Then the commit planner, which commits everything dirty that was not dirty when the run started, swept that file into the next attempt that passed, under that attempt’s message, and pushed it.

A rejected change got published in the name of an accepted one. Both bugs come from the same missing question, asked in two directions: what did this write actually do to the file that was there.

The check that confirms it before removing it

I keep coming back to the delete loop, because it is careful:

for (const filePath of this.createdFiles) {
  if (!existsSync(filePath)) continue;

It confirms the file is on disk before removing it. In a bucket named for files this run created, that guard is a formality, skipping anything a later step already cleaned up. For the third kind of file it passes for the opposite reason: the file is there because it was always there, and I never wrote down the difference between those two ways of being present.

The tracked test was never wrong. It answers exactly one question, whether git can put this file back, and I stored the answer under a label claiming to say whether I had made the file myself. The two agree on every file except the one where the disagreement is permanent.


← all writing