Skip to content

Preserve local content of first Reprint pull - #4531

Open
fredrikekelund wants to merge 17 commits into
trunkfrom
f26d/pull-reprint-adopt-flatten-to-content
Open

Preserve local content of first Reprint pull#4531
fredrikekelund wants to merge 17 commits into
trunkfrom
f26d/pull-reprint-adopt-flatten-to-content

Conversation

@fredrikekelund

@fredrikekelund fredrikekelund commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Related issues

How AI was used in this PR

Codex and Claude were used throughout. This feature went through several iterations – starting out with @epeicher's WordPress/reprint#383, then superseded by WordPress/reprint#557, and ultimately superseded again by WordPress/reprint#571. From understanding and reviewing Roberto's PR to planning and implementing a new solution, I jumped between different sessions and handovers in Codex and Claude.

Proposed Changes

When a remote site is pulled for the first time with Reprint, we want to preserve plugins, themes, and uploads in that site. For example, if the user has been developing a local theme before the pull, it would be detrimental if the pull wiped that (which it currently does).

This PR fixes the problem by using the new Reprint command merge-wp-content from WordPress/reprint#571, which strategically merges the wp-content dir from the existing local Studio site to the fs-root Reprint dir.

Studio had similar logic in preserveUnselectedLocalContent(), but this did not sufficiently address all use cases and was not as clever about merging as the Reprint command is. This PR removes that old logic in favor of the new upstream Reprint command.

Moreover, I identified a shortcoming in the open_basedir generation logic when testing this PR. For Reprint sites, wp-content becomes a symlink to the <fs-root>/wp-content directory, and we didn't resolve that symlink when searching for symlinks in the site tree. So, if I had a symlink in wp-content/themes/example pointing to ~/Desktop/example, ~/Desktop/example was never added to my open_basedir string. This bug is independent of the other changes in this PR, but I took the opportunity to fix it here.

Please note that the Reprint PHAR is temporarily committed to make testing easier.

