From e5a27aa16d417536227537ef7c59741acdc3eaf4 Mon Sep 17 00:00:00 2001 From: Roberto Aranda Date: Tue, 21 Jul 2026 13:14:01 +0200 Subject: [PATCH 1/2] Keep locally installed files when pulling onto an existing site (STU-1951) The first pull passes --force to flat-docroot to overwrite the blank install, which also deleted every plugin, theme and upload already in wp-content. Pass reprint's --preserve-local-content so wp-content is merged rather than replaced. It goes on every pull, not just the first: once merged, wp-content is a real directory, and a later pull without the flag fails trying to symlink over it. Re-run flat-docroot after the deferred file tail as well. Those files land in the raw directory after the flatten, and the flattened site now links entries individually rather than aliasing the whole wp-content, so without a second pass they would stay unlinked until the next pull. --- apps/cli/commands/pull-reprint.ts | 51 +++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/apps/cli/commands/pull-reprint.ts b/apps/cli/commands/pull-reprint.ts index a83c17abb8..1e01c6a5bf 100644 --- a/apps/cli/commands/pull-reprint.ts +++ b/apps/cli/commands/pull-reprint.ts @@ -553,6 +553,9 @@ export async function runCommand( verbose, selection ); + // The tail landed in the raw directory after flat-docroot ran, so + // link the files that just arrived into the flattened site. + await relinkFlattenedSite( getSiteRuntime( site ), studioMetadata, verbose ); } // The pull is done: drop the selection sidecar so the next pull asks @@ -1138,6 +1141,15 @@ export async function runFullPull( 'flat-docroot', '-', `--flatten-to=${ metadata.sitePath }`, + // `--force` replaces the blank install `studio create` produced, but on + // its own it would also delete everything the user had added to + // wp-content. `--preserve-local-content` merges wp-content instead: + // the remote wins on conflicts, local-only plugins/themes/uploads stay. + // + // It is passed on *every* pull, not just the first: once the first pull + // merges, wp-content is a real directory, and a delta re-pull without + // the flag would fail trying to symlink over it. + '--preserve-local-content', ...( force ? [ '--force' ] : [] ), `--state-dir=${ metadata.stateDirectory }`, `--fs-root=${ metadata.rawDirectory }`, @@ -1217,6 +1229,45 @@ export async function downloadSkippedFiles( logger.reportSuccess( __( 'Remaining files downloaded' ) ); } +/** + * Re-link the flattened site after the deferred file tail. + * + * `downloadSkippedFiles` lands media in the raw directory *after* + * `flat-docroot` already ran. The flattened site links the remote's entries + * individually rather than aliasing the whole `wp-content`, so anything that + * arrives late has no link yet and would stay invisible until the next pull. + * + * Re-running the flatten is idempotent and cheap: it refreshes the links that + * are already correct, adds links for the newly-arrived files, and prunes the + * ones whose target the remote dropped. `--force` is deliberately omitted — + * the site is already assembled, so nothing should be replaced here. + */ +export async function relinkFlattenedSite( + runtime: SiteRuntime, + metadata: PullSession, + verbose: boolean +): Promise< void > { + await runReprintCommandUntilComplete( + metadata.stateDirectory, + metadata.rawDirectory, + [ + 'flat-docroot', + '-', + `--flatten-to=${ metadata.sitePath }`, + '--preserve-local-content', + `--state-dir=${ metadata.stateDirectory }`, + `--fs-root=${ metadata.rawDirectory }`, + ], + ( progress ) => logger.reportProgress( progress ), + { + progressLabel: __( 'Linking new files' ), + mounts: [ { hostPath: metadata.sitePath, vfsPath: metadata.sitePath } ], + verboseCommands: verbose, + runtime, + } + ); +} + export function normalizeSiteUrl( url: string ): string { const trimmedUrl = url.trim(); const normalized = new URL( From 978b5c99d4580a2d875dc2d4c2834f2fe00a52c5 Mon Sep 17 00:00:00 2001 From: Roberto Aranda Date: Tue, 21 Jul 2026 19:16:20 +0200 Subject: [PATCH 2/2] Update flat-docroot arg expectations for --preserve-local-content The exact-equality assertion predates the flag. Also assert the delta pull still passes it: without --force it is what lets the flatten succeed over the real wp-content directory the first merge left behind. --- apps/cli/commands/tests/pull-reprint.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/cli/commands/tests/pull-reprint.test.ts b/apps/cli/commands/tests/pull-reprint.test.ts index 906f0f1dfe..2598cd601b 100644 --- a/apps/cli/commands/tests/pull-reprint.test.ts +++ b/apps/cli/commands/tests/pull-reprint.test.ts @@ -391,10 +391,13 @@ describe( 'CLI: studio pull-reprint single pull phase', () => { ] ); // Local flatten: `-` URL placeholder; --force only on a first pull // (this call passed force=true) to overwrite the blank install. + // --preserve-local-content goes on every pull so wp-content is merged + // rather than replaced and locally installed files survive. expect( flattenArgs ).toEqual( [ 'flat-docroot', '-', `--flatten-to=${ sitePath }`, + '--preserve-local-content', '--force', `--state-dir=${ stateDirectory }`, `--fs-root=${ rawDirectory }`, @@ -464,6 +467,9 @@ describe( 'CLI: studio pull-reprint single pull phase', () => { expect( commands ).toEqual( [ 'pull-files', 'flat-docroot', 'apply-runtime' ] ); const flattenArgs = reprint.mock.calls[ 1 ][ 2 ] as string[]; expect( flattenArgs ).not.toContain( '--force' ); + // Once merged, wp-content is a real directory; a delta pull without + // the flag would fail trying to symlink over it. + expect( flattenArgs ).toContain( '--preserve-local-content' ); fs.rmSync( technicalSiteDirectory, { recursive: true, force: true } ); } );