diff --git a/Dockerfile b/Dockerfile index 121704726..c57fb8246 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) @@ -29,6 +34,11 @@ RUN composer install --no-interaction --no-progress --optimize-autoloader 2>/dev # Copy the rest of the source COPY . . +# Prewarm the WordPress Playground cache so e2e tests can boot offline in the +# no-network sandbox used by docker-compose. +RUN mkdir -p /root/.wordpress-playground \ + && php -r '$config = json_decode(file_get_contents("plugins/wp-origin/blueprint-e2e.json"), true); if (! is_array($config) || ! isset($config["preferredVersions"]["wp"])) { fwrite(STDERR, "Missing WP Origin e2e WordPress version.\n"); exit(1); } $version = $config["preferredVersions"]["wp"]; $source = "https://downloads.wordpress.org/release/wordpress-" . $version . ".zip"; $target = "/root/.wordpress-playground/" . $version . ".zip"; if (! copy($source, $target)) { fwrite(STDERR, "Failed to prefetch " . $source . "\n"); exit(1); }' + # Re-run install in case the cached layer was stale RUN composer install --no-interaction --no-progress --optimize-autoloader 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-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/bin/test-wp-origin-git-actions-docker.sh b/bin/test-wp-origin-git-actions-docker.sh new file mode 100755 index 000000000..60b52db60 --- /dev/null +++ b/bin/test-wp-origin-git-actions-docker.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -eu + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" + +cd "$ROOT_DIR" +docker compose run --rm sandbox-wp-origin-e2e bash bin/test-wp-origin-git-actions.sh diff --git a/bin/test-wp-origin-git-actions.sh b/bin/test-wp-origin-git-actions.sh new file mode 100755 index 000000000..b435acd6b --- /dev/null +++ b/bin/test-wp-origin-git-actions.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +set -eu + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +WORK_DIR="$(mktemp -d)" +DEFAULT_PORT="${WP_ORIGIN_E2E_PORT:-}" +PLAYGROUND_LOG="$ROOT_DIR/.context/wp-origin-playground.log" +BLUEPRINT_TEMPLATE="$ROOT_DIR/plugins/wp-origin/blueprint-e2e.json" + +find_free_port() { + php -r '$server = stream_socket_server("tcp://127.0.0.1:0", $errno, $errstr); if (false === $server) { fwrite(STDERR, $errstr . PHP_EOL); exit(1); } $name = stream_socket_get_name($server, false); fclose($server); echo substr(strrchr($name, ":"), 1);' +} + +if [ -n "$DEFAULT_PORT" ]; then + PORT="$DEFAULT_PORT" +else + PORT="$(find_free_port)" +fi + +CREDENTIALS_FILE="$ROOT_DIR/.context/wp-origin-e2e-$PORT.json" +BLUEPRINT_FILE="$WORK_DIR/blueprint-e2e.json" + +if command -v wp-playground >/dev/null 2>&1; then + PLAYGROUND_CMD="wp-playground" +elif command -v wp-playground-cli >/dev/null 2>&1; then + PLAYGROUND_CMD="wp-playground-cli" +else + PLAYGROUND_CMD="npx --no-install @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 -f "$CREDENTIALS_FILE" + rm -rf "$WORK_DIR" +} +trap cleanup EXIT INT TERM + +mkdir -p "$ROOT_DIR/.context" +rm -f "$PLAYGROUND_LOG" "$CREDENTIALS_FILE" + +cd "$ROOT_DIR" +sed "s|__WP_ORIGIN_CREDENTIALS_FILE__|/workspace/.context/$(basename "$CREDENTIALS_FILE")|g" "$BLUEPRINT_TEMPLATE" > "$BLUEPRINT_FILE" +PLAYGROUND_PHP_VERSION="$(php -r '$config = json_decode(file_get_contents($argv[1]), true); echo $config["preferredVersions"]["php"];' "$BLUEPRINT_FILE")" +PLAYGROUND_WP_VERSION="$(php -r '$config = json_decode(file_get_contents($argv[1]), true); echo $config["preferredVersions"]["wp"];' "$BLUEPRINT_FILE")" + +export GIT_TERMINAL_PROMPT=0 +$PLAYGROUND_CMD server \ + --port="$PORT" \ + --php="$PLAYGROUND_PHP_VERSION" \ + --wp="$PLAYGROUND_WP_VERSION" \ + --blueprint="$BLUEPRINT_FILE" \ + --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" +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" + +POST_ID="$(curl -sS -f -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"]; +')" +PAGE_ID="$(curl -sS -f -H "Authorization: Basic $AUTH_HEADER" "$BASE_URL/wp-json/wp/v2/pages?slug=sample-page&context=edit" | php -r ' +$pages = json_decode(stream_get_contents(STDIN), true); +echo $pages[0]["id"]; +')" +REVISION_COUNT_BEFORE="$(curl -sS -f -H "Authorization: Basic $AUTH_HEADER" "$BASE_URL/wp-json/wp/v2/posts/$POST_ID/revisions?context=edit" | php -r ' +$revisions = json_decode(stream_get_contents(STDIN), true); +echo count($revisions); +')" + +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 + +UPDATED_CONTENT="$(curl -sS -f -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' + +REVISION_COUNT_AFTER_UPDATE="$(curl -sS -f -H "Authorization: Basic $AUTH_HEADER" "$BASE_URL/wp-json/wp/v2/posts/$POST_ID/revisions?context=edit" | php -r ' +$revisions = json_decode(stream_get_contents(STDIN), true); +echo count($revisions); +')" +[ "$REVISION_COUNT_AFTER_UPDATE" -gt "$REVISION_COUNT_BEFORE" ] +git pull --rebase origin trunk + +php -r ' +$path = $argv[1]; +$markdown = "---\n" + . "type: \"post\"\n" + . "slug: \"created-from-git\"\n" + . "status: \"publish\"\n" + . "title: \"Created From Git\"\n" + . "---\n\n" + . "Created from Git.\n"; +file_put_contents($path, $markdown); +' "$CLONE_DIR/post/created-from-git.md" + +php -r ' +$path = $argv[1]; +$markdown = "---\n" + . "type: \"page\"\n" + . "slug: \"page-from-git\"\n" + . "status: \"publish\"\n" + . "title: \"Page From Git\"\n" + . "---\n\n" + . "Page created from Git.\n"; +file_put_contents($path, $markdown); +' "$CLONE_DIR/page/page-from-git.md" + +rm "$CLONE_DIR/page/sample-page.md" +git add post/created-from-git.md page/page-from-git.md page/sample-page.md +git commit -m "Create and delete content from Git" +git push origin trunk + +CREATED_POST_CONTENT="$(curl -sS -f -H "Authorization: Basic $AUTH_HEADER" "$BASE_URL/wp-json/wp/v2/posts?slug=created-from-git&context=edit" | php -r ' +$posts = json_decode(stream_get_contents(STDIN), true); +echo $posts[0]["content"]["raw"]; +')" +printf '%s' "$CREATED_POST_CONTENT" | grep -q 'Created from Git' + +CREATED_PAGE_CONTENT="$(curl -sS -f -H "Authorization: Basic $AUTH_HEADER" "$BASE_URL/wp-json/wp/v2/pages?slug=page-from-git&context=edit" | php -r ' +$pages = json_decode(stream_get_contents(STDIN), true); +echo $pages[0]["content"]["raw"]; +')" +printf '%s' "$CREATED_PAGE_CONTENT" | grep -q 'Page created from Git' + +TRASHED_PAGE_STATUS="$(curl -sS -f -H "Authorization: Basic $AUTH_HEADER" "$BASE_URL/wp-json/wp/v2/pages/$PAGE_ID?context=edit" | php -r ' +$page = json_decode(stream_get_contents(STDIN), true); +echo $page["status"]; +')" +[ "$TRASHED_PAGE_STATUS" = "trash" ] +git pull --rebase origin trunk + +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" \ + -f \ + "$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 fetch origin trunk +git reset --hard FETCH_HEAD >/dev/null +grep -q 'Updated in WordPress' "$CLONE_DIR/post/hello-world.md" 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; } 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/Tests/MarkdownRoundTripFixtureTest.php b/components/Markdown/Tests/MarkdownRoundTripFixtureTest.php new file mode 100644 index 000000000..9eb03d5d3 --- /dev/null +++ b/components/Markdown/Tests/MarkdownRoundTripFixtureTest.php @@ -0,0 +1,87 @@ +consume(); + $block_markup = $result->get_block_markup(); + $metadata = array(); + foreach ( $result->get_all_metadata() as $key => $values ) { + $metadata[ $key ] = $values; + } + + $producer = new MarkdownProducer( new BlocksWithMetadata( $block_markup, $metadata ) ); + $round_tripped = $producer->produce(); + + $this->assertSame( + $markdown, + $round_tripped, + sprintf( + 'Fixture %s changed bytes during md -> wp -> md round-trip (%s != %s).', + basename( $fixture_path ), + sha1( $markdown ), + sha1( $round_tripped ) + ) + ); + } + + public static function provider_md_to_wp_to_md_fixtures() { + return self::paths_to_cases( + __DIR__ . '/fixtures/roundtrip/md-to-wp-to-md/*.md' + ); + } + + /** + * @dataProvider provider_wp_to_md_to_wp_fixtures + */ + public function test_wp_to_md_to_wp_round_trip_matches_fixture_markup( $fixture_path ) { + $block_markup = file_get_contents( $fixture_path ); + $producer = new MarkdownProducer( new BlocksWithMetadata( $block_markup, array() ) ); + $markdown = $producer->produce(); + $consumer = new MarkdownConsumer( $markdown ); + $result = $consumer->consume(); + + $this->assertSame( + $this->normalize_blocks( $block_markup ), + $this->normalize_blocks( $result->get_block_markup() ), + sprintf( + 'Fixture %s changed bytes during wp -> md -> wp round-trip (%s != %s).', + basename( $fixture_path ), + sha1( $this->normalize_blocks( $block_markup ) ), + sha1( $this->normalize_blocks( $result->get_block_markup() ) ) + ) + ); + } + + public static function provider_wp_to_md_to_wp_fixtures() { + return self::paths_to_cases( + __DIR__ . '/fixtures/roundtrip/wp-to-md-to-wp/*.html' + ); + } + + private static function paths_to_cases( $pattern ) { + $cases = array(); + foreach ( glob( $pattern ) as $path ) { + $cases[ basename( $path ) ] = array( $path ); + } + + return $cases; + } + + private function normalize_blocks( $markup ) { + $markup = preg_replace( '/-->\s+<', $markup ); + $markup = preg_replace( '/>\s+
Roses are red
\n```\n", + ); + + $cases['gutenberg fence with complex block'] = array( + 'description' => 'pullquote preserved inside gutenberg fence', + 'markdown' => "\n```gutenberg\n

