[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