⑂ offshoot

Branch diff

offshoot doesn't merge branches — see the FAQ's Why no merge? for the stance and why. What it does give you is a fast way to see WHAT changed between two branches (or two checkpoints, or a branch and itself over time), so you can decide by hand which fork "won."

Two ways to get there: the offshoot diff command below, or the raw recipe it's a thin wrapper over — offshoot export (or checkout --at) twice, then sqldiff yourself. Both end up running the exact same sqldiff invocation over the exact same materialized files; the command just saves you the two manual steps and adds a sqldiff-free --summary mode.

offshoot diff

offshoot diff <db>[@branch[@checkpoint]] <db>[@branch[@checkpoint]] [--summary] [--table T]

Each side is parsed the same triple-@ target form offshoot export uses (ops.ParseExportTarget): db alone means db@main's current head; db@branch means that branch's head; db@branch@checkpoint means that named checkpoint's historical state. The two sides can name the same db or two entirely different ones — cross-db diff is a legitimate shape for eval comparisons (e.g. diffing a golden dataset against a candidate that started life as a different database).

offshoot fork evals attempt-1
offshoot fork evals attempt-2
# ... two agent runs write into attempt-1 and attempt-2's checkouts,
# each checkpointed when done ...
offshoot checkpoint evals@attempt-1 done
offshoot checkpoint evals@attempt-2 done

offshoot diff evals@attempt-1@done evals@attempt-2@done

Default mode: sqldiff

With no --summary, offshoot diff materializes both sides read-only and runs sqldiff DB1 DB2, streaming its output (SQL statements that would transform the left side into the right side) straight to stdout.

Before either mode's own output, offshoot diff always prints one header line naming which raw target string is which side — the only place either mode ever repeats what you typed, so it's the one anchor tying "left"/ "right" (or, in --summary, a bare row-count column header) back to an actual db@branch[@checkpoint]:

left:  evals@attempt-1@done right: evals@attempt-2@done

sqldiff is a separate binary from the sqlite3 CLI — installing sqlite3 alone does not put sqldiff on PATH on every platform. If it's missing, offshoot diff fails with a clear, verified-per-OS install hint rather than a bare "executable file not found":

offshoot diff: sqldiff not found on PATH

sqldiff ships separately from the sqlite3 CLI. Install it:
  sudo apt-get install sqlite3-tools   # Debian/Ubuntu: sqldiff ships in this separate package

Or skip sqldiff entirely with 'offshoot diff ... --summary' for a
content-aware per-table summary (added/removed/changed rows) instead

On macOS, the hint is brew install sqldiff — verified against a real install on this project's own dev machine that Homebrew's general sqlite formula (keg-only, and already installed as a dependency of other things more often than not) does not include sqldiff; Homebrew ships it as its own separate formula instead, and installing it puts a working sqldiff straight on PATH (no keg-only PATH surgery needed, unlike the sqlite formula itself).

--summary: no sqldiff needed

offshoot diff evals@attempt-1@done evals@attempt-2@done --summary
left:  evals@attempt-1@done right: evals@attempt-2@done
TABLE     evals@attempt-1@done  evals@attempt-2@done  ADDED  REMOVED  CHANGED  STATUS
attempts  12                    12                    0      0        0        same
results   40                    46                    6      0        0        changed
scratch   3                     -                     -      -        -        removed
3 tables: 1 same, 1 changed, 0 added, 1 removed

(real output, from a store seeded exactly as the walkthrough above describes — evals@attempt-1 untouched from a 12/40/3-row base, evals@attempt-2 with 6 more results rows and scratch dropped entirely.)

