Your Shell History Is Not the File on Disk

I wanted a safe way to rename a half-remembered model flag across old terminal commands — gpt-6.1-astra to gpt-6-astra — without hand-editing $HISTFILE in an editor and hoping I hadn't broken a timestamp.
The naive plan was a one-line sed on the history file. Done, right?
Not quite. The shell I was typing in already had its own copy of that history in memory. Rewrite the file underneath a running shell and the shell keeps talking about the version it already loaded, not the one now on disk.
Building zsh-histfix — a small plugin that finds and replaces text in zsh history with a preview and a real undo — meant admitting that "the history" is not one artifact. It is a file and a shell's belief about that file, and the interesting bugs live in the gap between them.
Treat the shell's memory as a thing to verify, not assume
The safe version of the rewrite has to know that the events currently in the shell's memory still correspond, one for one, to records in the file it is about to change.
Without SHARE_HISTORY, that's exactly how the plugin treats it. Before touching a single existing record, it takes a snapshot of in-memory history (fc -W with an empty HISTORY_IGNORE) and maps each event to a file record. If an event exists only in memory — imported from elsewhere, or excluded from disk by a zshaddhistory hook — or if duplicate entries make the mapping ambiguous, it refuses. Not a best-effort replace with a warning afterward. A refusal, not a guess.
That mapping check was also the only version of this tool I was willing to run against my own terminal. It also only runs when there's a single, stable memory snapshot to check against.
SHARE_HISTORY breaks the easy fix
I assumed reloading the shell after a successful rewrite was the same problem everywhere: read the file back, done. SHARE_HISTORY — which Oh My Zsh turns on by default — made that assumption expensive, and it made the memory-snapshot check above moot: with it enabled, other shells' imports change the event list at every prompt, so the plugin takes no snapshot at all. There is no single, stable "memory" left to map against a file record.
What SHARE_HISTORY enables instead is every shell sharing a history file importing new commands from the others at each prompt, through an internal read position zsh keeps for that import stream. fc -R, the normal "reload from file" command, cannot reset that position. Call it after rewriting the file and the shell would, at the next prompt, import every record again.
The fix wasn't a reload. It was starting a new history level with fc -p, which reads the rewritten file fresh and resets the shared-read position as a side effect of opening that level. fc -P later would drop back to the old level and its old text, which is exactly the "undo without really undoing" trap I had to make sure the real histfix undo didn't fall into.
But fc -p takes zsh's own history lock first, and it will wait on a stale lock directory indefinitely — forever, not a timeout. If this same run's own lock cleanup fails to remove <HISTFILE>.LOCK right after its rewrite succeeds, calling fc -p immediately afterward would hang the shell. So the plugin checks: if the lock directory couldn't be removed, skip the reload and say so, rather than reload and risk freezing the terminal that just asked for a simple text replacement. test_share_history_skips_reload_when_lock_directory_remains is the regression that keeps that one honest.
The shell's in-memory events that only ever existed in memory — history excluded by HIST_IGNORE_SPACE, say — are no longer listed at the current level after that reload, and they were never on disk to begin with. They aren't erased, either: like the rest of the pre-reload list, they sit in the level below until that shell exits. That is correct, and it is also the kind of correct that feels like data loss the first time you see it happen.
A successful replacement is not the same as removal
The part of the README that worries me most isn't about zsh internals at all. It's the section called "Removing sensitive text," and it exists because people will reach for a history-editing tool specifically to scrub a leaked token.
Replacing a secret in the history file does not make the secret disappear:
- Any other open shell that already loaded the old command still has it in memory, and can write it back to disk later with
fc -W. - With
SHARE_HISTORY, each replacement that actually reloads pushes a new history level, and the level below keeps the old text until that shell exits. - The undo record itself,
<HISTFILE>.histfix-undo.json, holds the full before-and-after content until the next replacement or a successful undo. - If the process was killed mid-run, a private temporary directory under
$TMPDIR, or a separate temporary file left beside the history file, can each still hold a copy.
None of that is a bug in the replacement logic. It's what "replace" actually means when the thing you're editing has copies living in multiple processes' memory and a couple of recovery files on purpose. The README says it plainly: close the other shells, delete the undo record once you've checked the result, and rotate the credential — removing it from history does not revoke it.
I'd rather ship a tool that tells you where the old text can still be than one that implies a clean history file means a clean machine.
histfix --version currently reports 0.1.0, installable via brew install AndrewDongminYoo/tap/histfix. If you've ever edited a history file by hand and then wondered why your shell didn't seem to notice, that gap between the file and the shell's memory of it is the whole story — and it's worth checking which side of that gap your own tooling actually verifies.
Get the next post.
If you made it to the end, meet the next post in your inbox or RSS reader.