Testing Instructions

  1. npm run cli:build
  2. node apps/cli/dist/cli/main.mjs site create to create a new site
  3. Install a random plugin on the site (that is not present on the site you'll pull later)
  4. Upload an image to the site
  5. STUDIO_ENABLE_PULL_REPRINT=1 node apps/cli/dist/cli/main.mjs pull-reprint --path PATH_TO_SITE to do a complete pull of a remote site
  6. Let the pull finish
  7. Ensure that the plugin you installed still appears in wp-admin (although the full pull will have deactivated it)
  8. Ensure that the image you uploaded before is still present on disk

Pre-merge Checklist

  • Have you checked for TypeScript, React or other console errors?

fredrikekelund and others added 16 commits August 6, 2026 15:05
flat-docroot --force deletes whatever stands where a symlink must go,
so a first pull wiped the plugins, themes and uploads that only the
local site had. --on-flatten-to-conflict=adopt moves those entries into
the fs-root first, so they survive and stay where a later push can read
them, and a plugin or theme the pull also carries is left to the pull
rather than merged with the local copy.

Still first-pull only. A delta re-pull passes no mode at all, keeping
the default that refuses to overwrite a live site.

Reprint is pinned to the committed phar while these changes are
unreleased, so the download step no longer replaces it.
Testing pull-reprint end to end needs all three unreleased Reprint
changes at once, and no single branch carries them:

  #557 preserve-local-content-into-fs-root    02d0ab73
       --on-flatten-to-conflict=error|replace|adopt
  #540 f26d/apply-runtime-db-apply-option     ba329f38
       --target-* options for apply-runtime
  #436 adamziel/expose-import-layout-metadata 6dc0141f
       source layout and artifact status from import-metadata

Built from integration/studio-test-build in the reprint worktree, which
merges the three cleanly; its Import suite passes at 650 tests. Rebuild
by merging those three heads and running composer build:phar.
Reprint's adopt conflict mode now moves the wp-content entries that
only the site directory holds into the fs-root, so Studio no longer
needs its own pass to do it. The two overlapped on every remaining
case: an unselected path, a kept database and a local-only plugin are
all simply absent from the fs-root, which is what adopt keys on.

Reprint's version is also the more careful one. It stops at whole
plugins and themes, where seedEntry() recursed into any directory both
sides had and would merge two versions of the same plugin. And it runs
where the source of each component is already resolved from preflight,
so a detached uploads directory is adopted against the directory it
actually came from.

The first-pull selective-sync test now asserts what Studio is
responsible for — asking for the adopt mode — rather than the file
movement a mocked Reprint never performs.
resolveOnlyPathsToAbsolute() existed to tell the local preservation
pass which remote paths the selection covered. Nothing asks that
question now, so it and its token table go, along with the test that
was its only remaining consumer.

Also drops a stale line claiming a partial first-pull selection has its
unselected local content preserved here; the flatten step keeps it.
#436 and #540 have landed on Reprint's trunk, so the bundled build no
longer needs an integration branch carrying three heads. This one is
trunk plus the merge-wp-content PR, which replaces #557's
--on-flatten-to-conflict with a command of its own:

  merge-wp-content <url> --state-dir=DIR --fs-root=DIR --from=DIR

Built from the merge-wp-content branch in the reprint worktree with
composer build:phar; its Import suite passes at 650 tests. Rebuild by
checking out that branch and running the same command.

The download step in scripts/download-wp-server-files.ts stays disabled
while these changes are unreleased.
A first pull onto a site that already has content kept that content by
asking flat-docroot to adopt it. Reprint has replaced that mode with a
command of its own, so the pull now runs it as a step:

  merge-wp-content <url> --state-dir --fs-root --from=<site path>

It moves the plugins, themes, uploads and anything else only the local
wp-content has into the fs-root, so the symlink the flatten then puts in
their place still reaches them, and files-push, which reads the fs-root
and nothing else, can still see them. Entries the pull already has are
left alone, and a plugin or theme both sides have is never merged:
keeping the files the pulled version dropped would leave a directory
matching no release.

The step runs only on a first pull. Afterwards the site's wp-content is
a symlink into the fs-root, so it has nothing of its own left to move
and the command would find nothing. It has to follow pull-files, whose
local index Reprint reads as the record of a finished file pull; run any
earlier and every unfetched path looks absent, so local copies move in
and the pull writes remote versions over them.

flat-docroot goes back to --force on a first pull, exactly as it was
before the adopt mode existed.

Two consequences worth knowing, both unchanged from adopt. A blank
Studio install's wp-content/db.php and wp-content/mu-plugins/
sqlite-database-integration move into the fs-root, which is the tree
Reprint indexes and a future files-push would send, even though
Studio's own exporter excludes both as local-only. Neither runs: the
generated runtime.php pre-sets $wpdb, so require_wp_db() returns before
it would require db.php, and WordPress does not auto-load mu-plugin
subdirectories. And on a first pull with the database excluded, the live
wp-content/database/.ht.sqlite moves too, resolving again through the
symlink the flatten places, so there is a window between the two steps
where the site's database is not at its path.
Review of the Reprint side turned up two defects, both fixed on the
merge-wp-content branch at 3f8020ca and neither visible in Studio's
invocation, so this is a phar refresh with no code change beside it.

A relative symlink pointing inside the wp-content being merged now keeps
its value instead of being recomputed against where its target stood
before the run. A child theme pointing at its parent broke as soon as
both moved, which a first pull onto a site using one would have hit.

And --from is resolved against the working directory when relative. That
threw "Path must be absolute" on every run with anything to move.
Studio passes the absolute site.path from its own site record, so it
never reached this one.

Built from the merge-wp-content branch in the reprint worktree with
composer build:phar, and checked through the archive itself: a relative
--from resolves, an in-tree sibling link keeps its value and finds its
parent, and a link pointing out of the tree is still rewritten and still
resolves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reprint's --from took a site directory and appended wp-content to it.
It now takes that directory, so a site which moved WP_CONTENT_DIR merges
like a conventional one, and pointing the flag at a wp-content directory
no longer succeeds having done nothing.

Studio passes metadata.sitePath + wp-content, which is the same path it
was resolving to before, so the pull behaves as it did.

The bundled Reprint is rebuilt from the branch carrying that change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A pulled site's wp-content is a symlink into the pull's raw directory,
and nothing that looks for symlinks descends through one: `find` needs
-L to follow, the Node walk treats a symlinked directory as a leaf, and
chokidar runs with followSymlinks off. So the open_basedir scan of the
site directory stopped at that link and never saw the plugins and themes
behind it, and the watcher, pointed at the same link, saw nothing at all.

A theme symlinked into wp-content before a pull survives the pull now,
and then fails to load:

  Warning: is_dir(): open_basedir restriction in effect.
  File(…/wp-content/themes/koinonia) is not within the allowed path(s)

Resolving the directory before scanning it fixes both. On the test site
that takes the granted set from 4 entries to 12; seven of the new ones
are WP.com's symlinked plugins, which resolve inside the pull directory
and were already covered, and the eighth is the theme, which points at
~/Downloads and was not.

The scan resolves its own argument, so any caller handing it a symlink
is covered. That alone does not reach the reported case: the startup
scan is given the site directory, which is real, and the link is one
level inside it — hence the second scan naming wp-content.

realpath is the native variant for the reason getFullyResolvedTmpDirPath
already uses it: these paths become an INI value, and a Windows 8.3 short
name carries a tilde that PHP's argument scanner rejects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread scripts/download-wp-server-files.ts Outdated
// Reprint is pinned to the phar committed at wp-files/reprint/reprint.phar
// while this branch tracks unreleased changes (merge-wp-content).
// Downloading the latest release would overwrite it. Restore this entry
// once those changes ship; downloadFile() still knows how to place it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess, we will revert this before merging this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yeah, I should have called that out 👍

// themes and uploads it alone has move into the fs-root, so the symlink
// step 4 puts in their place still reaches them. Reprint refuses to run
// this before the file pull has finished, so it has to follow step 1.
if ( isFirstPull ) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

@gavande1 gavande1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested this multiple times on different sites, and everything worked as expected. The image and plugins survived the pull. Great work, LGTM 👍

Base automatically changed from f26d/pull-reprint-jetpack-remote-file-index-phar to trunk August 13, 2026 13:34
…opt-flatten-to-content

# Conflicts:
#	apps/cli/commands/pull-reprint.ts
#	apps/cli/commands/tests/pull-reprint.test.ts
#	apps/cli/lib/pull/preserve-local-content.test.ts
#	apps/cli/lib/pull/preserve-local-content.ts
#	apps/cli/lib/pull/reprint-selector.test.ts
#	apps/cli/lib/pull/reprint-selector.ts
#	apps/cli/php-server-child.ts
#	scripts/download-wp-server-files.ts
@wpmobilebot

Copy link
Copy Markdown
Collaborator

📊 Performance Test Results

Comparing 7cba081 vs trunk

app-size

Metric trunk 7cba081 Diff Change
App Size (Mac) 1406.76 MB 1406.77 MB +0.00 MB ⚪ 0.0%

site-editor

Metric trunk 7cba081 Diff Change
load 1060 ms 1077 ms +17 ms ⚪ 0.0%

site-startup

Metric trunk 7cba081 Diff Change
siteCreation 7521 ms 7566 ms +45 ms ⚪ 0.0%
siteStartup 2873 ms 2878 ms +5 ms ⚪ 0.0%

Results are median values from multiple test runs.

Legend: 🟢 Improvement (faster) | 🔴 Regression (slower) | ⚪ No change (<50ms diff)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants