Skip to content

cwp content pull

shipped 1.0.0
cwp content pull [env] [flags]

This name is an alias. The work moved to cwp pull --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 read (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
--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

Brings named posts from a site into the committed tree.

cwp refuses a bare cwp content pull and does not read it as --all. The command is one word from cwp pull, and that one replaces your whole local database. A command that guessed here would guess about the wrong one.

Selection is explicit: --post <slug|id|url> repeatable, --type <post_type>, or --all.

cwp resolves a selector against every configured group, not against one of them. A Bricks project scaffolded by cwp init has two: content for pages and templates for bricks_template. Naming a page there works without saying which group it is in. cwp refuses a slug that two groups both hold rather than guessing at it. --type says which one you meant.

A pull writes to the site it reads

Two things go up. Against a remote that means the full guard set: the confirmation, the restore point, --force on a protected environment.

  • the uid of anything adopted, so the item keeps its identity across a rename;
  • _cwp_sync, the hash of what cwp took. The next push compares against it.

A local pull writes to no remote and asks nothing. This is the one place in cwp where a command named pull has an upward effect. The command declares it as one rather than excusing it.

Example

cwp content pull --post about-us      # from the local site into content/
cwp content pull dev --type page
cwp content pull dev --all

Terms travel too, when a group declares them

A group that names taxonomies gets a file per term:

content:
  post_types: [page]
  taxonomies: [category, post_tag]
content/terms/post_tag/workshop.yml

Declared, never discovered. This is the load-bearing decision, not a preference. A live project’s fourteen taxonomies include three that store visitor IP addresses and user agents as terms. pull.truncate_taxonomies exists for them. A feature that discovered taxonomies would commit personal data to a git repository. Absent means none. A project that says nothing behaves exactly as it did.

A uid identifies a term, not its slug. If the slug were the identity, renaming veranstaltungen to events would look the same as deleting one term and creating another. A consolidation is mostly renames. The slug is the file name; the uid inside the file is the term.

The tree never records the count. The posts derive it, and writing it would dirty every term file whenever a post gained or lost a tag.

The pull names and skips a declared taxonomy the source does not register. A plugin switched off on one environment is a real difference between two sites.

A group that says menus: gets a file per navigation:

content:
  post_types: [page]
  menus: true          # or: [primary, footer]
content/menus/primary.yml

One file per menu, not one per entry. A menu entry has no slug to name a file after: WordPress leaves post_name empty or numeric. And a menu is the unit anybody reviews. Reordering two entries is one change to one file, and the nesting is structure in it:

{
  "uid": "8f1c…",
  "slug": "primary",
  "name": "Hauptmenü",
  "locations": ["primary"],
  "items": [
    {
      "uid": "3a20…",
      "label": "Über uns",
      "target": { "kind": "post", "type": "page", "slug": "ueber-uns", "uid": "7c1e…" },
      "children": [
        { "uid": "b904…", "label": "Kontakt",
          "target": { "kind": "custom", "url": "{{site}}/kontakt" } }
      ]
    }
  ]
}

The target makes an entry portable. WordPress stores it as a raw post id. That id is a different page on the next environment. The file carries an identity instead: a post type and slug, a taxonomy and slug, a post type’s archive, or a custom URL. The custom URL goes through the same {{site}} placeholder a page payload uses. The uid sits beside the slug when the target has one. A push resolves by identity first and by slug second: the uid survives a rename, the slug survives a target no group manages.

An entry whose page was deleted on the source is already broken there. The pull names it and leaves it out rather than committing a reference to nothing:

! primary: the entry "Termine" points at something dev no longer has — it was
  left out of the file rather than carried as a reference to nothing

The element that displays a menu is portable too. A navigation element stores which menu it shows as that menu’s term id. The file carries the slug instead, the same substitution one level in:

settings:
  menu:
    kind: menu
    slug: primary

This happens whatever the selection, so a page pulled on its own commits the same bytes as the same page pulled with --all. A menu that no longer exists on the source keeps its number: there is no slug to write, and inventing one would commit a reference to nothing.

Menus travel with --all; a run naming individual posts says so and skips them. The pull reports a menu named under menus: that the source does not have the way it reports an unregistered taxonomy.

cwp refuses two configurations when it reads cwp.yml. Both would manage one thing twice: nav_menu under taxonomies: beside menus:, and nav_menu_item under post_types:.

A narrowed run carries posts, and only posts

--post and --type name posts. Everything that belongs to the group as a whole travels with --all, and a narrowed run says which:

· menus     menus travel with --all
· terms     terms travel with --all
· content/authors.yml   the people travel with --all

The reason is the same one that stops a narrowed run from deleting. A run that knows about five posts cannot tell a term somebody removed from the tree on purpose from one nobody ever pulled. content/authors.yml describes the whole tree. Rewriting it from one environment’s accounts would flip it between the scrub’s placeholders and real names according to the side of the last pull.

Two posts cannot share one file

The tree names a file after the post type and the slug: content/page/kontakt.yml. WordPress keeps a slug unique among siblings, not site-wide. A top-level kontakt and a child of hilfe called kontakt are both legal, and both want that file.

The pull refuses and names them:

! content/page/kontakt.yml — page/kontakt (#30) and page/kontakt (#31)
✗ 1 post(s) would be written into a file another post already has
  → WordPress keeps a slug unique among siblings, and the tree names a file
    after the type and the slug — rename one of the slugs on the site and pull
    again

It refuses the same shape against the tree: a file that already describes a different post, recognised by the uid inside it.

A parent the tree does not manage

A child post carries its parent’s identity, not its id. Where the parent is in no managed group there is no identity to carry. The item then records no parent and the run says so:

! page/kind: its parent (#30) is in no managed group, so the item carries no
  parent and a push would put it at the top level

The site’s own URL leaves the file

A link an editor writes is an absolute URL, because the browser hands them one. The pull replaces this environment’s own address with {{site}} in post_content and post_excerpt. The push writes the target’s address back:

-<a href="https://project.ddev.site/kontakt/">write to us</a>
+<a href="{{site}}/kontakt/">write to us</a>

Without the placeholder a page pushed to another environment would serve a link back to the machine of its author. The same page read from two environments would commit different bytes.

The same holds for a term description, the introduction above its archive, and for a widget’s text. Those are the other two places where prose a person wrote travels in the tree.

The pull names a link to a different environment and does not rewrite it. cwp cannot know whether you meant that other site, so it says so and leaves the address alone:

! page/ueber-uns: links to another environment in its own text —
  https://old.example.com — that address travels unchanged and points there
  from every environment

What the tree holds and the source does not

A pull that covers a whole group names the items the source no longer has:

! 1 item(s) in the tree are not on dev (page/alte-aktion) — they were either
  deleted there or never pushed there, and cwp cannot tell which. Nothing was
  removed: delete them yourself if they are gone for good

Reported, never removed, exactly as the design-system read reports a stale package file. Deleting out of a git-tracked directory is not a side effect of a read. In a repository a deletion belongs in a commit.

The pull cannot say more than that. The evidence that would tell a deletion from a page nobody ever pushed is _cwp_sync, the marker cwp leaves on the post. That marker lived on the post that is gone.

It ends by pointing at the diff

A pull that wrote something closes with the step people skip:

what to do next:
  3 file(s) changed in the tree — the diff is the review
  → git diff -- content
  → commit them: a page built in a builder has no history anywhere else

Those two lines hold the whole argument for the content tree. A page built in a page builder is database state. Without these files there is nothing to read, nothing to review and nothing to revert to. A run that rewrote nothing prints no block, because there is nothing to look at.

Not through wp_get_nav_menu_items(). That call is WordPress’s display path and applies a filter. A plugin that shows account links only to logged-out visitors hides entries from that call. A membership, translation or role-visibility plugin does the same. The pull would then commit a menu with those entries missing. The next push would delete them, because a menu file describes the whole menu.

The rule generalises, and so does the fix. cwp is a storage transport, and anything WordPress renders through a filter has two answers. The shim stands those filters down before it reads: menus, terms, post meta, the options a settings: group carries, the roles table. What lands in the tree is what the database holds.

A key it asked for and cannot carry stops the run

A committed item holds one value per meta key. WordPress does not: a key can carry several rows, and a duplicated post carries a duplicate of every row it had.

Where the rows say the same thing, cwp collapses them and says nothing. Two rows holding 9 are the value 9, and nothing is lost. Where they say different things and the key is one your meta.include names, the run stops before it writes a single file:

! example_thing/some-slug: _thumbnail_id has 2 values on staging, and a committed item carries one
✖ 1 meta key(s) the tree asks for cannot travel — nothing was written
  → decide which value stands — `cwp wp --env staging -- post meta list 1234 --keys=_thumbnail_id`
    — or name the key under content.things.meta.exclude

It stops rather than writing what it could. An item written without a key it declares is an item the next push writes back without it, and cwp push deletes what the tree does not carry. The tree stays exactly as it was. The choice is yours: repair the rows on the source, or say in cwp.yml that the key does not travel.

A key your rules never asked for is a different matter and stops nothing. The run reports it once for the group, with the number of items that carry it:

! things: 2 meta key(s) not carried on 98 item(s) — _wp_old_date, _wp_old_slug
  — add a pattern under content.things.meta.include to keep them

A key that only some of the items carry gets its own count, _legacy (3). One line then answers the question a hundred lines used to.

What it does not do

It does not delete files in the tree that the site no longer has. A pull that changes nothing writes nothing, so re-running one does not dirty the tree or move an mtime.

It does not download media. The committed file describes each attachment by path; the files themselves travel with cwp pull or the uploads proxy.