History, branches and merging — tcm-ts

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 history lists the change (reads differently), tcm diff shows it, tcm reset and tcm revert undo 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 edit it 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, unless n or -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); --all lists 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 1 undoes 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 on main) gives it its message.
  • It goes where the entry goes. tcm push and tcm pull carry it (a tcm from before messages keeps it, and shows it under the entry's command), and tcm migrate keeps it. A codebase that has an entry already keeps the message it has: one changed with tcm describe after 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's tcm_merge asks 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.
  • --squash makes 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 status doesn'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, --squash or not: still one entry, with the branch's head as a second parent, and tcm merge says 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:

  1. main is 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.
  2. 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.
  3. Then the branch lands: a fast-forward, or one entry with --squash. As main came into it first, that's clean; if main moved meanwhile and it isn't, it isn't made.
  4. Squashed, the landing isn't in the branch's history, so main is 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 view shows it (a page: its Markdown), from the base's, three-way, as git merge-file does. 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 says merged as text. (It's checked once the merge's other changes are in, as tcm check would check it, not against the code as it was mid-merge.) Otherwise the name becomes a conflict, listed by the merge and by tcm 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 push sends their sides with it, and tcm pull keeps 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 it dropped it.

  • 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, as tcm view shows 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 one tcm update left 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 check checks 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–9

    Keep 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 check and tcm update refuse 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, from tcm edit or written by hand: tcm check says resolves, 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 edit and tcm check say 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 it resolve <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.

built from c482a14e