--summary never shells out to sqldiff (or any external binary at all) — it lists both sides' tables from sqlite_master and, for a table present on both sides with matching columns, counts rows added/removed/changed by row identity (the table's declared primary key when it's actually unique on both sides, otherwise the table's own internal rowid — see docs/reference.md for the exact fallback order). Rowid identity is meaningful when both sides descend from one seed and rowids were not renumbered (a VACUUM on a table without an INTEGER PRIMARY KEY, or a cross-database diff); otherwise it over-reports changes, which is the safe direction for a promote decision. Row counting itself is via database/sql + the mattn/go-sqlite3 driver this binary is already built with, opened strictly read-only (file:<path>?mode=ro&immutable=1 — verified against a chmod 0444 file, the exact permission checkout --at --read-only/the internal read-only cache produce). Useful when sqldiff isn't installed, or when you just want a per-table breakdown of what moved before reaching for the full SQL diff.

Each table gets one row: its name, each side's row count (- when the table doesn't exist on that side at all), the ADDED/REMOVED/CHANGED row counts (- when the table isn't comparable — added/removed wholesale, or a schema mismatch), and a status — added/removed for a table on only one side, changed when any row was added, removed, or changed (or the table's schema changed — the STATUS cell then gets a trailing " (schema)"), same otherwise. A trailing totals line answers "did anything change" without counting rows of output. The two count columns are headered with the raw target strings themselves (not bare LEFT/RIGHT), matching the left: ... right: ... line above — a reader scanning just the table never has to scroll back up to know which count is which side.

Note: a table whose column list differs between the two sides is reported — with row counts — but not compared (row identity has no defined meaning across a schema change); its ADDED/REMOVED/CHANGED cells read - and its STATUS is changed. Pass --table <name> to restrict either mode to one table — useful for drilling into exactly the table --summary flagged before reaching for the default sqldiff mode on just that table.

How each side is materialized (and the staleness rule)

Both modes materialize through the exact same read-only primitives offshoot export/offshoot checkout --at --read-only already provide (see docs/reference.md) — offshoot diff never opens or writes through a live checkout, and never takes a lease, so it's always safe to run alongside an open daemon session on either branch.

  • A named checkpoint (db@branch@checkpoint) is materialized via the read-only checkouts-ro cache (the same one checkout --at --read-only uses): a checkpoint's content is immutable once created, so a repeat diff against the same checkpoint is a cheap cache hit, not a re-materialize.
  • A bare target with no checkpoint (db@branch, meaning the branch's current head) is exported fresh to a private temporary file on every offshoot diff call, then removed once the diff is done. Head moves — a head-keyed entry in the read-only cache could never be an idempotent cache the way a checkpoint-keyed one can be, because "the same cache key" would silently start meaning "whatever head was at the last diff," not "head right now." Rather than teach the checkout-at cache an asymmetric "force by default, but only for head" special case, a head side is simply never cached: run offshoot diff app@main app@main@v1 twice with a write to app@main in between (and a offshoot checkpoint to make that write durable — diff reads the branch's last durable state, exactly like export does, not a live session's unflushed writes) and the second call reflects the new write every time.

The raw recipe, by hand

offshoot diff is not doing anything you can't do yourself with two commands you already have:

offshoot export app@attempt-1@v1 /tmp/left.db
offshoot export app@attempt-2@v1 /tmp/right.db
sqldiff /tmp/left.db /tmp/right.db

Or, for a live branch's current head, materialize a read-only historical checkout instead of exporting (skips the write-to-a-temp-file step, at the cost of leaving a cache entry behind under checkouts-ro):

offshoot checkout app@attempt-1 --at v1 --read-only
offshoot checkout app@attempt-2 --at v1 --read-only
sqldiff .offshoot/checkouts-ro/app/attempt-1@v1.db .offshoot/checkouts-ro/app/attempt-2@v1.db

Both export and checkout --at --read-only produce a plain SQLite file with zero ongoing relationship to the store (no .sum sidecar, no lease) — sqldiff, or any other tool that reads SQLite files, doesn't need to know offshoot exists at all.

No merge

offshoot diff is a read tool: it tells you what's different, it never resolves anything. There is no offshoot merge, and there won't be one — see the FAQ's Why no merge? for the full reasoning. Forks are for pick-a-winner: diff to decide, then offshoot promote the one that's actually correct.