Skip to content

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

Merged
DavidBabinec merged 25 commits into
mainfrom
feat/site-branches
Sep 11, 2026
Merged

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

Conversation

@DavidBabinec

@DavidBabinec DavidBabinec commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Refs #440 (staging environments: branches are the in-app form of it; no separate database or subdomain)
Refs #430 (a public preview URL for a draft: branch preview links are revocable but do not expire)
Refs #361 (versioning: a page's published versions can be restored into the draft; no draft snapshots yet)

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.

Rebase and review pass (2026-09-11)

Rebased onto main (through #526); three conflicts, all where main had landed the relay cell fix (#504) and the stale plugin-asset sweep (#359) that this branch already carried. Review findings fixed on the branch:

  • Preview links go through applyPublishedHtmlPipeline (form tokens, module scripts, plugin hooks) like every visitor-facing page; a $ in a branch name no longer corrupts the banner. Architecture gate covers the preview.
  • No branch-id oracle: an anonymous request naming an unknown branch gets the route's 401, not a 404.
  • Undo replays in reverse apply order, so undoing a merge that created a table with rows no longer answers 409 merge_apply.
  • Only main evicts the render cache on page deletes, row moves, and status changes.
  • Review page: keyed on the branch id, leaves a deleted branch through one path (no double history entry), and fallBackToMain drops the branch from the registry in the same tick so the page cannot switch back onto it.
  • One viewport-unit resolver (@core/utils/viewportUnits) for the canvas and the review frames; the server copy matched inside url() and strings and treated uppercase units as vmax.
  • Featured media labelled from the field definitions; node change lines no longer round-trip a prefix; ages past a week read as a date; nowIso() replaces nine inline binds and the dialect doc states the rule; one content hash and one canonical JSON serializer (undefined now follows JSON).
  • A page node written outside the editor without a children list is parsed as a leaf instead of being dropped and persisted away.
  • MCP: a bridged content tool's ownership check reads the row in the branch the connected workspace has open.
  • @core/branches barrel exports only what is used; jsonEquals removed.
  • From the architecture review: an entry on a preview link now carries its SEO title and description into the head; restoreDataTable (the merge's revival of a deleted table) holds a post type's title and slug like every other write; the cookbook names collectBranchEntities instead of a snapshotScope that no longer exists. The per-kind entity adapter is queued as the follow-up PR.
  • Rows are named by one rule everywhere (readDisplayTitle: primary field, title, first text field, else Untitled; never the id), so a custom-table row no longer shows its id in the merge plan. New E2E BRANCH-006: a post written in the Content workspace on a branch stays off main until the merge lands it.
  • E2E: the editor step follows the strip's Request merge… to the review (the strip hides the button on the review page); REVIEW-001 registered in the matrix notes and the validation table.

Verification on the rebased branch: bun run build, bun test, bun run lint, and bunx playwright test tests/e2e/branches.e2e.ts tests/e2e/version-history.e2e.ts tests/e2e/branch-review.e2e.ts tests/e2e/content.e2e.ts. Postgres: migrations 027 to 029 applied on a fresh PostgreSQL 16 and the whole flow (fork, post and custom table on the branch, isolation from main, preview link, merge plan labels, merge with delete, undo on a kept branch, delete) ran clean against the Postgres-backed server with no server errors.

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
CodeQL flagged getDataRow as imported and never used in
branchReview.test.ts. Neither tsc nor eslint checks unused
imports in test files, so the bot was the only gate that saw it.
A branch preview rendered its page and injected plugin frontend assets
by hand, skipping the rest of what a published page gets: form page
tokens, the module-JS script tags, and the publish hooks. A form on a
preview link was a dead HTML form and a module with client JS was
inert. The preview now feeds applyPublishedHtmlPipeline like every
other visitor-facing HTML path, and the architecture gate covers it.

The banner is spliced in with a function replacement: a branch named
"Q4 $` promo" used to paste the whole document into the banner, since
String.replace reads $ patterns in a string replacement.

The pipeline's input type no longer carries the CSS bundle, which only
the bake needs. The three template helpers the preview and the review
render used come through the @core/templates barrel.
An unauthenticated request naming a branch in X-Instatic-Branch got a
404 for an unknown id and a 401 for a real one, which let anyone probe
branch ids. resolveBranchScope now checks for a session before it
answers 404, so a guessed id reads like a real one; the 404 with its
code is still what a signed-in tab falls back on.

Undoing a merge that created a table with rows replayed the recorded
entries in apply order, so the table's before-image (absent) was
restored while its rows were still there and the repository refused
with 409 merge_apply. Undo replays in reverse apply order.

Deleting a page, moving a row, or retracting a row on a branch bumped
main's render cache version; only main's routes are served, so only
main's writes evict it now.

The branches route group took a scope it never read.
The review page kept the tab on the branch it shows. When a request
from the page found the branch deleted, the store dropped the tab to
main and started a re-read, and the page switched straight back onto
the deleted branch while the registry was stale. fallBackToMain now
drops the branch from the registry in the same tick. The page is keyed
on the branch id so what it learned about one branch does not decide a
link to the next, and a merge that deletes the branch leaves through
that one path instead of pushing /admin/site twice.

The merge plan fetch takes the abort signal too, and a failed refresh
of a review already on screen no longer replaces it with the error
state.

The server-side viewport-unit rewriter for the review frames was a
weaker copy of the canvas scanner: it matched inside url() and quoted
strings, treated an uppercase unit as vmax, and missed vi/vb. One
resolver now lives in @core/utils/viewportUnits and serves both frames;
the review render applies it to every style block and attribute.

Field changes name built-in fields from the field definitions (the
featured media cell was keyed by the wrong id and showed raw), the node
change details omit a prop that is the node's own label instead of the
client stripping the prefix back off, and every "n ago" label shares
one helper, so an age past a week reads as a date rather than
"9/1/2026 ago". The frame width follows REVIEW_VIEWPORT, the swipe
handle stays inside a short stack, and the page's round shapes use a
--radius-pill token.
nowIso() in @core/utils/isoDate is the one place that says why a
displayed timestamp is bound as ISO text rather than SQL
current_timestamp; the branch repositories use it and the dialect doc
states the rule. The publish snapshot hash and the branch content hash
were the same function twice; the site import's stableStringify was
canonicalJson again. canonicalJson now treats undefined the way JSON
does, so an in-memory value hashes like its stored form.

A page node written outside the editor with no children list was
dropped by the parser and the pruned tree written back by the relay;
a missing list is an empty one now.

@core/branches exported jsonEquals, which nothing called, and two dozen
schemas and types nothing outside the module imported.
Browser-bridged content tools act in whatever branch the connected
workspace has open, but the per-row ownership pre-check read the row
from main: a document that exists only on the branch was "not found",
and one whose author differs between main and the branch was judged by
main. The bridge records the workspace's branch from the request header
when the stream opens, and the pre-check reads the row there.
The review polish hid the strip's merge button on the review page and
moved the request into the footer; the editor step still expected the
strip button there. The step now follows the real path: the strip
reads "Request merge" for an editor without merge rights, lands on the
review, and the footer carries the request.

The feature matrix gains the REVIEW-001 note and the validation table
names the spec; the branch rows record today's run.
…book

A branch preview of an entry ignored the row's SEO title and
description: the public renderer passes them as documentMeta and the
preview did not, so the head showed the plain title. The preview
resolver now carries the override and the preview head matches a
published page.

restoreDataTable, the merge engine's path for a table the target side
had deleted, normalized the incoming field list without the hold every
other write applies: a post type revived with only its custom fields
lost title and slug and its entries lost their routes. It reads the
stored table first and holds the mandatory built-ins the way a PATCH
does.

The "add a new branched table" cookbook pointed at a snapshotScope that
no longer exists; it names collectBranchEntities now.
…h E2E

The merge plan named a row by its primary field or title and then fell
back to its slug and finally its id, so a row in a custom table without
a primary field showed up in the review as "row create uq5VO9VG...".
The Content explorer, the AI document list, and the Data delete prompt
each had their own version of the same rule.

readDisplayTitle in @core/data/cells is now the one rule: the table's
primary field, else the title, else the first text field with a value,
else "Untitled"; never the id or the slug. The merge entities, the
Content explorer, the AI bridge's document list, the Data delete prompt,
and the duplicate-row title all read it. The undo-with-rows test pins
the label of a title-less row.

BRANCH-006 drives the Content workspace on a branch: a post saved there
is absent on main, the review names it by its title, and merging with
delete lands it on main's Content list. startNewPost, saveSelectedDraft,
and entryRow moved from the Content spec into tests/e2e/helpers/content
so both specs share them. Registered in the feature matrix and the
validation table.
@DavidBabinec
DavidBabinec marked this pull request as ready for review September 11, 2026 19:51
@DavidBabinec
DavidBabinec merged commit 05555c3 into main Sep 11, 2026
11 checks passed
@DavidBabinec
DavidBabinec deleted the feat/site-branches branch September 11, 2026 19:52
@DavidBabinec
DavidBabinec restored the feat/site-branches branch September 11, 2026 20:02
@DavidBabinec
DavidBabinec deleted the feat/site-branches branch September 11, 2026 20:02
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