Skip to content

cwp content push

shipped 1.0.0
cwp 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.

tree → site

ArgumentWhat it isDefault
[env]environment to write (default: default_environment in cwp.yml)
FlagWhat it doesDefault
--post <selector...>slug, post id or URL, repeatable
--type <post_type>restrict to one post type
--allevery post in scopeoff
--forceoverride the protected-environment refusaloff
--no-backupskip the remote backup taken before the writeon
--yesskip the confirmation promptoff
--overwrite-conflictsoverwrite posts that changed on the target since cwp last saw themoff
--deletewith —all: trash posts on the target the tree no longer describesoff
--delete-termswith —all: remove terms of a declared taxonomy the tree no longer describesoff
--skip-missing-targetsleave out menu entries pointing at things the target does not haveoff
--publishpublish a post that does not exist on the target yetoff
--no-verifyskip reading every written post back to compare iton
--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 alreadyupload
--with-agentwith —dry-run on a host with no shell: install and remove the PHP agent so the plan is realoff

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:

  1. the survivors exist, renames applied;
  2. every post now points at one of them;
  3. 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.

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:

  1. The exact path. cwp reports nothing; this is the normal case.

  2. 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: bytes when the file sizes agree, name when 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
  3. 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 status reports the second as unmanaged, and --delete never touches it.

  • --delete needs --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. --publish or --status says 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. --delete trashes, 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 include patterns claim. A plugin’s meta cwp never carried is none of its business.
  • The usual set applies. protected refuses without --force. cwp takes a remote backup first. The confirmation lists every affected post. --dry-run runs 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 &gt;, & becomes &amp;. 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.