Quote

\n```\n", + ); + + // --- Front matter --- + + $cases['front matter survives round-trip'] = array( + 'description' => 'front matter is reproduced exactly', + 'markdown' => "---\nid: \"42\"\ntype: \"post\"\nslug: \"hello-world\"\nstatus: \"publish\"\ntitle: \"Hello World\"\ndate_gmt: \"2024-01-15 10:00:00\"\nmodified_gmt: \"2024-02-20 14:30:00\"\n---\n\nPost body here\n\n", + ); + + // --- Multiple blocks --- + + $cases['heading then paragraph'] = array( + 'description' => 'heading followed by paragraph', + 'markdown' => "## Introduction\n\nThis is the intro paragraph\n\n", + ); + + $cases['two paragraphs'] = array( + 'description' => 'two consecutive paragraphs', + 'markdown' => "First paragraph\n\nSecond paragraph\n\n", + ); + + $cases['mixed content'] = array( + 'description' => 'heading, paragraph, list', + 'markdown' => "## Getting started\n\nFollow these steps:\n\n- Step one\n- Step two\n- Step three\n\n", + ); + + return $cases; + } + + // ------------------------------------------------------------------------- + // wp -> md -> wp + // ------------------------------------------------------------------------- + + /** + * @dataProvider provider_wp_md_wp + */ + public function test_wp_to_md_to_wp( $description, $blocks, $expected_blocks ) { + $producer = new MarkdownProducer( new BlocksWithMetadata( $blocks, array() ) ); + $markdown = $producer->produce(); + $consumer = new MarkdownConsumer( $markdown ); + $result = $consumer->consume(); + $round_tripped = $result->get_block_markup(); + + $this->assertSame( + $this->normalize_blocks( $expected_blocks ), + $this->normalize_blocks( $round_tripped ), + "wp -> md -> wp round-trip failed for: $description" + ); + } + + public static function provider_wp_md_wp() { + $cases = array(); + + $cases['paragraph'] = array( + 'description' => 'paragraph', + 'blocks' => '

Hello world

', + 'expected_blocks' => '

Hello world

', + ); + + $cases['paragraph with link'] = array( + 'description' => 'paragraph with link', + 'blocks' => '

Visit WordPress

', + 'expected_blocks' => '

Visit WordPress

', + ); + + $cases['paragraph with bold and italic'] = array( + 'description' => 'paragraph with formatting', + 'blocks' => '

Bold and Italic

', + 'expected_blocks' => '

Bold and Italic

', + ); + + $cases['h2 heading'] = array( + 'description' => 'h2 heading', + 'blocks' => '

Section title

', + // The importer adds class and id attributes, which is expected behaviour. + 'expected_blocks' => '

Section title

', + ); + + $cases['unordered list'] = array( + 'description' => 'unordered list', + 'blocks' => '', + 'expected_blocks' => '', + ); + + $cases['table'] = array( + 'description' => 'table', + 'blocks' => '
Header 1Header 2
Cell 1Cell 2
', + 'expected_blocks' => '
Header 1Header 2
Cell 1Cell 2
', + ); + + // Unsupported blocks must survive as opaque gutenberg fences and come + // back byte-for-byte as the original block markup. + $cases['unsupported block preserved via gutenberg fence'] = array( + 'description' => 'unsupported block survives via gutenberg fence', + 'blocks' => '
Roses are red
', + 'expected_blocks' => '
Roses are red
', + ); + + $cases['mixed: paragraph then unsupported'] = array( + 'description' => 'paragraph followed by unsupported block', + 'blocks' => '

Intro

A poem line
', + 'expected_blocks' => '

Intro

A poem line
', + ); + + return $cases; + } + + // ------------------------------------------------------------------------- + // One-way alias: `block` fence on import normalises to `gutenberg` on export + // ------------------------------------------------------------------------- + + public function test_legacy_block_fence_accepted_on_import() { + $markdown = "```block\n
Roses are red
\n```\n"; + $consumer = new MarkdownConsumer( $markdown ); + $result = $consumer->consume(); + + $this->assertSame( + '
Roses are red
', + trim( $result->get_block_markup() ) + ); + } + + public function test_legacy_block_fence_exported_as_gutenberg() { + // When block markup that can't be expressed in Markdown is exported, the + // output uses `gutenberg`, not the old `block` language tag. + $blocks = '
Roses are red
'; + $producer = new MarkdownProducer( new BlocksWithMetadata( $blocks, array() ) ); + $markdown = $producer->produce(); + + $this->assertStringContainsString( '```gutenberg', $markdown ); + $this->assertStringNotContainsString( '```block', $markdown ); + } + + // ------------------------------------------------------------------------- + // Front matter: round-trip via MarkdownProducer + MarkdownConsumer + // ------------------------------------------------------------------------- + + public function test_front_matter_key_order_is_stable() { + $metadata = array( + 'id' => array( '42' ), + 'type' => array( 'post' ), + 'slug' => array( 'hello-world' ), + 'status' => array( 'publish' ), + 'title' => array( 'Hello World' ), + 'date_gmt' => array( '2024-01-15 10:00:00' ), + 'modified_gmt' => array( '2024-02-20 14:30:00' ), + ); + $blocks = '

Body

'; + + $producer = new MarkdownProducer( new BlocksWithMetadata( $blocks, $metadata ) ); + $markdown1 = $producer->produce(); + + // Produce again from the same input — output must be identical. + $producer = new MarkdownProducer( new BlocksWithMetadata( $blocks, $metadata ) ); + $markdown2 = $producer->produce(); + + $this->assertSame( $markdown1, $markdown2, 'Front matter output must be deterministic' ); + } + + public function test_front_matter_values_survive_round_trip() { + $expected = array( + 'id' => '7', + 'type' => 'page', + 'slug' => 'about', + 'status' => 'publish', + 'title' => 'About Us', + 'date_gmt' => '2023-06-01 09:00:00', + 'modified_gmt' => '2024-03-15 12:00:00', + ); + $metadata = array(); + foreach ( $expected as $key => $value ) { + $metadata[ $key ] = array( $value ); + } + $blocks = '

About page content

'; + + $producer = new MarkdownProducer( new BlocksWithMetadata( $blocks, $metadata ) ); + $markdown = $producer->produce(); + + $consumer = new MarkdownConsumer( $markdown ); + $result = $consumer->consume(); + $recovered = array(); + foreach ( $result->get_all_metadata() as $key => $value ) { + $recovered[ $key ] = is_array( $value ) ? reset( $value ) : $value; + } + + foreach ( $expected as $key => $expected_value ) { + $this->assertArrayHasKey( $key, $recovered, "Front matter key '$key' missing after round-trip" ); + $this->assertSame( + (string) $expected_value, + (string) $recovered[ $key ], + "Front matter value for '$key' changed after round-trip" + ); + } + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Normalise block markup for comparison. + * + * WordPress block markup uses HTML comments as delimiters + * (e.g. ). When the Markdown importer reconstructs + * block markup it may add a newline or space between a comment and the + * next tag. WordPress itself strips that whitespace on next save, so we + * normalise it away here to keep the tests focused on content, not + * incidental whitespace. + */ + private function normalize_blocks( $markup ) { + // Strip whitespace between --> and the next < (opening tag or comment). + $markup = preg_replace( '/-->\s+<', $markup ); + // Strip whitespace between a closing > and the next

Hello world

diff --git a/components/Markdown/Tests/fixtures/roundtrip/wp-to-md-to-wp/unsupported-block.html b/components/Markdown/Tests/fixtures/roundtrip/wp-to-md-to-wp/unsupported-block.html new file mode 100644 index 000000000..1bc53441a --- /dev/null +++ b/components/Markdown/Tests/fixtures/roundtrip/wp-to-md-to-wp/unsupported-block.html @@ -0,0 +1 @@ +
Roses are red
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/docker-compose.yml b/docker-compose.yml index 104a4e5b9..00200007c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,19 @@ services: security_opt: - no-new-privileges:true network_mode: none + sandbox-wp-origin-e2e: + build: . + volumes: + - .:/app + - vendor_data:/app/vendor + working_dir: /app + read_only: true + tmpfs: + - /tmp + cap_drop: + - ALL + security_opt: + - no-new-privileges:true volumes: vendor_data: 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..4b714282e --- /dev/null +++ b/plugins/wp-origin/blueprint-e2e.json @@ -0,0 +1,25 @@ +{ + "$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": "6.9.4" + }, + "steps": [ + { + "step": "activatePlugin", + "pluginPath": "/wordpress/wp-content/plugins/wp-origin" + }, + { + "step": "runPHP", + "code": { + "filename": "seed-wp-origin-e2e.php", + "content": " 'Administrator', 'number' => 1 ) );\n$admin = $admins[0];\nupdate_option( 'permalink_structure', '/%postname%/' );\nflush_rewrite_rules();\n$hello = get_page_by_path( 'hello-world', OBJECT, 'post' );\nif ( $hello ) {\n\twp_update_post( array(\n\t\t'ID' => $hello->ID,\n\t\t'post_title' => 'Hello World',\n\t\t'post_content' => '

Hello from WordPress

',\n\t\t'post_status' => 'publish',\n\t) );\n}\n$page = get_page_by_path( 'sample-page', OBJECT, 'page' );\nif ( $page ) {\n\twp_update_post( array(\n\t\t'ID' => $page->ID,\n\t\t'post_title' => 'Sample Page',\n\t\t'post_content' => '

Page from WordPress

',\n\t\t'post_status' => 'publish',\n\t) );\n}\nlist( $raw_password ) = WP_Application_Passwords::create_new_application_password( $admin->ID, array( 'name' => 'WP Origin E2E' ) );\n$password = preg_replace( '/\\s+/', '', $raw_password );\nfile_put_contents(\n\t'__WP_ORIGIN_CREDENTIALS_FILE__',\n\twp_json_encode(\n\t\tarray(\n\t\t\t'username' => $admin->user_login,\n\t\t\t'password' => $password,\n\t\t\t'post_slug' => 'hello-world',\n\t\t\t'page_slug' => 'sample-page',\n\t\t)\n\t)\n);\n" + } + } + ] +} 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..2fe5f84f1 --- /dev/null +++ b/plugins/wp-origin/class-wp-origin-buffering-response.php @@ -0,0 +1,36 @@ +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 ); + $response->header( self::MARKER_HEADER, '1' ); + 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..85a5181e9 --- /dev/null +++ b/plugins/wp-origin/class-wp-origin-plugin.php @@ -0,0 +1,580 @@ +/.*)?'; + const EPOCH_TIMESTAMP = 946684800; + + private static $supported_post_types = array( 'post', 'page' ); + + public static function bootstrap() { + 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 ); + } + + 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 ( ! is_user_logged_in() ) { + return new WP_Error( + 'wp_origin_auth_required', + 'Authentication required.', + array( 'status' => 401 ) + ); + } + + if ( self::is_push_request( $git_path ) && ! current_user_can( 'edit_posts' ) ) { + return new WP_Error( + 'wp_origin_forbidden', + 'You do not have permission to push content changes.', + array( 'status' => 403 ) + ); + } + + return true; + } + + public static function handle_rest_request( WP_REST_Request $request ) { + $repository_path = null; + // Fail closed if PHP would otherwise emit warnings into the Git packet stream. + $previous_error_handler = set_error_handler( array( __CLASS__, 'throw_on_php_warning' ) ); // phpcs:ignore + + try { + $repository_data = self::open_repository(); + $repository = $repository_data['repository']; + $repository_path = $repository_data['path']; + 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'] + ); + } catch ( Throwable $exception ) { + return self::build_protocol_error_response( + 'git-receive-pack', + self::get_throwable_message( $exception ) + ); + } + } + + return $response->to_rest_response(); + } catch ( Throwable $exception ) { + return new WP_Error( + 'wp_origin_error', + self::get_throwable_message( $exception ), + array( 'status' => 500 ) + ); + } finally { + restore_error_handler(); + self::delete_directory( $repository_path ); + } + } + + public static function serve_git_response( $served, $result, $request, $server ) { + unset( $server ); + + if ( ! $result instanceof WP_HTTP_Response ) { + return $served; + } + + $headers = $result->get_headers(); + if ( empty( $headers[ WP_Origin_Buffering_Response::MARKER_HEADER ] ) ) { + return $served; + } + + if ( ! headers_sent() ) { + status_header( $result->get_status() ); + foreach ( $headers as $name => $value ) { + if ( WP_Origin_Buffering_Response::MARKER_HEADER === $name ) { + continue; + } + header( $name . ': ' . $value ); + } + } + + echo $result->get_data(); + return true; + } + + public static function add_authentication_challenge( $response, $server, $request ) { + unset( $server ); + + if ( 0 !== strpos( $request->get_route(), '/' . self::ROUTE_NAMESPACE . '/md.git' ) ) { + return $response; + } + if ( ! $response instanceof WP_HTTP_Response ) { + return $response; + } + if ( 401 !== $response->get_status() ) { + return $response; + } + + $response->header( 'WWW-Authenticate', 'Basic realm="WP Origin"' ); + + return $response; + } + + 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_path = trailingslashit( sys_get_temp_dir() ) . 'wp-origin-' . wp_generate_uuid4(); + $repository = new GitRepository( + LocalFilesystem::create( $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 array( + 'path' => $repository_path, + 'repository' => $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 = self::EPOCH_TIMESTAMP; + 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 ); + continue; + } + } + if ( isset( $metadata['date_gmt'] ) ) { + $maybe_timestamp = strtotime( $metadata['date_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' => self::$supported_post_types, + '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' => array( (string) $post->ID ), + 'type' => array( $post->post_type ), + 'slug' => array( $post->post_name ), + 'status' => array( $post->post_status ), + 'title' => array( $post->post_title ), + 'date_gmt' => array( $post->post_date_gmt ), + 'modified_gmt' => array( $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.' ); + } + } + self::assert_can_edit_post( $post_id ); + + if ( false === wp_trash_post( $post_id ) ) { + throw new Exception( 'Push rejected because WordPress could not trash the deleted content.' ); + } + } + } + + 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.' ); + } + if ( isset( $metadata['slug'] ) && $metadata['slug'] !== $slug ) { + throw new Exception( 'Push rejected because the file slug does not match its filename.' ); + } + + $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.' ); + } + + if ( $existing_post ) { + self::assert_can_edit_post( $existing_post->ID ); + } else { + self::assert_can_create_post_type( $post_type ); + } + + $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], self::$supported_post_types, 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 . '/', $request_bytes, $matches ) ) { + return false; + } + + return array( + 'old_oid' => $matches[1], + 'new_oid' => $matches[2], + ); + } + + private static function assert_can_edit_post( $post_id ) { + if ( ! current_user_can( 'edit_post', $post_id ) ) { + throw new Exception( 'Push rejected because you do not have permission to edit one or more posts in this change.' ); + } + } + + private static function assert_can_create_post_type( $post_type ) { + $post_type_object = get_post_type_object( $post_type ); + $edit_posts_cap = $post_type_object && isset( $post_type_object->cap->edit_posts ) ? $post_type_object->cap->edit_posts : 'edit_posts'; + if ( ! current_user_can( $edit_posts_cap ) ) { + throw new Exception( 'Push rejected because you do not have permission to create this post type.' ); + } + } + + private static function get_throwable_message( Throwable $throwable ) { + $message = $throwable->getMessage(); + if ( '' === $message && isset( $throwable->code_str ) && is_string( $throwable->code_str ) && '' !== $throwable->code_str ) { + $message = $throwable->code_str; + } + if ( '' === $message ) { + $message = get_class( $throwable ); + } + + return $message; + } + + private static function delete_directory( $path ) { + if ( ! is_string( $path ) || '' === $path || ! is_dir( $path ) ) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $path, FilesystemIterator::SKIP_DOTS ), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ( $iterator as $item ) { + if ( $item->isDir() ) { + rmdir( $item->getPathname() ); + } else { + unlink( $item->getPathname() ); + } + } + + rmdir( $path ); + } + + public static function throw_on_php_warning( $severity, $message, $file, $line ) { + if ( 0 === ( error_reporting() & $severity ) ) { // phpcs:ignore + return false; + } + + throw new ErrorException( $message, 0, $severity, $file, $line ); + } +} 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 @@ +