Skip to content

Files#

File sync copies files between this site and a remote, inside one scope, in either direction. It is on the Files tab and in wp bb-migrate files.

wp bb-migrate files push "<connection>" --scope=theme:my-theme --dry-run
wp bb-migrate files push "<connection>" --scope=theme:my-theme --delete --purge
wp bb-migrate files pull "<connection>" --scope=uploads --paths=2026/10

How a sync works#

  1. The sender asks the remote for its file listing in the scope (relative path, size, modification time) and builds its own.
  2. It compares the two. A file is copied when the destination does not have it, when the size differs, or when the destination's copy is older than the source's by more than one second.
  3. Files travel in batches (small files) or in chunks (large files).
  4. With --delete, files the destination has and the source does not are deleted on the destination.
  5. With --purge, the destination's cache is purged.

A written file gets the source's modification time, so the next sync sees it as unchanged. Identical bytes with a newer modification time on the source still count as a change.

Because step 1 reads the remote listing, the remote needs allow_pull for a sync in either direction, and allow_push as well for a push. Theme and plugin scopes also need allow_code_files.

Scopes#

--scope What it covers
uploads (default) The uploads directory
themes Every theme
theme:<slug> One theme folder, the same folder name on both sites
plugins Every plugin except BB Migrate itself
plugin:<slug> One plugin folder

The admin screen offers uploads, themes, the active theme and plugins. Any other slug scope is available from WP-CLI.

Never transferred, in any scope: folders and files named node_modules, .git, .svn, .DS_Store or bb-migrate, partial files ending in .bbpart, and symlinks. In uploads, entries named cache are skipped as well. BB Migrate's own folder is not a scope: plugin:bb-migrate answers "Unknown scope".

Confining a sync with --paths#

By default a sync covers the whole scope. --paths confines it to named paths inside the scope, comma-separated; --paths-file=<file> reads them one per line, and --paths-file=- reads standard input. Both can be combined.

Entry Meaning
2026/10 A folder and everything under it
2026/10/poster.jpg One file
assets/dist/css/* A glob. * and ? do not cross a /; a folder that matches is included with everything under it
wp bb-migrate files push "<connection>" --scope=uploads --paths=custom-css-js,2026/10/poster.jpg
wp bb-migrate files push "<connection>" --scope=theme:my-theme --paths-file=theme-runtime-paths.txt
git diff --name-only HEAD~1 | wp bb-migrate files push "<connection>" --scope=theme:my-theme --paths-file=-

Leading ./ and / are dropped. An entry containing .. is ignored. When every entry is a plain path, only those folders are walked on each side, which is what makes a one-file push into a large uploads tree fast. One glob in the list makes both sides walk the whole scope and filter.

--paths also bounds deletion: with --delete, only files under the named paths are candidates.

Pruning with --delete#

Without --delete nothing is ever removed. With it, every file the destination has under the synced paths that the source does not have is deleted on the destination.

  • Allowed for theme and plugin scopes, and for uploads only when the sync is confined with --paths. A whole uploads tree cannot be pruned; the job stops with Delete is only allowed for theme/plugin scopes, or for uploads confined to named paths.
  • Only files are deleted. A folder left empty is removed too, up to but not including the scope root.
  • The same path rules apply as for writing: inside the scope root, no symlinks, nothing the uploads rule refuses.
  • On the command line a real run asks for confirmation unless --yes.

Typical use is a built theme: stale build files from the previous version disappear.

wp bb-migrate files push "<connection>" --scope=theme:my-theme --paths=assets/dist --delete --dry-run

Dry runs#

--dry-run (the List differences button) stops after the comparison. It writes nothing and prints one line per file:

copy    assets/dist/css/core.css
copy    functions.php
delete  assets/dist/css/old-sheet.css

Read the delete lines before running a sync with --delete for real.

Chunking and max request size#

max_request (Settings tab, 1048576 bytes by default) bounds the body of one request.

Case What is sent
Small files Gathered into one request until their estimated encoded size reaches max_request, at most 400 files per request
Large files (estimated encoded size over half of max_request) One file at a time, in chunks of half of max_request (at least 64 KB), appended on the receiver

Files are base64-encoded inside JSON, which is why the estimate is 1.4 times the file size and why a chunk is half the limit.

  • On a push the sender's max_request decides. If a proxy or firewall in front of the receiver truncates large bodies, lower max_request on the sending site.
  • On a pull the sender's value sizes the requests and the remote's value caps how many small files it puts into one response. Keep the sender's max_request no higher than the remote's: files the remote leaves out of a response because of its own, smaller limit are not requested again in that run (run the sync again to fetch them).

A file is written to <name>.bbpart and renamed into place when its last chunk has arrived, so a reader never sees a half-written file. New folders are created with mode 775 and files are left at 664, so that an SFTP account in the web server's group can still edit them. See Hosting notes.

Idempotent file requests are retried on a transport failure. See Protocol.

What the uploads scope refuses#

The uploads folder is served by the web server, so a file landing there must not be able to run or to reconfigure the server. In the uploads scope a path is refused, for reading, writing and deleting, when:

Rule Examples
Any path segment starts with a dot (dotfiles and dot-folders) .htaccess, .user.ini, .well-known/x, 2026/.hidden
The file is server configuration .htaccess, .htpasswd, .user.ini, php.ini, web.config
An executable extension appears anywhere in the file name shell.php, shell.php.jpg, a.php7, x.phtml, x.pht, x.phps, x.phar, x.cgi, x.pl, x.py, x.sh, x.shtml, x.ini

.js, .svg, .html and .css are media and are accepted. That includes files such as custom-css-js/*.js, which a snippet plugin prints site-wide: with "Accept push" on, a key holder can change site-wide script. That is what Accept push means.

Refused files are skipped, not reported as errors; a dry run may still list them.

The same rule is applied to page package files and to the files inside a package zip.

Every read, write and delete goes through the same checks:

  1. The scope must resolve to a root folder. Unknown scopes are refused.
  2. The relative path may not be empty, absolute, contain a NUL byte, or contain a .. segment.
  3. The first path segment may not be one of the skipped names, so the running plugin and its storage are never a target.
  4. The target, and its partial file, may not be a symlink. Listings skip symlinks.
  5. The resolved path must lie inside the resolved scope root. This catches a symlinked folder somewhere above the target that would lead outside the scope.

Purging the cache#

--purge runs after the copy and delete steps:

  • on a push, the remote fires bb_cache_purge_all and flushes its object cache. If the request fails, the log says Purge skipped: … and the sync still succeeds;
  • on a pull, this site does the same locally.

In the admin screen the purge box is ticked by default; on the command line it is off unless you pass --purge.

When a write fails#

A push stops on the first file the remote could not write:

The remote could not write 3 of 120 files (first: assets/dist/css/core.css). Its folder for this scope is not writable by PHP there (ownership / permissions), or the path is refused.

The usual cause is file ownership on the receiver: see Hosting notes and Troubleshooting.

A pull does not stop in the same way: files this site cannot write locally are skipped without an error. If a pull reports success and files are missing, check that PHP can write to the local scope folder.

Limits#

  • A listing holds at most 200,000 files per side.
  • The comparison uses size and modification time, not a hash.
  • wp bb-migrate sync runs the file scopes without --paths, --delete or --purge.