[wp-trac] [WordPress Trac] #65150: Issue with copy_dir() function t does not skips folders.
WordPress Trac
noreply at wordpress.org
Wed Apr 29 11:11:57 UTC 2026
#65150: Issue with copy_dir() function t does not skips folders.
----------------------------+------------------------------
Reporter: neo2k23 | Owner: (none)
Type: defect (bug) | Status: new
Priority: normal | Milestone: Awaiting Review
Component: Filesystem API | Version: trunk
Severity: normal | Resolution:
Keywords: | Focuses:
----------------------------+------------------------------
Comment (by hbhalodia):
Hi @neo2k23, Thanks for the ticket. I can verify the issue.
The issue is only with the nested subfolders if that contain the name, but
within same level it is getting skipped. But we also need to remove for
nested subfolders. In earlier version, the regex based matching was used,
which captured from nested subfolders as well, hence it was working. While
when doing conversion to array based matching, the nested folders scenario
was not handled.
== AI Usage
- GH Copilot
- Claude 4.6 Opus
- Asked AI to review the issue with current copy_dir function and was used
in version 3.2.1. Sharing below the detailed summary.
== Root Cause
=== Background
In WordPress ≤ 3.2.1, `copy_dir()` used a **regex-based** skip mechanism.
It built a pattern from the `$skip_list` and matched it against the **full
path** (`$from . $filename`) using a `$` anchor. The **entire
`$skip_list`** was passed unchanged to every recursive call:
{{{
// WP 3.2.1 — old behavior
$skip_regex = '!(' . rtrim($skip_regex, '|') . ')$!i';
// Matched against full path at every depth
if ( preg_match( $skip_regex, $from . $filename ) )
continue;
// Full skip_list passed to recursive calls
$result = copy_dir( $from . $filename, $to . $filename, $skip_list );
}}}
This meant a bare folder name like `'wp-content'` in the skip list would
match at **any** nesting depth because the regex anchored to the end of
the full path.
=== What Changed (Post-3.2.1)
The function was refactored to use `in_array()` for matching and a
decomposed `$sub_skip_list` for recursive calls:
{{{
// Current code — only top-level match works
if ( in_array( $filename, $skip_list, true ) ) {
continue;
}
// Only path-prefixed items are propagated
$sub_skip_list = array();
foreach ( $skip_list as $skip_item ) {
if ( str_starts_with( $skip_item, $filename . '/' ) ) {
$sub_skip_list[] = preg_replace( '!^' . preg_quote( $filename, '!'
) . '/!i', '', $skip_item );
}
}
$result = copy_dir( $from . $filename, $to . $filename, $sub_skip_list );
}}}
The `$sub_skip_list` generation **only keeps items prefixed with
`$filename . '/'`** and strips the prefix. Bare names (without `/`) are
**silently dropped**, so they never reach deeper recursion levels.
=== Example
Given this structure and `$skip_list = array( 'skip-me' )`:
{{{
source/
├── skip-me/ ← Skipped ✅ (top-level match via in_array)
├── folderA/
│ └── skip-me/ ← NOT skipped ❌ (dropped from sub_skip_list)
└── folderB/
└── folderC/
└── skip-me/ ← NOT skipped ❌ (dropped from sub_skip_list)
}}}
When recursing into `folderA`, the loop checks `str_starts_with( 'skip-
me', 'folderA/' )` → `false`, so `'skip-me'` is excluded from
`$sub_skip_list`. The nested `skip-me/` folder is then copied when it
shouldn't be.
== Fix
Added an `elseif` branch that propagates **bare names** (those without
`/`) into the `$sub_skip_list`:
{{{
foreach ( $skip_list as $skip_item ) {
if ( str_starts_with( $skip_item, $filename . '/' ) ) {
$sub_skip_list[] = preg_replace( '!^' . preg_quote( $filename,
'!' ) . '/!i', '', $skip_item );
+ } elseif ( ! str_contains( $skip_item, '/' ) ) {
+ $sub_skip_list[] = $skip_item;
}
}
}}}
=== Why This Works
- **Path-based items** (e.g. `'wp-admin/about.php'`) continue to be
decomposed as before — only `'about.php'` is passed when recursing into
`wp-admin/`.
- **Bare names** (e.g. `'wp-content'`, `'skip-me'`) are now passed through
to **all** subdirectory levels, restoring the pre-3.2.1 behavior where
they are matched at every depth.
- Items containing `/` that **don't** match the current directory prefix
are correctly excluded (they belong to a different subtree).
Thanks,
--
Ticket URL: <https://core.trac.wordpress.org/ticket/65150#comment:2>
WordPress Trac <https://core.trac.wordpress.org/>
WordPress publishing platform
More information about the wp-trac
mailing list