From f919b1249bbc28f112bc6327c27b782050d3b361 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 12 Jan 2026 12:44:23 +0000
Subject: [PATCH 01/15] Initial plan
From 8f2b422088b37344588325b0664a2a5111a17069 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 12 Jan 2026 12:50:51 +0000
Subject: [PATCH 02/15] Add Post_Revision_Command with restore and diff
subcommands
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
---
composer.json | 3 +
entity-command.php | 1 +
features/post-revision.feature | 108 ++++++++++++++++++++++
phpcs.xml.dist | 2 +-
src/Post_Revision_Command.php | 159 +++++++++++++++++++++++++++++++++
5 files changed, 272 insertions(+), 1 deletion(-)
create mode 100644 features/post-revision.feature
create mode 100644 src/Post_Revision_Command.php
diff --git a/composer.json b/composer.json
index 0c743040..caed252e 100644
--- a/composer.json
+++ b/composer.json
@@ -114,6 +114,9 @@
"post meta patch",
"post meta pluck",
"post meta update",
+ "post revision",
+ "post revision diff",
+ "post revision restore",
"post term",
"post term add",
"post term list",
diff --git a/entity-command.php b/entity-command.php
index 0cecf202..cdad9235 100644
--- a/entity-command.php
+++ b/entity-command.php
@@ -46,6 +46,7 @@
)
);
WP_CLI::add_command( 'post meta', 'Post_Meta_Command' );
+WP_CLI::add_command( 'post revision', 'Post_Revision_Command' );
WP_CLI::add_command( 'post term', 'Post_Term_Command' );
WP_CLI::add_command( 'post-type', 'Post_Type_Command' );
WP_CLI::add_command( 'site', 'Site_Command' );
diff --git a/features/post-revision.feature b/features/post-revision.feature
new file mode 100644
index 00000000..c827e126
--- /dev/null
+++ b/features/post-revision.feature
@@ -0,0 +1,108 @@
+Feature: Manage WordPress post revisions
+
+ Background:
+ Given a WP install
+
+ Scenario: Restore a post revision
+ When I run `wp post create --post_title='Original Post' --post_content='Original content' --porcelain`
+ Then STDOUT should be a number
+ And save STDOUT as {POST_ID}
+
+ When I run `wp post update {POST_ID} --post_content='Updated content'`
+ Then STDOUT should contain:
+ """
+ Success: Updated post {POST_ID}.
+ """
+
+ When I run `wp post list --post_type=revision --post_parent={POST_ID} --fields=ID,post_title --format=ids`
+ Then STDOUT should not be empty
+ And save STDOUT as {REVISION_ID}
+
+ When I run `wp post revision restore {REVISION_ID}`
+ Then STDOUT should contain:
+ """
+ Success: Restored revision
+ """
+
+ When I run `wp post get {POST_ID} --field=post_content`
+ Then STDOUT should contain:
+ """
+ Original content
+ """
+
+ Scenario: Restore invalid revision should fail
+ When I try `wp post revision restore 99999`
+ Then STDERR should contain:
+ """
+ Error: Invalid revision ID
+ """
+ And the return code should be 1
+
+ Scenario: Show diff between two revisions
+ When I run `wp post create --post_title='Test Post' --post_content='First version' --porcelain`
+ Then STDOUT should be a number
+ And save STDOUT as {POST_ID}
+
+ When I run `wp post update {POST_ID} --post_content='Second version'`
+ Then STDOUT should contain:
+ """
+ Success: Updated post {POST_ID}.
+ """
+
+ When I run `wp post list --post_type=revision --post_parent={POST_ID} --fields=ID --format=csv --orderby=ID --order=ASC`
+ Then STDOUT should not be empty
+
+ When I run `wp post list --post_type=revision --post_parent={POST_ID} --format=ids --orderby=ID --order=ASC`
+ Then STDOUT should not be empty
+
+ Scenario: Show diff between revision and current post
+ When I run `wp post create --post_title='Diff Test' --post_content='Original text' --porcelain`
+ Then STDOUT should be a number
+ And save STDOUT as {POST_ID}
+
+ When I run `wp post update {POST_ID} --post_content='Modified text'`
+ Then STDOUT should contain:
+ """
+ Success: Updated post {POST_ID}.
+ """
+
+ When I run `wp post list --post_type=revision --post_parent={POST_ID} --fields=ID --format=ids --orderby=ID --order=ASC`
+ Then STDOUT should not be empty
+ And save STDOUT as {REVISION_ID}
+
+ When I run `wp post revision diff {REVISION_ID}`
+ Then the return code should be 0
+
+ Scenario: Diff with invalid revision should fail
+ When I try `wp post revision diff 99999`
+ Then STDERR should contain:
+ """
+ Error: Invalid 'from' ID
+ """
+ And the return code should be 1
+
+ Scenario: Diff between two invalid revisions should fail
+ When I try `wp post revision diff 99998 99999`
+ Then STDERR should contain:
+ """
+ Error: Invalid 'from' ID
+ """
+ And the return code should be 1
+
+ Scenario: Diff with specific field
+ When I run `wp post create --post_title='Field Test' --post_content='Some content' --porcelain`
+ Then STDOUT should be a number
+ And save STDOUT as {POST_ID}
+
+ When I run `wp post update {POST_ID} --post_title='Modified Field Test'`
+ Then STDOUT should contain:
+ """
+ Success: Updated post {POST_ID}.
+ """
+
+ When I run `wp post list --post_type=revision --post_parent={POST_ID} --fields=ID --format=ids --orderby=ID --order=ASC`
+ Then STDOUT should not be empty
+ And save STDOUT as {REVISION_ID}
+
+ When I run `wp post revision diff {REVISION_ID} --field=post_title`
+ Then the return code should be 0
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
index 0df76141..6ab1a682 100644
--- a/phpcs.xml.dist
+++ b/phpcs.xml.dist
@@ -65,7 +65,7 @@
*/src/Network_Meta_Command\.php$
*/src/Network_Namespace\.php$
*/src/Option_Command\.php$
- */src/Post(_Block|_Meta|_Term|_Type)?_Command\.php$
+ */src/Post(_Block|_Meta|_Revision|_Term|_Type)?_Command\.php$
*/src/Signup_Command\.php$
*/src/Site(_Meta|_Option)?_Command\.php$
*/src/Term(_Meta)?_Command\.php$
diff --git a/src/Post_Revision_Command.php b/src/Post_Revision_Command.php
new file mode 100644
index 00000000..f3ca23a7
--- /dev/null
+++ b/src/Post_Revision_Command.php
@@ -0,0 +1,159 @@
+fetcher = new PostFetcher();
+ }
+
+ /**
+ * Restores a post revision.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : The revision ID to restore.
+ *
+ * ## EXAMPLES
+ *
+ * # Restore a post revision
+ * $ wp post revision restore 123
+ * Success: Restored revision 123.
+ *
+ * @subcommand restore
+ */
+ public function restore( $args ) {
+ $revision_id = (int) $args[0];
+
+ // Get the revision post
+ $revision = wp_get_post_revision( $revision_id );
+
+ if ( ! $revision ) {
+ WP_CLI::error( "Invalid revision ID {$revision_id}." );
+ }
+
+ // Restore the revision
+ $restored_post_id = wp_restore_post_revision( $revision_id );
+
+ if ( false === $restored_post_id || null === $restored_post_id ) {
+ WP_CLI::error( "Failed to restore revision {$revision_id}." );
+ }
+
+ WP_CLI::success( "Restored revision {$revision_id}." );
+ }
+
+ /**
+ * Shows the difference between two revisions.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : The 'from' revision ID or post ID.
+ *
+ * []
+ * : The 'to' revision ID. If not provided, compares with the current post.
+ *
+ * [--field=]
+ * : Compare specific field(s). Default: post_content
+ *
+ * ## EXAMPLES
+ *
+ * # Show diff between two revisions
+ * $ wp post revision diff 123 456
+ *
+ * # Show diff between a revision and the current post
+ * $ wp post revision diff 123
+ *
+ * @subcommand diff
+ */
+ public function diff( $args, $assoc_args ) {
+ $from_id = (int) $args[0];
+ $to_id = isset( $args[1] ) ? (int) $args[1] : null;
+ $field = Utils\get_flag_value( $assoc_args, 'field', 'post_content' );
+
+ // Get the 'from' revision or post
+ $from_revision = wp_get_post_revision( $from_id );
+ if ( ! $from_revision instanceof \WP_Post ) {
+ // Try as a regular post
+ $from_revision = get_post( $from_id );
+ if ( ! $from_revision instanceof \WP_Post ) {
+ WP_CLI::error( "Invalid 'from' ID {$from_id}." );
+ }
+ }
+
+ // Get the 'to' revision or post
+ $to_revision = null;
+ if ( $to_id ) {
+ $to_revision = wp_get_post_revision( $to_id );
+ if ( ! $to_revision instanceof \WP_Post ) {
+ // Try as a regular post
+ $to_revision = get_post( $to_id );
+ if ( ! $to_revision instanceof \WP_Post ) {
+ WP_CLI::error( "Invalid 'to' ID {$to_id}." );
+ }
+ }
+ } elseif ( 'revision' === $from_revision->post_type ) {
+ // If no 'to' ID provided, use the parent post of the revision
+ $to_revision = get_post( $from_revision->post_parent );
+ if ( ! $to_revision instanceof \WP_Post ) {
+ WP_CLI::error( "Could not find parent post for revision {$from_id}." );
+ }
+ } else {
+ WP_CLI::error( "Please provide a 'to' revision ID when comparing posts." );
+ }
+
+ // Validate field
+ if ( ! property_exists( $from_revision, $field ) || ! property_exists( $to_revision, $field ) ) {
+ WP_CLI::error( "Invalid field '{$field}'." );
+ }
+
+ // Get the field values
+ $left_string = $from_revision->{$field};
+ $right_string = $to_revision->{$field};
+
+ // Generate the diff
+ $diff_args = [
+ 'title_left' => sprintf(
+ '%s (%s) - ID %d',
+ $from_revision->post_title,
+ $from_revision->post_modified,
+ $from_revision->ID
+ ),
+ 'title_right' => sprintf(
+ '%s (%s) - ID %d',
+ $to_revision->post_title,
+ $to_revision->post_modified,
+ $to_revision->ID
+ ),
+ ];
+
+ $diff = wp_text_diff( $left_string, $right_string, $diff_args );
+
+ if ( ! $diff ) {
+ WP_CLI::success( 'No difference found.' );
+ return;
+ }
+
+ // Output the diff
+ WP_CLI::line( $diff );
+ }
+}
From 98d85906af44c31fe7ee1b4bce5df029ac8cc4d4 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 12 Jan 2026 12:53:56 +0000
Subject: [PATCH 03/15] Address code review feedback: validate fields properly
and remove unused property
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
---
src/Post_Revision_Command.php | 41 +++++++++++++++++++++++++++--------
1 file changed, 32 insertions(+), 9 deletions(-)
diff --git a/src/Post_Revision_Command.php b/src/Post_Revision_Command.php
index f3ca23a7..5d42de75 100644
--- a/src/Post_Revision_Command.php
+++ b/src/Post_Revision_Command.php
@@ -1,6 +1,5 @@
fetcher = new PostFetcher();
- }
+ /**
+ * Valid post fields that can be compared.
+ *
+ * @var array
+ */
+ private $valid_fields = [
+ 'post_title',
+ 'post_content',
+ 'post_excerpt',
+ 'post_name',
+ 'post_status',
+ 'post_type',
+ 'post_author',
+ 'post_date',
+ 'post_date_gmt',
+ 'post_modified',
+ 'post_modified_gmt',
+ 'post_parent',
+ 'menu_order',
+ 'comment_status',
+ 'ping_status',
+ ];
/**
* Restores a post revision.
@@ -122,11 +138,18 @@ public function diff( $args, $assoc_args ) {
}
// Validate field
- if ( ! property_exists( $from_revision, $field ) || ! property_exists( $to_revision, $field ) ) {
- WP_CLI::error( "Invalid field '{$field}'." );
+ if ( ! in_array( $field, $this->valid_fields, true ) ) {
+ WP_CLI::error( "Invalid field '{$field}'. Valid fields: " . implode( ', ', $this->valid_fields ) );
+ }
+
+ // Get the field values - use isset to check if field exists on the object
+ if ( ! isset( $from_revision->{$field} ) ) {
+ WP_CLI::error( "Field '{$field}' not found on revision {$from_id}." );
+ }
+ if ( ! isset( $to_revision->{$field} ) ) {
+ WP_CLI::error( "Field '{$field}' not found on revision/post {$to_id}." );
}
- // Get the field values
$left_string = $from_revision->{$field};
$right_string = $to_revision->{$field};
From 598c559c4736a7371387ebf4a642ab25e1c7466f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 12 Jan 2026 12:55:31 +0000
Subject: [PATCH 04/15] Fix error message display and simplify return value
check
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
---
src/Post_Revision_Command.php | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/Post_Revision_Command.php b/src/Post_Revision_Command.php
index 5d42de75..41e8f7ee 100644
--- a/src/Post_Revision_Command.php
+++ b/src/Post_Revision_Command.php
@@ -70,7 +70,7 @@ public function restore( $args ) {
// Restore the revision
$restored_post_id = wp_restore_post_revision( $revision_id );
- if ( false === $restored_post_id || null === $restored_post_id ) {
+ if ( false === $restored_post_id ) {
WP_CLI::error( "Failed to restore revision {$revision_id}." );
}
@@ -147,7 +147,8 @@ public function diff( $args, $assoc_args ) {
WP_CLI::error( "Field '{$field}' not found on revision {$from_id}." );
}
if ( ! isset( $to_revision->{$field} ) ) {
- WP_CLI::error( "Field '{$field}' not found on revision/post {$to_id}." );
+ $to_error_id = $to_id ?? $to_revision->ID;
+ WP_CLI::error( "Field '{$field}' not found on revision/post {$to_error_id}." );
}
$left_string = $from_revision->{$field};
From af620a236d24ec18489252a0a4bb103fbcd62258 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 12 Jan 2026 12:57:53 +0000
Subject: [PATCH 05/15] Handle null return value from wp_restore_post_revision
and improve error handling
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
---
src/Post_Revision_Command.php | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/Post_Revision_Command.php b/src/Post_Revision_Command.php
index 41e8f7ee..7edcb6d0 100644
--- a/src/Post_Revision_Command.php
+++ b/src/Post_Revision_Command.php
@@ -70,7 +70,8 @@ public function restore( $args ) {
// Restore the revision
$restored_post_id = wp_restore_post_revision( $revision_id );
- if ( false === $restored_post_id ) {
+ // wp_restore_post_revision() returns post ID on success, false on failure, or null if revision is same as current
+ if ( ! $restored_post_id ) {
WP_CLI::error( "Failed to restore revision {$revision_id}." );
}
@@ -146,6 +147,8 @@ public function diff( $args, $assoc_args ) {
if ( ! isset( $from_revision->{$field} ) ) {
WP_CLI::error( "Field '{$field}' not found on revision {$from_id}." );
}
+
+ // $to_revision is guaranteed to be non-null at this point due to earlier validation
if ( ! isset( $to_revision->{$field} ) ) {
$to_error_id = $to_id ?? $to_revision->ID;
WP_CLI::error( "Field '{$field}' not found on revision/post {$to_error_id}." );
From 9b54f2f9c70aa69b143d7680b525ead5cd5c8bac Mon Sep 17 00:00:00 2001
From: Pascal Birchler
Date: Tue, 13 Jan 2026 12:27:47 +0100
Subject: [PATCH 06/15] Update src/Post_Revision_Command.php
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
src/Post_Revision_Command.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/Post_Revision_Command.php b/src/Post_Revision_Command.php
index 7edcb6d0..1b1b7522 100644
--- a/src/Post_Revision_Command.php
+++ b/src/Post_Revision_Command.php
@@ -71,7 +71,7 @@ public function restore( $args ) {
$restored_post_id = wp_restore_post_revision( $revision_id );
// wp_restore_post_revision() returns post ID on success, false on failure, or null if revision is same as current
- if ( ! $restored_post_id ) {
+ if ( false === $restored_post_id ) {
WP_CLI::error( "Failed to restore revision {$revision_id}." );
}
From 3e511a1395831c4bc815d222e286e5301b631666 Mon Sep 17 00:00:00 2001
From: Pascal Birchler
Date: Tue, 13 Jan 2026 12:28:03 +0100
Subject: [PATCH 07/15] Update src/Post_Revision_Command.php
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
src/Post_Revision_Command.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/Post_Revision_Command.php b/src/Post_Revision_Command.php
index 1b1b7522..4f2de5db 100644
--- a/src/Post_Revision_Command.php
+++ b/src/Post_Revision_Command.php
@@ -87,7 +87,7 @@ public function restore( $args ) {
* : The 'from' revision ID or post ID.
*
* []
- * : The 'to' revision ID. If not provided, compares with the current post.
+ * : The 'to' revision ID or post ID. If not provided, compares with the current post.
*
* [--field=]
* : Compare specific field(s). Default: post_content
From acc29070b2ae6fc2dc6eaf18a146595f324cdb00 Mon Sep 17 00:00:00 2001
From: Pascal Birchler
Date: Tue, 13 Jan 2026 12:28:22 +0100
Subject: [PATCH 08/15] Update src/Post_Revision_Command.php
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
src/Post_Revision_Command.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/Post_Revision_Command.php b/src/Post_Revision_Command.php
index 4f2de5db..961cf646 100644
--- a/src/Post_Revision_Command.php
+++ b/src/Post_Revision_Command.php
@@ -145,7 +145,7 @@ public function diff( $args, $assoc_args ) {
// Get the field values - use isset to check if field exists on the object
if ( ! isset( $from_revision->{$field} ) ) {
- WP_CLI::error( "Field '{$field}' not found on revision {$from_id}." );
+ WP_CLI::error( "Field '{$field}' not found on post/revision {$from_id}." );
}
// $to_revision is guaranteed to be non-null at this point due to earlier validation
From c1c8cf91aa7e45697ffe8c09c916b3855ebd11bc Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 13 Jan 2026 11:30:43 +0000
Subject: [PATCH 09/15] Complete test scenario for diff between two revisions
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
---
features/post-revision.feature | 19 ++++++++++++++++---
1 file changed, 16 insertions(+), 3 deletions(-)
diff --git a/features/post-revision.feature b/features/post-revision.feature
index c827e126..db80ee77 100644
--- a/features/post-revision.feature
+++ b/features/post-revision.feature
@@ -49,11 +49,24 @@ Feature: Manage WordPress post revisions
Success: Updated post {POST_ID}.
"""
- When I run `wp post list --post_type=revision --post_parent={POST_ID} --fields=ID --format=csv --orderby=ID --order=ASC`
- Then STDOUT should not be empty
+ When I run `wp post update {POST_ID} --post_content='Third version'`
+ Then STDOUT should contain:
+ """
+ Success: Updated post {POST_ID}.
+ """
- When I run `wp post list --post_type=revision --post_parent={POST_ID} --format=ids --orderby=ID --order=ASC`
+ When I run `wp post list --post_type=revision --post_parent={POST_ID} --fields=ID --format=ids --orderby=ID --order=ASC`
Then STDOUT should not be empty
+ And save STDOUT as {REVISION_IDS}
+
+ When I run `echo "{REVISION_IDS}" | awk '{print $1}'`
+ Then save STDOUT as {REVISION_ID_1}
+
+ When I run `echo "{REVISION_IDS}" | awk '{print $2}'`
+ Then save STDOUT as {REVISION_ID_2}
+
+ When I run `wp post revision diff {REVISION_ID_1} {REVISION_ID_2}`
+ Then the return code should be 0
Scenario: Show diff between revision and current post
When I run `wp post create --post_title='Diff Test' --post_content='Original text' --porcelain`
From 4b174c42e117c0fbef06467b67472bf1ff6d5b67 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 16 Jan 2026 20:42:12 +0000
Subject: [PATCH 10/15] Replace wp_text_diff HTML output with CLI-friendly
colored diff
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
---
src/Post_Revision_Command.php | 99 +++++++++++++++++++++++++++--------
1 file changed, 78 insertions(+), 21 deletions(-)
diff --git a/src/Post_Revision_Command.php b/src/Post_Revision_Command.php
index 961cf646..804193ca 100644
--- a/src/Post_Revision_Command.php
+++ b/src/Post_Revision_Command.php
@@ -157,30 +157,87 @@ public function diff( $args, $assoc_args ) {
$left_string = $from_revision->{$field};
$right_string = $to_revision->{$field};
- // Generate the diff
- $diff_args = [
- 'title_left' => sprintf(
- '%s (%s) - ID %d',
- $from_revision->post_title,
- $from_revision->post_modified,
- $from_revision->ID
- ),
- 'title_right' => sprintf(
- '%s (%s) - ID %d',
- $to_revision->post_title,
- $to_revision->post_modified,
- $to_revision->ID
- ),
- ];
-
- $diff = wp_text_diff( $left_string, $right_string, $diff_args );
-
- if ( ! $diff ) {
+ // Split content into lines for diff
+ $left_lines = explode( "\n", $left_string );
+ $right_lines = explode( "\n", $right_string );
+
+ // Create Text_Diff object
+ $text_diff = new \Text_Diff( 'auto', [ $left_lines, $right_lines ] );
+
+ // Check if there are any changes
+ if ( 0 === $text_diff->countAddedLines() && 0 === $text_diff->countDeletedLines() ) {
WP_CLI::success( 'No difference found.' );
return;
}
- // Output the diff
- WP_CLI::line( $diff );
+ // Display header
+ WP_CLI::line(
+ WP_CLI::colorize(
+ sprintf(
+ '%%y--- %s (%s) - ID %d%%n',
+ $from_revision->post_title,
+ $from_revision->post_modified,
+ $from_revision->ID
+ )
+ )
+ );
+ WP_CLI::line(
+ WP_CLI::colorize(
+ sprintf(
+ '%%y+++ %s (%s) - ID %d%%n',
+ $to_revision->post_title,
+ $to_revision->post_modified,
+ $to_revision->ID
+ )
+ )
+ );
+ WP_CLI::line( '' );
+
+ // Render the diff using CLI-friendly format
+ $this->render_cli_diff( $text_diff );
+ }
+
+ /**
+ * Renders a diff in CLI-friendly format with colors.
+ *
+ * @param \Text_Diff $diff The diff object to render.
+ */
+ private function render_cli_diff( $diff ) {
+ $edits = $diff->getDiff();
+
+ foreach ( $edits as $edit ) {
+ switch ( get_class( $edit ) ) {
+ case 'Text_Diff_Op_copy':
+ // Unchanged lines - show in default color
+ foreach ( $edit->orig as $line ) {
+ WP_CLI::line( ' ' . $line );
+ }
+ break;
+
+ case 'Text_Diff_Op_add':
+ // Added lines - show in green
+ foreach ( $edit->final as $line ) {
+ WP_CLI::line( WP_CLI::colorize( '%g+ ' . $line . '%n' ) );
+ }
+ break;
+
+ case 'Text_Diff_Op_delete':
+ // Deleted lines - show in red
+ foreach ( $edit->orig as $line ) {
+ WP_CLI::line( WP_CLI::colorize( '%r- ' . $line . '%n' ) );
+ }
+ break;
+
+ case 'Text_Diff_Op_change':
+ // Changed lines - show deletions in red, additions in green
+ foreach ( $edit->orig as $line ) {
+ WP_CLI::line( WP_CLI::colorize( '%r- ' . $line . '%n' ) );
+ }
+ foreach ( $edit->final as $line ) {
+ WP_CLI::line( WP_CLI::colorize( '%g+ ' . $line . '%n' ) );
+ }
+ break;
+ }
+ }
}
}
From 087ee6bc2b03cb08df2c7b04b0310b35b72a0a7f Mon Sep 17 00:00:00 2001
From: Pascal Birchler
Date: Fri, 16 Jan 2026 21:27:12 +0100
Subject: [PATCH 11/15] Fix test
---
features/post-revision.feature | 19 +++++++++++++++++--
1 file changed, 17 insertions(+), 2 deletions(-)
diff --git a/features/post-revision.feature b/features/post-revision.feature
index db80ee77..2330eca7 100644
--- a/features/post-revision.feature
+++ b/features/post-revision.feature
@@ -3,6 +3,9 @@ Feature: Manage WordPress post revisions
Background:
Given a WP install
+ # Creating a published post doesn't create an initial revision,
+ # so we update it twice here and restore the middle version.
+ # See https://github.com/wp-cli/entity-command/issues/564.
Scenario: Restore a post revision
When I run `wp post create --post_title='Original Post' --post_content='Original content' --porcelain`
Then STDOUT should be a number
@@ -14,10 +17,22 @@ Feature: Manage WordPress post revisions
Success: Updated post {POST_ID}.
"""
- When I run `wp post list --post_type=revision --post_parent={POST_ID} --fields=ID,post_title --format=ids`
+ When I run `wp post list --post_type=revision --post_parent={POST_ID} --format=ids`
Then STDOUT should not be empty
And save STDOUT as {REVISION_ID}
+ When I run `wp post update {POST_ID} --post_content='Another one'`
+ Then STDOUT should contain:
+ """
+ Success: Updated post {POST_ID}.
+ """
+
+ When I run `wp post get {POST_ID} --field=post_content`
+ Then STDOUT should contain:
+ """
+ Another one
+ """
+
When I run `wp post revision restore {REVISION_ID}`
Then STDOUT should contain:
"""
@@ -27,7 +42,7 @@ Feature: Manage WordPress post revisions
When I run `wp post get {POST_ID} --field=post_content`
Then STDOUT should contain:
"""
- Original content
+ Updated content
"""
Scenario: Restore invalid revision should fail
From f812fc584999fa531ec775777c2e46d7900dd297 Mon Sep 17 00:00:00 2001
From: Pascal Birchler
Date: Fri, 16 Jan 2026 21:52:09 +0100
Subject: [PATCH 12/15] Update tests
---
features/post-revision.feature | 21 ++++++++++++++++++---
src/Post_Revision_Command.php | 5 +++++
2 files changed, 23 insertions(+), 3 deletions(-)
diff --git a/features/post-revision.feature b/features/post-revision.feature
index 2330eca7..9a63289b 100644
--- a/features/post-revision.feature
+++ b/features/post-revision.feature
@@ -64,7 +64,7 @@ Feature: Manage WordPress post revisions
Success: Updated post {POST_ID}.
"""
- When I run `wp post update {POST_ID} --post_content='Third version'`
+ When I run `wp post update {POST_ID} --post_title='New Title' --post_content='Third version'`
Then STDOUT should contain:
"""
Success: Updated post {POST_ID}.
@@ -81,7 +81,19 @@ Feature: Manage WordPress post revisions
Then save STDOUT as {REVISION_ID_2}
When I run `wp post revision diff {REVISION_ID_1} {REVISION_ID_2}`
- Then the return code should be 0
+ Then STDOUT should contain:
+ """
+ - Second version
+ + Third version
+ """
+ And STDOUT should contain:
+ """
+ --- Test Post
+ """
+ And STDOUT should contain:
+ """
+ +++ New Title
+ """
Scenario: Show diff between revision and current post
When I run `wp post create --post_title='Diff Test' --post_content='Original text' --porcelain`
@@ -99,7 +111,10 @@ Feature: Manage WordPress post revisions
And save STDOUT as {REVISION_ID}
When I run `wp post revision diff {REVISION_ID}`
- Then the return code should be 0
+ Then STDOUT should contain:
+ """
+ Success: No difference found.
+ """
Scenario: Diff with invalid revision should fail
When I try `wp post revision diff 99999`
diff --git a/src/Post_Revision_Command.php b/src/Post_Revision_Command.php
index 804193ca..71931831 100644
--- a/src/Post_Revision_Command.php
+++ b/src/Post_Revision_Command.php
@@ -161,6 +161,11 @@ public function diff( $args, $assoc_args ) {
$left_lines = explode( "\n", $left_string );
$right_lines = explode( "\n", $right_string );
+ if ( ! class_exists( 'Text_Diff', false ) ) {
+ // @phpstan-ignore constant.notFound
+ require ABSPATH . WPINC . '/wp-diff.php';
+ }
+
// Create Text_Diff object
$text_diff = new \Text_Diff( 'auto', [ $left_lines, $right_lines ] );
From b6babf60540b6fe34dce3aaa4554ae6aad1fb0b6 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 25 Jan 2026 16:42:47 +0000
Subject: [PATCH 13/15] Add wp post revision prune command
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
---
composer.json | 1 +
features/post-revision.feature | 101 +++++++++++++++++++
src/Post_Revision_Command.php | 172 +++++++++++++++++++++++++++++++++
3 files changed, 274 insertions(+)
diff --git a/composer.json b/composer.json
index caed252e..4872de7c 100644
--- a/composer.json
+++ b/composer.json
@@ -116,6 +116,7 @@
"post meta update",
"post revision",
"post revision diff",
+ "post revision prune",
"post revision restore",
"post term",
"post term add",
diff --git a/features/post-revision.feature b/features/post-revision.feature
index 9a63289b..57bed2f9 100644
--- a/features/post-revision.feature
+++ b/features/post-revision.feature
@@ -149,3 +149,104 @@ Feature: Manage WordPress post revisions
When I run `wp post revision diff {REVISION_ID} --field=post_title`
Then the return code should be 0
+
+ Scenario: Prune revisions keeping latest N
+ When I run `wp post create --post_title='Prune Test' --post_content='Version 1' --porcelain`
+ Then STDOUT should be a number
+ And save STDOUT as {POST_ID}
+
+ When I run `wp post update {POST_ID} --post_content='Version 2'`
+ When I run `wp post update {POST_ID} --post_content='Version 3'`
+ When I run `wp post update {POST_ID} --post_content='Version 4'`
+ When I run `wp post update {POST_ID} --post_content='Version 5'`
+
+ When I run `wp post list --post_type=revision --post_parent={POST_ID} --format=count`
+ Then STDOUT should be:
+ """
+ 5
+ """
+
+ When I run `wp post revision prune {POST_ID} --latest=2 --yes`
+ Then STDOUT should contain:
+ """
+ Success: Deleted 3 revisions for post {POST_ID}.
+ """
+
+ When I run `wp post list --post_type=revision --post_parent={POST_ID} --format=count`
+ Then STDOUT should be:
+ """
+ 2
+ """
+
+ Scenario: Prune revisions keeping earliest N
+ When I run `wp post create --post_title='Prune Earliest Test' --post_content='Version 1' --porcelain`
+ Then STDOUT should be a number
+ And save STDOUT as {POST_ID}
+
+ When I run `wp post update {POST_ID} --post_content='Version 2'`
+ When I run `wp post update {POST_ID} --post_content='Version 3'`
+ When I run `wp post update {POST_ID} --post_content='Version 4'`
+
+ When I run `wp post list --post_type=revision --post_parent={POST_ID} --format=count`
+ Then STDOUT should be:
+ """
+ 4
+ """
+
+ When I run `wp post revision prune {POST_ID} --earliest=2 --yes`
+ Then STDOUT should contain:
+ """
+ Success: Deleted 2 revisions for post {POST_ID}.
+ """
+
+ When I run `wp post list --post_type=revision --post_parent={POST_ID} --format=count`
+ Then STDOUT should be:
+ """
+ 2
+ """
+
+ Scenario: Prune revisions for all posts
+ When I run `wp post create --post_title='Post 1' --post_content='Content 1' --porcelain`
+ Then save STDOUT as {POST_ID_1}
+
+ When I run `wp post update {POST_ID_1} --post_content='Update 1'`
+ When I run `wp post update {POST_ID_1} --post_content='Update 2'`
+ When I run `wp post update {POST_ID_1} --post_content='Update 3'`
+
+ When I run `wp post create --post_title='Post 2' --post_content='Content 2' --porcelain`
+ Then save STDOUT as {POST_ID_2}
+
+ When I run `wp post update {POST_ID_2} --post_content='Update 1'`
+ When I run `wp post update {POST_ID_2} --post_content='Update 2'`
+
+ When I run `wp post revision prune --latest=1 --yes`
+ Then STDOUT should contain:
+ """
+ Success: Deleted
+ """
+ And STDOUT should contain:
+ """
+ revisions across
+ """
+
+ Scenario: Prune with no flags should fail
+ When I run `wp post create --post_title='Test' --post_content='Content' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I try `wp post revision prune {POST_ID}`
+ Then STDERR should contain:
+ """
+ Error: Please specify either --latest or --earliest flag.
+ """
+ And the return code should be 1
+
+ Scenario: Prune with both flags should fail
+ When I run `wp post create --post_title='Test' --post_content='Content' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I try `wp post revision prune {POST_ID} --latest=5 --earliest=5`
+ Then STDERR should contain:
+ """
+ Error: Cannot specify both --latest and --earliest flags.
+ """
+ And the return code should be 1
diff --git a/src/Post_Revision_Command.php b/src/Post_Revision_Command.php
index 71931831..199ccd4e 100644
--- a/src/Post_Revision_Command.php
+++ b/src/Post_Revision_Command.php
@@ -245,4 +245,176 @@ private function render_cli_diff( $diff ) {
}
}
}
+
+ /**
+ * Deletes old post revisions.
+ *
+ * ## OPTIONS
+ *
+ * [...]
+ * : One or more post IDs to prune revisions for. If not provided, prunes revisions for all posts.
+ *
+ * [--latest=]
+ * : Keep only the latest N revisions per post. Older revisions will be deleted.
+ *
+ * [--earliest=]
+ * : Keep only the earliest N revisions per post. Newer revisions will be deleted.
+ *
+ * [--yes]
+ * : Skip confirmation prompt.
+ *
+ * ## EXAMPLES
+ *
+ * # Delete all but the latest 5 revisions for post 123
+ * $ wp post revision prune 123 --latest=5
+ * Success: Deleted 3 revisions for post 123.
+ *
+ * # Delete all but the latest 5 revisions for all posts
+ * $ wp post revision prune --latest=5
+ * Success: Deleted 150 revisions across 30 posts.
+ *
+ * # Delete all but the earliest 2 revisions for posts 123 and 456
+ * $ wp post revision prune 123 456 --earliest=2
+ * Success: Deleted 5 revisions for post 123.
+ * Success: Deleted 3 revisions for post 456.
+ *
+ * @subcommand prune
+ */
+ public function prune( $args, $assoc_args ) {
+ $latest = Utils\get_flag_value( $assoc_args, 'latest', null );
+ $earliest = Utils\get_flag_value( $assoc_args, 'earliest', null );
+
+ // Validate flags
+ if ( null === $latest && null === $earliest ) {
+ WP_CLI::error( 'Please specify either --latest or --earliest flag.' );
+ }
+
+ if ( null !== $latest && null !== $earliest ) {
+ WP_CLI::error( 'Cannot specify both --latest and --earliest flags.' );
+ }
+
+ $limit = $latest ?? $earliest;
+ $keep_latest = null !== $latest;
+
+ if ( ! is_numeric( $limit ) || (int) $limit < 1 ) {
+ WP_CLI::error( 'Limit must be a positive integer.' );
+ }
+
+ $limit = (int) $limit;
+
+ // Get posts to process
+ if ( ! empty( $args ) ) {
+ $post_ids = array_map( 'intval', $args );
+ } else {
+ // Get all posts that have revisions
+ global $wpdb;
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ $post_ids = $wpdb->get_col(
+ "SELECT DISTINCT post_parent FROM {$wpdb->posts} WHERE post_type = 'revision' AND post_parent > 0"
+ );
+ $post_ids = array_map( 'intval', $post_ids );
+ }
+
+ if ( empty( $post_ids ) ) {
+ WP_CLI::warning( 'No posts found with revisions.' );
+ return;
+ }
+
+ // Confirm deletion if processing multiple posts without --yes flag
+ if ( count( $post_ids ) > 1 && ! Utils\get_flag_value( $assoc_args, 'yes', false ) ) {
+ WP_CLI::confirm(
+ sprintf(
+ 'Are you sure you want to prune revisions for %d posts?',
+ count( $post_ids )
+ ),
+ $assoc_args
+ );
+ }
+
+ $total_deleted = 0;
+ $posts_processed = 0;
+
+ foreach ( $post_ids as $post_id ) {
+ $deleted = $this->prune_post_revisions( $post_id, $limit, $keep_latest );
+
+ if ( false === $deleted ) {
+ WP_CLI::warning( "Post {$post_id} does not exist or has no revisions." );
+ continue;
+ }
+
+ if ( $deleted > 0 ) {
+ ++$posts_processed;
+ $total_deleted += $deleted;
+ WP_CLI::success( "Deleted {$deleted} revision" . ( $deleted > 1 ? 's' : '' ) . " for post {$post_id}." );
+ } elseif ( count( $post_ids ) === 1 ) {
+ WP_CLI::success( "No revisions to delete for post {$post_id}." );
+ }
+ }
+
+ if ( count( $post_ids ) > 1 ) {
+ if ( $total_deleted > 0 ) {
+ WP_CLI::success(
+ sprintf(
+ 'Deleted %d revision%s across %d post%s.',
+ $total_deleted,
+ $total_deleted > 1 ? 's' : '',
+ $posts_processed,
+ $posts_processed > 1 ? 's' : ''
+ )
+ );
+ } else {
+ WP_CLI::success( 'No revisions to delete.' );
+ }
+ }
+ }
+
+ /**
+ * Prunes revisions for a single post.
+ *
+ * @param int $post_id The post ID.
+ * @param int $limit Number of revisions to keep.
+ * @param bool $keep_latest Whether to keep the latest revisions (true) or earliest (false).
+ * @return int|false Number of revisions deleted, or false if post not found.
+ */
+ private function prune_post_revisions( $post_id, $limit, $keep_latest ) {
+ $post = get_post( $post_id );
+
+ if ( ! $post ) {
+ return false;
+ }
+
+ // Get all revisions for this post
+ $revisions = wp_get_post_revisions( $post_id, [ 'order' => 'ASC' ] );
+
+ if ( empty( $revisions ) ) {
+ return false;
+ }
+
+ $revision_count = count( $revisions );
+
+ // If we have fewer or equal revisions than the limit, nothing to delete
+ if ( $revision_count <= $limit ) {
+ return 0;
+ }
+
+ // Determine which revisions to delete
+ $revisions_array = array_values( $revisions );
+
+ if ( $keep_latest ) {
+ // Keep the latest N, delete the rest (from beginning)
+ $to_delete = array_slice( $revisions_array, 0, $revision_count - $limit );
+ } else {
+ // Keep the earliest N, delete the rest (from end)
+ $to_delete = array_slice( $revisions_array, $limit );
+ }
+
+ $deleted = 0;
+ foreach ( $to_delete as $revision ) {
+ if ( $revision instanceof \WP_Post && wp_delete_post_revision( $revision->ID ) ) {
+ ++$deleted;
+ }
+ }
+
+ return $deleted;
+ }
}
From b18631e86b871c837dfabe2f6134a42ee0c608ab Mon Sep 17 00:00:00 2001
From: Pascal Birchler
Date: Sun, 25 Jan 2026 12:16:01 -0500
Subject: [PATCH 14/15] Lint fixes
---
features/post-revision.feature | 24 ++++++++++++------------
1 file changed, 12 insertions(+), 12 deletions(-)
diff --git a/features/post-revision.feature b/features/post-revision.feature
index 57bed2f9..3ffd7872 100644
--- a/features/post-revision.feature
+++ b/features/post-revision.feature
@@ -156,11 +156,11 @@ Feature: Manage WordPress post revisions
And save STDOUT as {POST_ID}
When I run `wp post update {POST_ID} --post_content='Version 2'`
- When I run `wp post update {POST_ID} --post_content='Version 3'`
- When I run `wp post update {POST_ID} --post_content='Version 4'`
- When I run `wp post update {POST_ID} --post_content='Version 5'`
+ And I run `wp post update {POST_ID} --post_content='Version 3'`
+ And I run `wp post update {POST_ID} --post_content='Version 4'`
+ And I run `wp post update {POST_ID} --post_content='Version 5'`
- When I run `wp post list --post_type=revision --post_parent={POST_ID} --format=count`
+ And I run `wp post list --post_type=revision --post_parent={POST_ID} --format=count`
Then STDOUT should be:
"""
5
@@ -184,10 +184,10 @@ Feature: Manage WordPress post revisions
And save STDOUT as {POST_ID}
When I run `wp post update {POST_ID} --post_content='Version 2'`
- When I run `wp post update {POST_ID} --post_content='Version 3'`
- When I run `wp post update {POST_ID} --post_content='Version 4'`
+ And I run `wp post update {POST_ID} --post_content='Version 3'`
+ And I run `wp post update {POST_ID} --post_content='Version 4'`
- When I run `wp post list --post_type=revision --post_parent={POST_ID} --format=count`
+ And I run `wp post list --post_type=revision --post_parent={POST_ID} --format=count`
Then STDOUT should be:
"""
4
@@ -210,16 +210,16 @@ Feature: Manage WordPress post revisions
Then save STDOUT as {POST_ID_1}
When I run `wp post update {POST_ID_1} --post_content='Update 1'`
- When I run `wp post update {POST_ID_1} --post_content='Update 2'`
- When I run `wp post update {POST_ID_1} --post_content='Update 3'`
+ And I run `wp post update {POST_ID_1} --post_content='Update 2'`
+ And I run `wp post update {POST_ID_1} --post_content='Update 3'`
- When I run `wp post create --post_title='Post 2' --post_content='Content 2' --porcelain`
+ And I run `wp post create --post_title='Post 2' --post_content='Content 2' --porcelain`
Then save STDOUT as {POST_ID_2}
When I run `wp post update {POST_ID_2} --post_content='Update 1'`
- When I run `wp post update {POST_ID_2} --post_content='Update 2'`
+ And I run `wp post update {POST_ID_2} --post_content='Update 2'`
- When I run `wp post revision prune --latest=1 --yes`
+ And I run `wp post revision prune --latest=1 --yes`
Then STDOUT should contain:
"""
Success: Deleted
From 8503d88caaa654a982e1b6161f2f0b7532946111 Mon Sep 17 00:00:00 2001
From: Pascal Birchler
Date: Sun, 25 Jan 2026 12:19:30 -0500
Subject: [PATCH 15/15] Fix tests
---
features/post-revision.feature | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/features/post-revision.feature b/features/post-revision.feature
index 3ffd7872..594d6a7e 100644
--- a/features/post-revision.feature
+++ b/features/post-revision.feature
@@ -160,16 +160,18 @@ Feature: Manage WordPress post revisions
And I run `wp post update {POST_ID} --post_content='Version 4'`
And I run `wp post update {POST_ID} --post_content='Version 5'`
+ # The initial post does not create a revision.
+ # See https://core.trac.wordpress.org/ticket/30854
And I run `wp post list --post_type=revision --post_parent={POST_ID} --format=count`
Then STDOUT should be:
"""
- 5
+ 4
"""
When I run `wp post revision prune {POST_ID} --latest=2 --yes`
Then STDOUT should contain:
"""
- Success: Deleted 3 revisions for post {POST_ID}.
+ Success: Deleted 2 revisions for post {POST_ID}.
"""
When I run `wp post list --post_type=revision --post_parent={POST_ID} --format=count`
@@ -187,16 +189,18 @@ Feature: Manage WordPress post revisions
And I run `wp post update {POST_ID} --post_content='Version 3'`
And I run `wp post update {POST_ID} --post_content='Version 4'`
+ # The initial post does not create a revision.
+ # See https://core.trac.wordpress.org/ticket/30854
And I run `wp post list --post_type=revision --post_parent={POST_ID} --format=count`
Then STDOUT should be:
"""
- 4
+ 3
"""
When I run `wp post revision prune {POST_ID} --earliest=2 --yes`
Then STDOUT should contain:
"""
- Success: Deleted 2 revisions for post {POST_ID}.
+ Success: Deleted 1 revision for post {POST_ID}.
"""
When I run `wp post list --post_type=revision --post_parent={POST_ID} --format=count`