From 4a22e54284f37807bfb5c69ced63c334f9676aa3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:51:10 +0000 Subject: [PATCH 01/10] Initial plan From 249767ead1a8ed368acee2beba12d9b3184e9647 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:59:16 +0000 Subject: [PATCH 02/10] Add media replace subcommand Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- composer.json | 1 + features/media-replace.feature | 108 +++++++++++++++++++++++++++ src/Media_Command.php | 132 ++++++++++++++++++++++++++++++++- 3 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 features/media-replace.feature diff --git a/composer.json b/composer.json index c5a3779b..3c443a14 100644 --- a/composer.json +++ b/composer.json @@ -39,6 +39,7 @@ "media fix-orientation", "media import", "media regenerate", + "media replace", "media image-size" ] }, diff --git a/features/media-replace.feature b/features/media-replace.feature new file mode 100644 index 00000000..8638fa29 --- /dev/null +++ b/features/media-replace.feature @@ -0,0 +1,108 @@ +Feature: Replace WordPress attachment files + + Background: + Given a WP install + + Scenario: Replace an attachment file with a local file + 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 --porcelain` + Then save STDOUT as {ATTACHMENT_ID} + + When I run `wp media replace {ATTACHMENT_ID} {CACHE_DIR}/canola.jpg` + Then STDOUT should contain: + """ + Replaced file for attachment ID {ATTACHMENT_ID} + """ + And STDOUT should contain: + """ + Success: Replaced 1 of 1 images. + """ + + Scenario: Replace an attachment file from a URL + 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 --porcelain` + Then save STDOUT as {ATTACHMENT_ID} + + When I run `wp media replace {ATTACHMENT_ID} 'http://wp-cli.org/behat-data/canola.jpg'` + Then STDOUT should contain: + """ + Replaced file for attachment ID {ATTACHMENT_ID} + """ + And STDOUT should contain: + """ + Success: Replaced 1 of 1 images. + """ + + Scenario: Replace an attachment file and output only the attachment ID in porcelain mode + 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 --porcelain` + Then save STDOUT as {ATTACHMENT_ID} + + When I run `wp media replace {ATTACHMENT_ID} {CACHE_DIR}/canola.jpg --porcelain` + Then STDOUT should be: + """ + {ATTACHMENT_ID} + """ + + Scenario: Preserve attachment metadata after replacing the file + 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 Image Title" --porcelain` + Then save STDOUT as {ATTACHMENT_ID} + + When I run `wp media replace {ATTACHMENT_ID} {CACHE_DIR}/canola.jpg` + Then STDOUT should contain: + """ + Success: Replaced 1 of 1 images. + """ + + When I run `wp post get {ATTACHMENT_ID} --field=post_title` + Then STDOUT should be: + """ + My Image Title + """ + + Scenario: Error when replacing with a non-existent local file + Given download: + | path | url | + | {CACHE_DIR}/large-image.jpg | http://wp-cli.org/behat-data/large-image.jpg | + + When I run `wp media import {CACHE_DIR}/large-image.jpg --porcelain` + Then save STDOUT as {ATTACHMENT_ID} + + When I try `wp media replace {ATTACHMENT_ID} /tmp/nonexistent-file.jpg` + Then STDERR should contain: + """ + Error: Unable to replace attachment + """ + And STDERR should contain: + """ + File doesn't exist. + """ + And the return code should be 1 + + Scenario: Error when replacing with an invalid attachment ID + When I try `wp media replace 999999 /tmp/fake.jpg` + Then STDERR should contain: + """ + Error: Invalid attachment ID 999999. + """ + And the return code should be 1 diff --git a/src/Media_Command.php b/src/Media_Command.php index 1fc15663..c81e52c7 100644 --- a/src/Media_Command.php +++ b/src/Media_Command.php @@ -3,7 +3,7 @@ use WP_CLI\Utils; /** - * Imports files as attachments, regenerates thumbnails, or lists registered image sizes. + * Imports files as attachments, regenerates thumbnails, replaces existing attachment files, or lists registered image sizes. * * ## EXAMPLES * @@ -548,6 +548,136 @@ public function import( $args, $assoc_args = array() ) { } } + /** + * Replaces the file for an existing attachment while preserving its identity. + * + * ## OPTIONS + * + * + * : ID of the attachment whose file is to be replaced. + * + * + * : Path to the replacement file. Supports local paths and URLs. + * + * [--skip-delete] + * : Skip deletion of old thumbnail files after replacement. + * + * [--porcelain] + * : Output just the attachment ID after replacement. + * + * ## EXAMPLES + * + * # Replace an attachment file with a local file. + * $ wp media replace 123 ~/new-image.jpg + * Replaced file for attachment ID 123 with '/home/user/new-image.jpg'. + * Success: Replaced 1 of 1 images. + * + * # Replace an attachment file with a file from a URL. + * $ wp media replace 123 'http://example.com/image.jpg' + * Replaced file for attachment ID 123 with 'http://example.com/image.jpg'. + * Success: Replaced 1 of 1 images. + * + * # Replace and output just the attachment ID. + * $ wp media replace 123 ~/new-image.jpg --porcelain + * 123 + * + * @param string[] $args Positional arguments. + * @param array{'skip-delete'?: bool, porcelain?: bool} $assoc_args Associative arguments. + * @return void + */ + public function replace( $args, $assoc_args = array() ) { + $attachment_id = (int) $args[0]; + $file = $args[1]; + + // Validate attachment exists. + $attachment = get_post( $attachment_id ); + if ( ! $attachment || 'attachment' !== $attachment->post_type ) { + WP_CLI::error( "Invalid attachment ID {$attachment_id}." ); + } + + // Handle remote vs local file (same pattern as import). + // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- parse_url will only be used in absence of wp_parse_url. + $is_file_remote = function_exists( 'wp_parse_url' ) ? wp_parse_url( $file, PHP_URL_HOST ) : parse_url( $file, PHP_URL_HOST ); + $orig_filename = $file; + + if ( empty( $is_file_remote ) ) { + if ( ! file_exists( $file ) ) { + WP_CLI::error( "Unable to replace attachment {$attachment_id} with file '{$file}'. Reason: File doesn't exist." ); + } + $tempfile = $this->make_copy( $file ); + $name = Utils\basename( $file ); + } else { + $tempfile = download_url( $file ); + if ( is_wp_error( $tempfile ) ) { + WP_CLI::error( + sprintf( + "Unable to replace attachment %d with file '%s'. Reason: %s", + $attachment_id, + $file, + implode( ', ', $tempfile->get_error_messages() ) + ) + ); + } + $name = (string) strtok( Utils\basename( $file ), '?' ); + } + + // Get old metadata before replacement for cleanup. + $old_fullsizepath = $this->get_attached_file( $attachment_id ); + $old_metadata = wp_get_attachment_metadata( $attachment_id ); + + // Move the temp file into the uploads directory. + $file_array = array( + 'name' => $name, + 'tmp_name' => $tempfile, + ); + + $uploaded = wp_handle_sideload( $file_array, array( 'test_form' => false ) ); + + if ( isset( $uploaded['error'] ) ) { + WP_CLI::error( "Failed to process file '{$orig_filename}': {$uploaded['error']}" ); + } + + $new_file_path = $uploaded['file']; + $new_mime_type = $uploaded['type']; + + // Delete old thumbnail files unless asked to skip. + if ( ! Utils\get_flag_value( $assoc_args, 'skip-delete' ) + && false !== $old_fullsizepath + && is_array( $old_metadata ) + ) { + $this->remove_old_images( $old_metadata, $old_fullsizepath, array() ); + } + + // Update the attachment's MIME type. + $updated = wp_update_post( + array( + 'ID' => $attachment_id, + 'post_mime_type' => $new_mime_type, + ) + ); + if ( is_wp_error( $updated ) ) { + WP_CLI::warning( + sprintf( 'Failed to update MIME type for attachment %d: %s', $attachment_id, $updated->get_error_message() ) + ); + } + + // Update the attached file path. + update_attached_file( $attachment_id, $new_file_path ); + + // Generate and update new attachment metadata. + $new_metadata = wp_generate_attachment_metadata( $attachment_id, $new_file_path ); + wp_update_attachment_metadata( $attachment_id, $new_metadata ); + + if ( Utils\get_flag_value( $assoc_args, 'porcelain' ) ) { + WP_CLI::line( (string) $attachment_id ); + } else { + WP_CLI::log( + sprintf( "Replaced file for attachment ID %d with '%s'.", $attachment_id, $orig_filename ) + ); + Utils\report_batch_operation_results( 'image', 'replace', 1, 1, 0 ); + } + } + /** * Lists image sizes registered with WordPress. * From ba8050b865dc47f811b1395e403d2c3964f741d4 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 20 Mar 2026 08:13:45 +0100 Subject: [PATCH 03/10] Update src/Media_Command.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Media_Command.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Media_Command.php b/src/Media_Command.php index c81e52c7..8f5832d7 100644 --- a/src/Media_Command.php +++ b/src/Media_Command.php @@ -634,6 +634,9 @@ public function replace( $args, $assoc_args = array() ) { $uploaded = wp_handle_sideload( $file_array, array( 'test_form' => false ) ); if ( isset( $uploaded['error'] ) ) { + if ( isset( $tempfile ) && is_string( $tempfile ) && file_exists( $tempfile ) ) { + unlink( $tempfile ); + } WP_CLI::error( "Failed to process file '{$orig_filename}': {$uploaded['error']}" ); } From 018be36f8600ed281a53395a8e809aff582a6737 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 20 Mar 2026 08:14:47 +0100 Subject: [PATCH 04/10] Update src/Media_Command.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Media_Command.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Media_Command.php b/src/Media_Command.php index 8f5832d7..e65f8306 100644 --- a/src/Media_Command.php +++ b/src/Media_Command.php @@ -656,11 +656,13 @@ public function replace( $args, $assoc_args = array() ) { array( 'ID' => $attachment_id, 'post_mime_type' => $new_mime_type, - ) + ), + true ); - if ( is_wp_error( $updated ) ) { + if ( false === $updated || is_wp_error( $updated ) ) { + $message = is_wp_error( $updated ) ? $updated->get_error_message() : 'Unknown error.'; WP_CLI::warning( - sprintf( 'Failed to update MIME type for attachment %d: %s', $attachment_id, $updated->get_error_message() ) + sprintf( 'Failed to update MIME type for attachment %d: %s', $attachment_id, $message ) ); } From 4f6b949a15a9f4f4a5e48ba03e197452e32560b0 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 20 Mar 2026 08:15:23 +0100 Subject: [PATCH 05/10] Update src/Media_Command.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Media_Command.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Media_Command.php b/src/Media_Command.php index e65f8306..80981c33 100644 --- a/src/Media_Command.php +++ b/src/Media_Command.php @@ -679,7 +679,7 @@ public function replace( $args, $assoc_args = array() ) { WP_CLI::log( sprintf( "Replaced file for attachment ID %d with '%s'.", $attachment_id, $orig_filename ) ); - Utils\report_batch_operation_results( 'image', 'replace', 1, 1, 0 ); + Utils\report_batch_operation_results( 'attachment', 'replace', 1, 1, 0 ); } } From 886d4d29dfc9164bc7ce91011fe35019593b8160 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 20 Mar 2026 08:15:56 +0100 Subject: [PATCH 06/10] Update src/Media_Command.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Media_Command.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Media_Command.php b/src/Media_Command.php index 80981c33..d629739d 100644 --- a/src/Media_Command.php +++ b/src/Media_Command.php @@ -671,7 +671,16 @@ public function replace( $args, $assoc_args = array() ) { // Generate and update new attachment metadata. $new_metadata = wp_generate_attachment_metadata( $attachment_id, $new_file_path ); - wp_update_attachment_metadata( $attachment_id, $new_metadata ); + if ( is_array( $new_metadata ) && ! empty( $new_metadata ) ) { + wp_update_attachment_metadata( $attachment_id, $new_metadata ); + } else { + WP_CLI::warning( + sprintf( + "Failed to generate new attachment metadata for attachment ID %d. Existing metadata has been preserved.", + $attachment_id + ) + ); + } if ( Utils\get_flag_value( $assoc_args, 'porcelain' ) ) { WP_CLI::line( (string) $attachment_id ); From c10d8b1a02e8b474e946ed91eed5fe4f74a92131 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 20 Mar 2026 08:16:06 +0100 Subject: [PATCH 07/10] Update src/Media_Command.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Media_Command.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/Media_Command.php b/src/Media_Command.php index d629739d..852a597b 100644 --- a/src/Media_Command.php +++ b/src/Media_Command.php @@ -649,6 +649,24 @@ public function replace( $args, $assoc_args = array() ) { && is_array( $old_metadata ) ) { $this->remove_old_images( $old_metadata, $old_fullsizepath, array() ); + + // Also delete the previous full-size file itself to avoid leaving an orphan. + if ( $old_fullsizepath !== $new_file_path && file_exists( $old_fullsizepath ) ) { + @unlink( $old_fullsizepath ); + } + + // For big-image scaling (WP 5.3+), delete the original image if present in metadata. + if ( ! empty( $old_metadata['original_image'] ) && ! empty( $old_metadata['file'] ) ) { + $uploads = wp_get_upload_dir(); + if ( ! empty( $uploads['basedir'] ) ) { + $dirname = dirname( $old_metadata['file'] ); + $original_image_rel = ( '.' === $dirname || '/' === $dirname ) ? $old_metadata['original_image'] : $dirname . '/' . $old_metadata['original_image']; + $original_image_abspath = $uploads['basedir'] . '/' . $original_image_rel; + if ( $original_image_abspath !== $new_file_path && file_exists( $original_image_abspath ) ) { + @unlink( $original_image_abspath ); + } + } + } } // Update the attachment's MIME type. From 8fc573a1cac1c7a04f0d9b8ce5eb25176b9944f7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 07:23:56 +0000 Subject: [PATCH 08/10] Address PR review feedback: fix noun, temp file cleanup, error handling, skip-delete test Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- features/media-replace.feature | 27 ++++++++++++++++++++++++--- src/Media_Command.php | 14 +++++++------- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/features/media-replace.feature b/features/media-replace.feature index 8638fa29..d90497ac 100644 --- a/features/media-replace.feature +++ b/features/media-replace.feature @@ -20,7 +20,7 @@ Feature: Replace WordPress attachment files """ And STDOUT should contain: """ - Success: Replaced 1 of 1 images. + Success: Replaced 1 of 1 attachments. """ Scenario: Replace an attachment file from a URL @@ -39,7 +39,7 @@ Feature: Replace WordPress attachment files """ And STDOUT should contain: """ - Success: Replaced 1 of 1 images. + Success: Replaced 1 of 1 attachments. """ Scenario: Replace an attachment file and output only the attachment ID in porcelain mode @@ -71,7 +71,7 @@ Feature: Replace WordPress attachment files When I run `wp media replace {ATTACHMENT_ID} {CACHE_DIR}/canola.jpg` Then STDOUT should contain: """ - Success: Replaced 1 of 1 images. + Success: Replaced 1 of 1 attachments. """ When I run `wp post get {ATTACHMENT_ID} --field=post_title` @@ -106,3 +106,24 @@ Feature: Replace WordPress attachment files Error: Invalid attachment ID 999999. """ And the return code should be 1 + + Scenario: Skip deletion of old thumbnails when --skip-delete flag is used + 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 --porcelain` + Then save STDOUT as {ATTACHMENT_ID} + + When I run `wp post meta get {ATTACHMENT_ID} _wp_attached_file` + Then save STDOUT as {OLD_FILE} + + When I run `wp media replace {ATTACHMENT_ID} {CACHE_DIR}/canola.jpg --skip-delete` + Then STDOUT should contain: + """ + Success: Replaced 1 of 1 attachments. + """ + + Then the wp-content/uploads/{OLD_FILE} file should exist diff --git a/src/Media_Command.php b/src/Media_Command.php index 852a597b..582816af 100644 --- a/src/Media_Command.php +++ b/src/Media_Command.php @@ -656,11 +656,12 @@ public function replace( $args, $assoc_args = array() ) { } // For big-image scaling (WP 5.3+), delete the original image if present in metadata. - if ( ! empty( $old_metadata['original_image'] ) && ! empty( $old_metadata['file'] ) ) { + $original_image = isset( $old_metadata['original_image'] ) ? (string) $old_metadata['original_image'] : ''; + if ( '' !== $original_image && ! empty( $old_metadata['file'] ) ) { $uploads = wp_get_upload_dir(); if ( ! empty( $uploads['basedir'] ) ) { - $dirname = dirname( $old_metadata['file'] ); - $original_image_rel = ( '.' === $dirname || '/' === $dirname ) ? $old_metadata['original_image'] : $dirname . '/' . $old_metadata['original_image']; + $dirname = dirname( $old_metadata['file'] ); + $original_image_rel = ( '.' === $dirname || '/' === $dirname ) ? $original_image : $dirname . '/' . $original_image; $original_image_abspath = $uploads['basedir'] . '/' . $original_image_rel; if ( $original_image_abspath !== $new_file_path && file_exists( $original_image_abspath ) ) { @unlink( $original_image_abspath ); @@ -677,10 +678,9 @@ public function replace( $args, $assoc_args = array() ) { ), true ); - if ( false === $updated || is_wp_error( $updated ) ) { - $message = is_wp_error( $updated ) ? $updated->get_error_message() : 'Unknown error.'; + if ( is_wp_error( $updated ) ) { WP_CLI::warning( - sprintf( 'Failed to update MIME type for attachment %d: %s', $attachment_id, $message ) + sprintf( 'Failed to update MIME type for attachment %d: %s', $attachment_id, $updated->get_error_message() ) ); } @@ -694,7 +694,7 @@ public function replace( $args, $assoc_args = array() ) { } else { WP_CLI::warning( sprintf( - "Failed to generate new attachment metadata for attachment ID %d. Existing metadata has been preserved.", + 'Failed to generate new attachment metadata for attachment ID %d. Existing metadata has been preserved.', $attachment_id ) ); From d79ce7b43ca54c7615be16334908b3aca2f83590 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 20 Mar 2026 08:35:35 +0100 Subject: [PATCH 09/10] Apply suggestion from @swissspidy --- features/media-replace.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/media-replace.feature b/features/media-replace.feature index d90497ac..06981ec6 100644 --- a/features/media-replace.feature +++ b/features/media-replace.feature @@ -126,4 +126,4 @@ Feature: Replace WordPress attachment files Success: Replaced 1 of 1 attachments. """ - Then the wp-content/uploads/{OLD_FILE} file should exist + And the wp-content/uploads/{OLD_FILE} file should exist From 3451d7865782338ff54795672493dd2b6b5e0148 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Sat, 21 Mar 2026 00:28:38 +0100 Subject: [PATCH 10/10] Lint fix --- src/Media_Command.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Media_Command.php b/src/Media_Command.php index 22b2133f..3f7e2129 100644 --- a/src/Media_Command.php +++ b/src/Media_Command.php @@ -751,7 +751,7 @@ public function replace( $args, $assoc_args = array() ) { WP_CLI::error( "Unable to replace attachment {$attachment_id} with file '{$file}'. Reason: File doesn't exist." ); } $tempfile = $this->make_copy( $file ); - $name = Utils\basename( $file ); + $name = Path::basename( $file ); } else { $tempfile = download_url( $file ); if ( is_wp_error( $tempfile ) ) { @@ -764,7 +764,7 @@ public function replace( $args, $assoc_args = array() ) { ) ); } - $name = (string) strtok( Utils\basename( $file ), '?' ); + $name = (string) strtok( Path::basename( $file ), '?' ); } // Get old metadata before replacement for cleanup.