From b4355e0f45de7fb5e080e85e2a58e5b91ef828ea Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 11:36:47 +0000 Subject: [PATCH 1/4] Add failing tests for silently ignored parameters Three documented parameters accept a value and drop it: - `user create --user_nicename` and `--rich_editing`. User_Command::create() builds an explicit stdClass for wp_insert_user() and never sets either property, so both are read off $assoc_args nowhere. - `post create` / `post update --post_modified` and `--post_modified_gmt`. wp_insert_post() computes both itself and never reads them from $postarr; on update they are unconditionally current_time( 'mysql' ). All three report success. These are the same silent no-op as https://github.com/wp-cli/wp-cli/issues/5286, reached from the other side: there the parameter name is wrong, here the name is right and the value is discarded anyway. These scenarios fail on main. The fix follows in the next commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014SSZzqMJRDTiLiDxQEPYcL --- features/post.feature | 48 +++++++++++++++++++++++++++++++++++++++++++ features/user.feature | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/features/post.feature b/features/post.feature index 02a845e01..0462afb32 100644 --- a/features/post.feature +++ b/features/post.feature @@ -591,3 +591,51 @@ Feature: Manage WordPress posts """ {"block_version":1} """ + + Scenario: Set a post's modification date on update + Given a WP install + + When I run `wp post create --post_title='A post' --post_status=publish --porcelain` + Then STDOUT should be a number + And save STDOUT as {POST_ID} + + When I run `wp post update {POST_ID} --post_modified='2020-01-01 12:00:00'` + Then STDOUT should be: + """ + Success: Updated post {POST_ID}. + """ + + When I run `wp post get {POST_ID} --field=post_modified` + Then STDOUT should be: + """ + 2020-01-01 12:00:00 + """ + + Scenario: Set a post's modification date on create + Given a WP install + + When I run `wp post create --post_title='Another post' --post_date='2019-05-05 10:00:00' --post_modified='2020-01-01 12:00:00' --porcelain` + Then STDOUT should be a number + And save STDOUT as {POST_ID} + + When I run `wp post get {POST_ID} --field=post_modified` + Then STDOUT should be: + """ + 2020-01-01 12:00:00 + """ + + Scenario: A post's modification date defaults to the current time + Given a WP install + + When I run `wp post create --post_title='Undated post' --post_status=publish --porcelain` + Then STDOUT should be a number + And save STDOUT as {POST_ID} + + When I run `wp post update {POST_ID} --post_title='Retitled'` + Then STDOUT should be: + """ + Success: Updated post {POST_ID}. + """ + + When I run `wp post get {POST_ID} --field=post_modified` + Then STDOUT should not be empty diff --git a/features/user.feature b/features/user.feature index 76ede2740..dfcaba6e3 100644 --- a/features/user.feature +++ b/features/user.feature @@ -811,3 +811,41 @@ Feature: Manage WordPress users """ newtestuser """ + + Scenario: Create a user with a nicename and rich editing preference + Given a WP install + + When I run `wp user create bob bob@example.com --user_nicename=bobby --rich_editing=false --porcelain` + Then STDOUT should be a number + And save STDOUT as {USER_ID} + + When I run `wp user get {USER_ID} --field=user_nicename` + Then STDOUT should be: + """ + bobby + """ + + When I run `wp user meta get {USER_ID} rich_editing` + Then STDOUT should be: + """ + false + """ + + Scenario: Creating a user without a nicename falls back to the login + Given a WP install + + When I run `wp user create carol carol@example.com --porcelain` + Then STDOUT should be a number + And save STDOUT as {USER_ID} + + When I run `wp user get {USER_ID} --field=user_nicename` + Then STDOUT should be: + """ + carol + """ + + When I run `wp user meta get {USER_ID} rich_editing` + Then STDOUT should be: + """ + true + """ From aeaa92ba467817b364191456073c26f4cf135a20 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 11:45:23 +0000 Subject: [PATCH 2/4] Apply the parameters that were being silently discarded Makes the tests from the previous commit pass. user create: set user_nicename and rich_editing on the object handed to wp_insert_user(). Both use the same `false` default as the neighbouring properties, which core reads as "not supplied" - user_nicename then falls back to the login and rich_editing to 'true'. post create / post update: wp_insert_post() derives post_modified and post_modified_gmt itself and never reads them back from $postarr, so apply the requested value through the wp_insert_post_data filter, registered around the insert and removed straight after. When only one of the pair is given the other is derived from it, so the two never disagree. That last change makes a previously harmless bug visible: `post create --from-post` copies the source post's fields, and post_modified was not among the ones it unset. Core ignored it before, so the duplicate got the current time; now it would inherit the original's. Unset it alongside post_date, and cover it with a scenario. The alternative to all of this is to remove the parameters from the docblocks instead. That is a smaller change but loses functionality the documentation has been promising, so it seemed the wrong way round - happy to invert it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014SSZzqMJRDTiLiDxQEPYcL --- features/post-create-duplicate.feature | 20 ++++++++++ src/Post_Command.php | 55 ++++++++++++++++++++++++++ src/User_Command.php | 4 ++ 3 files changed, 79 insertions(+) diff --git a/features/post-create-duplicate.feature b/features/post-create-duplicate.feature index bfc9f3bc2..dcca8d81e 100644 --- a/features/post-create-duplicate.feature +++ b/features/post-create-duplicate.feature @@ -79,3 +79,23 @@ Feature: Create Duplicate WordPress post from existing posts. Then STDOUT should be a table containing rows: | Field | Value | | post_type | page | + + Scenario: Duplicating a post does not inherit its modification date + Given a WP install + + When I run `wp post create --post_title='Source' --post_status=publish --porcelain` + Then STDOUT should be a number + And save STDOUT as {SOURCE_ID} + + When I run `wp post update {SOURCE_ID} --post_modified='2015-03-03 09:00:00'` + Then STDOUT should not be empty + + When I run `wp post create --from-post={SOURCE_ID} --post_title='Duplicate' --porcelain` + Then STDOUT should be a number + And save STDOUT as {DUPLICATE_ID} + + When I run `wp post get {DUPLICATE_ID} --field=post_modified` + Then STDOUT should not contain: + """ + 2015-03-03 + """ diff --git a/src/Post_Command.php b/src/Post_Command.php index 5e10c546b..68fde8467 100644 --- a/src/Post_Command.php +++ b/src/Post_Command.php @@ -194,6 +194,8 @@ public function create( $args, $assoc_args ) { $post_id = $post_arr['ID']; unset( $post_arr['post_date'] ); unset( $post_arr['post_date_gmt'] ); + unset( $post_arr['post_modified'] ); + unset( $post_arr['post_modified_gmt'] ); unset( $post_arr['guid'] ); unset( $post_arr['ID'] ); @@ -242,8 +244,14 @@ function ( $params ) { } } + $modified_callback = self::add_post_modified_filter( $params ); + $result = wp_insert_post( $params, true ); + if ( $modified_callback ) { + remove_filter( 'wp_insert_post_data', $modified_callback ); + } + if ( $filter_callback ) { remove_filter( 'user_has_cap', $filter_callback ); } @@ -253,6 +261,47 @@ function ( $params ) { ); } + /** + * Applies an explicitly requested modification date. + * + * wp_insert_post() derives post_modified and post_modified_gmt itself and + * never reads them back from $postarr — on update they are unconditionally + * the current time — so the documented parameters have to be applied to the + * post data on its way to the database. + * + * @param array $params Parameters passed to wp_insert_post() or wp_update_post(). + * @return callable|null The registered callback, for the caller to remove, or null when + * no modification date was requested. + */ + private static function add_post_modified_filter( $params ) { + $modified = []; + + foreach ( [ 'post_modified', 'post_modified_gmt' ] as $key ) { + if ( ! empty( $params[ $key ] ) ) { + $modified[ $key ] = $params[ $key ]; + } + } + + if ( empty( $modified ) ) { + return null; + } + + // Keep the pair consistent when only one of the two was given. + if ( ! isset( $modified['post_modified_gmt'] ) ) { + $modified['post_modified_gmt'] = get_gmt_from_date( $modified['post_modified'] ); + } elseif ( ! isset( $modified['post_modified'] ) ) { + $modified['post_modified'] = get_date_from_gmt( $modified['post_modified_gmt'] ); + } + + $callback = static function ( $data ) use ( $modified ) { + return array_merge( $data, $modified ); + }; + + add_filter( 'wp_insert_post_data', $callback ); + + return $callback; + } + /** * Updates one or more existing posts. * @@ -422,8 +471,14 @@ function ( $params ) { } } + $modified_callback = self::add_post_modified_filter( $params ); + $result = wp_update_post( $params, true ); + if ( $modified_callback ) { + remove_filter( 'wp_insert_post_data', $modified_callback ); + } + if ( $filter_callback ) { remove_filter( 'user_has_cap', $filter_callback ); } diff --git a/src/User_Command.php b/src/User_Command.php index 59d6f3d34..98bcecc19 100644 --- a/src/User_Command.php +++ b/src/User_Command.php @@ -437,6 +437,10 @@ public function create( $args, $assoc_args ) { $user->user_url = Utils\get_flag_value( $assoc_args, 'user_url', false ); + $user->user_nicename = Utils\get_flag_value( $assoc_args, 'user_nicename', false ); + + $user->rich_editing = Utils\get_flag_value( $assoc_args, 'rich_editing', false ); + if ( isset( $assoc_args['user_pass'] ) ) { $user->user_pass = $assoc_args['user_pass']; } else { From 4ccf3013e167fe751847999b0c0215d822ced393 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 10:14:35 +0000 Subject: [PATCH 3/4] Give the modification date a type PHPStan can follow The pair was collected into an array with both keys optional, so nothing told PHPStan that reaching the consistency check meant at least one of them was present, and the values stayed mixed all the way into get_gmt_from_date() and get_date_from_gmt(). Two scalars say the same thing in a way the analyser can follow, and the array is built once both are known. is_scalar() guards the cast: a value that is not scalar could not be a date anyway, and casting one to string is what the strict rule is there to prevent. No behaviour change - `! empty()` still decides whether each was given, and the pair is still completed from whichever half arrived. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014SSZzqMJRDTiLiDxQEPYcL --- src/Post_Command.php | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/Post_Command.php b/src/Post_Command.php index 86381e3b4..ffecd3063 100644 --- a/src/Post_Command.php +++ b/src/Post_Command.php @@ -281,25 +281,29 @@ function ( $params ) { * no modification date was requested. */ private static function add_post_modified_filter( $params ) { - $modified = []; - - foreach ( [ 'post_modified', 'post_modified_gmt' ] as $key ) { - if ( ! empty( $params[ $key ] ) ) { - $modified[ $key ] = $params[ $key ]; - } - } - - if ( empty( $modified ) ) { + $local = ! empty( $params['post_modified'] ) && is_scalar( $params['post_modified'] ) + ? (string) $params['post_modified'] + : null; + $gmt = ! empty( $params['post_modified_gmt'] ) && is_scalar( $params['post_modified_gmt'] ) + ? (string) $params['post_modified_gmt'] + : null; + + if ( null === $local && null === $gmt ) { return null; } // Keep the pair consistent when only one of the two was given. - if ( ! isset( $modified['post_modified_gmt'] ) ) { - $modified['post_modified_gmt'] = get_gmt_from_date( $modified['post_modified'] ); - } elseif ( ! isset( $modified['post_modified'] ) ) { - $modified['post_modified'] = get_date_from_gmt( $modified['post_modified_gmt'] ); + if ( null === $gmt ) { + $gmt = get_gmt_from_date( $local ); + } elseif ( null === $local ) { + $local = get_date_from_gmt( $gmt ); } + $modified = [ + 'post_modified' => $local, + 'post_modified_gmt' => $gmt, + ]; + $callback = static function ( $data ) use ( $modified ) { return array_merge( $data, $modified ); }; From e325a80dc3729624471615eb1ef2814f6ef996c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 13:01:56 +0000 Subject: [PATCH 4/4] Cover the paths the first round of tests missed Three gaps, all raised on the pull request. Only '--post_modified' was ever passed, so the branch that fills in the local time from a GMT one was never run. It has a scenario now, and to make the derivation visible rather than incidental the site is put in a timezone with an offset: noon GMT is 07:00 in New York in January and 08:00 in June, and both are asserted, on create and on update. The scenario for the default modification date asserted only that the field was not empty, which a stale value satisfies just as well as a fresh one. It now sets a known date first and asserts the update does not leave it behind. Neither of the user scenarios ran on multisite, where the user is made by wpmu_create_user() with a login, a password and an email, and everything else is applied afterwards by wp_update_user(). That is a different path to wp_insert_user() and it is covered now. Both new scenarios fail against main, where the nicename comes back as the login instead. The multisite login is 'robert' rather than 'bob' because multisite requires four characters, which is a good illustration of why the branch was worth testing separately. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014SSZzqMJRDTiLiDxQEPYcL --- features/post.feature | 58 ++++++++++++++++++++++++++++++++++++++++++- features/user.feature | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/features/post.feature b/features/post.feature index 21f755bc8..ce9858e4e 100644 --- a/features/post.feature +++ b/features/post.feature @@ -724,10 +724,18 @@ Feature: Manage WordPress posts Scenario: A post's modification date defaults to the current time Given a WP install - When I run `wp post create --post_title='Undated post' --post_status=publish --porcelain` + # Given a known modification date, so that an update which failed to set one + # would leave this value behind and be caught. + When I run `wp post create --post_title='Undated post' --post_status=publish --post_modified='2019-02-03 04:05:06' --porcelain` Then STDOUT should be a number And save STDOUT as {POST_ID} + When I run `wp post get {POST_ID} --field=post_modified` + Then STDOUT should be: + """ + 2019-02-03 04:05:06 + """ + When I run `wp post update {POST_ID} --post_title='Retitled'` Then STDOUT should be: """ @@ -735,4 +743,52 @@ Feature: Manage WordPress posts """ When I run `wp post get {POST_ID} --field=post_modified` + Then STDOUT should not contain: + """ + 2019-02-03 04:05:06 + """ + And STDOUT should not be empty + + Scenario: Setting only the GMT modification date derives the local one + Given a WP install + + # A timezone with an offset, so the derived local value is distinguishable + # from the GMT one it was derived from. 1 January is outside DST in New York. + When I run `wp option update timezone_string 'America/New_York'` Then STDOUT should not be empty + + When I run `wp post create --post_title='GMT only' --post_status=publish --post_modified_gmt='2020-01-01 12:00:00' --porcelain` + Then STDOUT should be a number + And save STDOUT as {POST_ID} + + When I run `wp post get {POST_ID} --field=post_modified_gmt` + Then STDOUT should be: + """ + 2020-01-01 12:00:00 + """ + + When I run `wp post get {POST_ID} --field=post_modified` + Then STDOUT should be: + """ + 2020-01-01 07:00:00 + """ + + # And the same on update, which takes the other of the two code paths. + When I run `wp post update {POST_ID} --post_modified_gmt='2020-06-01 12:00:00'` + Then STDOUT should be: + """ + Success: Updated post {POST_ID}. + """ + + When I run `wp post get {POST_ID} --field=post_modified_gmt` + Then STDOUT should be: + """ + 2020-06-01 12:00:00 + """ + + # June is inside DST, so the offset is four hours rather than five. + When I run `wp post get {POST_ID} --field=post_modified` + Then STDOUT should be: + """ + 2020-06-01 08:00:00 + """ diff --git a/features/user.feature b/features/user.feature index dfcaba6e3..f89518126 100644 --- a/features/user.feature +++ b/features/user.feature @@ -849,3 +849,48 @@ Feature: Manage WordPress users """ true """ + + # Multisite creates the user through wpmu_create_user(), which takes only a + # login, a password and an email, and then applies the rest with + # wp_update_user(). That is a different path to wp_insert_user(), so both + # fields are worth asserting again here. + Scenario: Create a user with a nicename and rich editing preference on multisite + Given a WP multisite install + + # Multisite requires a login of at least four characters, and a login that + # differs from the nicename shows the nicename was applied rather than + # derived. + When I run `wp user create robert robert@example.com --user_nicename=bobby --rich_editing=false --porcelain` + Then STDOUT should be a number + And save STDOUT as {USER_ID} + + When I run `wp user get {USER_ID} --field=user_nicename` + Then STDOUT should be: + """ + bobby + """ + + When I run `wp user meta get {USER_ID} rich_editing` + Then STDOUT should be: + """ + false + """ + + Scenario: Creating a user without a nicename falls back to the login on multisite + Given a WP multisite install + + When I run `wp user create carol carol@example.com --porcelain` + Then STDOUT should be a number + And save STDOUT as {USER_ID} + + When I run `wp user get {USER_ID} --field=user_nicename` + Then STDOUT should be: + """ + carol + """ + + When I run `wp user meta get {USER_ID} rich_editing` + Then STDOUT should be: + """ + true + """