Shipping

Refusing the write is cheaper than recovering the work

Untracked files on a protected branch exist in one place, so a later branch switch deletes them with no error and nothing to recover. Refuse at write time.

MobileByteSensei 10 min read
A sewn text block beside a loose stack of new sheets, lifting at the desk's edge
gitguardrailsworkflow

Three hours of work disappeared here once. A skill, an orchestrator, some wiring, a handful of fixes — all new files, all written while sitting on the default branch, none of them ever added to git.

A later command needed to switch branches. It switched branches. There was no error.

The case this post is about

One distinction, which decides whether work survives a branch switch:

edit an existing file   →  git knows it  →  a switch conflicts or carries it  →  recoverable
write a new file        →  git knows nothing  →  a switch deletes it  →  gone

Three hours of work disappeared on the left-hand side of that: new files, written on a protected branch, when a later command switched branches to do something unrelated.

Not lost in a conflict, not stashed, not in a dangling commit. Untracked files have no copy anywhere, so every recovery mechanism you reach for — reflog, stash list, fsck — is empty, and the files were never in any of them.

This post follows that one row, because an edit and a write look like the same operation and only one of them is reversible.

The symptom

The loss is selective, and that is the part that makes it hard to see coming.

Edits to existing files survive. Git knows about those files, so a switch either carries the changes across or refuses with a conflict — either way you still have your work. New files that were never added have no entry anywhere: not in the index, not in a commit, not in the object database. From git's point of view they are debris in the working directory, and a forced checkout treats them accordingly.

So what you lose is precisely the set of things that existed in exactly one place.

A page sewn through the fold, and a page merely laid in beside it

The second thing is that no step in the chain did anything wrong. The command that switched branches was asked to switch branches. Git did what it was told, efficiently and without complaint. There is no bug here to find and no component to blame, which means there is also no error message, no exit code, and no log line — the failure consists entirely of an absence that nobody is watching for.

The third is the recovery attempt, which is where the real feeling of it lands. You go to the reflog: it tracks references, and a file that was never committed never produced one. You look for a stash: nothing was stashed. You go hunting for dangling objects: an object is created when git is told about content, and it never was. Every one of these mechanisms is excellent and every one of them is empty, because they all share a precondition — that git was informed the work existed.

A card index with every drawer pulled out and not one card on any rod

And the fourth is the delay. The write and the loss are separated by however long you keep working: minutes, or an afternoon. By the time the files vanish you have long stopped thinking about which branch you were on, so the connection between I started writing on the default branch and my files are gone is not one anybody makes in the moment. What you experience is files disappearing for no reason, which is both alarming and useless as a diagnosis.

Why the obvious fix failed

Commit early and often. Correct advice that does not apply at the moment it is needed. The window of exposure opens the instant the first new file is written and closes when you commit; "commit early" asks you to interrupt the exact activity — writing something new — that creates the exposure. It is also, again, a rule enforced by memory, and the memory you need is of a branch name you chose hours ago.

Stash before switching. This is what most tooling does and it is explicitly forbidden here, for a reason worth stating: a stash moves your work somewhere you cannot see it. It converts a visible dirty tree into an invisible entry in a list people forget exists, and a stash that silently fails to apply later produces the same end state as the loss it was preventing. Work that matters should be committed, where it is in the history and on a branch, not hidden.

Warn on the protected branch. Cheap and almost useless. The warning fires at write time, which is right, and then does nothing, which is the problem — it lands in the middle of a wall of output during an activity that is going well, and warnings during success are not read. By the time it would have mattered it has scrolled away.

Protect at push time. This already existed, and it is where most branch protection lives: refuse to push to the default branch, require a pull request, the standard arrangement. It is a good control and it is aimed somewhere else entirely. Push-time protection governs what reaches the remote. The files were lost locally, hours before anything was pushed, and no amount of server-side policy reaches backwards into a working directory.

The actual mechanism

Refuse the write, and scope the refusal to exactly the operation that can lose data.

The distinction that makes this workable is between creating a new file and editing an existing one. They feel like the same activity and they have completely different risk:

edit an existing file   →  git knows it  →  a switch conflicts or carries it  →  recoverable
write a new file        →  git knows nothing  →  a switch deletes it  →  gone

A blanket "no changes on a protected branch" would be unusable. People legitimately fix a typo, tweak a config, check something quickly, and a gate that blocks all of it gets switched off within a week — and a gate that is off protects nothing. Blocking only new-file creation hits the precise set of operations that are unrecoverable and leaves everything else alone.

That precision is the whole reason the refusal can be hard rather than advisory. A control that fires rarely and is right every time survives; one that fires constantly gets disabled, and the disabling is what actually costs you the work.

There is a sub-case in that table that is easy to get wrong, and I had it wrong at first. Does the file exist on disk and does git know about the file are not the same question, and the gate cares about the second one. A file written ten minutes ago on the same protected branch exists — so an edit to it reads as the safe row — and it is still untracked, so it is still in the unrecoverable set. Test existence and you wave through every edit to work you created earlier in the same session, which is exactly the work most likely to be lost.

