Skip to main content

Git as an undo history for fiction

Hayden Westbrook
writing-studio git node cli

I write fiction in plain Markdown files, one file per scene, and I built a command-line tool called writing-studio to organise them. One of its jobs is to make sure I never lose text to an overwrite. The mechanism is git, but the writer is never asked to know that.

The idea

Every time a scene is saved, the tool commits it to a git repository rooted at the project directory. In the built-in editor a save is Ctrl+O. With an external editor, a save is exiting the editor after making changes. There is no setup step. The first save creates the repository if there is not one yet.

I chose git over inventing a snapshot format for a few reasons. History, diffs and “show me the file at that point” already exist and are well tested. The result is an ordinary repo, so git log and git diff work if I want them. And the same repo can later be pushed to a remote for sharing with an editor: the tool has thin wrappers over git remote add, push and pull and no sync protocol of its own.

The code lives in one module, packages/core/src/vcs.js. It shells out to the git binary with spawnSync rather than using a library.

What a save does

A save stages the scene file, plus its .notes.json and .provenance.json sidecar files if they exist, then checks whether anything is actually staged. If nothing changed, it returns without committing, so saving an unchanged scene never creates an empty commit. The default commit message is Save <path> (<n> words), where the word count is a whitespace split of the text.

A few small decisions keep this from becoming a nuisance:

  • Missing git is not an error. The tool checks once whether a git binary exists and caches the answer. If not, versioning quietly does nothing and the save still writes the file.
  • Commit identity. If the user has no user.name or user.email configured anywhere, the tool sets a local fallback identity inside that repo only. It never overrides a real one.
  • Failures are swallowed on purpose. If git add or git commit fails, the function returns null. A failed commit must never stop a writer from saving prose.

Looking back

There are four commands: ws scene log, diff, show and restore, each taking a scene path. The log lists every save of that scene, newest first, and it uses git log --follow, so history survives a rename of the scene file. diff compares the working copy with the last save, or with a revision you give it. show prints the scene’s full text at a past save.

Restore semantics

Restore is the part I thought hardest about. The obvious implementation is git checkout <rev> -- file, which puts the old text in place and leaves you with an uncommitted change. Another is git reset, which rewrites history. I did neither.

restore reads the scene’s content at the chosen revision, writes it over the working file, and then commits that as a new save with the message Restore <path> to <rev>. It is history-preserving, in the same spirit as git revert. The saves between then and now stay in the log. If I restore the wrong version, restoring again from the log gets me back to where I was.

For a writer this matters more than it does for code. In fiction, the paragraph I cut last Tuesday is often the one I want back on Friday, and I do not want “undo” to be a one-way door. The tests cover the edge case where the scene is already at the requested version: restoring then commits nothing.

The opt-out, and why it exists

Auto-committing is a decision made on the writer’s behalf, and I do not think it should be invisible or mandatory. Some people keep their writing in a repository they already manage, with their own commit style, and a tool adding a commit on every save would be noise. Others do not want a manuscript’s full revision trail sitting in a hidden folder. A draft is a private thing, and the trail records every sentence you wrote and deleted. For those writers the safe default is a way to turn it off.

So there are two switches:

  • The environment variable STUDIO_AUTOCOMMIT. Setting it to 0, false, off or no disables commits. Setting it to 1, true, on or yes forces them on.
  • A project-level setting in project.json: "studio": { "autocommit": false }.

The environment variable wins over the project file, so a one-off override does not require editing the project. If neither is set, auto-commit is on. If project.json is missing or unreadable, that also falls back to on.

When commits are off, saves still write the scene file. The function that commits also returns before it creates a repository, and a test asserts that no .git directory appears. That detail matters: turning the feature off should mean the tool leaves no trace in the project, not just that it stops adding commits.

What I would change

Two things. The default message is only a word count, which makes a long log hard to scan. A short note on the save would help, though prompting on every save would defeat the point. And the opt-out is all-or-nothing per project. A writer might reasonably want history for finished chapters but none for a private scratch scene, and today they cannot say so.

The test file for this module, packages/core/test/vcs.test.js, has eleven tests covering lazy repo creation, no-op saves, notes files, history, diff, restore and the opt-out precedence. They are skipped when no git binary exists, which seems fitting for a feature that treats git as optional.