24122 Commits

Author SHA1 Message Date
Álvaro Tejero Cantero
9fae01b79b
♻️ Port branching to the unified jobs substrate
develop replaced the legacy one-shot task system with app.jobs
(#11542). Two branching call sites still used the removed API, so the
backend no longer compiled on top of it:

- files-branch/delete-branch! schedules the branch file's
  :delete-object job with jobs/submit, joined to the caller's
  transaction through the cfg's ::db/conn, as files.clj does.
- files-pull-request/send-review-request-emails! queues the review
  request with eml/send and ::eml/reuse-conn, as the comment emails
  do, and now takes the transaction cfg instead of the connection.

The tests follow: the rollback test redefines eml/send, and the
file-gc scheduler test reads the scheduled work from the job table,
whose params are plain JSON.

AI-assisted-by: claude-opus-5-5
2026-10-06 11:02:31 +02:00
Álvaro Tejero Cantero
d490639489
🐛 Give the branches shortcut the static label develop requires
develop's translation cleanup (penpot/penpot#11853) makes every shortcut
definition carry a :label fn holding a literal (tr "shortcuts.<id>"),
and command-definitions-carry-static-labels pins it. :open-branches,
added on the branching fork, had no label and no English string, so the
shortcuts panel showed the raw key and the test failed by name on the
rebase.

The label reads "Branches", the name the main menu already gives the
same panel.

frontend tests: 1200 tests, 4580 assertions, 0 failures.

AI-assisted-by: claude-opus-5-5
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
d975b1f0b6
🐛 Keep the branch column out of a trimmed library's envelope
The viewer test that develop carries, share-link-bundle-trims-linked-
libraries, pins the envelope keys a trimmed library may expose, and the
branching column :is-branch is not one of them: whether the source file
of a library is a branch is not something a share-link viewer may read.
The column exists only on this stack, so develop's test could not see
it and the collision appears only on the rebase.

trim-library-data drops it. A library a branch links is materialised,
so the column is false there anyway; dropping it is about the envelope,
not about hiding a value.

backend-tests.rpc-viewer-test: 11 tests, 95 assertions, 0 failures.

AI-assisted-by: claude-opus-5-5
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
1410d66ca2
🔥 Drop support for the branching preview's older databases
Branches and databases from the preview's earlier shapes are not
supported, and such branches are recreated. The earlier shapes are the
copy model, where a branch file kept its own data payload, and the
tables registered as 0150-add-file-branch-table,
0152-add-file-branch-table and 0153-add-file-pull-request-tables. This
commit removes the code that existed only for them. A fresh database
ends with the same schema as before.

- 0155-add-file-branch-table.sql loses its header about the old
  registrations, every IF NOT EXISTS, and the base_branch_revn upgrade
  and backfill. CREATE TABLE file_branch already declares the column.
- 0156 and 0157 lose every IF NOT EXISTS.
- 0158-add-file-branch-change-data-version folds into 0157, and its
  file and registration go. CREATE TABLE file_branch_change declares
  data_version NOT NULL, because both writers of the op log, a branch
  save and the squash of an update from main, stamp every row.
- need-migration-to? loses its nil case, which replayed an unstamped
  op-log row at the base's version, and the branch derive loses the
  comment that described it. Stamped rows replay as before.
- The tests for a re-run of the branch table migration and for the
  replay of an unstamped row go, with the require the first one used.

A database that holds the preview's older shapes must drop its
branching tables and the file.is_branch column, and delete the
branching steps from its migrations table, before it boots this code.
A database that ran 0157 without 0158 lacks data_version and counts as
an older shape. A database that already ran 0155 to 0158 under these
names keeps working, because a migration runs once per name.

AI-assisted-by: claude-opus-5-5
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
6605968633
🐛 Merge token activation through develop's tokens status
develop moved token activation out of the tokens lib into the document's
:tokens-status (penpot/penpot#10293): ctob/get-active-theme-paths is
gone, :set-active-token-themes is no longer a change type, and a set is
activated by id in the status rather than through the hidden theme. The
branch merge still read active theme paths off the lib, which on the JVM
is a compile error in app.common.files.branch-merge and therefore stops
every backend namespace that requires it from loading.

The merge now reads both halves of activation off :tokens-status, as ids,
and keeps the two conflict kinds and resolution keys the UI already
offers (:active-themes, :active-sets). compute-changes settles each half
by the rule it used before and emits one :set-tokens-status, because the
change replaces the whole status. The hidden theme stays out of the
theme diff, as before.

The two activation tests now build the status with develop's own
cfo/activate-set and cfo/activate-theme, and a third pins that a
conflicting activation keeps main's status until a resolution takes the
branch's.

common-tests.files-branch-merge-test: 84 tests, 331 assertions, 0
failures.

AI-assisted-by: claude-opus-5-5
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
b0e01ef2ef
🐛 Fix the defects the engine design read found
Depth gate: the compare reads the op log and applies the depth gate
before it derives the branch, the same order merge and update from
main use, so a log too deep to replay refuses before the derive runs.
The refusal names the materialise and the delete, the two remedies the
gates do not refuse, in the hint and in the notification copy.

Resolutions: the conflict answers of merge-file-branch and
update-branch-from-main carry the main revision the conflicts were
computed against, and the client sends it as expected-main-revn when
resolutions are applied before the dialog's diff fetch returns.

Libraries: absorbing skips branch files, so unpublishing or deleting a
shared library writes no payload on a branch and the branch keeps its
own link; a soft-deleted library resolves nothing there until the
branch's next update from main brings main's absorbed copies. The
absorbed write passes the file's features to update-file!, which the
hand-built params dropped, so the pointer-map fragments the absorption
regenerates are persisted and no payload references a fragment no
write produced.

Queries: the project file count, the team file count, the list of
files that use a library, and the socket-REPL team duplication skip
branch files.

Snapshots: the branch refusal lives in check-restorable!, which the
restore command calls before its backup snapshot and the two
socket-REPL restore helpers reach through restore!.

Migration 0155: the base_branch_revn backfill repositions only the
rows whose branch file carries a data payload, so a re-run changes
nothing on the branches the op-log model creates.

Tests: the branch merge tests run in JavaScript, which the common test
runner lists; new backend tests pin the gates, the absorption skip,
the query filters, the restore refusals, the migration re-run, and the
conflict answers' revision; a new frontend test pins the token the
senders carry with resolutions.

AI-assisted-by: mixed models
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
2458bdb88b
📚 Drop the two docket ids left in the branch comments
Two frontend comments still cited a docket entry id that resolves only
on the machine the set lives on, which the reviewer this branch is
written for cannot follow. Each citation stood beside the behaviour it
records, so both sentences stand without it, as in the earlier commit
that dropped four such ids.

The change in data/workspace/branches.cljs was staged by hunk: the
working tree holds another session's uncommitted edits to that file.

AI-assisted-by: mixed models
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
fe64834d1d
📚 Call the ids a comparison runs in an id space, not a frame
The branch comments called the set of ids a document's references are
written in (main's file id and media rows, or the branch's) a frame.
Penpot's code uses frame for a board, through `:frame-id`, so a reader
could not tell which sense a comment meant. The comments, the
docstrings of `get-base-data`, `branch-base-data`, and `remap-changes`,
and one test name now say id space. Every board-sense use of frame is
unchanged.

AI-assisted-by: mixed models
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
325f963685
💄 Say that Try interactions plays the published branch
The create pull request note said Try interactions plays the branch
"as it is at this moment", which reads as the live branch. The player
serves the pull request's pinned review snapshot, so an edit made
after the request shows there only once newer changes are published.
The note now says so.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
c7673fee31
📚 Drop the local docket ids from the branch comments
Four comments cited docket entry ids that resolve only on the machine
the set lives on, and the reviewer this branch is written for cannot
follow them. Each citation stood beside the number or the rule it
records, so the sentences stand without it.

AI-assisted-by: mixed models
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
4bb0d79f84
💄 Satisfy the stylesheet linter in the workspace palette
The clearance fallback loses its unit on a zero length, and the comment
about the banner's published room takes the blank line the rule asks for
above it. Both come from the palette lift, and neither changes what the
bar renders.

AI-assisted-by: claude-opus-5-5
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
8e62644741
🐛 Fix the open defects of the branching tech preview
Merge engine: a same-parent reorder, an addition under a parent main
deleted, and a branch deleting a container main edited each lost work
silently. Each now follows a policy read from
backend/resources/app/branch-merge-policies.edn, re-read when the file
changes, with the defaults: merge the reorder, and raise a conflict on
the deleted parent or container. Added siblings keep the branch's order.
A :mov-page index counts the target's full page list, a cleared page
background reaches main, a per-attribute resolution can remove an
attribute, and a token set reorder survives a rename on the other side.
Conflict payloads name :main and :branch by document in both
directions. Reorder detection costs nothing measurable on a page of
20,201 objects.

Data versions: each op-log row records the data version it was written
at, the branch replays its log in segments and migrates the document
at each boundary, and the comparison base is migrated like main and the
branch, so an upgrade no longer shows as false conflicts.

Writers: a binfile import, an admin repair, file GC and a snapshot
restore refuse a branch file, whose payload nothing reads, and the
branch base pin cannot be renamed into a user snapshot. A branch save
runs the ordinary media fix-up and logs the rewritten references. An
update from main deletes the branch's copy of media main deleted and
squashes no phantom media changes.

Commands and UI: the pull-request commands publish their messages after
the commit, the listing takes one connection per comparison and shows a
warning instead of "in sync" when a comparison fails, materialize takes
:branch-file-id, and a dashboard fetch that never answers ends in an
error state instead of a spinner, with a refetch on reconnect.

backend/dev/branch_merge_cases.clj builds one example file per merge
policy case for demonstrating the alternatives.

Two models wrote this change, claude-opus-5-5 and mimo-v2.6-pro, and
the work switched between them, so the trailer reads mixed models.

AI-assisted-by: mixed models
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
f6dc4bed2d
📚 Replace the branching guide page with a placeholder
The page described a feature that changes daily behind the `:branching`
product flag, and every change cost two descriptions: the page and the
design notes the review reads. The page now names the featureset it
will cover, branches, pull requests, reviews and merges, and promises
its description at the point the feature is proposed for merging into
develop, written against the code that the proposal ships.

The name moves with it, because the page covers version control for
files and not one of its mechanisms.

AI-assisted-by: mimo-v2.6-pro
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
0e51e5b3b8
🐛 Keep the conflict refusal's error page and fix the guide
The backend answers a :conflict refusal (:file-modified, :file-locked)
with HTTP 409 since the previous commit. On the frontend that type had
no ptk/handle-error method, so the version-restore flow would have
fallen to the generic toast instead of the error page the old 500
reached. errors.cljs now maps :conflict to the same exceptional-state
handler.

The technical guide's audit paragraph said rename, archive and delete
report nothing; they report a duration and an outcome. The merge step
list now records the re-validation after the repair and its refusal,
and the update section names the same refusal.

AI-assisted-by: mimo-v2.6-pro, claude-opus-5-5
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
ac3abfaf38
💄 Tell reviewers they open the branch itself, not a read-only copy
The create pull request dialog promised reviewers "a read-only sandbox
with the branch state at this moment". A review opens the live branch
file as an ordinary editable workspace, so a reviewer's edit changes
the branch and marks the pull request with "New changes on branch". The
pinned snapshot taken when the request is created backs only Try
interactions and that marker.

The note now says so: reviewers open the branch itself, any edit they
make changes it, and Try interactions plays the state of this moment.
Only en.po carries the key.

AI-assisted-by: claude-opus-5-5
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
863c7cab1b
🐛 Fix the defects the review of the branch commands found
The source notes on files_branch.clj listed 32 review findings. This
commit fixes the 21 that are defects of the code; each fix carries a
regression test that failed before it and passes after.

Concurrency: merge, update from main, rename, archive and delete now
re-read the branch row under the advisory lock and work off that row,
so a concurrent merge or update can no longer leave a ten-year base
pin behind or release a superseded base twice. Delete and archive take
the branch file lock, create and reopen of a pull request refuse a
branch that a concurrent merge closed, and create checks the branch
quota under the lock. Merge, update and delete publish their msgbus
messages only after the transaction commits.

Merge, update and materialize re-validate after the repair: merge and
update refuse a result that stays invalid, materialize logs it. The
change log row carries the repair changes, and the three paths drop
the cached file summary under :redis-cache.

Contracts: a :conflict refusal answers 409 instead of 500, resolution
values must be :main or :branch, and update from main accepts the
expected-main-revn token the frontend now sends from a loaded diff.

Smaller fixes: create refuses a blank name, writes no file_data row,
and a rename reaches the branch file's own name. Deleting a kept
merged branch keeps its merged status. get-branch-diff reports the
creation time of the current base snapshot. Rename, archive and delete
report their duration and outcome. An image used only by a library
colour is copied into the target file. Dead fallbacks and dead
deleted-at checks are gone.

AI-assisted-by: claude-opus-5-5
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
ca07aa6e8a
📚 Bring comments and docstrings in line with the op-log engine
Comments written for the duplicate-file prototype still described a
branch as a copy of its file, a merge as clean-only, token diffing as
missing, a review as a read-only snapshot, and file_change rows as
living for the team deletion delay. Each now states what the code at
this revision does: a branch is a file row derived from a pinned base
plus its op log, every change kind except :page-attrs is translated,
merges apply resolutions, a review opens the live editable branch, an
update squashes the log and repositions the base, and file_change rows
expire after one hour. The audited docstring names the props it sets,
the step 6 comment says each revn counter starts at its own side, and
handle-file-merged names both topics it arrives on.

Comment, docstring, and SQL-comment lines only. clj-kondo reports no
errors on the touched Clojure files. Translations are untouched.

AI-assisted-by: claude-opus-5-5
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
16b7e64fbb
📚 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
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
5cb615ea88
🐛 Stop an update from main discarding a diverged branch's own work
Data loss, reproduced twice rather than inferred. A branch stores no
data of its own, so its whole work lives in `file_branch_change`, and
`update-branch-from-main` had a fast path that deleted every one of
those rows whenever the update found nothing of main's to apply. The
comment there assumed the branch was in sync with main, which holds only
when the branch has no changes of its own, and `branch-diverged?` was
already computed two lines above and consulted only inside
`reposition-base!`.

A branch with one fill edit of `#bb0000` and an untouched main, updated
with no resolutions, returned `:updated` and came back `#000000` with an
empty log. The user asked to catch up with a main that had not moved and
lost their edit. Through the modal the same defect made the resolution
that says "keep mine" the one that discards it.

The fast path is now guarded by `(not branch-diverged?)`, so a diverged
branch falls through to the squash that re-derives its net against the
new base. The predicate is the right one: it compares the branch file's
own revision counter against the revision the base was last positioned
at, and a log row and a revision bump are one event, because
`persist-branch-file!` is the log's only appender. The comment at the
guard states the invariant, which is that the log may be emptied only
when what it encodes already sits in the base.

One correction to how the fast path was described while this was being
chased: `(empty? changes)` does not mean the branch has nothing of its
own. In the update direction the change set is MAIN's contribution, so a
branch that is behind main in content already took the squash path and
still does. The fast path is reached when main has nothing to contribute,
which is what the third new test pins.

The three sibling paths that also empty or replace a log were each
judged and are sound, because each one writes the state elsewhere inside
the same transaction before emptying.

Three regression tests, one per reproduction plus the case the fast path
exists for, asserting through the derived document rather than through
log row counts, because a legitimate squash rewrites the log while
preserving the state.

Verified: the whole backend suite, 923 tests and 5,550 assertions with 0
failures. The two live demo branches were checked read-only and still
hold their one op-log row each, so the hand-test fixture was never
damaged.
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
a033a8e1e3
🐛 Compare in main's frame, and stop decoding the merge base twice
Two defects with one cause, which is that a comparison and a write want
different identifier frames and the code used one frame for both.

Bringing main into a branch compared in the branch's frame. It remapped
main and the base into that frame, which puts the branch's inherited
content where it cannot agree with the base: everything the branch
inherited still names main, so every reference the remap rewrote read as
a difference. On a demo branch that was 364 of 368 reported conflicts,
and none of them was an edit anybody made. The comparison now runs in
main's frame, exactly as the compare view and the listing summary
already do, and the changes it produces are translated into the branch's
frame before they are applied and logged. That translation is the new
`branch_merge.cljc::remap-changes`, which is a separate function rather
than a call to `remap-refs` for a reason its docstring gives: `relink-refs`
finds a reference where the attribute and the value travel together, and
a change vector carries the identifier alone. The write map is renamed to
`apply-map`, so no later reader can reach for it as the comparison map.

The compare also decoded its merge base twice. The derive resolves and
decodes the pinned snapshot in order to fold the op log over it, and the
caller then decoded the same bytes again for the comparison's third
version, which is about 406 ms on the design-system file and is paid by
every uncached branch summary in a listing as well. The derive now
offers the pre-fold value it already holds, behind an explicit opt-in, so
an ordinary read gains neither a key nor a cost, and a branch read that
forgets the flag raises rather than comparing against nothing. The
invariant a comment now states is that the comparison's base is the
pre-fold value, which is sound because the fold is pure: `process-changes`
threads a value, every method builds persistently, and `PointerMap`'s
`assoc` and `without` return new objects, so the pre-fold base shares
structure with the folded result and costs no extra memory for the pages
the log did not touch.

The new test asserts both halves, because either alone passes a wrong
implementation. A branch carrying only inherited content, updated from a
main that changed elsewhere, reports no conflicts and leaves an empty op
log. Main and the branch repainting the same shape still reports exactly
one conflict, naming that shape.

Verified: the whole backend suite, 919 tests and 5,525 assertions with 0
failures.
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
b9ff74164e
🐛 Give the exit door a button, name what the counters count, and unswap the update resolver
Four defects the first by-hand pass found, and one of them was a lie the
copy told.

The exit door has a button. The limits dialog and three refusal messages
told a user that any branch can become an ordinary file, and nothing
under `frontend/src` called the command that does it. The kebab now
carries the action between Archive and Delete for an open branch, behind
a confirmation whose copy says what is kept and what is lost: the
content stays, and the file stops being a branch, so it can no longer be
compared with main or merged into main.

The counters say what they count. Both spans carry a title naming the
direction and the unit, which is entity-level changes across shapes,
pages, components, colours, typographies, media, and tokens, and stating
that saves are not counted. That reading is what made a jump from 7 to
420 look like a regression when the number was right.

The update resolver showed each side's values under the other side's
heading, and the cause was not a swapped label. The conflict payload is
role-keyed rather than document-keyed: `compute-merge*` runs the update
direction with the branch as `theirs`, and `three-way-entities` writes
`:main` for `theirs`, so an update hands the branch under the key named
`:main`. One binding re-keys each conflict's two sides, and the values
inside `:changed-attrs` with them, in update mode only. The headings,
the chips, and the resolution keywords then all speak documents, the
bulk buttons select the side they name, and the merge mode is unchanged
by construction rather than by inspection. The modal's fallback diff
fetch now also runs in the mode's own direction, so its key space
matches.

The two bars keep off each other. The banner publishes the room it takes
from the bottom edge as one custom property, measured from its live box,
and the palette lifts itself by that value. So the offset follows the
collapsed and the expanded state, and no literal is repeated in two
stylesheets.

Verified in the running frontend rather than by inspection: both
namespaces reload with no warnings, the re-keying is an involution on a
synthetic update conflict with base, id, reason, and label intact, the
real row component renders the new menu for open, archived, and merged
rows, and the confirm's accept path reaches the backend command with the
branch file id.
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
81a605f11d
✨ Report what the listing and the branch open cost, and bound the concurrency
Three of the preview's paths reported no duration, and two of them were
the slowest a user meets. A trial can be asked what a merge cost and
could not be asked what the panel cost.

The listing now passes through `audited` with two counters beside the
duration, being how many open branches it compared and how many of those
the summary cache answered, because a listing's cost is entirely
explained by that ratio. The vector result was the one risk and it holds:
a Clojure vector carries metadata, and nothing between the handler and
the audit step rebuilds the value.

A branch open now reports the derive's own time, its op-log depth, and
its batch count, from `branch-file-data` itself. Depth is what predicts
the cost, so the record carries it. An ordinary read never enters that
function, so it gains no line, no prop, and no timer.

A branch save gains the log line the other five print, and its timer
moved rather than gaining a caveat. It now opens before `get-file`, so
the number covers the read and, for a branch, the derive inside it, which
is what compare, merge, update and materialize have always covered. The
six figures are comparable for the first time.

The concurrency limits were decoration. Thirteen ids were declared across
the branch and pull-request commands and `climit.edn` configured none, so
`climit/wrap` logged a warning at every start and ran each handler
unwrapped, leaving the root chain's forty global permits as the only
bound on an operation that holds three realized documents. Every declared
id now has an entry, the two readers that realize whole documents gained
a declaration, and each number is justified in a comment against
`create-file-snapshot` as its anchor and marked provisional until a
peak-heap measurement replaces it.

The test helper drives the private save path directly, so it opens the
timer the RPC method would have opened.

Verified: 73 tests, 694 assertions, 0 failures across the three branch
suites and `rpc-file-test`.
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
8a3330f781
🐛 Compare a nil-valued attribute and an absent one as equal
A branch copy re-points its local references through the duplication pipeline
and leaves keys like :fill-color-ref-file and :typography-ref-file present with
nil where the source omitted them. Comparing the two forms as different made an
untouched entity look edited on one side, which becomes a modify-modify
conflict as soon as the other side moves the same shape.

The comparison now drops nil-valued keys deeply, for all three sides, in
three-way-entities and so for every pass that goes through it. The new engine
test failed before the change on both orderings and passes after; the three
branch suites report 30 tests, 245 assertions, 0 failures.
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
6259f2b375
🐛 Carry the command's conflicts into the resolution modal
The modal rebuilt its conflict list from a diff fetched separately, which is
computed in another id frame than the one the update command resolves against,
so an update could open a resolver showing 0 of 0 with an Apply that could
never enable. The conflicts a command returns are authoritative, because they
are what its own resolved? matches against: the modal now prefers them, keeps
them in the store, and the bulk actions resolve that same set.

Verified in a browser against preview-demo-loud without applying: 0 of 368
resolved with Apply disabled, then 368 of 368 with Apply enabled after Use
main for all. Main stayed at revn 28453 and the branch row did not move.
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
aad3719721
📚 Name the audited set, not every mutation on a branch
The sentence opened with every mutation and then listed three that are the
exception, which a reader has to reconcile. It now opens with the set that
does attach the three keys: the mutations through `audited` and a save routed
to a branch's op log.
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
c68833d209
📚 The audit props cover the mutations, not every command
The guide claimed the three keys for every branch operation. The three metadata
mutations and the three reads carry the generic RPC event alone, which records
who called what and no duration, so the section now says which is which.
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
f0d6852394
📚 Say which command attaches which audit props
The guide claimed the three audit keys for every branch operation through
`audited`, which was true of creation, compare, merge, update-from-main and
materialise. A save routed to a branch's op log attaches them on the
`update-file` event instead, because that is the props map the ordinary save
already replaces.
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
003ff8e771
🐛 Audit a branch save and a materialise like every other command
The preview's observability criterion is stated as every branch operation, and
two of them sat outside it. A save routed to a branch's op log carried only the
ordinary save props, and materialise carried none, so the two operations that
matter most to an enterprise trial were the two with no duration in the record.

Both attach `branch-operation`, `branch-outcome` and `branch-duration-ms` now:
`save-branch`/`saved` on the save path, `materialize`/`materialized` on the exit
door, including its idempotent no-op. The props travel in `::audit/replace-props`
on the save path because that is the key the ordinary save already replaces, so
the ordinary save's own props are untouched.

The test that asserts the audit props now covers a save and a materialise
alongside creation and merge. The three branch namespaces are 40 tests and 338
assertions, green.
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
e9833fdb55
💄 Clip the limits dialog to its panel and scroll the sections 2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
d4b0944589
💄 Scroll the limits dialog inside its container, group its digits 2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
7df1f9c876
💄 Bound the limits dialog height and group its numbers 2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
05d123aa62
🐛 Render the limits list through a component, not a hiccup helper
A helper returning [:li ...] hands React a raw CLJS vector, because rumext
compiles hiccup at macro-expansion time; React then renders the leading
keyword as a child and the whole workspace tree falls into the error
boundary (React #31). Found by opening the dialog in a browser.
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
a6e7972297
✨ Publish the preview's limits in the branch panel
Every refusal the branching commands can emit now has a line a user can
read: the three size gates name the limit they hit with the number the
backend sent, the scope refusals (no branch of a branch, a released merge
base, a change kind an update cannot replay, a branch whose state moved)
each get their own message, and a Branching limits dialog reachable from
the panel footer states them before anybody meets one. The three numbers
come from ::get-branching-limits, so the copy keeps no second copy of a
limit it could drift from.
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
264f1e930c
✨ Publish the size limits so the UI can state them before a refusal 2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
fcaf1945fe
📚 Name the three readers that share the summary cache 2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
74174deb0d
⚡ Share one branch summary cache across all three readers
The pull-request listing and the file-open info query each recomputed the
same [ahead behind conflicts] the branches listing had already cached, so
a file with ten open pull requests paid the whole cold cost the branch
panel no longer pays. All three now go through cached-diff-counts, keyed
on the same (base-snapshot-id, source-revn, branch-revn) tuple, and none
of them realizes main when every row it needs is cached.
2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
c78e6f6eea
📚 Describe the size gates and the per-operation audit record 2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
5d2dc63cf7
🐛 Audit every outcome of a merge and an update, not only the applied one
A no-op merge, a conflict refusal and an unsupported-kind refusal are
outcomes the first enterprise trial will ask about, and only the applied
path was carrying its duration.
2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
b09638957d
✨ Refuse instead of timing out, and record what each operation cost
Three gates, each a measured cost divided into a 30 s budget and halved:
50,000 shapes (a merge on 21,169 shapes costs about 6 s), 500 pages (a
whole-file comparison of 48 pages costs 402 ms), and 100,000 op-log
changes (one value-derived squash already emits about 19,000). Each
refusal is a :restriction naming the limit, the actual value and what to
do instead, and none of them truncates anything.

The listing never refuses: it renders the branch panel, and its cost is
bounded by the summary cache instead.

Every branch operation also attaches its duration and outcome to its own
audit event, because duration is the number the first enterprise trial
will be asked about and the generic RPC event does not carry it.
2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
8b714497b0
📚 Describe bounding the branch side, and where it does not apply 2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
6491570a31
♻️ Make both compute-merge arities land on one implementation
A 4-arity that delegates through the var counts as two calls to any test
that redefines it, which made a call-counting assertion measure dispatch
instead of comparisons.
2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
d7241c6f9a
✨ Prune the branch side of a diff to what its op log touched
The op log IS the delta between a branch and its merge base, so folding
validate/extract-affected-ids over it yields the exact set of pages the
branch can differ in. compute-merge takes :only-pages and bounds every
page pass to it, the presence pass included, which is where a whole-file
comparison spends its time.

The forward pass is bounded; the reverse one reports main's changes and
stays whole-file. The bound is dropped whenever the log holds a change
the reducer cannot scope to a page or a component (a deletion, page
order, a colour, a token), because for those the affected set is
incomplete and a bounded diff would hide work.
2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
7e623c4526
📚 Describe materialization, the preview's exit door 2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
0027f49347
📎 Assert the materialised payload as a main file_data row 2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
04c60331b6
📎 Assert materialization against the ordinary storage path 2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
bdcb979211
✨ Let any branch become an ordinary file
::materialize-file-branch persists a branch's derived state as the
file's own data payload, drops the op log, releases the pinned base
snapshot, closes any pull request over it and clears the branch marker,
so every read and write path takes the ordinary route from then on.
That is the preview's exit door: the flag can go off and the engine can
be replaced without stranding anybody with an unreadable file.

It keys on the branch file rather than the branch metadata, so calling
it twice is a reported no-op rather than an error.
2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
b6232edfd5
⚡ Keep the summary cache for a working day, not ten minutes
The fetch the cache exists for is a file open, and expireAfterAccess of
ten minutes threw the entry away between two sessions on the same file:
a listing measured at 5 ms was 26 s again fifteen minutes later. An
entry is a uuid and five numbers, so the window costs nothing.
2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
48f2f4c55f
🐛 Restore the app.config require the cache test needs 2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
cf50457e89
🐛 Restore the uuid require the cache test needs 2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
93cec8be7c
📎 Assert a repeat listing performs no comparison work 2026-10-06 10:36:54 +02:00