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"
+ . '
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='
' \
+ --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.