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#
- The sender asks the remote for its file listing in the scope (relative path, size, modification time) and builds its own.
- 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.
- Files travel in batches (small files) or in chunks (large files).
- With
--delete, files the destination has and the source does not are deleted on the destination. - 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
uploadsonly when the sync is confined with--paths. A whole uploads tree cannot be pruned; the job stops withDelete 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_requestdecides. If a proxy or firewall in front of the receiver truncates large bodies, lowermax_requeston 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_requestno 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.
Symlinks and path containment#
Every read, write and delete goes through the same checks:
- The scope must resolve to a root folder. Unknown scopes are refused.
- The relative path may not be empty, absolute, contain a NUL byte, or contain a
..segment. - The first path segment may not be one of the skipped names, so the running plugin and its storage are never a target.
- The target, and its partial file, may not be a symlink. Listings skip symlinks.
- 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_alland flushes its object cache. If the request fails, the log saysPurge 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 syncruns the file scopes without--paths,--deleteor--purge.