diff --git a/components/Markdown/class-markdownconsumer.php b/components/Markdown/class-markdownconsumer.php index 768282f8..43e914c5 100644 --- a/components/Markdown/class-markdownconsumer.php +++ b/components/Markdown/class-markdownconsumer.php @@ -443,7 +443,12 @@ private function parse_frontmatter_block( $frontmatter ) { } private function parse_frontmatter_scalar( $raw ) { - if ( preg_match( '/^\[|\{|- /', $raw ) ) { + // Detect YAML flow sequences ([...]), flow mappings ({...}), and block + // sequence items (- item) by their leading indicator. The `^` must bind all + // three alternatives: without the group, `\{` and `- ` matched anywhere in + // the value, so any quoted scalar containing "{" or "- " (e.g. a title like + // "Jetpack 13.0 - AI Assistant") was wrongly parsed as an empty array. + if ( preg_match( '/^(?:\[|\{|- )/', $raw ) ) { return array(); } diff --git a/plugins/push-md/Tests/EndToEndTest.php b/plugins/push-md/Tests/EndToEndTest.php index 7d70affe..eebce6ca 100644 --- a/plugins/push-md/Tests/EndToEndTest.php +++ b/plugins/push-md/Tests/EndToEndTest.php @@ -132,6 +132,183 @@ public function testCloneFailsClosedWhenPageParentIsNotExported() { } } + /** + * Seeding mode (push_md_allow_create_on_missing_id) must resolve by slug and + * ignore the front matter id, even when that id points at a *different* post of + * the same type. Without the fix this rejects with "conflicts with the WordPress + * post already mapped to this file path". + */ + public function testSeedingResolvesBySlugWhenIdPointsAtAnotherPost() { + $this->set_allow_create_on_missing_id( true ); + try { + $suffix = uniqid( 'pmd-stype-' ); + $id_a = $this->create_page_via_rest( + array( + 'slug' => $suffix . '-a', + 'title' => 'Stype A ' . $suffix, + 'status' => 'publish', + 'content' => '

STYPE-A-ORIG

', + ) + ); + $id_b = $this->create_page_via_rest( + array( + 'slug' => $suffix . '-b', + 'title' => 'Stype B ' . $suffix, + 'status' => 'publish', + 'content' => '

STYPE-B-ORIG

', + ) + ); + + $clone = $this->clone_repo( $suffix ); + $this->configure_git( $clone ); + $page_md = $clone . '/page/' . $suffix . '-a.md'; + $this->assertFileExists( $page_md ); + // Point page A's file at page B's id (same type, different post) and edit body. + $this->edit_file( $page_md, 'id: "' . $id_a . '"', 'id: "' . $id_b . '"' ); + $this->edit_file( $page_md, 'STYPE-A-ORIG', 'STYPE-A-UPDATED' ); + $this->commit_and_push( $clone, 'page/' . $suffix . '-a.md', 'Seed: id points at another post' ); + + // The update must land on page A (slug match), not page B (the id'd post). + $this->assertStringContainsString( 'STYPE-A-UPDATED', $this->fetch_content( $id_a, 'pages' ) ); + $this->assertStringContainsString( 'STYPE-B-ORIG', $this->fetch_content( $id_b, 'pages' ) ); + } finally { + $this->set_allow_create_on_missing_id( false ); + } + } + + /** + * Seeding mode must resolve by slug even when the front matter id collides with a + * post of a *different* type. Without the fix this rejects with "references a + * different WordPress post type". + */ + public function testSeedingResolvesBySlugWhenIdCollidesWithOtherType() { + $this->set_allow_create_on_missing_id( true ); + try { + $suffix = uniqid( 'pmd-xtype-' ); + $post_id = $this->create_post_via_rest( + array( + 'slug' => $suffix . '-post', + 'title' => 'Xtype Post ' . $suffix, + 'status' => 'publish', + 'content' => '

XTYPE-POST-ORIG

', + ) + ); + $page_id = $this->create_page_via_rest( + array( + 'slug' => $suffix . '-page', + 'title' => 'Xtype Page ' . $suffix, + 'status' => 'publish', + 'content' => '

XTYPE-PAGE-ORIG

', + ) + ); + + $clone = $this->clone_repo( $suffix ); + $this->configure_git( $clone ); + $page_md = $clone . '/page/' . $suffix . '-page.md'; + $this->assertFileExists( $page_md ); + // Point the page file at the *post's* id (cross-type collision) and edit body. + $this->edit_file( $page_md, 'id: "' . $page_id . '"', 'id: "' . $post_id . '"' ); + $this->edit_file( $page_md, 'XTYPE-PAGE-ORIG', 'XTYPE-PAGE-UPDATED' ); + $this->commit_and_push( $clone, 'page/' . $suffix . '-page.md', 'Seed: cross-type id collision' ); + + $this->assertStringContainsString( 'XTYPE-PAGE-UPDATED', $this->fetch_content( $page_id, 'pages' ) ); + $this->assertStringContainsString( 'XTYPE-POST-ORIG', $this->fetch_content( $post_id, 'posts' ) ); + } finally { + $this->set_allow_create_on_missing_id( false ); + } + } + + /** + * Two files in one push whose front matter ids collide must both resolve by their + * own slugs. Without the fix this rejects with "multiple Markdown files reference + * the same WordPress post ID". + */ + public function testSeedingAllowsTwoFilesWithCollidingIds() { + $this->set_allow_create_on_missing_id( true ); + try { + $suffix = uniqid( 'pmd-dup-' ); + $page_id = $this->create_page_via_rest( + array( + 'slug' => $suffix . '-page', + 'title' => 'Dup Page ' . $suffix, + 'status' => 'publish', + 'content' => '

DUP-PAGE-ORIG

', + ) + ); + $post_id = $this->create_post_via_rest( + array( + 'slug' => $suffix . '-post', + 'title' => 'Dup Post ' . $suffix, + 'status' => 'publish', + 'content' => '

DUP-POST-ORIG

', + ) + ); + + $clone = $this->clone_repo( $suffix ); + $this->configure_git( $clone ); + $page_md = $clone . '/page/' . $suffix . '-page.md'; + $post_md = $clone . '/post/' . $suffix . '-post.md'; + $this->assertFileExists( $page_md ); + $this->assertFileExists( $post_md ); + // Make both files claim the same id (the post's), so id-based resolution + // would map both to one post; slug-based resolution keeps them distinct. + $this->edit_file( $page_md, 'id: "' . $page_id . '"', 'id: "' . $post_id . '"' ); + $this->edit_file( $page_md, 'DUP-PAGE-ORIG', 'DUP-PAGE-UPDATED' ); + $this->edit_file( $post_md, 'DUP-POST-ORIG', 'DUP-POST-UPDATED' ); + + $this->run_cmd( array( 'git', '-C', $clone, 'add', 'page/' . $suffix . '-page.md', 'post/' . $suffix . '-post.md' ) ); + $this->run_cmd( array( 'git', '-C', $clone, 'commit', '-m', 'Seed: two files, colliding ids' ) ); + $this->run_cmd( array( 'git', '-C', $clone, 'push', 'origin', 'trunk' ) ); + + $this->assertStringContainsString( 'DUP-PAGE-UPDATED', $this->fetch_content( $page_id, 'pages' ) ); + $this->assertStringContainsString( 'DUP-POST-UPDATED', $this->fetch_content( $post_id, 'posts' ) ); + } finally { + $this->set_allow_create_on_missing_id( false ); + } + } + + /** + * Production mode (option off) must still reject a front matter id that references + * a different post type -- the strict guard is preserved. + */ + public function testProductionStillRejectsCrossTypeIdMismatch() { + $this->set_allow_create_on_missing_id( false ); + $suffix = uniqid( 'pmd-strict-' ); + $post_id = $this->create_post_via_rest( + array( + 'slug' => $suffix . '-post', + 'title' => 'Strict Post ' . $suffix, + 'status' => 'publish', + 'content' => '

STRICT-POST-ORIG

', + ) + ); + $page_id = $this->create_page_via_rest( + array( + 'slug' => $suffix . '-page', + 'title' => 'Strict Page ' . $suffix, + 'status' => 'publish', + 'content' => '

STRICT-PAGE-ORIG

', + ) + ); + + $clone = $this->clone_repo( $suffix ); + $this->configure_git( $clone ); + $page_md = $clone . '/page/' . $suffix . '-page.md'; + $this->assertFileExists( $page_md ); + $this->edit_file( $page_md, 'id: "' . $page_id . '"', 'id: "' . $post_id . '"' ); + $this->edit_file( $page_md, 'STRICT-PAGE-ORIG', 'STRICT-PAGE-UPDATED' ); + + $this->run_cmd( array( 'git', '-C', $clone, 'add', 'page/' . $suffix . '-page.md' ) ); + $this->run_cmd( array( 'git', '-C', $clone, 'commit', '-m', 'Strict: cross-type id mismatch' ) ); + $push = $this->run_cmd( array( 'git', '-C', $clone, 'push', 'origin', 'trunk' ), true ); + + $this->assertNotSame( 0, $push['code'], 'Production push must reject a cross-type id mismatch.' ); + $this->assertStringContainsString( + 'Push rejected because Markdown front matter id references a different WordPress post type.', + $push['output'] + ); + } + public function testFullRoundTrip() { $hierarchy_suffix = uniqid( 'hierarchy-' ); $parent_a_slug = 'parent-a-' . $hierarchy_suffix; @@ -1064,6 +1241,50 @@ private function update_post_via_rest( $id, array $payload ) { $this->assertSame( 200, $status, "REST update failed: $response" ); } + private function create_post_via_rest( array $payload ) { + $ch = curl_init( $this->base_url . '/wp-json/wp/v2/posts?context=edit' ); + curl_setopt_array( + $ch, + array( + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CUSTOMREQUEST => 'POST', + CURLOPT_HTTPHEADER => array( $this->auth_header, 'Content-Type: application/json' ), + CURLOPT_POSTFIELDS => pmd_e2e_json_encode( $payload ), + ) + ); + $response = curl_exec( $ch ); + $status = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); + curl_close( $ch ); + $this->assertTrue( 200 === $status || 201 === $status, "REST post create failed: $response" ); + + $post = json_decode( $response, true ); + $this->assertIsArray( $post, "Unexpected REST post create response: $response" ); + $this->assertArrayHasKey( 'id', $post, "REST post create response had no ID: $response" ); + + return intval( $post['id'] ); + } + + private function set_allow_create_on_missing_id( $enabled ) { + $ch = curl_init( $this->base_url . '/wp-json/push-md-test/v1/allow-create-on-missing-id' ); + curl_setopt_array( + $ch, + array( + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CUSTOMREQUEST => 'POST', + CURLOPT_HTTPHEADER => array( $this->auth_header, 'Content-Type: application/json' ), + CURLOPT_POSTFIELDS => pmd_e2e_json_encode( array( 'enabled' => (bool) $enabled ) ), + ) + ); + $response = curl_exec( $ch ); + $status = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); + curl_close( $ch ); + $this->assertSame( + 200, + $status, + 'Could not toggle push_md_allow_create_on_missing_id (is ci-mu-test-helper.php installed?): ' . $response + ); + } + private function curl_get( $url ) { $ch = curl_init( $url ); curl_setopt_array( diff --git a/plugins/push-md/Tests/ExportPathTest.php b/plugins/push-md/Tests/ExportPathTest.php index 7e531a4e..49450ca7 100644 --- a/plugins/push-md/Tests/ExportPathTest.php +++ b/plugins/push-md/Tests/ExportPathTest.php @@ -12,9 +12,18 @@ class WP_Post { public $post_type = 'post'; public $post_name = ''; public $post_parent = 0; + public $post_status = 'publish'; + public $post_title = ''; + public $post_date = '2000-01-01 00:00:00'; + public $post_date_gmt = '2000-01-01 00:00:00'; + public $post_modified_gmt = '2000-01-01 00:00:00'; + public $post_content = '

Test content

'; + public $post_excerpt = ''; } } +$push_md_test_posts = array(); + if ( ! function_exists( 'sanitize_title' ) ) { function sanitize_title( $title ) { $title = strtolower( (string) $title ); @@ -38,10 +47,125 @@ function taxonomy_exists( $taxonomy ) { } } +if ( ! function_exists( 'current_user_can' ) ) { + function current_user_can( $capability ) { + unset( $capability ); + + return true; + } +} + +if ( ! function_exists( 'get_posts' ) ) { + function get_posts( $args = array() ) { + global $push_md_test_posts; + + $posts = $push_md_test_posts; + + if ( isset( $args['post_type'] ) ) { + $post_types = (array) $args['post_type']; + $posts = array_filter( + $posts, + function ( $post ) use ( $post_types ) { + return in_array( $post->post_type, $post_types, true ); + } + ); + } + + if ( isset( $args['name'] ) ) { + $posts = array_filter( + $posts, + function ( $post ) use ( $args ) { + return $post->post_name === $args['name']; + } + ); + } + + if ( isset( $args['post_parent'] ) ) { + $posts = array_filter( + $posts, + function ( $post ) use ( $args ) { + return intval( $post->post_parent ) === intval( $args['post_parent'] ); + } + ); + } + + if ( isset( $args['post_status'] ) ) { + $statuses = (array) $args['post_status']; + $posts = array_filter( + $posts, + function ( $post ) use ( $statuses ) { + return in_array( $post->post_status, $statuses, true ); + } + ); + } + + usort( + $posts, + function ( $a, $b ) { + return intval( $a->ID ) - intval( $b->ID ); + } + ); + + if ( isset( $args['fields'] ) && 'ids' === $args['fields'] ) { + return array_map( + function ( $post ) { + return intval( $post->ID ); + }, + $posts + ); + } + + return $posts; + } +} + +if ( ! function_exists( 'get_post' ) ) { + function get_post( $post_id ) { + global $push_md_test_posts; + + foreach ( $push_md_test_posts as $post ) { + if ( intval( $post->ID ) === intval( $post_id ) ) { + return $post; + } + } + + return null; + } +} + +if ( ! function_exists( 'get_option' ) ) { + function get_option( $name, $default = false ) { + unset( $name ); + + return $default; + } +} + +if ( ! function_exists( 'wp_json_encode' ) ) { + function wp_json_encode( $data, $options = 0 ) { + return json_encode( $data, $options ); + } +} + +if ( ! function_exists( 'esc_html' ) ) { + function esc_html( $text ) { + return htmlspecialchars( (string) $text, ENT_QUOTES, 'UTF-8' ); + } +} + require_once dirname( __DIR__ ) . '/class-push-md-plugin.php'; class PMD_Export_Path_Test extends TestCase { + /** + * @after + */ + public function reset_test_posts() { + global $push_md_test_posts; + + $push_md_test_posts = array(); + } + public function testPostWithEmptySlugUsesStableIdFallbackPath() { $this->assertSame( 'post/post-4937.md', @@ -100,16 +224,102 @@ public function testFallbackShapedRealSlugIsAcceptedWhenItIsCurrent() { ); } - private function post( $id, $post_type, $post_name ) { + public function testDuplicatePathThrowsActionableErrorNamingBothPosts() { + $this->set_test_posts( + array( + $this->post( 101, 'post', 'shared-slug' ), + $this->post( 202, 'post', 'shared-slug' ), + ) + ); + + try { + $this->export_wordpress_content(); + $this->fail( 'Expected duplicate export paths to be rejected.' ); + } catch ( Exception $exception ) { + $message = $exception->getMessage(); + $this->assertStringContainsString( 'post/shared-slug.md', $message ); + $this->assertStringContainsString( '101', $message ); + $this->assertStringContainsString( '202', $message ); + } + } + + public function testDetectExportPathCollisionsReturnsConflictingGroup() { + $this->set_test_posts( + array( + $this->post( 101, 'post', 'shared-slug' ), + $this->post( 202, 'post', 'shared-slug' ), + $this->post( 303, 'post', 'unique-slug' ), + ) + ); + + $collisions = Push_MD_Plugin::detect_export_path_collisions(); + + $this->assertCount( 1, $collisions ); + $this->assertSame( 'post/shared-slug.md', $collisions[0]['path'] ); + $this->assertSame( array( 101, 202 ), $collisions[0]['post_ids'] ); + } + + public function testDetectExportPathCollisionsIgnoresUniquePaths() { + $this->set_test_posts( + array( + $this->post( 101, 'post', 'first-slug' ), + $this->post( 202, 'post', 'second-slug' ), + ) + ); + + $this->assertSame( array(), Push_MD_Plugin::detect_export_path_collisions() ); + } + + public function testDetectExportPathCollisionsSkipsUnbuildablePaths() { + // A published page pointing at a missing parent makes build_markdown_path() + // throw for that page; detection must skip it instead of aborting, and + // still report the unrelated post collision. + $orphan = $this->post( 70, 'page', 'orphan' ); + $orphan->post_parent = 9999; + + $this->set_test_posts( + array( + $orphan, + $this->post( 101, 'post', 'shared-slug' ), + $this->post( 202, 'post', 'shared-slug' ), + ) + ); + + $collisions = Push_MD_Plugin::detect_export_path_collisions(); + + $this->assertCount( 1, $collisions ); + $this->assertSame( 'post/shared-slug.md', $collisions[0]['path'] ); + $this->assertSame( array( 101, 202 ), $collisions[0]['post_ids'] ); + } + + private function post( $id, $post_type, $post_name, $post_status = 'publish', $post_date_gmt = '2000-01-01 00:00:00' ) { $post = new WP_Post(); $post->ID = $id; $post->post_type = $post_type; $post->post_name = $post_name; $post->post_parent = 0; + $post->post_status = $post_status; + $post->post_title = 'Post ' . $id; + $post->post_date = $post_date_gmt; + $post->post_date_gmt = $post_date_gmt; + $post->post_modified_gmt = $post_date_gmt; return $post; } + private function set_test_posts( $posts ) { + global $push_md_test_posts; + + $push_md_test_posts = $posts; + } + + private function export_wordpress_content() { + $method = new ReflectionMethod( Push_MD_Plugin::class, 'export_wordpress_content' ); + $method->setAccessible( true ); + + return $method->invoke( null ); + } + private function assert_id_fallback_path_is_current( $path, WP_Post $post ) { $method = new ReflectionMethod( Push_MD_Plugin::class, 'assert_id_fallback_path_is_current' ); $method->setAccessible( true ); diff --git a/plugins/push-md/Tests/ci-mu-test-helper.php b/plugins/push-md/Tests/ci-mu-test-helper.php index 885a7bbb..7935184b 100644 --- a/plugins/push-md/Tests/ci-mu-test-helper.php +++ b/plugins/push-md/Tests/ci-mu-test-helper.php @@ -29,3 +29,29 @@ add_filter( 'push_md_seed_tick_reschedule_seconds', static function () { return 0; } ); + +// CI-only route so the e2e suite can flip push_md_allow_create_on_missing_id +// (local one-way seeding mode) over HTTP -- the test process has no wp-cli. +// Admin-only; this mu-plugin is dropped in only by the e2e workflow. +add_action( 'rest_api_init', static function () { + register_rest_route( + 'push-md-test/v1', + '/allow-create-on-missing-id', + array( + 'methods' => 'POST', + 'permission_callback' => static function () { + return current_user_can( 'manage_options' ); + }, + 'callback' => static function ( $request ) { + $enabled = (bool) $request->get_param( 'enabled' ); + if ( $enabled ) { + update_option( 'push_md_allow_create_on_missing_id', 1 ); + } else { + delete_option( 'push_md_allow_create_on_missing_id' ); + } + + return array( 'enabled' => $enabled ); + }, + ) + ); +} ); diff --git a/plugins/push-md/admin-shell.css b/plugins/push-md/admin-shell.css index b9f49a31..8a27f1ba 100644 --- a/plugins/push-md/admin-shell.css +++ b/plugins/push-md/admin-shell.css @@ -449,3 +449,54 @@ padding-top: 0; } } + +.push-md-collisions-panel { + border-color: var(--push-md-red); + background: #fff5f5; +} + +.push-md-collisions-panel[hidden] { + display: none; +} + +.push-md-collisions-panel h2 { + color: #8a1f11; +} + +.push-md-collisions-panel p { + margin: 0 0 12px; + color: var(--push-md-ink); + font-size: 13px; + line-height: 1.4; +} + +.push-md-collisions-list { + display: flex; + flex-direction: column; + gap: 12px; + margin: 0; +} + +.push-md-collisions-list > li { + margin: 0; + font-size: 12px; + line-height: 1.35; +} + +.push-md-collisions-list > li > code { + color: #8a1f11; + background: #ffe3e3; + border-radius: 4px; + padding: 1px 4px; +} + +.push-md-collisions-list ul { + margin: 6px 0 0; + padding-left: 18px; + list-style: disc; + color: var(--push-md-muted); +} + +.push-md-collisions-list ul li { + margin: 2px 0; +} diff --git a/plugins/push-md/admin-shell.js b/plugins/push-md/admin-shell.js index f7286d73..c64f1808 100644 --- a/plugins/push-md/admin-shell.js +++ b/plugins/push-md/admin-shell.js @@ -30,6 +30,8 @@ var cwdEl = document.getElementById( 'push-md-prompt-cwd' ); var titleEl = document.getElementById( 'push-md-terminal-title' ); var commitListEl = document.getElementById( 'push-md-commit-list' ); + var collisionPanelEl = document.getElementById( 'push-md-collisions-panel' ); + var collisionListEl = document.getElementById( 'push-md-collisions-list' ); var checkout = normalizeCheckout( progress.checkout ); var cwd = '/'; var history = []; @@ -58,6 +60,7 @@ ); messageEl.textContent = progress.message; renderCommits( progress.commits || [] ); + renderCollisions( progress.collisions || [] ); if (previousState !== 'done' && progress.state === 'done' && ! hasAnnouncedReady) { hasAnnouncedReady = true; appendLine( __( 'remote: Initial import complete. The checkout is ready.', 'push-md' ), 'is-success' ); @@ -121,6 +124,51 @@ ); } + function renderCollisions(collisions) { + if ( ! collisionPanelEl || ! collisionListEl) { + return; + } + collisionListEl.textContent = ''; + if ( ! collisions.length) { + collisionPanelEl.hidden = true; + return; + } + collisionPanelEl.hidden = false; + collisions.forEach( + function (collision) { + var item = document.createElement( 'li' ); + var pathCode = document.createElement( 'code' ); + pathCode.textContent = collision.path; + item.appendChild( pathCode ); + + var postsList = document.createElement( 'ul' ); + (collision.posts || []).forEach( + function (post) { + var label = sprintf( + /* translators: 1: Post title, 2: Post ID, 3: Post status. */ + __( '%1$s (ID %2$d, %3$s)', 'push-md' ), + post.title || __( '(untitled)', 'push-md' ), + post.id, + post.status + ); + var postItem = document.createElement( 'li' ); + if (post.edit_url) { + var link = document.createElement( 'a' ); + link.href = post.edit_url; + link.textContent = label; + postItem.appendChild( link ); + } else { + postItem.textContent = label; + } + postsList.appendChild( postItem ); + } + ); + item.appendChild( postsList ); + collisionListEl.appendChild( item ); + } + ); + } + function appendLine(text, className) { var line = document.createElement( 'div' ); line.className = 'push-md-terminal-line'; diff --git a/plugins/push-md/class-push-md-admin.php b/plugins/push-md/class-push-md-admin.php index 9af1ecb6..f610c3f2 100644 --- a/plugins/push-md/class-push-md-admin.php +++ b/plugins/push-md/class-push-md-admin.php @@ -9,11 +9,12 @@ */ class Push_MD_Admin { - const PAGE_SLUG = 'push-md'; - const REST_NAMESPACE = 'push-md/v1'; - const STATUS_ROUTE = '/seed-status'; - const RETRY_ROUTE = '/seed-retry'; - const ASSET_VERSION = '0.6.7'; + const PAGE_SLUG = 'push-md'; + const REST_NAMESPACE = 'push-md/v1'; + const STATUS_ROUTE = '/seed-status'; + const RETRY_ROUTE = '/seed-retry'; + const ASSET_VERSION = '0.6.7'; + const COLLISIONS_TRANSIENT = 'push_md_collisions'; public static function bootstrap() { add_action( 'admin_menu', array( __CLASS__, 'register_menu' ) ); @@ -81,15 +82,66 @@ public static function admin_only() { public static function rest_status() { Push_MD_Seeder::drive( 1.5 ); - return rest_ensure_response( Push_MD_Seeder::get_progress() ); + $progress = Push_MD_Seeder::get_progress(); + $progress['collisions'] = self::collisions_for_display(); + + return rest_ensure_response( $progress ); } public static function rest_retry() { Push_MD_Seeder::reset(); Push_MD_Seeder::on_activation(); Push_MD_Seeder::tick(); + delete_transient( self::COLLISIONS_TRANSIENT ); + + $progress = Push_MD_Seeder::get_progress(); + $progress['collisions'] = self::collisions_for_display(); - return rest_ensure_response( Push_MD_Seeder::get_progress() ); + return rest_ensure_response( $progress ); + } + + /** + * Build the collision report consumed by the admin page and status route. + * + * Detection mirrors the export query and can scan every supported post, so + * the result is cached briefly because the status route is polled. + * + * @return array List of array( 'path' => string, 'posts' => array ) where + * each post carries id, title, status, and an edit URL. + */ + private static function collisions_for_display() { + $cached = get_transient( self::COLLISIONS_TRANSIENT ); + if ( is_array( $cached ) ) { + return $cached; + } + + $payload = array(); + foreach ( Push_MD_Plugin::detect_export_path_collisions() as $collision ) { + $posts = array(); + foreach ( $collision['post_ids'] as $post_id ) { + $post = get_post( $post_id ); + if ( ! $post ) { + continue; + } + + $edit_url = get_edit_post_link( $post_id, 'raw' ); + $posts[] = array( + 'id' => intval( $post_id ), + 'title' => $post->post_title, + 'status' => $post->post_status, + 'edit_url' => $edit_url ? $edit_url : '', + ); + } + + $payload[] = array( + 'path' => $collision['path'], + 'posts' => $posts, + ); + } + + set_transient( self::COLLISIONS_TRANSIENT, $payload, 30 ); + + return $payload; } private static function add_username_to_url( $url, $username ) { @@ -122,12 +174,14 @@ public static function render_page() { // Drive the seeder before painting so the first screen already // has real checkout state whenever the host can do quick work. Push_MD_Seeder::drive( 1.5 ); - $progress = Push_MD_Seeder::get_progress(); - $nonce = wp_create_nonce( 'wp_rest' ); - $status_url = esc_url_raw( rest_url( self::REST_NAMESPACE . self::STATUS_ROUTE ) ); - $retry_url = esc_url_raw( rest_url( self::REST_NAMESPACE . self::RETRY_ROUTE ) ); - $git_url = esc_url_raw( rest_url( Push_MD_Plugin::ROUTE_NAMESPACE . '/md.git' ) ); - $user = wp_get_current_user(); + $progress = Push_MD_Seeder::get_progress(); + $collisions = self::collisions_for_display(); + $progress['collisions'] = $collisions; + $nonce = wp_create_nonce( 'wp_rest' ); + $status_url = esc_url_raw( rest_url( self::REST_NAMESPACE . self::STATUS_ROUTE ) ); + $retry_url = esc_url_raw( rest_url( self::REST_NAMESPACE . self::RETRY_ROUTE ) ); + $git_url = esc_url_raw( rest_url( Push_MD_Plugin::ROUTE_NAMESPACE . '/md.git' ) ); + $user = wp_get_current_user(); if ( $user && $user->exists() ) { $git_url = self::add_username_to_url( $git_url, $user->user_login ); } @@ -192,6 +246,12 @@ public static function render_page() { +
> +

+

+ +
+
diff --git a/plugins/push-md/class-push-md-plugin.php b/plugins/push-md/class-push-md-plugin.php index 9d55ec9d..4b027374 100644 --- a/plugins/push-md/class-push-md-plugin.php +++ b/plugins/push-md/class-push-md-plugin.php @@ -645,7 +645,14 @@ private static function export_wordpress_content() { $path = self::build_markdown_path( $post ); if ( isset( $files[ $path ] ) ) { - throw new Exception( 'Git export rejected because multiple WordPress entities map to the same Push MD path: ' . esc_html( $path ) ); + throw new Exception( + sprintf( + 'Git export rejected because multiple WordPress entities map to the same Push MD path: %1$s (WordPress post IDs %2$d and %3$d). Change a slug or trash a duplicate, then pull again.', + esc_html( $path ), + intval( $files[ $path ]['post']->ID ), + intval( $post->ID ) + ) + ); } $content = self::export_post_to_markdown( $post ); @@ -692,6 +699,55 @@ private static function export_wordpress_content() { return $files; } + /** + * Detect WordPress entities that would export to the same Push MD path. + * + * Mirrors the post query used by export_wordpress_content() so the report + * matches what blocks the export. Posts whose path cannot be built (for + * example a page whose parent hierarchy is broken) are skipped because that + * is a separate export failure, not a path collision. + * + * @return array List of array( 'path' => string, 'post_ids' => int[] ) for + * every path claimed by more than one post. + */ + public static function detect_export_path_collisions() { + $posts = get_posts( + array( + 'post_type' => self::get_export_post_types(), + 'post_status' => self::$supported_post_statuses, + 'posts_per_page' => -1, + 'orderby' => 'ID', + 'order' => 'ASC', + ) + ); + + $paths = array(); + foreach ( $posts as $post ) { + try { + $path = self::build_markdown_path( $post ); + } catch ( Exception $e ) { + continue; + } + + if ( ! isset( $paths[ $path ] ) ) { + $paths[ $path ] = array(); + } + $paths[ $path ][] = intval( $post->ID ); + } + + $collisions = array(); + foreach ( $paths as $path => $post_ids ) { + if ( count( $post_ids ) > 1 ) { + $collisions[] = array( + 'path' => $path, + 'post_ids' => $post_ids, + ); + } + } + + return $collisions; + } + private static function add_default_agent_guidance_files( &$files, &$has_guideline_skills, &$agent_guide_skill_path ) { if ( ! self::guidelines_enabled() ) { return; @@ -1452,6 +1508,19 @@ private static function apply_repository_diff_to_wordpress( GitRepository $repos ); } + if ( self::allow_create_on_missing_id() ) { + // Seeding a fresh site: apply ancestor pages before their nested children so + // each parent exists before a child resolves it. Sorting paths ascending + // orders e.g. page/features.md before page/features/security.md (the ".md" + // dot sorts before the "/" path separator). + usort( + $upsert_plans, + static function ( $a, $b ) { + return strcmp( $a['path'], $b['path'] ); + } + ); + } + foreach ( $upsert_plans as $plan ) { $applied = self::upsert_post_from_markdown( $plan['path'], @@ -1630,6 +1699,24 @@ private static function reject_executable_file_changes( $old_files, $new_files ) } } + /** + * Public entry point for seeding a single content file into this site. + * + * Delegates to the private upsert. Intended for non-git transports that have + * the markdown in hand (e.g. a WordPress Playground runPHP blueprint step that + * cannot reach the git endpoint). Set the push_md_allow_create_on_missing_id + * option BEFORE calling this if the front matter id may not exist on the + * target (a fresh seed/preview site); this method does not change that option. + * + * @param string $path Repo-relative content path, e.g. post/.md. + * @param string $markdown The file's markdown (with front matter). + * @param array $options Optional upsert options (e.g. skip_modified_check). + * @return array{post_id:int,change:?array} Same shape upsert returns. + */ + public static function seed_markdown_file( $path, $markdown, $options = array() ) { + return self::upsert_post_from_markdown( $path, $markdown, $options ); + } + private static function upsert_post_from_markdown( $path, $markdown, $options = array() ) { self::assert_content_has_no_nul_bytes( $markdown ); $post_type = self::path_to_post_type( $path ); @@ -2365,15 +2452,21 @@ private static function find_post_id_by_path_metadata( $path, $metadata, $includ $include_trash ); } + // During local one-way seeding (push_md_allow_create_on_missing_id), the front + // matter ids are foreign production ids that do not map to this site, so resolve + // purely by slug/path and ignore the id. This stops a production id that happens + // to collide with an unrelated local post id from rejecting the whole push. + // Production pushes leave the option off and keep strict id-based resolution. + $ignore_frontmatter_id = self::allow_create_on_missing_id(); if ( 'page' === $post_type ) { - if ( isset( $metadata['id'] ) ) { + if ( isset( $metadata['id'] ) && ! $ignore_frontmatter_id ) { return self::find_post_id_by_frontmatter_id( $path, $metadata, $include_trash ); } return self::find_page_id_by_path( $path, $include_trash ); } - if ( isset( $metadata['id'] ) ) { + if ( isset( $metadata['id'] ) && ! $ignore_frontmatter_id ) { return self::find_post_id_by_frontmatter_id( $path, $metadata, $include_trash ); } @@ -2425,6 +2518,15 @@ private static function find_post_id_by_frontmatter_id( $path, $metadata, $inclu $post = get_post( $id ); if ( ! $post ) { + if ( self::allow_create_on_missing_id() ) { + // Local one-way seeding (e.g. a fresh Studio site): the front matter id + // references a post id that does not exist here. Fall back to slug/path + // resolution so the push updates a matching post or creates a new one, + // instead of rejecting the whole push. Stays idempotent across re-seeds. + $path_metadata = $metadata; + unset( $path_metadata['id'] ); + return self::find_post_id_by_path_metadata( $path, $path_metadata, $include_trash ); + } throw new Exception( 'Push rejected because Markdown front matter id does not reference an existing WordPress post.' ); } if ( $post_type !== $post->post_type ) { @@ -2448,6 +2550,20 @@ private static function find_post_id_by_frontmatter_id( $path, $metadata, $inclu return $id; } + /** + * Whether a push may resolve by slug/path (updating or creating a post) when a + * front matter id does not reference an existing post, instead of rejecting. + * + * Defaults to false so production pushes still fail fast on an id mismatch -- a + * guard against pushing to the wrong or incomplete site. Local one-way seeding + * (e.g. Studio) opts in via the push_md_allow_create_on_missing_id option, or a + * filter of the same name. + */ + private static function allow_create_on_missing_id() { + return (bool) get_option( 'push_md_allow_create_on_missing_id', false ) + || (bool) apply_filters( 'push_md_allow_create_on_missing_id', false ); + } + private static function normalize_frontmatter_post_id( $id ) { $id = trim( (string) $id ); if ( ! preg_match( '/^[1-9][0-9]*$/', $id ) ) { @@ -2535,6 +2651,14 @@ private static function resolve_page_parent_id( $parent_slugs, $include_trash = if ( empty( $parents ) ) { $page_id = self::find_slugless_page_id_by_fallback_slug( $slug, $parent_id, $statuses ); if ( ! $page_id ) { + if ( self::allow_create_on_missing_id() ) { + // Seeding a fresh site: this parent page does not exist yet. The + // validation (dry-run) pass tolerates the gap by returning no + // parent, and the apply pass applies ancestor pages before their + // children (the upsert plans are path-sorted), so the real parent + // exists by the time a child page is created. + return 0; + } throw new Exception( 'Push rejected because nested page paths must reference existing WordPress parent pages.' ); } diff --git a/plugins/push-md/class-push-md-seeder.php b/plugins/push-md/class-push-md-seeder.php index 44a7ff46..93a6d01a 100644 --- a/plugins/push-md/class-push-md-seeder.php +++ b/plugins/push-md/class-push-md-seeder.php @@ -589,6 +589,11 @@ private static function initialize() { ); // phpcs:enable + $collisions = Push_MD_Plugin::detect_export_path_collisions(); + if ( ! empty( $collisions ) ) { + throw new Exception( self::format_collision_failure_message( $collisions ) ); + } + $existing = self::get_progress_storage(); $tick_count = isset( $existing['tick_count'] ) ? intval( $existing['tick_count'] ) : 0; $progress = array( @@ -619,6 +624,26 @@ private static function initialize() { } } + private static function format_collision_failure_message( $collisions ) { + $shown = array_slice( $collisions, 0, 5 ); + $details = array(); + foreach ( $shown as $collision ) { + $details[] = sprintf( + '%s (post IDs %s)', + $collision['path'], + implode( ', ', array_map( 'intval', $collision['post_ids'] ) ) + ); + } + + $message = 'Push MD import blocked because multiple WordPress entities map to the same path. Change a slug or trash a duplicate, then retry: ' . implode( '; ', $details ); + $remaining = count( $collisions ) - count( $shown ); + if ( $remaining > 0 ) { + $message .= sprintf( ' (and %d more)', $remaining ); + } + + return $message . '.'; + } + private static function initialize_theme_base_commit() { $theme_base_files = Push_MD_Plugin::export_theme_base_content(); if ( empty( $theme_base_files ) ) { diff --git a/plugins/push-md/readme.txt b/plugins/push-md/readme.txt index 3b570c00..12c5ab3e 100644 --- a/plugins/push-md/readme.txt +++ b/plugins/push-md/readme.txt @@ -99,6 +99,10 @@ Push MD checks permissions for each changed object. Updating or trashing existin No. File paths are the identity model. Push MD rejects `id`, `slug`, `type`, and unknown front matter fields. Rename or move files only when the corresponding path operation is supported and safe. += What happens if two posts map to the same file path? = + +Every exported item needs a unique file path. If two or more items would map to the same path — for example two posts that share a slug after an import or migration — Push MD blocks the export and reports the conflict on Tools → Push MD, naming the conflicting posts. Change one post's slug or trash the duplicate, then pull again. + = Can I still edit content in WP-Admin? = Yes. WordPress remains the source of truth. Pull before editing locally to pick up recent WP-Admin changes, and Push MD will reject stale pushes that could overwrite newer WordPress edits. @@ -139,6 +143,14 @@ The removed tables contain Push MD's derived Git repository history. Reinstallin == Changelog == += 0.6.7 = + +When seeding content onto a fresh site (the push_md_allow_create_on_missing_id option), resolve posts and pages by slug/path and ignore the Markdown front matter id. Foreign production ids that happen to collide with unrelated local post ids no longer reject the push. Production pushes are unaffected and keep strict id-based resolution. + += 0.6.6 = + +Reject exports when multiple WordPress items map to the same file path, with a clearer Git error and a Path collisions report on Tools → Push MD so the conflict can be resolved at the source. + = 0.5.0 = Initial release.