From 19ceb7b9d77142606df5ba3998bc8e14cf23ddf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 22 Apr 2026 15:03:26 +0200 Subject: [PATCH 1/2] Add real git CLI end-to-end test --- bin/test-git-cli-e2e.sh | 4 + components/Git/Tests/GitCliEndToEndTest.php | 280 ++++++++++++++++++ components/Git/Tests/GitRepositoryTest.php | 16 + .../fixtures/git-http-endpoint-router.php | 54 ++++ components/Git/class-gitrepository.php | 6 +- 5 files changed, 357 insertions(+), 3 deletions(-) create mode 100755 bin/test-git-cli-e2e.sh create mode 100644 components/Git/Tests/GitCliEndToEndTest.php create mode 100644 components/Git/Tests/fixtures/git-http-endpoint-router.php diff --git a/bin/test-git-cli-e2e.sh b/bin/test-git-cli-e2e.sh new file mode 100755 index 000000000..4500d1d1e --- /dev/null +++ b/bin/test-git-cli-e2e.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -eu + +docker compose run --rm sandbox vendor/bin/phpunit components/Git/Tests/GitCliEndToEndTest.php diff --git a/components/Git/Tests/GitCliEndToEndTest.php b/components/Git/Tests/GitCliEndToEndTest.php new file mode 100644 index 000000000..3494f1c3f --- /dev/null +++ b/components/Git/Tests/GitCliEndToEndTest.php @@ -0,0 +1,280 @@ +markTestSkipped( 'proc_open() is required for the Git CLI end-to-end test.' ); + } + if ( ! $this->command_is_available( 'git' ) ) { + $this->markTestSkipped( 'The git command is required for the Git CLI end-to-end test.' ); + } + + $this->temp_dir = sys_get_temp_dir() . '/php-toolkit-git-cli-' . uniqid(); + $this->git_home = $this->temp_dir . '/home'; + $this->remote_repository_path = $this->temp_dir . '/remote-repository'; + $this->working_copy_path = $this->temp_dir . '/working-copy'; + $this->router_script_path = dirname( __FILE__ ) . '/fixtures/git-http-endpoint-router.php'; + + mkdir( $this->temp_dir, 0777, true ); + mkdir( $this->git_home, 0777, true ); + + $this->initialize_remote_repository(); + $this->start_http_server(); + } + + /** + * @after + */ + public function tear_down_environment() { + if ( is_resource( $this->server_process ) ) { + @proc_terminate( $this->server_process ); + @proc_close( $this->server_process ); + } + + $this->delete_directory( $this->temp_dir ); + } + + public function test_real_git_cli_can_clone_push_and_pull() { + $remote_url = sprintf( 'http://127.0.0.1:%d/repo.git', $this->server_port ); + $this->assert_command_succeeds( + sprintf( + 'git -c protocol.version=2 clone %s %s', + escapeshellarg( $remote_url ), + escapeshellarg( $this->working_copy_path ) + ) + ); + + $this->assertSame( + "Hello from the server\n", + file_get_contents( $this->working_copy_path . '/README.md' ) + ); + + $this->assert_command_succeeds( 'git config user.name "PHP Toolkit"', $this->working_copy_path ); + $this->assert_command_succeeds( 'git config user.email "php-toolkit@example.com"', $this->working_copy_path ); + + file_put_contents( $this->working_copy_path . '/README.md', "Updated from clone\n" ); + $this->assert_command_succeeds( 'git add README.md', $this->working_copy_path ); + $this->assert_command_succeeds( 'git commit -m "Update README"', $this->working_copy_path ); + $this->assert_command_succeeds( 'git push origin trunk', $this->working_copy_path ); + + $remote_repository = $this->open_remote_repository(); + $this->assertSame( + "Updated from clone\n", + $remote_repository->read_object_by_path( '/README.md' )->consume_all() + ); + + $remote_repository->set_branch_tip( 'HEAD', 'ref: refs/heads/trunk' ); + $remote_repository->commit( + array( + 'updates' => array( + 'README.md' => "Updated on the server\n", + ), + ) + ); + + $this->assert_command_succeeds( 'git pull --ff-only origin trunk', $this->working_copy_path ); + $this->assertSame( + "Updated on the server\n", + file_get_contents( $this->working_copy_path . '/README.md' ) + ); + } + + private function initialize_remote_repository() { + $repository = $this->open_remote_repository(); + $repository->set_config_value( 'user.name', 'PHP Toolkit' ); + $repository->set_config_value( 'user.email', 'php-toolkit@example.com' ); + $repository->set_branch_tip( 'HEAD', 'ref: refs/heads/trunk' ); + $repository->commit( + array( + 'updates' => array( + 'README.md' => "Hello from the server\n", + ), + ) + ); + } + + private function open_remote_repository() { + return new GitRepository( + LocalFilesystem::create( $this->remote_repository_path ), + array( + 'default_branch' => 'trunk', + ) + ); + } + + private function start_http_server() { + $this->server_port = $this->find_available_port(); + + $command = sprintf( + '%s -S 127.0.0.1:%d %s', + escapeshellarg( PHP_BINARY ), + $this->server_port, + escapeshellarg( $this->router_script_path ) + ); + + $descriptor_spec = array( + 0 => array( 'pipe', 'r' ), + 1 => array( 'file', $this->temp_dir . '/server.stdout.log', 'a' ), + 2 => array( 'file', $this->temp_dir . '/server.stderr.log', 'a' ), + ); + + $this->server_process = proc_open( + $command, + $descriptor_spec, + $pipes, + dirname( dirname( dirname( dirname( __FILE__ ) ) ) ), + array( + 'PHP_TOOLKIT_GIT_E2E_REPOSITORY_PATH' => $this->remote_repository_path, + ) + ); + + if ( ! is_resource( $this->server_process ) ) { + $this->fail( 'Failed to start the PHP built-in server for the Git CLI end-to-end test.' ); + } + + fclose( $pipes[0] ); + + $started = false; + for ( $attempt = 0; $attempt < 50; $attempt++ ) { + $socket = @fsockopen( '127.0.0.1', $this->server_port ); + if ( false !== $socket ) { + fclose( $socket ); + $started = true; + break; + } + usleep( 100000 ); + } + + if ( ! $started ) { + $this->fail( + "Failed to start the PHP built-in server.\n" . + $this->get_server_logs() + ); + } + } + + private function find_available_port() { + $server = stream_socket_server( 'tcp://127.0.0.1:0', $errno, $errstr ); + if ( false === $server ) { + $this->fail( sprintf( 'Failed to find an available port: %s', $errstr ) ); + } + + $server_name = stream_socket_get_name( $server, false ); + fclose( $server ); + + return intval( substr( strrchr( $server_name, ':' ), 1 ) ); + } + + private function command_is_available( $command ) { + $result = $this->run_command( sprintf( 'command -v %s', escapeshellarg( $command ) ) ); + + return 0 === $result['exit_code']; + } + + private function assert_command_succeeds( $command, $cwd = null ) { + $result = $this->run_command( $command, $cwd ); + + $this->assertSame( + 0, + $result['exit_code'], + sprintf( + "Command failed: %s\nstdout:\n%s\nstderr:\n%s\nserver logs:\n%s", + $command, + $result['stdout'], + $result['stderr'], + $this->get_server_logs() + ) + ); + + return $result; + } + + private function run_command( $command, $cwd = null ) { + $descriptor_spec = array( + 0 => array( 'pipe', 'r' ), + 1 => array( 'pipe', 'w' ), + 2 => array( 'pipe', 'w' ), + ); + + $process = proc_open( + $command, + $descriptor_spec, + $pipes, + $cwd, + array( + 'GIT_CONFIG_NOSYSTEM' => '1', + 'GIT_TERMINAL_PROMPT' => '0', + 'HOME' => $this->git_home, + ) + ); + + if ( ! is_resource( $process ) ) { + $this->fail( sprintf( 'Failed to start command: %s', $command ) ); + } + + fclose( $pipes[0] ); + $stdout = stream_get_contents( $pipes[1] ); + $stderr = stream_get_contents( $pipes[2] ); + fclose( $pipes[1] ); + fclose( $pipes[2] ); + + return array( + 'exit_code' => proc_close( $process ), + 'stdout' => $stdout, + 'stderr' => $stderr, + ); + } + + private function get_server_logs() { + $stdout_log = $this->temp_dir . '/server.stdout.log'; + $stderr_log = $this->temp_dir . '/server.stderr.log'; + + return "stdout:\n" . + ( is_file( $stdout_log ) ? file_get_contents( $stdout_log ) : '' ) . + "\nstderr:\n" . + ( is_file( $stderr_log ) ? file_get_contents( $stderr_log ) : '' ); + } + + private function delete_directory( $path ) { + if ( ! $path || ! file_exists( $path ) ) { + return; + } + + if ( is_file( $path ) || is_link( $path ) ) { + @unlink( $path ); + return; + } + + $entries = scandir( $path ); + if ( false === $entries ) { + return; + } + + foreach ( $entries as $entry ) { + if ( '.' === $entry || '..' === $entry ) { + continue; + } + $this->delete_directory( $path . '/' . $entry ); + } + + @rmdir( $path ); + } +} diff --git a/components/Git/Tests/GitRepositoryTest.php b/components/Git/Tests/GitRepositoryTest.php index 7601f90a0..e690f9b85 100644 --- a/components/Git/Tests/GitRepositoryTest.php +++ b/components/Git/Tests/GitRepositoryTest.php @@ -173,6 +173,22 @@ public function test_commit() { $this->assertEquals( $commit_oid, $repo->get_branch_tip() ); } + public function test_initial_commit_has_no_null_parent() { + $repo = new GitRepository( InMemoryFilesystem::create() ); + $repo->set_branch_tip( 'refs/heads/trunk', Commit::NULL_HASH ); + $repo->set_branch_tip( 'HEAD', 'ref: refs/heads/trunk' ); + + $commit_oid = $repo->commit( + array( + 'updates' => array( + 'README.md' => 'Hello, world!', + ), + ) + ); + + $this->assertSame( array(), $repo->read_object( $commit_oid )->as_commit()->parents ); + } + public function test_find_path_descendants() { $repo = new GitRepository( InMemoryFilesystem::create() ); $repo->set_branch_tip( 'refs/heads/trunk', Commit::NULL_HASH ); diff --git a/components/Git/Tests/fixtures/git-http-endpoint-router.php b/components/Git/Tests/fixtures/git-http-endpoint-router.php new file mode 100644 index 000000000..c4e43bd58 --- /dev/null +++ b/components/Git/Tests/fixtures/git-http-endpoint-router.php @@ -0,0 +1,54 @@ + 'trunk', + ) +); +$endpoint = new GitEndpoint( $repository ); +$response = new BufferingResponseWriter(); + +try { + $endpoint->handle_request( + $git_path, + file_get_contents( 'php://input' ), + $response + ); +} catch ( Exception $exception ) { + http_response_code( 500 ); + header( 'Content-Type: text/plain' ); + echo $exception->getMessage(); +} diff --git a/components/Git/class-gitrepository.php b/components/Git/class-gitrepository.php index c0b81287e..42b24b30b 100644 --- a/components/Git/class-gitrepository.php +++ b/components/Git/class-gitrepository.php @@ -727,7 +727,7 @@ public function commit( $options = array() ) { // Create a new commit object. $commit_options = $options['commit'] ?? array(); $commit_options['tree'] = $root_tree_oid; - if ( ! isset( $commit_options['parents'] ) && $this->get_branch_tip( 'HEAD' ) ) { + if ( ! isset( $commit_options['parents'] ) && ! Commit::is_null_hash( $head ) ) { $commit_options['parents'] = array( $head ); } @@ -919,8 +919,8 @@ public function get_commits_range( string $head_oid, string $last_ancestor_oid, continue; } $visited[ $current_oid ] = true; - $commits[] = $current_oid; - $commit = $this->read_object( $current_oid )->as_commit(); + $commits[] = $current_oid; + $commit = $this->read_object( $current_oid )->as_commit(); foreach ( $commit->parents as $parent_hash ) { $queue[] = $parent_hash; } From c157dca62383ecba5fc09ac08aef1de6b6bfa95a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 22 Apr 2026 15:29:11 +0200 Subject: [PATCH 2/2] WP Origin: expose WordPress as a Git remote for Markdown content Adds the WP Origin plugin, which makes WordPress posts and pages available as a Git remote. Users and coding agents can clone, pull, and push Markdown files using standard Git tooling, with WordPress as the source of truth. The plugin exposes a Git Smart HTTP endpoint at /wp-json/git/v1/md.git, backed by HTTP Basic Auth with application passwords. Posts and pages are serialized as Markdown with YAML front matter. Pushes validate staleness before applying changes and move deleted files to trash rather than destroying content. This commit also renames the Markdown fenced-code block language from `block` to `gutenberg` (the old name is still accepted on import), and adds a real end-to-end test script that boots a WordPress Playground instance, clones the repo, edits and pushes a post, and verifies that a stale push is rejected. --- Dockerfile | 5 + bin/run-server-dev.sh | 3 +- bin/test-wp-origin-git-actions.sh | 118 +++++ .../Markdown/Tests/MarkdownConsumerTest.php | 15 + .../Markdown/Tests/MarkdownProducerTest.php | 11 + .../Markdown/class-markdownconsumer.php | 3 +- .../Markdown/class-markdownproducer.php | 2 +- package.json | 3 +- plugins/wp-origin/blueprint-e2e.json | 22 + .../class-wp-origin-buffering-response.php | 33 ++ plugins/wp-origin/class-wp-origin-plugin.php | 443 ++++++++++++++++++ plugins/wp-origin/wp-origin-dev-bootstrap.php | 38 ++ plugins/wp-origin/wp-origin.php | 19 + 13 files changed, 710 insertions(+), 5 deletions(-) create mode 100755 bin/test-wp-origin-git-actions.sh create mode 100644 plugins/wp-origin/blueprint-e2e.json create mode 100644 plugins/wp-origin/class-wp-origin-buffering-response.php create mode 100644 plugins/wp-origin/class-wp-origin-plugin.php create mode 100644 plugins/wp-origin/wp-origin-dev-bootstrap.php create mode 100644 plugins/wp-origin/wp-origin.php diff --git a/Dockerfile b/Dockerfile index 121704726..4c9bb416a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libsqlite3-dev \ libonig-dev \ locales \ + nodejs \ + npm \ unzip \ git \ && sed -i 's/# en_US.UTF-8/en_US.UTF-8/' /etc/locale.gen \ @@ -20,6 +22,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Install Composer COPY --from=composer:2 /usr/bin/composer /usr/bin/composer +# Install Playground CLI for WordPress integration smoke tests. +RUN npm install -g @wp-playground/cli@latest + WORKDIR /app # Install dependencies first (cached layer for faster rebuilds) diff --git a/bin/run-server-dev.sh b/bin/run-server-dev.sh index 48e82482d..1f39d8738 100644 --- a/bin/run-server-dev.sh +++ b/bin/run-server-dev.sh @@ -10,5 +10,6 @@ npx @wp-playground/cli@latest \ --mount=`pwd`/plugins/static-files-editor:/wordpress/wp-content/plugins/static-files-editor \ --mount=`pwd`/plugins/url-updater:/wordpress/wp-content/plugins/url-updater \ --mount=`pwd`/plugins/git-repo:/wordpress/wp-content/plugins/git-repo \ + --mount=`pwd`/plugins/wp-origin:/wordpress/wp-content/plugins/wp-origin \ --mount=`pwd`/.my-notes-git:/wordpress/wp-content/uploads \ - --blueprint=./blueprint-dev.json \ No newline at end of file + --blueprint=./blueprint-dev.json diff --git a/bin/test-wp-origin-git-actions.sh b/bin/test-wp-origin-git-actions.sh new file mode 100755 index 000000000..178341f83 --- /dev/null +++ b/bin/test-wp-origin-git-actions.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +set -eu + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +PORT="${WP_ORIGIN_E2E_PORT:-9409}" +PLAYGROUND_LOG="$ROOT_DIR/.context/wp-origin-playground.log" +CREDENTIALS_FILE="$ROOT_DIR/.context/wp-origin-e2e.json" +WORK_DIR="$(mktemp -d)" + +if command -v wp-playground >/dev/null 2>&1; then + PLAYGROUND_CMD="wp-playground" +else + PLAYGROUND_CMD="npx @wp-playground/cli" +fi + +cleanup() { + if [ -n "${PLAYGROUND_PID:-}" ] && kill -0 "$PLAYGROUND_PID" 2>/dev/null; then + kill "$PLAYGROUND_PID" 2>/dev/null || true + wait "$PLAYGROUND_PID" 2>/dev/null || true + fi + rm -rf "$WORK_DIR" +} +trap cleanup EXIT INT TERM + +mkdir -p "$ROOT_DIR/.context" +rm -f "$PLAYGROUND_LOG" "$CREDENTIALS_FILE" + +cd "$ROOT_DIR" + +$PLAYGROUND_CMD server \ + --port="$PORT" \ + --blueprint="$ROOT_DIR/plugins/wp-origin/blueprint-e2e.json" \ + --mount="$ROOT_DIR:/workspace" \ + --mount="$ROOT_DIR/vendor:/wordpress/wp-content/vendor" \ + --mount="$ROOT_DIR/components:/wordpress/wp-content/components" \ + --mount="$ROOT_DIR/plugins/wp-origin:/wordpress/wp-content/plugins/wp-origin" \ + >"$PLAYGROUND_LOG" 2>&1 & +PLAYGROUND_PID=$! + +for _ in $(seq 1 120); do + if [ -f "$CREDENTIALS_FILE" ]; then + break + fi + sleep 1 +done + +if [ ! -f "$CREDENTIALS_FILE" ]; then + cat "$PLAYGROUND_LOG" + echo "WP Origin e2e setup did not produce credentials." >&2 + exit 1 +fi + +USERNAME="$(php -r 'echo json_decode(file_get_contents($argv[1]), true)["username"];' "$CREDENTIALS_FILE")" +PASSWORD="$(php -r 'echo json_decode(file_get_contents($argv[1]), true)["password"];' "$CREDENTIALS_FILE")" + +AUTH_HEADER="$(php -r 'echo base64_encode($argv[1] . ":" . $argv[2]);' "$USERNAME" "$PASSWORD")" +BASE_URL="http://127.0.0.1:$PORT" +REMOTE_AUTH_URL="http://$USERNAME:$PASSWORD@127.0.0.1:$PORT/wp-json/git/v1/md.git" +REMOTE_URL="$BASE_URL/wp-json/git/v1/md.git" +CLONE_DIR="$WORK_DIR/clone" + +git -c protocol.version=2 clone "$REMOTE_AUTH_URL" "$CLONE_DIR" + +test -f "$CLONE_DIR/post/hello-world.md" +test -f "$CLONE_DIR/page/sample-page.md" +grep -q 'Hello from WordPress' "$CLONE_DIR/post/hello-world.md" + +cd "$CLONE_DIR" +git config user.name "WP Origin E2E" +git config user.email "wp-origin-e2e@example.com" + +php -r ' +$path = $argv[1]; +$contents = file_get_contents($path); +$contents = str_replace("Hello from WordPress", "Updated from Git", $contents); +file_put_contents($path, $contents); +' "$CLONE_DIR/post/hello-world.md" + +git add post/hello-world.md +git commit -m "Update hello world from Git" +git push origin trunk + +POST_ID="$(curl -sS -H "Authorization: Basic $AUTH_HEADER" "$BASE_URL/wp-json/wp/v2/posts?slug=hello-world&context=edit" | php -r ' +$posts = json_decode(stream_get_contents(STDIN), true); +echo $posts[0]["id"]; +')" + +UPDATED_CONTENT="$(curl -sS -H "Authorization: Basic $AUTH_HEADER" "$BASE_URL/wp-json/wp/v2/posts/$POST_ID?context=edit" | php -r ' +$post = json_decode(stream_get_contents(STDIN), true); +echo $post["content"]["raw"]; +')" +printf '%s' "$UPDATED_CONTENT" | grep -q 'Updated from Git' + +php -r ' +$path = $argv[1]; +$contents = file_get_contents($path); +$contents = str_replace("Updated from Git", "Stale local edit", $contents); +file_put_contents($path, $contents); +' "$CLONE_DIR/post/hello-world.md" + +git add post/hello-world.md +git commit -m "Create stale local edit" + +UPDATE_PAYLOAD='{"content":"

Updated in WordPress

"}' +curl -sS \ + -X POST \ + -H "Authorization: Basic $AUTH_HEADER" \ + -H "Content-Type: application/json" \ + -d "$UPDATE_PAYLOAD" \ + "$BASE_URL/wp-json/wp/v2/posts/$POST_ID?context=edit" >/dev/null + +if git push origin trunk; then + echo "Expected stale push to fail." >&2 + exit 1 +fi + +git pull --rebase origin trunk +grep -q 'Updated in WordPress' "$CLONE_DIR/post/hello-world.md" diff --git a/components/Markdown/Tests/MarkdownConsumerTest.php b/components/Markdown/Tests/MarkdownConsumerTest.php index 1283d72f4..36f066958 100644 --- a/components/Markdown/Tests/MarkdownConsumerTest.php +++ b/components/Markdown/Tests/MarkdownConsumerTest.php @@ -204,4 +204,19 @@ public function test_frontmatter_extraction() { ); $this->assertEquals( $expected_metadata, $metadata ); } + + public function test_gutenberg_fence_is_preserved_as_block_markup() { + $markdown = <<
Roses are red
+``` +MD; + $consumer = new MarkdownConsumer( $markdown ); + $result = $consumer->consume(); + + $this->assertEquals( + '
Roses are red
', + trim( $result->get_block_markup() ) + ); + } } diff --git a/components/Markdown/Tests/MarkdownProducerTest.php b/components/Markdown/Tests/MarkdownProducerTest.php index cafb49151..738e2a5cf 100644 --- a/components/Markdown/Tests/MarkdownProducerTest.php +++ b/components/Markdown/Tests/MarkdownProducerTest.php @@ -147,6 +147,17 @@ public static function provider_test_conversion() { | Cell 3 | Cell 4 | +MD + , + ), + 'Unsupported blocks are preserved as gutenberg fences' => array( + 'blocks' => '
Roses are red
', + 'expected' => <<
Roses are red
+``` + MD , ), diff --git a/components/Markdown/class-markdownconsumer.php b/components/Markdown/class-markdownconsumer.php index c49a41459..4a4339a0c 100644 --- a/components/Markdown/class-markdownconsumer.php +++ b/components/Markdown/class-markdownconsumer.php @@ -181,7 +181,7 @@ private function convert_markdown_to_blocks() { if ( method_exists( $node, 'getInfo' ) && $node->getInfo() ) { $attrs['language'] = preg_replace( '/[ \t\r\n\f].*/', '', $node->getInfo() ); } - if ( 'block' === $attrs['language'] ) { + if ( 'block' === $attrs['language'] || 'gutenberg' === $attrs['language'] ) { // This is a special case for preserving block literals that could not be expressed as markdown. $this->append_content( "\n" . $node->getLiteral() . "\n" ); } else { @@ -287,7 +287,6 @@ private function convert_markdown_to_blocks() { break; default: - error_log( 'Unhandled node type: ' . get_class( $node ) ); return null; } } else { diff --git a/components/Markdown/class-markdownproducer.php b/components/Markdown/class-markdownproducer.php index 1e20cf429..b88fa04a8 100644 --- a/components/Markdown/class-markdownproducer.php +++ b/components/Markdown/class-markdownproducer.php @@ -282,7 +282,7 @@ function ( $cell, $width ) { } $markdown = array(); $markdown[] = ''; - $markdown[] = '```block'; + $markdown[] = '```gutenberg'; $markdown[] = \serialize_block( $block ); $markdown[] = '```'; $markdown[] = ''; diff --git a/package.json b/package.json index d340c9b20..e12587c15 100644 --- a/package.json +++ b/package.json @@ -21,5 +21,6 @@ "bugs": { "url": "https://github.com/WordPress/php-toolkit/issues" }, - "homepage": "https://github.com/WordPress/php-toolkit#readme" + "homepage": "https://github.com/WordPress/php-toolkit#readme", + "packageManager": "npm@11.12.1+sha512.cdca14b85d647b3192028d02aadbe82d75f79a446aceea9874be98e6d768f20ebd3555770a48d0e9906106007877bbc690f715e9372f2e2dc644a3c3157fb14c" } diff --git a/plugins/wp-origin/blueprint-e2e.json b/plugins/wp-origin/blueprint-e2e.json new file mode 100644 index 000000000..29c98a3f0 --- /dev/null +++ b/plugins/wp-origin/blueprint-e2e.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://playground.wordpress.net/blueprint-schema.json", + "meta": { + "title": "WP Origin E2E", + "description": "Boot a WordPress site with WP Origin active and seeded content.", + "author": "adamziel" + }, + "preferredVersions": { + "php": "8.1", + "wp": "latest" + }, + "steps": [ + { + "step": "activatePlugin", + "pluginPath": "/wordpress/wp-content/plugins/wp-origin" + }, + { + "step": "wp-cli", + "command": "wp eval '$admins = get_users( array( \"role\" => \"Administrator\", \"number\" => 1 ) ); $admin = $admins[0]; update_option( \"permalink_structure\", \"/%postname%/\" ); flush_rewrite_rules(); $hello = get_page_by_path( \"hello-world\", OBJECT, \"post\" ); if ( $hello ) { wp_update_post( array( \"ID\" => $hello->ID, \"post_title\" => \"Hello World\", \"post_content\" => \"

Hello from WordPress

\", \"post_status\" => \"publish\" ) ); } $page = get_page_by_path( \"sample-page\", OBJECT, \"page\" ); if ( $page ) { wp_update_post( array( \"ID\" => $page->ID, \"post_title\" => \"Sample Page\", \"post_content\" => \"

Page from WordPress

\", \"post_status\" => \"publish\" ) ); } list( $raw_password ) = WP_Application_Passwords::create_new_application_password( $admin->ID, array( \"name\" => \"WP Origin E2E\" ) ); $password = preg_replace( \"/\\\\s+/\", \"\", $raw_password ); file_put_contents( \"/workspace/.context/wp-origin-e2e.json\", wp_json_encode( array( \"username\" => $admin->user_login, \"password\" => $password, \"post_slug\" => \"hello-world\", \"page_slug\" => \"sample-page\" ) ) );'" + } + ] +} diff --git a/plugins/wp-origin/class-wp-origin-buffering-response.php b/plugins/wp-origin/class-wp-origin-buffering-response.php new file mode 100644 index 000000000..514d23130 --- /dev/null +++ b/plugins/wp-origin/class-wp-origin-buffering-response.php @@ -0,0 +1,33 @@ +http_code = $code; + } + + public function send_header( $name, $value ) { + $this->headers[ $name ] = $value; + } + + public function append_bytes( $body ): void { + $this->body .= $body; + } + + public function close_writing(): void { + } + + public function to_rest_response() { + $response = new WP_REST_Response( $this->body, $this->http_code ); + foreach ( $this->headers as $name => $value ) { + $response->header( $name, $value ); + } + + return $response; + } +} diff --git a/plugins/wp-origin/class-wp-origin-plugin.php b/plugins/wp-origin/class-wp-origin-plugin.php new file mode 100644 index 000000000..649fd6799 --- /dev/null +++ b/plugins/wp-origin/class-wp-origin-plugin.php @@ -0,0 +1,443 @@ +/.*)?'; + + public static function bootstrap() { + add_action( 'rest_api_init', array( __CLASS__, 'register_routes' ) ); + } + + public static function register_routes() { + register_rest_route( + self::ROUTE_NAMESPACE, + self::ROUTE_PATTERN, + array( + 'methods' => WP_REST_Server::ALLMETHODS, + 'callback' => array( __CLASS__, 'handle_rest_request' ), + 'permission_callback' => array( __CLASS__, 'check_permissions' ), + ) + ); + } + + public static function check_permissions( WP_REST_Request $request ) { + $git_path = self::build_git_path( $request ); + if ( self::is_push_request( $git_path ) ) { + return current_user_can( 'edit_posts' ); + } + + return is_user_logged_in(); + } + + public static function handle_rest_request( WP_REST_Request $request ) { + try { + $repository = self::open_repository(); + self::sync_repository_from_wordpress( $repository ); + + $git_path = self::build_git_path( $request ); + $request_body = file_get_contents( 'php://input' ); + + $push_header = null; + if ( self::is_push_request( $git_path ) ) { + $push_header = self::parse_push_header( $request_body ); + if ( false === $push_header ) { + return self::build_protocol_error_response( + 'git-receive-pack', + 'Invalid push request.' + ); + } + + $current_head = $repository->get_branch_tip( 'refs/heads/' . self::DEFAULT_BRANCH ); + 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 WP_Origin_Buffering_Response(); + $endpoint = new GitEndpoint( $repository ); + $endpoint->handle_request( $git_path, $request_body, $response ); + + if ( self::is_push_request( $git_path ) ) { + try { + self::apply_repository_changes_to_wordpress( + $repository, + $push_header['old_oid'], + $push_header['new_oid'] + ); + self::sync_repository_from_wordpress( $repository ); + } catch ( Exception $exception ) { + $repository->set_branch_tip( 'refs/heads/' . self::DEFAULT_BRANCH, $push_header['old_oid'] ); + $repository->set_branch_tip( 'HEAD', 'ref: refs/heads/' . self::DEFAULT_BRANCH ); + + return self::build_protocol_error_response( + 'git-receive-pack', + $exception->getMessage() + ); + } + } + + return $response->to_rest_response(); + } catch ( Exception $exception ) { + return new WP_Error( + 'wp_origin_error', + $exception->getMessage(), + array( 'status' => 500 ) + ); + } + } + + private static function build_protocol_error_response( $service, $message ) { + $response = new WP_Origin_Buffering_Response(); + $response->send_header( 'Content-Type', 'application/x-' . $service . '-result' ); + $response->send_header( 'Cache-Control', 'no-cache' ); + $response->send_header( 'Git-Protocol', 'version=2' ); + $response->append_bytes( + WordPress\Git\Protocol\GitProtocolEncoderPipe::encode_packet_line( + 'error ' . rtrim( $message ) . "\n", + "\x03" + ) . '0000' + ); + + return $response->to_rest_response(); + } + + private static function build_git_path( WP_REST_Request $request ) { + $path = $request->get_param( 'path' ); + if ( ! is_string( $path ) || '' === $path ) { + $path = '/'; + } + + $query_params = $request->get_query_params(); + if ( '/info/refs' === $path && isset( $query_params['service'] ) ) { + $path .= '?service=' . $query_params['service']; + } + + return $path; + } + + private static function is_push_request( $git_path ) { + return '/git-receive-pack' === $git_path; + } + + private static function open_repository() { + $repository = new GitRepository( + LocalFilesystem::create( self::get_repository_path() ), + array( + 'default_branch' => self::DEFAULT_BRANCH, + ) + ); + + if ( ! $repository->get_config_value( 'user.name' ) ) { + $repository->set_config_value( 'user.name', get_option( 'blogname', 'WP Origin' ) ); + } + if ( ! $repository->get_config_value( 'user.email' ) ) { + $repository->set_config_value( 'user.email', get_option( 'admin_email', 'wp-origin@example.com' ) ); + } + $repository->set_branch_tip( 'HEAD', 'ref: refs/heads/' . self::DEFAULT_BRANCH ); + + return $repository; + } + + private static function get_repository_path() { + $upload_dir = wp_upload_dir(); + + return $upload_dir['basedir'] . '/wp-origin/repository'; + } + + private static function sync_repository_from_wordpress( GitRepository $repository ) { + $exported_files = self::export_wordpress_content(); + $current_files = array(); + + try { + $current_head = $repository->get_branch_tip( 'refs/heads/' . self::DEFAULT_BRANCH ); + if ( ! Commit::is_null_hash( $current_head ) ) { + $current_files = self::read_markdown_files_from_commit( $repository, $current_head ); + } + } catch ( GitException $exception ) { + $current_head = Commit::NULL_HASH; + } + + $updates = array(); + foreach ( $exported_files as $path => $contents ) { + if ( ! isset( $current_files[ $path ] ) || $current_files[ $path ] !== $contents ) { + $updates[ $path ] = $contents; + } + } + + $deletes = array(); + foreach ( $current_files as $path => $contents ) { + if ( ! isset( $exported_files[ $path ] ) ) { + $deletes[] = $path; + } + } + + if ( empty( $updates ) && empty( $deletes ) ) { + return; + } + + $commit_timestamp = time(); + foreach ( $exported_files as $path => $contents ) { + $metadata = self::parse_markdown_metadata( $contents ); + if ( isset( $metadata['modified_gmt'] ) ) { + $maybe_timestamp = strtotime( $metadata['modified_gmt'] . ' UTC' ); + if ( false !== $maybe_timestamp ) { + $commit_timestamp = max( $commit_timestamp, $maybe_timestamp ); + } + } + } + + $repository->commit( + array( + 'updates' => $updates, + 'deletes' => $deletes, + 'commit' => array( + 'message' => 'Sync from WordPress', + 'author_date' => gmdate( Commit::DATE_FORMAT, $commit_timestamp ), + 'committer_date' => gmdate( Commit::DATE_FORMAT, $commit_timestamp ), + ), + ) + ); + } + + private static function export_wordpress_content() { + $posts = get_posts( + array( + 'post_type' => array( 'post', 'page' ), + 'post_status' => array( 'publish', 'draft', 'pending', 'private', 'future' ), + 'posts_per_page' => -1, + 'orderby' => 'ID', + 'order' => 'ASC', + ) + ); + + $files = array(); + foreach ( $posts as $post ) { + if ( ! current_user_can( 'read_post', $post->ID ) ) { + continue; + } + + $metadata = array( + 'id' => (string) $post->ID, + 'type' => $post->post_type, + 'slug' => $post->post_name, + 'status' => $post->post_status, + 'title' => $post->post_title, + 'date_gmt' => $post->post_date_gmt, + 'modified_gmt' => $post->post_modified_gmt, + ); + + $producer = new MarkdownProducer( + new BlocksWithMetadata( + $post->post_content, + $metadata + ) + ); + $path = self::build_markdown_path( $post->post_type, $post->post_name ); + $files[ $path ] = $producer->produce(); + } + + ksort( $files ); + + return $files; + } + + private static function build_markdown_path( $post_type, $slug ) { + return ltrim( $post_type . '/' . $slug . '.md', '/' ); + } + + private static function apply_repository_changes_to_wordpress( GitRepository $repository, $old_commit, $new_commit ) { + $old_files = Commit::is_null_hash( $old_commit ) ? array() : self::read_markdown_files_from_commit( $repository, $old_commit ); + $new_files = self::read_markdown_files_from_commit( $repository, $new_commit ); + + $updated_post_ids = array(); + foreach ( $new_files as $path => $contents ) { + if ( isset( $old_files[ $path ] ) && $old_files[ $path ] === $contents ) { + continue; + } + + $post_id = self::upsert_post_from_markdown( $path, $contents ); + if ( $post_id ) { + $updated_post_ids[ $post_id ] = true; + } + } + + foreach ( $old_files as $path => $contents ) { + if ( isset( $new_files[ $path ] ) ) { + continue; + } + + $metadata = self::parse_markdown_metadata( $contents ); + $post_id = isset( $metadata['id'] ) ? intval( $metadata['id'] ) : 0; + if ( ! $post_id ) { + $post_id = self::find_post_id_by_path_metadata( $path, $metadata ); + } + if ( ! $post_id || isset( $updated_post_ids[ $post_id ] ) ) { + continue; + } + + if ( isset( $metadata['modified_gmt'] ) ) { + $current_modified = get_post_field( 'post_modified_gmt', $post_id ); + if ( $current_modified && $current_modified !== $metadata['modified_gmt'] ) { + throw new Exception( 'Push rejected because a deleted post changed in WordPress. Pull the latest changes and try again.' ); + } + } + + wp_trash_post( $post_id ); + } + } + + private static function upsert_post_from_markdown( $path, $markdown ) { + $post_type = self::path_to_post_type( $path ); + $slug = self::path_to_slug( $path ); + $consumer = new MarkdownConsumer( $markdown ); + $result = $consumer->consume(); + $metadata = array(); + foreach ( $result->get_all_metadata() as $key => $value ) { + $metadata[ $key ] = is_array( $value ) ? reset( $value ) : $value; + } + + if ( isset( $metadata['type'] ) && $metadata['type'] !== $post_type ) { + throw new Exception( 'Push rejected because the file post type does not match its directory.' ); + } + + $post_id = isset( $metadata['id'] ) ? intval( $metadata['id'] ) : 0; + if ( $post_id && get_post( $post_id ) ) { + $existing_post = get_post( $post_id ); + } else { + $post_id = self::find_post_id_by_path_metadata( $path, $metadata ); + $existing_post = $post_id ? get_post( $post_id ) : null; + } + + if ( $existing_post && isset( $metadata['modified_gmt'] ) && $existing_post->post_modified_gmt !== $metadata['modified_gmt'] ) { + throw new Exception( 'Push rejected because WordPress content changed since the last pull.' ); + } + + $postarr = array( + 'post_type' => $post_type, + 'post_name' => isset( $metadata['slug'] ) ? $metadata['slug'] : $slug, + 'post_title' => isset( $metadata['title'] ) ? $metadata['title'] : ucwords( str_replace( '-', ' ', $slug ) ), + 'post_status' => isset( $metadata['status'] ) ? $metadata['status'] : 'draft', + 'post_content' => $result->get_block_markup(), + ); + + if ( isset( $metadata['date_gmt'] ) && '' !== $metadata['date_gmt'] ) { + $postarr['post_date_gmt'] = $metadata['date_gmt']; + } + + if ( $existing_post ) { + $postarr['ID'] = $existing_post->ID; + $post_id = wp_update_post( wp_slash( $postarr ), true ); + } else { + $post_id = wp_insert_post( wp_slash( $postarr ), true ); + } + + if ( is_wp_error( $post_id ) ) { + throw new Exception( $post_id->get_error_message() ); + } + + return $post_id; + } + + private static function find_post_id_by_path_metadata( $path, $metadata ) { + $post_type = self::path_to_post_type( $path ); + $slug = isset( $metadata['slug'] ) ? $metadata['slug'] : self::path_to_slug( $path ); + $posts = get_posts( + array( + 'post_type' => $post_type, + 'name' => $slug, + 'post_status' => array( 'publish', 'draft', 'pending', 'private', 'future', 'trash' ), + 'posts_per_page' => 1, + 'fields' => 'ids', + ) + ); + + if ( empty( $posts ) ) { + return 0; + } + + return intval( $posts[0] ); + } + + private static function path_to_post_type( $path ) { + $segments = explode( '/', ltrim( $path, '/' ) ); + if ( empty( $segments[0] ) || ! in_array( $segments[0], array( 'post', 'page' ), true ) ) { + throw new Exception( 'Push rejected because the file path is outside the supported post type directories.' ); + } + + return $segments[0]; + } + + private static function path_to_slug( $path ) { + $basename = basename( $path ); + if ( 'md' !== pathinfo( $basename, PATHINFO_EXTENSION ) ) { + throw new Exception( 'Push rejected because only Markdown files are supported.' ); + } + + return pathinfo( $basename, PATHINFO_FILENAME ); + } + + private static function parse_markdown_metadata( $markdown ) { + $consumer = new MarkdownConsumer( $markdown ); + $result = $consumer->consume(); + $metadata = array(); + foreach ( $result->get_all_metadata() as $key => $value ) { + $metadata[ $key ] = is_array( $value ) ? reset( $value ) : $value; + } + + return $metadata; + } + + private static function read_markdown_files_from_commit( GitRepository $repository, $commit_hash ) { + $commit = $repository->read_object( $commit_hash )->as_commit(); + $files = array(); + + if ( Commit::is_null_hash( $commit->tree ) ) { + return $files; + } + + self::collect_tree_files( $repository, $commit->tree, '', $files ); + ksort( $files ); + + return $files; + } + + private static function collect_tree_files( GitRepository $repository, $tree_hash, $prefix, &$files ) { + $tree = $repository->read_object( $tree_hash )->as_tree(); + foreach ( $tree->entries as $entry ) { + $path = ltrim( $prefix . '/' . $entry->name, '/' ); + if ( TreeEntry::FILE_MODE_DIRECTORY === $entry->get_mode_bucket() ) { + self::collect_tree_files( $repository, $entry->hash, $path, $files ); + continue; + } + if ( 'md' !== pathinfo( $path, PATHINFO_EXTENSION ) ) { + continue; + } + $files[ $path ] = $repository->read_object( $entry->hash )->consume_all(); + } + } + + private static function parse_push_header( $request_bytes ) { + if ( ! preg_match( '/([0-9a-f]{40}) ([0-9a-f]{40}) refs\\/heads\\/' . self::DEFAULT_BRANCH . "\0/", $request_bytes, $matches ) ) { + return false; + } + + return array( + 'old_oid' => $matches[1], + 'new_oid' => $matches[2], + ); + } +} diff --git a/plugins/wp-origin/wp-origin-dev-bootstrap.php b/plugins/wp-origin/wp-origin-dev-bootstrap.php new file mode 100644 index 000000000..ffa05d1a9 --- /dev/null +++ b/plugins/wp-origin/wp-origin-dev-bootstrap.php @@ -0,0 +1,38 @@ +addClassMap( $wp_origin_classmap ); + +$wp_origin_psr4 = require __DIR__ . '/../../vendor/composer/autoload_psr4.php'; +foreach ( $wp_origin_psr4 as $prefix => $paths ) { + $wp_origin_loader->setPsr4( $prefix, $paths ); +} + +$wp_origin_namespaces = require __DIR__ . '/../../vendor/composer/autoload_namespaces.php'; +foreach ( $wp_origin_namespaces as $prefix => $paths ) { + $wp_origin_loader->set( $prefix, $paths ); +} + +$wp_origin_loader->register( true ); + +$wp_origin_files = array( + __DIR__ . '/../../components/DataLiberation/URL/functions.php', + __DIR__ . '/../../components/Encoding/utf8.php', + __DIR__ . '/../../components/Encoding/compat-utf8.php', + __DIR__ . '/../../components/Encoding/utf8-encoder.php', + __DIR__ . '/../../components/Filesystem/functions.php', + __DIR__ . '/../../components/Zip/functions.php', + __DIR__ . '/../../components/Polyfill/mbstring.php', + __DIR__ . '/../../components/Polyfill/php-functions.php', + __DIR__ . '/../../components/Git/functions.php', +); + +foreach ( $wp_origin_files as $wp_origin_file ) { + require_once $wp_origin_file; +} diff --git a/plugins/wp-origin/wp-origin.php b/plugins/wp-origin/wp-origin.php new file mode 100644 index 000000000..8f8cae78f --- /dev/null +++ b/plugins/wp-origin/wp-origin.php @@ -0,0 +1,19 @@ +