cwp content push
shipped 1.0.0cwp content push [env] [flags]
This name is an alias. The work moved to cwp push --only content, and that page documents it. This spelling still runs for one minor cycle.
Acts on the local site. Name an environment to act there instead.
| Argument | What it is | Default |
|---|---|---|
[env] | environment to write (default: default_environment in cwp.yml) |
| Flag | What it does | Default |
|---|---|---|
--post <selector...> | slug, post id or URL, repeatable | |
--type <post_type> | restrict to one post type | |
--all | every post in scope | off |
--force | override the protected-environment refusal | off |
--no-backup | skip the remote backup taken before the write | on |
--yes | skip the confirmation prompt | off |
--overwrite-conflicts | overwrite posts that changed on the target since cwp last saw them | off |
--delete | with —all: trash posts on the target the tree no longer describes | off |
--delete-terms | with —all: remove terms of a declared taxonomy the tree no longer describes | off |
--skip-missing-targets | leave out menu entries pointing at things the target does not have | off |
--publish | publish a post that does not exist on the target yet | off |
--no-verify | skip reading every written post back to compare it | on |
--status <status> | set the status of every pushed post, new or existing | |
--media <mode> | attachments the target lacks: upload them, or require them to be there already | upload |
--with-agent | with —dry-run on a host with no shell: install and remove the PHP agent so the plan is real | off |
Plus the shared flags --json, -v, --verbose, -q, --quiet and --dry-run.
What it does
Sends named posts from the committed tree to a site. You select them with
--post, --type or --all. cwp lists every affected post before it writes.
Terms are upserted before any post
A push sends the terms the tree describes first. It matches them by uid. A rename therefore arrives as a rename and not as a new term beside the old one. A parent goes before its children.
The order is the feature. It is the reason the step lives inside
cwp content push and not in a command of its own. A consolidation is a
sequence:
- the survivors exist, renames applied;
- every post now points at one of them;
- the ones referenced by nothing go.
A separate command would let somebody run those steps in the wrong order. Step 3 is only safe after step 2 has happened.
cwp creates a term the tree describes and never one it only saw referenced.
A slug in a post’s terms with no term file behind it gets a report and no
invented term.
cwp refuses a push whose target does not register a declared taxonomy. Posts
that reference terms the target cannot create would arrive with their
categorisation silently missing. The fix is a cwp deploy.
cwp refuses a term that changed on the target the way it refuses a page.
--overwrite-conflicts is the same way through:
✗ 1 term(s) changed on "prod" since cwp last saw them
→ take their version with `cwp content pull prod --all`, or overwrite it with
--overwrite-conflicts — the changed ones are listed above
Renaming a tag in the WordPress admin is a real edit. The marker on the term lets a push tell it from a rename made in the tree. A term the target does not have yet is no conflict but an ordinary first push. A term the target holds under the same slug and the same name without ever having met cwp is no conflict either: cwp adopts it. The comparison leaves identity out of the hash. An adoption therefore cannot read as an edit.
The featured image resolves to the target’s own copy
A _thumbnail_id is an attachment id, and an id means a different picture on
every install. The tree holds the upload path instead: media:2026/07/hero.jpg,
the form an image inside a builder payload already uses. Each side resolves it
to its own number.
content.<group>.meta.attachments declares the
keys this applies to. Nothing can infer them: a bare string holding an integer
looks the same whatever the key means. _thumbnail_id is the default.
cwp uploads a featured image the target does not have like any other picture.
--media require refuses it, and cwp refuses outright when the image is on
neither side. This works on a project with no page builder at all: the upload
is the content shim’s own operation, not a builder ability.
An image is recognised by its name, not by the month it was uploaded
cwp identifies an attachment by its upload path, 2026/07/portrait.jpg. The
path carries the month somebody uploaded the file, and the month is no property
of the image. The same picture therefore sits under 2026/07 locally and under
2026/08 on the target.
The match runs in three steps:
-
The exact path. cwp reports nothing; this is the normal case.
-
The same filename, anywhere in the uploads directory. cwp adopts one such attachment. The plan names the path the target uses and how cwp recognised it:
byteswhen the file sizes agree,namewhen the filename was all there was to go on:already on prod under another path — would be adopted ✓ 2026/07/portrait.jpg #12798 2026/08/portrait.jpg bytes -
Several files of that name. cwp refuses and names every candidate with its id and path. Picking one is a decision for the person watching.
Two things keep step 2 rare. An upload files under the tree’s own directory and
not under the current month. The exact path then matches on the next push. And
the tree records each attachment’s size on pull. The size confirms a match when
the file is not on this machine, the normal case under
pull.uploads: proxy.
cwp reads an adopted attachment under the tree’s path everywhere else.
cwp content status therefore does not report a page as
changed when its image lives under a different month there.
An image’s title, alt text, caption and description travel with it
WordPress keeps four editorial fields on an attachment. The tree records all four:
media:
2026/07/hero.jpg:
alt: A sentence describing the image for people who cannot see it
caption: A short line printed under the image
description: |
Where this crop comes from and what it is for.
filename: hero.jpg
path: 2026/07/hero.jpg
title: The image, named
caption and description appear only when the site has them. An absent
key means the tree has no opinion about that field, and a push leaves the field
on the target as it is. A caption written on the target therefore survives a
push that says nothing about it. Clearing a field locally does not clear it
there. The place to empty a caption is the site that holds it.
Where the tree carries a field and the target holds something else, the tree wins. The plan names every such field with both values before cwp writes anything:
would be overwritten on prod
! 2026/07/hero.jpg caption (empty) A short line printed under the image
No conflict check stands behind this the way one does for a post. cwp does not hash media. It cannot tell a caption nobody touched from one written on the site an hour ago. It names the change before making it.
An attachment the target does not have yet arrives with all four fields set by the upload itself.
A child page hangs under its parent
The tree records a page’s parent by uid. The push resolves it against what the target holds, including pages this same run is creating. The pages therefore go up parent-first. A whole subtree travels in one push, and a child pushed on its own hangs under a parent that has been on the target for months.
cwp names rather than guesses a parent that is under no cwp identity:
! page/beratung — the parent is neither in this run nor on prod under a cwp
identity, so the page keeps whatever parent it has there
cwp writes the page and leaves only its post_parent alone. This is deliberate.
It is also a limit: the committed form spells “no parent” and “a parent cwp does
not manage” the same way. A page moved to the top level in the tree does not
travel. Sending a zero would flatten every page whose parent never had a uid.
—delete-terms removes what the tree stopped describing
✓ term removed — post_tag/alte-aktion
cwp refuses to remove a term anything still carries, and no flag gets past that:
✗ refusing to remove 1 term(s) still in use on "prod": post_tag/workshop (12 post(s))
The count is WordPress’s own and covers every post of every type, including the ones the tree deliberately does not manage. A tag can sit on an event this project never carries. A count over managed posts alone would report zero and delete a term still in use. The way through is step 2 of the consolidation: re-tag those posts, push that, then run the deletion.
Removals run after the post writes. The confirmation names each term with the count that made it safe to go.
A deletion travels with —delete, and it trashes
The tree declares what a site should hold. Without --delete it cannot declare
an absence: removing a page’s file changes nothing anywhere.
With --delete, cwp moves a post the tree no longer describes to the trash:
✓ trashed — #42 alte-aktion
Trash, never permanent deletion. Here the content transport is safer than
cwp bricks push --delete. A deleted global class is
gone. A trashed post keeps its content, its identity and its URL history, and
WordPress can restore it. cwp offers no flag for the permanent kind.
Three rules decide what is in scope:
-
A post is only in scope if cwp adopted it. The uid gives an absence its meaning. A post cwp pulled was described by the tree and is not any more. A post nobody ever pulled was never described at all.
cwp content statusreports the second asunmanaged, and--deletenever touches it. -
--deleteneeds--all. The comparison is the whole tree against the whole target. Naming posts would make the run mean something other than what you typed. cwp refuses the combination rather than narrowing it. -
The confirmation names every page with its URL. A page has visitors and inbound links where a global class has neither:
--delete: these 2 post(s) WILL BE TRASHED (recoverable from the WordPress trash): page/alte-aktion — https://example.com/alte-aktion
Trashing runs after the writes. A page that moved from one group to another therefore lands under its new file instead of going to the trash in the same run. The run names a post it could not trash with the reason and says the mirror is partial rather than claiming to have mirrored.
The navigation travels whole, after the pages
A group with menus: configured sends its menus
at the end of the run. The order matters here too. An entry may link to a page
this same push is creating. A menu cannot resolve a page that does not exist
yet.
cwp writes a menu whole. The file describes the navigation, so cwp removes an entry the file no longer carries from that menu on the target. Unlike a trashed page, that removal is permanent: a menu entry is a pointer with no content of its own. The confirmation says so before anything happens:
and write 2 menu(s), replacing their entries on the target:
primary (7 entries)
footer (3 entries)
cwp refuses an entry that points at something the target does not have:
✗ 2 menu entries point at things "prod" does not have: primary → page/termine,
footer → page/presse
→ nothing has been written. Push the pages they point at in the same run,
create them there, or drop those entries with --skip-missing-targets
Pages this same push creates count as present. The first push to a fresh
environment therefore carries a menu and the pages it links to together.
--skip-missing-targets leaves out exactly the entries named, no wider, and
names each one again in the report. Agreeing that a menu may arrive incomplete
is not the same as knowing which part is missing.
cwp refuses a menu that changed on the target since it last saw it, the way it
refuses a page. --overwrite-conflicts is the same way through. The marker sits
on the menu’s term. The comparison therefore covers the whole navigation and
not any one entry. A menu rearranged in the WordPress admin shows up there.
Theme locations travel with the menu. cwp writes locations: [primary] from
the file into the theme’s nav_menu_locations and touches only the assignments
concerning that menu. cwp reports a location the active theme does not register
and does not invent it.
Menus travel with --all only. cwp reports a menu on the target the tree does
not describe and leaves it alone.
The element that displays a menu follows it
A navigation element on a page stores which menu it shows. The builder stores that as the menu’s term id, a number that means a different menu on every environment. The tree carries the slug instead, and the push resolves it against the target:
settings:
menu:
kind: menu
slug: primary
cwp refuses a menu the target does not have, before it writes anything:
✗ 1 menu(s) a page displays are not on "prod" and are not in this run:
haupt — displayed by page/kontakt
→ push them in the same run — `--all` carries the navigation — or create them
there
Menus this run creates count as present, exactly as pages linked from the tree
do. cwp writes those pages twice: once with the rest, and once after the
navigation exists. The menu has no id before its creation, and the run says
menus resolved against the second write.
The menu need not be one the project manages. A page may display a navigation that only ever changes on the target. That navigation resolves by slug like any other.
What it refuses
A page changed on the target since cwp last saw it. Every transfer records
_cwp_sync, the hash of the item as cwp left that site. A push can therefore
tell “the tree moved” from “the site moved”. The second refuses with exit 5:
✗ 1 post(s) changed on "dev" since cwp last saw them
→ take their version with `cwp content pull dev --all`, or overwrite it with
--overwrite-conflicts — the changed ones are listed above
--force does not override this. The two questions have nothing to do
with each other: “yes, write to production” does not imply “yes, discard
whatever an editor changed there”.
A reference the target does not have. An element tree names element types and global class and component ids. None of them travel with the page. All of them fail the same silent way: the page saves, the markup renders, and what you built is not what appears.
Each kind gets its own line because the fixes differ. An element type is code
and travels with cwp deploy. A class or component is
design-system state and travels with cwp bricks push.
! 3 components are not on "dev": 3c9e7c, b38830, c72701
— send the design system first (`cwp bricks push dev`)
cwp deliberately does not check var(--x). A CSS custom property is either
a Bricks global variable or a plain declaration in the theme stylesheet. The
reference reads the same in both cases. A check on it would refuse correct
pushes, so cwp leaves the reference alone.
An attachment that is on neither side. --media upload, the default, sends
files the target lacks and names every one in the confirmation. --media require refuses instead. Uploads go as base64: Bricks’ upload ability rejects
loopback URLs by design, so “let the remote fetch it from my DDEV site” has no
working form. The push refuses files above 24 MB with a measurement instead of
a PHP memory error.
Defaults that are guards
- cwp creates a post that does not exist on the target as
draft.--publishor--statussays otherwise. cwp does not touch an existing post’s status unless you give--status. Pushing a layout fix must not publish a page somebody parked, and must not unpublish a live one. - cwp never deletes a post on the target.
--deletetrashes, and there is no--prune. The one exception is a menu entry the tree stopped describing. That entry goes outright: it is a pointer with no content of its own. The page it pointed at stays untouched, and the menu file puts the entry back. - cwp replaces the meta it manages and does not merge it: a key the item no
longer carries disappears. This covers only keys the group’s own
includepatterns claim. A plugin’s meta cwp never carried is none of its business. - The usual set applies.
protectedrefuses without--force. cwp takes a remote backup first. The confirmation lists every affected post.--dry-runruns every read-only preflight for real, and the plan is truthful.
The rollback is per page
Bricks takes a revision before every element write. Each pushed page therefore has an undo that costs nothing and undoes exactly that page, where the remote backup restores the whole app to fix one paragraph. The summary prints it:
push summary:
about-us → dev (post 12783) — roll back:
cwp ability bricks/restore-revision '{"revisionId":12791}' dev
→ cwp content status dev — every pushed page should now read as in sync
The last line is not a formality. “The push reported success” and “the two
sides agree afterwards” are different claims. cwp content status checks the
second one.
Example
cwp content push dev --post about-us
cwp content push dev --all --dry-run
cwp content push prod --post about-us --force --publish
A host that rewrites what it is given
Some hosts define DISALLOW_UNFILTERED_HTML. Then no user holds
unfiltered_html, not even an administrator. WordPress filters every write, and
it does two different things:
- escaping:
>becomes>,&becomes&. Nothing is lost, the page renders the same, and the target would never match the tree again. The push writes those bytes back and names the field it restored. - stripping: a
<script>, a disallowed attribute. Something is lost, and the host decided that. The push refuses and says what would go.
A repaired write is not a failure. The push reports it anyway: a host that rewrites its input is worth knowing about.
If that trade is wrong for your project, allow_unfiltered_write: true in
cwp.yml stands the filter down for the length
of each content write. The write then goes through as it is: no escaping, no
stripping, no refusal. This switches your host’s protection off, so every
affected write says so and cwp doctor reports it for as long as the key
stands.
What it does not do
It deletes nothing on the target. It pushes no media that exists on neither side. It merges nothing: cwp refuses a conflict and does not resolve it.
It transfers no post type that no content group claims.