feat(branches): site branches with preview links, three-way merge, and version restore - #484
Draft
DavidBabinec wants to merge 16 commits into
Draft
feat(branches): site branches with preview links, three-way merge, and version restore#484DavidBabinec wants to merge 16 commits into
DavidBabinec wants to merge 16 commits into
Conversation
…d version restore Every content row, table, and the site shell now carry a branch: main is the live site, and a branch is a private fork edited through the same editor, addressed by the X-Instatic-Branch header on every admin request and by branch-qualified collab doc ids. Branches are created, switched, renamed, and deleted from a toolbar chip and a context strip; a branch can be shared through a revocable preview link, updated from main, and merged into main through a three-way review that surfaces conflicts per field. Published versions of a page can be listed and restored into the draft. Publishing, scheduling, public routes, forms, plugins, the dashboard, and MCP headless reads stay pinned to main. The collab relay refuses documents for deleted branches, keeps its invalidation bookkeeping per branch, and reseeds a branch from its rows if a delete fails after it was tombstoned. Verification: bunx tsc -b, bun test, bun run build, bun run lint, and Playwright tests/e2e/branches.e2e.ts + tests/e2e/version-history.e2e.ts.
…ads and requests Review a branch before merging: files become merge entities, the plan carries per-change detail (fields, page tree diffs, file text), merge requests and comments live on the branch, and /admin/branches/:id/review shows one timeline per change with before/after page renders whose highlights come from the tree diff.
Highlight labels read as Changed/Added/Removed with the node name when it has one, short pages get short frames, the old merge dialog is now UpdateBranchDialog (merging lives on the review page), BRANCH-005 merges from the page, branches and audit docs describe the review.
- Plan reads redact rows the reader cannot see; file path collisions become conflicts; render responses are text/plain with a sandbox CSP. - One open request per branch (partial unique index), ISO timestamps, closed states behave the same for everyone. - Page rows written outside the editor parse through parsePageNode, so the collab seeder never meets a node without its maps (the 'change reverted' storm), and the review compares nodes as the editor loads them; node labels come from the editor name. - Review frames re-measure on mark changes without touching refs in render; the layout is imported through its barrel (one chunk).
- Rows of borderless surface tiles on the workspace canvas, card radius, 1px-gap tile groups instead of dividers; state and kind badges are TagPills; the facts sit in one row on wide screens. - TagPill gains a state tone (success, warning, danger) that keeps the gradient tint but colours it from the semantic tokens. - The E2E home seed is exact, so a reused database cannot skew the diff.
`when` does two jobs: it hides a command when false AND scores +250 when true, so a genuinely contextual predicate (you have a selection, an undoable edit) lifts the command above one that merely matches the query. Site branches used it as a plain environment gate on Publish (`isOnMainBranch()`), which on main is true essentially always. That standing +250 beat the +150 recency boost and pinned Publish to the top of the empty palette, so a recently run command never floated up again. Splits the two ideas: `available` hides without scoring, and the two environment gates (publish on main, branch actions off main) use it. Every remaining `when` is contextual, which is what the boost was built for.
Main landed `026_plugin_media_sources` (#487) while this branch was open, so both sides claimed 026. Since neither of these has shipped, the branch moves up rather than main: site branches becomes 027 and the merge review 028, in both dialect files, with the feature doc following.
DavidBabinec
force-pushed
the
feat/site-branches
branch
from
September 5, 2026 09:40
5c3f9e3 to
36f825e
Compare
The review header phrases the request stamp as "<name> requested <stamp> ago", but formatRelativeTime answers "now" under a minute, so every freshly opened request read "requested now ago" until the clock ticked past sixty seconds. That is the exact window a reviewer opens the page in. relativeIso stays the bare stamp for the columns that render one on its own; a new relativeIsoAgo owns the past-tense phrasing and answers "just now" for the sub-minute case. Unit test covers both.
One capability, site.branches.manage, used to cover both forking a branch
and merging it into main. Forking is additive and private; merging
rewrites main's drafts. Bundling them meant a contributor could never
fork a branch and ask for review without also being able to land it, so
the showcase persona had to hold merge rights it was not supposed to use.
site.branches.create now forks a branch and covers the branches the user
forked: rename, delete, update from main, share or revoke a preview link.
site.branches.manage keeps its name and covers every branch, plus the two
gatekeeper acts that touch the live site: merge into main and decline a
merge request. It does not fork on its own. Owner and Admin hold both.
The rule lives once, in src/core/branches/access.ts (canActOnBranch,
canMergeBranches), and both the server gates and the admin UI use it, so
a control is never offered and then refused: the chip's Create, the
strip's share/update/rename/revoke/delete, the manage dialog's per-row
rename/delete (disabled with the reason when out of reach), and the
Spotlight commands. The merge/update handler gates by direction. The
branch registry already recorded created_by_user_id, so no schema change.
The unshipped 027_site_branches migration now seeds the new capability
next to the old one in both dialects, and createTestDb runs
syncSystemRoles after migrations the way boot does, so system roles in
tests come from code rather than the seed snapshot. Without that the
Owner in every capability test was frozen at the seed and forking 403'd.
Docs: capabilities reference (counts corrected to the real array length,
they had drifted to 36/38/39), the branches feature doc, picker labels.
Verification:
bun test cmsMigrations + migration-parity 9 pass
bun test branches handler/preview/merge, access rule,
picker coverage, handler-gate, spotlight 42 pass
bun run build tsc + vite clean
bun run lint clean
bun test (full) 6909 pass, 0 fail
Fixes the issues found testing the merge review on a real templated site:
- Highlights never appeared: template composition prefixes every node id
(c0_ for the page, t<i>_ per outer template), and the review matched
bare ids. composedNodeSourceId in @core/templates maps a rendered uid
back to its page node; PageCompare resolves every uid through it and
outlines a loop node once per item.
- Swipe did not drag: pointer drag anywhere on the stack, a grab handle
at the divider; the range keeps the keyboard path.
- A loop section rendered on the branch side only: loops on main read
published versions (imported rows have none), the branch read drafts.
SourceFetchContext.drafts is an explicit switch; the review passes it
for both sides, because a merge compares drafts with drafts.
- "What changed" was thin and wrong: per-node details from the tree diff
(text: "old" -> "new"; structured props and node fields by name), field
labels for SEO title, SEO description, featured media, cleared fields
keep the old value in view, and empty cells (absent, null, "") compare
equal so rows written by different paths never read as changed. The
SEO loss itself is the relay bug fixed in fix/relay-row-cells.
- Undo: every apply is recorded in site_branch_merges (migration 029)
with each entity's before-image on the target, the branch, and the
base. POST .../merge/undo and .../update/undo reverse the latest apply
behind the same gates and step-up; refused with 409 merge_undo when
the target moved since. Undoing a merge reopens the request it
answered. The review footer shows Undo merge while lastMerge is set and
the success toast carries an Undo action; the page stays on the review
after a merge. Delete branch after merging now defaults to off because
a merge that deleted the branch cannot be undone.
- Confirmations before merging into main and updating from main:
useConfirmAction on the confirm primitive, which gains a primary tone.
- The strip button only opens the review, so it reads Review merge...
and hides on the review page, whose footer carries the merge.
- BranchReviewPage footer extracted to ReviewFooter.tsx (700-line budget).
Verification:
bun run build clean (tsc + vite)
bun run lint clean
bun test full suite, all pass (incl. new
merge undo + HTTP gate tests and the
relay cell-merge regression)
…review can ask Testing the merge on the showcase skipped the new confirmation: the review's Review component owns the confirm hook and renders the workspace layout itself, and ConfirmDeleteProvider was mounted inside that layout (and again inside the editor body), so the hook found no provider and fell back to committing at once. One provider now wraps the whole admin in AuthenticatedAdmin, inside StepUpProvider; the two layout-level copies are gone. docs/editor.md and docs/features/editor-preferences.md describe the placement. Also from the same test pass: - The change list read "Changed text: text: ...": the diff names the prop in full and the node label was the same word. changedNodeLine in reviewFormat.ts is the one formatter for both the compare and the change card, and drops a prop name the label already says. - The swipe's pointer release closes over nothing; it lives at module scope as releaseSwipePointer. Verification: bun run build clean bun run lint clean bun test full suite, all pass headless Chromium on the showcase: a real pointer drag moves the swipe divider (--split 50% -> 72%, capture held across moves)
…py and handle Three site_branches / site_branch_previews writes bound SQLite's current_timestamp, a space-separated local-time string that Date.parse reads as local time, so a branch merged a second ago read "updated 2h ago" in the strip and the palette. touchBranch, the rename update, and the preview revoke now bind new Date().toISOString(), like every other branch write (the same fix the merge requests got earlier). Also from the end-to-end pass on the showcase: - The merge confirmation read "1 change land"; it agrees in number now. - A page frame is far taller than the viewport, so a swipe handle at half the stack's height was usually off screen. It sits 180px from the top, in the first screen, with the divider line still full height. Verification: bun run build clean bun run lint clean bun test (branch server suites, dialogs, architecture) all pass headless Chromium on the showcase: merge with confirm and step-up, undo with step-up, main's draft restored, branch kept its edit
A tab remembers its branch in sessionStorage and sends it as X-Instatic-Branch on every admin request, the sign-in included. The CMS dispatcher resolved that header before any route group ran, so once the branch was gone (deleted from another tab, or the database reset under the tab) every request answered 404 branch_not_found, the login form showed "Branch "staging" does not exist", and nothing could recover: the fallback that drops a tab back to main lives in the authenticated branch store, which never loads before sign-in. The account groups (setup, session, login, preferences, users, roles, audit) now run before the branch header is looked at. They hold no branched data, and signing in is exactly how such a tab recovers: the first content request after it answers branch_not_found and the store's existing listener switches the tab to main with its notice. A branch's existence is also no longer revealed before authentication. Regression test in branchesHandler.test.ts: setup status, session, and a failed login all ignore a stale header, while the branches list still refuses it with the code the client falls back on. docs/features/ branches.md describes the order. Verification: bun run build clean bun run lint clean bun test (branch, auth, architecture suites) all pass headless Chromium on the showcase: a tab with a deleted branch stored signs in, lands on main with the "Branch no longer exists" notice
…ranch is gone Deleting a branch from the strip while standing on its merge review left the page announcing "There is no branch" although the tab had already dropped back to main: the route still named the deleted branch. The review now leaves for the site editor whenever its branch disappears after having loaded, however it went (dropped from the footer, deleted from the strip or another tab, the database reset under it). A link to a branch that never loaded keeps the message and gains a Back to the site button instead of being a dead end. The footer gains Drop changes for whoever may delete the branch (a manager, or its creator): the same DeleteBranchDialog and step-up as the strip's Delete branch, so discarding a reviewed branch is one action from the place the decision is made. Verification: bun run build clean bun run lint clean bun test (architecture, dialogs) all pass headless Chromium on the showcase: Drop changes from the review lands on the site editor on main with the Deleted toast; a dead review link shows the message and the way back
…desktop screen The review shows a page in an iframe as tall as the document, so the page can be seen whole and scaled. That frame has no screen height of its own: a hero set to 62vh measured itself against the document, grew it, and was measured again, up to the frame's 2400px ceiling. Setomi's home page showed a hero three times taller than on the live site. The render endpoint now resolves every viewport unit (vh, vw, vmin, vmax, with the d/s/l prefixes) in the page's style blocks and style attributes against REVIEW_VIEWPORT (1280 x 800, shared from @core/branches with the frame's width), which is what a desktop screen does with the same rules; the frame shows that screen's rendering, captured full length. Names such as --gap-1vh and text that mentions a unit are left alone. Unit tests cover the block, the attribute, calc(), the prefixed units, names, and text. docs/features/branches.md describes it. Verification: bun run build clean bun run lint clean bun test (review, branches handler, architecture) all pass headless Chromium on the showcase: the Setomi hero measures as on a desktop screen in both frames
The page frames were capped at 2400px of document, so a change at the bottom of a long page was out of the frame: Setomi's home page is 4288px tall once its viewport units resolve as on a screen. The frame is now as tall as the page; the remaining ceiling (16000px) only guards against a runaway layout, a document that keeps growing as it is measured. The two columns of the side-by-side mode share one scroll with the same top, so positions still line up. Verification: bun run build clean bun run lint clean bun test (architecture) all pass headless Chromium on the showcase: both frames of a changed home page measure the page's full height
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Site branches: a private fork of the whole site (shell, tables, rows, files) that is edited in the same editor, reviewed, and merged back into main.
branch_id; main is the live site. Admin requests name the branch in theX-Instatic-Branchheader, collab documents in their doc ids./admin/branches/:id/review): every planned change with its detail, pages rendered before and after with the changed, added, and removed nodes outlined from the plan's tree diff, field and schema diffs, file line diffs, a comment thread per change, and a request → review → merge flow. Editors without merge rights request a merge with a note; branch managers decline with a note or merge with a step-up, deciding each conflict inline. Merging deletes the branch when asked and writes only main's draft; nothing is published.Feature doc:
docs/features/branches.md(including the "Merge review" section).Two capabilities, not one
Forking is additive and private; merging rewrites main's drafts. They are gated separately:
site.branches.createforks a branch and covers the branches you forked: rename, delete, update from main, share or revoke a preview link.site.branches.managecovers every branch, plus the two acts that touch the live site: merge into main and decline a merge request. It does not fork on its own.Owner and Admin hold both. The rule lives once, in
src/core/branches/access.ts(canActOnBranch,canMergeBranches), and the server gates and the admin UI both use it, so a control is never offered and then refused: the chip's Create, the context strip's actions, the manage dialog's per-row rename and delete (disabled with the reason when out of reach), and the Spotlight commands. A contributor can fork, work, and ask for review without being able to land anything, which is the persona the review page was built for.The unshipped
027_site_branchesmigration seeds the new capability next to the old one in both dialects, andcreateTestDbnow runssyncSystemRolesafter migrations the way boot does, so system roles in tests come from code rather than the seed snapshot.Why
Editors could not stage a redesign or a content campaign without it going live piecemeal, and had no way to hand a draft to someone outside the admin for review. Without the review page a merge was a blind dialog, so the feature ships with the review as its merge path.
Impact
site,data_tables, anddata_rowstake aBranchScope; site files are merge entities with path-collision conflicts; the collab relay refuses documents of deleted branches and reseeds a revived branch from its rows; rows written outside the editor parse throughparsePageNodeon load. Migrations 026 and 027 are additive (027 addssite_branch_merge_requestsandsite_branch_review_comments).text/plainwith a sandbox CSP and load into sandboxedsrcdocframes; plan reads redact rows the reader cannot see.Verification
The branch-review spec runs the whole flow across two accounts (owner and an editor persona without merge rights) on a fresh database.
Review polish (2026-09-05)
Fixes found testing the merge review on a real templated site:
c0_,t<i>_); the review now maps every rendereduidback to its page node throughcomposedNodeSourceId(@core/templates). A node inside a loop is outlined once per item.SourceFetchContext.drafts), so a post-type loop no longer renders on the branch side only (main's imported rows have no published versions).text: “old” → “new”), proper field labels (SEO title, SEO description, featured media), cleared fields keep the old value, and empty cells (absent /null/"") compare equal. The SEO loss itself was a relay bug: fix(collab): keep the cells a doc does not own when the relay persists a row #504.site_branch_merges(migration029) with before-images;POST …/merge/undoand…/update/undoreverse the latest apply behind the same gates + step-up, refused with409 merge_undowhen the target moved since. The footer shows Undo merge, the success toast carries Undo, the page stays on the review after a merge, and Delete branch after merging defaults to off (a merge that deleted the branch cannot be undone).useConfirmAction; the confirm primitive gains aprimarytone).BranchReviewPagefooter extracted toReviewFooter.tsx(700-line budget).Verification:
bun run build,bun run lint,bun test(full suite) clean; new server tests cover the undo (both sides + base restored, refused after main moved, HTTP gates + 409) and the relay cell-merge regression.Follow-ups from the end-to-end pass on a staged showcase:
ConfirmDeleteProvideris mounted once at the admin root (the review rendered its layout from above the provider and silently skipped the confirmation);touchBranch, rename, and preview revoke bind ISO timestamps instead of SQLite's localcurrent_timestamp(a just-merged branch read "updated 2h ago"); the change list no longer repeats a prop name the node label already says; the swipe handle stays in the first screen.A tab that remembered a since-deleted branch could not sign in again (the dispatcher resolved the branch header before the account routes; the login answered
404 branch_not_found). Account routes now run before the header is looked at; the store's existing fallback drops the tab to main after sign-in.The review footer gains Drop changes… for whoever may delete the branch (same dialog and step-up as the strip), and the page leaves for the site editor whenever its branch disappears after loading; a dead review link keeps the message with a Back to the site button.
Review frames resolve viewport units against a fixed desktop viewport (
REVIEW_VIEWPORT, 1280×800) server-side, so a62vhhero is as tall as on a screen instead of growing with the document-tall frame.