Skip to content

feat(branches): site branches with preview links, three-way merge, and version restore - #484

Draft
DavidBabinec wants to merge 16 commits into
mainfrom
feat/site-branches
Draft

feat(branches): site branches with preview links, three-way merge, and version restore#484
DavidBabinec wants to merge 16 commits into
mainfrom
feat/site-branches

Conversation

@DavidBabinec

@DavidBabinec DavidBabinec commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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.

  • Every content row, table, and the site shell carry a branch_id; main is the live site. Admin requests name the branch in the X-Instatic-Branch header, collab documents in their doc ids.
  • Toolbar chip + context strip: create, switch, rename, delete, share a revocable preview link, update from main. Spotlight commands for all of it.
  • Merge review page (/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.
  • Version history: list a page's published versions and restore one into the draft.
  • Publishing, scheduling, public routes, forms, plugins, dashboard, and MCP reads stay pinned to main. Site export follows the branch.

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.create forks a branch and covers the branches you forked: rename, delete, update from main, share or revoke a preview link.
  • site.branches.manage covers 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_branches migration seeds the new capability next to the old one in both dialects, and createTestDb now runs syncSystemRoles after 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

  • Users: new branch controls in the Site, Content, and Data workspaces; publish controls disable on a branch with the reason inline; merges go through the review page.
  • Developers: repositories on site, data_tables, and data_rows take a BranchScope; 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 through parsePageNode on load. Migrations 026 and 027 are additive (027 adds site_branch_merge_requests and site_branch_review_comments).
  • Client provider: local updates are held until the server names the document lineage, which removes a stale-reset race on row creation.
  • Review render responses are text/plain with a sandbox CSP and load into sandboxed srcdoc frames; plan reads redact rows the reader cannot see.

Verification

bun run build
bun test
bun run lint
bun run doctor
bunx playwright test tests/e2e/branches.e2e.ts tests/e2e/version-history.e2e.ts tests/e2e/branch-review.e2e.ts

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:

  • Highlights — template composition prefixes node ids (c0_, t<i>_); the review now maps every rendered uid back to its page node through composedNodeSourceId (@core/templates). A node inside a loop is outlined once per item.
  • Swipe drags (pointer drag on the stack, grab handle at the divider; the range keeps the keyboard path).
  • Both sides read drafts in loops (SourceFetchContext.drafts), so a post-type loop no longer renders on the branch side only (main's imported rows have no published versions).
  • What changed — per-node details from the tree diff (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.
  • Undo — every apply is recorded in site_branch_merges (migration 029) with before-images; POST …/merge/undo and …/update/undo reverse the latest apply behind the same gates + step-up, refused with 409 merge_undo when 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).
  • Confirmations before merging into main and updating from main (useConfirmAction; the confirm primitive gains a primary tone).
  • Strip button reads Review merge… and hides on the review page; the review's footer carries the merge.
  • BranchReviewPage footer extracted to ReviewFooter.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: ConfirmDeleteProvider is 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 local current_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 a 62vh hero is as tall as on a screen instead of growing with the document-tall frame.

Comment thread src/__tests__/server/branchReview.test.ts Fixed
…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.
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant