From 2d45f30e3eced0a40381da39d2e77b828bf5ffdd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:50:48 +0000 Subject: [PATCH 1/6] Initial plan From 87ff0046aafa366ca4035f07e491e53cc371bd33 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 23:00:16 +0000 Subject: [PATCH 2/6] Add wp media prune command to remove generated thumbnails Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- composer.json | 1 + features/media-prune.feature | 175 ++++++++++++++++++++++++++ src/Media_Command.php | 233 +++++++++++++++++++++++++++++++++++ 3 files changed, 409 insertions(+) create mode 100644 features/media-prune.feature diff --git a/composer.json b/composer.json index c5a3779b..c5ed11aa 100644 --- a/composer.json +++ b/composer.json @@ -38,6 +38,7 @@ "media", "media fix-orientation", "media import", + "media prune", "media regenerate", "media image-size" ] diff --git a/features/media-prune.feature b/features/media-prune.feature new file mode 100644 index 00000000..d2844314 --- /dev/null +++ b/features/media-prune.feature @@ -0,0 +1,175 @@ +Feature: Prune WordPress attachment thumbnails + + Background: + Given a WP install + And I try `wp theme install twentynineteen --activate` + + Scenario: Prune all images while none exists + When I try `wp media prune --yes` + Then STDERR should contain: + """ + No images found. + """ + And the return code should be 0 + + @require-wp-5.3 + Scenario: Prune all thumbnails for all images + Given download: + | path | url | + | {CACHE_DIR}/large-image.jpg | http://wp-cli.org/behat-data/large-image.jpg | + | {CACHE_DIR}/canola.jpg | http://wp-cli.org/behat-data/canola.jpg | + And I run `wp option update uploads_use_yearmonth_folders 0` + + When I run `wp media import {CACHE_DIR}/large-image.jpg --title="My large attachment" --porcelain` + Then save STDOUT as {LARGE_ATTACHMENT_ID} + And the wp-content/uploads/large-image.jpg file should exist + And the wp-content/uploads/large-image-scaled.jpg file should exist + And the wp-content/uploads/large-image-150x150.jpg file should exist + And the wp-content/uploads/large-image-300x225.jpg file should exist + + When I run `wp media import {CACHE_DIR}/canola.jpg --title="My medium attachment" --porcelain` + Then save STDOUT as {MEDIUM_ATTACHMENT_ID} + And the wp-content/uploads/canola.jpg file should exist + And the wp-content/uploads/canola-150x150.jpg file should exist + And the wp-content/uploads/canola-300x225.jpg file should exist + + When I run `wp media prune --yes` + Then STDOUT should contain: + """ + Found 2 images to prune. + """ + And STDOUT should contain: + """ + /2 Pruned thumbnails for "My large attachment" (ID {LARGE_ATTACHMENT_ID}) + """ + And STDOUT should contain: + """ + /2 Pruned thumbnails for "My medium attachment" (ID {MEDIUM_ATTACHMENT_ID}) + """ + And STDOUT should contain: + """ + Success: Pruned 2 of 2 images. + """ + And the wp-content/uploads/large-image.jpg file should exist + And the wp-content/uploads/large-image-scaled.jpg file should exist + And the wp-content/uploads/large-image-150x150.jpg file should not exist + And the wp-content/uploads/large-image-300x225.jpg file should not exist + And the wp-content/uploads/canola.jpg file should exist + And the wp-content/uploads/canola-150x150.jpg file should not exist + And the wp-content/uploads/canola-300x225.jpg file should not exist + + And I run `wp post meta get {LARGE_ATTACHMENT_ID} _wp_attachment_metadata --format=json` + Then STDOUT should not contain: + """ + "thumbnail" + """ + And STDOUT should not contain: + """ + "medium" + """ + + @require-wp-5.3 + Scenario: Prune a specific image size + Given download: + | path | url | + | {CACHE_DIR}/large-image.jpg | http://wp-cli.org/behat-data/large-image.jpg | + And I run `wp option update uploads_use_yearmonth_folders 0` + + When I run `wp media import {CACHE_DIR}/large-image.jpg --title="My large attachment" --porcelain` + Then save STDOUT as {LARGE_ATTACHMENT_ID} + And the wp-content/uploads/large-image-150x150.jpg file should exist + And the wp-content/uploads/large-image-300x225.jpg file should exist + + When I run `wp media prune --image_size=thumbnail {LARGE_ATTACHMENT_ID}` + Then STDOUT should contain: + """ + Pruned thumbnails for "My large attachment" (ID {LARGE_ATTACHMENT_ID}) + """ + And STDOUT should contain: + """ + Success: Pruned 1 of 1 images. + """ + And the wp-content/uploads/large-image-150x150.jpg file should not exist + And the wp-content/uploads/large-image-300x225.jpg file should exist + + @require-wp-5.3 + Scenario: Prune does not remove abandoned (unregistered) thumbnails by default + Given download: + | path | url | + | {CACHE_DIR}/large-image.jpg | http://wp-cli.org/behat-data/large-image.jpg | + And a wp-content/mu-plugins/media-settings.php file: + """ + ...] + * : One or more IDs of the attachments to prune. + * + * [--image_size=...] + * : Name of the image size to remove. Repeat the flag to specify multiple. Only thumbnails of specified image size(s) will be removed, thumbnails of other image sizes will not. + * + * [--remove-abandoned] + * : Also remove thumbnails for image sizes that are no longer registered. + * + * [--yes] + * : Answer yes to the confirmation message. Confirmation only shows when no IDs passed as arguments. + * + * ## EXAMPLES + * + * # Remove all generated thumbnails for all images, without confirmation. + * $ wp media prune --yes + * Found 3 images to prune. + * 1/3 Pruned thumbnails for "Sydney Harbor Bridge" (ID 760). + * 2/3 Pruned thumbnails for "Boardwalk" (ID 757). + * 3/3 Pruned thumbnails for "Sunburst Over River" (ID 756). + * Success: Pruned 3 of 3 images. + * + * # Remove only the "large" thumbnails for all images. + * $ wp media prune --image_size=large + * Do you really want to prune the "large" image size for all images? [y/n] y + * Found 3 images to prune. + * 1/3 Pruned thumbnails for "Sydney Harbor Bridge" (ID 760). + * 2/3 Pruned thumbnails for "Boardwalk" (ID 757). + * 3/3 Pruned thumbnails for "Sunburst Over River" (ID 756). + * Success: Pruned 3 of 3 images. + * + * # Remove all thumbnails including those for unregistered sizes. + * $ wp media prune --remove-abandoned --yes + * Found 3 images to prune. + * 1/3 Pruned thumbnails for "Sydney Harbor Bridge" (ID 760). + * 2/3 Pruned thumbnails for "Boardwalk" (ID 757). + * 3/3 Pruned thumbnails for "Sunburst Over River" (ID 756). + * Success: Pruned 3 of 3 images. + * + * @param string[] $args Positional arguments. + * @param array{image_size?: string|string[], 'remove-abandoned'?: bool, yes?: bool} $assoc_args Associative arguments. + * @return void + */ + public function prune( $args, $assoc_args = array() ) { + // Extract image_size separately as it may be a string or an array of strings. + $image_size_raw = $assoc_args['image_size'] ?? null; + unset( $assoc_args['image_size'] ); + + // Normalize to an array: with WP-CLI 3.x and the ellipsis syntax, repeated flags yield an array. + // With earlier versions a single string is returned. + $image_sizes = array(); + if ( null !== $image_size_raw ) { + $image_sizes = is_array( $image_size_raw ) ? $image_size_raw : [ $image_size_raw ]; + } + + if ( $image_sizes ) { + $registered_sizes = get_intermediate_image_sizes(); + foreach ( $image_sizes as $size ) { + if ( ! in_array( $size, $registered_sizes, true ) ) { + WP_CLI::error( sprintf( 'Unknown image size "%s".', $size ) ); + } + } + } + + $remove_abandoned = Utils\get_flag_value( $assoc_args, 'remove-abandoned' ); + + if ( empty( $args ) ) { + if ( $image_sizes ) { + WP_CLI::confirm( + sprintf( + 'Do you really want to prune the %s for all images?', + $this->get_image_sizes_description( $image_sizes, 'image size' ) + ), + $assoc_args + ); + } else { + WP_CLI::confirm( 'Do you really want to prune all images?', $assoc_args ); + } + } + + $additional_mime_types = array(); + + if ( Utils\wp_version_compare( '4.7', '>=' ) ) { + $additional_mime_types[] = 'application/pdf'; + } + + $images = $this->get_images( $args, $additional_mime_types ); + $count = $images->post_count; + + if ( ! $count ) { + WP_CLI::warning( 'No images found.' ); + return; + } + + WP_CLI::log( + sprintf( + 'Found %1$d %2$s to prune.', + $count, + _n( 'image', 'images', $count ) + ) + ); + + $number = 0; + $successes = 0; + $errors = 0; + $skips = 0; + + /** + * @var int $post_id + */ + foreach ( $images->posts as $post_id ) { + ++$number; + if ( 0 === $number % self::WP_CLEAR_OBJECT_CACHE_INTERVAL ) { + // @phpstan-ignore function.deprecated + Utils\wp_clear_object_cache(); + } + $this->process_prune( $post_id, $image_sizes, $remove_abandoned, $number . '/' . $count, $successes, $errors, $skips ); + } + + Utils\report_batch_operation_results( 'image', 'prune', $count, $successes, $errors, $skips ); + } + /** * Creates attachments from local files or URLs. * @@ -849,6 +989,99 @@ private function process_regeneration( $id, $skip_delete, $only_missing, $delete ++$successes; } + /** + * Process pruning of generated thumbnails for an attachment. + * + * @param int $id Attachment ID. + * @param string[] $image_sizes Specific image size names to prune; empty means all registered sizes. + * @param bool $remove_abandoned Also remove thumbnails for unregistered (abandoned) image sizes. + * @param string $progress Current progress string (e.g. "1/5"). + * @param int $successes Count of successes. Passed by reference. + * @param int $errors Count of errors. Passed by reference. + * @param int $skips Count of skips. Passed by reference. + * @param-out int $successes + * @param-out int $errors + * @param-out int $skips + * @return void + */ + private function process_prune( $id, $image_sizes, $remove_abandoned, $progress, &$successes, &$errors, &$skips ) { + $title = get_the_title( $id ); + if ( '' === $title ) { + if ( metadata_exists( 'post', $id, '_cover_hash' ) ) { + $att_desc = sprintf( 'cover attachment (ID %d)', $id ); + } else { + $att_desc = sprintf( '"(no title)" (ID %d)', $id ); + } + } else { + $att_desc = sprintf( '"%1$s" (ID %2$d)', $title, $id ); + } + + $fullsizepath = $this->get_attached_file( $id ); + + if ( false === $fullsizepath || ! file_exists( $fullsizepath ) ) { + WP_CLI::warning( "Can't find $att_desc." ); + ++$errors; + return; + } + + $metadata = wp_get_attachment_metadata( $id ); + + if ( ! is_array( $metadata ) || empty( $metadata['sizes'] ) ) { + WP_CLI::log( "$progress No thumbnails to prune for $att_desc." ); + ++$skips; + return; + } + + $registered_sizes = get_intermediate_image_sizes(); + $dir_path = dirname( $fullsizepath ) . '/'; + $sizes_to_prune = array(); + + foreach ( $metadata['sizes'] as $size_name => $size_meta ) { + $is_registered = in_array( $size_name, $registered_sizes, true ); + + // Determine whether this size should be pruned. + if ( $image_sizes ) { + // Specific sizes requested: prune if explicitly listed, or if abandoned with --remove-abandoned. + $should_prune = in_array( $size_name, $image_sizes, true ) + || ( ! $is_registered && $remove_abandoned ); + } else { + // No specific sizes: prune all registered sizes, plus abandoned if requested. + $should_prune = $is_registered || $remove_abandoned; + } + + if ( ! $should_prune ) { + continue; + } + + $intermediate_path = $dir_path . $size_meta['file']; + + // Never remove the full-size file. + if ( $intermediate_path === $fullsizepath ) { + continue; + } + + if ( file_exists( $intermediate_path ) ) { + unlink( $intermediate_path ); + } + + $sizes_to_prune[] = $size_name; + } + + if ( empty( $sizes_to_prune ) ) { + WP_CLI::log( "$progress No thumbnails to prune for $att_desc." ); + ++$skips; + return; + } + + foreach ( $sizes_to_prune as $size_name ) { + unset( $metadata['sizes'][ $size_name ] ); + } + wp_update_attachment_metadata( $id, $metadata ); + + WP_CLI::log( "$progress Pruned thumbnails for $att_desc." ); + ++$successes; + } + /** * Removes old images. * From 9102a6fd04c978e1009af54d384236b8e914eb75 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 20 Mar 2026 00:23:15 +0100 Subject: [PATCH 3/6] Update src/Media_Command.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Media_Command.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Media_Command.php b/src/Media_Command.php index 7182abca..e909266f 100644 --- a/src/Media_Command.php +++ b/src/Media_Command.php @@ -1037,6 +1037,10 @@ private function process_prune( $id, $image_sizes, $remove_abandoned, $progress, $sizes_to_prune = array(); foreach ( $metadata['sizes'] as $size_name => $size_meta ) { + // The 'full' size is not an intermediate size and should never be pruned. + if ( 'full' === $size_name ) { + continue; + } $is_registered = in_array( $size_name, $registered_sizes, true ); // Determine whether this size should be pruned. From 071fc7388953f0cab14f96ea452c38806408cd67 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:46:33 +0000 Subject: [PATCH 4/6] Check unlink() return value in process_prune, log warning on failure Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/Media_Command.php | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Media_Command.php b/src/Media_Command.php index e909266f..a8a39e5b 100644 --- a/src/Media_Command.php +++ b/src/Media_Command.php @@ -1035,6 +1035,7 @@ private function process_prune( $id, $image_sizes, $remove_abandoned, $progress, $registered_sizes = get_intermediate_image_sizes(); $dir_path = dirname( $fullsizepath ) . '/'; $sizes_to_prune = array(); + $failed_delete = false; foreach ( $metadata['sizes'] as $size_name => $size_meta ) { // The 'full' size is not an intermediate size and should never be pruned. @@ -1064,16 +1065,22 @@ private function process_prune( $id, $image_sizes, $remove_abandoned, $progress, continue; } - if ( file_exists( $intermediate_path ) ) { - unlink( $intermediate_path ); + if ( file_exists( $intermediate_path ) && ! unlink( $intermediate_path ) ) { + WP_CLI::warning( "Could not delete thumbnail file '{$size_meta['file']}' for $att_desc." ); + $failed_delete = true; + continue; } $sizes_to_prune[] = $size_name; } if ( empty( $sizes_to_prune ) ) { - WP_CLI::log( "$progress No thumbnails to prune for $att_desc." ); - ++$skips; + if ( $failed_delete ) { + ++$errors; + } else { + WP_CLI::log( "$progress No thumbnails to prune for $att_desc." ); + ++$skips; + } return; } From 2cd99a82686c3133bd4234f6130ef0a037e03afb Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 20 Mar 2026 08:31:11 +0100 Subject: [PATCH 5/6] Apply suggestions from code review Co-authored-by: Pascal Birchler --- features/media-prune.feature | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/features/media-prune.feature b/features/media-prune.feature index d2844314..f73db433 100644 --- a/features/media-prune.feature +++ b/features/media-prune.feature @@ -58,7 +58,7 @@ Feature: Prune WordPress attachment thumbnails And the wp-content/uploads/canola-150x150.jpg file should not exist And the wp-content/uploads/canola-300x225.jpg file should not exist - And I run `wp post meta get {LARGE_ATTACHMENT_ID} _wp_attachment_metadata --format=json` + When I run `wp post meta get {LARGE_ATTACHMENT_ID} _wp_attachment_metadata --format=json` Then STDOUT should not contain: """ "thumbnail" @@ -123,7 +123,7 @@ Feature: Prune WordPress attachment thumbnails """ And the wp-content/uploads/large-image-200x200.jpg file should exist - And I run `wp post meta get {LARGE_ATTACHMENT_ID} _wp_attachment_metadata --format=json` + When I run `wp post meta get {LARGE_ATTACHMENT_ID} _wp_attachment_metadata --format=json` Then STDOUT should contain: """ "abandoned_size" @@ -160,7 +160,7 @@ Feature: Prune WordPress attachment thumbnails """ And the wp-content/uploads/large-image-200x200.jpg file should not exist - And I run `wp post meta get {LARGE_ATTACHMENT_ID} _wp_attachment_metadata --format=json` + When I run `wp post meta get {LARGE_ATTACHMENT_ID} _wp_attachment_metadata --format=json` Then STDOUT should not contain: """ "abandoned_size" From 0c9ee9b9fe54960f0aa519ca8f7af4dd6e73d992 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 20 Mar 2026 08:52:41 +0100 Subject: [PATCH 6/6] Update src/Media_Command.php --- src/Media_Command.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Media_Command.php b/src/Media_Command.php index a8a39e5b..4318d645 100644 --- a/src/Media_Command.php +++ b/src/Media_Command.php @@ -1046,9 +1046,8 @@ private function process_prune( $id, $image_sizes, $remove_abandoned, $progress, // Determine whether this size should be pruned. if ( $image_sizes ) { - // Specific sizes requested: prune if explicitly listed, or if abandoned with --remove-abandoned. - $should_prune = in_array( $size_name, $image_sizes, true ) - || ( ! $is_registered && $remove_abandoned ); + // Specific sizes requested: only prune if explicitly listed. + $should_prune = in_array( $size_name, $image_sizes, true ); } else { // No specific sizes: prune all registered sizes, plus abandoned if requested. $should_prune = $is_registered || $remove_abandoned;