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( 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 } ); } );