History, branches and merging — tcm-ts

History, branches and merging

Names are the only thing in a codebase that changes, so history is a history of names:

  • Each command that changes names makes one history entry. An entry is a state of all your names and the entries it came from.
  • An entry's hash comes from just those, not from who made it, when, or its message. So the same change made from the same point is the same entry, with the same hash in every codebase that has it.
  • A branch points at its latest entry. Every history starts from the same empty entry, so any two codebases share an ancestor.
  • History is never rewritten, and code is never deleted: undoing is a new entry.

How code reads: comments, layout and docs

A definition's hash is its code. Its comments, layout and doc aren't in it (hashing). How a definition reads is still part of a branch, and changes with it as its names do:

  • A change to only how a definition reads is this 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 all keep the hash. They change how the definition reads on this branch only: other branches read it as before.
  • It's a history entry like any. tcm history lists it (reads differently), tcm diff shows it, tcm reset and tcm revert undo it, and push and pull carry it. A codebase receiving a text checks it's the same code.
  • Merges take it three-way, as they take names: a side's change to how a definition reads is kept.
    • Both sides changed the same definition's comments or doc differently: this branch's is kept, and the merge says so (kept).
    • One side changed its comments or doc and the other its code: the comment change is merged into the new code as text (merged as text). Where that overlaps, would change the code, or the other side removed the definition, it's dropped: tcm edit it to write it again.

Reviewing changes: tcm diff

tcm diff reviews changes by meaning:

tcm diff
what the last command changed
tcm diff <n|#hash>
changes since a history entry
tcm diff <branch>
changes against another branch
tcm diff <a> <b>
changes between two branches or entries
tcm diff <#def> <#def>
two definitions' code, line by line
tcm diff <conflicted name>
the merge base's definition against each side's
  • Edits are line diffs, with each side's own names.
  • Definitions re-hashed only because a dependency changed are listed apart, with what triggered them.
  • Renames and aliases are shown 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.

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, newest first: 30 unless n (or -n <n>, --limit <n>) says. Each has its hash, how many entries back it is, its message if it has one, and the command that made it, named by what it changed (update lib.f, lib.g (+3), not the scratch file it came from). Under it is what it edited, added, removed and renamed, and a count of definitions only re-hashed because a dependency changed; --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. A reset can be reset too, and it's safe after a push. In an app it's also how you roll back a deploy (production).
  • tcm revert <n|#hash> undoes one entry (a landing, say) and nothing after it, as a new entry. What it changed goes back three-way, so changes made since stay; a change made since to what it changed is a conflict (settle it).
  • An entry is a number or a hash. A number counts back on this branch (tcm reset 1 undoes the last command). A hash can be from any branch's history. The # is optional, for shells that read #… as a comment (##abc is the same hash too).

Messages

An entry can say what it's for, as a commit message does. Pass -m <message> (or --message) to a command that makes one: add, update, patch, mv, alias, rm, resolve, import, upgrade, fmt, merge, revert, reset. Write a line saying what and why, then more after a blank line if needed:

$ 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 and the command under it.
  • It's in no hash, so you can change it without changing history. tcm describe [n|#hash] -m <message> gives an entry a message or replaces it; -m "" takes it away; tcm describe [n|#hash] shows it. The default entry is the latest. A command with -m that makes an entry that already exists (a one-step branch landed squashed with nothing new on main) gives that entry the message.
  • It goes where the entry goes. tcm push, tcm pull and tcm migrate carry it. A tcm from before messages shows it under the entry's command. A codebase that already has the entry keeps the message it has: one changed with tcm describe after a push changes 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, titled by its message; an agent's tcm_merge asks for one (agents). A merged (not fast-forwarded) contribution with a description has its title and description as its message.

Branches

A branch is a set of names, the history that led to them, and how each definition reads. Definitions themselves are shared between branches.

  • tcm branch lists branches; tcm branch <name> creates one from this branch.
  • tcm branch <name> --delete deletes one: its names, and its session's directory unless that holds unsaved work. The history it made stays. It refuses main, the branch you're on, and a running session's branch.
  • tcm switch <name> changes branch. In a session, TCM_BRANCH keeps a process on its own branch instead.

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. --no-fast-forward makes a merge entry anyway, so what came in is one entry that tcm revert undoes whole.
  • Otherwise it's a three-way merge of names from the best common ancestor, recorded as an entry with both as parents. Later merges start from there, in any codebase with 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 (even where it could fast-forward). The other branch keeps its own entries, and pushing this branch sends only what it names, not every version along the way. Once its names are here, the other branch's work counts as merged: tcm status doesn't list it, and squashing it again brings nothing. 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 names the releases. Otherwise the next release here would plan the first again, and a push wouldn't carry the releases.

