Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions components/Git/Tests/GitRemoteTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
<?php

namespace WordPress\Git\Tests;

use PHPUnit\Framework\TestCase;
use WordPress\ByteStream\MemoryPipe;
use WordPress\Filesystem\InMemoryFilesystem;
use WordPress\Git\GitEndpoint;
use WordPress\Git\GitFilesystem;
use WordPress\Git\GitRemote;
use WordPress\Git\GitRepository;
use WordPress\Git\Protocol\GitProtocolEncoderPipe;
use WordPress\HttpClient\Response;

class GitRemoteTest extends TestCase {

/**
* Verifies the issue #292 path: a caller can request the remote symbolic
* HEAD ref and read files from the fetched commit through GitFilesystem.
*/
public function test_pull_head_resolves_remote_head() {
$remote_repository = new GitRepository(
InMemoryFilesystem::create(),
array(
'default_branch' => '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 );
}
}
37 changes: 29 additions & 8 deletions components/Git/class-gitremote.php
Original file line number Diff line number Diff line change
Expand Up @@ -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/' ) );
Expand Down Expand Up @@ -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:<hash>".
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:<hash>".
$ref_name = $matches[1];
$hash = $matches[2];
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
Loading