diff --git a/components/Git/Tests/GitRemoteTest.php b/components/Git/Tests/GitRemoteTest.php new file mode 100644 index 000000000..596074853 --- /dev/null +++ b/components/Git/Tests/GitRemoteTest.php @@ -0,0 +1,187 @@ + 'main', + ) + ); + $remote_head = $remote_repository->commit( + array( + 'updates' => array( + 'README.md' => 'Hello from HEAD', + ), + ) + ); + + $local_repository = new GitRepository( InMemoryFilesystem::create() ); + $local_repository->add_remote( 'origin', 'https://example.com/repo.git' ); + + $remote = new GitRemote( + $local_repository, + 'origin', + array( + 'http_client' => new GitRemoteTestClient( new GitEndpoint( $remote_repository ) ), + ) + ); + + // The return value should be the commit advertised by the remote HEAD. + $this->assertSame( $remote_head, $remote->pull( 'HEAD' ) ); + + // The local repository should also point HEAD at that fetched commit. + $this->assertSame( $remote_head, $local_repository->get_branch_tip( 'HEAD' ) ); + + // The fetched commit's tree should be available for Blueprint git:directory use. + $this->assertSame( 'Hello from HEAD', GitFilesystem::create( $local_repository )->get_contents( '/README.md' ) ); + } + + /** + * Verifies ls-refs attributes are parsed separately from ref names. + */ + public function test_ls_refs_strips_nul_attributes_and_reads_peeled_hash() { + $tag_hash = '1111111111111111111111111111111111111111'; + $peeled_hash = '2222222222222222222222222222222222222222'; + + $local_repository = new GitRepository( InMemoryFilesystem::create() ); + $local_repository->add_remote( 'origin', 'https://example.com/repo.git' ); + + $remote = new GitRemote( + $local_repository, + 'origin', + array( + 'http_client' => new GitRemoteStaticResponseClient( + GitProtocolEncoderPipe::encode_packet_lines( + array( + $tag_hash . " refs/tags/v1.0\0peeled:" . $peeled_hash . "\n", + '0000', + ) + ) + ), + ) + ); + + $this->assertSame( + array( 'refs/tags/v1.0' => $peeled_hash ), + $remote->ls_refs( 'refs/tags/v1.0' ) + ); + } +} + +/** + * Minimal in-process Git HTTP client used by GitRemoteTest. + */ +class GitRemoteTestClient { + + private $endpoint; + + public function __construct( GitEndpoint $endpoint ) { + $this->endpoint = $endpoint; + } + + public function fetch( $request, array $options = array() ) { + $path = parse_url( $request->url, PHP_URL_PATH ); + $request_body = $request->upload_body_stream ? $request->upload_body_stream->consume_all() : ''; + $response = new GitProtocolEncoderPipe(); + + // Route GitRemote's upload-pack requests to the in-memory GitEndpoint. + if ( 0 === substr_compare( $path, '/git-upload-pack', - strlen( '/git-upload-pack' ) ) ) { + $parsed = $this->endpoint->parse_message( $request_body ); + $command = $parsed['capabilities']['command'] ?? 'fetch'; + switch ( $command ) { + case 'ls-refs': + $this->endpoint->handle_ls_refs_request( $request_body, $response ); + break; + case 'fetch': + if ( ! isset( $parsed['capabilities']['command'] ) ) { + $request_body = $this->normalize_legacy_fetch_request( $request_body ); + } + $this->endpoint->handle_fetch_request( $request_body, $response ); + break; + } + } + + return new GitRemoteTestResponseStream( $response->consume_all(), $request ); + } + + private function normalize_legacy_fetch_request( $request_body ) { + /* + * GitRemote::git_upload_pack() currently emits a compact fetch request + * without the protocol v2 command header. The test endpoint expects that + * header, so normalize only the test request before routing it. + */ + $offset = 0; + $lines = array( + "command=fetch\n", + "agent=git/2.37.3\n", + "object-format=sha1\n", + '0001', + ); + + while ( $packet = GitEndpoint::decode_next_packet_line( $request_body, $offset ) ) { + if ( '#packet' !== $packet['type'] ) { + continue; + } + + $payload = $packet['payload']; + if ( 0 === strpos( $payload, 'want ' ) ) { + $parts = explode( ' ', $payload ); + $payload = 'want ' . $parts[1]; + } + + $lines[] = $payload . "\n"; + } + $lines[] = '0000'; + + return GitProtocolEncoderPipe::encode_packet_lines( $lines ); + } +} + +/** + * Memory-backed response stream with the await_response() method GitRemote expects. + */ +class GitRemoteTestResponseStream extends MemoryPipe { + + private $response; + + public function __construct( $bytes, $request ) { + parent::__construct( $bytes ); + $this->response = new Response( $request ); + $this->response->status_code = 200; + } + + public function await_response() { + return $this->response; + } +} + +class GitRemoteStaticResponseClient { + + private $response_bytes; + + public function __construct( $response_bytes ) { + $this->response_bytes = $response_bytes; + } + + public function fetch( $request, array $options = array() ) { + return new GitRemoteTestResponseStream( $this->response_bytes, $request ); + } +} diff --git a/components/Git/class-gitremote.php b/components/Git/class-gitremote.php index 0e1629410..2d4695418 100644 --- a/components/Git/class-gitremote.php +++ b/components/Git/class-gitremote.php @@ -72,12 +72,12 @@ public function ls_refs( $prefix = '' ) { while ( $protocol->next_token() ) { switch ( $protocol->get_token_type() ) { case '#packet-footer': - $ref_line = $protocol->get_packet_body(); - $ref = $this->parse_ref_line( $ref_line ); - $refs[ $ref['ref_name'] ] = $ref['hash']; + $ref_line = $protocol->get_packet_body(); + $ref = $this->parse_ref_line( $ref_line ); if ( false === $ref ) { continue 2; } + $refs[ $ref['ref_name'] ] = $ref['hash']; if ( 0 === strncmp( $ref['ref_name'], 'refs/heads/', strlen( 'refs/heads/' ) ) ) { $branch_name = substr( $ref['ref_name'], strlen( 'refs/heads/' ) ); @@ -110,9 +110,20 @@ private function parse_ref_line( $ref_line ) { } $hash = substr( $ref_line, 0, $space_pos ); $ref_name = substr( $ref_line, $space_pos + 1 ); + $attrs = ''; + + // Git may append NUL-separated attributes after the ref name, e.g. "HEAD\0symref=...". + $attributes_pos = strpos( $ref_name, "\0" ); + if ( false !== $attributes_pos ) { + $attrs = substr( $ref_name, $attributes_pos + 1 ); + $ref_name = substr( $ref_name, 0, $attributes_pos ); + } - // Check for peeled hash at end. - if ( preg_match( '/^(.+) peeled:([a-f0-9]{40})$/', $ref_name, $matches ) ) { + // Protocol v2 advertises peeled tag hashes as NUL-separated attributes: "ref\0peeled:". + if ( preg_match( '/(?:^| )peeled:([a-f0-9]{40})(?: |$)/', $attrs, $matches ) ) { + $hash = $matches[1]; + } elseif ( preg_match( '/^(.+) peeled:([a-f0-9]{40})$/', $ref_name, $matches ) ) { + // Keep compatibility with responses that append peeled hashes after the ref name: "ref peeled:". $ref_name = $matches[1]; $hash = $matches[2]; } @@ -291,8 +302,16 @@ public function pull( $full_branch_name = null, $options = array() ) { } if ( isset( $options['force'] ) && $options['force'] ) { - $nice_branch_name = $this->localize_ref_name( $full_branch_name ); - $this->repository->set_branch_tip( 'refs/heads/' . $nice_branch_name, $remote_head ); + /** + * HEAD is a special ref, not a branch name. Store it directly instead + * of rewriting it to refs/heads/HEAD, which would create a fake branch. + */ + if ( 'HEAD' === $full_branch_name ) { + $this->repository->set_branch_tip( 'HEAD', $remote_head ); + } else { + $nice_branch_name = $this->localize_ref_name( $full_branch_name ); + $this->repository->set_branch_tip( 'refs/heads/' . $nice_branch_name, $remote_head ); + } return $remote_head; } @@ -333,7 +352,9 @@ public function fetch( $full_branch_name, $options = array() ) { $last_fetched_head_ref = Commit::NULL_HASH; } - $remote_head = $this->get_remote_head( 'refs/heads/' . $branch_name ); + // Remote HEAD is advertised as "HEAD"; regular branch names are advertised under refs/heads/. + $remote_branch_name = 'HEAD' === $full_branch_name ? 'HEAD' : 'refs/heads/' . $branch_name; + $remote_head = $this->get_remote_head( $remote_branch_name ); try { if ( $remote_head === $last_fetched_head_ref ) { return $remote_head;