The correct test is whether the path is tracked, which git will answer directly. It is one extra call and it closes the window that would otherwise open the moment the first file slips past.

Two identical sheets on the shelf, one of them with an accession slip

The second half is that the refusal must carry the remedy. "You are on a protected branch" sends people to argue with the policy. "Run the session-start command first, then re-run this write" is one command and the problem is gone — and that command is the thing that would have created a proper branch in the first place, so the refusal is really just a late reminder of the step that was skipped.

The fix

At write time, three facts are available and the decision is mechanical:

which repo is this path in
what branch is it on, and is that branch protected
is this path tracked by git

The three facts, and the asymmetry that makes the rule usable — a NEW file is refused outright, an existing file only warns:

# PreToolUse hook on Write/Edit. Exit 2 refuses.
path="$1"
repo_root=$(cd "$(dirname "$path")" && git rev-parse --show-toplevel 2>/dev/null) || exit 0
branch=$(git -C "$repo_root" rev-parse --abbrev-ref HEAD)

case "$branch" in
  main|master|development|develop|release/*) ;;
  *) exit 0 ;;                                   # not protected — nothing to say
esac

rel="${path#$repo_root/}"
if git -C "$repo_root" ls-files --error-unmatch "$rel" >/dev/null 2>&1; then
  # TRACKED: an edit is recoverable — it shows in the diff and survives a switch.
  echo "warning: editing a tracked file on protected branch '$branch'" >&2
  exit 0
fi

# UNTRACKED + protected = the work-loss case. A new file here is invisible to git,
# so the next branch switch or merge discards it with nothing to recover from.
cat >&2 <<MSG
refusing: new file on protected branch '$branch'
  $rel is untracked, so a branch switch would discard it silently.
  start a session branch first, then re-run this write.
MSG
exit 2

The two cases, which look identical to the person typing:

$ edit src/Existing.kt            # on 'development'
warning: editing a tracked file on protected branch 'development'
  (proceeds — the change is in the diff)

$ write src/BrandNew.kt           # on 'development'
refusing: new file on protected branch 'development'
  src/BrandNew.kt is untracked, so a branch switch would discard it silently.
  start a session branch first, then re-run this write.

The distinction is the whole rule. Refusing every write on a protected branch is too blunt to live with; refusing only the one that cannot be recovered is a rule people keep.

Protected branch plus an untracked path means stop, name the remedy, and do not proceed. Protected branch plus a tracked path means warn and continue. Anything else is silent.

The ordering matters: the check runs before the write, not after. A refusal that happens after the content is on disk has not prevented anything — it has created a file in a place it just declared unsafe, which is strictly worse than either outcome.

And there is one escape, which is a human saying so explicitly, in the same exchange. Not a flag, not an environment variable set once and forgotten, not a config key — a statement, in context, that this particular write is intended. That keeps the override from becoming ambient, which is how overrides usually stop being overrides.

Six pieces of work, three of them recorded, and what the branch switch leaves behind

How we proved it

The measurement is the incident. One session lost roughly three hours of work: a new skill, an orchestrator, matrix wiring, and a set of vault-sync fixes. All of it was new files. None of it had been added to git. A later command performed a branch switch and the working directory came back without them.

That is not a benchmark, it is a single data point, and it is the only kind of evidence this class of problem produces — you do not get a rate for an event that destroys its own evidence. What makes it sufficient is the asymmetry it exposed: the cost of the control is one command, run once, at the start of a session. The cost of the failure was an afternoon and a reconstruction from memory.

What I have not solved: the gate sits in the write path of the tooling. A file created any other way — an editor saving, a script, a shell redirect — does not pass through it and gets no protection at all. For the way I work that covers most of the exposure. It is a tooling-level control and I would not describe it as a guarantee, because the same mistake made in a text editor produces exactly the same loss.

A second gap: the protected list is configuration. A branch nobody added to it gets nothing, and I have no check that notices a new long-lived branch deserves to be on the list. Today that list is short and stable. The day somebody adds a release/ line and does not update the list, this control quietly does not apply, and nothing will say so.

And a thing I did not do: I never tried to recover the lost files. Not from the filesystem, not from editor backups, not from any temporary directory. I concluded they were gone because every git mechanism was empty, and git being empty is not the same as the bytes being unrecoverable. If I had to re-run that afternoon I would look harder before accepting the loss — which does not change the argument for the gate, but it does mean my "unrecoverable" was a conclusion rather than an exhausted search.

What this generalizes to

For any operation that can destroy work, find the earliest moment the system has enough information to refuse, and put the control there.

The information needed here — which branch, does this file exist — was available at the write, hours before the loss. Everything downstream of that point was progressively worse positioned: the branch switch did not know the files mattered, and the recovery tooling did not know they had existed. Controls get cheaper and more precise the earlier they sit, because that is where the context still exists.

And when you are deciding how hard a control should be, weigh the two costs rather than the probability. A refusal that is occasionally unnecessary costs seconds. An unrecoverable loss costs whatever was in the window. Where those two numbers are that far apart, the right setting is a refusal — and the work that makes a refusal tolerable is narrowing it until it only fires on the operations that genuinely cannot be undone.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS