Skip to content

Variations (trying something without breaking the lesson)

A variation is a separate copy of a lesson you can change freely. The lesson everybody else reads doesn't move while you work on one, and nothing you do to a variation reaches it until you say so.

It exists for the thing authors were doing the hard way: rewriting half a lesson to see whether the rewrite is better. Before this, the only way to do that without risking the original was to fork it into a whole second lesson and open a proposal against yourself. A variation is the same idea at the right size.

What it looks like

The editor shows which copy you're on, next to the "Version saved" chip:

text
  Version saved 2 minutes ago     Main lesson  ▾
  Version saved just now          Simpler for Year 3  ▾   <- on a variation

Clicking it opens the list. From there you can start one, switch between them, rename one, delete one, and — the point of the whole thing — bring one into the main lesson.

Each variation says how much work is sitting on it ("3 changes that aren't in the main lesson"), which is the only number an author needs before deciding whether to open it.

Bringing one in

This runs the same block-by-block merge as everything else in Version history: the main lesson and the variation are lined up against the commit they last agreed on, and only a block both sides changed in the same field reaches a dialog. Usually nothing does, and the merge summary just says what it settled.

The order matters and is fixed: the editor switches to the main lesson first, then merges the variation into it. That is what makes the result the lesson with your changes folded in, rather than the variation with the lesson folded in — which is the same commit and the opposite meaning. Anything you had unsaved is committed to the variation on the way out, so nothing in flight is carried across by accident.

Afterwards the variation is still there, now reading "0 changes that aren't in the main lesson". Keep it and carry on, or delete it.

Trying somebody else's proposal in one

A variation is also where a reviewer can put a proposal they aren't sure about — "Try it in a variation" on its page — and read the whole lesson with the change in it before deciding. The lesson doesn't move and the proposal stays open.

Variations are as public as the lesson

They travel with the lesson, so a variation you start on your laptop is there when you open the lesson on your phone. That is the point — but it means a variation lives in the same packfile as the lesson, and a published lesson's packfile is public, because that is what makes forking work.

So: anyone who can open the lesson can read its variations. On a private draft that's you (and anyone you trust); on a published lesson that's everyone. The dialog says so, in those words, where an author will see it.

If you want to try something genuinely privately, fork the lesson into a new private draft instead — see Pull requests.

What it is underneath

A variation is a branch of the lesson's git repository, and switching between them is a checkout. None of that vocabulary appears in the app, deliberately: an author isn't doing version control, they're trying something and keeping the original safe while they do.

The mapping is exact, though, and everything on this page falls out of it.

In the appIn the repository
The main lessonrefs/heads/main — the default branch
A variationrefs/heads/<name>
Which one you're editingHEAD, a symbolic ref
SwitchingWriting HEAD, and adopting the doc at the new tip
Bringing one inA merge commit on main — or a fast-forward*
"3 changes that aren't in…"Commits on the branch not reachable from main

* When main is the merge base and the editor has nothing uncommitted, the branch simply moves: there is nothing for a merge commit to record. Otherwise it is a two-parent commit, and only blocks changed on both sides in the same field reach a dialog — a caption edited here and a width edited there merge field by field, with both kept.

Recording the current variation in HEAD rather than beside the repository is what makes it survive a reload, a second tab, and the two places a repository gets copied wholesale — publishing a draft (adoptDraftRepo) and forking a lesson locally (copyRepo), neither of which knows branches exist.

Names

Git bounds what a branch may be called, so what an author types is converted: spaces become hyphens, anything git reserves is dropped, and the result is capped at 32 characters. It is read back the other way for display, so "Simpler for Year 3" round-trips. The rules live in @spelling-creator/core/git/refs and are imported by both the editor and the Worker, so what the app offers and what the server accepts can't drift apart.

A lesson may have at most 12 branches. That ceiling isn't taste: the branch map rides in the R2 object's customMetadata alongside the pack it belongs to (so a reader can never pair one moment's bytes with another moment's refs), and R2 caps that metadata at 2 KB.

How they travel

The lesson's stored refs.json gained a map, and kept head meaning exactly what it always did — the default branch, which is what a reader, a forker and the lesson's own page ask for:

json
{
  "head": "<oid>",
  "refs": {
    "main": "<oid>",
    "Simpler-for-Year-3": "<oid>"
  },
  "size": 41203,
  "updatedAt": "..."
}

The pack holds every object reachable from any branch. That costs almost nothing: branches of one lesson share nearly all of their objects, and the packer dedupes by oid, so a second variation adds only the commits unique to it.

A fork takes the default branch alone. Somebody else's half-finished ideas aren't part of what was forked, and adopting them as branches of the fork would claim they were.

A proposal carries exactly one branch: the one you were working on when you proposed it. Both halves matter. Offering the branch you are looking at is the only reading of "propose these changes" that isn't a trap — work an idea up on a variation, propose, and you would otherwise have sent the untouched lesson and been told it worked. Offering only that one is what keeps the rest to yourself. See Pull requests.

Pushing more than one branch

The compare-and-swap that has always guarded a push now runs per branch. Three headers describe what a push wants, and the Worker applies all of it or none:

text
X-Git-Refs      the branches to set, { "<name>": "<oid>" }
X-Git-Expected  what the client believes the hub holds for each name it touches,
                with "" meaning "I believe this one does not exist yet"
X-Git-Deletes   the branches to remove, comma-separated

Atomicity is free: refs.json is a single R2 object and already the commit point, so every branch advances or none does.

The rule that makes this safe with two devices is that a branch a push doesn't mention is left exactly as it is. Otherwise a device that had never heard of a variation would delete it simply by not knowing about it. A client sending neither X-Git-Refs nor X-Git-Expected — one written before any of this — therefore still means "move the lesson, leave everything else alone", and keeps working.

Deleting has to be asked for

Which leaves a gap: if a push only ever adds, how does a deletion travel? It can't be inferred, for the reason above — "I don't have it" and "I deleted it" look identical from a ref map.

So a delete leaves a marker in the repository (refs/deleted/<name>, holding the tip it pointed at), the next push turns that into an explicit X-Git-Deletes instruction compare-and-swapped against that tip, and only a push that actually landed clears the marker. Cleared any earlier and the variation would be gone locally, alive on the hub, and back on the next device that opened the lesson.

The same marker is why fetching doesn't undo a delete: a branch on the hub that we hold a marker for is not adopted back. And reusing the name clears it — a name used again is not the deleted variation returning, and a marker left behind would make the next push ask to create and remove one name in a single request. The Worker refuses that request rather than picking a half.

Fetching prunes in the other direction too. A branch the hub no longer has, which we still hold at exactly the tip it last told us about, holds nothing that isn't already gone, so it goes — otherwise a deletion made on one device would be undone by another that still had the branch. One that has moved holds unpushed work, and that is the author's to keep: it goes back up, and they can delete it again.

Where it lives

PieceWhat it does
@spelling-creator/core/git/refsName rules, limits, and the ref map's wire format. No git.
@spelling-creator/core/git/repocurrentBranch, create / checkout / rename / delete, aheadCount
@spelling-creator/core/git/packPacking every branch; a clone writing them back
@spelling-creator/core/browser/git/syncPer-branch push, adopting the hub's branches, prepareBranchMerge
apps/api/src/routes/git.jsThe per-branch compare-and-swap (applyRefs)
apps/web/src/lib/git/useLessonGit.jsThe editor's variation state and actions
apps/web/src/components/VariationsDialog.jsxThe list, and everything you can do from it

Copyright © 2026 Spelling Creator.