From f1c06669322d9594d5b29b398762ff5893305609 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:01:33 +0000 Subject: [PATCH 1/6] Initial plan From 23cd649ef36373817fbf3b7b510f9a49f63da9af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:10:48 +0000 Subject: [PATCH 2/6] Add wp term prune command to delete terms with 0 or 1 published posts Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- composer.json | 1 + features/term-prune.feature | 137 ++++++++++++++++++++++++++++++++++++ src/Term_Command.php | 103 +++++++++++++++++++++++++++ 3 files changed, 241 insertions(+) create mode 100644 features/term-prune.feature diff --git a/composer.json b/composer.json index a5258111a..8ca40bac7 100644 --- a/composer.json +++ b/composer.json @@ -190,6 +190,7 @@ "term meta pluck", "term meta update", "term recount", + "term prune", "term update", "user", "user add-cap", diff --git a/features/term-prune.feature b/features/term-prune.feature new file mode 100644 index 000000000..6bb4dfae1 --- /dev/null +++ b/features/term-prune.feature @@ -0,0 +1,137 @@ +Feature: Prune unused taxonomy terms + + Background: + Given a WP install + + Scenario: Prune terms with no published posts + When I run `wp term create post_tag 'Unused Tag' --slug=unused-tag --porcelain` + Then STDOUT should be a number + And save STDOUT as {TERM_ID} + + When I run `wp term prune post_tag` + Then STDOUT should contain: + """ + Deleted post_tag {TERM_ID}. + """ + And STDOUT should contain: + """ + Success: + """ + And the return code should be 0 + + When I try `wp term get post_tag {TERM_ID}` + Then STDERR should contain: + """ + Error: Term doesn't exist. + """ + + Scenario: Does not prune terms with more than one published post + When I run `wp term create post_tag 'Popular Tag' --slug=popular-tag --porcelain` + Then STDOUT should be a number + And save STDOUT as {TERM_ID} + + When I run `wp post create --post_title='Post 1' --post_status=publish --porcelain` + Then STDOUT should be a number + And save STDOUT as {POST_ID_1} + + When I run `wp post create --post_title='Post 2' --post_status=publish --porcelain` + Then STDOUT should be a number + And save STDOUT as {POST_ID_2} + + When I run `wp post term set {POST_ID_1} post_tag {TERM_ID} --by=id` + Then STDOUT should not be empty + + When I run `wp post term set {POST_ID_2} post_tag {TERM_ID} --by=id` + Then STDOUT should not be empty + + When I run `wp term prune post_tag` + Then STDOUT should not contain: + """ + Deleted post_tag {TERM_ID}. + """ + + When I run `wp term get post_tag {TERM_ID} --field=name` + Then STDOUT should be: + """ + Popular Tag + """ + + Scenario: Prune terms with exactly one published post + When I run `wp term create post_tag 'Single Post Tag' --slug=single-post-tag --porcelain` + Then STDOUT should be a number + And save STDOUT as {TERM_ID} + + When I run `wp post create --post_title='Post 1' --post_status=publish --porcelain` + Then STDOUT should be a number + And save STDOUT as {POST_ID} + + When I run `wp post term set {POST_ID} post_tag {TERM_ID} --by=id` + Then STDOUT should not be empty + + When I run `wp term prune post_tag` + Then STDOUT should contain: + """ + Deleted post_tag {TERM_ID}. + """ + And the return code should be 0 + + When I try `wp term get post_tag {TERM_ID}` + Then STDERR should contain: + """ + Error: Term doesn't exist. + """ + + Scenario: Dry run previews terms without deleting them + When I run `wp term create post_tag 'Unused Tag' --slug=unused-tag --porcelain` + Then STDOUT should be a number + And save STDOUT as {TERM_ID} + + When I run `wp term prune post_tag --dry-run` + Then STDOUT should contain: + """ + Would delete post_tag {TERM_ID}. + """ + And STDOUT should contain: + """ + Success: + """ + And the return code should be 0 + + When I run `wp term get post_tag {TERM_ID} --field=name` + Then STDOUT should be: + """ + Unused Tag + """ + + Scenario: Prune with an invalid taxonomy + When I try `wp term prune nonexistent_taxonomy` + Then STDERR should be: + """ + Error: Taxonomy nonexistent_taxonomy doesn't exist. + """ + And the return code should be 1 + + Scenario: Prune multiple taxonomies at once + # Assign an extra post to the default Uncategorized category so its count + # exceeds the prune threshold and it won't interfere with the test. + When I run `wp post create --post_title='Extra Post' --post_status=publish --post_category=1 --porcelain` + Then STDOUT should be a number + + When I run `wp term create post_tag 'Unused Tag' --slug=unused-tag --porcelain` + Then STDOUT should be a number + And save STDOUT as {TAG_TERM_ID} + + When I run `wp term create category 'Unused Category' --slug=unused-category --porcelain` + Then STDOUT should be a number + And save STDOUT as {CAT_TERM_ID} + + When I run `wp term prune post_tag category` + Then STDOUT should contain: + """ + Deleted post_tag {TAG_TERM_ID}. + """ + And STDOUT should contain: + """ + Deleted category {CAT_TERM_ID}. + """ + And the return code should be 0 diff --git a/src/Term_Command.php b/src/Term_Command.php index 6547d834c..3e2560271 100644 --- a/src/Term_Command.php +++ b/src/Term_Command.php @@ -35,6 +35,11 @@ * Success: Updated category term count * Success: Updated post_tag term count * + * # Prune terms with 0 or 1 published posts + * $ wp term prune post_tag + * Deleted post_tag 15. + * Success: Pruned 1 of 5 terms. + * * @package wp-cli */ class Term_Command extends WP_CLI_Command { @@ -682,6 +687,104 @@ public function recount( $args ) { } } + /** + * Removes terms with 0 or 1 published posts from one or more taxonomies. + * + * Useful for cleaning up large sites with many unused or barely-used terms. + * The term count is based on the number of published posts assigned to each + * term. + * + * ## OPTIONS + * + * ... + * : One or more taxonomies to prune. + * + * [--dry-run] + * : Preview the terms to be pruned, without actually deleting them. + * + * ## EXAMPLES + * + * # Prune post tags with 0 or 1 published posts. + * $ wp term prune post_tag + * Deleted post_tag 15. + * Success: Pruned 1 of 5 terms. + * + * # Dry run to preview which terms would be pruned. + * $ wp term prune post_tag --dry-run + * Would delete post_tag 15. + * Success: 1 post_tag term would be pruned. + * + * # Prune multiple taxonomies at once. + * $ wp term prune category post_tag + * Deleted category 8. + * Success: Pruned 1 of 3 terms. + * Deleted post_tag 15. + * Success: Pruned 1 of 5 terms. + */ + public function prune( $args, $assoc_args ) { + foreach ( $args as $taxonomy ) { + if ( ! taxonomy_exists( $taxonomy ) ) { + WP_CLI::error( "Taxonomy {$taxonomy} doesn't exist." ); + } + } + + $dry_run = (bool) Utils\get_flag_value( $assoc_args, 'dry-run', false ); + + foreach ( $args as $taxonomy ) { + $terms = get_terms( + [ + 'taxonomy' => $taxonomy, + 'hide_empty' => false, + ] + ); + + // This should never happen because of the taxonomy_exists check above. + if ( is_wp_error( $terms ) ) { + WP_CLI::warning( "Could not retrieve terms for taxonomy {$taxonomy}." ); + continue; + } + + /** + * @var \WP_Term[] $terms + */ + + $total = 0; + $successes = 0; + $errors = 0; + + foreach ( $terms as $term ) { + if ( $term->count > 1 ) { + continue; + } + + ++$total; + + if ( $dry_run ) { + WP_CLI::log( "Would delete {$taxonomy} {$term->term_id}." ); + ++$successes; + continue; + } + + $result = wp_delete_term( $term->term_id, $taxonomy ); + + if ( is_wp_error( $result ) ) { + WP_CLI::warning( $result ); + ++$errors; + } elseif ( $result ) { + WP_CLI::log( "Deleted {$taxonomy} {$term->term_id}." ); + ++$successes; + } + } + + if ( $dry_run ) { + $term_word = Utils\pluralize( 'term', $successes ); + WP_CLI::success( "{$successes} {$taxonomy} {$term_word} would be pruned." ); + } else { + Utils\report_batch_operation_results( 'term', 'prune', $total, $successes, $errors ); + } + } + } + /** * Migrate a term of a taxonomy to another taxonomy. * From ffec9faa32169898522a9712e0ea91700380808f Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 2 Mar 2026 17:52:28 +0100 Subject: [PATCH 3/6] Update src/Term_Command.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Term_Command.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Term_Command.php b/src/Term_Command.php index 3e2560271..d84f43b20 100644 --- a/src/Term_Command.php +++ b/src/Term_Command.php @@ -773,6 +773,9 @@ public function prune( $args, $assoc_args ) { } elseif ( $result ) { WP_CLI::log( "Deleted {$taxonomy} {$term->term_id}." ); ++$successes; + } else { + WP_CLI::warning( "Failed to delete {$taxonomy} {$term->term_id}." ); + ++$errors; } } From 7140614dc21acc67c287e5eb05850918fdc46af4 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 2 Mar 2026 17:52:35 +0100 Subject: [PATCH 4/6] Update src/Term_Command.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Term_Command.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Term_Command.php b/src/Term_Command.php index d84f43b20..818acd66e 100644 --- a/src/Term_Command.php +++ b/src/Term_Command.php @@ -748,16 +748,17 @@ public function prune( $args, $assoc_args ) { * @var \WP_Term[] $terms */ - $total = 0; - $successes = 0; - $errors = 0; + $total = count( $terms ); + $prunable = 0; + $successes = 0; + $errors = 0; foreach ( $terms as $term ) { if ( $term->count > 1 ) { continue; } - ++$total; + ++$prunable; if ( $dry_run ) { WP_CLI::log( "Would delete {$taxonomy} {$term->term_id}." ); From be42053f0c6b5ca91ebed105502ab2677bdddc15 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:57:12 +0000 Subject: [PATCH 5/6] Combine two foreach loops in prune() into a single loop Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/Term_Command.php | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/Term_Command.php b/src/Term_Command.php index 818acd66e..4c051b216 100644 --- a/src/Term_Command.php +++ b/src/Term_Command.php @@ -722,15 +722,13 @@ public function recount( $args ) { * Success: Pruned 1 of 5 terms. */ public function prune( $args, $assoc_args ) { + $dry_run = (bool) Utils\get_flag_value( $assoc_args, 'dry-run', false ); + foreach ( $args as $taxonomy ) { if ( ! taxonomy_exists( $taxonomy ) ) { WP_CLI::error( "Taxonomy {$taxonomy} doesn't exist." ); } - } - $dry_run = (bool) Utils\get_flag_value( $assoc_args, 'dry-run', false ); - - foreach ( $args as $taxonomy ) { $terms = get_terms( [ 'taxonomy' => $taxonomy, @@ -748,18 +746,15 @@ public function prune( $args, $assoc_args ) { * @var \WP_Term[] $terms */ - $total = count( $terms ); - $prunable = 0; - $successes = 0; - $errors = 0; + $total = count( $terms ); + $successes = 0; + $errors = 0; foreach ( $terms as $term ) { if ( $term->count > 1 ) { continue; } - ++$prunable; - if ( $dry_run ) { WP_CLI::log( "Would delete {$taxonomy} {$term->term_id}." ); ++$successes; From 50e6e0865f1b5ca8ef26943364077f204fe64a6b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:15:34 +0000 Subject: [PATCH 6/6] Refactor prune() to use direct DB query instead of get_terms() for better performance Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/Term_Command.php | 38 +++++++++++++------------------------- 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/src/Term_Command.php b/src/Term_Command.php index 4c051b216..a3dbfac40 100644 --- a/src/Term_Command.php +++ b/src/Term_Command.php @@ -722,6 +722,8 @@ public function recount( $args ) { * Success: Pruned 1 of 5 terms. */ public function prune( $args, $assoc_args ) { + global $wpdb; + $dry_run = (bool) Utils\get_flag_value( $assoc_args, 'dry-run', false ); foreach ( $args as $taxonomy ) { @@ -729,48 +731,34 @@ public function prune( $args, $assoc_args ) { WP_CLI::error( "Taxonomy {$taxonomy} doesn't exist." ); } - $terms = get_terms( - [ - 'taxonomy' => $taxonomy, - 'hide_empty' => false, - ] + $term_ids_to_prune = $wpdb->get_col( + $wpdb->prepare( + "SELECT term_id FROM {$wpdb->term_taxonomy} WHERE taxonomy = %s AND count <= 1", + $taxonomy + ) ); - // This should never happen because of the taxonomy_exists check above. - if ( is_wp_error( $terms ) ) { - WP_CLI::warning( "Could not retrieve terms for taxonomy {$taxonomy}." ); - continue; - } - - /** - * @var \WP_Term[] $terms - */ - - $total = count( $terms ); + $total = count( $term_ids_to_prune ); $successes = 0; $errors = 0; - foreach ( $terms as $term ) { - if ( $term->count > 1 ) { - continue; - } - + foreach ( $term_ids_to_prune as $term_id ) { if ( $dry_run ) { - WP_CLI::log( "Would delete {$taxonomy} {$term->term_id}." ); + WP_CLI::log( "Would delete {$taxonomy} {$term_id}." ); ++$successes; continue; } - $result = wp_delete_term( $term->term_id, $taxonomy ); + $result = wp_delete_term( $term_id, $taxonomy ); if ( is_wp_error( $result ) ) { WP_CLI::warning( $result ); ++$errors; } elseif ( $result ) { - WP_CLI::log( "Deleted {$taxonomy} {$term->term_id}." ); + WP_CLI::log( "Deleted {$taxonomy} {$term_id}." ); ++$successes; } else { - WP_CLI::warning( "Failed to delete {$taxonomy} {$term->term_id}." ); + WP_CLI::warning( "Term {$term_id} in taxonomy {$taxonomy} doesn't exist." ); ++$errors; } }