From ab0800c09ad08308bb0b5b1db56b47e3df9932f7 Mon Sep 17 00:00:00 2001 From: robertsreberski Date: Tue, 9 Jun 2026 12:49:39 +0200 Subject: [PATCH 1/3] Reject duplicate Push MD export paths and report collisions When two WordPress entities map to the same Push MD file path, fail the export instead of papering over it with synthetic --wp- filenames. The fail-closed behavior is surfaced to both consumers: - Agents cloning/fetching get an actionable Git error naming the conflicting post IDs (or, before the initial import finishes, the seeder failure message via not_ready_message()). - Site admins see a "Path collisions" panel on Tools -> Push MD listing the conflicting posts with edit links and how to resolve them. Details: - export_wordpress_content() throw now names both post IDs plus a fix hint; add detect_export_path_collisions() as the shared detection helper used by the seeder and admin surfaces. - Seeder initialize() detects collisions and fails loudly through the existing record_failure() path instead of silently overwriting the losing post during the initial import. - Admin status route and page render a live-refreshing collision panel (briefly cached detection, JS renderCollisions(), warning styling). - Tests cover the actionable throw and detection (group, unique-path, unbuildable-path skip); readme FAQ + version bumped to 0.6.6. --- plugins/push-md/Tests/ExportPathTest.php | 212 ++++++++++++++++++++++- plugins/push-md/admin-shell.css | 51 ++++++ plugins/push-md/admin-shell.js | 48 +++++ plugins/push-md/class-push-md-admin.php | 86 +++++++-- plugins/push-md/class-push-md-plugin.php | 58 ++++++- plugins/push-md/class-push-md-seeder.php | 25 +++ plugins/push-md/readme.txt | 8 + 7 files changed, 473 insertions(+), 15 deletions(-) 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/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 876e49ba..89036071 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.6'; + 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.6'; + 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..b15af87d 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; 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 2a13b1d2..dc8171b6 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,10 @@ The removed tables contain Push MD's derived Git repository history. Reinstallin == Changelog == += 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. From a4f65f256d085c36df1fb392c15d52a052d74d17 Mon Sep 17 00:00:00 2001 From: robertsreberski Date: Tue, 16 Jun 2026 12:19:47 +0200 Subject: [PATCH 2/3] push-md: seed id'd content onto fresh sites + fix front-matter scalar parsing - allow_create_on_missing_id (option/filter, default off): when set, an unknown front-matter id resolves by slug (update-or-create) instead of rejecting, and missing parent pages are tolerated with ancestor pages applied before children. Production keeps rejecting unknown ids (guard intact). - seed_markdown_file(): public wrapper over upsert_post_from_markdown so non-git transports (e.g. a WordPress Playground runPHP step) can seed a single file. - Fix parse_frontmatter_scalar: anchor the YAML sequence/mapping detection regex so quoted titles/descriptions containing "{" or "- " are not mis-parsed as empty arrays. --- .../Markdown/class-markdownconsumer.php | 7 ++- plugins/push-md/class-push-md-plugin.php | 62 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) 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/class-push-md-plugin.php b/plugins/push-md/class-push-md-plugin.php index b15af87d..b527f286 100644 --- a/plugins/push-md/class-push-md-plugin.php +++ b/plugins/push-md/class-push-md-plugin.php @@ -1508,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'], @@ -1686,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 ); @@ -2481,6 +2512,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 ) { @@ -2504,6 +2544,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 ) ) { @@ -2591,6 +2645,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.' ); } From ce38fe98569c8cb7b561f5cfe9d3437764d7f8fe Mon Sep 17 00:00:00 2001 From: robertsreberski Date: Thu, 18 Jun 2026 16:51:55 +0200 Subject: [PATCH 3/3] push-md: resolve by slug when seeding, ignoring front-matter id When push_md_allow_create_on_missing_id is enabled (local one-way seeding onto a fresh site), resolve posts and pages purely by slug/path and ignore the Markdown front-matter id. Production ids in the seed content can collide with unrelated local auto-increment post ids; previously such a collision hard- rejected the whole (atomic) push with "references a different WordPress post type", "multiple Markdown files reference the same WordPress post ID", or the path/id conflict error. Gate the two id-resolver branches in find_post_id_by_path_metadata() on ! allow_create_on_missing_id(). Production pushes leave the option off and keep strict id-based resolution unchanged. Bump to 0.6.7; add four e2e regression tests plus a CI-only REST toggle for the option. --- plugins/push-md/Tests/EndToEndTest.php | 221 ++++++++++++++++++++ plugins/push-md/Tests/ci-mu-test-helper.php | 26 +++ plugins/push-md/class-push-md-plugin.php | 10 +- plugins/push-md/push-md.php | 2 +- plugins/push-md/readme.txt | 6 +- 5 files changed, 261 insertions(+), 4 deletions(-) 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/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/class-push-md-plugin.php b/plugins/push-md/class-push-md-plugin.php index b527f286..4b027374 100644 --- a/plugins/push-md/class-push-md-plugin.php +++ b/plugins/push-md/class-push-md-plugin.php @@ -2452,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 ); } diff --git a/plugins/push-md/push-md.php b/plugins/push-md/push-md.php index d88b24d4..edea901f 100644 --- a/plugins/push-md/push-md.php +++ b/plugins/push-md/push-md.php @@ -3,7 +3,7 @@ * Plugin Name: Push MD * Plugin URI: https://pushmd.blog/ * Description: Edit WordPress content with Git, Markdown and block files, reviewable diffs, and safe pushes. - * Version: 0.6.6 + * Version: 0.6.7 * Requires at least: 6.9 * Requires PHP: 7.2 * Author: Automattic diff --git a/plugins/push-md/readme.txt b/plugins/push-md/readme.txt index dc8171b6..12c5ab3e 100644 --- a/plugins/push-md/readme.txt +++ b/plugins/push-md/readme.txt @@ -4,7 +4,7 @@ Tags: git, markdown, content, workflow Requires at least: 6.9 Tested up to: 7.0 Requires PHP: 7.2 -Stable tag: 0.6.6 +Stable tag: 0.6.7 License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -143,6 +143,10 @@ 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.