Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
2ab3274
Fix EEXIST when wp-content path is blocked by a non-directory file
ivan-ottinger May 20, 2026
12431b6
Simplify ensureDir by dropping the defensive isDirectory check
ivan-ottinger May 20, 2026
55ba472
Handle ENOTDIR ancestor blocker and log the unlink
ivan-ottinger May 20, 2026
2678104
Walk up to find blocker; do not trust error.path cross-platform
ivan-ottinger May 20, 2026
9296fa3
Trim ensureDir comments
ivan-ottinger May 20, 2026
6aff819
Bound ensureDir blocker search to a rootDir
ivan-ottinger May 20, 2026
cc44b60
Revert "Bound ensureDir blocker search to a rootDir"
ivan-ottinger May 20, 2026
706b640
Merge remote-tracking branch 'origin/trunk' into fix-import-eexist-sy…
ivan-ottinger May 20, 2026
d160c34
Simplify findNonDirectoryAncestor with a top-down walk
ivan-ottinger May 20, 2026
3b3381a
Drop redundant ensureDir comment
ivan-ottinger May 20, 2026
191924b
Restore ensureDir intent comment and trim the algorithm restatement
ivan-ottinger May 20, 2026
bac7a8b
Log ensureDir blocker removal only after unlink succeeds
ivan-ottinger May 21, 2026
27d51aa
Drop findNonDirectoryAncestor doc comment
ivan-ottinger May 21, 2026
cef106d
Recurse upward in ensureDir instead of a separate path walker
ivan-ottinger May 21, 2026
5506260
Move ensureDir blocker to trash instead of unlinking
ivan-ottinger May 21, 2026
9690c31
Merge remote-tracking branch 'origin/trunk' into fix-import-eexist-sy…
ivan-ottinger May 21, 2026
76cc459
Merge branch 'trunk' into fix-import-eexist-symlink-stub
gavande1 May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion apps/cli/lib/import-export/import/importers/importer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createInterface } from 'readline';
import { DEFAULT_PHP_VERSION } from '@studio/common/constants';
import { generateBackupFilename } from '@studio/common/lib/generate-backup-filename';
import { ImportEvents } from '@studio/common/lib/import-export-events';
import { isErrnoException } from '@studio/common/lib/is-errno-exception';
import { serializePlugins } from '@studio/common/lib/serialize-plugins';
import { type SupportedPHPVersion } from '@studio/common/types/php-versions';
import { __, sprintf } from '@wordpress/i18n';
Expand All @@ -28,6 +29,34 @@ export interface Importer extends ImportExportEventEmitter {
import( site: SiteData ): Promise< ImporterResult >;
}

// Recovers from EEXIST/ENOTDIR by removing a non-directory blocker on the path.
export async function ensureDir( dir: string ): Promise< void > {
try {
await fs.promises.mkdir( dir, { recursive: true } );
} catch ( error ) {
if ( ! isErrnoException( error ) || ( error.code !== 'EEXIST' && error.code !== 'ENOTDIR' ) ) {
throw error;
}
const parent = path.dirname( dir );
if ( parent === dir ) {
throw error;
}
await ensureDir( parent );
try {
const stat = await fs.promises.stat( dir );
if ( ! stat.isDirectory() ) {
await trash( dir );
console.warn( `ensureDir: moved non-directory blocker at ${ dir } to trash` );
}
} catch ( e ) {
if ( ! isErrnoException( e ) || e.code !== 'ENOENT' ) {
throw e;
}
}
await fs.promises.mkdir( dir, { recursive: true } );
}
}

abstract class BaseImporter extends ImportExportEventEmitter implements Importer {
protected meta?: MetaFileData;

Expand Down Expand Up @@ -236,7 +265,7 @@ abstract class BaseBackupImporter extends BaseImporter {
);

const destPath = path.join( wpContentDestDir, relativePath );
await fs.promises.mkdir( path.dirname( destPath ), { recursive: true } );
await ensureDir( path.dirname( destPath ) );
await fs.promises.copyFile( file, destPath );

processedItems++;
Expand Down
55 changes: 55 additions & 0 deletions apps/cli/lib/import-export/import/importers/tests/importer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { ensureDir } from '../importer';

describe( 'ensureDir', () => {
let tmpDir: string;

beforeEach( () => {
tmpDir = fs.mkdtempSync( path.join( os.tmpdir(), 'studio-ensure-dir-' ) );
} );

afterEach( () => {
fs.rmSync( tmpDir, { recursive: true, force: true } );
} );

it( 'creates a directory that does not exist', async () => {
const target = path.join( tmpDir, 'a', 'b', 'c' );
await ensureDir( target );
expect( fs.lstatSync( target ).isDirectory() ).toBe( true );
} );

it( 'is a no-op when the directory already exists', async () => {
const target = path.join( tmpDir, 'existing' );
fs.mkdirSync( target );
await expect( ensureDir( target ) ).resolves.toBeUndefined();
expect( fs.lstatSync( target ).isDirectory() ).toBe( true );
} );

it( 'replaces a non-directory file blocking the target path', async () => {
const plugins = path.join( tmpDir, 'wp-content', 'plugins' );
fs.mkdirSync( plugins, { recursive: true } );
const blocker = path.join( plugins, 'akismet' );
fs.writeFileSync( blocker, '/managed/akismet' );
expect( fs.lstatSync( blocker ).isFile() ).toBe( true );

await ensureDir( blocker );

expect( fs.lstatSync( blocker ).isDirectory() ).toBe( true );
} );

it( 'replaces a non-directory file blocking an ancestor of the target path', async () => {
const plugins = path.join( tmpDir, 'wp-content', 'plugins' );
fs.mkdirSync( plugins, { recursive: true } );
const blocker = path.join( plugins, 'akismet' );
fs.writeFileSync( blocker, '/managed/akismet' );

const deeper = path.join( blocker, '_inc' );
await ensureDir( deeper );

expect( fs.lstatSync( blocker ).isDirectory() ).toBe( true );
expect( fs.lstatSync( deeper ).isDirectory() ).toBe( true );
} );
} );