📚 Make the technical guide describe the op-log engine it ships with

The guide still opened by calling a branch an isolated copy of a file,
called the merge base a snapshot frozen at creation and the op log
append-only, and gave the creation transaction a reason for deferring
constraints that does not hold. It now says a branch copies no data,
that an update from main repositions the base and squashes or empties
the log, and that every row referencing the new file is inserted after
it.

The same pass corrects five more statements: base_branch_revn holds the
branch file's own revision; a branch kept after a merge keeps its pin;
op log rows survive a logical deletion and go with the cascade when the
file row is hard-deleted; a resolution may be a per-attribute map; and
the listing now runs through `audited`, while a refusal raised as an
error writes no audit event. The RPC table gains get-branching-limits,
the test lists name all five test namespaces, and the UI and event
lists name the limits, merge, and info dialogs.

AI-assisted-by: claude-opus-5-5
This commit is contained in:
Álvaro Tejero Cantero 2026-09-30 23:00:34 +02:00
parent 5cb615ea88
commit 16b7e64fbb
No known key found for this signature in database

View File

@ -1,16 +1,19 @@
---
title: 3.12. Branching
desc: Learn how Penpot file branching works — isolated file copies, a three-way entity-level merge engine, conflict resolution, the RPC API and the workspace UI. View Penpot's technical guide for self-hosting, configuration and developer insights.
desc: Learn how Penpot file branching works — branches that copy no data, a three-way entity-level merge engine, conflict resolution, the RPC API and the workspace UI. View Penpot's technical guide for self-hosting, configuration and developer insights.
---
# File branching
Branching lets a user take an isolated, editable copy of a file (a **branch**),
work on it without touching the original (**main**), and later bring the changes
back through a **merge**. It is conceptually the same idea Git offers for source
Branching lets a user open an editable **branch** of a file, work on it without
touching the original (**main**), and later bring the changes back through a
**merge**. It is conceptually the same idea Git offers for source
code, applied to design files: the workspace even reuses a small set of `git-*`
icons (`git-branch`, `git-branch-plus`, `git-commit`, `git-merge`) to make the
mental model obvious.
A branch copies no data: it is a file row flagged `is_branch`, a
`file_branch` row, an op log in `file_branch_change`, and a pinned snapshot
of main that serves as its merge base.
The whole feature is gated behind the `:branching` product flag and was added in
the `2.16` API generation (all the RPC methods carry `::doc/added "2.16"`).
@ -21,12 +24,15 @@ Branching always reasons about **three** versions of the same file:
* **branch** — the isolated working state the user edits. A branch is
**not a stored copy**: it is a reference to the merge base plus an
append-only **op log** (`file_branch_change`), one change vector per
branch save. The branch's `:data` is *derived* on every read by
replaying that log over the base. Its file row still exists (the
frontend keys on file ids), but it stores no data payload.
* **merge base** — a frozen snapshot of main taken at the exact moment the
branch was created.
**op log** (`file_branch_change`). Each branch save appends one change
vector to the log, and a successful update from main replaces the log
with the branch's squashed net changes or empties it. The branch's
`:data` is *derived* on every read by replaying that log over the base.
Its file row still exists (the frontend keys on file ids), but it stores
no data payload.
* **merge base** — a pinned snapshot of main. Branch creation takes the
first one, and each successful update from main replaces it with a
snapshot of main's current state (`reposition-base!`).
Comparing two versions is not enough to decide who is right when a value
differs. With the third (the base) the engine can tell *who* changed *what*:
@ -69,7 +75,9 @@ Tests:
```text
common/test/common_tests/files_branch_merge_test.cljc ; unit tests for the diff/merge engine
backend/test/backend_tests/branch_merge_test.clj ; engine tests on hand-built data
backend/test/backend_tests/rpc_file_branch_test.clj ; integration tests for the RPC commands
backend/test/backend_tests/rpc_file_branch_oplog_test.clj ; op log storage and derivation invariants
```
The branching commands closely mirror the patterns in
@ -147,8 +155,9 @@ Key points:
* **`file_branch_change`** is the branch's op log: one row per branch save
(the blob-encoded change vector, `UNIQUE (file_id, revn)`). The branch's
`:data` is derived by replaying its rows in revn order over the base
snapshot. Rows are replaced (squashed) by `update-branch-from-main` and
cascade away when the branch is deleted.
snapshot. Rows are replaced (squashed) by `update-branch-from-main`, stay
in place when the branch is logically deleted, and go with the foreign
key's cascade when the garbage collector hard-deletes the branch file row.
* **`base_snapshot_id`** references the merge-base snapshot. **`base_revn`**
records MAIN's revision number and **`base_branch_revn`** the BRANCH file's
@ -167,8 +176,7 @@ Key points:
The `::create-file-branch` command (`files_branch.clj`) refuses to branch a
branch (`:cannot-branch-a-branch`) and runs everything inside a single
transaction (with `SET CONSTRAINTS ALL DEFERRED`, because the new file and
its branch row reference each other) **under the file's advisory lock**
transaction **under the file's advisory lock**
(`db/xact-lock!`, the same one update-file takes), so the base snapshot and
the branch's initial state (the empty op log over that base) are guaranteed
to come from the SAME state of main — a concurrent save between the two
@ -179,8 +187,10 @@ manufacture phantom diffs later:
future. The far-future deletion date keeps the snapshot from being pruned by
the snapshot GC while the branch is open — the diff needs it for as long as
the branch lives. The pin is **released** (rescheduled with the team's
normal deletion delay) whenever the branch is merged, deleted or its base
is repositioned, so closed branches do not leak multi-year snapshots. If
normal deletion delay) whenever the branch is deleted, materialised, or its
base is repositioned, so closed branches do not leak multi-year snapshots.
A merge releases it through the deletion, and a branch kept after the
merge keeps its pin. If
the snapshot can ever not be resolved, merge/update/diff **refuse** with
`:base-snapshot-missing` — silently falling back to main would degrade the
three-way merge into "branch overwrites main" without conflicts.
@ -189,12 +199,17 @@ manufacture phantom diffs later:
derived data is plain maps) and data version. No data, no media, no
library copy: the read path derives the state, and the op log starts
empty, so the branch's initial content equals the base by construction.
The new file row references nothing created in this transaction, and
every row that references it is inserted after it. No insert therefore
depends on the `SET CONSTRAINTS ALL DEFERRED` the transaction opens with.
3. **Mark the new file as a branch** (`is_branch = true`) so it stays out of
the listings, grant the creator ownership (`file_profile_rel`, the same
grant the old duplicate path made), and copy main's `file_library_rel`
rows so shared-library components keep resolving on the derived state.
4. **Insert the `file_branch` row** with `status = 'open'`, the base snapshot
id and `base_revn = base_branch_revn = (:revn file)`.
id, `base_revn` set to main's revision (`(:revn file)`), and
`base_branch_revn` set to the branch file's own revision
(`(:revn branch-file)`, 0 for the new file row).
Before any of this it enforces edition permissions on main and checks two quotas:
`::quotes/branches-per-file` and `::quotes/branches-per-team` (configurable via
@ -430,24 +445,29 @@ The flow:
* publishes a `:file-merged` message on the msgbus topic of main so every
open client reloads the file in real time.
Closing the branch (`finish-branch!`) always releases the pinned base
snapshot and, **unless the caller passes `:keep-branch true`**, logically
deletes the branch and its file in the SAME transaction (`delete-branch!`) —
merged copies do not pile up, and there is no client-driven second call that
could be lost mid-way. The `:file-deleted` msgbus message carries the
`session-id`, so the merging client (which is navigating to main on its own)
is not raced into the dashboard by its own deletion.
Closing the branch (`finish-branch!`) marks it `merged` and, **unless the
caller passes `:keep-branch true`**, logically deletes the branch and its file
in the SAME transaction (`delete-branch!`) — merged copies do not pile up, and
there is no client-driven second call that could be lost mid-way. The deletion
also releases the pinned base snapshot. A kept branch keeps its pin, because
its state is still derived from that snapshot. The `:file-deleted` msgbus
message carries the `session-id`, so the merging client (which is navigating
to main on its own) is not raced into the dashboard by its own deletion.
### Resolutions
Conflict resolutions are a simple map of **entity id → `:main` | `:branch`**.
Conflict resolutions are a map keyed by **entity id**. A value is either
`:main` or `:branch` for the whole entity, or, for a conflict that carries
`:changed-attrs`, a per-attribute map `{attr → :main | :branch}` that
resolves the conflict only when it names every changed attribute.
The id is usually a uuid, but structural conflicts use keyword ids
(`:active-themes`, `:active-sets`, `:page-order`, `:token-set-order` — each
distinct, so resolving one can never accidentally satisfy another). `:branch`
takes the branch side, `:main` (or absent) leaves main untouched. Resolving a
delete conflict to `:branch` really restores: a shape comes back with its whole
surviving subtree, a page with its full contents, a token set with its tokens. The frontend builds this
map through `set-conflict-resolution` (one entity) and `set-all-resolutions`
map through `set-conflict-resolution` (one entity),
`set-conflict-attr-resolution` (one attribute), and `set-all-resolutions`
(bulk), then passes it to the merge command.
## From merge to change ops: `compute-changes`
@ -591,21 +611,25 @@ The listing is deliberately not gated. It renders the branch panel, so
refusing it would take the panel away rather than protect it, and its cost
is bounded by the summary cache instead.
Every mutation that runs through `audited`, and every save routed to a
Every command that runs through `audited`, and every save routed to a
branch's op log, attaches `:branch-operation`, `:branch-outcome` and
`:branch-duration-ms` to its own audit event, because the generic RPC event
records who called what and not how long it took, and duration is the number
the first enterprise trial will be asked about. Creation, compare, merge,
update-from-main and materialise attach them through `audited`; the save
attaches them on the `update-file` event, whose props it replaces. Every
outcome carries them, the no-op merge, the idempotent materialise and both
refusal shapes included.
the first enterprise trial will be asked about. Creation, the listing,
compare, merge, update-from-main, and materialise attach them through
`audited`, and the listing adds `:branches-compared` and `:branches-cached`,
the two numbers that explain its duration. The save attaches them on the
`update-file` event, whose props it replaces. Every outcome these commands
return carries them, the no-op merge, the idempotent materialise, and the
`:conflicts` and `:unsupported` refusals included. A refusal raised as an
error, such as a size gate's `:restriction` or `:file-modified`, carries
none of them: the audit middleware records a call only when it returns, so
the error writes no audit event at all.
Three mutations are the exception: `update-file-branch`,
`archive-file-branch` and `delete-file-branch` are single-row metadata
updates that carry only the generic event. The three reads are the same:
the listing, the branch context and the limits are hot paths, and the
listing's own cost is recorded by the summary cache instead.
`archive-file-branch`, and `delete-file-branch` do not run through `audited`
and carry only the generic event. Two reads are the same: the branch
context, which the frontend asks for on every file open, and the limits.
## RPC API summary
@ -621,6 +645,7 @@ All commands live in `app.rpc.commands.files-branch`, are gated by
| `::update-branch-from-main` | mutation | Pull main's changes into the branch |
| `::materialize-file-branch` | mutation | Turn a branch into an ordinary file (idempotent) |
| `::get-file-branch-info` | query | Branch metadata when opening a branch file |
| `::get-branching-limits` | query | Size limits the gates enforce, for the UI to show |
| `::update-file-branch` | mutation | Rename / edit description |
| `::archive-file-branch` | mutation | Archive / restore a branch |
| `::delete-file-branch` | mutation | Logically delete a branch |
@ -636,8 +661,9 @@ All commands live in `app.rpc.commands.files-branch`, are gated by
(the diff result + selected change + resolutions) and `workspace-branch-context`
(metadata when editing a branch).
* Events: `fetch-branches`, `create-branch`, `open-branch`, `fetch-branch-diff`,
`set-conflict-resolution`, `set-all-resolutions`, `merge-branch`,
`update-branch-from-main`, `fetch-branch-context`, plus `rename-branch`,
`set-conflict-resolution`, `set-conflict-attr-resolution`,
`set-all-resolutions`, `merge-branch`, `update-branch-from-main`,
`fetch-branch-context`, `fetch-branching-limits`, plus `rename-branch`,
`archive-branch`, `delete-branch`. Each maps to the matching `rp/cmd!` call
(`:create-file-branch`, `:get-file-branches`, `:get-branch-diff`,
`:merge-file-branch`, `:update-branch-from-main`, …).
@ -652,6 +678,12 @@ All commands live in `app.rpc.commands.files-branch`, are gated by
archived branches; **`branch-entry*`** is a single branch card with rename /
archive / delete actions.
* **`create-branch-dialog*`** — the create modal (name + description).
* **`branch-info-dialog*`** is the branch info card: status, author, source
file, dates, change counts, and a description its author can edit.
* **`merge-branch-dialog*`** is the merge confirmation that `confirm-merge!`
opens, with a checkbox that keeps the merged branch (`:keep-branch`).
* **`limits-dialog*`** lists the size limits it fetches through
`::get-branching-limits`, plus the scope and merge limitations.
* **`branch-compare-dialog*`** — a read-only diff viewer, filterable by category
(pages / components / colors / tokens) and status (added / modified / deleted).
* **`branch-conflicts-dialog*`** — the conflict-resolution UI, showing
@ -670,8 +702,12 @@ card so branches can be opened or created without entering the file.
The pure engine is covered by `common-tests.files-branch-merge-test`, which
exercises `three-way-entities` and `compute-merge` across the add / modify /
delete / conflict scenarios and can be run in both the JVM and JavaScript
runners. The RPC layer is covered end-to-end by
`backend-tests.rpc-file-branch-test` (create / list / diff / merge). See the
runners. `backend-tests.branch-merge-test` adds engine cases on hand-built
data in the backend runner. The RPC layer is covered end-to-end by
`backend-tests.rpc-file-branch-test` (create / list / diff / merge).
`backend-tests.rpc-file-branch-oplog-test` asserts the op log storage and
derivation invariants, and `backend-tests.rpc-file-pull-request-test`
covers the pull request commands. See the
[Unit tests](/technical-guide/developer/common/#unit-tests) section for how to run
them.