diff --git a/packages/playground/data-liberation/bootstrap.php b/packages/playground/data-liberation/bootstrap.php index f13a00e582..51c2543dee 100644 --- a/packages/playground/data-liberation/bootstrap.php +++ b/packages/playground/data-liberation/bootstrap.php @@ -10,6 +10,9 @@ require_once __DIR__ . '/blueprints-library/src/WordPress/AsyncHttp/HttpError.php'; require_once __DIR__ . '/blueprints-library/src/WordPress/AsyncHttp/Connection.php'; require_once __DIR__ . '/blueprints-library/src/WordPress/AsyncHttp/Client.php'; +require_once __DIR__ . '/blueprints-library/src/WordPress/AsyncHttp/ResponseWriter/ResponseWriter.php'; +require_once __DIR__ . '/blueprints-library/src/WordPress/AsyncHttp/ResponseWriter/StreamingResponseWriter.php'; +require_once __DIR__ . '/blueprints-library/src/WordPress/AsyncHttp/ResponseWriter/BufferingResponseWriter.php'; require_once __DIR__ . '/blueprints-library/src/WordPress/Filesystem/WP_Abstract_Filesystem.php'; require_once __DIR__ . '/blueprints-library/src/WordPress/Filesystem/WP_Local_Filesystem.php'; diff --git a/packages/playground/data-liberation/src/git/WP_Git_Client.php b/packages/playground/data-liberation/src/git/WP_Git_Client.php index af527f580b..3a80c1323b 100644 --- a/packages/playground/data-liberation/src/git/WP_Git_Client.php +++ b/packages/playground/data-liberation/src/git/WP_Git_Client.php @@ -147,7 +147,7 @@ public function list_objects($ref_hash) { 'Content-Type: application/x-git-upload-pack-request', ]); - $pack_data = $this->accumulate_pack_data_from_multiplexed_chunks($response); + $pack_data = self::accumulate_pack_data_from_multiplexed_chunks($response); return WP_Git_Pack_Processor::decode($pack_data); } @@ -208,7 +208,7 @@ public function fetchObjects($refs) { 'Accept: application/x-git-upload-pack-advertisement', 'Content-Type: application/x-git-upload-pack-request', ]); - $pack_data = $this->accumulate_pack_data_from_multiplexed_chunks($response); + $pack_data = self::accumulate_pack_data_from_multiplexed_chunks($response); WP_Git_Pack_Processor::decode($pack_data, $this->index); return true; } @@ -256,9 +256,9 @@ private function encode_packet_line($data) { return str_pad(dechex($length), 4, '0', STR_PAD_LEFT) . $data; } - private function accumulate_pack_data_from_multiplexed_chunks($raw_response) { + static public function accumulate_pack_data_from_multiplexed_chunks($raw_response) { $parsed_pack_data = []; - $parsed_chunks = $this->parse_multiplexed_pack_data($raw_response); + $parsed_chunks = self::parse_multiplexed_pack_data($raw_response); foreach($parsed_chunks as $chunk) { if($chunk['type'] !== 'side-band') { continue; diff --git a/packages/playground/data-liberation/src/git/WP_Git_Pack_Processor.php b/packages/playground/data-liberation/src/git/WP_Git_Pack_Processor.php index 33f7667db3..b9e008c768 100644 --- a/packages/playground/data-liberation/src/git/WP_Git_Pack_Processor.php +++ b/packages/playground/data-liberation/src/git/WP_Git_Pack_Processor.php @@ -155,16 +155,15 @@ static private function wrap_object($type, $object) { static public function encode_packet_lines(array $payloads): string { $lines = []; foreach($payloads as $payload) { - if($payload === '0000' || $payload === '0001' || $payload === '0002') { - $lines[] = $payload; - } else { - $lines[] = self::encode_packet_line($payload); - } + $lines[] = self::encode_packet_line($payload); } return implode('', $lines); } static public function encode_packet_line(string $payload): string { + if($payload === '0000' || $payload === '0001' || $payload === '0002') { + return $payload; + } $length = strlen($payload) + 4; return sprintf("%04x", $length) . $payload; } @@ -179,55 +178,9 @@ static public function decode($pack_bytes, $pack_index=null) { } $parsed_pack = self::parse_pack_data($pack_bytes); - $objects = $parsed_pack['objects']; - - $by_oid = []; - $by_offset = []; - $resolved_objects = 0; - // Index entities and resolve deltas - // Run until all objects are resolved - while($resolved_objects < count($objects)) { - $resolved_in_this_iteration = 0; - for($i = 0; $i < count($objects); $i++) { - // Skip already processed objects - if( - isset($by_offset[$objects[$i]['header_offset']]) && - isset($by_oid[$objects[$i]['oid']]) - ) { - continue; - } - - if($objects[$i]['type'] === self::OBJECT_TYPE_OFS_DELTA) { - $target_offset = $objects[$i]['header_offset'] - $objects[$i]['ofs']; - if(!isset($by_offset[$target_offset])) { - continue; - } - // TODO: Make sure the base object will never be another delta. - $base = $objects[$by_offset[$target_offset]]; - $objects[$i]['content'] = self::applyDelta($base['content'], $objects[$i]['content']); - $objects[$i]['type'] = $base['type']; - } else if($objects[$i]['type'] === self::OBJECT_TYPE_REF_DELTA) { - if(!isset($by_oid[$objects[$i]['reference']])) { - continue; - } - $base = $objects[$by_oid[$objects[$i]['reference']]]; - $objects[$i]['content'] = self::applyDelta($base['content'], $objects[$i]['content']); - $objects[$i]['type'] = $base['type']; - } - $oid = sha1(self::wrap_git_object($objects[$i]['type'], $objects[$i]['content'])); - $objects[$i]['oid'] = $oid; - $by_oid[$oid] = $i; - $by_offset[$objects[$i]['header_offset']] = $i; - ++$resolved_in_this_iteration; - ++$resolved_objects; - } - if($resolved_in_this_iteration === 0) { - throw new Exception('Could not resolve objects'); - } - } // Resolve trees - foreach($objects as $object) { + foreach($parsed_pack['objects'] as $object) { $pack_index->add_object($object['type'], $object['content']); } @@ -402,7 +355,7 @@ static private function readVariableLength($data, &$offset) { return $result; } - static private function parse_pack_data($packData) { + static public function parse_pack_data($packData) { $offset = 0; // Basic sanity checks @@ -435,6 +388,51 @@ static private function parse_pack_data($packData) { $object['content'] = self::inflate_object($packData, $offset, $object['uncompressed_length']); $objects[] = $object; } + + $by_oid = []; + $by_offset = []; + $resolved_objects = 0; + // Index entities and resolve deltas + // Run until all objects are resolved + while($resolved_objects < count($objects)) { + $resolved_in_this_iteration = 0; + for($i = 0; $i < count($objects); $i++) { + // Skip already processed objects + if( + isset($by_offset[$objects[$i]['header_offset']]) && + isset($by_oid[$objects[$i]['oid']]) + ) { + continue; + } + + if($objects[$i]['type'] === self::OBJECT_TYPE_OFS_DELTA) { + $target_offset = $objects[$i]['header_offset'] - $objects[$i]['ofs']; + if(!isset($by_offset[$target_offset])) { + continue; + } + // TODO: Make sure the base object will never be another delta. + $base = $objects[$by_offset[$target_offset]]; + $objects[$i]['content'] = self::applyDelta($base['content'], $objects[$i]['content']); + $objects[$i]['type'] = $base['type']; + } else if($objects[$i]['type'] === self::OBJECT_TYPE_REF_DELTA) { + if(!isset($by_oid[$objects[$i]['reference']])) { + continue; + } + $base = $objects[$by_oid[$objects[$i]['reference']]]; + $objects[$i]['content'] = self::applyDelta($base['content'], $objects[$i]['content']); + $objects[$i]['type'] = $base['type']; + } + $oid = sha1(self::wrap_git_object($objects[$i]['type'], $objects[$i]['content'])); + $objects[$i]['oid'] = $oid; + $by_oid[$oid] = $i; + $by_offset[$objects[$i]['header_offset']] = $i; + ++$resolved_in_this_iteration; + ++$resolved_objects; + } + if($resolved_in_this_iteration === 0) { + throw new Exception('Could not resolve objects'); + } + } return [ 'objects' => $objects, 'total_objects' => $objectCount, diff --git a/packages/playground/data-liberation/src/git/WP_Git_Repository.php b/packages/playground/data-liberation/src/git/WP_Git_Repository.php index 6b466b9d5b..940cb6d977 100644 --- a/packages/playground/data-liberation/src/git/WP_Git_Repository.php +++ b/packages/playground/data-liberation/src/git/WP_Git_Repository.php @@ -123,7 +123,7 @@ class WP_Git_Repository { private $diff_engine; private const DELETE_PLACEHOLDER = 'DELETE_PLACEHOLDER'; - private const NULL_OID = '0000000000000000000000000000000000000000'; + public const NULL_OID = '0000000000000000000000000000000000000000'; public function __construct( WP_Abstract_Filesystem $fs, @@ -372,7 +372,7 @@ public function oid_exists($oid) { public function read_by_path($path, $root_tree_oid=null) { if($root_tree_oid === null) { $head_oid = $this->get_ref_head('HEAD'); - if(false === $this->read_object($head_oid)) { + if(!$head_oid || false === $this->read_object($head_oid)) { return false; } $root_tree_oid = $this->get_parsed_commit()['tree'] ?? null; @@ -433,7 +433,7 @@ public function find_path_descendants($path) { return $oids; } - public function find_objects_added_in($new_tree_oid, $old_tree_oid=null, $options=[]) { + public function find_objects_added_in($new_tree_oid, $old_tree_oid=WP_Git_Repository::NULL_OID, $options=[]) { $old_tree_index = $options['old_tree_index'] ?? $this; if($old_tree_index === null) { $old_tree_index = $this; @@ -452,7 +452,7 @@ public function find_objects_added_in($new_tree_oid, $old_tree_oid=null, $option } // Resolve the actual tree oid if $old_tree_oid is a commit - if($old_tree_oid) { + if(!$this->is_null_oid($old_tree_oid)) { if(false === $old_tree_index->read_object($old_tree_oid)) { $this->last_error = 'Failed to read object: ' . $old_tree_oid; return false; @@ -475,6 +475,9 @@ public function find_objects_added_in($new_tree_oid, $old_tree_oid=null, $option if($current_new_oid === $current_old_oid) { continue; } + if($this->is_null_oid($current_new_oid)) { + continue; + } if(false === $this->read_object($current_new_oid)) { $this->last_error = 'Failed to read object: ' . $current_new_oid; @@ -492,7 +495,7 @@ public function find_objects_added_in($new_tree_oid, $old_tree_oid=null, $option yield $this->get_oid(); $old_tree = []; - if($current_old_oid) { + if(!$this->is_null_oid($current_old_oid)) { if(false === $old_tree_index->read_object($current_old_oid)) { $this->last_error = 'Failed to read object: ' . $current_old_oid; return false; @@ -506,6 +509,10 @@ public function find_objects_added_in($new_tree_oid, $old_tree_oid=null, $option } } + private function is_null_oid($oid) { + return $oid === null || $oid === WP_Git_Repository::NULL_OID; + } + public function set_ref_head($ref, $oid) { $path = $this->resolve_ref_file_path($ref); if(!$path) { diff --git a/packages/playground/data-liberation/src/git/WP_Git_Server.php b/packages/playground/data-liberation/src/git/WP_Git_Server.php index 9fa80d2c10..382b4f8a0e 100644 --- a/packages/playground/data-liberation/src/git/WP_Git_Server.php +++ b/packages/playground/data-liberation/src/git/WP_Git_Server.php @@ -1,8 +1,6 @@ repository = $repository; } + public function handle_request($path, $request_bytes, $response) { + switch($path) { + case '/HEAD': + $response->write(WP_Git_Pack_Processor::encode_packet_line(sha1("a") . " HEAD\n")); + $response->write(WP_Git_Pack_Processor::encode_packet_line("0000")); + $response->end(); + break; + // @TODO handle service=git-upload-pack + case '/info/refs?service=git-upload-pack': + $parsed = $this->parse_message($request_bytes); + $response->send_header( + 'Content-Type', + 'application/x-git-upload-pack-advertisement' + ); + $response->send_header( + 'Cache-Control', + 'no-cache' + ); + $response->send_header( + 'Git-Protocol', + 'version=2' + ); + $response->write(WP_Git_Pack_Processor::encode_packet_lines([ + "# service=git-upload-pack\n", + "0000", + "version 2\n", + "agent=git/github-395dce4f6ecf\n", + "ls-refs=unborn\n", + "fetch=shallow wait-for-done filter\n", + "server-option\n", + "object-format=sha1\n", + "0000" + ])); + flush(); + $response->end(); + break; + case '/git-upload-pack': + $parsed = $this->parse_message($request_bytes); + switch($parsed['capabilities']['command']) { + case 'ls-refs': + $this->handle_ls_refs_request($request_bytes, $response); + break; + case 'fetch': + $this->handle_fetch_request($request_bytes, $response); + break; + default: + throw new Exception('Unknown command: ' . $parsed['capabilities']['command']); + } + break; + case '/git-receive-pack': + throw new Exception('Not implemented yet'); + default: + throw new Exception('Unknown path: ' . $path); + } + } + /** * Handle Git protocol v2 ls-refs command * @@ -51,27 +105,46 @@ public function __construct(WP_Git_Repository $repository) { * pointing to an unborn branch in the form "unborn HEAD symref-target:". * * @see https://git-scm.com/docs/protocol-v2#_ls_refs - * @param array $request The parsed request data + * @param array $request_bytes The parsed request data * @return string The response in Git protocol v2 format */ - public function handle_ls_refs_request($request) { - $parsed = $this->parse_message($request); + public function handle_ls_refs_request($request_bytes, ResponseWriter $response) { + $response->send_header( + 'Content-Type', + 'application/x-git-upload-pack-advertisement' + ); + $response->send_header( + 'Cache-Control', + 'no-cache' + ); + $response->send_header( + 'Git-Protocol', + 'version=2' + ); + + $parsed = $this->parse_message($request_bytes); if(!$parsed) { - return false; + // return false; } $prefix = $parsed['arguments']['ref-prefix'][0] ?? ''; $refs = $this->repository->list_refs($prefix); - $response = ''; + $first_ref = array_key_first($refs); foreach ($refs as $ref_name => $ref_hash) { + $line = $ref_hash . ' ' . $ref_name; + if($ref_name === $first_ref) { + $line .= "\0multi_ack thin-pack side-band side-band-64k ofs-delta shallow deepen-since deepen-not deepen-relative no-progress include-tag multi_ack_detailed allow-tip-sha1-in-want allow-reachable-sha1-in-want no-done symref=HEAD:refs/heads/trunk filter object-format=sha1 agent=git/github-395dce4f6ecf"; + } // Format: \n - $response .= WP_Git_Pack_Processor::encode_packet_line( - $ref_hash . ' ' . $ref_name . "\n" + $response->write( + WP_Git_Pack_Processor::encode_packet_line( + $line . "\n" + ) ); } // End the response with 0000 - return $response . "0000"; + $response->write(WP_Git_Pack_Processor::encode_packet_line("0000")); } /** @@ -105,18 +178,18 @@ public function capability_advertise() { "0000"; } - public function parse_message($request_bytes) { + public function parse_message($request_bytes_bytes) { $offset = 0; return [ - 'capabilities' => $this->parse_capabilities($request_bytes, $offset), - 'arguments' => $this->parse_arguments($request_bytes, $offset), + 'capabilities' => $this->parse_capabilities($request_bytes_bytes, $offset), + 'arguments' => $this->parse_arguments($request_bytes_bytes, $offset), ]; } - private function parse_capabilities($request_bytes, &$offset=0) { + private function parse_capabilities($request_bytes_bytes, &$offset=0) { $capabilities = []; while (true) { - $line = WP_Git_Pack_Processor::decode_next_packet_line($request_bytes, $offset); + $line = WP_Git_Pack_Processor::decode_next_packet_line($request_bytes_bytes, $offset); if ($line === false || $line['type'] !== '#packet') { break; } @@ -126,10 +199,10 @@ private function parse_capabilities($request_bytes, &$offset=0) { return $capabilities; } - private function parse_arguments($request_bytes, &$offset=0) { + private function parse_arguments($request_bytes_bytes, &$offset=0) { $arguments = []; while (true) { - $line = WP_Git_Pack_Processor::decode_next_packet_line($request_bytes, $offset); + $line = WP_Git_Pack_Processor::decode_next_packet_line($request_bytes_bytes, $offset); if ($line === false || $line['type'] !== '#packet') { break; } @@ -154,38 +227,98 @@ private function parse_arguments($request_bytes, &$offset=0) { /** * Handle Git protocol v2 fetch command with "want" packets * - * @param array $request The parsed request data + * @param array $request_bytes The parsed request data * @return string The response in Git protocol v2 format containing the pack data */ - public function handle_fetch_request($request) { - $parsed = $this->parse_message($request); + public function handle_fetch_request($request_bytes, $response) { + $parsed = $this->parse_message($request_bytes); if (!$parsed || empty($parsed['arguments']['want'])) { return false; } + $filter_raw = $parsed['arguments']['filter'][0] ?? null; + $filter = $this->parse_filter($filter_raw); + if($filter === false) { + throw new Exception('Invalid filter: ' . $filter_raw); + } + + $have_oids = [ + WP_Git_Repository::NULL_OID => true, + ]; + if(isset($parsed['arguments']['have'])) { + foreach($parsed['arguments']['have'] as $have_hash) { + $have_oids[$have_hash] = true; + } + } + $objects_to_send = []; + $acks = []; foreach ($parsed['arguments']['want'] as $want_hash) { + // For all the requested non-shallow commits, find + // most recent parent commit the client we have in + // common with the client. + $common_parent_hash = WP_Git_Repository::NULL_OID; + $commit_hash = $want_hash; + while($this->repository->read_object($commit_hash)) { + $objects_to_send[] = $commit_hash; + if($this->repository->get_type() !== WP_Git_Pack_Processor::OBJECT_TYPE_COMMIT) { + // Just send non-commit objects as they are. It would be lovely to + // delta-compress them in the future. + continue 2; + } + + $parsed_commit = $this->repository->get_parsed_commit(); + if(!isset($parsed_commit['parent'])) { + break; + } + + $commit_hash = $parsed_commit['parent']; + if(isset($have_oids[$commit_hash])) { + break; + } + } + $common_parent_hash = $commit_hash; + // For each wanted commit, find objects not present in any of the have commits $new_objects = $this->repository->find_objects_added_in( $want_hash, - $parsed['arguments']['have'] ?? ['0000000000000000000000000000000000000000'] + $common_parent_hash ); - $objects_to_send = array_merge($objects_to_send, $new_objects); + $objects_to_send = array_merge( + $objects_to_send, + iterator_to_array($new_objects) + ); + if($common_parent_hash !== WP_Git_Repository::NULL_OID) { + $acks[] = $common_parent_hash; + } } - $objects_to_send = array_unique($objects_to_send); + $acks = array_unique($acks); + if(count($parsed['arguments']['have']) > 0) { + $response->write(WP_Git_Pack_Processor::encode_packet_line("acknowledgments\n")); + if(count($acks) > 0) { + foreach($acks as $ack) { + $response->write(WP_Git_Pack_Processor::encode_packet_line("ACK $ack\n")); + } + } else { + $response->write(WP_Git_Pack_Processor::encode_packet_line("NAK\n")); + } + $response->write(WP_Git_Pack_Processor::encode_packet_line("ready\n")); + $response->write(WP_Git_Pack_Processor::encode_packet_line("0001")); + } + $response->write(WP_Git_Pack_Processor::encode_packet_line("packfile\n")); // Pack the objects + $objects_to_send = array_unique($objects_to_send); $pack_objects = []; foreach ($objects_to_send as $oid) { $this->repository->read_object($oid); // Apply blob filters if specified if ($this->repository->get_type() === WP_Git_Pack_Processor::OBJECT_TYPE_BLOB) { - $filter = $parsed['arguments']['filter'] ?? null; - if ($filter) { - if ($filter['type'] === 'none') { + if ($filter['type'] === 'blob') { + if ($filter['filter'] === 'none') { continue; // Skip all blobs - } else if ($filter['type'] === 'limit') { + } else if ($filter['filter'] === 'limit') { $content = $this->repository->read_entire_object_contents(); if (strlen($content) > $filter['size']) { continue; // Skip large blobs @@ -205,15 +338,29 @@ public function handle_fetch_request($request) { // @TODO: Implement history truncation based on deepen value // This would involve walking the commit history and including // only commits within the specified depth + throw new Exception('Deepen is not implemented yet'); } // Encode the pack + // @TODO: Stream the pack data instead of buffering it $pack_data = WP_Git_Pack_Processor::encode($pack_objects); - // Format the response according to protocol v2 - return - WP_Git_Pack_Processor::encode_packet_line("packfile\n") . - WP_Git_Pack_Processor::encode_packet_line("\x01" . $pack_data) . // side-band channel 1 - "0000"; + $response->write(WP_Git_Pack_Processor::encode_packet_line("\x01" . $pack_data)); + $response->write(WP_Git_Pack_Processor::encode_packet_line("0000")); + $response->end(); + return true; + } + + private function parse_filter($filter) { + if($filter === null) { + return ['type' => 'none']; + } else if($filter === 'blob:none') { + return ['type' => 'blob', 'filter' => 'none']; + } else if(str_starts_with($filter, 'blob:limit=')) { + $limit = substr($filter, strlen('blob:limit=')); + return ['type' => 'blob', 'filter' => 'limit', 'size' => intval($limit)]; + } + return false; } } + diff --git a/packages/playground/data-liberation/tests/WPGitServerTests.php b/packages/playground/data-liberation/tests/WPGitServerTests.php index 030bf56d92..413f99836a 100644 --- a/packages/playground/data-liberation/tests/WPGitServerTests.php +++ b/packages/playground/data-liberation/tests/WPGitServerTests.php @@ -2,6 +2,7 @@ use PHPUnit\Framework\TestCase; use WordPress\Filesystem\WP_In_Memory_Filesystem; +use WordPress\AsyncHttp\ResponseWriter\BufferingResponseWriter; class WPGitServerTests extends TestCase { @@ -255,4 +256,170 @@ public function provideRefRequests() { ]; } + public function test_handle_fetch_request_returns_packfile() { + // Create a more complex repository structure for testing + $readme_oid = $this->repository->add_object( + WP_Git_Pack_Processor::OBJECT_TYPE_BLOB, + "# Hello World" + ); + $large_file_oid = $this->repository->add_object( + WP_Git_Pack_Processor::OBJECT_TYPE_BLOB, + str_repeat('x', 2000) // 2KB file + ); + + $tree_oid = $this->repository->add_object( + WP_Git_Pack_Processor::OBJECT_TYPE_TREE, + WP_Git_Pack_Processor::encode_tree_bytes([ + [ + 'mode' => WP_Git_Pack_Processor::FILE_MODE_REGULAR_NON_EXECUTABLE, + 'name' => 'README.md', + 'sha1' => $readme_oid + ], + [ + 'mode' => WP_Git_Pack_Processor::FILE_MODE_REGULAR_NON_EXECUTABLE, + 'name' => 'large.txt', + 'sha1' => $large_file_oid + ] + ]) + ); + + $commit_oid = $this->repository->add_object( + WP_Git_Pack_Processor::OBJECT_TYPE_COMMIT, + "tree $tree_oid\nparent 0000000000000000000000000000000000000000\nauthor Test 1234567890 +0000\ncommitter Test 1234567890 +0000\n\nInitial commit\n" + ); + + $test_cases = [ + 'basic fetch' => [ + 'request' => WP_Git_Pack_Processor::encode_packet_lines([ + "command=fetch\n", + "0000", + "want $commit_oid\n", + "done\n", + "0000", + ]), + 'expected_oids' => [ + $commit_oid, + $tree_oid, + $readme_oid, + $large_file_oid, + ], + ], + 'fetch with blob:none filter' => [ + 'request' => WP_Git_Pack_Processor::encode_packet_lines([ + "command=fetch\n", + "0000", + "want $commit_oid\n", + "filter blob:none\n", + "done\n", + "0000", + ]), + 'expected_oids' => [ + $commit_oid, + $tree_oid, + ], + ], + 'fetch with blob size limit' => [ + 'request' => WP_Git_Pack_Processor::encode_packet_lines([ + "command=fetch\n", + "0000", + "want $commit_oid\n", + "filter blob:limit=1000\n", + "done\n", + "0000", + ]), + 'expected_oids' => [ + $commit_oid, + $tree_oid, + $readme_oid, + ], + ], + 'fetch with multiple wants' => [ + 'request' => WP_Git_Pack_Processor::encode_packet_lines([ + "command=fetch\n", + "0000", + "want $commit_oid\n", + "want $tree_oid\n", + "done\n", + "0000", + ]), + // same objects, just different entry point + 'expected_oids' => [ + $commit_oid, + $tree_oid, + $readme_oid, + $large_file_oid, + ], + ] + ]; + + foreach ($test_cases as $name => $test) { + /** @var BufferingResponseWriter */ + $response = $this->getMockBuilder(BufferingResponseWriter::class) + ->onlyMethods(['end']) + ->getMock(); + $this->server->handle_fetch_request($test['request'], $response); + + // Verify response format + $response = $response->get_buffered_body(); + $expected_response_start = WP_Git_Pack_Processor::encode_packet_lines([ + "acknowledgments\n", + "NACK\n", + "ready\n", + "0001", + "packfile\n", + ]); + $actual_response_start = substr($response, 0, strlen($expected_response_start)); + $this->assertEquals( + $expected_response_start, + $actual_response_start, + "$name: Response should start with packfile header" + ); + + $rest_of_response = substr($response, strlen($expected_response_start)); + $pack_data = WP_Git_Client::accumulate_pack_data_from_multiplexed_chunks( + $rest_of_response + ); + $pack = WP_Git_Pack_Processor::parse_pack_data($pack_data); + + $this->assertCount( + count($test['expected_oids']), + $pack['objects'], + "$name: Pack should contain expected number of objects" + ); + foreach($pack['objects'] as $object) { + $this->assertContains($object['oid'], $test['expected_oids']); + } + } + } + + // public function test_handle_fetch_request_validates_filter() { + // $this->expectException(Exception::class); + // $this->expectExceptionMessage('Invalid filter: invalid:filter'); + + // $request = WP_Git_Pack_Processor::encode_packet_lines([ + // "command=fetch\n", + // "0000", + // "want " . $this->main_branch_oid . "\n", + // "filter invalid:filter\n", + // "done\n", + // "0000", + // ]); + + // $this->server->handle_fetch_request($request); + // } + + // public function test_handle_fetch_request_requires_want() { + // $request = WP_Git_Pack_Processor::encode_packet_lines([ + // "command=fetch\n", + // "0000", + // "done\n", + // "0000", + // ]); + + // $this->assertFalse( + // $this->server->handle_fetch_request($request), + // "Fetch request without want should return false" + // ); + // } + } \ No newline at end of file