Repository navigation
Implement branches: scoped views over the shared log - #5
Merged
Merged
Conversation
… 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
Branchstruct carries metadata (name, parent, fork version vector). AVisibilitypredicate determines which rows are visible on a target branch based on ancestry and fork points.Kind::Branchrows via P4 anti-entropy, converging deterministically via last-writer-wins on order key.asp branchsubcommands and desktop branch switcher with graph viewer.Key Changes
Core protocol (asp-core):
branch.rsmodule:Branchstruct,BranchSetfor the branch tree,Visibilitypredicate,visible_rows()filter,reconcile_branches()for synced convergence, andbuild_graph()for the network DAG.log.rs: AddedMAIN_BRANCH_IDconstant,Kind::MergeandKind::Branchrow types,branch_idandmerge_parentfields onLogRow.engine.rs: Branch-awaretip()(filters to visible rows),head_branch(),branch_set(),single_branch()fast path, andreconcile_branches()hook on row integration.memengine.rs: In-memory branch state (branches,head) and parity with native engine.sqlite.rs: Schema migration to addbranch_id,merge_parentcolumns tologtable and newbranchesandheadtables. Idempotent migration preserves pre-branching DBs.fold.rs: Scoped fold viavisible_rows()filter;Kind::Mergerows are skipped (no content).scope.rs: Memoized glob matching to prevent exponential backtracking on hostile.aspignorepatterns.Desktop engine:
BranchDtofor the UI projection of branches.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).TypeScript SDK:
Vault.currentBranch(),listBranches(),createBranch(),checkoutBranch(),deleteBranch(),branchGraph().MAIN_BRANCHconstant.branches.test.tsverifying fork-in-the-past isolation and sync convergence.CLI:
asp branchsubcommand withlist,create,checkout,deleteoperations.Testing:
fuzz_invariants.rs.https://claude.ai/code/session_01Jh1S322hNNNZExiHKSaemV