Lesson 23 — Branches: fork, edit, diff, merge
Goal: by the end of this lesson you can fork the graph onto
a feature branch, edit fn-defs without affecting main, see what
changed, merge back — and understand why some fn-defs are
branch-local (their edits stay on the branch by design).
Concepts introduced: branch, main, feature branch,
fork, diff, merge, :branch-local?, :skipped audit list.
Branches are views, not copies
The fn-graph stored in graphden's database has TWO faces:
- Identity rows —
:fnentities. One row per fn. Shared across every branch. - Version rows —
:fn-version,:binding-version,:fn-slot-version,:binding-list-item-version. One row per (entity-id, branch-id) where someone made an edit.
A branch is just a name + a :base-branch-id pointer to its
parent. When you ask "what's the value of :my-fn on branch
feat?", the resolver walks: own version on feat → versions
merged into feat → recurse to the parent branch. That walk is
the only thing that "differentiates" branches. There's no
copying.
main is the root branch. Every other branch is a fork of
some existing branch — its :base-branch-id is the branch it
was forked from.
Three rules follow, and they are all you need to predict what a branch shows:
- A branch is a live view of its base for everything it hasn't
touched. Edit, delete or create something on
mainand every branch forked frommainsees it at once — unless that branch edited the same entity itself. Only what you touched is pinned. - A merge carries a branch's own changes onto the target, and from
then on they count as the target's. Merging
featintomainwrites one merge record; nothing is copied. The source cannot be deleted while a surviving target still reads through that merge. - A conflict is one entity edited on both sides since the fork, to
different content. Identical edits on both sides are not a
conflict; a change that reached
mainthrough someone else's merge ISmain's change, so a second branch editing the same entity is asked, not silently overwritten.
A removable tutorial sandbox
The guided tour keeps tutorial functions off your real main. Create
tutorial-sandbox first and extend str-upper into branch-demo there.
Bind base version, try the branch-local toggle, then turn it off again:
this demonstration needs transferable versions.
Fork tutorial-branch from that sandbox. Change the existing function's
binding to branch version, set its description to source draft, and
extend it into the new child branch-added. Return to the common sandbox
and create tutorial-merge-target: source and target are siblings.
Compare the source from the target. The existing UUID has a field edit
and a binding replacement; the child is an addition. Merge the source,
then change its value to second branch version. The target still reads
the first merge until you merge the source again.
Finish and choose Delete created entities. Cleanup returns to main,
deletes the merged target first, then the source, then their common base.
The tour retains the actual creation UUIDs across reloads and cancellation;
a same-named replacement is never claimed as a tutorial branch. No archive
is needed to keep these practice branches hidden.
The fork → edit → diff → merge loop
At the top of the Explorer there's a branch chip showing your
current branch. Click it for the branch popover — a flat list of
the org's branches (main and your current branch sort first).
Create → type a name, click Create
(forks from the current branch)
Advanced → (cloud/tenancy only) "Who can write" the new
branch: "Everyone with write access (default)",
"Only me (org admins can unlock)", "Org admins only"
(the ⛨ menu on an existing branch offers the same
three, worded "Only the owner …")
row → switch → click a branch row to switch to it
Δ (compare) → COMPARE MODE: pick this branch as the
second one and the whole editor becomes
the diff. The picked row's Δ stays lit;
click it again to exit.
✅ (approve) → approve a proposed branch for merge
⇢ (merge) → fold another branch into this one
⋯ (more) → the labeled per-row menu:
💬 Review & comments — the conversation
dialog: what changed, the thread,
suggestions
📤 Propose for review — submit into its base
⚙ Protection… — "push only via merge",
required approvals (0–3, one-tap segments),
count-own-approval. Works everywhere,
including single-user.
⛨ Who can write… — (cloud/tenancy only) the same
three choices, on an existing branch
📦 Archive / Reopen — fold a finished branch
into the Merged group, or bring one back
× Delete branch
Merged · N → collapsed group at the bottom: branches already
merged into their base. A merge folds its source
here (it cannot be deleted — the base reads
through it); click a row to reopen it (asks first).
The ⋯ is accented when the row is proposed or
protected; a 🔒 marks write-policy rows.
A protected branch refuses edits — and merges into it — from anyone outside its policy (the branch owner and the org's admins always keep access, so nothing can be locked forever). On a self-hosted single-user instance there are no other users to keep out, so the write-policy affordances (⛨ / Advanced) stay hidden.
Push only via merge (⋯ → ⚙ Protection)
Open ⋯ → ⚙ Protection… on a branch row and tick "Push only via merge (no
direct writes)". Unlike write-access (⛨), this shows on a single-user
instance too — it doesn't care who you are, only how the branch
changes. With it on, the branch stops accepting direct edits: creating,
editing or deleting a fn-def straight on it comes back as "This branch
accepts changes only via merge…" (a 409). The only way to change it is
to merge another branch into it — exactly the GitHub "protect
main, land through pull requests" workflow.
The usual shape: turn it on for main, do your work on a child branch
(feat-…), then merge the child into main. It rides on the branch
itself, so it survives reloads and applies to every client. Untick it
any time to re-open direct writes.
Change proposals & review
Requiring review before a merge — 📤 propose, ✅ approve, required approvals on the target, comment threads and suggestions — is lesson 24's subject; its knobs live in the same ⚙ Protection… dialog.
Try it — the plain flow
Prefer to be shown? This lesson exists as a guided in-editor tour: open the demo with the tour running (no sign-up), or pick “Interactive tutorial” in the editor's account menu.
-
Make something to change: find
str-upper(core.strings),⋯→ Extend, name itbranch-demo. Click the+on its:stringslot, Bind literal, typemain version, Save. -
Click the branch chip. In the create row, type
tutorial-branchand click Create. The editor reloads ontutorial-branch; the URL gets?branch=tutorial-branch. -
Still looking at
branch-demo, click its value and change it tobranch version. -
Switch back to
main(branch chip → themainrow).branch-demoreadsmain versionagain — your edit lives only ontutorial-branch. -
Open the branch popover, click
Δnext totutorial-branch— the editor becomes the diff (compare mode). Read it in four places:- The Explorer. Every changed fn is badged
+/±/−, with a one-line digest under the row (string: main version (there branch version)); namespaces carry+n ±n −n ∿nsummaries. Fns that exist only on the compared branch show as dimmed ghost rows (click one to switch there). - The canvas. Changed cards and their changed args ring, and the change is written ON the node: the value the other branch holds appears as a separate read-only literal node, connected to the current value by a dashed replacement link. The counterpart names the compared branch; list items keep their own occurrence UUID. a renamed fn shows its other name on the card; an arg you bound here only rings green.
- The inspector. Selecting a changed fn shows its exact
old → newfields in the diff panel — with 💬 anchors for line-comments. - The chip.
Δ vs tutorial-branch · 1appears by the branch chip — the number is how many fns differ. It survives reloads, so "always see my drift vs main" is one click. Its menu is the review cockpit: 💬 Review & comments, 📤 propose the current branch and ⇢ merge the compared branch in.
- The Explorer. Every changed fn is badged
-
Two things make it a diff of the GRAPH rather than of rows:
- Changed inside (
∿). A fn whose own rows are equal on both branches but which INHERITS a change — its parent's binding was retuned, a fn it references differs, a type it uses moved — carries a dashed ring and a∿badge (the Explorer row says∿ via <fn>). Click the badge: an ancestor is revealed inside the card, at the level that holds it, so you see itsΔrows in the context of THIS graph; a referenced fn opens as the root. The∿ insidechip turns these marks off. - A replaced branch of the graph. When an arg points at one
fn here and another there (
⋯-bindit to a different fn on your branch), the canvas shows both: your side is the real card, and the compared branch's side hangs beside it as a dashed, dimmed GHOST — that fn and what it composes, read-only, joined to the arg by a dashed elbow. Click its head to fold it.
- Changed inside (
-
Under the kind chips the Explorer gains a diff chip row:
Δ changed(show only what differs, auto-expanding the groups that hold them),+/±/−by change type,Aa core(hide edits that touch nothing but names and descriptions),💬 notes(mark fns that carry anchored review comments — a💬2rides next to the badge) andfx(only changes whose EFFECT SET differs — those carry aneffects: pure here · time theremark, the strongest "this affects behaviour" signal). While any diff chip is on, the chip turns dashed and countsvisible/total(say· 1/3) — a reminder that "no badges" means "hidden by the chip", not "no changes". -
The diff isn't only about values — it shows the graph's SHAPE changing too. Each entry kind reads differently:
You did (on the branch) The diff row says edited a bound value ± arg port·8080 → 9090renamed / re-described the fn ± fn·description: old → newexposed a new arg (extended structure) + slot retries·at position 2bound an arg to another fn (an edge!) + arg handler·ref → :my-handlerre-aimed an existing edge ± arg handler·ref-fn-id: :a → :badded/edited a list element ± item 0 of nums·1 → 2created a whole fn a −ghost row here ("only on"), a +group in ITS branch's viewTry one: on
tutorial-branch, extend some fn with a new child (or ⋯-bind an arg to a different fn), then compare again — the structural rows appear in that fn's inspector diff panel, and the same args ring on the canvas. -
Exit compare mode: click the lit
Δagain (or the chip's×). -
From
main, click⇢next totutorial-branch. Confirm. The page reloads andmainnow sees your edit. (The guided tour leaves this click to you: a merged branch becomes part ofmain's history and can no longer be deleted — lesson 24 explains why.)
Conflicts
If you edited the same entity on BOTH branches after their fork
point — to different content — merge stops. The conflict modal asks
"which side wins" per entity. Pick source (the branch you're merging
in) or target (the branch you're merging into) per row, hit
Apply merge. Two things that are NOT a conflict: the same edit
made on both sides, and an entity only one side touched. And one
thing that is: the other side's version arrived through a merge — a
sibling branch landed its edit on main before you did. The API's
conflict rows carry fields (which fields differ, each side's value)
and slot-name so a reviewer can decide without reading two version
maps.
Syncing main into a feature branch
A long-lived branch doesn't need to "pull" main: it follows main
live for everything it hasn't touched. Merging main INTO the branch
(⇢ on the main row while standing on the branch) is still
useful — it is how you settle the entities BOTH sides edited: the
conflict modal lets you keep yours or take main's, and afterwards the
next sync only asks about newer edits. Everything you didn't touch
keeps following main after the sync, exactly as before it. Taking
main's side pins that value as the branch's own edit — a later change
to it on main is a conflict again.
What DOESN'T merge: branch-local fn-defs
Ordinary functions merge by default. The exercise above turns the flag on to demonstrate its effect, then turns it off so the shared function can merge. You do not need to disable this flag on every new function.
Keep shared logic separate from configuration. An application can take a
query callable; distinct prod-query and test-query functions construct
it with their own connection values. The ordinary
callable constructor
captures those values. Distinct start functions reference the corresponding
query identities. Updating test-query changes that identity; it does not
replace a reference to prod-query just because both share an ancestor.
Editing prod-query itself on a test branch still edits the same identity
and normally participates in merging. Names do not select an environment.
Some fn-defs encode environment-specific runtime config — a
web-server's port, a Vault secret's path, a cron schedule. You
don't want those merging from dev into main and silently
clobbering production.
Graphden marks these with :branch-local? true on the :fn
row. In the editor that is the 📍 strip at the bottom of a
card you own: it reads merges across branches (dimmed) until you
click it, tick Branch-local and Save — then branch-local.
The flag is monotonic-OR over :parent-ids: if any ancestor
is sticky-local, you are too — the strip then names the ancestor
and the box is read-only; a write that tries to widen it is
refused (:constraint-violation/branch-local-widening). Seeded
defaults:
| Seeded sticky-local | Why |
|---|---|
:http-server |
Port + handler are per-environment |
:secret-leaf |
Vault path is per-environment |
:schedule |
Cron cadence is per-environment |
:interval, :interval-now |
Tick cadence is per-environment |
:env |
Env-var indirection is per-deployment |
:deploy-config |
A deployment setting is per-instance by definition |
Any fn-def parented from one of those inherits the flag. So this fn-def…
{:name :my-web-server
:parent :http-server
:args {:handler :my-handler :port 8080}}
…is effectively branch-local because :http-server is. On a
merge, the resolver filters out its version rows on the target
branch; compare mode and the Review dialog mark such rows with
📍 branch-local (the inspector's diff panel carries it too), and
the post-merge alert names exactly what didn't propagate:
2 branch-local fns did NOT propagate to main:
:my-web-server, :my-vault-secret
(Marked with 📍 in the diff.)
The merge API surfaces the same list as :skipped {:branch-local […]}:
{
"ok": true,
"merge": { "id": "...", "source-branch-id": "...", ... },
"skipped": {
"branch-local": [
{"entity-name": "fn", "entity-id": "uuid", "fn-name": "my-web-server"}
]
}
}
The handler/business-logic fn-def REFERENCED by the branch-local
config (e.g. :my-handler above) DOES merge normally — only the
sticky-local node itself stays scoped. So you can iterate on
shared business logic on a feature branch and merge it cleanly
while leaving the per-environment config alone.
Why this isn't just "version everything"
Asymmetry is intentional. Merge says "fold sibling's history
in". Inheritance (a branch reading from its :base-branch- id) says "I'm a child branch, give me my parent's state".
:branch-local? blocks the first but not the second — when you
fork dev from main, dev correctly inherits main's
sticky-local web-server config, because you EXPLICITLY chose to
fork.
Merge policy: :forbid-invalid?
A branch can opt in to a merge-time QUALITY gate. Pass
"forbid-invalid?": true when creating it (API-only for now —
the editor's branch popover doesn't expose the flag yet):
curl -X POST "$BASE/api/branches" \
-H "Content-Type: application/json" \
-d '{"name": "release", "forbid-invalid?": true}'
While either the SOURCE or the TARGET branch carries recorded
type diagnostics (what the Explorer's ⚠ type errors filter marks — Lesson 19),
merging INTO such a branch is refused with a 409
(:merge-protection-violation) whose message names the broken
fns: "Merge blocked: target branch forbids invalid fns —
unresolved type errors on: …". Fix the flagged fns (or merge into
a branch without the flag) and retry.
Contrast with :branch-local? above — that's a different KIND of
gate: :branch-local? is per-FN and silently SKIPS config-like
fns while the merge succeeds; :forbid-invalid? is per-BRANCH
and blocks the WHOLE merge while type errors exist anywhere on
either side. One scopes what propagates; the other enforces
when propagation may happen at all.
Try it (sticky-local edition)
- On
main, extendconstastutorial-api-baseand bind its:valuetohttp://localhost:9001— the kind of per-environment setting a merge must not carry. Click the card's📍 merges across branchesstrip, tick Branch-local, Save: the strip reads branch-local. (A fn parented from a seeded sticky-local base such as:http-servershows that from the start — inherited, with the ancestor named.) - Fork to
feat-staging. On the new branch change the value tohttps://staging.example.com. - Still ON
feat-staging, pressΔon themainrow (compare mode) and select the fn — the inspector's diff panel shows the changed value under a📍 branch-localbadge (the Review dialog's change list carries it too). - Merge
feat-staging→main. The alert names your fn as skipped. Checkmain— it still readshttp://localhost:9001. - Switch back to
feat-staging—https://staging.example.com. The branch that wrote the version keeps it.
What we glossed over
- Per-branch services (running
:my-web-serveras a managed service on dev while:web-serverruns on main) — Lesson 35. - How the resolver handles deep branch chains (B forked from A, C forked from B, merge edges everywhere) — see docs/VERSIONING.md.
- The conflict-resolution shape returned by the API — see
:_merge-apply-err-conflictin the branches package.
Next
Lesson 24 — Review: propose, approve, and the protected merge: the merge you just made, made conditional on someone's approval.