diff --git a/components/Filesystem/Tests/FilesystemTestCase.php b/components/Filesystem/Tests/FilesystemTestCase.php index ad2d32f2..7a84a954 100644 --- a/components/Filesystem/Tests/FilesystemTestCase.php +++ b/components/Filesystem/Tests/FilesystemTestCase.php @@ -67,7 +67,7 @@ public function testMkdirRecursive() { public function testRmRemovesExistingFile() { $this->fs->put_contents( '/test.txt', 'test' ); - $this->fs->rm( '/test.txt' ); + $this->assertTrue( $this->fs->rm( '/test.txt' ) ); $this->assertFalse( $this->fs->exists( '/test.txt' ) ); } diff --git a/components/Filesystem/class-sqlitefilesystem.php b/components/Filesystem/class-sqlitefilesystem.php index b1189c65..b2c78732 100644 --- a/components/Filesystem/class-sqlitefilesystem.php +++ b/components/Filesystem/class-sqlitefilesystem.php @@ -69,8 +69,10 @@ public function ls( $path = '/' ) { $result = $stmt->execute(); $entries = array(); - while ( $row = $result->fetchArray( SQLITE3_ASSOC ) ) { + $row = $result->fetchArray( SQLITE3_ASSOC ); + while ( $row ) { $entries[] = $row['name']; + $row = $result->fetchArray( SQLITE3_ASSOC ); } return $entries; @@ -279,6 +281,8 @@ function () use ( $path ) { $e ); } + + return true; } public function rmdir( $path, $options = array() ) { diff --git a/components/Filesystem/class-wpdbfilesystem.php b/components/Filesystem/class-wpdbfilesystem.php index 92114453..50a06499 100644 --- a/components/Filesystem/class-wpdbfilesystem.php +++ b/components/Filesystem/class-wpdbfilesystem.php @@ -343,6 +343,8 @@ function () use ( $path ) { $e ); } + + return true; } public function rmdir( $path, $options = array() ) { diff --git a/components/Git/Tests/GitServerTest.php b/components/Git/Tests/GitServerTest.php index 90a38b99..fe9fed88 100644 --- a/components/Git/Tests/GitServerTest.php +++ b/components/Git/Tests/GitServerTest.php @@ -548,7 +548,10 @@ public function test_handle_push_request() { ->getMock(); $git_encoder = new GitProtocolEncoderPipe( $response ); - $this->server->handle_push_request( $test['request'], $git_encoder ); + $this->assertTrue( + $this->server->handle_push_request( $test['request'], $git_encoder ), + "$name: Push request should succeed" + ); $response_body = $git_encoder->consume_all(); // Should contain "ok" response diff --git a/components/Git/class-gitendpoint.php b/components/Git/class-gitendpoint.php index 61b68d6e..13c9446a 100644 --- a/components/Git/class-gitendpoint.php +++ b/components/Git/class-gitendpoint.php @@ -425,7 +425,12 @@ public function handle_push_request( $request_bytes, GitProtocolEncoderPipe $git // Handle deletion. if ( Commit::is_null_hash( $new_oid ) ) { if ( $this->repository->delete_branch( $ref_name ) ) { - $git_response->append_packet_line( "ok $ref_name\n" ); + $git_response->append_sideband_packet_line( "unpack ok\n" ); + $git_response->append_sideband_packet_line( "ok $ref_name\n" ); + $git_response->append_sideband_packet_line( '0000' ); + $git_response->append_packet_line( '0000' ); + + return true; } else { $git_response->append_error_packet_line( "error $ref_name delete failed\n" ); $git_response->append_error_packet_line( '0000' ); diff --git a/plugins/push-md/Tests/BranchPreviewTest.php b/plugins/push-md/Tests/BranchPreviewTest.php new file mode 100644 index 00000000..7eaa98a8 --- /dev/null +++ b/plugins/push-md/Tests/BranchPreviewTest.php @@ -0,0 +1,207 @@ +repository(); + $current = $repository->get_branch_tip( 'refs/heads/trunk' ); + $new_oid = '1111111111111111111111111111111111111111'; + $header = $this->parse_push_header( + $this->push_request( $current, $new_oid, 'refs/heads/trunk' ), + $repository, + $current + ); + + $this->assertFalse( $header['is_preview'] ); + $this->assertSame( 'trunk', $header['branch_name'] ); + $this->assertSame( $current, $header['old_oid'] ); + $this->assertSame( $new_oid, $header['new_oid'] ); + } + + public function testPreviewBranchPushUsesCurrentTrunkAsBase() { + $repository = $this->repository(); + $current = $repository->get_branch_tip( 'refs/heads/trunk' ); + $new_oid = '2222222222222222222222222222222222222222'; + $header = $this->parse_push_header( + $this->push_request( Commit::NULL_HASH, $new_oid, 'refs/heads/preview-home' ), + $repository, + $current + ); + + $this->assertTrue( $header['is_preview'] ); + $this->assertFalse( $header['is_delete'] ); + $this->assertSame( 'preview-home', $header['branch_name'] ); + $this->assertSame( $current, $header['base_oid'] ); + $this->assertSame( $current, $header['validation_old_oid'] ); + } + + public function testPreviewBranchUpdateMustMatchExistingBranchTip() { + $repository = $this->repository(); + $current = $repository->get_branch_tip( 'refs/heads/trunk' ); + $branch_tip = '3333333333333333333333333333333333333333'; + $repository->set_branch_tip( 'refs/heads/preview-home', $branch_tip ); + + $header = $this->parse_push_header( + $this->push_request( $branch_tip, '4444444444444444444444444444444444444444', 'refs/heads/preview-home' ), + $repository, + $current + ); + + $this->assertTrue( $header['is_preview'] ); + $this->assertSame( $branch_tip, $header['validation_old_oid'] ); + } + + public function testPreviewBranchDeletionIsAccepted() { + $repository = $this->repository(); + $current = $repository->get_branch_tip( 'refs/heads/trunk' ); + $branch_tip = '4444444444444444444444444444444444444444'; + $repository->set_branch_tip( 'refs/heads/preview-home', $branch_tip ); + + $header = $this->parse_push_header( + $this->push_request( $branch_tip, Commit::NULL_HASH, 'refs/heads/preview-home' ), + $repository, + $current + ); + + $this->assertTrue( $header['is_preview'] ); + $this->assertTrue( $header['is_delete'] ); + $this->assertSame( 'preview-home', $header['branch_name'] ); + $this->assertSame( $branch_tip, $header['old_oid'] ); + $this->assertSame( Commit::NULL_HASH, $header['new_oid'] ); + } + + public function testPreviewBranchRejectsStaleOldOid() { + $repository = $this->repository(); + $current = $repository->get_branch_tip( 'refs/heads/trunk' ); + $repository->set_branch_tip( 'refs/heads/preview-home', '5555555555555555555555555555555555555555' ); + + $header = $this->parse_push_header( + $this->push_request( '6666666666666666666666666666666666666666', '7777777777777777777777777777777777777777', 'refs/heads/preview-home' ), + $repository, + $current + ); + + $this->assertSame( + 'Push rejected because the preview branch changed. Fetch the latest branch state and try again.', + $header['error'] + ); + } + + public function testProtectedPreviewBranchNamesAreRejected() { + $repository = $this->repository(); + $current = $repository->get_branch_tip( 'refs/heads/trunk' ); + $header = $this->parse_push_header( + $this->push_request( Commit::NULL_HASH, '8888888888888888888888888888888888888888', 'refs/heads/_push_md_seed' ), + $repository, + $current + ); + + $this->assertSame( + 'Push rejected because the preview branch name is not supported.', + $header['error'] + ); + } + + public function testTrunkDeletionIsStillRejected() { + $repository = $this->repository(); + $current = $repository->get_branch_tip( 'refs/heads/trunk' ); + $header = $this->parse_push_header( + $this->push_request( $current, Commit::NULL_HASH, 'refs/heads/trunk' ), + $repository, + $current + ); + + $this->assertSame( + 'Push rejected because deleting trunk is not supported.', + $header['error'] + ); + } + + private function repository() { + $repository = new GitRepository( + InMemoryFilesystem::create(), + array( + 'default_branch' => 'trunk', + ) + ); + $repository->set_config_value( 'user.name', 'Push MD Test' ); + $repository->set_config_value( 'user.email', 'push-md@example.com' ); + $repository->commit( + array( + 'updates' => array( + 'post/hello.md' => "---\ntitle: \"Hello\"\n---\n\nHello.\n", + ), + ) + ); + + return $repository; + } + + private function push_request( $old_oid, $new_oid, $ref_name ) { + return GitProtocolEncoderPipe::encode_packet_lines( + array( + $old_oid . ' ' . $new_oid . ' ' . $ref_name . "\0report-status side-band-64k\n", + '0000', + ) + ); + } + + private function parse_push_header( $request, GitRepository $repository, $current_head ) { + $method = new ReflectionMethod( Push_MD_Plugin::class, 'parse_push_header' ); + $method->setAccessible( true ); + + return $method->invoke( null, $request, $repository, $current_head ); + } +} diff --git a/plugins/push-md/Tests/EndToEndTest.php b/plugins/push-md/Tests/EndToEndTest.php index 7d70affe..fe56105b 100644 --- a/plugins/push-md/Tests/EndToEndTest.php +++ b/plugins/push-md/Tests/EndToEndTest.php @@ -132,6 +132,462 @@ public function testCloneFailsClosedWhenPageParentIsNotExported() { } } + public function testBranchPreviewPushRendersForAuthenticatedAdminWithoutMutatingLiveContent() { + $suffix = uniqid( 'branch-preview-' ); + $slug = $suffix; + $new_slug = 'branch-only-' . $suffix; + $live_slug = 'live-only-' . $suffix; + $branch = 'preview/' . $suffix; + $live_text = 'Live branch preview ' . $suffix; + $preview_text = 'Preview branch content ' . $suffix; + $new_text = 'Branch-only preview content ' . $suffix; + $live_only = 'Live-only concurrent content ' . $suffix; + $post_id = $this->create_post_via_rest( + array( + 'slug' => $slug, + 'title' => 'Branch Preview ' . $suffix, + 'status' => 'publish', + 'content' => '

' . $live_text . '

', + ) + ); + + $revision_count_before = $this->count_revisions( $post_id, 'posts' ); + $clone_dir = $this->clone_repo( 'branch-preview' ); + $this->configure_git( $clone_dir ); + $this->assertFileExists( $clone_dir . '/post/' . $slug . '.md' ); + + $this->run_cmd( array( 'git', '-C', $clone_dir, 'checkout', '-b', $branch ) ); + $this->edit_file( + $clone_dir . '/post/' . $slug . '.md', + $live_text, + $preview_text + ); + file_put_contents( + $clone_dir . '/post/' . $new_slug . '.md', + "---\nstatus: \"publish\"\ntitle: \"Branch Only $suffix\"\n---\n\n$new_text\n" + ); + $this->run_cmd( array( 'git', '-C', $clone_dir, 'add', 'post/' . $slug . '.md', 'post/' . $new_slug . '.md' ) ); + $this->run_cmd( array( 'git', '-C', $clone_dir, 'commit', '-m', 'Preview branch content' ) ); + + $push_result = $this->run_cmd( array( 'git', '-C', $clone_dir, 'push', 'origin', 'HEAD:refs/heads/' . $branch ) ); + $this->assertStringContainsString( 'Push MD stored preview branch ' . $branch . ' without changing WordPress content.', $push_result['output'] ); + $this->assertStringContainsString( 'Preview: ', $push_result['output'] ); + $this->assertStringContainsString( '?branch=', $push_result['output'] ); + $this->assertStringContainsString( 'Changed preview URLs:', $push_result['output'] ); + $this->assertStringContainsString( 'Updated post/' . $slug . '.md: ', $push_result['output'] ); + $this->assertStringContainsString( 'Created post/' . $new_slug . '.md: ', $push_result['output'] ); + $this->assertStringContainsString( '/' . rawurlencode( $slug ) . '/?branch=' . $branch, $push_result['output'] ); + $this->assertStringContainsString( '/' . rawurlencode( $new_slug ) . '/?branch=' . $branch, $push_result['output'] ); + + $this->assertSame( $revision_count_before, $this->count_revisions( $post_id, 'posts' ), 'Branch push must not create WordPress revisions.' ); + $this->assertStringContainsString( $live_text, $this->fetch_content( $post_id, 'posts' ) ); + $this->assertStringNotContainsString( $preview_text, $this->fetch_content( $post_id, 'posts' ) ); + $this->assert_slug_absent( $new_slug, 'posts' ); + + $preview_url = $this->base_url . '/' . rawurlencode( $slug ) . '/?branch=' . rawurlencode( $branch ); + $public_body = $this->curl_get_public( $preview_url ); + $this->assertStringContainsString( $live_text, $public_body ); + $this->assertStringNotContainsString( $preview_text, $public_body ); + $this->assertStringNotContainsString( + $new_text, + $this->curl_get_public( $this->base_url . '/' . rawurlencode( $new_slug ) . '/?branch=' . rawurlencode( $branch ) ) + ); + + $preview_response = $this->curl_get_with_headers( $preview_url, true ); + $this->assertSame( 200, $preview_response['status'], 'Authenticated preview request should render the post.' ); + $this->assertStringContainsString( $preview_text, $preview_response['body'] ); + $this->assertStringNotContainsString( $live_text, $preview_response['body'] ); + $this->assertStringContainsString( 'PushMD Branch', $preview_response['body'] ); + $this->assertStringContainsString( 'Live site', $preview_response['body'] ); + $this->assertStringContainsString( $branch . ' (active)', $preview_response['body'] ); + $new_preview_response = $this->curl_get_with_headers( + $this->base_url . '/' . rawurlencode( $new_slug ) . '/?branch=' . rawurlencode( $branch ), + true + ); + $this->assertSame( 200, $new_preview_response['status'], 'Authenticated preview request should render branch-only posts.' ); + $this->assertStringContainsString( $new_text, $new_preview_response['body'] ); + $home_preview_response = $this->curl_get_with_headers( $this->base_url . '/?branch=' . rawurlencode( $branch ), true ); + $this->assertSame( 200, $home_preview_response['status'], 'Authenticated preview homepage should render.' ); + $this->assertStringContainsString( $new_text, $home_preview_response['body'], 'Authenticated branch preview query loops should include branch-only posts.' ); + $this->assertStringNotContainsString( $new_text, $this->curl_get_public( $this->base_url . '/?branch=' . rawurlencode( $branch ) ) ); + $this->assertArrayHasKey( 'cache-control', $preview_response['headers'] ); + $this->assertArrayHasKey( 'x-push-md-preview-branch', $preview_response['headers'] ); + $this->assertStringContainsString( 'no-store', implode( ', ', $preview_response['headers']['cache-control'] ) ); + $this->assertSame( array( $branch ), $preview_response['headers']['x-push-md-preview-branch'] ); + $this->assertSame( $revision_count_before, $this->count_revisions( $post_id, 'posts' ), 'Preview rendering must not create WordPress revisions.' ); + + $branches = json_decode( $this->curl_get( $this->base_url . '/wp-json/push-md/v1/branches' ), true ); + $this->assertIsArray( $branches, 'Unexpected branch listing response.' ); + $this->assertArrayHasKey( 'branches', $branches ); + $branch_metadata = $this->find_branch_metadata( $branches['branches'], $branch ); + $this->assertNotEmpty( $branch_metadata, 'Preview branch metadata was not listed.' ); + $this->assertStringContainsString( '?branch=', $branch_metadata['url'] ); + $this->assertArrayHasKey( 'changed_urls', $branch_metadata ); + $this->assertIsArray( $branch_metadata['changed_urls'] ); + $updated_preview_url = $this->find_changed_url_item( $branch_metadata['changed_urls'], 'post/' . $slug . '.md' ); + $created_preview_url = $this->find_changed_url_item( $branch_metadata['changed_urls'], 'post/' . $new_slug . '.md' ); + $this->assertSame( 'updated', $updated_preview_url['action'] ); + $this->assertSame( 'created', $created_preview_url['action'] ); + $this->assertStringContainsString( '/' . rawurlencode( $slug ) . '/?branch=' . $branch, $updated_preview_url['url'] ); + $this->assertStringContainsString( '/' . rawurlencode( $new_slug ) . '/?branch=' . $branch, $created_preview_url['url'] ); + $this->assertArrayHasNoTokenKeys( $branch_metadata ); + + $live_only_id = $this->create_post_via_rest( + array( + 'slug' => $live_slug, + 'title' => 'Live Only ' . $suffix, + 'status' => 'publish', + 'content' => '

' . $live_only . '

', + ) + ); + + $merge_response = $this->curl_post_json( + $this->base_url . '/wp-json/push-md/v1/branches/merge', + array( + 'branch' => $branch, + ) + ); + $this->assertSame( 200, $merge_response['status'], 'Branch merge should succeed: ' . $merge_response['body'] ); + $merge = json_decode( $merge_response['body'], true ); + $this->assertSame( $branch, $merge['branch'] ); + $this->assertTrue( $merge['branch_deleted'], 'Merged preview branch ref should be deleted.' ); + $this->assertNotEmpty( $merge['changes'] ); + $this->assertStringContainsString( $preview_text, $this->fetch_content( $post_id, 'posts' ) ); + $new_post_id = $this->fetch_id_by_slug( $new_slug, 'posts' ); + $this->assertStringContainsString( $new_text, $this->fetch_content( $new_post_id, 'posts' ) ); + $this->assertStringContainsString( $live_only, $this->fetch_content( $live_only_id, 'posts' ) ); + $this->assertGreaterThan( $revision_count_before, $this->count_revisions( $post_id, 'posts' ), 'Branch merge should create a normal WordPress revision.' ); + $seed_status = json_decode( $this->curl_get( $this->base_url . '/wp-json/push-md/v1/seed-status' ), true ); + $this->assertIsArray( $seed_status, 'Unexpected seed status response after branch merge.' ); + $this->assertArrayHasKey( 'commits', $seed_status ); + $this->assertNotEmpty( $seed_status['commits'], 'Commit history should not be empty after branch merge.' ); + $this->assertSame( 'Preview branch content', $seed_status['commits'][0]['subject'], 'Branch merge should preserve the branch commit subject at the top of commit history.' ); + + $merged_preview_response = $this->curl_get_with_headers( $preview_url, true ); + $this->assertSame( 200, $merged_preview_response['status'], 'Merged preview branch URL should fall back to the live site.' ); + $this->assertArrayNotHasKey( 'x-push-md-preview-branch', $merged_preview_response['headers'], 'Merged preview branch URLs should not activate preview rendering.' ); + + $remote_branch = $this->run_cmd( array( 'git', 'ls-remote', $this->remote_url(), 'refs/heads/' . $branch ) ); + $this->assertSame( '', trim( $remote_branch['output'] ), 'Merged preview branch ref should no longer be advertised.' ); + + $branches_after_merge = json_decode( $this->curl_get( $this->base_url . '/wp-json/push-md/v1/branches' ), true ); + $merged_metadata = $this->find_branch_metadata( $branches_after_merge['branches'], $branch ); + $this->assertNotEmpty( $merged_metadata, 'Merged preview branch metadata should remain available for history.' ); + $this->assertSame( 'merged', $merged_metadata['status'] ); + $this->assertFalse( $merged_metadata['active'] ); + $this->assertNotEmpty( $merged_metadata['merged_at'] ); + $this->assertSame( $branch, $merged_metadata['branch'] ); + $this->assertNotEmpty( $merged_metadata['changed_urls'], 'Merged branch history should keep the changed URL list.' ); + } + + public function testPreviewBranchUpdatesRenderLatestBranchCommitWithoutMutatingLiveContent() { + $suffix = uniqid( 'branch-update-' ); + $slug = $suffix; + $branch = 'preview/' . $suffix; + $live_text = 'Live update branch preview ' . $suffix; + $first_preview_text = 'First preview branch update ' . $suffix; + $next_preview_text = 'Second preview branch update ' . $suffix; + $post_id = $this->create_post_via_rest( + array( + 'slug' => $slug, + 'title' => 'Branch Update Preview ' . $suffix, + 'status' => 'publish', + 'content' => '

' . $live_text . '

', + ) + ); + + $revision_count_before = $this->count_revisions( $post_id, 'posts' ); + $clone_dir = $this->clone_repo( 'branch-update' ); + $this->configure_git( $clone_dir ); + $this->run_cmd( array( 'git', '-C', $clone_dir, 'checkout', '-b', $branch ) ); + + $this->edit_file( + $clone_dir . '/post/' . $slug . '.md', + $live_text, + $first_preview_text + ); + $this->run_cmd( array( 'git', '-C', $clone_dir, 'add', 'post/' . $slug . '.md' ) ); + $this->run_cmd( array( 'git', '-C', $clone_dir, 'commit', '-m', 'First preview branch update' ) ); + $this->run_cmd( array( 'git', '-C', $clone_dir, 'push', 'origin', 'HEAD:refs/heads/' . $branch ) ); + $first_tip = trim( $this->run_cmd( array( 'git', '-C', $clone_dir, 'rev-parse', 'HEAD' ) )['output'] ); + + $first_preview_response = $this->curl_get_with_headers( + $this->base_url . '/' . rawurlencode( $slug ) . '/?branch=' . rawurlencode( $branch ), + true + ); + $this->assertSame( 200, $first_preview_response['status'], 'Authenticated preview request should render the first branch tip.' ); + $this->assertStringContainsString( $first_preview_text, $first_preview_response['body'] ); + $this->assertStringNotContainsString( $live_text, $first_preview_response['body'] ); + $branches = json_decode( $this->curl_get( $this->base_url . '/wp-json/push-md/v1/branches' ), true ); + $this->assertSame( $first_tip, $this->find_branch_metadata( $branches['branches'], $branch )['tip_oid'] ); + + $this->edit_file( + $clone_dir . '/post/' . $slug . '.md', + $first_preview_text, + $next_preview_text + ); + $this->run_cmd( array( 'git', '-C', $clone_dir, 'add', 'post/' . $slug . '.md' ) ); + $this->run_cmd( array( 'git', '-C', $clone_dir, 'commit', '-m', 'Second preview branch update' ) ); + $this->run_cmd( array( 'git', '-C', $clone_dir, 'push', 'origin', 'HEAD:refs/heads/' . $branch ) ); + $next_tip = trim( $this->run_cmd( array( 'git', '-C', $clone_dir, 'rev-parse', 'HEAD' ) )['output'] ); + $this->assertNotSame( $first_tip, $next_tip ); + + $next_preview_response = $this->curl_get_with_headers( + $this->base_url . '/' . rawurlencode( $slug ) . '/?branch=' . rawurlencode( $branch ), + true + ); + $this->assertSame( 200, $next_preview_response['status'], 'Authenticated preview request should render the latest branch tip.' ); + $this->assertStringContainsString( $next_preview_text, $next_preview_response['body'] ); + $this->assertStringNotContainsString( $first_preview_text, $next_preview_response['body'] ); + $this->assertStringNotContainsString( $live_text, $next_preview_response['body'] ); + $branches = json_decode( $this->curl_get( $this->base_url . '/wp-json/push-md/v1/branches' ), true ); + $this->assertSame( $next_tip, $this->find_branch_metadata( $branches['branches'], $branch )['tip_oid'] ); + + $this->assertSame( $revision_count_before, $this->count_revisions( $post_id, 'posts' ), 'Preview branch updates must not create WordPress revisions.' ); + $this->assertStringContainsString( $live_text, $this->fetch_content( $post_id, 'posts' ) ); + $this->assertStringNotContainsString( $next_preview_text, $this->fetch_content( $post_id, 'posts' ) ); + + $this->delete_preview_branch( $clone_dir, $branch ); + } + + public function testPreviewBranchForceWithLeaseUpdateAfterRebaseResetsBaseToCurrentTrunk() { + $suffix = uniqid( 'branch-rebase-' ); + $slug = $suffix; + $live_slug = 'live-only-' . $suffix; + $branch = 'preview/' . $suffix; + $live_text = 'Live rebase branch preview ' . $suffix; + $first_preview_text = 'First rebase branch update ' . $suffix; + $unsafe_text = 'Unsafe rebase branch update ' . $suffix; + $next_preview_text = 'Second rebase branch update ' . $suffix; + $live_only_text = 'Live-only rebase content ' . $suffix; + $post_id = $this->create_post_via_rest( + array( + 'slug' => $slug, + 'title' => 'Branch Rebase Preview ' . $suffix, + 'status' => 'publish', + 'content' => '

' . $live_text . '

', + ) + ); + + $clone_dir = $this->clone_repo( 'branch-rebase' ); + $this->configure_git( $clone_dir ); + $this->run_cmd( array( 'git', '-C', $clone_dir, 'checkout', '-b', $branch ) ); + $this->edit_file( + $clone_dir . '/post/' . $slug . '.md', + $live_text, + $first_preview_text + ); + $this->run_cmd( array( 'git', '-C', $clone_dir, 'add', 'post/' . $slug . '.md' ) ); + $this->run_cmd( array( 'git', '-C', $clone_dir, 'commit', '-m', 'First rebased preview branch update' ) ); + $this->run_cmd( array( 'git', '-C', $clone_dir, 'push', 'origin', 'HEAD:refs/heads/' . $branch ) ); + $first_tip = trim( $this->run_cmd( array( 'git', '-C', $clone_dir, 'rev-parse', 'HEAD' ) )['output'] ); + + $this->create_post_via_rest( + array( + 'slug' => $live_slug, + 'title' => 'Live Only Rebase ' . $suffix, + 'status' => 'publish', + 'content' => '

' . $live_only_text . '

', + ) + ); + + $this->run_cmd( array( 'git', '-C', $clone_dir, 'reset', '--hard', 'origin/trunk' ) ); + $this->edit_file( + $clone_dir . '/post/' . $slug . '.md', + $live_text, + $unsafe_text + ); + $this->run_cmd( array( 'git', '-C', $clone_dir, 'add', 'post/' . $slug . '.md' ) ); + $this->run_cmd( array( 'git', '-C', $clone_dir, 'commit', '-m', 'Unsafe rebased preview branch update' ) ); + $unsafe_push_result = $this->run_cmd( + array( + 'git', + '-C', + $clone_dir, + 'push', + '--force-with-lease=refs/heads/' . $branch . ':' . $first_tip, + 'origin', + 'HEAD:refs/heads/' . $branch, + ), + true + ); + $this->assertNotSame( 0, $unsafe_push_result['code'], 'Preview branch replacements that are not based on current trunk should be rejected.' ); + $this->assertStringContainsString( 'Push rejected because preview branch replacements must be rebased onto the latest trunk.', $unsafe_push_result['output'] ); + $remote_branch_after_rejection = $this->run_cmd( array( 'git', 'ls-remote', $this->remote_url(), 'refs/heads/' . $branch ) ); + $this->assertStringContainsString( $first_tip, $remote_branch_after_rejection['output'], 'Rejected preview branch replacements should roll back the remote preview branch.' ); + + $this->run_cmd( array( 'git', '-C', $clone_dir, 'reset', '--hard', $first_tip ) ); + $this->run_cmd( array( 'git', '-C', $clone_dir, 'fetch', 'origin', 'trunk' ) ); + $rebased_base = trim( $this->run_cmd( array( 'git', '-C', $clone_dir, 'rev-parse', 'origin/trunk' ) )['output'] ); + $this->run_cmd( array( 'git', '-C', $clone_dir, 'rebase', 'origin/trunk' ) ); + $this->edit_file( + $clone_dir . '/post/' . $slug . '.md', + $first_preview_text, + $next_preview_text + ); + $this->run_cmd( array( 'git', '-C', $clone_dir, 'add', 'post/' . $slug . '.md' ) ); + $this->run_cmd( array( 'git', '-C', $clone_dir, 'commit', '-m', 'Second rebased preview branch update' ) ); + $push_result = $this->run_cmd( + array( + 'git', + '-C', + $clone_dir, + 'push', + '--force-with-lease=refs/heads/' . $branch . ':' . $first_tip, + 'origin', + 'HEAD:refs/heads/' . $branch, + ) + ); + $this->assertStringContainsString( 'Push MD replaced preview branch ' . $branch . ' after rebase without changing WordPress content.', $push_result['output'] ); + $this->assertStringContainsString( 'Preview base reset to trunk ' . substr( $rebased_base, 0, 12 ) . '.', $push_result['output'] ); + $next_tip = trim( $this->run_cmd( array( 'git', '-C', $clone_dir, 'rev-parse', 'HEAD' ) )['output'] ); + $this->assertNotSame( $first_tip, $next_tip ); + + $next_preview_response = $this->curl_get_with_headers( + $this->base_url . '/' . rawurlencode( $slug ) . '/?branch=' . rawurlencode( $branch ), + true + ); + $this->assertSame( 200, $next_preview_response['status'], 'Authenticated rebased preview request should render the latest branch tip.' ); + $this->assertStringContainsString( $next_preview_text, $next_preview_response['body'] ); + $this->assertStringNotContainsString( $first_preview_text, $next_preview_response['body'] ); + $this->assertStringContainsString( $live_text, $this->fetch_content( $post_id, 'posts' ) ); + $this->assertStringNotContainsString( $next_preview_text, $this->fetch_content( $post_id, 'posts' ) ); + + $branches = json_decode( $this->curl_get( $this->base_url . '/wp-json/push-md/v1/branches' ), true ); + $branch_metadata = $this->find_branch_metadata( $branches['branches'], $branch ); + $this->assertSame( $next_tip, $branch_metadata['tip_oid'] ); + $this->assertSame( $rebased_base, $branch_metadata['base_oid'], 'Rebased preview updates should reset the preview base to current trunk.' ); + $this->assertNotEmpty( $this->find_changed_url_item( $branch_metadata['changed_urls'], 'post/' . $slug . '.md' ) ); + $this->assertSame( array(), $this->find_changed_url_item( $branch_metadata['changed_urls'], 'post/' . $live_slug . '.md' ) ); + + $this->delete_preview_branch( $clone_dir, $branch ); + } + + public function testPreviewBranchMergeRejectsWhenSamePostChangedInWordPress() { + $suffix = uniqid( 'branch-conflict-' ); + $slug = $suffix; + $branch = 'preview/' . $suffix; + $live_text = 'Live conflict branch preview ' . $suffix; + $preview_text = 'Preview conflict branch update ' . $suffix; + $concurrent_text = 'Concurrent WordPress update ' . $suffix; + $post_id = $this->create_post_via_rest( + array( + 'slug' => $slug, + 'title' => 'Branch Conflict Preview ' . $suffix, + 'status' => 'publish', + 'content' => '

' . $live_text . '

', + ) + ); + + $clone_dir = $this->clone_repo( 'branch-conflict' ); + $this->configure_git( $clone_dir ); + $this->run_cmd( array( 'git', '-C', $clone_dir, 'checkout', '-b', $branch ) ); + $this->edit_file( + $clone_dir . '/post/' . $slug . '.md', + $live_text, + $preview_text + ); + $this->run_cmd( array( 'git', '-C', $clone_dir, 'add', 'post/' . $slug . '.md' ) ); + $this->run_cmd( array( 'git', '-C', $clone_dir, 'commit', '-m', 'Preview conflicting branch edit' ) ); + $this->run_cmd( array( 'git', '-C', $clone_dir, 'push', 'origin', 'HEAD:refs/heads/' . $branch ) ); + + sleep( 1 ); + $this->update_post_via_rest( + $post_id, + array( + 'content' => '

' . $concurrent_text . '

', + ) + ); + + $merge_response = $this->curl_post_json( + $this->base_url . '/wp-json/push-md/v1/branches/merge', + array( + 'branch' => $branch, + ) + ); + $this->assertSame( 400, $merge_response['status'], 'Branch merge should reject stale branch content: ' . $merge_response['body'] ); + $this->assertStringContainsString( 'WordPress content changed since the preview branch was created', $merge_response['body'] ); + $this->assertStringContainsString( $concurrent_text, $this->fetch_content( $post_id, 'posts' ) ); + $this->assertStringNotContainsString( $preview_text, $this->fetch_content( $post_id, 'posts' ) ); + + $this->delete_preview_branch( $clone_dir, $branch ); + } + + public function testPreviewBranchRendersChangedTemplatePartForAuthenticatedAdminWithoutMutatingLiveContent() { + $suffix = uniqid( 'branch-template-' ); + $branch = 'preview/' . $suffix; + $preview_text = 'Preview footer template part ' . $suffix; + $clone_dir = $this->clone_repo( 'branch-template' ); + $this->configure_git( $clone_dir ); + + $footer_files = glob( $clone_dir . '/wp_template_part/*/footer.html' ); + $this->assertNotEmpty( $footer_files, 'Expected an active theme footer template part.' ); + $footer_path = $footer_files[0]; + $footer_relative = substr( $footer_path, strlen( $clone_dir ) + 1 ); + $original_footer = file_get_contents( $footer_path ); + $this->assertStringNotContainsString( $preview_text, $this->curl_get_public( $this->base_url . '/' ) ); + + $this->run_cmd( array( 'git', '-C', $clone_dir, 'checkout', '-b', $branch ) ); + file_put_contents( + $footer_path, + '' . "\n" + . '
' . "\n" + . "\t" . '' . "\n" + . "\t" . '

' . $preview_text . '

' . "\n" + . "\t" . '' . "\n" + . '
' . "\n" + . '' + ); + $this->run_cmd( array( 'git', '-C', $clone_dir, 'add', $footer_relative ) ); + $this->run_cmd( array( 'git', '-C', $clone_dir, 'commit', '-m', 'Preview footer template part' ) ); + $this->run_cmd( array( 'git', '-C', $clone_dir, 'push', 'origin', 'HEAD:refs/heads/' . $branch ) ); + + $public_preview_response = $this->curl_get_with_headers( $this->base_url . '/?branch=' . rawurlencode( $branch ), false ); + $this->assertSame( 200, $public_preview_response['status'], 'Public preview request should fall back to live content.' ); + $this->assertStringNotContainsString( $preview_text, $public_preview_response['body'] ); + + $admin_preview_response = $this->curl_get_with_headers( $this->base_url . '/?branch=' . rawurlencode( $branch ), true ); + $this->assertSame( 200, $admin_preview_response['status'], 'Authenticated preview request should render the branch template part.' ); + $this->assertStringContainsString( $preview_text, $admin_preview_response['body'] ); + $this->assertArrayHasKey( 'x-push-md-preview-branch', $admin_preview_response['headers'] ); + $this->assertSame( array( $branch ), $admin_preview_response['headers']['x-push-md-preview-branch'] ); + $this->assertStringNotContainsString( $preview_text, $this->curl_get_public( $this->base_url . '/' ) ); + + $this->run_cmd( array( 'git', '-C', $clone_dir, 'checkout', 'trunk' ) ); + $this->assertSame( $original_footer, file_get_contents( $footer_path ), 'Preview template part push must not mutate the live trunk checkout.' ); + + $this->delete_preview_branch( $clone_dir, $branch ); + } + + public function testPreviewBranchRestRoutesRequireAdmin() { + $suffix = uniqid( 'branch-permission-' ); + $list_url = $this->base_url . '/wp-json/push-md/v1/branches'; + $merge_url = $this->base_url . '/wp-json/push-md/v1/branches/merge'; + $branch = 'preview/' . $suffix; + $anonymous = $this->curl_get_with_headers( $list_url, false ); + $anon_merge = $this->curl_post_json_with_auth( $merge_url, array( 'branch' => $branch ), false ); + $username = 'branch-subscriber-' . $suffix; + $subscriber_id = $this->create_user_via_rest( + array( + 'username' => $username, + 'email' => $username . '@example.com', + 'password' => 'subscriber-password-' . $suffix, + 'roles' => array( 'subscriber' ), + ) + ); + $subscriber_password = $this->create_application_password_via_rest( $subscriber_id, 'Branch Permission E2E ' . $suffix ); + $subscriber_header = 'Authorization: Basic ' . base64_encode( $username . ':' . $subscriber_password ); + $subscriber_list = $this->curl_get_with_headers( $list_url, $subscriber_header ); + $subscriber_merge = $this->curl_post_json_with_auth( $merge_url, array( 'branch' => $branch ), $subscriber_header ); + $admin_list = $this->curl_get_with_headers( $list_url, true ); + + $this->assertContains( $anonymous['status'], array( 401, 403 ), 'Anonymous branch list request should be denied.' ); + $this->assertContains( $anon_merge['status'], array( 401, 403 ), 'Anonymous branch merge request should be denied.' ); + $this->assertSame( 403, $subscriber_list['status'], 'Subscriber branch list request should be denied.' ); + $this->assertSame( 403, $subscriber_merge['status'], 'Subscriber branch merge request should be denied.' ); + $this->assertSame( 200, $admin_list['status'], 'Admin branch list request should be allowed.' ); + } + public function testFullRoundTrip() { $hierarchy_suffix = uniqid( 'hierarchy-' ); $parent_a_slug = 'parent-a-' . $hierarchy_suffix; @@ -1014,6 +1470,29 @@ private function create_page_via_rest( array $payload ) { return intval( $page['id'] ); } + 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 update_page_via_rest( $id, array $payload ) { $ch = curl_init( $this->base_url . '/wp-json/wp/v2/pages/' . $id . '?context=edit' ); curl_setopt_array( @@ -1064,6 +1543,33 @@ private function update_post_via_rest( $id, array $payload ) { $this->assertSame( 200, $status, "REST update failed: $response" ); } + private function create_user_via_rest( array $payload ) { + $response = $this->curl_post_json( $this->base_url . '/wp-json/wp/v2/users?context=edit', $payload ); + $this->assertTrue( 200 === $response['status'] || 201 === $response['status'], 'REST user create failed: ' . $response['body'] ); + + $user = json_decode( $response['body'], true ); + $this->assertIsArray( $user, 'Unexpected REST user create response: ' . $response['body'] ); + $this->assertArrayHasKey( 'id', $user, 'REST user create response had no ID: ' . $response['body'] ); + + return intval( $user['id'] ); + } + + private function create_application_password_via_rest( $user_id, $name ) { + $response = $this->curl_post_json( + $this->base_url . '/wp-json/wp/v2/users/' . $user_id . '/application-passwords', + array( + 'name' => $name, + ) + ); + $this->assertTrue( 200 === $response['status'] || 201 === $response['status'], 'REST application password create failed: ' . $response['body'] ); + + $app_password = json_decode( $response['body'], true ); + $this->assertIsArray( $app_password, 'Unexpected REST application password create response: ' . $response['body'] ); + $this->assertArrayHasKey( 'password', $app_password, 'REST application password create response had no password: ' . $response['body'] ); + + return preg_replace( '/\s+/', '', $app_password['password'] ); + } + private function curl_get( $url ) { $ch = curl_init( $url ); curl_setopt_array( @@ -1079,6 +1585,102 @@ private function curl_get( $url ) { return $body; } + private function curl_get_public( $url ) { + $ch = curl_init( $url ); + curl_setopt_array( + $ch, + array( + CURLOPT_RETURNTRANSFER => true, + ) + ); + $body = curl_exec( $ch ); + curl_close( $ch ); + + return $body; + } + + private function curl_get_with_headers( $url, $authenticated ) { + $headers = array(); + $request_headers = $this->request_headers_for_auth( $authenticated ); + $options = array( + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADERFUNCTION => function ( $ch, $header ) use ( &$headers ) { + unset( $ch ); + $length = strlen( $header ); + $header = trim( $header ); + if ( false !== strpos( $header, ':' ) ) { + list( $name, $value ) = explode( ':', $header, 2 ); + $name = strtolower( trim( $name ) ); + if ( ! isset( $headers[ $name ] ) ) { + $headers[ $name ] = array(); + } + $headers[ $name ][] = trim( $value ); + } + + return $length; + }, + ); + if ( $request_headers ) { + $options[ CURLOPT_HTTPHEADER ] = $request_headers; + } + + $ch = curl_init( $url ); + curl_setopt_array( $ch, $options ); + $body = curl_exec( $ch ); + $status = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); + curl_close( $ch ); + + return array( + 'status' => $status, + 'headers' => $headers, + 'body' => $body, + ); + } + + private function curl_post_json( $url, array $payload ) { + return $this->curl_post_json_with_auth( $url, $payload, true ); + } + + private function curl_post_json_with_auth( $url, array $payload, $authenticated ) { + $ch = curl_init( $url ); + curl_setopt_array( + $ch, + array( + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CUSTOMREQUEST => 'POST', + CURLOPT_HTTPHEADER => $this->request_headers_for_auth( $authenticated, true ), + CURLOPT_POSTFIELDS => pmd_e2e_json_encode( $payload ), + ) + ); + $body = curl_exec( $ch ); + $status = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); + curl_close( $ch ); + + return array( + 'status' => $status, + 'body' => $body, + ); + } + + private function request_headers_for_auth( $authenticated, $include_json = false ) { + $headers = array(); + if ( true === $authenticated ) { + $headers[] = $this->auth_header; + } elseif ( is_string( $authenticated ) && '' !== $authenticated ) { + $headers[] = $authenticated; + } + if ( $include_json ) { + $headers[] = 'Content-Type: application/json'; + } + + return $headers; + } + + private function delete_preview_branch( $clone_dir, $branch ) { + $delete_result = $this->run_cmd( array( 'git', '-C', $clone_dir, 'push', 'origin', ':refs/heads/' . $branch ) ); + $this->assertStringContainsString( 'Push MD deleted preview branch ' . $branch . '.', $delete_result['output'] ); + } + private function http_status( $url ) { $ch = curl_init( $url ); curl_setopt_array( @@ -1096,6 +1698,55 @@ private function http_status( $url ) { return $status; } + private function count_revisions( $id, $endpoint ) { + $body = $this->curl_get( $this->base_url . '/wp-json/wp/v2/' . $endpoint . '/' . $id . '/revisions?context=edit' ); + $revisions = json_decode( $body, true ); + $this->assertIsArray( $revisions, "Unexpected revisions response for $endpoint/$id: $body" ); + + return count( $revisions ); + } + + private function find_branch_metadata( $branches, $branch_name ) { + if ( ! is_array( $branches ) ) { + return array(); + } + + foreach ( $branches as $branch ) { + if ( is_array( $branch ) && isset( $branch['branch'] ) && $branch_name === $branch['branch'] ) { + return $branch; + } + } + + return array(); + } + + private function find_changed_url_item( $items, $path ) { + if ( ! is_array( $items ) ) { + return array(); + } + + foreach ( $items as $item ) { + if ( is_array( $item ) && isset( $item['path'] ) && $path === $item['path'] ) { + return $item; + } + } + + return array(); + } + + private function assertArrayHasNoTokenKeys( $value ) { + if ( ! is_array( $value ) ) { + return; + } + + foreach ( $value as $key => $nested_value ) { + $key = strtolower( (string) $key ); + $this->assertStringNotContainsString( 'token', $key ); + $this->assertStringNotContainsString( 'secret', $key ); + $this->assertArrayHasNoTokenKeys( $nested_value ); + } + } + private function run_cmd( array $args, $allow_failure = false ) { $command = ''; foreach ( $args as $arg ) { diff --git a/plugins/push-md/admin-shell.css b/plugins/push-md/admin-shell.css index b9f49a31..d4c17f22 100644 --- a/plugins/push-md/admin-shell.css +++ b/plugins/push-md/admin-shell.css @@ -306,6 +306,150 @@ color: #1f6f43; } +.push-md-branches-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.push-md-branches-header h2 { + margin: 0; +} + +.push-md-branch-message { + margin: 0 0 10px; + color: var(--push-md-muted); + font-size: 13px; + line-height: 1.4; +} + +.push-md-branch-message:empty { + display: none; +} + +.push-md-branch-message.is-success { + color: #1f6f43; +} + +.push-md-branch-message.is-warning { + color: #8a4b08; +} + +.push-md-branch-message.is-error { + color: #b32d2e; +} + +.push-md-branch-list { + border-top: 1px solid var(--push-md-line); +} + +.push-md-branch-list:empty { + border-top: 0; +} + +.push-md-branch-section { + padding-top: 12px; +} + +.push-md-branch-section + .push-md-branch-section { + margin-top: 12px; +} + +.push-md-branch-section-title { + margin: 0; + padding: 0 0 8px; + color: var(--push-md-muted); + font-size: 12px; + font-weight: 600; + line-height: 1.3; + text-transform: uppercase; +} + +.push-md-branch-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 16px; + padding: 14px 0; + border-bottom: 1px solid var(--push-md-line); +} + +.push-md-branch-row.is-merged { + opacity: 0.82; +} + +.push-md-branch-details { + min-width: 0; +} + +.push-md-branch-name { + display: inline-block; + max-width: 100%; + margin-bottom: 8px; + padding: 2px 5px; + border-radius: 4px; + background: #edf6ff; + color: #0a4b78; + overflow-wrap: anywhere; +} + +.push-md-branch-meta { + display: flex; + flex-wrap: wrap; + gap: 6px 12px; + color: var(--push-md-muted); + font-size: 12px; +} + +.push-md-branch-meta-item strong { + color: var(--push-md-ink); + font-weight: 600; +} + +.push-md-changed-url-list { + display: flex; + flex-direction: column; + gap: 6px; + margin: 12px 0 0; + padding: 0; + list-style: none; + font-size: 12px; + line-height: 1.4; +} + +.push-md-changed-url-list li { + display: grid; + grid-template-columns: 76px minmax(0, 1fr); + gap: 8px; + margin: 0; +} + +.push-md-changed-url-list li.is-muted { + display: block; + color: var(--push-md-muted); +} + +.push-md-changed-url-list a, +.push-md-changed-target { + min-width: 0; + overflow-wrap: anywhere; +} + +.push-md-changed-action { + color: var(--push-md-muted); + font-weight: 600; +} + +.push-md-branch-actions { + display: flex; + flex-wrap: wrap; + align-content: flex-start; + justify-content: flex-end; + gap: 8px; + min-width: 230px; +} + .push-md-progress-track { height: 12px; border-radius: 999px; @@ -424,6 +568,7 @@ .push-md-terminal-input-row, .push-md-import-header, + .push-md-branches-header, .push-md-retry-row { align-items: stretch; flex-direction: column; @@ -448,4 +593,17 @@ .push-md-copy-table td:last-child { padding-top: 0; } + + .push-md-branch-row { + grid-template-columns: 1fr; + } + + .push-md-branch-actions { + justify-content: flex-start; + min-width: 0; + } + + .push-md-changed-url-list li { + grid-template-columns: 1fr; + } } diff --git a/plugins/push-md/admin-shell.js b/plugins/push-md/admin-shell.js index f7286d73..1f78964c 100644 --- a/plugins/push-md/admin-shell.js +++ b/plugins/push-md/admin-shell.js @@ -14,6 +14,8 @@ var nonce = config.nonce || ''; var statusUrl = config.statusUrl || ''; var retryUrl = config.retryUrl || ''; + var branchesUrl = config.branchesUrl || ''; + var mergeBranchUrl = config.mergeBranchUrl || ''; var remoteUrl = config.remoteUrl || ''; var checkoutDir = config.checkoutDir || 'site'; var cloneCommand = config.cloneCommand || ('git clone ' + remoteUrl + ' ' + checkoutDir); @@ -30,6 +32,9 @@ 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 branchListEl = document.getElementById( 'push-md-branch-list' ); + var branchMessageEl = document.getElementById( 'push-md-branches-message' ); + var branchRefreshEl = document.getElementById( 'push-md-branches-refresh' ); var checkout = normalizeCheckout( progress.checkout ); var cwd = '/'; var history = []; @@ -121,6 +126,330 @@ ); } + function setBranchMessage(message, className) { + if ( ! branchMessageEl) { + return; + } + + branchMessageEl.className = 'push-md-branch-message' + (className ? ' ' + className : ''); + branchMessageEl.textContent = message || ''; + } + + function parseJsonResponse(response) { + return response.text().then( + function (text) { + var data = {}; + if (text) { + try { + data = JSON.parse( text ); + } catch (error) { + data = { message: text }; + } + } + + if ( ! response.ok) { + throw new Error( data.message || response.statusText || __( 'Request failed.', 'push-md' ) ); + } + + return data; + } + ); + } + + function fetchBranches() { + if ( ! branchesUrl || ! branchListEl || ! branchMessageEl) { + return; + } + + setBranchMessage( __( 'Loading preview branches...', 'push-md' ), 'is-muted' ); + fetch( + branchesUrl, + { + credentials: 'same-origin', + headers: { 'X-WP-Nonce': nonce } + } + ).then( + parseJsonResponse + ).then( + function (data) { + renderBranches( data.branches || [] ); + } + ).catch( + function (error) { + branchListEl.textContent = ''; + setBranchMessage( error.message || __( 'Could not load preview branches.', 'push-md' ), 'is-error' ); + } + ); + } + + function renderBranches(branches) { + branchListEl.textContent = ''; + branches = Array.isArray( branches ) ? branches.slice( 0 ) : []; + branches.sort( + function (left, right) { + return intval( right.updated_at ) - intval( left.updated_at ); + } + ); + + if ( ! branches.length) { + setBranchMessage( __( 'No preview branches.', 'push-md' ), 'is-muted' ); + return; + } + + var activeBranches = []; + var mergedBranches = []; + branches.forEach( + function (branch) { + if (branch.merged_at) { + mergedBranches.push( branch ); + } else { + activeBranches.push( branch ); + } + } + ); + + setBranchMessage( '' ); + if (activeBranches.length) { + branchListEl.appendChild( createBranchSection( __( 'Active previews', 'push-md' ), activeBranches, 'is-active' ) ); + } + if (mergedBranches.length) { + branchListEl.appendChild( createBranchSection( __( 'Merged branches', 'push-md' ), mergedBranches, 'is-merged' ) ); + } + if ( ! activeBranches.length) { + setBranchMessage( __( 'No active preview branches.', 'push-md' ), 'is-muted' ); + } + } + + function createBranchSection(title, branches, className) { + var section = document.createElement( 'div' ); + var heading = document.createElement( 'h3' ); + + section.className = 'push-md-branch-section ' + className; + heading.className = 'push-md-branch-section-title'; + heading.textContent = title; + section.appendChild( heading ); + + branches.forEach( + function (branch) { + section.appendChild( createBranchRow( branch ) ); + } + ); + + return section; + } + + function createBranchRow(branch) { + var branchName = String( branch.branch || '' ); + var previewUrl = String( branch.url || '' ); + var isMerged = Boolean( branch.merged_at ); + var row = document.createElement( 'div' ); + var details = document.createElement( 'div' ); + var actions = document.createElement( 'div' ); + var name = document.createElement( 'code' ); + var meta = document.createElement( 'div' ); + var preview = document.createElement( 'a' ); + var copy = document.createElement( 'button' ); + var merge = document.createElement( 'button' ); + + row.className = 'push-md-branch-row' + (isMerged ? ' is-merged' : ''); + details.className = 'push-md-branch-details'; + actions.className = 'push-md-branch-actions'; + name.className = 'push-md-branch-name'; + meta.className = 'push-md-branch-meta'; + name.textContent = branchName; + + meta.appendChild( + createMetaItem( + __( 'Updated', 'push-md' ), + formatTimestamp( branch.updated_at ) + ) + ); + if (branch.tip_oid) { + meta.appendChild( createMetaItem( __( 'Tip', 'push-md' ), shortOid( branch.tip_oid ) ) ); + } + if (branch.base_oid) { + meta.appendChild( createMetaItem( __( 'Base', 'push-md' ), shortOid( branch.base_oid ) ) ); + } + if (branch.merged_at) { + meta.appendChild( createMetaItem( __( 'Merged', 'push-md' ), formatTimestamp( branch.merged_at ) ) ); + } + + details.appendChild( name ); + details.appendChild( meta ); + details.appendChild( createChangedUrlList( branch.changed_urls || [], isMerged ) ); + + if (isMerged) { + merge.type = 'button'; + merge.className = 'button'; + merge.disabled = true; + merge.textContent = __( 'Merged', 'push-md' ); + actions.appendChild( merge ); + row.appendChild( details ); + row.appendChild( actions ); + + return row; + } + + preview.className = 'button'; + preview.href = previewUrl; + preview.target = '_blank'; + preview.rel = 'noopener noreferrer'; + preview.textContent = __( 'Preview', 'push-md' ); + + copy.type = 'button'; + copy.className = 'button'; + copy.textContent = __( 'Copy URL', 'push-md' ); + copy.addEventListener( + 'click', + function () { + copyText( previewUrl, copy ); + } + ); + + merge.type = 'button'; + merge.className = 'button button-primary'; + merge.textContent = __( 'Merge', 'push-md' ); + merge.addEventListener( + 'click', + function () { + mergeBranch( branchName, merge ); + } + ); + + actions.appendChild( preview ); + actions.appendChild( copy ); + actions.appendChild( merge ); + row.appendChild( details ); + row.appendChild( actions ); + + return row; + } + + function createMetaItem(label, value) { + var item = document.createElement( 'span' ); + var labelEl = document.createElement( 'span' ); + var valueEl = document.createElement( 'strong' ); + item.className = 'push-md-branch-meta-item'; + labelEl.textContent = label + ': '; + valueEl.textContent = value; + item.appendChild( labelEl ); + item.appendChild( valueEl ); + + return item; + } + + function createChangedUrlList(changedUrls, isMerged) { + var list = document.createElement( 'ul' ); + list.className = 'push-md-changed-url-list'; + changedUrls = Array.isArray( changedUrls ) ? changedUrls : []; + + if ( ! changedUrls.length) { + var empty = document.createElement( 'li' ); + empty.className = 'is-muted'; + empty.textContent = __( 'No changed preview URLs available.', 'push-md' ); + list.appendChild( empty ); + return list; + } + + changedUrls.forEach( + function (item) { + var row = document.createElement( 'li' ); + var action = document.createElement( 'span' ); + var target = isMerged ? document.createElement( 'span' ) : document.createElement( 'a' ); + + action.className = 'push-md-changed-action'; + action.textContent = formatChangedAction( item.action ); + target.className = 'push-md-changed-target'; + target.textContent = String( item.path || item.url || '' ); + if ( ! isMerged) { + target.href = String( item.url || '' ); + target.target = '_blank'; + target.rel = 'noopener noreferrer'; + } + + row.appendChild( action ); + row.appendChild( target ); + list.appendChild( row ); + } + ); + + return list; + } + + function formatChangedAction(action) { + if (action === 'created') { + return __( 'Created', 'push-md' ); + } + if (action === 'deleted') { + return __( 'Deleted', 'push-md' ); + } + if (action === 'updated') { + return __( 'Updated', 'push-md' ); + } + + return __( 'Changed', 'push-md' ); + } + + function mergeBranch(branchName, button) { + if ( ! mergeBranchUrl) { + return; + } + if ( ! window.confirm( sprintf( __( 'Merge "%s" into live WordPress content?', 'push-md' ), branchName ) ) ) { + return; + } + + var originalText = button.textContent; + button.disabled = true; + button.textContent = __( 'Merging...', 'push-md' ); + setBranchMessage( sprintf( __( 'Merging %s...', 'push-md' ), branchName ), 'is-warning' ); + + fetch( + mergeBranchUrl, + { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': nonce + }, + body: JSON.stringify( { branch: branchName } ) + } + ).then( + parseJsonResponse + ).then( + function (data) { + setBranchMessage( sprintf( __( 'Merged %s into live content.', 'push-md' ), data.branch || branchName ), 'is-success' ); + fetchBranches(); + poll(); + } + ).catch( + function (error) { + button.disabled = false; + button.textContent = originalText; + setBranchMessage( error.message || sprintf( __( 'Could not merge %s.', 'push-md' ), branchName ), 'is-error' ); + } + ); + } + + function intval(value) { + var parsed = parseInt( value, 10 ); + return isNaN( parsed ) ? 0 : parsed; + } + + function formatTimestamp(value) { + var timestamp = intval( value ); + if ( ! timestamp) { + return __( 'Unknown', 'push-md' ); + } + + return new Date( timestamp * 1000 ).toLocaleString(); + } + + function shortOid(value) { + value = String( value || '' ); + return value.length > 12 ? value.substring( 0, 12 ) : value; + } + function appendLine(text, className) { var line = document.createElement( 'div' ); line.className = 'push-md-terminal-line'; @@ -869,7 +1198,12 @@ } ); + if (branchRefreshEl) { + branchRefreshEl.addEventListener( 'click', fetchBranches ); + } + render( progress ); + fetchBranches(); updatePrompt(); bootTranscript(); poll(); diff --git a/plugins/push-md/class-push-md-admin.php b/plugins/push-md/class-push-md-admin.php index 9af1ecb6..d2dcc602 100644 --- a/plugins/push-md/class-push-md-admin.php +++ b/plugins/push-md/class-push-md-admin.php @@ -13,7 +13,9 @@ class Push_MD_Admin { const REST_NAMESPACE = 'push-md/v1'; const STATUS_ROUTE = '/seed-status'; const RETRY_ROUTE = '/seed-retry'; - const ASSET_VERSION = '0.6.7'; + const BRANCHES_ROUTE = '/branches'; + const MERGE_ROUTE = '/branches/merge'; + const ASSET_VERSION = '0.6.9'; public static function bootstrap() { add_action( 'admin_menu', array( __CLASS__, 'register_menu' ) ); @@ -72,6 +74,31 @@ public static function register_rest_routes() { 'permission_callback' => array( __CLASS__, 'admin_only' ), ) ); + register_rest_route( + self::REST_NAMESPACE, + self::BRANCHES_ROUTE, + array( + 'methods' => 'GET', + 'callback' => array( __CLASS__, 'rest_branches' ), + 'permission_callback' => array( __CLASS__, 'admin_only' ), + ) + ); + register_rest_route( + self::REST_NAMESPACE, + self::MERGE_ROUTE, + array( + 'methods' => 'POST', + 'callback' => array( __CLASS__, 'rest_merge_branch' ), + 'permission_callback' => array( __CLASS__, 'admin_only' ), + 'args' => array( + 'branch' => array( + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + ) + ); } public static function admin_only() { @@ -92,6 +119,30 @@ public static function rest_retry() { return rest_ensure_response( Push_MD_Seeder::get_progress() ); } + public static function rest_branches() { + return rest_ensure_response( + array( + 'branches' => Push_MD_Plugin::list_preview_branches(), + ) + ); + } + + public static function rest_merge_branch( WP_REST_Request $request ) { + try { + return rest_ensure_response( + Push_MD_Plugin::merge_preview_branch( + (string) $request->get_param( 'branch' ) + ) + ); + } catch ( Throwable $exception ) { + return new WP_Error( + 'push_md_branch_merge_failed', + $exception->getMessage(), + array( 'status' => 400 ) + ); + } + } + private static function add_username_to_url( $url, $username ) { if ( '' === $username ) { return $url; @@ -122,12 +173,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(); + $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 ) ); + $branches_url = esc_url_raw( rest_url( self::REST_NAMESPACE . self::BRANCHES_ROUTE ) ); + $merge_url = esc_url_raw( rest_url( self::REST_NAMESPACE . self::MERGE_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 ); } @@ -160,6 +213,8 @@ public static function render_page() { 'nonce' => $nonce, 'statusUrl' => $status_url, 'retryUrl' => $retry_url, + 'branchesUrl' => $branches_url, + 'mergeBranchUrl' => $merge_url, 'remoteUrl' => $git_url, 'cloneCommand' => $clone_command, 'checkoutDir' => $site_slug, @@ -266,6 +321,15 @@ 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..e6429450 100644 --- a/plugins/push-md/class-push-md-plugin.php +++ b/plugins/push-md/class-push-md-plugin.php @@ -3,6 +3,7 @@ use WordPress\DataLiberation\DataFormatConsumer\BlocksWithMetadata; use WordPress\Filesystem\WpdbFilesystem; use WordPress\Git\GitEndpoint; +use WordPress\Git\GitException; use WordPress\Git\GitRepository; use WordPress\Git\Model\Commit; use WordPress\Git\Model\TreeEntry; @@ -49,6 +50,8 @@ class Push_MD_Plugin { const THEME_BASE_COMMIT_MESSAGE = 'Initial theme base from WordPress'; const THEME_BASE_SYNC_MESSAGE = 'Sync theme base from WordPress'; const WORDPRESS_SYNC_MESSAGE = 'Sync from WordPress'; + const BRANCH_PREVIEWS_OPTION = 'push_md_branch_previews'; + const BRANCH_QUERY_PARAM = 'branch'; public static $supported_post_types = array( 'post', 'page' ); public static $supported_post_statuses = array( 'publish', 'draft', 'pending', 'private', 'future' ); @@ -59,6 +62,10 @@ class Push_MD_Plugin { private static $json_post_types = array( 'wp_global_styles' ); + private static $active_preview_branch = null; + private static $active_preview_files = null; + private static $active_preview_changed_paths = array(); + private static $guideline_type_directories = array( 'artifact' => 'artifacts', 'content' => 'content', @@ -70,6 +77,8 @@ class Push_MD_Plugin { public static function bootstrap() { add_action( 'init', array( __CLASS__, 'install_default_agent_skill' ), 20 ); + add_action( 'parse_request', array( __CLASS__, 'maybe_enable_branch_preview' ), 1 ); + add_action( 'admin_bar_menu', array( __CLASS__, 'add_admin_bar_branch_switcher' ), 90 ); add_action( 'rest_api_init', array( __CLASS__, 'register_routes' ) ); add_filter( 'rest_post_dispatch', array( __CLASS__, 'add_authentication_challenge' ), 10, 3 ); add_filter( 'rest_pre_serve_request', array( __CLASS__, 'serve_git_response' ), 10, 4 ); @@ -212,6 +221,841 @@ private static function current_user_can_read_exported_content() { return true; } + public static function maybe_enable_branch_preview() { + if ( is_admin() || ! isset( $_GET[ self::BRANCH_QUERY_PARAM ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only frontend preview switch. + return; + } + + $branch_name = wp_unslash( $_GET[ self::BRANCH_QUERY_PARAM ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only frontend preview switch. + if ( ! is_string( $branch_name ) ) { + return; + } + if ( ! self::is_valid_preview_branch_name( $branch_name ) ) { + return; + } + + self::maybe_authenticate_branch_preview_request(); + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + try { + $repository = self::open_repository(); + $ref_name = 'refs/heads/' . $branch_name; + if ( ! $repository->branch_exists( $ref_name ) ) { + return; + } + + $tip = $repository->get_branch_tip( $ref_name ); + if ( Commit::is_null_hash( $tip ) ) { + return; + } + + $branches = self::get_preview_branches(); + $branch_metadata = isset( $branches[ $branch_name ] ) && is_array( $branches[ $branch_name ] ) + ? $branches[ $branch_name ] + : array(); + if ( self::is_preview_branch_merged( $branch_metadata ) ) { + return; + } + + $base_files = array(); + $base_oid = isset( $branch_metadata['base_oid'] ) && is_string( $branch_metadata['base_oid'] ) + ? $branch_metadata['base_oid'] + : $repository->get_branch_tip( 'refs/heads/' . self::DEFAULT_BRANCH ); + if ( is_string( $base_oid ) && '' !== $base_oid && ! Commit::is_null_hash( $base_oid ) && $repository->has_object( $base_oid ) ) { + $base_files = self::read_repository_entries_from_commit( $repository, $base_oid ); + } + + self::$active_preview_branch = $branch_name; + self::$active_preview_files = self::read_repository_entries_from_commit( $repository, $tip ); + self::$active_preview_changed_paths = self::calculate_preview_changed_paths( $base_files, self::$active_preview_files ); + + add_filter( 'posts_pre_query', array( __CLASS__, 'filter_preview_posts_pre_query' ), 10, 2 ); + add_filter( 'the_posts', array( __CLASS__, 'filter_preview_posts' ), 10, 2 ); + add_filter( 'pre_handle_404', array( __CLASS__, 'filter_preview_404' ), 10, 2 ); + add_filter( 'pre_get_block_template', array( __CLASS__, 'filter_preview_block_template' ), 10, 3 ); + add_filter( 'pre_get_block_file_template', array( __CLASS__, 'filter_preview_block_template' ), 10, 3 ); + add_filter( 'get_block_templates', array( __CLASS__, 'filter_preview_block_templates' ), 10, 3 ); + add_filter( 'wp_theme_json_data_user', array( __CLASS__, 'filter_preview_global_styles' ) ); + add_filter( 'render_block_core/navigation', array( __CLASS__, 'filter_preview_navigation_block' ), 10, 2 ); + add_filter( 'pre_get_shortlink', array( __CLASS__, 'filter_preview_shortlink' ), 10, 4 ); + add_action( 'send_headers', array( __CLASS__, 'send_branch_preview_headers' ) ); + add_action( 'wp_footer', array( __CLASS__, 'render_branch_preview_notice' ) ); + } catch ( Throwable $exception ) { + self::$active_preview_branch = null; + self::$active_preview_files = null; + self::$active_preview_changed_paths = array(); + } + } + + private static function maybe_authenticate_branch_preview_request() { + if ( is_user_logged_in() || ! function_exists( 'wp_authenticate_application_password' ) ) { + return; + } + if ( empty( $_SERVER['PHP_AUTH_USER'] ) || ! isset( $_SERVER['PHP_AUTH_PW'] ) ) { + return; + } + + $username = (string) wp_unslash( $_SERVER['PHP_AUTH_USER'] ); + $password = (string) wp_unslash( $_SERVER['PHP_AUTH_PW'] ); + add_filter( 'application_password_is_api_request', '__return_true' ); + try { + $user = wp_authenticate_application_password( null, $username, $password ); + } finally { + remove_filter( 'application_password_is_api_request', '__return_true' ); + } + if ( $user instanceof WP_User ) { + wp_set_current_user( $user->ID ); + } + } + + public static function filter_preview_posts_pre_query( $posts, $query ) { + if ( ! self::is_branch_preview_active() || ! method_exists( $query, 'is_main_query' ) || ! $query->is_main_query() ) { + return $posts; + } + if ( is_array( $posts ) ) { + return $posts; + } + + $path = self::preview_request_path_from_query( $query ); + if ( '' === $path || ! isset( self::$active_preview_files[ $path ] ) ) { + return $posts; + } + if ( self::preview_path_maps_to_existing_post( $path, self::$active_preview_files[ $path ] ) ) { + return $posts; + } + + $post = self::preview_post_from_entry( $path, self::$active_preview_files[ $path ], null ); + if ( ! $post ) { + return $posts; + } + + $query->is_singular = true; + $query->is_single = 'post' === $post->post_type; + $query->is_page = 'page' === $post->post_type; + $query->is_home = false; + $query->is_archive = false; + $query->is_404 = false; + $query->posts = array( $post ); + $query->post_count = 1; + $query->found_posts = 1; + $query->queried_object = $post; + $query->queried_object_id = $post->ID; + + return array( $post ); + } + + public static function filter_preview_posts( $posts, $query ) { + if ( ! self::is_branch_preview_active() || ! is_array( $posts ) ) { + return $posts; + } + + $preview_posts = array(); + $seen_paths = array(); + foreach ( $posts as $post ) { + if ( ! $post instanceof WP_Post || ! in_array( $post->post_type, self::get_supported_post_types(), true ) ) { + $preview_posts[] = $post; + continue; + } + + try { + $path = self::build_markdown_path( $post ); + } catch ( Throwable $exception ) { + $preview_posts[] = $post; + continue; + } + + $seen_paths[ $path ] = true; + if ( ! isset( self::$active_preview_files[ $path ] ) ) { + continue; + } + + $preview_post = self::preview_post_from_entry( $path, self::$active_preview_files[ $path ], $post ); + if ( $preview_post ) { + $preview_posts[] = $preview_post; + } + } + + $posts_before_branch_only = count( $preview_posts ); + $preview_posts = self::append_branch_only_preview_posts_for_query( $preview_posts, $query, $seen_paths ); + if ( count( $preview_posts ) !== $posts_before_branch_only ) { + $preview_posts = self::sort_preview_posts_for_query( $preview_posts, $query ); + } + + if ( is_object( $query ) ) { + $query->posts = $preview_posts; + $query->post_count = count( $preview_posts ); + if ( isset( $query->found_posts ) && $query->found_posts < $query->post_count ) { + $query->found_posts = $query->post_count; + } + } + + return $preview_posts; + } + + private static function append_branch_only_preview_posts_for_query( $preview_posts, $query, $seen_paths ) { + if ( ! self::preview_query_can_include_branch_only_posts( $query ) ) { + return $preview_posts; + } + + foreach ( self::$active_preview_files as $path => $entry ) { + if ( isset( $seen_paths[ $path ] ) || ! self::is_branch_preview_path_changed( $path ) || TreeEntry::FILE_MODE_SYMBOLIC_LINK === $entry['mode'] ) { + continue; + } + + try { + $post_type = self::path_to_post_type( $path ); + if ( ! in_array( $post_type, self::$supported_post_types, true ) || self::preview_path_maps_to_existing_post( $path, $entry ) ) { + continue; + } + + $post = self::preview_post_from_entry( $path, $entry, null ); + } catch ( Throwable $exception ) { + continue; + } + + if ( $post && self::preview_post_matches_query( $post, $query ) ) { + $preview_posts[] = $post; + } + } + + return $preview_posts; + } + + private static function preview_query_can_include_branch_only_posts( $query ) { + if ( ! is_object( $query ) ) { + return false; + } + + if ( ! empty( $query->is_singular ) || ! empty( $query->is_single ) || ! empty( $query->is_page ) ) { + return false; + } + + $query_vars = isset( $query->query_vars ) && is_array( $query->query_vars ) ? $query->query_vars : array(); + if ( isset( $query_vars['fields'] ) && '' !== $query_vars['fields'] && 'all' !== $query_vars['fields'] ) { + return false; + } + + foreach ( array( 'p', 'page_id', 'name', 'pagename' ) as $singular_var ) { + if ( ! empty( $query_vars[ $singular_var ] ) ) { + return false; + } + } + + if ( ! empty( $query_vars['post__in'] ) ) { + return false; + } + + return true; + } + + private static function preview_post_matches_query( WP_Post $post, $query ) { + $query_vars = isset( $query->query_vars ) && is_array( $query->query_vars ) ? $query->query_vars : array(); + + if ( ! in_array( $post->post_type, self::preview_query_post_types( $query_vars ), true ) ) { + return false; + } + if ( ! self::preview_post_status_matches_query( $post, $query_vars ) ) { + return false; + } + if ( ! self::preview_post_date_matches_query( $post, $query_vars ) ) { + return false; + } + if ( ! self::preview_post_search_matches_query( $post, $query_vars ) ) { + return false; + } + if ( ! empty( $query_vars['post__not_in'] ) && in_array( $post->ID, array_map( 'intval', (array) $query_vars['post__not_in'] ), true ) ) { + return false; + } + if ( ! empty( $query_vars['post_name__in'] ) && ! in_array( $post->post_name, (array) $query_vars['post_name__in'], true ) ) { + return false; + } + + return true; + } + + private static function preview_query_post_types( $query_vars ) { + $post_type = isset( $query_vars['post_type'] ) ? $query_vars['post_type'] : ''; + if ( '' === $post_type ) { + return array( 'post' ); + } + if ( 'any' === $post_type ) { + return self::$supported_post_types; + } + + return array_values( array_intersect( (array) $post_type, self::$supported_post_types ) ); + } + + private static function preview_post_status_matches_query( WP_Post $post, $query_vars ) { + if ( empty( $query_vars['post_status'] ) ) { + return 'publish' === $post->post_status; + } + + $post_status = (array) $query_vars['post_status']; + if ( in_array( 'any', $post_status, true ) ) { + return true; + } + + return in_array( $post->post_status, $post_status, true ); + } + + private static function preview_post_date_matches_query( WP_Post $post, $query_vars ) { + $timestamp = self::timestamp_from_gmt_string( $post->post_date_gmt ); + if ( false === $timestamp ) { + $timestamp = self::timestamp_from_gmt_string( get_gmt_from_date( $post->post_date ) ); + } + if ( false === $timestamp ) { + return true; + } + + $checks = array( + 'year' => 'Y', + 'monthnum' => 'n', + 'day' => 'j', + 'hour' => 'G', + 'minute' => 'i', + 'second' => 's', + ); + foreach ( $checks as $query_var => $format ) { + if ( ! isset( $query_vars[ $query_var ] ) || '' === (string) $query_vars[ $query_var ] ) { + continue; + } + if ( in_array( $query_var, array( 'year', 'monthnum', 'day' ), true ) && 0 === intval( $query_vars[ $query_var ] ) ) { + continue; + } + if ( intval( gmdate( $format, $timestamp ) ) !== intval( $query_vars[ $query_var ] ) ) { + return false; + } + } + + return true; + } + + private static function preview_post_search_matches_query( WP_Post $post, $query_vars ) { + if ( empty( $query_vars['s'] ) ) { + return true; + } + + $needle = strtolower( trim( (string) $query_vars['s'] ) ); + if ( '' === $needle ) { + return true; + } + + $haystack = strtolower( wp_strip_all_tags( $post->post_title . ' ' . $post->post_content ) ); + + return false !== strpos( $haystack, $needle ); + } + + private static function sort_preview_posts_for_query( $posts, $query ) { + if ( ! self::preview_query_can_sort_by_date( $query ) ) { + return $posts; + } + + $order = isset( $query->query_vars['order'] ) ? strtoupper( (string) $query->query_vars['order'] ) : 'DESC'; + usort( + $posts, + function ( $a, $b ) use ( $order ) { + if ( ! $a instanceof WP_Post || ! $b instanceof WP_Post ) { + return 0; + } + + $a_time = self::preview_post_sort_timestamp( $a ); + $b_time = self::preview_post_sort_timestamp( $b ); + if ( $a_time === $b_time ) { + return 0; + } + + if ( 'ASC' === $order ) { + return $a_time < $b_time ? -1 : 1; + } + + return $a_time > $b_time ? -1 : 1; + } + ); + + return $posts; + } + + private static function preview_query_can_sort_by_date( $query ) { + if ( ! is_object( $query ) || ! isset( $query->query_vars ) || ! is_array( $query->query_vars ) ) { + return false; + } + + $orderby = isset( $query->query_vars['orderby'] ) ? $query->query_vars['orderby'] : ''; + if ( '' === $orderby || 'date' === $orderby ) { + return true; + } + if ( is_array( $orderby ) ) { + $keys = array_keys( $orderby ); + return array( 'date' ) === $keys || in_array( 'date', $orderby, true ); + } + + return false; + } + + private static function preview_post_sort_timestamp( WP_Post $post ) { + $timestamp = self::timestamp_from_gmt_string( $post->post_date_gmt ); + if ( false === $timestamp ) { + $timestamp = self::timestamp_from_gmt_string( get_gmt_from_date( $post->post_date ) ); + } + + return false === $timestamp ? 0 : $timestamp; + } + + public static function filter_preview_404( $preempt, $query ) { + if ( self::is_branch_preview_active() && method_exists( $query, 'is_main_query' ) && $query->is_main_query() && ! empty( $query->posts ) ) { + return false; + } + + return $preempt; + } + + public static function filter_preview_block_template( $block_template, $id, $template_type ) { + if ( ! self::is_branch_preview_active() || ! self::is_raw_block_post_type( $template_type ) ) { + return $block_template; + } + + foreach ( self::preview_raw_block_paths_for_id( $template_type, $id ) as $path ) { + if ( isset( self::$active_preview_files[ $path ] ) && self::is_branch_preview_path_changed( $path ) ) { + return self::preview_block_template_from_entry( $path, self::$active_preview_files[ $path ], $block_template, $id ); + } + } + + return $block_template; + } + + public static function filter_preview_block_templates( $query_result, $query, $template_type ) { + unset( $query ); + + if ( ! self::is_branch_preview_active() || ! self::is_raw_block_post_type( $template_type ) || ! is_array( $query_result ) ) { + return $query_result; + } + + $templates = array(); + $seen = array(); + foreach ( $query_result as $block_template ) { + $preview_template = $block_template; + if ( is_object( $block_template ) && isset( $block_template->id ) ) { + $preview_template = self::filter_preview_block_template( $block_template, $block_template->id, $template_type ); + } + if ( is_object( $preview_template ) && isset( $preview_template->id ) ) { + $seen[ $preview_template->id ] = true; + } + $templates[] = $preview_template; + } + + foreach ( self::$active_preview_files as $path => $entry ) { + if ( TreeEntry::FILE_MODE_SYMBOLIC_LINK === $entry['mode'] || ! self::is_branch_preview_path_changed( $path ) || ! self::is_raw_block_path( $path ) ) { + continue; + } + + $identity = self::path_to_raw_block_identity( $path ); + if ( $template_type !== $identity['post_type'] ) { + continue; + } + + $template = self::preview_block_template_from_entry( $path, $entry, null, '' ); + if ( ! $template || isset( $seen[ $template->id ] ) ) { + continue; + } + + $seen[ $template->id ] = true; + $templates[] = $template; + } + + return $templates; + } + + public static function filter_preview_global_styles( $theme_json ) { + if ( ! self::is_branch_preview_active() || ! method_exists( $theme_json, 'update_with' ) || ! function_exists( 'get_stylesheet' ) ) { + return $theme_json; + } + + $path = self::build_global_styles_path( get_stylesheet() ); + if ( ! isset( self::$active_preview_files[ $path ] ) || ! self::is_branch_preview_path_changed( $path ) || TreeEntry::FILE_MODE_SYMBOLIC_LINK === self::$active_preview_files[ $path ]['mode'] ) { + return $theme_json; + } + + $config = self::parse_global_styles_json( $path, self::$active_preview_files[ $path ]['content'] ); + $theme_json->update_with( $config ); + + return $theme_json; + } + + public static function filter_preview_navigation_block( $block_content, $block ) { + if ( ! self::is_branch_preview_active() || empty( $block['attrs']['ref'] ) ) { + return $block_content; + } + + $post = get_post( intval( $block['attrs']['ref'] ) ); + if ( ! $post || 'wp_navigation' !== $post->post_type ) { + return $block_content; + } + + $path = self::build_markdown_path( $post ); + if ( ! isset( self::$active_preview_files[ $path ] ) || ! self::is_branch_preview_path_changed( $path ) || TreeEntry::FILE_MODE_SYMBOLIC_LINK === self::$active_preview_files[ $path ]['mode'] ) { + return $block_content; + } + + return do_blocks( self::$active_preview_files[ $path ]['content'] ); + } + + public static function filter_preview_shortlink( $shortlink, $id, $context, $allow_slugs ) { + unset( $id, $context, $allow_slugs ); + + if ( self::is_branch_preview_active() ) { + return ''; + } + + return $shortlink; + } + + public static function send_branch_preview_headers() { + if ( ! self::is_branch_preview_active() || headers_sent() ) { + return; + } + + header( 'Cache-Control: no-store, no-cache, must-revalidate, max-age=0' ); + header( 'X-Push-MD-Preview-Branch: ' . self::$active_preview_branch ); + } + + public static function render_branch_preview_notice() { + if ( ! self::is_branch_preview_active() ) { + return; + } + ?> +
+ +
+ get_node( 'site-name' ) + ? 'site-name' + : false; + $wp_admin_bar->add_node( + array( + 'id' => 'push-md-branch-switcher', + 'parent' => $parent_id, + 'title' => esc_html__( 'PushMD Branch', 'push-md' ), + ) + ); + + $wp_admin_bar->add_node( + array( + 'id' => 'push-md-branch-live', + 'parent' => 'push-md-branch-switcher', + 'title' => null === self::$active_preview_branch + ? esc_html__( 'Live site (active)', 'push-md' ) + : esc_html__( 'Live site', 'push-md' ), + 'href' => self::get_admin_bar_live_url(), + ) + ); + + foreach ( $branches as $branch_name => $branch ) { + if ( ! is_array( $branch ) || ! self::is_valid_preview_branch_name( $branch_name ) ) { + continue; + } + + $title = $branch_name === self::$active_preview_branch + ? sprintf( + /* translators: %s: preview branch name. */ + __( '%s (active)', 'push-md' ), + $branch_name + ) + : $branch_name; + $wp_admin_bar->add_node( + array( + 'id' => 'push-md-branch-' . md5( $branch_name ), + 'parent' => 'push-md-branch-switcher', + 'title' => esc_html( $title ), + 'href' => self::get_admin_bar_branch_url( $branch_name ), + ) + ); + } + } + + private static function get_admin_bar_live_url() { + return remove_query_arg( self::BRANCH_QUERY_PARAM, self::get_admin_bar_base_url() ); + } + + private static function get_admin_bar_branch_url( $branch_name ) { + return add_query_arg( self::BRANCH_QUERY_PARAM, $branch_name, self::get_admin_bar_base_url() ); + } + + private static function get_admin_bar_base_url() { + if ( is_admin() ) { + return home_url( '/' ); + } + + $request_uri = isset( $_SERVER['REQUEST_URI'] ) + ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) + : '/'; + + return home_url( $request_uri ); + } + + private static function is_branch_preview_active() { + return null !== self::$active_preview_branch && is_array( self::$active_preview_files ); + } + + private static function is_branch_preview_path_changed( $path ) { + return isset( self::$active_preview_changed_paths[ $path ] ); + } + + private static function calculate_preview_changed_paths( $base_files, $preview_files ) { + return self::calculate_repository_changed_paths( $base_files, $preview_files ); + } + + private static function calculate_repository_changed_paths( $base_files, $changed_files ) { + $changed_paths = array(); + foreach ( $changed_files as $path => $entry ) { + if ( ! isset( $base_files[ $path ] ) || ! self::repository_entries_match( $base_files[ $path ], $entry ) ) { + $changed_paths[ $path ] = true; + } + } + foreach ( array_keys( $base_files ) as $path ) { + if ( ! isset( $changed_files[ $path ] ) ) { + $changed_paths[ $path ] = true; + } + } + + return $changed_paths; + } + + private static function preview_request_path_from_query( $query ) { + $query_vars = isset( $query->query_vars ) && is_array( $query->query_vars ) ? $query->query_vars : array(); + + if ( ! empty( $query_vars['pagename'] ) ) { + $page_path = trim( (string) $query_vars['pagename'], '/' ); + if ( '' !== $page_path ) { + return 'page/' . $page_path . '.md'; + } + } + if ( ! empty( $query_vars['name'] ) ) { + $post_slug = trim( (string) $query_vars['name'], '/' ); + if ( '' !== $post_slug ) { + return 'post/' . $post_slug . '.md'; + } + } + + global $wp; + $request_path = isset( $wp->request ) ? trim( (string) $wp->request, '/' ) : ''; + if ( '' === $request_path ) { + return ''; + } + + $page_path = 'page/' . $request_path . '.md'; + if ( isset( self::$active_preview_files[ $page_path ] ) ) { + return $page_path; + } + + $post_path = 'post/' . basename( $request_path ) . '.md'; + if ( false === strpos( $request_path, '/' ) && isset( self::$active_preview_files[ $post_path ] ) ) { + return $post_path; + } + + return ''; + } + + private static function preview_post_from_entry( $path, $entry, $existing_post ) { + if ( TreeEntry::FILE_MODE_SYMBOLIC_LINK === $entry['mode'] ) { + return null; + } + + $post_type = self::path_to_post_type( $path ); + if ( self::is_raw_block_post_type( $post_type ) || 'wp_global_styles' === $post_type ) { + return null; + } + + if ( 'wp_guideline' === $post_type ) { + $metadata = array(); + $block_markup = $entry['content']; + if ( self::is_guideline_skill_path( $path ) ) { + $skill = self::split_guideline_skill_markdown( $entry['content'] ); + $metadata = $skill['metadata']; + $block_markup = $skill['content']; + } + } else { + self::assert_markdown_front_matter_is_closed( $entry['content'] ); + $consumer = new MarkdownConsumer( $entry['content'] ); + $result = $consumer->consume(); + $block_markup = $result->get_block_markup(); + $metadata = array(); + foreach ( $result->get_all_metadata() as $key => $value ) { + $metadata[ $key ] = is_array( $value ) ? reset( $value ) : $value; + } + $metadata = self::normalize_supported_frontmatter( + $metadata, + array( 'id', 'title', 'date', 'status', 'description' ) + ); + } + + $slug = self::path_to_slug( $path ); + $post = $existing_post ? clone $existing_post : self::create_virtual_preview_post( $path, $post_type, $slug ); + if ( ! $post ) { + return null; + } + + $post->post_content = $block_markup; + $post->post_title = isset( $metadata['title'] ) ? $metadata['title'] : ( $post->post_title ? $post->post_title : ucwords( str_replace( '-', ' ', $slug ) ) ); + $post->post_excerpt = isset( $metadata['description'] ) ? $metadata['description'] : $post->post_excerpt; + if ( isset( $metadata['status'] ) ) { + $post->post_status = self::normalize_frontmatter_status( $metadata['status'] ); + } + $post_date_gmt = self::frontmatter_date_to_mysql_gmt( $metadata ); + if ( '' !== $post_date_gmt ) { + $post->post_date_gmt = $post_date_gmt; + $post->post_date = get_date_from_gmt( $post_date_gmt ); + } + + return $post; + } + + private static function preview_path_maps_to_existing_post( $path, $entry ) { + if ( TreeEntry::FILE_MODE_SYMBOLIC_LINK === $entry['mode'] ) { + return false; + } + + $post_type = self::path_to_post_type( $path ); + if ( ! in_array( $post_type, array( 'post', 'page' ), true ) ) { + return false; + } + + self::assert_markdown_front_matter_is_closed( $entry['content'] ); + $metadata = self::parse_markdown_metadata( $entry['content'] ); + + return (bool) self::find_post_id_by_path_metadata( $path, $metadata, false ); + } + + private static function create_virtual_preview_post( $path, $post_type, $slug ) { + if ( ! class_exists( 'WP_Post' ) ) { + return null; + } + + $post_id = -1 * abs( crc32( $path ) ); + $post_obj = (object) array( + 'ID' => $post_id, + 'post_author' => get_current_user_id(), + 'post_date' => current_time( 'mysql' ), + 'post_date_gmt' => current_time( 'mysql', true ), + 'post_content' => '', + 'post_title' => ucwords( str_replace( '-', ' ', $slug ) ), + 'post_excerpt' => '', + 'post_status' => 'publish', + 'comment_status' => 'closed', + 'ping_status' => 'closed', + 'post_password' => '', + 'post_name' => $slug, + 'to_ping' => '', + 'pinged' => '', + 'post_modified' => current_time( 'mysql' ), + 'post_modified_gmt' => current_time( 'mysql', true ), + 'post_content_filtered' => '', + 'post_parent' => 0, + 'guid' => self::get_preview_branch_url( self::$active_preview_branch ) . '#' . rawurlencode( $path ), + 'menu_order' => 0, + 'post_type' => $post_type, + 'post_mime_type' => '', + 'comment_count' => 0, + 'filter' => 'raw', + ); + + if ( 'page' === $post_type ) { + $parent_slugs = self::path_to_page_slugs( $path ); + array_pop( $parent_slugs ); + if ( ! empty( $parent_slugs ) ) { + $post_obj->post_parent = -1 * abs( crc32( 'page/' . implode( '/', $parent_slugs ) . '.md' ) ); + } + } + + return new WP_Post( $post_obj ); + } + + private static function preview_raw_block_paths_for_id( $post_type, $id ) { + $id = (string) $id; + $paths = array(); + if ( '' === $id ) { + return $paths; + } + + if ( false !== strpos( $id, '//' ) ) { + list( $theme_slug, $slug ) = explode( '//', $id, 2 ); + if ( '' !== $theme_slug && '' !== $slug ) { + $paths[] = self::build_raw_block_path( $post_type, str_replace( '//', '/', $slug ), $theme_slug ); + } + } + + $paths[] = self::build_raw_block_path( $post_type, str_replace( '//', '/', $id ) ); + + return array_values( array_unique( $paths ) ); + } + + private static function preview_block_template_from_entry( $path, $entry, $existing_template, $requested_id ) { + if ( TreeEntry::FILE_MODE_SYMBOLIC_LINK === $entry['mode'] || ! class_exists( 'WP_Block_Template' ) ) { + return null; + } + + self::assert_raw_block_html_has_no_front_matter( $entry['content'] ); + self::assert_raw_block_html_has_block_markup( $entry['content'] ); + self::assert_block_markup_is_safe( $entry['content'] ); + + $identity = self::path_to_raw_block_identity( $path ); + $template = $existing_template instanceof WP_Block_Template ? clone $existing_template : new WP_Block_Template(); + $theme = $identity['theme']; + if ( '' === $theme && false !== strpos( (string) $requested_id, '//' ) ) { + list( $theme ) = explode( '//', (string) $requested_id, 2 ); + } + if ( '' === $theme && function_exists( 'get_stylesheet' ) && self::is_theme_scoped_raw_block_post_type( $identity['post_type'] ) ) { + $theme = self::sanitize_repository_path_segment( get_stylesheet() ); + } + + $template->id = '' !== $theme && self::is_theme_scoped_raw_block_post_type( $identity['post_type'] ) ? $theme . '//' . $identity['slug'] : $identity['slug']; + $template->theme = $theme; + $template->content = $entry['content']; + $template->slug = $identity['slug']; + $template->source = 'custom'; + $template->type = $identity['post_type']; + $template->title = ucwords( str_replace( '-', ' ', str_replace( '//', '/', $identity['slug'] ) ) ); + $template->status = 'publish'; + $template->has_theme_file = false; + $template->origin = 'custom'; + if ( 'wp_template_part' === $identity['post_type'] && empty( $template->area ) ) { + $template->area = 'uncategorized'; + } + + return $template; + } + public static function handle_rest_request( WP_REST_Request $request ) { $previous_error_handler = set_error_handler( array( __CLASS__, 'throw_on_php_warning' ) ); // phpcs:ignore $git_path = ''; @@ -257,7 +1101,7 @@ public static function handle_rest_request( WP_REST_Request $request ) { $push_header = null; if ( self::is_push_request( $git_path ) ) { - $push_header = self::parse_push_header( $request_body ); + $push_header = self::parse_push_header( $request_body, $repository, $current_head ); if ( false === $push_header ) { return self::build_protocol_error_response( 'git-receive-pack', @@ -270,13 +1114,6 @@ public static function handle_rest_request( WP_REST_Request $request ) { $push_header['error'] ); } - - if ( $push_header['old_oid'] !== $current_head ) { - return self::build_protocol_error_response( - 'git-receive-pack', - 'Push rejected because the remote changed. Pull the latest changes and try again.' - ); - } } $response = new Push_MD_Buffering_Response(); @@ -285,12 +1122,17 @@ public static function handle_rest_request( WP_REST_Request $request ) { if ( self::is_push_request( $git_path ) ) { try { - $push_summary = self::apply_repository_changes_to_wordpress( - $repository, - $push_header['old_oid'], - $push_header['new_oid'] - ); - $response->append_progress_messages( self::format_push_summary_messages( $push_summary ) ); + if ( $push_header['is_preview'] ) { + self::finalize_preview_branch_push( $repository, $push_header ); + $response->append_progress_messages( self::format_preview_branch_push_messages( $push_header, $repository ) ); + } else { + $push_summary = self::apply_repository_changes_to_wordpress( + $repository, + $push_header['old_oid'], + $push_header['new_oid'] + ); + $response->append_progress_messages( self::format_push_summary_messages( $push_summary ) ); + } } catch ( Throwable $exception ) { self::rollback_rejected_push_ref( $repository, $push_header ); @@ -1316,24 +2158,93 @@ public static function repository_identity( GitRepository $repository ) { } private static function apply_repository_changes_to_wordpress( GitRepository $repository, $old_commit, $new_commit ) { + self::validate_repository_changes_for_wordpress( $repository, $old_commit, $new_commit ); + + $push_summary = self::apply_repository_diff_to_wordpress( $repository, $old_commit, $new_commit, false ); + + return $push_summary; + } + + private static function validate_repository_changes_for_wordpress( GitRepository $repository, $old_commit, $new_commit ) { $commit_hashes = self::get_push_commit_hashes( $repository, $old_commit, $new_commit ); - $push_summary = array(); foreach ( $commit_hashes as $commit_hash ) { self::validate_single_commit_content_changes( $repository, $commit_hash ); } - $push_summary = self::apply_repository_diff_to_wordpress( $repository, $old_commit, $new_commit, false ); + self::apply_repository_diff_to_wordpress( $repository, $old_commit, $new_commit, false, true ); + } - return $push_summary; + private static function finalize_preview_branch_push( GitRepository $repository, &$push_header ) { + if ( $push_header['is_delete'] ) { + self::delete_preview_branch_metadata( $push_header['branch_name'] ); + return; + } + + if ( + ! Commit::is_null_hash( $push_header['old_oid'] ) && + ! self::is_commit_ancestor( $repository, $push_header['validation_old_oid'], $push_header['new_oid'] ) + ) { + $current_head = $repository->get_branch_tip( 'refs/heads/' . self::DEFAULT_BRANCH ); + if ( ! self::is_commit_ancestor( $repository, $current_head, $push_header['new_oid'] ) ) { + throw new Exception( 'Push rejected because preview branch replacements must be rebased onto the latest trunk. Fetch trunk, rebase your preview branch, and push again with --force-with-lease.' ); + } + + $push_header['base_oid'] = $current_head; + $push_header['validation_old_oid'] = $current_head; + $push_header['is_replace'] = true; + } + + self::validate_repository_changes_for_wordpress( + $repository, + $push_header['validation_old_oid'], + $push_header['new_oid'] + ); + self::update_preview_branch_metadata( $push_header ); + } + + private static function is_commit_ancestor( GitRepository $repository, $ancestor, $descendant ) { + if ( $ancestor === $descendant || Commit::is_null_hash( $ancestor ) ) { + return true; + } + if ( Commit::is_null_hash( $descendant ) ) { + return false; + } + + try { + $repository->get_commits_range( + $descendant, + $ancestor, + array( + 'include_ancestor' => false, + ) + ); + + return true; + } catch ( GitException $exception ) { + return false; + } } private static function rollback_rejected_push_ref( GitRepository $repository, $push_header ) { - $branch_name = 'refs/heads/' . self::DEFAULT_BRANCH; + $branch_name = isset( $push_header['ref_name'] ) ? $push_header['ref_name'] : 'refs/heads/' . self::DEFAULT_BRANCH; try { - if ( $push_header['new_oid'] === $repository->get_branch_tip( $branch_name ) ) { + if ( $push_header['is_delete'] ) { $repository->set_branch_tip( $branch_name, $push_header['old_oid'] ); + return; + } + + if ( ! $repository->branch_exists( $branch_name ) ) { + return; + } + + if ( $push_header['new_oid'] === $repository->get_branch_tip( $branch_name ) ) { + if ( Commit::is_null_hash( $push_header['old_oid'] ) ) { + $repository->delete_branch( $branch_name ); + } else { + $repository->set_branch_tip( $branch_name, $push_header['old_oid'] ); + } } } catch ( Throwable $exception ) { // Preserve the original rejection reason for the Git client. @@ -1377,7 +2288,7 @@ private static function validate_single_commit_content_changes( GitRepository $r } } - private static function apply_repository_diff_to_wordpress( GitRepository $repository, $old_commit, $new_commit, $skip_modified_checks ) { + private static function apply_repository_diff_to_wordpress( GitRepository $repository, $old_commit, $new_commit, $skip_modified_checks, $dry_run = false ) { $old_files = Commit::is_null_hash( $old_commit ) ? array() : self::read_repository_entries_from_commit( $repository, $old_commit ); @@ -1452,6 +2363,10 @@ private static function apply_repository_diff_to_wordpress( GitRepository $repos ); } + if ( $dry_run ) { + return array(); + } + foreach ( $upsert_plans as $plan ) { $applied = self::upsert_post_from_markdown( $plan['path'], @@ -2154,10 +3069,519 @@ private static function format_push_summary_messages( $push_summary ) { return $messages; } + private static function format_preview_branch_push_messages( $push_header, ?GitRepository $repository = null ) { + if ( ! empty( $push_header['is_delete'] ) ) { + return array( + sprintf( + 'Push MD deleted preview branch %s.', + self::sanitize_push_summary_text( $push_header['branch_name'] ) + ), + ); + } + + $messages = array(); + if ( ! empty( $push_header['is_replace'] ) ) { + $messages[] = sprintf( + 'Push MD replaced preview branch %s after rebase without changing WordPress content.', + self::sanitize_push_summary_text( $push_header['branch_name'] ) + ); + $messages[] = sprintf( + 'Preview base reset to trunk %s.', + self::sanitize_push_summary_text( substr( $push_header['base_oid'], 0, 12 ) ) + ); + } else { + $messages[] = sprintf( + 'Push MD stored preview branch %s without changing WordPress content.', + self::sanitize_push_summary_text( $push_header['branch_name'] ) + ); + } + $messages[] = sprintf( + 'Preview: %s', + self::sanitize_push_summary_text( self::get_preview_branch_url( $push_header['branch_name'] ) ) + ); + + $changed_urls = array(); + if ( $repository ) { + try { + $changed_urls = self::get_preview_branch_changed_url_items( $repository, $push_header ); + } catch ( Throwable $exception ) { + $changed_urls = array(); + } + } + + if ( ! empty( $changed_urls ) ) { + $messages[] = 'Changed preview URLs:'; + foreach ( $changed_urls as $changed_url ) { + $messages[] = sprintf( + '- %s %s: %s', + ucfirst( $changed_url['action'] ), + self::sanitize_push_summary_text( $changed_url['path'] ), + self::sanitize_push_summary_text( $changed_url['url'] ) + ); + } + } + + $messages[] = 'Merge this branch from the Push MD admin REST API or Tools page when ready.'; + + return $messages; + } + + private static function get_preview_branch_changed_url_items( GitRepository $repository, $push_header ) { + $branch_name = isset( $push_header['branch_name'] ) ? $push_header['branch_name'] : ''; + $new_oid = isset( $push_header['new_oid'] ) ? $push_header['new_oid'] : ''; + if ( '' === $branch_name || '' === $new_oid || Commit::is_null_hash( $new_oid ) ) { + return array(); + } + + $base_files = array(); + $base_oid = isset( $push_header['base_oid'] ) ? $push_header['base_oid'] : ''; + if ( is_string( $base_oid ) && '' !== $base_oid && ! Commit::is_null_hash( $base_oid ) && $repository->has_object( $base_oid ) ) { + $base_files = self::read_repository_entries_from_commit( $repository, $base_oid ); + } + + $changed_files = self::read_repository_entries_from_commit( $repository, $new_oid ); + $changed_paths = self::calculate_repository_changed_paths( $base_files, $changed_files ); + ksort( $changed_paths ); + + $items = array(); + foreach ( array_keys( $changed_paths ) as $path ) { + $entry = isset( $changed_files[ $path ] ) + ? $changed_files[ $path ] + : ( isset( $base_files[ $path ] ) ? $base_files[ $path ] : null ); + $items[] = array( + 'action' => isset( $changed_files[ $path ] ) ? ( isset( $base_files[ $path ] ) ? 'updated' : 'created' ) : 'deleted', + 'path' => $path, + 'url' => self::get_preview_url_for_repository_path( $path, $entry, $branch_name ), + ); + } + + return $items; + } + + private static function get_preview_url_for_repository_path( $path, $entry, $branch_name ) { + $branch_url = self::get_preview_branch_url( $branch_name ); + if ( ! is_array( $entry ) || TreeEntry::FILE_MODE_SYMBOLIC_LINK === $entry['mode'] ) { + return $branch_url; + } + + try { + $post_type = self::path_to_post_type( $path ); + if ( ! in_array( $post_type, self::$supported_post_types, true ) ) { + return $branch_url; + } + + self::assert_markdown_front_matter_is_closed( $entry['content'] ); + $metadata = self::parse_markdown_metadata( $entry['content'] ); + $post_id = self::find_post_id_by_path_metadata( $path, $metadata, false ); + if ( $post_id ) { + $url = get_permalink( $post_id ); + } elseif ( 'page' === $post_type ) { + $url = self::get_preview_page_permalink_for_path( $path ); + } else { + $post = self::preview_post_from_entry( $path, $entry, null ); + $url = $post ? self::get_preview_post_permalink( $post ) : ''; + } + if ( $url ) { + return add_query_arg( self::BRANCH_QUERY_PARAM, $branch_name, $url ); + } + } catch ( Throwable $exception ) { + return $branch_url; + } + + return $branch_url; + } + + private static function get_preview_page_permalink_for_path( $path ) { + $page_path = implode( '/', self::path_to_page_slugs( $path ) ); + if ( '' === (string) get_option( 'permalink_structure' ) ) { + return add_query_arg( 'pagename', $page_path, home_url( '/' ) ); + } + + return home_url( user_trailingslashit( '/' . $page_path, 'page' ) ); + } + + private static function get_preview_post_permalink( WP_Post $post ) { + $structure = (string) get_option( 'permalink_structure' ); + if ( '' === $structure || ( false !== strpos( $structure, '%post_id%' ) && false === strpos( $structure, '%postname%' ) ) ) { + return add_query_arg( 'name', $post->post_name, home_url( '/' ) ); + } + + $timestamp = self::preview_post_sort_timestamp( $post ); + if ( ! $timestamp ) { + $timestamp = time(); + } + + $author = get_userdata( $post->post_author ); + $tokens = array( + '%year%' => gmdate( 'Y', $timestamp ), + '%monthnum%' => gmdate( 'm', $timestamp ), + '%day%' => gmdate( 'd', $timestamp ), + '%hour%' => gmdate( 'H', $timestamp ), + '%minute%' => gmdate( 'i', $timestamp ), + '%second%' => gmdate( 's', $timestamp ), + '%postname%' => $post->post_name, + '%category%' => 'uncategorized', + '%author%' => $author ? $author->user_nicename : '', + ); + + return home_url( user_trailingslashit( strtr( $structure, $tokens ), 'single' ) ); + } + private static function sanitize_push_summary_text( $text ) { return str_replace( array( "\r", "\n" ), ' ', (string) $text ); } + public static function get_preview_branch_url( $branch_name ) { + return add_query_arg( + self::BRANCH_QUERY_PARAM, + $branch_name, + home_url( '/' ) + ); + } + + public static function get_preview_branches() { + $branches = get_option( self::BRANCH_PREVIEWS_OPTION, array() ); + + return is_array( $branches ) ? $branches : array(); + } + + private static function get_active_preview_branches() { + $branches = self::get_preview_branches(); + $active = array(); + + foreach ( $branches as $branch_name => $branch ) { + if ( ! is_array( $branch ) || ! self::is_valid_preview_branch_name( $branch_name ) || self::is_preview_branch_merged( $branch ) ) { + continue; + } + + $active[ $branch_name ] = $branch; + } + + return $active; + } + + private static function is_preview_branch_merged( $branch ) { + return is_array( $branch ) && ! empty( $branch['merged_at'] ); + } + + private static function update_preview_branch_metadata( $push_header ) { + $branches = self::get_preview_branches(); + $branch_name = $push_header['branch_name']; + $existing = isset( $branches[ $branch_name ] ) && is_array( $branches[ $branch_name ] ) + ? $branches[ $branch_name ] + : array(); + $created_at = isset( $existing['created_at'] ) && ! self::is_preview_branch_merged( $existing ) ? intval( $existing['created_at'] ) : time(); + + $branches[ $branch_name ] = array( + 'branch' => $branch_name, + 'ref' => $push_header['ref_name'], + 'owner' => get_current_user_id(), + 'base_oid' => $push_header['base_oid'], + 'tip_oid' => $push_header['new_oid'], + 'url' => self::get_preview_branch_url( $branch_name ), + 'created_at' => $created_at, + 'updated_at' => time(), + ); + + update_option( self::BRANCH_PREVIEWS_OPTION, $branches, false ); + } + + private static function delete_preview_branch_metadata( $branch_name ) { + $branches = self::get_preview_branches(); + unset( $branches[ $branch_name ] ); + + update_option( self::BRANCH_PREVIEWS_OPTION, $branches, false ); + } + + public static function list_preview_branches() { + $repository = self::open_repository(); + $branches = self::get_preview_branches(); + $response = array(); + + foreach ( $branches as $branch_name => $branch ) { + if ( ! is_array( $branch ) || ! self::is_valid_preview_branch_name( $branch_name ) ) { + continue; + } + + $ref_name = 'refs/heads/' . $branch_name; + $is_merged = self::is_preview_branch_merged( $branch ); + if ( ! $is_merged && ! $repository->branch_exists( $ref_name ) ) { + continue; + } + + $branch['status'] = $is_merged ? 'merged' : 'active'; + $branch['active'] = ! $is_merged; + if ( ! $is_merged ) { + $branch['tip_oid'] = $repository->get_branch_tip( $ref_name ); + $branch['url'] = self::get_preview_branch_url( $branch_name ); + try { + $branch['changed_urls'] = self::get_preview_branch_changed_url_items( + $repository, + array( + 'branch_name' => $branch_name, + 'base_oid' => isset( $branch['base_oid'] ) ? $branch['base_oid'] : '', + 'new_oid' => $branch['tip_oid'], + ) + ); + } catch ( Throwable $exception ) { + $branch['changed_urls'] = array(); + } + } elseif ( ! isset( $branch['changed_urls'] ) || ! is_array( $branch['changed_urls'] ) ) { + $branch['changed_urls'] = array(); + } + $response[] = $branch; + } + + return $response; + } + + public static function merge_preview_branch( $branch_name ) { + if ( ! self::is_valid_preview_branch_name( $branch_name ) ) { + throw new Exception( 'Invalid preview branch name.' ); + } + + $repository = self::open_repository(); + self::sync_repository_from_wordpress( $repository ); + + $ref_name = 'refs/heads/' . $branch_name; + if ( ! $repository->branch_exists( $ref_name ) ) { + throw new Exception( 'Preview branch not found.' ); + } + + $current_head = $repository->get_branch_tip( 'refs/heads/' . self::DEFAULT_BRANCH ); + $branch_tip = $repository->get_branch_tip( $ref_name ); + $branches = self::get_preview_branches(); + $branch_metadata = isset( $branches[ $branch_name ] ) && is_array( $branches[ $branch_name ] ) + ? $branches[ $branch_name ] + : array(); + if ( self::is_preview_branch_merged( $branch_metadata ) ) { + throw new Exception( 'Preview branch has already been merged.' ); + } + + $base_oid = isset( $branch_metadata['base_oid'] ) && is_string( $branch_metadata['base_oid'] ) + ? $branch_metadata['base_oid'] + : $current_head; + $merged_oid = $branch_tip; + $changed_urls = array(); + try { + $changed_urls = self::get_preview_branch_changed_url_items( + $repository, + array( + 'branch_name' => $branch_name, + 'base_oid' => $base_oid, + 'new_oid' => $branch_tip, + ) + ); + } catch ( Throwable $exception ) { + $changed_urls = array(); + } + + $can_fast_forward = true; + $range_exception = null; + try { + $repository->get_commits_range( + $branch_tip, + $current_head, + array( + 'include_ancestor' => false, + ) + ); + } catch ( GitException $exception ) { + $can_fast_forward = false; + $range_exception = $exception; + } + + if ( $can_fast_forward ) { + self::validate_repository_changes_for_wordpress( $repository, $current_head, $branch_tip ); + $push_summary = self::apply_repository_diff_to_wordpress( $repository, $current_head, $branch_tip, false ); + $repository->set_branch_tip( 'refs/heads/' . self::DEFAULT_BRANCH, $branch_tip ); + } else { + if ( ! is_string( $base_oid ) || '' === $base_oid || Commit::is_null_hash( $base_oid ) || ! $repository->has_object( $base_oid ) ) { + throw $range_exception; + } + + self::assert_preview_branch_merge_has_no_overlapping_changes( $repository, $base_oid, $current_head, $branch_tip ); + self::validate_repository_changes_for_wordpress( $repository, $base_oid, $branch_tip ); + $merged_oid = self::replay_preview_branch_commits_onto_trunk( $repository, $branch_name, $base_oid, $branch_tip, $current_head ); + self::validate_repository_changes_for_wordpress( $repository, $current_head, $merged_oid ); + $push_summary = self::apply_repository_diff_to_wordpress( $repository, $current_head, $merged_oid, false ); + $repository->set_branch_tip( 'refs/heads/' . self::DEFAULT_BRANCH, $merged_oid ); + } + + $merged_at = time(); + $archived_branch = $branch_metadata; + $archived_branch['branch'] = $branch_name; + $archived_branch['ref'] = $ref_name; + if ( ! isset( $archived_branch['owner'] ) ) { + $archived_branch['owner'] = get_current_user_id(); + } + if ( ! isset( $archived_branch['created_at'] ) ) { + $archived_branch['created_at'] = $merged_at; + } + $archived_branch['updated_at'] = isset( $archived_branch['updated_at'] ) ? intval( $archived_branch['updated_at'] ) : $merged_at; + $archived_branch['base_oid'] = $base_oid; + $archived_branch['tip_oid'] = $branch_tip; + $archived_branch['merged_oid'] = $merged_oid; + $archived_branch['merged_at'] = $merged_at; + $archived_branch['merged_by'] = get_current_user_id(); + $archived_branch['url'] = self::get_preview_branch_url( $branch_name ); + $archived_branch['changed_urls'] = $changed_urls; + $archived_branch['ref_deleted_at'] = 0; + $branches[ $branch_name ] = $archived_branch; + update_option( self::BRANCH_PREVIEWS_OPTION, $branches, false ); + + $branch_deleted = false; + try { + if ( $repository->branch_exists( $ref_name ) ) { + $repository->delete_branch( $ref_name ); + $branch_deleted = true; + } + } catch ( Throwable $exception ) { + $branch_deleted = false; + } + + if ( $branch_deleted ) { + $branches = self::get_preview_branches(); + $branches[ $branch_name ]['ref_deleted_at'] = time(); + $branches[ $branch_name ]['ref_delete_failed'] = false; + update_option( self::BRANCH_PREVIEWS_OPTION, $branches, false ); + } + + return array( + 'branch' => $branch_name, + 'tip_oid' => $branch_tip, + 'merged_oid' => $merged_oid, + 'branch_deleted' => $branch_deleted, + 'changes' => $push_summary, + ); + } + + private static function replay_preview_branch_commits_onto_trunk( GitRepository $repository, $branch_name, $base_oid, $branch_tip, $current_head ) { + $commit_hashes = $repository->get_commits_range( + $branch_tip, + $base_oid, + array( + 'include_ancestor' => false, + ) + ); + $commit_hashes = array_reverse( $commit_hashes ); + + $temporary_ref = 'refs/heads/push-md/merge-preview-' . md5( $branch_name . $branch_tip . $current_head . microtime( true ) ); + $previous_head = $repository->get_branch_tip( 'HEAD', array( 'follow_symrefs' => false ) ); + $repository->set_branch_tip( $temporary_ref, $current_head ); + + try { + $repository->set_branch_tip( 'HEAD', 'ref: ' . $temporary_ref . "\n" ); + $replayed_tip = $current_head; + + foreach ( $commit_hashes as $commit_hash ) { + $commit = $repository->read_object( $commit_hash )->as_commit(); + $parent_hash = empty( $commit->parents ) ? Commit::NULL_HASH : $commit->get_first_parent_hash(); + $old_files = Commit::is_null_hash( $parent_hash ) + ? array() + : self::read_repository_entries_from_commit( $repository, $parent_hash ); + $new_files = self::read_repository_entries_from_commit( $repository, $commit_hash ); + $head_files = self::read_repository_entries_from_commit( $repository, $replayed_tip ); + $delta = self::calculate_preview_branch_replay_delta( $head_files, $old_files, $new_files ); + + if ( empty( $delta['updates'] ) && empty( $delta['symlinks'] ) && empty( $delta['deletes'] ) ) { + continue; + } + + $replayed_tip = $repository->commit( + array( + 'updates' => $delta['updates'], + 'create_symlinks' => $delta['symlinks'], + 'deletes' => $delta['deletes'], + 'commit' => array( + 'message' => $commit->message, + 'author' => $commit->author, + 'author_date' => $commit->author_date, + 'committer' => $commit->committer, + 'committer_date' => $commit->committer_date, + ), + ) + ); + } + + return $replayed_tip; + } finally { + $repository->set_branch_tip( 'HEAD', $previous_head ); + if ( $repository->branch_exists( $temporary_ref ) ) { + $repository->delete_branch( $temporary_ref ); + } + } + } + + private static function calculate_preview_branch_replay_delta( $head_files, $old_files, $new_files ) { + $branch_delta = self::calculate_file_delta( $old_files, $new_files ); + $updates = array(); + $symlinks = array(); + $deletes = array(); + + foreach ( $branch_delta['updates'] as $path => $content ) { + $entry = array( + 'mode' => TreeEntry::FILE_MODE_REGULAR_NON_EXECUTABLE, + 'content' => $content, + ); + if ( isset( $head_files[ $path ] ) && self::repository_entries_match( $head_files[ $path ], $entry ) ) { + continue; + } + $updates[ $path ] = $content; + } + + foreach ( $branch_delta['symlinks'] as $path => $target ) { + $entry = array( + 'mode' => TreeEntry::FILE_MODE_SYMBOLIC_LINK, + 'content' => $target, + ); + if ( isset( $head_files[ $path ] ) && self::repository_entries_match( $head_files[ $path ], $entry ) ) { + continue; + } + $symlinks[ $path ] = $target; + } + + foreach ( $branch_delta['deletes'] as $path ) { + if ( isset( $head_files[ $path ] ) ) { + $deletes[] = $path; + } + } + + return array( + 'updates' => $updates, + 'symlinks' => $symlinks, + 'deletes' => $deletes, + ); + } + + private static function assert_preview_branch_merge_has_no_overlapping_changes( GitRepository $repository, $base_oid, $current_head, $branch_tip ) { + $base_files = Commit::is_null_hash( $base_oid ) + ? array() + : self::read_repository_entries_from_commit( $repository, $base_oid ); + $current_files = self::read_repository_entries_from_commit( $repository, $current_head ); + $branch_files = self::read_repository_entries_from_commit( $repository, $branch_tip ); + + $current_changed_paths = self::calculate_repository_changed_paths( $base_files, $current_files ); + $branch_changed_paths = self::calculate_repository_changed_paths( $base_files, $branch_files ); + + foreach ( array_keys( $branch_changed_paths ) as $path ) { + if ( ! isset( $current_changed_paths[ $path ] ) ) { + continue; + } + + $current_entry = isset( $current_files[ $path ] ) ? $current_files[ $path ] : null; + $branch_entry = isset( $branch_files[ $path ] ) ? $branch_files[ $path ] : null; + if ( $current_entry && $branch_entry && self::repository_entries_match( $current_entry, $branch_entry ) ) { + continue; + } + if ( ! $current_entry && ! $branch_entry ) { + continue; + } + + throw new Exception( 'Push rejected because WordPress content changed since the preview branch was created. Pull the latest changes and recreate the preview branch.' ); + } + } + private static function get_repository_identity( GitRepository $repository ) { return $repository->get_config_value( 'user.name' ) . ' <' . $repository->get_config_value( 'user.email' ) . '>'; } @@ -2984,7 +4408,7 @@ private static function collect_tree_entries( GitRepository $repository, $tree_h } } - private static function parse_push_header( $request_bytes ) { + private static function parse_push_header( $request_bytes, GitRepository $repository, $current_head ) { $commands = self::parse_push_commands( $request_bytes ); if ( empty( $commands ) ) { return false; @@ -2996,23 +4420,114 @@ private static function parse_push_header( $request_bytes ) { } $command = $commands[0]; - if ( 'refs/heads/' . self::DEFAULT_BRANCH !== $command['ref'] ) { + if ( 0 !== strpos( $command['ref'], 'refs/heads/' ) ) { + return array( + 'error' => 'Push rejected because Push MD only accepts branch refs.', + ); + } + + $branch_name = substr( $command['ref'], strlen( 'refs/heads/' ) ); + if ( self::DEFAULT_BRANCH === $branch_name ) { + if ( Commit::is_null_hash( $command['new_oid'] ) ) { + return array( + 'error' => 'Push rejected because deleting trunk is not supported.', + ); + } + if ( $command['old_oid'] !== $current_head ) { + return array( + 'error' => 'Push rejected because the remote changed. Pull the latest changes and try again.', + ); + } + return array( - 'error' => 'Push rejected because Push MD only accepts pushes to trunk.', + 'old_oid' => $command['old_oid'], + 'new_oid' => $command['new_oid'], + 'ref_name' => $command['ref'], + 'branch_name' => $branch_name, + 'is_preview' => false, + 'is_delete' => false, + 'base_oid' => $current_head, + 'validation_old_oid' => $command['old_oid'], ); } - if ( Commit::is_null_hash( $command['new_oid'] ) ) { + + if ( ! self::is_valid_preview_branch_name( $branch_name ) ) { + return array( + 'error' => 'Push rejected because the preview branch name is not supported.', + ); + } + + $is_delete = Commit::is_null_hash( $command['new_oid'] ); + $branch_exists = $repository->branch_exists( $command['ref'] ); + $current_branch = $branch_exists ? $repository->get_branch_tip( $command['ref'] ) : Commit::NULL_HASH; + + if ( $command['old_oid'] !== $current_branch ) { return array( - 'error' => 'Push rejected because deleting trunk is not supported.', + 'error' => 'Push rejected because the preview branch changed. Fetch the latest branch state and try again.', ); } + $metadata = self::get_preview_branches(); + $existing_metadata = isset( $metadata[ $branch_name ] ) && is_array( $metadata[ $branch_name ] ) ? $metadata[ $branch_name ] : array(); + $base_oid = ! self::is_preview_branch_merged( $existing_metadata ) && isset( $existing_metadata['base_oid'] ) && is_string( $existing_metadata['base_oid'] ) + ? $existing_metadata['base_oid'] + : $current_head; + $validation_old_oid = Commit::is_null_hash( $command['old_oid'] ) ? $base_oid : $command['old_oid']; + return array( - 'old_oid' => $command['old_oid'], - 'new_oid' => $command['new_oid'], + 'old_oid' => $command['old_oid'], + 'new_oid' => $command['new_oid'], + 'ref_name' => $command['ref'], + 'branch_name' => $branch_name, + 'is_preview' => true, + 'is_delete' => $is_delete, + 'base_oid' => $base_oid, + 'validation_old_oid' => $validation_old_oid, ); } + private static function is_valid_preview_branch_name( $branch_name ) { + if ( ! is_string( $branch_name ) || '' === $branch_name ) { + return false; + } + if ( self::DEFAULT_BRANCH === $branch_name || '_push_md_seed' === $branch_name || 'HEAD' === strtoupper( $branch_name ) ) { + return false; + } + if ( 0 === strpos( $branch_name, 'push-md/' ) ) { + return false; + } + if ( + false !== strpos( $branch_name, '..' ) || + false !== strpos( $branch_name, '@{' ) || + false !== strpos( $branch_name, '//' ) || + false !== strpos( $branch_name, '\\' ) || + false !== strpos( $branch_name, ' ' ) + ) { + return false; + } + if ( '/' === $branch_name[0] || '/' === substr( $branch_name, -1 ) || '.' === substr( $branch_name, -1 ) ) { + return false; + } + if ( preg_match( '/[\x00-\x20~^:?*\[\]]/', $branch_name ) ) { + return false; + } + + $segments = explode( '/', $branch_name ); + foreach ( $segments as $segment ) { + if ( + '' === $segment || + '.' === $segment || + '..' === $segment || + '.' === $segment[0] || + '.lock' === substr( $segment, -5 ) + ) { + return false; + } + } + + return true; + } + private static function parse_push_commands( $request_bytes ) { $commands = array(); $offset = 0; diff --git a/plugins/push-md/docker-demo/README.md b/plugins/push-md/docker-demo/README.md index 504a84ec..57c3a133 100644 --- a/plugins/push-md/docker-demo/README.md +++ b/plugins/push-md/docker-demo/README.md @@ -1,8 +1,10 @@ # Push MD demo (Docker) -A self-contained WordPress install pre-loaded with 120 posts and the -Push MD plugin **deactivated**, so you can press "Activate" yourself -and watch the async seeder build the initial import. +A self-contained WordPress install pre-loaded with the same seeded +Hello World post, Sample Page, custom `blog-home` template, and +bulk posts used by the Push MD E2E workflow. The Push MD plugin is +left **deactivated**, so you can press "Activate" yourself and watch +the async seeder build the initial import. ## Run it diff --git a/plugins/push-md/docker-demo/init.sh b/plugins/push-md/docker-demo/init.sh index d827e206..048d4d8a 100755 --- a/plugins/push-md/docker-demo/init.sh +++ b/plugins/push-md/docker-demo/init.sh @@ -15,13 +15,18 @@ set -euo pipefail cd /var/www/html -for _ in $(seq 1 60); do - if [ -f wp-load.php ]; then +for _ in $(seq 1 120); do + if [ -f wp-load.php ] && [ -f wp-config.php ]; then break fi sleep 1 done +if [ ! -f wp-load.php ] || [ ! -f wp-config.php ]; then + echo "WordPress files were not ready in /var/www/html." >&2 + exit 1 +fi + if ! wp core is-installed 2>/dev/null; then wp core install \ --url=http://${PUSH_MD_DEMO_HOST:-localhost:8090} \ @@ -32,6 +37,22 @@ if ! wp core is-installed 2>/dev/null; then --skip-email wp option update permalink_structure '/%postname%/' wp rewrite flush --hard + wp option update gutenberg-experiments '{"gutenberg-guidelines":true}' --format=json + + wp post update 1 \ + --post_title='Hello World' \ + --post_content='

Hello from WordPress

' \ + --post_status=publish + wp post update 2 \ + --post_title='Sample Page' \ + --post_content='

Page from WordPress

' \ + --post_status=publish + wp post create \ + --post_type=wp_template \ + --post_name='blog-home' \ + --post_title='Blog Home' \ + --post_content='

Template from WordPress

' \ + --post_status=publish wp post generate \ --count=120 \ diff --git a/plugins/push-md/docs/prd.md b/plugins/push-md/docs/prd.md index a337ed8b..568c67da 100644 --- a/plugins/push-md/docs/prd.md +++ b/plugins/push-md/docs/prd.md @@ -91,14 +91,22 @@ partial writes, lossy conversion, ambiguous identity, or silent normalization. - Updating or trashing content requires permission to edit or delete that object. - Publishing, scheduling, or making content private requires the corresponding WordPress capabilities. +- Branch preview URLs require an authenticated administrator. Preview access is + not tokenized in v1. +- Merging a branch preview requires an authenticated Push MD admin action. ## 4. Git Endpoint And Protocol Scope - The canonical remote URL is `/wp-json/git/v1/md.git`. -- The repository branch is `trunk`. +- The primary publishing branch is `trunk`. - Pushes may update only one ref at a time. -- Pushes to branches other than `trunk` are rejected. +- Pushes to `trunk` are publishing pushes. They validate the pushed range and, + on success, apply supported content changes to WordPress. +- Pushes to non-`trunk` branches are branch preview pushes. They validate the + pushed range and store Git objects, refs, and preview metadata, but they must + not mutate WordPress content. - Deleting `trunk` is rejected. +- Deleting a preview branch may remove only the Git ref and preview metadata. - Stale pushes are rejected when WordPress content changed after the client last fetched the remote state. - If a push is rejected, WordPress content must remain unchanged. @@ -240,32 +248,142 @@ Push validation rejects: files. - Creates, edits, or deletes of generated guidance symlinks. - Parent page deletes while child page files still exist. -- Multi-ref pushes, non-`trunk` pushes, and `trunk` deletion. +- Multi-ref pushes, invalid or protected branch names, and `trunk` deletion. - Pushes based on a stale remote state. Errors should be actionable from a Git client and should explain whether the user needs to pull/rebase, fix a file, change permissions, or avoid an unsupported operation. -## 9. Conflict Model - -Before accepting a push, Push MD refreshes the remote view from WordPress. If -WordPress changed since the client's base commit, the push is rejected. The user -or agent must pull, rebase or merge locally, resolve conflicts, and push again. +## 9. Branch Previews + +Branch previews let a user push a branch, view the site as that branch would +render, and merge it only when ready. They must be additive to the current +`trunk` workflow: existing `trunk` clone, pull, push, import, revision, and +permission behavior must not change. + +### 9.1 Branch Storage + +- Preview branches live in the same wpdb-backed Git object store as `trunk`, + using the existing `{$wpdb->prefix}push_md_files` and + `{$wpdb->prefix}push_md_directory_entries` tables. +- A branch push may create or update `refs/heads/{branch}` and write Git + objects needed by that ref. +- A branch push may store lightweight preview metadata, such as branch ref, + owner user ID, base commit, tip commit, created time, and last pushed time. +- Branch pushes must not call WordPress content mutation APIs, including + `wp_insert_post()`, `wp_update_post()`, `wp_trash_post()`, template writes, + Global Styles writes, term writes, or revision-producing updates. +- The Git object store and preview metadata are derived Push MD state, not site + content. It is acceptable for branch pushes to update that derived state. + +### 9.2 Branch Creation And Updates + +- A user can create a branch from the current `trunk` tip with normal Git + commands, for example `git checkout -b my-change` followed by + `git push origin my-change`. +- When a preview branch is first created, Push MD records the current `trunk` + tip as that branch's base commit. Validation for the initial branch push + covers the diff from that base commit to the branch tip, not every historical + commit already present on `trunk`. +- Updating an existing preview branch validates the newly pushed range from the + previous branch tip to the new branch tip. +- Push MD accepts only safe `refs/heads/*` preview branch names. Reserved refs + such as `trunk`, `_push_md_seed`, internal remote refs, path traversal, empty + segments, and ambiguous ref names are rejected. +- Branch pushes run the same content validation as `trunk` pushes, including + path rules, front matter rules, block markup validation, Global Styles JSON + validation, symlink and executable mode rejection, read-only theme JSON + rejection, and page hierarchy checks. +- Permission checks still run for the content changes represented by the branch + so a user cannot stage a preview they would be forbidden to publish. +- If validation fails, the branch ref must remain at its previous value and + WordPress content must remain unchanged. +- On success, Git output includes an admin preview URL for the branch. + +### 9.3 Admin Preview URLs + +- Each preview branch is available at a deterministic URL that uses the branch + query parameter, for example `https://example.com/?branch=my-change`. +- The `branch` query value must be a branch name, not a ref path. Push MD maps + it to `refs/heads/{branch}` after validating it with the same safe branch-name + rules used for branch pushes. +- Branch names must be URL-encoded when needed. +- Preview requests require a logged-in administrator. Users who are not logged + in, or who cannot administer Push MD, must see the normal live site or an + authorization failure rather than branch content. +- Push MD must not issue, store, or require preview tokens for v1 branch + previews. +- Preview URLs must send no-cache headers and should make preview mode visible + to administrators so the branch view is not confused with live content. + +### 9.4 Request-Scoped Rendering Overlay + +- A preview request renders the public frontend through a request-scoped overlay + derived from the selected branch tip. +- The overlay can replace, add, or hide supported posts and pages for that + request without saving any `WP_Post` rows. +- The overlay can replace supported block templates, template parts, + navigation posts, and Global Styles for that request without creating + customizations or revisions. +- The overlay should use WordPress rendering APIs and filters where possible so + themes, blocks, shortcodes, and normal frontend behavior still run. +- Preview rendering must never persist branch content as canonical WordPress + content. If a preview request crashes or exits early, the live site state must + remain unchanged. + +### 9.5 Merge To WordPress + +- Publishing a preview branch requires an explicit authenticated Push MD admin + action, exposed through REST and optionally the Tools > Push MD UI. +- Merge is fast-forward-only in v1. Before merging, Push MD refreshes `trunk` + from current WordPress content and verifies that the preview branch descends + from the current `trunk` tip. +- If WordPress or `trunk` changed since the branch was based, merge is rejected. + The user must pull or rebase `trunk`, update the branch, and push it again. +- A merge validates the branch again immediately before applying it. +- Only after validation succeeds may Push MD apply the branch diff through + WordPress APIs and create normal WordPress revisions. +- If any branch change cannot be applied, no earlier branch change may remain + applied to WordPress. +- After a successful merge, `trunk` should point at the merged branch tip or an + equivalent merge commit that represents the applied WordPress state. + +### 9.6 Out Of Scope For Branch Preview v1 + +- No GitHub-style pull request system, comments, reviews, required checks, or + external Git host integration. +- No WP-Admin editor or Site Editor preview overlay in v1; the target preview + surface is the public frontend. +- No preview support for media uploads, plugin/theme PHP code, arbitrary + database tables, or unsupported post types. +- No automatic publishing from a non-`trunk` branch push. + +## 10. Conflict Model + +Before accepting a publishing push to `trunk`, Push MD refreshes the remote view +from WordPress. If WordPress changed since the client's base commit, the push is +rejected. The user or agent must pull, rebase or merge locally, resolve +conflicts, and push again. + +Before merging a preview branch, Push MD performs the same freshness check +against current `trunk`. A stale branch can still exist as a preview, but it +cannot be merged until it is rebased or otherwise updated on top of current +`trunk`. This keeps WordPress as the source of truth and avoids overwriting WP-Admin edits with stale local content. -## 10. Core User Flows +## 11. Core User Flows -### 10.1 Clone +### 11.1 Clone 1. A user authenticates with Basic Auth and an application password. 2. The user runs `git clone https://example.com/wp-json/git/v1/md.git`. 3. The clone checks out `trunk`. 4. The working tree contains supported WordPress content and agent guidance. -### 10.2 Edit A Post Or Page +### 11.2 Edit A Post Or Page 1. The user edits `post/{slug}.md`, `page/{slug}.md`, or a nested page path. 2. The user commits locally. @@ -273,20 +391,20 @@ edits with stale local content. 4. Push MD validates the whole push, checks permissions, updates WordPress, and lets WordPress create revisions. -### 10.3 Create Content +### 11.3 Create Content 1. The user adds a new `post/{slug}.md` or page file. 2. Front matter is optional except where WordPress behavior requires a value. 3. Push MD creates the matching post or page using safe WordPress defaults. 4. Nested pages require an existing exported parent path. -### 10.4 Delete And Restore Content +### 11.4 Delete And Restore Content 1. Deleting a post or page file trashes the matching WordPress object. 2. Re-adding the same file path restores that trashed object. 3. The plugin rejects deletes that would leave exported child pages orphaned. -### 10.5 Edit Block Theme Content +### 11.5 Edit Block Theme Content 1. The user edits a supported `.html` block entity file or `wp_global_styles/{theme}.json`. @@ -294,7 +412,7 @@ edits with stale local content. 3. Theme source files remain read-only. 4. Deletes and renames are rejected. -### 10.6 Resolve A Stale Push +### 11.6 Resolve A Stale Push 1. A user edits content in WP-Admin after another user cloned. 2. The stale local clone attempts to push. @@ -302,7 +420,28 @@ edits with stale local content. 4. The local user pulls, rebases or merges, resolves conflicts, and pushes the resolved tree. -## 11. Testing And Reliability +### 11.7 Create A Branch Preview + +1. The user creates a local branch from current `trunk`. +2. The user edits files, commits locally, and pushes the branch. +3. Push MD validates the pushed commits and permissions. +4. Push MD stores the branch ref and preview metadata without changing + WordPress content or revisions. +5. Git output returns an admin preview URL such as + `https://example.com/?branch=my-change`. +6. A logged-in administrator can view the public frontend rendered from the + branch overlay. + +### 11.8 Merge A Branch Preview + +1. An admin opens the branch in Push MD and chooses merge. +2. Push MD refreshes `trunk` from current WordPress content. +3. Push MD rejects the merge if the branch is stale, invalid, or unauthorized. +4. Push MD applies the branch diff through WordPress APIs. +5. WordPress creates the normal revisions and canonical content changes. +6. A fresh pull from `trunk` includes the merged content. + +## 12. Testing And Reliability The plugin should keep a mix of focused unit tests and end-to-end Git flow tests. @@ -312,6 +451,14 @@ Required coverage: - Basic Auth for clone and push. - REST/WP-Admin edits flowing back through Git. - Stale push rejection. +- Branch push validation without WordPress content mutation. +- Branch preview URLs use `?branch=` and require an authenticated + administrator. +- Branch preview metadata does not include token values or token hashes. +- Preview overlay renders branch posts, pages, templates, template parts, + navigation, and Global Styles without creating revisions. +- Branch merge freshness checks, successful application, and stale merge + rejection. - Multi-file and multi-commit push rejection without partial WordPress writes. - Malformed front matter rejection. - Rejection of `id`, `slug`, `type`, and unknown front matter fields. @@ -327,7 +474,7 @@ Required coverage: The smoke-test script should remain usable by agents as an acceptance harness against a local WordPress playground or sandbox site. -## 12. Future Work +## 13. Future Work - Media mirroring for referenced attachments, including relative Markdown links, binary hashing, and safe import of new media. @@ -336,10 +483,13 @@ against a local WordPress playground or sandbox site. - Additional post types with explicit path and permission rules. - Better large-site pagination, streaming, and memory limits. - Optional mapping between Git commits and WordPress revision sets. +- Branch preview collaboration features such as comments, review state, + approvals, and external Git host links. +- WP-Admin editor and Site Editor branch preview support. - WordPress.com or Jetpack transport once the standalone plugin behavior is stable. -## 13. Success Metrics +## 14. Success Metrics - A user can clone a test site and see supported content as files. - A user can edit an existing post/page locally and push it without data loss. @@ -347,6 +497,11 @@ against a local WordPress playground or sandbox site. - A user can safely trash and restore content through file deletion/re-addition. - Block theme files and Global Styles overlays round-trip through supported create/update flows. +- A user can push a preview branch and receive an admin preview URL without + changing WordPress content or creating revisions. +- A logged-in administrator can see branch content on the public frontend. +- An admin can merge a fresh preview branch and see the expected WordPress + content and revisions afterward. - Unsafe pushes fail before any WordPress content changes. - Stale pushes are rejected and recoverable through normal Git pull/rebase workflows.