---
url: https://spellingcreator.org/docs/developers/web-app/variations.md
---

# Variations

For how to use this, see [Variations](../../guide/variations.md).

A **variation** is a separate copy of a lesson its author can change freely. The
lesson everybody else reads doesn't move while they work on one, and nothing done
to a variation reaches it until they bring it in.

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.

Variations are built on the per-lesson git repository described in
[Version history](../version-history.md).

## 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 UI words are "variation", "the main lesson" and
"bring it in".

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

| In the app                    | In the repository                                   |
| ----------------------------- | --------------------------------------------------- |
| The main lesson               | `refs/heads/main`, the default branch               |
| A variation                   | `refs/heads/<name>`                                 |
| Which one you're editing      | `HEAD`, a symbolic ref                              |
| Switching                     | Writing `HEAD`, and adopting the doc at the new tip |
| New variation                 | A branch created at the commit you're on            |
| Bringing one in               | A 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`), both in `@spelling-creator/core/browser/git/fs`, neither of
which knows branches exist.

### Bringing one in

This runs the same block-by-block merge as everything else in
[Version history](../version-history.md): 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. If every commit on the variation is already
in `main`, there is no dialog at all, only a notice that it's already part of
the main lesson.

The order matters and is fixed (`handleBringVariationIn` in `EditorPage.jsx`):
the editor **switches to the main lesson first**, then merges the variation into
it with `prepareBranchMerge`. 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 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".

### Trying somebody else's proposal in one

A variation is also where a reviewer can put a [proposal](./pull-requests.md)
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. See
[Trying it before deciding](./pull-requests.md#trying-it-before-deciding).

## Visibility

They travel with the lesson, so a variation started on one device is there when
the lesson is opened on another. 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 the author (and anyone they trust); on a published lesson that's everyone.
The dialog says so where an author will see it ("Variations are saved with the
lesson, so anyone who can open the lesson can see them.").

## Names

Git bounds what a branch may be called, so what an author types is converted
(`toBranchName`): runs of spaces become one hyphen, anything outside
`A-Z a-z 0-9 . _ -` is dropped, leading and trailing separators go, and the
result is capped at 32 characters (cut at a hyphen when one is near the end).
It is read back the other way for display (`branchLabel` turns every hyphen into
a space), so a typed phrase 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** (`MAX_BRANCHES`), `main` included.
The editor doesn't count them when a variation is created; a push over the limit
is refused by the Worker ("A lesson can have at most 12 versions."). 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>",
    "Shorter-passages": "<oid>"
  },
  "size": 41203,
  "updatedAt": "..."
}
```

`GET /git/:lessonId/pack` returns the pack with its tip in `X-Git-Head` and
every branch it holds in `X-Git-Refs`.

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](./pull-requests.md).

### Pushing more than one branch

The compare-and-swap that has always guarded a push now runs per branch
(`applyRefs` in `apps/api/src/routes/git.js`). Three headers on
`PUT /git/:lessonId/pack` 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

| Piece                                          | What it does                                                                                                                    |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `@spelling-creator/core/git/refs`              | Name rules (`toBranchName`, `branchLabel`), limits, and the ref map's wire format. No git.                                      |
| `@spelling-creator/core/git/repo`              | `currentBranch`, `createBranch` / `checkoutBranch` / `renameBranch` / `deleteBranch`, `aheadCount`, the `refs/deleted/` markers |
| `@spelling-creator/core/git/pack`              | Packing every branch; a clone writing them back                                                                                 |
| `@spelling-creator/core/browser/git/fs`        | `adoptDraftRepo` and `copyRepo`, which carry `HEAD` along                                                                       |
| `@spelling-creator/core/browser/git/sync`      | Per-branch push, adopting the hub's branches, `prepareBranchMerge`                                                              |
| `apps/api/src/routes/git.js`                   | The per-branch compare-and-swap (`applyRefs`)                                                                                   |
| `apps/web/src/lib/git/useLessonGit.js`         | The editor's variation state and actions                                                                                        |
| `apps/web/src/components/VariationsDialog.jsx` | The list, and everything you can do from it                                                                                     |
| `apps/web/src/pages/EditorPage.jsx`            | The variation chip, and `handleBringVariationIn`                                                                                |
