From 7d1ab4f819e490b3b2a4c3eafb96044b71b70440 Mon Sep 17 00:00:00 2001 From: Ivan Ottinger <25105483+ivan-ottinger@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:04:47 +0200 Subject: [PATCH 1/5] Add .deployignore support to Site Sync Integrate the deploy-ignore filter (from PR #2924) into the Site Sync flow. Files matching .deployignore patterns are now hidden from the sync tree UI and excluded from the push archive. - Add additionalDefaults parameter to createDeployIgnoreFilter for sync-specific Studio-internal exclusions - Update shouldExcludeFromSync to use the ignore filter alongside the existing dotfile check - Thread the deploy-ignore filter through listLocalFileTree recursion - Pass the filter via ExportOptions to DefaultExporter for archive filtering during sync push - Downgrade ignore package from v7 to v5 to match the rest of the dependency tree and avoid TypeScript type conflicts --- apps/studio/src/ipc-handlers.ts | 18 ++++++++++++++---- .../export/exporters/default-exporter.ts | 10 ++++++++-- .../src/lib/import-export/export/types.ts | 2 ++ apps/studio/src/modules/sync/constants.ts | 10 +++++----- .../src/modules/sync/lib/ipc-handlers.ts | 8 ++++++++ apps/studio/src/modules/sync/lib/tree-utils.ts | 12 ++++-------- package-lock.json | 11 +---------- tools/common/lib/deploy-ignore.ts | 9 ++++++++- tools/common/package.json | 2 +- 9 files changed, 51 insertions(+), 31 deletions(-) diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 8dfb223dc2..7f0abc841c 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -24,6 +24,7 @@ import { } from '@studio/common/lib/agent-skills'; import { validateBlueprintData } from '@studio/common/lib/blueprint-validation'; import { parseCliError, errorMessageContains } from '@studio/common/lib/cli-error'; +import { createDeployIgnoreFilter } from '@studio/common/lib/deploy-ignore'; import { calculateDirectorySizeForArchive, isWordPressDirectory, @@ -93,6 +94,7 @@ import { import { editSiteViaCli, EditSiteOptions } from 'src/modules/cli/lib/cli-site-editor'; import { isStudioCliInstalled } from 'src/modules/cli/lib/ipc-handlers'; import { STABLE_BIN_DIR_PATH } from 'src/modules/cli/lib/windows-installation-manager'; +import { SYNC_ADDITIONAL_DEFAULTS } from 'src/modules/sync/constants'; import { shouldExcludeFromSync, shouldLimitDepth } from 'src/modules/sync/lib/tree-utils'; import { supportedEditorConfig, SupportedEditor } from 'src/modules/user-settings/lib/editor'; import { getUserEditor, getUserTerminal } from 'src/modules/user-settings/lib/ipc-handlers'; @@ -107,6 +109,7 @@ import { updateAppdata, } from 'src/storage/user-data'; import { Blueprint } from 'src/stores/wpcom-api'; +import type { Ignore } from 'ignore'; import type { RawDirectoryEntry } from 'src/modules/sync/types'; import type { WpCliResult } from 'src/site-server'; @@ -1635,11 +1638,16 @@ export async function listLocalFileTree( siteId: string, path: string, maxDepth: number = 3, - currentDepth: number = 0 + currentDepth: number = 0, + deployIgnore?: Ignore ): Promise< RawDirectoryEntry[] > { const server = SiteServer.get( siteId ); if ( ! server ) throw new Error( 'Site not found' ); + if ( ! deployIgnore ) { + deployIgnore = await createDeployIgnoreFilter( server.details.path, SYNC_ADDITIONAL_DEFAULTS ); + } + const fullPath = nodePath.join( server.details.path, path ); try { @@ -1647,12 +1655,13 @@ export async function listLocalFileTree( const result = []; for ( const entry of entries ) { - if ( shouldExcludeFromSync( entry.name ) ) { + const itemPath = nodePath.join( path, entry.name ).replace( /\\/g, '/' ); + + if ( shouldExcludeFromSync( itemPath, deployIgnore ) ) { continue; } const isDirectory = entry.isDirectory(); - const itemPath = nodePath.join( path, entry.name ).replace( /\\/g, '/' ); const directoryEntry: RawDirectoryEntry = { name: entry.name, @@ -1668,7 +1677,8 @@ export async function listLocalFileTree( siteId, itemPath, maxDepth, - currentDepth + 1 + currentDepth + 1, + deployIgnore ); } catch ( childErr ) { console.warn( `Failed to load children for ${ itemPath }:`, childErr ); diff --git a/apps/studio/src/lib/import-export/export/exporters/default-exporter.ts b/apps/studio/src/lib/import-export/export/exporters/default-exporter.ts index 5508d1b32e..e87f7dc022 100644 --- a/apps/studio/src/lib/import-export/export/exporters/default-exporter.ts +++ b/apps/studio/src/lib/import-export/export/exporters/default-exporter.ts @@ -226,14 +226,20 @@ export class DefaultExporter extends EventEmitter implements Exporter { ); if ( this.isExactPathExcluded( entryPathRelativeToArchiveRoot ) || - this.isPathExcludedByPattern( fullEntryPathOnDisk ) + this.isPathExcludedByPattern( fullEntryPathOnDisk ) || + this.options.deployIgnore?.ignores( + entryPathRelativeToArchiveRoot.replace( /\\/g, '/' ) + ) ) { return false; } return entry; } ); } else { - if ( this.isExactPathExcluded( archivePath ) ) { + if ( + this.isExactPathExcluded( archivePath ) || + this.options.deployIgnore?.ignores( archivePath.replace( /\\/g, '/' ) ) + ) { continue; } this.archiveBuilder.file( fullPath, { name: archivePath } ); diff --git a/apps/studio/src/lib/import-export/export/types.ts b/apps/studio/src/lib/import-export/export/types.ts index b4c33f60db..34f74e442b 100644 --- a/apps/studio/src/lib/import-export/export/types.ts +++ b/apps/studio/src/lib/import-export/export/types.ts @@ -1,5 +1,6 @@ import type { ProgressData } from 'archiver'; import type { EventEmitter } from 'events'; +import type { Ignore } from 'ignore'; export interface ExportOptions { site: SiteDetails; @@ -8,6 +9,7 @@ export interface ExportOptions { phpVersion: string; splitDatabaseDumpByTable?: boolean; specificSelectionPaths?: string[]; + deployIgnore?: Ignore; } export type ExportOptionsIncludes = 'wpContent' | 'database'; diff --git a/apps/studio/src/modules/sync/constants.ts b/apps/studio/src/modules/sync/constants.ts index 8fd0cd8e10..0faa325412 100644 --- a/apps/studio/src/modules/sync/constants.ts +++ b/apps/studio/src/modules/sync/constants.ts @@ -1,11 +1,11 @@ -export const SYNC_EXCLUSIONS = [ +/** + * Studio-internal exclusions that should always be excluded from sync, + * in addition to the base deploy-ignore defaults. + */ +export const SYNC_ADDITIONAL_DEFAULTS = [ 'database', 'db.php', 'debug.log', 'sqlite-database-integration', - '.DS_Store', - 'Thumbs.db', - '.git', - 'node_modules', 'cache', ]; diff --git a/apps/studio/src/modules/sync/lib/ipc-handlers.ts b/apps/studio/src/modules/sync/lib/ipc-handlers.ts index 6ff3a7f51c..c8f9009b23 100644 --- a/apps/studio/src/modules/sync/lib/ipc-handlers.ts +++ b/apps/studio/src/modules/sync/lib/ipc-handlers.ts @@ -3,6 +3,7 @@ import fs from 'fs'; import fsPromises from 'fs/promises'; import { randomUUID } from 'node:crypto'; import path from 'node:path'; +import { createDeployIgnoreFilter } from '@studio/common/lib/deploy-ignore'; import { getCurrentUserId } from '@studio/common/lib/shared-config'; import wpcomFactory from '@studio/common/lib/wpcom-factory'; import wpcomXhrRequest from '@studio/common/lib/wpcom-xhr-request-factory'; @@ -20,6 +21,7 @@ import { exportBackup } from 'src/lib/import-export/export/export-manager'; import { ExportOptions } from 'src/lib/import-export/export/types'; import { getAuthenticationToken } from 'src/lib/oauth'; import { keepSqliteIntegrationUpdated } from 'src/lib/sqlite-versions'; +import { SYNC_ADDITIONAL_DEFAULTS } from 'src/modules/sync/constants'; import { SyncSite } from 'src/modules/sync/types'; import { SiteServer } from 'src/site-server'; import { loadUserData, lockAppdata, saveUserData, unlockAppdata } from 'src/storage/user-data'; @@ -163,6 +165,11 @@ export async function exportSiteForPush( await keepSqliteIntegrationUpdated( site.details.path ); + const deployIgnore = await createDeployIgnoreFilter( + site.details.path, + SYNC_ADDITIONAL_DEFAULTS + ); + const shouldIncludeSyncOption = ( optionsToSync: SyncOption[] | undefined, option: SyncOption @@ -186,6 +193,7 @@ export async function exportSiteForPush( phpVersion: site.details.phpVersion, splitDatabaseDumpByTable: true, specificSelectionPaths: configuration?.specificSelectionPaths, + deployIgnore, }; const onEvent = () => {}; diff --git a/apps/studio/src/modules/sync/lib/tree-utils.ts b/apps/studio/src/modules/sync/lib/tree-utils.ts index e4a27a1623..e2cd0967cd 100644 --- a/apps/studio/src/modules/sync/lib/tree-utils.ts +++ b/apps/studio/src/modules/sync/lib/tree-utils.ts @@ -1,17 +1,13 @@ import { TreeNode } from 'src/components/tree-view'; -import { SYNC_EXCLUSIONS } from '../constants'; import type { RawDirectoryEntry } from '../types'; +import type { Ignore } from 'ignore'; -export const shouldExcludeFromSync = ( itemName: string ): boolean => { +export const shouldExcludeFromSync = ( relativePath: string, deployIgnore: Ignore ): boolean => { + const itemName = relativePath.split( '/' ).pop() || ''; if ( itemName.startsWith( '.' ) ) { return true; } - - if ( SYNC_EXCLUSIONS.includes( itemName ) ) { - return true; - } - - return false; + return deployIgnore.ignores( relativePath ); }; export const shouldLimitDepth = ( relativePath: string ): boolean => { diff --git a/package-lock.json b/package-lock.json index df02592bab..d2963ef893 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26919,7 +26919,7 @@ "cross-port-killer": "^1.4.0", "date-fns": "^3.3.1", "fast-deep-equal": "^3.1.3", - "ignore": "^7.0.5", + "ignore": "^5.3.2", "lockfile": "^1.0.4", "wpcom": "^7.1.1", "wpcom-xhr-request": "^1.3.0", @@ -26932,15 +26932,6 @@ "@wp-playground/blueprints": "3.1.15" } }, - "tools/common/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "tools/compare-perf": { "version": "0.0.1", "license": "GPLv2", diff --git a/tools/common/lib/deploy-ignore.ts b/tools/common/lib/deploy-ignore.ts index 6298250406..ee21403fbb 100644 --- a/tools/common/lib/deploy-ignore.ts +++ b/tools/common/lib/deploy-ignore.ts @@ -16,9 +16,16 @@ const DEPLOY_IGNORE_FILENAME = '.deployignore'; * and any patterns from a .deployignore file at the given root. * * @param rootPath - The site root directory to look for .deployignore in + * @param additionalDefaults - Extra patterns to include as built-in defaults */ -export async function createDeployIgnoreFilter( rootPath: string ): Promise< Ignore > { +export async function createDeployIgnoreFilter( + rootPath: string, + additionalDefaults?: string[] +): Promise< Ignore > { const ig = ignore().add( DEPLOY_IGNORE_DEFAULTS ); + if ( additionalDefaults ) { + ig.add( additionalDefaults ); + } const deployIgnorePath = path.join( rootPath, DEPLOY_IGNORE_FILENAME ); try { diff --git a/tools/common/package.json b/tools/common/package.json index 453398e31c..0f9f645d86 100644 --- a/tools/common/package.json +++ b/tools/common/package.json @@ -13,7 +13,7 @@ "cross-port-killer": "^1.4.0", "date-fns": "^3.3.1", "fast-deep-equal": "^3.1.3", - "ignore": "^7.0.5", + "ignore": "^5.3.2", "lockfile": "^1.0.4", "wpcom": "^7.1.1", "wpcom-xhr-request": "^1.3.0", From e70aff52cf72fbb11e93c920a074e5dc286ecf8d Mon Sep 17 00:00:00 2001 From: Ivan Ottinger <25105483+ivan-ottinger@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:22:17 +0200 Subject: [PATCH 2/5] Check deployIgnore on top-level archive paths When specificSelectionPaths is undefined, the exporter reads wp-content from disk directly. Top-level directories like wordpress-seo need to be checked against the deploy-ignore filter before archiving, not just their children in the callback. --- .../lib/import-export/export/exporters/default-exporter.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/studio/src/lib/import-export/export/exporters/default-exporter.ts b/apps/studio/src/lib/import-export/export/exporters/default-exporter.ts index e87f7dc022..154de425e4 100644 --- a/apps/studio/src/lib/import-export/export/exporters/default-exporter.ts +++ b/apps/studio/src/lib/import-export/export/exporters/default-exporter.ts @@ -216,6 +216,10 @@ export class DefaultExporter extends EventEmitter implements Exporter { continue; } + if ( this.options.deployIgnore?.ignores( archivePath.replace( /\\/g, '/' ) ) ) { + continue; + } + const stat = await fs.promises.stat( fullPath ); if ( stat.isDirectory() ) { this.archiveBuilder.directory( fullPath, archivePath, ( entry ) => { From e88ba03df98b0b718c0519e7dd077bc91674f7ff Mon Sep 17 00:00:00 2001 From: Ivan Ottinger <25105483+ivan-ottinger@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:32:29 +0200 Subject: [PATCH 3/5] Filter meta.json plugin and theme lists against deployIgnore The server-side import uses meta.json to install plugins/themes from WordPress.org. Without filtering this list, excluded plugins would be reinstalled by the server even though their files were excluded from the archive. --- .../export/exporters/default-exporter.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/studio/src/lib/import-export/export/exporters/default-exporter.ts b/apps/studio/src/lib/import-export/export/exporters/default-exporter.ts index 154de425e4..818ee050c1 100644 --- a/apps/studio/src/lib/import-export/export/exporters/default-exporter.ts +++ b/apps/studio/src/lib/import-export/export/exporters/default-exporter.ts @@ -22,6 +22,7 @@ import { Exporter, BackupCreateProgressEventData, StudioJson, + StudioJsonPluginOrTheme, } from 'src/lib/import-export/export/types'; import { getWordPressVersionFromInstallation } from 'src/lib/wp-versions'; import { SiteServer } from 'src/site-server'; @@ -302,8 +303,18 @@ export class DefaultExporter extends EventEmitter implements Exporter { this.getSiteThemes( this.options.site.id ), ] ); - studioJson.plugins = plugins; - studioJson.themes = themes; + studioJson.plugins = this.options.deployIgnore + ? plugins.filter( + ( p: StudioJsonPluginOrTheme ) => + ! this.options.deployIgnore!.ignores( `wp-content/plugins/${ p.name }` ) + ) + : plugins; + studioJson.themes = this.options.deployIgnore + ? themes.filter( + ( t: StudioJsonPluginOrTheme ) => + ! this.options.deployIgnore!.ignores( `wp-content/themes/${ t.name }` ) + ) + : themes; const tempDir = await fs.promises.mkdtemp( path.join( os.tmpdir(), 'studio-export-' ) ); const studioJsonPath = path.join( tempDir, 'meta.json' ); From fd6e94c1593c87ac2ebcd16e4a36a65311089e15 Mon Sep 17 00:00:00 2001 From: Ivan Ottinger <25105483+ivan-ottinger@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:54:06 +0200 Subject: [PATCH 4/5] Update SYNC_ADDITIONAL_DEFAULTS comment to clarify overridability --- apps/studio/src/modules/sync/constants.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/studio/src/modules/sync/constants.ts b/apps/studio/src/modules/sync/constants.ts index 0faa325412..4f8de06231 100644 --- a/apps/studio/src/modules/sync/constants.ts +++ b/apps/studio/src/modules/sync/constants.ts @@ -1,6 +1,7 @@ /** - * Studio-internal exclusions that should always be excluded from sync, - * in addition to the base deploy-ignore defaults. + * Studio-internal exclusions excluded from sync by default, + * in addition to the base deploy-ignore defaults. These are pre-seeded + * but can be overridden via negation patterns in .deployignore. */ export const SYNC_ADDITIONAL_DEFAULTS = [ 'database', From edfa8e533e33506429ff6cde42cd2836fd055c2d Mon Sep 17 00:00:00 2001 From: Ivan Ottinger <25105483+ivan-ottinger@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:55:18 +0200 Subject: [PATCH 5/5] Add tests for additionalDefaults parameter and negation override --- tools/common/lib/tests/deploy-ignore.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tools/common/lib/tests/deploy-ignore.test.ts b/tools/common/lib/tests/deploy-ignore.test.ts index be0c2b8eac..dc758e7d5b 100644 --- a/tools/common/lib/tests/deploy-ignore.test.ts +++ b/tools/common/lib/tests/deploy-ignore.test.ts @@ -72,6 +72,20 @@ describe( 'createDeployIgnoreFilter', () => { expect( ig.ignores( 'uploads/2025/photo.jpg' ) ).toBe( false ); } ); + it( 'should apply additional defaults when provided', async () => { + const ig = await createDeployIgnoreFilter( tempDir, [ 'cache', 'database' ] ); + expect( ig.ignores( 'cache' ) ).toBe( true ); + expect( ig.ignores( 'database' ) ).toBe( true ); + expect( ig.ignores( '.git' ) ).toBe( true ); + } ); + + it( 'should allow .deployignore to override additional defaults via negation', async () => { + fs.writeFileSync( path.join( tempDir, '.deployignore' ), '!cache\n' ); + const ig = await createDeployIgnoreFilter( tempDir, [ 'cache', 'database' ] ); + expect( ig.ignores( 'cache' ) ).toBe( false ); + expect( ig.ignores( 'database' ) ).toBe( true ); + } ); + it( 'should handle empty .deployignore file', async () => { fs.writeFileSync( path.join( tempDir, '.deployignore' ), '' ); const ig = await createDeployIgnoreFilter( tempDir );