How a merge decides

  • 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), three-way from the base's, as git merge-file does. If the edits are apart (different methods of a class, different bullets of a page) and the result typechecks with the merge's other changes in, it's taken (merged as text). Otherwise the name is a conflict, listed by the merge and by tcm todo, each side labelled (ours: this branch, theirs: the one merged in):

    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
  • A name one side deleted is kept if the other side's code still uses it.

  • Everything is re-derived against what the merge names, 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. What no longer typechecks goes to tcm todo, and the merge names it.

  • A conflicted name holds both definitions, each re-derived against the merge. Code using one directly keeps using it. Code reaching it only through definitions both sides re-derived uses this branch's, so the other side's code may not typecheck until the conflict is settled. A definition that can't follow a change (it doesn't typecheck against it) keeps what it used: tcm view '#<hash>' names that by its name elsewhere (an older lib.save, lib.save on main).

  • A conflict's sides and merge base are kept with the branch until it's resolved, and travel with push and pull.

A merge left to settle waits apart

A merge that leaves anything to settle (a conflict, or code that stops typechecking) 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), and what stopped typechecking in merge-fix.ts. Only what failed itself is there; 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.
  • Once nothing's left 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.

Landing

On main (or with --squash, as sessions land), tcm merge <branch> lands the branch:

  1. main is merged into the branch first, there. If that leaves something to settle, it waits on the branch (above) and nothing lands. What both sides changed and merged cleanly as text is named (both changed lib.x): read it before it lands, as tests can't tell what each side meant.
  2. Then the branch's tcm todo and the tests the change reaches (tcm test) run on the branch. Anything left to fix, or a failing test, and nothing lands.
  3. Then the branch lands: a fast-forward, or one entry with --squash. If main moved meanwhile and it's no longer clean, it isn't made.
  4. Squashed, main is merged into the branch once more (changing none of its names), so work kept on the branch merges cleanly 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.

Settling a conflict

Merge it by hand: tcm edit <name>. It writes a scratch file named after the name (lib.k.ts; a page: its Markdown file) holding both sides merged three-way from the base, as tcm view shows each. Where both changed the same lines, the file has git's conflict markers, each on 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, how many stretches both changed, and, where nothing's marked, whether the merge typechecks:

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. That resolves the conflict and re-derives what used either side against it.

  • Any definition saved at a conflicted name resolves it, from tcm edit or written by hand: tcm check says resolves, and history names the entry resolve lib.k (merged) (or (ours), (theirs) where it's that side's).
  • tcm check and tcm update refuse a file with markers left in: lines starting <<<<<<<, ======= and >>>>>>>, in order. Text only mentioning them is fine.
  • Each conflict gets its own file. An existing file is written over only if nothing unsaved would be lost; otherwise tcm edit says what's in it that isn't saved.
  • A conflict that uses another conflicted name can't typecheck until that one's settled (uses lib.k, which is still conflicted). Settle that first, or edit both together, tcm edit lib.m lib.k: one file, saved as one, resolves both.

Keep one side: tcm resolve <name> --ours (or --theirs), or tcm resolve <name> <#hash> for that definition. Everything that used the others is re-derived against it, and history names it resolve <name> (ours) (or (theirs), or the hash). Resolving one conflict re-derives code that used what it dropped, so a hash copied before can be stale: prefer --ours/--theirs.

tcm diff <name> shows the base's definition against each side's.

Criss-cross merges

Branches that merged each other (two sessions landing on main and merging it back in, say) can have several best common ancestors. As git's recursive strategy does, tcm merges those first, oldest first, into a virtual base, then merges the two branches from it, so neither side's undoing of the other's changes is lost. A conflict while building the base stays a conflict in it: 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 never saved.

tcm todo

tcm todo lists what's left to fix: a merge waiting apart first, then conflicts (each side labelled, with how to settle it), then dependents that stopped typechecking after an update or merge. tcm edit them, fix, and tcm update.

  • It shows the first 20 dependents (fixing those often fixes the rest); --all shows every one.
  • --json prints all of it as JSON for tools: the branch, a merge not finished, conflicts with their sides, what needs fixing and why, and code using unnamed versions.
  • It's worked out from what the branch names now. Going back (tcm reset, a merge dropped) leaves nothing to fix.

Built from c482a14e