35 KiB
title, desc
| title | desc |
|---|---|
| 3.12. Branching | 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. |
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
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.
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").
Conceptual model
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:datais 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.
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:
- if main still equals the base, then the branch is the one that changed it;
- if the branch still equals the base, then main is the one that changed it;
- if both changed it, to different values, that is a conflict and a human must pick a side.
This is the classic three-way merge algorithm, applied at the granularity of individual entities (a color, a shape, a typography, a token, …) rather than to the file blob as a whole.
A second property makes the whole thing possible: the branch shares the
internal :data ids of main. Because a branch state starts as
main's state, "color X" in the branch and "color X" in main are the same
entity with the same id, and the engine can pair them up and diff them.
Only the ids created on the branch (new shapes, components, media)
carry branch-local ids; remap-refs normalizes those before diffing
and merging. Without stable ids an id-based merge would be impossible.
Where the code lives
The feature spans the three subsystems. The core, shared logic lives in
common so it can run identically in the JVM and in JavaScript test runners.
common/src/app/common/files/branch_merge.cljc ; three-way diff + merge->changes engine
backend/src/app/rpc/commands/files_branch.clj ; RPC commands (create/list/diff/merge/update)
backend/src/app/binfile/common.clj ; branch :data derivation (get-file* / branch-file-data)
backend/src/app/rpc/commands/files_update.clj ; branch save routing (persist-branch-file!)
backend/src/app/migrations/sql/0155-add-file-branch-table.sql ; schema (file_branch table + is_branch)
backend/src/app/migrations/sql/0157-add-file-branch-change-table.sql ; op log (file_branch_change)
frontend/src/app/main/data/workspace/branches.cljs ; frontend state + RPC calls (ptk events)
frontend/src/app/main/ui/workspace/sidebar/branches.cljs ; panels & dialogs
frontend/src/app/main/ui/dashboard/branches_popover.cljs ; dashboard file-card popover
Tests:
common/test/common_tests/files_branch_merge_test.cljc ; unit tests for the diff/merge engine
backend/test/backend_tests/rpc_file_branch_test.clj ; integration tests for the RPC commands
The branching commands closely mirror the patterns in
app.rpc.commands.files-snapshot, and reuse the production change pipeline
(app.common.files.changes/process-changes), the file snapshot machinery
(app.features.file-snapshots), and the file read/save paths
(app.binfile.common, app.rpc.commands.files-update).
Data model
Two migrations add the branching schema: one column and one table on top of
the existing file machinery, plus the op log table.
ALTER TABLE file
ADD COLUMN is_branch boolean NOT NULL DEFAULT false;
CREATE TABLE file_branch (
id uuid PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
branch_file_id uuid NOT NULL REFERENCES file(id) ON DELETE CASCADE DEFERRABLE,
source_file_id uuid NOT NULL REFERENCES file(id) ON DELETE CASCADE DEFERRABLE,
base_snapshot_id uuid NULL,
-- main's revn when the base was (re)positioned
base_revn bigint NOT NULL,
-- the BRANCH file's revn at the same moment (independent counter)
base_branch_revn bigint NOT NULL DEFAULT 0,
created_by uuid NULL REFERENCES profile(id) ON DELETE SET NULL,
name text NOT NULL,
description text NULL,
status text NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'merged', 'archived')),
merged_at timestamptz NULL,
merged_by uuid NULL REFERENCES profile(id) ON DELETE SET NULL,
deleted_at timestamptz NULL
);
CREATE TABLE file_branch_change (
id uuid PRIMARY KEY,
branch_id uuid NOT NULL REFERENCES file_branch(id) ON DELETE CASCADE DEFERRABLE,
file_id uuid NOT NULL REFERENCES file(id) ON DELETE CASCADE DEFERRABLE,
revn bigint NOT NULL,
changes bytea NOT NULL,
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
deleted_at timestamptz NULL,
UNIQUE (file_id, revn)
);
Key points:
-
is_branchonfileflags the branch file so it is hidden from the normal project/team file listings, the dashboard search, the team trash (and its restore path), and project duplication/binfile export — you don't want every branch mixed in with real files, nor restorable zombies without theirfile_branchrow. When the source file is deleted, thedelete-objectcascade propagates the logical deletion to its branches (they would otherwise be unreachable forever). -
branch_file_idpoints to the file row that is the branch; the row carries no data payload, but it anchors permissions, libraries, msgbus topics and media ownership.source_file_idpoints to main. AUNIQUEindex onbranch_file_idguarantees a file can be the branch of at most one source. -
file_branch_changeis the branch's op log: one row per branch save (the blob-encoded change vector,UNIQUE (file_id, revn)). The branch's:datais derived by replaying its rows in revn order over the base snapshot. Rows are replaced (squashed) byupdate-branch-from-mainand cascade away when the branch is deleted. -
base_snapshot_idreferences the merge-base snapshot.base_revnrecords MAIN's revision number andbase_branch_revnthe BRANCH file's one at the moment the base was (re)positioned — the two files carry independent revn counters, so each side's cheap "did anything change?" gate compares against its own counter (see ahead/behind below). The migration is intentionally idempotent (IF NOT EXISTSeverywhere) so instances that applied it under its old development name pick up new columns on re-apply. -
statusmoves throughopen→merged(after a successful integration) oropen↔archived(a reversible "hide it" state). Logical deletion is handled the standard Penpot way viadeleted_atrather than a hard delete.
Lifecycle of a branch
Creating a branch
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
(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
would otherwise make the base differ from the branch's initial content and
manufacture phantom diffs later:
- Materialize the merge base.
fsnap/create!takes a system snapshot of main labelledbranch-base/<name>, with adeleted-at3650 days in the 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 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. - Create the branch file row. An empty file row (
ctf/make-filewith no pages) carries main's project, features (minus thefdata/*ones, the 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. - 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'sfile_library_relrows so shared-library components keep resolving on the derived state. - Insert the
file_branchrow withstatus = 'open', the base snapshot id andbase_revn = base_branch_revn = (:revn file).
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
PENPOT_QUOTES_BRANCHES_PER_FILE / PENPOT_QUOTES_BRANCHES_PER_TEAM,
registered in app.config).
On the frontend, create-branch (in branches.cljs) first force-persists any
pending local changes, then calls :create-file-branch, so the branch starts
from a fully-saved state of main.
Working on a branch
Once created, main and the branch evolve independently: main keeps its
stored data, the branch keeps an op log. When a branch file is opened, the
backend derives its state (base + replayed ops) before serving it, and the
frontend fetches its context via :get-file-branch-info (events
fetch-branches / fetch-branch-context) and shows a
branch-context-banner* at the top of the workspace indicating "you are on a
branch", together with its ahead / behind counts and the merge / update
actions.
Ahead / behind counts
::get-file-branches lists a file's branches and annotates each with
entity-level ahead / behind / conflicts counts — the same numbers the
compare dialog shows.
Computing these means running the full three-way diff, which is expensive, so
there is a two-level gate (branch-diff-counts):
- Each file carries a monotonically increasing
revn. The command first computes cheap revn deltas:ahead-revn = branch_revn - base_branch_revn,behind-revn = source_revn - base_revn(revn-deltas) — each side against its OWN counter; mixing them breaks after an update-from-main repositions the base. - If both deltas are zero, the branch is in sync and the diff is skipped
entirely (
[0 0 0]). - Otherwise main's
:datais realized once and shared across all branches of the same source, while each diverged branch's state is derived (base snapshot + op log replay) on demand.aheadis the count of clean changes in the:branch->maindirection,behindthe count in the reverse direction, andconflictsis only non-zero when both sides diverged. Only open branches are diffed (merged/archived ones report[0 0 0]), and a listing never fails because of one corrupted branch: count errors degrade to zeros with a warning (the merge itself still refuses loudly).
The three-way diff engine
app.common.files.branch-merge is the heart of the feature. It is a pure,
side-effect-free engine: it reads three :data blobs and returns a serializable
summary. It never mutates anything.
three-way-entities — the per-entity truth table
three-way-entities diffs one indexed collection (id -> value) across
base / theirs / ours and returns {:changes [...] :conflicts [...]}. For each id
it applies this decision table (theirs is main, ours is the branch, in the
default :branch->main direction):
| Situation | Outcome |
|---|---|
| deleted in both | nothing |
deleted in branch, main intact (= base) |
clean :deleted |
deleted in main, branch intact (= base) |
nothing (already gone in main) |
| deleted in branch / modified in main | conflict :delete-modify |
| modified in branch / deleted in main | conflict :modify-delete |
| new in branch only | clean :added |
| new in main only | nothing |
| added on both, same value | nothing |
| added on both, different value | conflict :add-add |
present in all three, branch = base |
nothing (main wins) |
present in all three, main = base |
clean :modified (branch wins) |
present in all three, branch = main |
nothing (converged) |
| present in all three, all three differ | conflict :modify-modify |
So there are exactly four conflict reasons: :delete-modify,
:modify-delete, :add-add and :modify-modify. Every conflict descriptor
carries :base, :main and :branch values plus a :changed-attrs map
(attribute → {:main v :branch v}) so the UI can render a side-by-side diff.
Shapes are diffed stripped of their derived attrs (strip-shapes:
:shapes, :touched, :selrect, :points) and the page root frame
(uuid/zero) is ignored. Stripping happens at CLASSIFICATION level, not just
display: this is what prevents false conflicts — both sides adding children to
the same frame only differ on :shapes, and a library sync on main only flips
:touched; neither is a user change, so neither may turn a clean branch edit
into a modify-modify/delete conflict. Containment (:parent-id/:frame-id)
is deliberately kept: reparenting is a real, user-meaningful change. The same
principle strips the :modified-at bookkeeping stamp from colors,
typographies and components (strip-modified-at): the apply pipeline
re-touches them, so after an update-from-main the copied entities would
otherwise differ from main forever.
Pages and token sets get a content-valued presence pass (presence-only):
presence is diffed over the normalized content instead of a bare marker, so
deleting a page or token set the other side edited raises a proper
:delete-modify/:modify-delete conflict instead of silently dropping those
edits; content-only modifications are filtered out (the granular passes own
them) and the conflict payloads are slimmed for the wire.
compute-merge — across the whole file
compute-merge runs three-way-entities over every mergeable collection in the
file and concatenates the results:
- colors, typographies, components (row metadata), media — straightforward indexed collections.
- pages (
diff-pages) — page add/delete, page metadata (name/background/grid), per-page guides, flows, default-grids, plugin-data, and the shapes (:objects) of every common page. Residual page attributes that no pass handles are surfaced under the:page-attrskind so they are refused rather than silently dropped. - tokens (
diff-tokens) — token-set add/delete, set rename, set order, themes, active theme paths, active-set toggles, and per-token values.
The result is {:changes [...] :conflicts [...] :stats {...}}, where :stats
counts added / modified / deleted / conflicts. The direction argument swaps which
side is "theirs" and which is "ours": :branch->main (merge / compare) vs
:main->branch (update from main).
remap-refs — the local-reference fix-up
The derived branch data starts as main's data, so base entities already
reference main's file id and main's media ids. What carries branch-local
ids is everything the branch created: components get :component-file set to
the branch file id, and media added on the branch gets its own
file_media_object rows with fresh ids. If diffed as-is, those entities
would look modified, inflating the diff and conflicts; worse, after a merge
the references could not be resolved in the target file — repair would
silently detach components and images would break when the branch file is
GC'd.
bm/remap-refs [data id-map] rewrites exactly that local-reference surface
(:component-file, :fill-color-ref-file, :stroke-color-ref-file,
:typography-ref-file, shadow/grid :file-id, and the media refs), through
an {old-id -> new-id} map; ids not in
the map (external libraries) are untouched. The backend builds that map per
operation: the branch file id plus the media pairs (media-pairs, rows
matched across the two files by storage object + name + dimensions + mtype).
Media added on the source side of a merge/update gets fresh ids
pre-allocated before the diff, and its file_media_object rows are copied
into the target file when the operation applies — so merged shapes always
reference rows owned by the file that outlives the branch.
Merging a branch into main
::merge-file-branch integrates the branch back into main. Only an editor of
main may do it (same rule as Figma). The whole operation runs in a transaction
guarded by db/xact-lock! on both file ids (in stable order, to avoid
deadlocks) — without the branch lock, edits saved to the branch while the merge
runs would be silently lost when the branch is marked merged and deleted.
The flow:
- Load main, the branch (normalized), and the base (from the snapshot).
- Optimistic concurrency check. If the caller passed
expected-main-revnand it no longer matches main'srevn, it raises:file-modified("recompute the diff and retry"). The frontend always sends it (taken from the diff's:meta :main-revn), so resolutions computed against a stale diff can never apply; on:file-modifiedit re-fetches the diff and asks the user to review again. - Conflict gate.
compute-mergeis run; any conflict not resolved in theresolutionsmap aborts with{:status :conflicts :conflicts [...]}. - Unsupported gate.
compute-changesis run; if it reports any:unsupportedkinds it aborts with{:status :unsupported :kinds [...]}— nothing is applied, so no change is ever silently lost. - No-op shortcut. If there are no changes to apply (the branch already matches main), the branch is closed without touching main.
- Apply. Otherwise it:
- takes a safety snapshot of pre-merge main (
pre-merge/<name>, with the team's normal deletion delay) so the merge can be rolled back via the version history; - applies the change vector through
cpc/process-changes, bumpingrevn; - validates the merged file (
cfv/validate-file) and, if it finds problems, runscfr/repair-fileand applies the repair changes; - copies the
file_media_objectrows of media added on the branch into main, under the pre-allocated ids the changes reference; - writes a
file_change(xlog) row, persists the file (fupd/persist-file!), marks the branchmerged; - publishes a
:file-mergedmessage on the msgbus topic of main so every open client reloads the file in real time.
- takes a safety snapshot of pre-merge main (
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.
Resolutions
Conflict resolutions are a simple map of entity id → :main | :branch.
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
(bulk), then passes it to the merge command.
From merge to change ops: compute-changes
A diff summary is not directly applicable; it has to become the same kind of raw
change maps the editor produces. compute-changes does that translation and
returns {:changes [...] :unsupported #{...}}. The RPCs pass it the
compute-merge summary they already computed for the conflict gate, so the
full three-way diff is not run a second time just to derive :unsupported.
Mergeable vs. unsupported kinds
mergeable-kinds is the source of truth for what can be translated:
#{:color :typography :media :shape :token :token-set :token-set-rename :token-set-order
:token-theme :token-active-themes :token-active-sets
:page :page-order :page-guide :page-flow :page-grid :page-plugin :component}
unsupported-kinds returns any kind present in the diff that is not in this set
(notably :page-attrs, the residual page attributes). When that set is non-empty
the merge is refused — the engine prefers to refuse rather than drop a change it
cannot faithfully reproduce.
Per-kind translation
flat-changes is the shared helper that walks a flat id -> value collection and
emits add / modify / delete ops, honouring resolutions (a conflicting entity is
emitted only when its resolution is :branch). The concrete op types it produces:
- colors / typographies / media —
:add-color/:mod-color/:del-color, and the analogous:*-typography/:*-mediaops. - shapes (
page-shape-changes) — the most involved pass:- modifications become
:mod-objwith:setoperations for the attrs that differ (structural attrs:shapes/:parent-id/:frame-idare excluded — they are applied via moves, not plain sets); per-attr resolutions that take a geometry attr also carry the branch's:selrect/:pointsso position and caches stay consistent; - deletions become
:del-obj; - additions become
:add-obj, emitted in topological order so a newly added parent is created before its newly added children; a modify-delete conflict resolved to:branchre-adds the shape's whole surviving subtree the same way; - reparenting of existing shapes becomes
:mov-objects, emitted last (after new containers exist) and ordered by depth so a container moves before the shapes moved into it. Reparenting cannot be a:set :parent-idop — that would leave the shape loose in its old parent and let file repair duplicate it.
- modifications become
- pages —
:add-page(full page incl. objects) /:del-page,:mod-page(name/background/grid),:mov-page(reorder), plus per-page:set-guide,:set-flow,:set-default-gridand:set-plugin-data. - components —
:add-component/:mod-component/:del-component(a branch soft-delete,:deleted true, is surfaced as a real:del-component). Components are emitted before shapes so:del-componentcan capture the main-instance objects before:del-objremoves them. - tokens —
:set-token(per-token values),:set-token-set(create/delete and rename),:move-token-set(reorder),:set-token-theme,:set-active-token-themes.
Updating a branch from main
::update-branch-from-main is the reverse direction: it brings into the
branch the changes main received since the merge base. It needs edition
permissions on the branch file, locks the branch file id, and applies main's
changes (plus any conflicts resolved to :main) into the branch.
It works like a merge with compute-merge/compute-changes run with the sides
swapped, the same :conflicts / :unsupported gates, and the same safety
snapshot. Two extra steps at the end keep the op log sound:
- it squashes the op log: the repositioned base already carries
everything main contributed, so the log is replaced with the net
branch-only changes (
compute-changeswith base = main's current state and branch = the updated branch state). Replaying those over the new base reproduces the updated branch exactly, and main-side ops never accumulate in the log. If that net cannot be translated into replayable ops the update refuses (:unsupported-update-squash), so nothing is persisted; - it repositions the merge base: the base snapshot is moved forward to
the current state of main (releasing the previous one), and both
base_revn/base_branch_revnare refreshed. When the branch carried its own changes,base_branch_revnis stored one revn short on purpose: the new base equals MAIN, so the branch still differs from it even if its revn never moves again — the trick keeps the cheap gate open and the diff reports the real counts.
UI resolutions arrive in main/branch terms and are inverted server-side (per-attr maps included) before translation.
RPC API summary
All commands live in app.rpc.commands.files-branch, are gated by
check-branching-enabled!, and were added in 2.16.
| Command | Kind | Purpose |
|---|---|---|
::create-file-branch |
mutation | Snapshot main + create the branch file and op log |
::get-file-branches |
query | List branches with ahead/behind/conflicts counts |
::get-branch-diff |
query | Read-only three-way diff (either direction) |
::merge-file-branch |
mutation | Integrate a branch into main (resolutions, expected-main-revn, keep-branch) |
::update-branch-from-main |
mutation | Pull main's changes into the branch |
::get-file-branch-info |
query | Branch metadata when opening a branch file |
::update-file-branch |
mutation | Rename / edit description |
::archive-file-branch |
mutation | Archive / restore a branch |
::delete-file-branch |
mutation | Logically delete a branch |
Frontend integration
State and events
app.main.data.workspace.branches holds the state and wraps the RPC calls as
ptk events. The relevant pieces:
- State keys:
workspace-branches(the list + status),workspace-branch-diff(the diff result + selected change + resolutions) andworkspace-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, plusrename-branch,archive-branch,delete-branch. Each maps to the matchingrp/cmd!call (:create-file-branch,:get-file-branches,:get-branch-diff,:merge-file-branch,:update-branch-from-main, …). reload-file-window/show-merge-resulthandle the post-merge UX (reacting to the:file-mergedmsgbus notification and showing the result).
UI components
app.main.ui.workspace.sidebar.branches provides the workspace UI:
branches-toolbox*— the sidebar panel listing the current, open and archived branches;branch-entry*is a single branch card with rename / archive / delete actions.create-branch-dialog*— the create modal (name + description).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 base / main / branch side by side and letting the user pick a side per conflict.branch-context-banner*— the top-of-workspace banner shown while editing a branch, with the ahead/behind counts and the merge / update actions.confirm-merge!/confirm-update!guard the irreversible operations with a confirmation modal.
app.main.ui.dashboard.branches_popover adds a popover to the dashboard file
card so branches can be opened or created without entering the file.
Testing
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
Unit tests section for how to run
them.
Pull requests
On top of branching, pull requests add a review step before the merge:
the author of a branch asks one or more team members (reviewers) to
evaluate their changes. The feature is part of branching and is gated
behind the same :branching product flag; it lives in:
backend/src/app/migrations/sql/0156-add-file-pull-request-tables.sql
backend/src/app/rpc/commands/files_pull_request.clj
frontend/src/app/main/data/workspace/pull_requests.cljs
frontend/src/app/main/ui/workspace/sidebar/pull_requests.cljs
backend/test/backend_tests/rpc_file_pull_request_test.clj
Model
A pull request is not a file copy. The file_pull_request row points
to its file_branch and to a pinned review snapshot of the branch
file (label pr-review/<title>, the same ~10-year pin/release mechanics
as the merge-base snapshot). There is at most one open pull request per
branch (partial unique index).
Opening a review (?pr-id= on the workspace url) opens the live
branch file as a completely normal workspace file — navigable and
editable, so reviewers can try and validate anything (edits go to the
branch, as always) — decorated with the review banner and actions. The
pinned snapshot backs two things instead of the canvas: the cheap
"outdated" gate (branch_revn > review_revn) and the prototype
viewer: ::get-view-only-bundle accepts an optional pr-id and
serves the snapshot overlay read-only (permissions taken from the target
file, edition stripped; share-links do not apply), reachable from the
review banner's "Try interactions" action.
::get-pull-request-bundle serves the same snapshot overlay as a
workspace-shaped file bundle for API consumers; it refuses once the pull
request is closed, so the pinned state stops resolving when the review
ends (closing, merging or deleting the branch releases the pin).
Because the sandbox is pinned, the author can keep editing the branch;
publishing the new state to reviewers is an explicit action
(::update-pull-request-snapshot) that repositions the snapshot.
branch_revn > review_revn is the cheap "outdated" gate, mirroring the
ahead/behind revn gates.
Reviews
Reviewers are stored in file_pull_request_review (one row per profile,
pending / approved / changes-requested). A verdict records the
review_revn it was issued over, so it goes stale (not deleted) when
the author publishes newer changes. The aggregate review-state is
derived, never stored: current changes-requested beats current
approved; stale verdicts only count as "in review". Approval is
informative: merging keeps requiring edition permissions on main and
goes through the exact same ::merge-file-branch gates.
Stored status is only open → merged/closed (with reopen). All the
UI states (approved, changes requested, outdated, conflicts) are derived
per request from the reviews and the existing diff-count helpers.
Accepting a pull request is offered from the sandbox banner (and the
sidebar entries) to any user with edition permissions on main, but it is
just an entry point into the exact same branch merge dialog and
::merge-file-branch flow — including conflict resolution and the
safety snapshot. Note that merging always integrates the LIVE branch
state: an outdated pull request merges the newest changes, not the
pinned review snapshot.
Lifecycle hooks
The branch lifecycle closes its pull request: merge marks it merged,
branch delete/archive marks it closed (see
close-branch-pull-requests! in files_branch.clj), and the
delete-object cascade closes it when the branch file dies through
other paths (the row itself survives as history; it is only logically
deleted when MAIN dies). Reviewers are notified by email
(review-request template) and through the dashboard notifications
dropdown (::get-profile-pending-reviews: open pull requests where the
profile has no current verdict); :pull-request-* msgbus messages on
main's topic keep open clients fresh.
Current scope and limitations
The feature was built in phases, and the engine refuses anything it cannot yet reproduce faithfully rather than risk losing work:
- The merge is id-based and assumes base, main and branch share the same file data version; explicit data-version normalization before diffing is a later refinement.
- Residual page attributes (
:page-attrs) are surfaced but refused, so a merge that touches them returns{:status :unsupported}. - Comments / comment-thread positions are deliberately not migrated across a merge (the same choice Figma makes).
- The merge base snapshot is kept alive for the whole life of the branch; the
far-future
deleted-atis what protects it from the snapshot GC. - Branch state is derived (base snapshot + op log replay) on every read,
so a long-lived branch replays one blob per save;
update-branch-from-mainsquashes the log, but there is no standalone compaction yet. The derived data also inherits the base's data version, so a branch reads:base-snapshot-missingloudly if its pin was released.