Fix EEXIST in importer when wp-content path is blocked by a non-directory file#3553
Merged
Conversation
When a Pressable site is checked out via Git on Windows with core.symlinks=false, tracked symlinks (e.g. wp-content/plugins/akismet) materialize as small regular files containing the symlink target as text. The Jetpack importer's subsequent mkdir(...recursive:true) on that path throws EEXIST because the path exists and is not a directory, aborting the Sync pull. ensureDir() unlinks a non-directory blocking the target before retrying. Happy paths are unchanged.
mkdir with recursive:true is documented to throw EEXIST only when the path exists and is not a directory, so the post-catch lstat was guarding an impossible case.
mkdir(recursive:true) throws ENOTDIR (not EEXIST) when the blocker is an intermediate path component, e.g. when the importer copies a deep file like wp-content/plugins/akismet/_inc/akismet.css before a top-level file in the same plugin. Catch both codes, require error.path, and warn about the removed blocker so production logs reflect when the recovery fires. Adds a regression test for the ancestor-blocker case.
Node populates error.path differently on Linux (the original mkdir target) versus Windows (the actual non-directory blocker). The previous code unlinked error.path and worked only on Windows; on Linux the unlink hit the same ENOTDIR. Replace it with a directed lstat walk-up from dir to locate the real blocker, then unlink that.
Contributor
There was a problem hiding this comment.
Pull request overview
This PR hardens the CLI import path by handling cases where a non-directory file (often from Windows Git symlink checkouts) blocks creation of directories under wp-content, preventing mkdir({ recursive: true }) from throwing EEXIST/ENOTDIR and aborting the import.
Changes:
- Add
ensureDir(dir)helper that retriesmkdir(recursive:true)after locating and unlinking a non-directory path component blocker. - Use
ensureDirwhen preparing destination directories duringwp-contentimport. - Add Vitest unit coverage for creation, idempotency, and both “target blocker” and “ancestor blocker” scenarios.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| apps/cli/lib/import-export/import/importers/importer.ts | Introduces ensureDir + blocker discovery and uses it in importWpContent before copying files. |
| apps/cli/lib/import-export/import/importers/tests/importer.test.ts | Adds unit tests validating ensureDir behavior across common blocker cases. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Pass the destination wp-content directory to ensureDir and refuse to unlink any blocker that resolves outside of it. Without this guard, a malformed archive entry that escapes the destination tree (e.g. via `..` segments) could cause an unrelated file on disk to be unlinked before mkdir retries.
This reverts commit 6aff819.
The previous bottom-up walk had to special-case ENOTDIR on lstat to keep climbing through a file blocker on macOS (where lstat below a file throws ENOTDIR rather than ENOENT). Walking top-down sidesteps the quirk — we never stat below a file because we stop at the first non-directory. Use stat (not lstat) so that symlinks to directories, which mkdir traverses without complaint, are recognized as directories rather than flagged as blockers.
Collaborator
📊 Performance Test ResultsComparing 76cc459 vs trunk app-size
site-editor
site-startup
Results are median values from multiple test runs. Legend: 🟢 Improvement (faster) | 🔴 Regression (slower) | ⚪ No change (<50ms diff) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Related issues
How AI was used in this PR
Diagnosis, reproduction, and patch were drafted with Claude Code. The bug was reproduced end-to-end on Windows; the underlying Node fs behavior was verified via probes on Windows, macOS, and the Linux CI runs on this branch's commits.
Proposed Changes
ensureDir( dir )helper inapps/cli/lib/import-export/import/importers/importer.tsthat recovers fromEEXIST/ENOTDIRonmkdir(recursive:true)by moving the non-directory blocker to trash before retrying.BaseBackupImporter.importWpContent— the call site that throws.(
EEXIST), and ancestor-component blocker (ENOTDIR).Why this happens
The reporter's Pressable repo
serverbranch trackswp-content/plugins/akismetas a Git symlink (mode120000, confirmed viagit ls-tree). On their Windows machine withcore.symlinks=false(Git default on Windows),git checkoutmaterialized that entry as a 41-byte regular file containing the symlink target text. The next Studio Sync pull failed in the Jetpack importer:mkdir(recursive:true)treatsEEXISTas a no-op only when the existing path is itself a directory. A regular file at that path makes mkdir throw and the import abort.A related case the helper also handles: when
mkdiris called on a path below the blocker (e.g.…/akismet/_incwhile…/akismetis the file), Node raisesENOTDIRinstead.When
mkdirfails, the helper recurses upward: it ensures the parent directory exists first, thenstats the leaf — if it exists and isn't a directory, move it to trash before retryingmkdir. Each level only has to decide whether its own path is a blocker, because the recursion guarantees the parent is already a directory by the time control returns. Trash (rather than unlink) is used so that — in the unlikely event the blocker is something the user values rather than a checkout artifact — the file is recoverable, matching the precedent set bymoveExistingDatabaseToTrashin the same file.Testing Instructions
1. Unit tests
Four tests pass.
2. End-to-end Sync pull
Connect a Pressable site to Studio and run an initial Sync pull. Note the local site path, e.g.
~/Studio/my-site.Replace the akismet plugin directory with a regular file:
macOS / Linux:
Windows (PowerShell):
Trigger another Sync pull in the Studio UI.
Pull should succeed.
Against the unpatched
trunkbuild, the pull fails withEEXIST: file already exists, mkdir '...wp-content/plugins/akismet'.Pre-merge Checklist
wp-studioworkspace; ESLint clean on modified filesapps/cli/lib/import-export)