Skip to content

Add --preserve-local-content to merge wp-content instead of replacing it#383

Open
epeicher wants to merge 2 commits into
trunkfrom
stu-1951-make-pull-reprint-not-delete-local-files
Open

Add --preserve-local-content to merge wp-content instead of replacing it#383
epeicher wants to merge 2 commits into
trunkfrom
stu-1951-make-pull-reprint-not-delete-local-files

Conversation

@epeicher

@epeicher epeicher commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

What it does

Adds --preserve-local-content to flat-docroot and pull. With it, an existing wp-content is merged rather than replaced: entries the source also has get linked, entries that exist only in the target are left alone.

Opt-in — without the flag, behaviour is unchanged.

Rationale

flat-docroot --force replaces a conflicting non-symlink target with a symlink, and for a directory that means deleting it first:

if ( is_dir( $target ) ) {
    $this->remove_directory_recursive( $target );
}
symlink( $link_value, $target );

That's fine for core paths, which the source owns entirely. It isn't fine for wp-content: flattening onto a docroot that already has content deletes every plugin, theme and upload the target had. A consumer flattening onto a pre-existing site needs --force to overwrite core, but must not lose wp-content in the process.

Implementation

flatten_merge_directory() wraps flatten_place_symlink(). When either side isn't a real directory it delegates unchanged — one symlink for the whole subtree. When both sides are real directories it keeps the target and walks the source:

  • real directory on both sides → recurse
  • anything else → symlink it (the source wins)
  • present only in the target → untouched

Recursion stops as soon as one side isn't a real directory, so a subtree the target doesn't have still costs a single symlink. uploads/ only expands into per-entry links if the target already has matching directories.

Two consequences of walking the source instead of aliasing it:

Collisions must resolve, not raise. A merge implies the source wins, so the delegated call passes $preserve || $force. Otherwise every path present on both sides — wp-content/database, for one — would fail unless the caller also passed --force, which replaces whole directories and defeats the merge.

Removals need pruning. Walking the source can't notice entries it no longer has, so links to deleted files would accumulate. After merging, symlinks whose target is gone are unlinked — but only when the link resolves back inside the fs root, since those are the ones the flatten created. Real files, and symlinks the target owns, are untouched.

Only wp-content merges; the rest of ABSPATH is core and is still replaced.

Testing instructions

cd tests && phpunit Import/FlatDocrootPreserveLocalContentTest.php

Seven cases: the merge keeps target-only entries; the source wins a collision without --force; a link whose source entry is gone is pruned; a dangling link resolving outside the fs root is left alone; a subtree the target lacks costs a single symlink; and without the flag an existing directory is still a conflict, with --force still replacing it wholesale.

Writing them turned up a bug in the prune, fixed in the second commit: it resolved the link lexically while get_filesystem_root_path() returns a canonical path, so any link routed through a symlink — /tmp and /var on macOS, or a symlinked project directory — failed the containment check and was never pruned.

The rest of the suite is unaffected. Note that a local run without MySQL fails ~151 database-dependent tests with Connection refused; those are unrelated.

With --force, flat-docroot replaces a conflicting non-symlink target with a
symlink, recursively deleting whatever that directory already held. For
wp-content that loses every file present only in the target.

--preserve-local-content keeps a directory that exists on both sides and
places the source's entries inside it: real directories on both sides recurse,
anything else is symlinked (the source wins), and entries present only in the
target are left untouched. Recursion stops as soon as one side is not a real
directory, so a subtree the target lacks still costs a single symlink.

Merging also prunes symlinks pointing into the fs root whose target is gone,
because walking the source alone cannot notice entries it no longer has.
@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Pull pipeline performance — large-directory

Site: large-directory · 2,000+ plus targeted file-transfer scenarios files · 10,000 posts · 25,000 postmeta · PHP 8.5.8

trunk baseline unavailable — showing PR numbers only.

Stage Wall time Resume attempts Status Details
playground-sqlite-db-pull 7.01 s 1 condition=db-pull in PHP.wasm
runtime=php.wasm 8.3
wp_mysql_parser=enabled
mode=lexer
native_lexer=verified
native_token_stream=WP_MySQL_Native_Token_Stream
native_token_count=18
native_parser=selected
playground-sqlite-db-apply 2.87 s 1 condition=db-apply to SQLite in PHP.wasm
runtime=php.wasm 8.3
wp_mysql_parser=enabled
mode=parser
native_lexer=verified
native_token_stream=WP_MySQL_Native_Token_Stream
native_token_count=18
native_parser=verified
native_ast=WP_MySQL_Native_Parser_Node
sqlite_driver_parser=verified
Total 9.88 s

Numbers carry runner noise; treat single-run deltas as directional, not authoritative.

📈 Trunk performance history — commit-by-commit timeline.

The prune resolved a link lexically against the target path, while
get_filesystem_root_path() returns a canonical one. Any link whose route passed
through a symlink therefore failed the containment check and was never removed —
/tmp and /var on macOS, or a symlinked project directory. Resolve against the
target's real path so both sides are canonical.

The tests cover the merge keeping target-only entries, the source winning a
collision without --force, pruning a link whose source entry is gone, leaving a
dangling link that resolves outside the fs root alone, a subtree the target
lacks costing a single symlink, and the flag being opt-in.
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.

1 participant