Skip to content

Implement branches: scoped views over the shared log - #5

Merged
cjroth merged 18 commits into
mainfrom
claude/branching-spec-github-checks-o9mxfi
Jun 30, 2026
Merged

cjroth merged 18 commits into
mainfrom
claude/branching-spec-github-checks-o9mxfi

Conversation

@cjroth

@cjroth cjroth commented Jun 30, 2026

Copy link
Copy Markdown
Owner

This PR implements the branching system (§2, §7) as described in the asp protocol specification. Branches are scoped views over the shared log, not forks—every branch converges to one state per peer, and branch records sync like content does.

Summary

Branches enable users to create isolated development lines that fork from any point in history and merge back. The implementation adds:

  • Branch records and visibility predicates: A Branch struct carries metadata (name, parent, fork version vector). A Visibility predicate determines which rows are visible on a target branch based on ancestry and fork points.
  • Scoped state materialization: The fold now filters rows to those visible on the checked-out branch (HEAD) before computing state. Single-branch vaults (no branch records, HEAD=main) take a fast path that is byte-identical to pre-branching behavior.
  • Branch lifecycle operations: Create, checkout, delete, and list branches. Branch records are synced as Kind::Branch rows via P4 anti-entropy, converging deterministically via last-writer-wins on order key.
  • Network graph visualization: A GitHub-style branch/commit DAG showing the history of all branches with fork and merge edges.
  • CLI and UI surfaces: asp branch subcommands and desktop branch switcher with graph viewer.
  • Cross-surface convergence: Native engine, wasm node, and TypeScript SDK all implement identical branching logic, verified by deterministic multi-peer fuzz tests.

Key Changes

Core protocol (asp-core):

  • New branch.rs module: Branch struct, BranchSet for the branch tree, Visibility predicate, visible_rows() filter, reconcile_branches() for synced convergence, and build_graph() for the network DAG.
  • log.rs: Added MAIN_BRANCH_ID constant, Kind::Merge and Kind::Branch row types, branch_id and merge_parent fields on LogRow.
  • engine.rs: Branch-aware tip() (filters to visible rows), head_branch(), branch_set(), single_branch() fast path, and reconcile_branches() hook on row integration.
  • memengine.rs: In-memory branch state (branches, head) and parity with native engine.
  • sqlite.rs: Schema migration to add branch_id, merge_parent columns to log table and new branches and head tables. Idempotent migration preserves pre-branching DBs.
  • fold.rs: Scoped fold via visible_rows() filter; Kind::Merge rows are skipped (no content).
  • scope.rs: Memoized glob matching to prevent exponential backtracking on hostile .aspignore patterns.

Desktop engine:

  • BranchDto for the UI projection of branches.
  • Branch API methods: list_branches(), create_branch(), checkout_branch(), delete_branch(), branch_graph().
  • vaults_ready() gate for deterministic UI initialization.

Desktop UI:

  • BranchControls.tsx: Branch switcher chip, dropdown to list/create/delete/switch branches.
  • BranchGraph.tsx: SVG renderer for the network DAG.
  • branchLayout.ts: Pure layout engine (lanes, node positions, fork/merge edges).
  • Test coverage for both components.

TypeScript SDK:

  • Vault.currentBranch(), listBranches(), createBranch(), checkoutBranch(), deleteBranch(), branchGraph().
  • MAIN_BRANCH constant.
  • Conformance test branches.test.ts verifying fork-in-the-past isolation and sync convergence.

CLI:

  • asp branch subcommand with list, create, checkout, delete operations.

Testing:

  • Deterministic multi-peer branch convergence fuzz test in fuzz_invariants.rs.
  • E2E CLI lifecycle test in `branches

https://claude.ai/code/session_01Jh1S322hNNNZExiHKSaemV

claude and others added 18 commits June 30, 2026 02:32
… fold + checkout)

Implements §2–§4 of specs/branching.md: branches are a visibility predicate
over the shared CRDT log, not a fork of it. Concurrent edits within a branch
still auto-merge; rows on different branches are simply out of each other's
fold scope.

Data model (§3):
- LogRow gains branch_id (default "main") + merge_parent; both join the Merkle
  id (seal) and the wire format → PROTO bump 2→3. Kind::Merge / Kind::Branch
  added. #[serde(default)] + an "ADD COLUMN" migration keep pre-branching DBs
  and rows reading back byte-identical as `main`.
- sqlite: branches + head tables; branches()/put_branch()/branch()/head()/
  set_head(); idempotent migrate_branching().

Core (new branch.rs, wasm-safe):
- Branch record + content-hashed derive_id; BranchSet + visible()/Visibility —
  the one rule everything derives from (ancestors up to the fork_vv gate;
  cyclic/dangling lineage broken deterministically, never panics).
- version_vector_of() for fork points.

Engine (§4):
- materialize() folds visible(HEAD); tip()/current_for_path() are branch-scoped
  (single-branch fast path stays whole-log). branch_id=HEAD threaded through all
  authoring. create_branch / checkout / fork_from_time (edit-in-past ⇒ branch) /
  delete_branch / branches / current_branch. Fold cache is per-checked-out
  branch (rebuilt on checkout).

Tests: visible() unit battery (isolation, multi-level lineage, cycle/dangling);
branch-scoped fold differential + isolation + back-compat in fold_props;
engine edit-in-past/checkout/delete integration; branches/head store roundtrip.
Single-branch vaults fold byte-identically to before (back-compat gate green).

Also: regenerate SDK conformance vectors for the new row shape (folded content
unchanged); fix 4 pre-existing clippy -D warnings in desktop/engine examples.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jh1S322hNNNZExiHKSaemV
…ocal CI gate

Review/hardening pass on the P2 branching work:

- BUG: state_as_of / file_at folded ALL rows ignoring HEAD — a history scrub on
  a branch mixed in sibling/post-fork history (and could 3-way-merge a divergent
  main edit into the branch view). Now scoped to visible(HEAD) (§4.6). Regression
  test: divergent post-fork edit on main stays invisible on the branch slider.

- Tests: branch-scoped INCREMENTAL fold differential (FoldState refold ==
  from-scratch scoped fold, after every row, random arrival) — the §8.2 primary
  gate extended to a scoped view; adversarial branch fuzz (garbage branch_ids,
  cyclic/dangling/self lineage, huge fork_vv) must never panic and stay
  deterministic.

- Tooling: scripts/check.sh — a fast local CI gate (build + asp-core test +
  desktop-engine test + clippy -D warnings + wasm compile, with --cov/--vectors/
  --e2e). Documents that the networked e2e/demo tests are load-flaky (real iroh
  QUIC under parallel load) so a lone failure is a flake to re-run, not a
  regression — confirmed: the CI e2e failures reproduce 0/6 in isolation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jh1S322hNNNZExiHKSaemV
Addresses "make sure all branches sync as well as the one it's currently on."
Content rows already sync branch-agnostically (version-vector catch-up carries
every branch's rows); this adds the missing piece — the branch METADATA.

- Branch records are authored as synced Kind::Branch log rows (§7): the result
  blob is the JSON-encoded Branch (name/parent/fork_vv/created/deleted), keyed by
  file_id = branch_id. reconcile_branches() folds them last-writer-wins by
  (lamport, site_id, id), so create / rename / delete — including concurrent
  edits from two devices — converge deterministically and ride the same
  anti-entropy path as content (no separate channel).
- create_branch / delete_branch now author records; integrate/integrate_many
  reconcile the branch set BEFORE the fold so a freshly-arrived branch's fork_vv
  is in scope. A peer thus learns every branch, can check any out, and sees its
  isolated state — even branches it never created.
- Store::branch_rows(); branch::{encode_branch_record, reconcile_branches}.

Tests: two-engine catch-up — B learns A's branch purely from sync, holds main's
state, checks out the branch to its isolated state, and converges a tombstone;
reconcile LWW + order-invariance unit test. CLI + desktop inherit this through
the shared native Engine; the wasm MemEngine gets parity next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jh1S322hNNNZExiHKSaemV
…hes too)

The wasm thin node now mirrors the native engine's branching so cross-surface
sync converges the branch set + per-branch state on CLI ↔ desktop ↔ web:

- MemEngine gains HEAD + the synced branch set; create_branch / checkout /
  fork_from_time / delete_branch / branches / current_branch; scoped
  materialize + branch-scoped tip; reconcile_branches on integrate / integrate_
  many / import_state. Content authoring tags branch_id = HEAD.

Tests: native Engine → wasm MemEngine catch-up converges main, learns the
branch from sync alone, and checks it out to its isolated state; MemEngine-only
branch ops + isolation + delete rules. Single-branch folds stay byte-identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jh1S322hNNNZExiHKSaemV
…c tests

The TypeScript SDK now exposes the full branch model (the user's "SDK has
branching" ask), running the REAL wasm engine so it converges byte-identically
to native:

- asp-wasm: current_branch / branches_json / create_branch / fork_at / checkout /
  delete_branch bindings; MemEngine::visible_version_vector for the fork point.
- SDK: Vault.currentBranch/branches/createBranch/forkAt/checkout/deleteBranch +
  BranchInfo type + MAIN_BRANCH const.
- test/branches.test.ts (real wasm): fork-in-past isolation + delete rules; ALL
  branches sync between peers (B learns A's branch from sync, checks it out to
  its isolated state) — the headline guarantee; concurrent same-name creation
  converges to two distinct branches (§7). 24/24 SDK tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jh1S322hNNNZExiHKSaemV
The CLI now drives branches through the shared native engine (the user's "CLI
has branching" ask):

- `asp branch list [--json]` (marks HEAD with *), `asp branch create <name>
  [--checkout]`, `asp branch checkout <id|name>`, `asp branch delete <id|name>`.
  Name/id resolver; create forks HEAD at its current vv (Engine::
  create_branch_here + visible_version_vector).

Tests (tests/e2e/tests/branches.rs):
- cli_branch_lifecycle_local (deterministic, no network): create/checkout/delete
  + on-disk isolation between branches.
- branches_propagate_through_hub: A creates a branch + edits on it; a fresh clone
  learns the branch and checks it out to its isolated state — branches sync, not
  just HEAD.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jh1S322hNNNZExiHKSaemV
3 in-memory peers run random branch ops (create / fork-in-past / checkout /
edit / delete) interleaved with full gossip, then gossip to a fixpoint. Asserts
every peer converges to (a) the identical LIVE branch set and (b) byte-identical
materialized state for EACH branch — the cross-surface "all branches sync +
converge per branch" guarantee, over 30 random histories. Deterministic (no
network), so it's a stable CI gate rather than a flaky one. 0 divergences.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jh1S322hNNNZExiHKSaemV
…etry)

Root-causes the recurring red CI: the multi-process e2e/demo tests drive real
iroh QUIC and flake under PARALLEL load (each passes reliably in isolation —
verified 0 failures across serial runs; many different tests flake across runs,
so it's resource/timing contention, not a logic bug).

Systemic fix (the real one):
- ci.yml: run deterministic tests once in parallel; run the networked crates
  (asp-e2e + asp-desktop-engine) SERIALLY (--test-threads=1) with up to 3
  attempts; same for the demo live-wss interop. Serial execution removes the
  parallel-load races; the bounded retry covers residual transients. A genuine
  regression still fails all three serial attempts — no masking.

Belt-and-suspenders hardening of the repeat offenders (converge with a bounded
re-sync instead of a one-shot assert, the pattern the watcher tests already use):
- clone_catchup::offline_then_reconnect_catchup
- relay_topology::two_clones_through_one_relay
- vault_following::separate_populated_vaults_do_not_silently_merge (wait for the
  hub to adopt A's vault before C connects — an intra-test async-adoption race)
- branches::branches_propagate_through_hub (my new test, same fix)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jh1S322hNNNZExiHKSaemV
Two related startup bugs surfaced by the branching schema work.

1. core: the `log_branch` index was in SCHEMA, so on a vault created
   before branching `CREATE INDEX ... ON log(branch_id)` ran before
   `migrate_branching` ALTERed the column in — aborting the open batch
   with "no such column: branch_id". Create/open of any existing vault
   failed. Move the index into `migrate_branching` (after the column is
   guaranteed to exist; idempotent). Adds a pre-branching-DB regression
   test.

2. desktop: with that open failure, `reopen_saved` dropped every saved
   folder, leaving an empty `desktop_folders.json`. The empty reopen then
   emitted the one-shot `vaults-ready` event before the webview's listener
   attached, so it was missed and the "Loading your vaults…" gate never
   cleared. Add a `vaults_ready()` engine flag/command the UI queries on
   mount as a race-proof fallback to the event.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-github-checks-o9mxfi

# Conflicts:
#	crates/asp-core/src/memengine.rs
…delete

You can now actually branch from the desktop (and web) UI:

Core/engine:
- build_graph(): the GitHub-network-style branch/commit DAG — lanes per branch,
  rows coarsened into settle-commits, fork edges into the parent lane, bounded
  per lane. Exposed via Engine::graph / MemEngine::graph / wasm graph_json.
- Engine::create_branch_here_wire / delete_branch now return the authored branch
  record so the desktop pushes it live to peers; branch_record_wire for the fork
  flow.

Desktop wiring (every layer):
- DesktopEngine: list_branches / current_branch / create_branch / checkout_branch
  / fork_branch_at / delete_branch / graph (live-broadcast the records).
- Tauri commands + handler registration; api.ts (+ types) and webApi.ts (wasm)
  implementations — same surface on desktop and web.

UI:
- BranchControls: a branch switcher under the vault switcher — shows HEAD, lists
  branches (click to switch), "New branch from here" (create-and-switch), delete,
  and opens the network graph.
- BranchGraph + branchLayout: SVG network view (lanes, commits, fork/merge edges,
  click-a-lane-to-checkout) with a pure, unit-tested layout.

Tests: branchLayout (geometry), BranchControls (create/switch/delete/graph flows
against a mock api), BranchGraph (render + lane click). Desktop suite 481 pass,
SDK 24 pass, typecheck clean, asp-core clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jh1S322hNNNZExiHKSaemV
…cks-o9mxfi' into claude/branching-spec-github-checks-o9mxfi

# Conflicts:
#	desktop/engine/src/lib.rs
#	desktop/src/App.coldstart.test.tsx
Accidentally committed in the merge; it is a captured bun test summary,
not a source file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jh1S322hNNNZExiHKSaemV
build_graph (coarsen → cap → chain → fork-edge resolution) had the most
moving parts in branch.rs yet only one example test. Add a fuzz over random
rows on an adversarial branch set (deleted/cyclic/dangling lineage, caps that
force split_off) asserting it never panics, is permutation-invariant, and
holds structural invariants: every parent id resolves to a node, node lanes
match their branch lane, the per-lane cap is honoured, deleted branches never
become lanes, and at most one current lane. All invariants pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jh1S322hNNNZExiHKSaemV
delete_branch auto-checks-out the deleted branch's parent, but checkout()
accepts tombstoned branches and the parent may itself be deleted. Sequence
main<-a<-b (on b): delete a (not HEAD, no checkout), then delete b (HEAD)
landed HEAD on the tombstoned a — a branch absent from the switcher, leaving
the user stranded with no obvious way back.

Walk up to the nearest *live* ancestor (cycle- and dangling-safe, defaulting
to main) instead of blindly taking the immediate parent. Applied to both the
native engine and MemEngine for SDK parity, each with a regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jh1S322hNNNZExiHKSaemV
Hardening of the branch-creation entry points (found while pressure-testing
the branch paths):

- Reject empty/whitespace-only branch names. Core accepted "" / "   " from
  the CLI and SDK, producing a branch that resolve_branch can never match by
  name — addressable only by its raw content-hash id.
- Reject forking off a non-existent parent. create_branch never checked the
  parent existed, so an unknown/stale id yielded an orphan branch with an
  empty fork_vv that, on checkout, shows an empty working tree instead of an
  error.
- Reject non-finite fork timestamps at the wasm boundary. fork_at(t: f64)
  did `t as i64`, and `NaN as i64` saturates to 0 — a silent fork "before the
  beginning" capturing no rows.

Validation lives in asp-core (validate_branch_name + parent check) so the
native engine, MemEngine, and the wasm SDK all enforce it identically;
regression tests on both engines. The UI already trimmed names, so this only
tightens the CLI/SDK paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jh1S322hNNNZExiHKSaemV
Two algorithmic hot-path fixes found while pressure-testing branch perf:

- build_graph grouped rows per lane with `rows.iter().filter(branch_id==)`,
  a full-log pass per branch — O(lanes × rows). At ~30 branches over a 100k-row
  vault that is ~3M comparisons on every network-graph open. Group rows by
  branch in a single O(rows) pass and consume each group once. Output is
  byte-identical (the build_graph invariant/permutation fuzz still passes).

- branch_rows() ran `SELECT * FROM log WHERE kind='branch'` with no index on
  kind — an O(N) full scan, re-run by reconcile_branches on every branch
  authoring and every remote integration. Add a partial index over just the
  (few) branch records so it's a tiny index probe. A new EXPLAIN QUERY PLAN
  test asserts the planner uses it and never full-scans the log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jh1S322hNNNZExiHKSaemV
The hand-rolled glob matcher recursed without memoization, so a pattern with
several `*` against a long near-matching path — e.g. `*a*a*a*…*b` vs `aaaa…` —
backtracked in O(2^n). A 25-char pattern over a 40-char path already runs for
>20s. `.aspignore` is honored when materialized from a peer push (see
disk_capture.rs), so a malicious peer could ship a pathological ignore pattern
and hang scope-matching on every file path — a CPU denial of service across
peers. The existing scope fuzz uses random patterns, which never produce the
alternating `*literal` structure that triggers the blowup, so it never caught
this.

Memoize failed (pattern_index, text_index) states, bounding the matcher to
O(|pattern|·|text|) while preserving exact match semantics (the differential
oracle test still agrees, and all scope cases pass). Add a regression test with
a many-`*` pattern over a long text that must resolve effectively instantly —
without the memo it would hang.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jh1S322hNNNZExiHKSaemV
@cjroth
cjroth merged commit f696f26 into main Jun 30, 2026
6 checks passed
@cjroth
cjroth deleted the claude/branching-spec-github-checks-o9mxfi branch June 30, 2026 22:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants