19. History, branches and merging
Names are the only mutable thing in a codebase, so history is a history of namespaces, kept the way Unison keeps it:
- A namespace is a tree of hashed nodes. Each node is hashed from the names in it and the hashes of the nodes under it, so one state of all your names has one hash, and a change rewrites only the nodes on its path.
- A history entry (a causal) is a namespace and the entries it came from. Its hash is made from just those, not from who made it or when, so the same change made from the same point is the same entry, and history has the same hashes in every codebase that has it. Each command that changes names makes one entry.
- A branch points at its latest entry. Every history starts from the same empty one, so any two codebases share an ancestor.
19.1 How code reads: comments, layout and docs
A definition's hash is its code: its comments, its layout and its doc aren't in it (hashing), as Unison keeps docs apart from the code they're about. How a definition reads is still part of a branch, and changes with it like its names do:
- A change to only how a definition reads is the branch's. Saving the same code with other comments, layout,
names for what it imports from outside the codebase, or doc (
~ text,~ doc),tcm fmt, and a mirror spelling code as its files do, keep the hash, and change how the definition reads on this branch alone: other branches read it as before. - It's a history entry like any. Each way a definition has been written is kept beside it, by a hash of its own
(of its text and its doc), and a namespace node says which one each of its names shows, where that isn't the
text the definition was first stored with. So
tcm historylists the change (reads differently),tcm diffshows it,tcm resetandtcm revertundo it, and pushing and pulling carry it (a codebase receiving a text checks it's the same code, as it checks code). A node naming no other text is hashed as it always was. - Merges take it three-way, as they do names: a side's change to how a definition reads is kept. Where both
sides changed the same definition's comments or doc differently, this branch's is kept, and the merge says so
(
kept). Where one side changed a definition's comments and the other its code, the code is kept and the comment change isn't (dropped):tcm editit to write it again.
19.2 Reviewing changes: tcm diff
tcm diff reviews changes by meaning: what the last command changed, or tcm diff <n|#hash> since a history
entry, tcm diff <branch> against another branch, tcm diff <a> <b> between two branches or entries.
- Edits are shown as line diffs, with each side's own names.
- Definitions re-hashed only because a dependency changed are listed separately, with what triggered them.
- Renames and aliases are recognized as such.
- Renaming locals, reformatting or changing comments doesn't change the hash: such a definition is listed apart
(
comments, layout or doc changed), with a line diff of how it reads.
19.3 History and undo
$ tcm history 3
#upoceguk 0 2026-10-29 01:08:47 mv geo.distance geo.dist
geo.dist + #ot2b562p
geo.distance - #ot2b562p
#eizpcqxs 1 2026-10-29 01:08:47 update geo.distance
geo.distance #c5ttv34t → #ot2b562p
and 1 updated because a dependency changed (`tcm history --all` lists them)
#ufvwor4x 2 2026-10-29 01:08:46 add geo.pathLength
geo.pathLength + #2gf3dz43
tcm history [n]shows this branch's latest entries (30, unlessnor-n <n>says): each one's hash, how many entries back it is, its message if it was given one, the command that made it, named by what it changed (update lib.f, lib.g (+3), not the scratch file it came from), and what it changed: what it edited, added, removed and renamed by name, and how many definitions it re-hashed only because a dependency changed (a change to something much code uses re-hashes hundreds);--alllists those too. A merge is one entry, listing the history it merged.tcm reset <n|#hash>brings back the names as they were at an entry, as a new entry on top: history is never rewritten, so a reset can be reset too, and it's safe after a push. Code is never deleted. In an app it's also how you roll back a deploy (production).- An entry is a number (how many back on this branch:
tcm reset 1undoes the last command) or a hash from any branch's history; the#is optional, which helps in shells that read#…as a comment (and a doubled one,##abc, is the same hash).
19.3.1 Messages
An entry can say what it's for, as a commit message does: -m <message> (or --message) on a command that makes
one (add, update, patch, mv, alias, rm, resolve, import, upgrade, fmt, merge, revert,
reset). A message is a line saying what and why, then more after a blank line if it needs it:
$ tcm patch geo.dist --old 'Math.abs(a - b)' --new 'Math.hypot(a, b)' -m 'geo.dist: Euclidean, so 2-D points work
Callers pass x and y offsets now.'
$ tcm history 1
#izftjcue 0 2026-11-03 05:00:06 geo.dist: Euclidean, so 2-D points work
Callers pass x and y offsets now.
update geo.dist
geo.dist #6udjjymz → #mxhghddi
- It's the entry's title.
tcm history,tcm reset's list of what to undo and the web UI call an entry by its message's first line, with the rest of it and the command that made it (named by what it changed, as an entry without a message is) under it. - It's in no hash. An entry's hash is its names and the entries it came from, so the same change from the same
point is the same entry whatever it says, and a message can change without changing history:
tcm describe [n|#hash] -m <message>gives an entry one, or another (-m ""takes it away);tcm describe [n|#hash]shows it. Without an entry, it's the latest. A command told what it's for that makes an entry there already (a branch of one step, landed squashed with nothing new onmain) gives it its message. - It goes where the entry goes.
tcm pushandtcm pullcarry it (a tcm from before messages keeps it, and shows it under the entry's command), andtcm migratekeeps it. A codebase that has an entry already keeps the message it has: one changed withtcm describeafter a push is changed here, not on the remote. - A landing says what the work was for.
tcm merge <branch> --squash -m …makes one entry of a branch's work, called by its message; an agent'stcm_mergeasks for one (agents). A contribution with a description, merged (not fast-forwarded), has its title and description as its message.
19.4 Branches
tcm branch [name] lists or creates branches; tcm switch <name> changes branch. A branch is a set of names
(and the history that led to them), with how each definition reads; the definitions themselves are shared.
19.5 Merging
tcm merge <branch> goes by history:
- If this branch already has the other's history, there's nothing to merge.
- If the other branch has this one's, this branch moves to it (a fast-forward, with no merge entry).
- Otherwise it's a three-way merge of names from the best common ancestor of the two histories, recorded as an entry with both as parents. The next merge starts from there, in any codebase that has this history, so pulling and pushing back and forth never merges the same change twice.
--squashmakes what came in one entry whose only parent is this branch's head (where it could fast-forward too): the other branch's entries, each step it took, stay its own, and pushing this branch sends only what it names, not every version along the way. The other branch's work counts as here once its names are:tcm statusdoesn't list it, and squashing it in again finds nothing to bring. Sessions land this way (agents).- A branch that made releases keeps its history,
--squashor not: still one entry, with the branch's head as a second parent, andtcm mergesays which releases. A release is an entry of the branch's, so squashed, it wouldn't be in this branch's history: the next release here wouldn't follow it (it would plan the first again), and a push wouldn't carry it.
19.5.1 A merge left to settle waits apart
A merge that leaves anything to settle (a conflict, or code that stops typechecking against it) isn't made on the
branch: the branch stays where it was, and the merge waits apart (tcm todo and tcm status say so first:
merging main into s: not finished). It writes scratch files holding what's to settle: each conflicted name's sides
merged, as tcm edit writes them (lib.k.ts, below), and what stopped typechecking, in merge-fix.ts (only what
failed itself: what failed because something it uses did follows once that's fixed). Commands on the branch (tcm edit, update, resolve, todo, view, test, …) work on the merge as merged, and once one leaves nothing to
settle, the branch moves to the merge, with its entry and every one that settled it. Until then the branch is as it
was, for every other process too, and tcm push sends it as it is. tcm merge --abort drops the merge; merging
again while one waits is refused. tcm pull merges the same way.
19.5.2 Landing
On main (and with --squash, as sessions land), tcm merge <branch> lands the branch:
mainis merged into the branch first, there; where that leaves something to settle, it waits on the branch (above) and nothing lands. What both changed and merged as text cleanly is named (both changed lib.x): worth reading before it lands, as tests can't tell what each side meant.- Then what needs fixing on the branch (
tcm todo), and the tests the change reaches (tcm test), each on the branch; anything left, or a test failing, and nothing lands. - Then the branch lands: a fast-forward, or one entry with
--squash. Asmaincame into it first, that's clean; ifmainmoved meanwhile and it isn't, it isn't made. - Squashed, the landing isn't in the branch's history, so
mainis merged into the branch once more (changing none of its names): work kept on the branch then merges from the landing, and a name it changes again isn't a conflict with its own landed version.
So main never holds a conflict, or code that doesn't typecheck, from a merge. A remote's branch (tcm merge origin/main) isn't landed: it's merged, and only if that's clean. An agent's tcm_merge is this landing.
A name changed on one side takes that side.
A definition only re-hashed because a dependency moved doesn't count as a change.
A definition both sides edited is merged as text: its code as
tcm viewshows it (a page: its Markdown), from the base's, three-way, asgit merge-filedoes. If the edits are apart (different methods of a class, different bullets of a page) and the result typechecks, it's taken, and the merge saysmerged as text. (It's checked once the merge's other changes are in, astcm checkwould check it, not against the code as it was mid-merge.) Otherwise the name becomes a conflict, listed by the merge and bytcm todo, each definition labelled with its side (ours: this branch's, theirs: the one merged in) and branch:conflict lib.Store: #g3o6xnsj (ours: main) vs #5ren724d (theirs: b) tcm edit lib.Store merges them (conflict markers where they overlap); tcm resolve lib.Store --ours or --theirs keeps one
Which side each came from, and the merge base's definition, are kept with the branch until the conflict is resolved (as a setting of the codebase, beside its history: no hash changes). A branch holding conflicts (made by a tcm from before merges waited apart) carries them:
tcm pushsends their sides with it, andtcm pullkeeps them, labelled as the merge that made them labelled them.A side's change to a definition's comments or doc, where the other side changed its code (or only re-hashed it, after a dependency moved), is merged into that code as text, and kept if that leaves the code as it is (
merged as text); otherwise the merge says itdroppedit.A name one side deleted is kept if the other side's code still uses it.
Every definition on the merged branch then uses what the branch names. Whatever a side had at a name (an update, the other side's re-derived version, the base's) is replaced by what's merged there, and everything that used it, from either side, is re-derived against that, in dependency order, as an update re-derives its dependents: code taken from one side follows the other side's changes to what it uses, through whatever is between. What no longer typechecks goes to
todo, and the merge names it.A conflicted name holds both definitions, each re-derived against what the merge names like all code (so each uses the other side's changes to what it uses), and code using one directly goes on using it. Code reaching it through definitions both sides only re-derived (a class both changed, used through one that holds it) uses this branch's, so the other side's code needing its own may not typecheck until it's settled. A definition that can't follow (it doesn't typecheck against this side's change) keeps using what it used:
tcm view '#<hash>'names that by its name elsewhere (an older lib.save,lib.save on main).
19.5.3 Settling a conflict
tcm edit <name>writes a scratch file of its own, named after the name (lib.k.ts; a page: its Markdown file), holding the two sides merged three-way from the merge base's definition, astcm viewshows each, with what either side imports but what nothing in it uses (an import only one side's code used, on lines the other changed). Conflicts edited one after another each have their own file; a file there already is written over only if that loses nothing (it holds only what's saved, as onetcm updateleft behind does, or the conflict's untouched edit), else it says what's in it that isn't saved. Where both changed the same lines the file has git's conflict markers, each a line of its own:<<<<<<< ours (session/x) const a = 10; ||||||| base const a = 1; ======= const a = 100; >>>>>>> theirs (main)
It says what each side changed from the base (lines added and taken out, and where that is in the file), how many stretches both changed (marked, or taken once where they changed them alike), and, where nothing's marked, whether the merge typechecks, checked as
tcm checkchecks it:wrote lib.k to lib.k.ts lib.k: ours (s) and theirs (main), merged from the base ours: +1 −1 lines, at lines 3–9 theirs: +2 −2 lines, at lines 3–9, 11 both: 1 stretch changed differently, marked at lines 3–9Keep what each side should, take the markers out, and
tcm update lib.k.ts: saving a definition at a conflicted name resolves the conflict to it, and re-derives what used either side's against it, once. (tcm checkandtcm updaterefuse a file with markers left in it: lines<<<<<<<,=======and>>>>>>>, in order, at the start of a line; text only mentioning them isn't one.) Any definition saved at a conflicted name resolves it so, fromtcm editor written by hand:tcm checksaysresolves, and history names the entry so:resolve lib.k (merged)(or(ours),(theirs), where what's saved is that side's definition).A conflict that uses another conflicted name can't typecheck until that one's settled (a conflicted name isn't one definition to check against):
tcm editandtcm checksay so (uses lib.k, which is still conflicted). Settle that first, or edit both together,tcm edit lib.m lib.k: one file holds both, each merged from its base, and saving it resolves both.tcm resolve <name> --ours(or--theirs) keeps one side's definition whole, by its side: resolving one conflict re-derives the other side's code that used what it drops, so a hash copied before can be stale.tcm resolve <name> <#hash>keeps that one. Either way, everything that used the others is re-derived against the one kept; history names itresolve <name> (ours)(or(theirs), or the hash).tcm diff <name>shows the base's definition against each side's;tcm diff <#a> <#b>any two definitions.
19.5.4 Criss-cross merges
Branches that merged each other (a criss-cross: two sessions landing on main and merging it back in, say) can
have several best common ancestors, each missing what the others changed. As git's recursive strategy does, tcm
merges them first, oldest first and each step three-way from their own common ancestors, into a virtual base, and
merges the two branches from that; merging from any one of them would take the others' changes as unchanged, and
lose a side's undoing of them. A conflict while building the virtual base stays a conflict in it (the name holds
both definitions, as git keeps conflict markers): neither side counts as unchanged for keeping one of them, so
where the two branches settled it alike the merge agrees, and where they settled it differently it's a conflict
again. The virtual base is only compared against, never saved. (The same merge made on both branches is one entry,
its hash being its names and parents, so merging each other with nothing else between makes no criss-cross.)
19.6 tcm todo
Conflicts (each definition labelled with its side of the merge, and how to settle it), plus dependents that
stopped typechecking after an update or merge. tcm edit them, fix, and tcm update. A merge waiting apart is
said first.
What needs fixing is worked out from what the branch names now: a definition that couldn't follow a change counts
while it still uses the version that was replaced (or uses one that does), so going back (tcm reset, a merge
dropped) leaves nothing to fix, whatever was recorded before.