package com.atlassian.bitbucket.mesh.git;

import com.atlassian.bitbucket.mesh.AbstractGrpcServiceTest;
import com.atlassian.bitbucket.mesh.MockResponseObserver;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.builder.catfile.InfoBatchCheckCatFilesStdioHandler;
import com.atlassian.bitbucket.mesh.git.builder.config.GetConfigCompletionHandler;
import com.atlassian.bitbucket.mesh.git.builder.difftree.CallbackChangeDiffTreeStdioHandler;
import com.atlassian.bitbucket.mesh.git.builder.foreachref.*;
import com.atlassian.bitbucket.mesh.git.builder.revlist.CallbackRevListStdioHandler;
import com.atlassian.bitbucket.mesh.git.builder.revlist.GitRevListBuilder;
import com.atlassian.bitbucket.mesh.git.builder.revlist.SingleCommitRevListStdioHandler;
import com.atlassian.bitbucket.mesh.git.builder.revlist.TraversalRevListStdioHandler;
import com.atlassian.bitbucket.mesh.git.commit.Commit;
import com.atlassian.bitbucket.mesh.git.commit.CommitGraphNode;
import com.atlassian.bitbucket.mesh.git.commit.TraversalCallback;
import com.atlassian.bitbucket.mesh.git.commit.TraversalStatus;
import com.atlassian.bitbucket.mesh.git.content.Change;
import com.atlassian.bitbucket.mesh.git.exception.CommandFailedException;
import com.atlassian.bitbucket.mesh.git.exception.CommitNotFoundException;
import com.atlassian.bitbucket.mesh.git.exception.ObjectNotFoundException;
import com.atlassian.bitbucket.mesh.git.exception.RepositoryReadOnlyException;
import com.atlassian.bitbucket.mesh.git.ref.Branch;
import com.atlassian.bitbucket.mesh.git.ref.Ref;
import com.atlassian.bitbucket.mesh.git.ref.Tag;
import com.atlassian.bitbucket.mesh.grpc.BidirectionalFragmentResponseObserver;
import com.atlassian.bitbucket.mesh.grpc.FragmentResponseObserver;
import com.atlassian.bitbucket.mesh.grpc.RequestFragmentCreator;
import com.atlassian.bitbucket.mesh.io.MoreFiles;
import com.atlassian.bitbucket.mesh.junit.LogListener;
import com.atlassian.bitbucket.mesh.junit.TemporaryDirectory;
import com.atlassian.bitbucket.mesh.process.ProcessStdioHandler;
import com.atlassian.bitbucket.mesh.process.ProcessSummary;
import com.atlassian.bitbucket.mesh.process.handler.*;
import com.atlassian.bitbucket.mesh.repository.BitbucketServerRepository;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.atlassian.bitbucket.mesh.rpc.v1.*;
import com.atlassian.bitbucket.mesh.test.HasGitError;
import com.atlassian.bitbucket.mesh.test.annotation.DirtiesRepo;
import com.atlassian.bitbucket.mesh.test.annotation.Repo;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.protobuf.ByteString;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import io.grpc.stub.StreamObserver;
import org.apache.commons.lang3.StringUtils;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;

import javax.annotation.Nonnull;
import javax.annotation.Resource;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Predicate;
import java.util.stream.Stream;

import static com.atlassian.bitbucket.mesh.git.GitConstants.*;
import static com.atlassian.bitbucket.mesh.git.GitRefPattern.HEADS;
import static com.atlassian.bitbucket.mesh.git.GitRefPattern.TAGS;
import static com.atlassian.bitbucket.mesh.git.GitUtils.NULL_TREE;
import static com.atlassian.bitbucket.mesh.git.builder.foreachref.ForEachTagParser.FORMAT;
import static com.atlassian.bitbucket.mesh.git.builder.revlist.CommitPumpSettings.UNLIMITED_MESSAGE_LENGTH;
import static com.atlassian.bitbucket.mesh.git.ref.RefOrder.ALPHABETICAL;
import static com.atlassian.bitbucket.mesh.git.ref.RefOrder.MODIFICATION;
import static com.atlassian.bitbucket.mesh.rpc.util.ByteStringUtils.toByteStringsLazily;
import static com.atlassian.bitbucket.mesh.util.ShaUtils.NULL_SHA1;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Objects.requireNonNull;
import static org.hamcrest.Matchers.*;

@SuppressWarnings("deprecation")
public class GrpcPlumbingServiceTest extends AbstractGrpcServiceTest {

    //protobuf doesn't have the concept of null/unset, so this provides some reasonable defaults that
    //can then be customized as necessary inside various tests, rather than repeating them everywhere
    private static final RpcDiffOptions DEFAULT_DIFF_OPTIONS = RpcDiffOptions.newBuilder()
            .setRenames(RpcDiffRenames.RENAMES_OFF)
            .build();
    private static final String HEAD = "refs/heads/master";

    @Rule
    public final LogListener logListener = new LogListener(GrpcPlumbingService.class);
    @Rule
    public final TemporaryDirectory temporaryDirectory = new TemporaryDirectory();
    @Rule
    public final ExpectedException thrown = ExpectedException.none();

    @Resource
    private GitAgent agent;
    @Repo
    private BitbucketServerRepository bitbucketServer;
    @Resource
    private GitProcessBuilderFactory builderFactory;
    @Repo(GitRepositories.DIFF_TREE)
    private Repository diffTree;
    @Repo(GitRepositories.COMMENT_DRIFT)
    private Repository drift;
    @Repo(GitRepositories.MERGE)
    private Repository merge;
    @Repo(GitRepositories.MERGE_COMMITS)
    private Repository mergeCommits;
    @Repo(GitRepositories.QA_RESOURCES)
    private Repository qaResources;
    @Repo
    private Repository repository;

    @Test
    public void testCallBlockGc() {
        RpcCallRequest.Builder builder = createCallRequestBuilder("gc")
                .setRepository(repository.getId());

        FragmentResponseObserver<List<String>> observer =
                new FragmentResponseObserver<>(new LinesStdioHandler(), new GitProcessCompletionHandler());
        stub().call(builder.build(), observer);

        StatusRuntimeException thrown = assertThrows(StatusRuntimeException.class, observer::asResult);
        assertSame(Status.Code.INVALID_ARGUMENT, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.unsupportedCommand("git gc"));
    }

    @Test
    public void testCallCatFile() {
        RpcCallRequest.Builder builder = createCallRequestBuilder("cat-file")
                .addArguments(ByteString.copyFromUtf8("-p"))
                .addArguments(ByteString.copyFromUtf8("master:modification/mod_file.txt"))
                .setRepository(repository.getId());

        List<String> file = call(builder, new LinesStdioHandler());

        assertEquals(2, file.size());
        assertNotNull(file);
        assertEquals("this is a test file", file.get(0));
        assertEquals("another line", file.get(1));
    }

    @Test
    public void testCallConfigWithNonDefaultCompletionHandlerForNonExistentKey() {
        RpcCallRequest.Builder builder = createCallRequestBuilder("config")
                .addArguments(ByteString.copyFromUtf8("--get"))
                .addArguments(ByteString.copyFromUtf8(CONFIG_GC_PRUNE_EXPIRE))
                .setRepository(bitbucketServer.getId());

        // The requested config key does not exist, so an empty response is returned when using
        // GetConfigCompletionHandler (rather than throwing an exception, which is what the default exit handler does).
        FragmentResponseObserver<String> observer =
                new FragmentResponseObserver<>(new StringStdioHandler(), new GetConfigCompletionHandler());
        stub().call(builder.build(), observer);
        String result = observer.asResult();

        assertThat(result, emptyString());
        logListener.assertWarn("'git config' is deprecated");
    }

    @Test
    public void testCallDiffWithConfiguration() {
        RpcCallRequest.Builder builder = createCallRequestBuilder("diff")
                .addAllArguments(toByteStringsLazily(ImmutableList.of(
                        "-U",
                        "--dst-prefix=dst://",
                        "--src-prefix=src://",
                        "9c05f43f859375e392d90d23a13717c16d0fdcda",
                        "2d8897c9ac29ce42c3442cf80ac977057045e7f6")))
                .setOptions(RpcGitOptions.newBuilder()
                        .putConfig("color.ui", ByteString.copyFromUtf8("never"))
                        .putConfig("diff.renames", ByteString.copyFromUtf8("copies")))
                .setRepository(repository.getId());

        String result = call(builder, new StringStdioHandler());

        assertThat(result, startsWith("diff --git src://binary/A.zip dst://binary/B.zip"));
    }

    @Test
    public void testCallLsTreeWithNonexistentCommit() {
        thrown.expect(ObjectNotFoundException.class);
        thrown.expectMessage("No such object: nonexistent");

        RpcCallRequest.Builder builder = createCallRequestBuilder("ls-tree")
                .addArguments(ByteString.copyFromUtf8("nonexistent"))
                .addArguments(ByteString.copyFromUtf8("--"))
                .addArguments(ByteString.copyFromUtf8("irrelevant"))
                .setRepository(repository.getId());

        call(builder, new StringStdioHandler());
    }

    @DirtiesRepo
    @Test
    public void testCallNotes() throws Exception {
        String note = "D flat";
        String object = "9c05f43f859375e392d90d23a13717c16d0fdcda";

        // Add the notes entry from stdin
        RpcCallRequest.Builder builder = createCallRequestBuilder("notes")
                .addArguments(ByteString.copyFromUtf8("add"))
                .addArguments(ByteString.copyFromUtf8("-F"))
                .addArguments(ByteString.copyFromUtf8("-"))
                .addArguments(ByteString.copyFromUtf8(object))
                .setOptions(RpcGitOptions.newBuilder()
                        .putConfig(CONFIG_USER_EMAIL, ByteString.copyFromUtf8("jdoe@example.com"))
                        .putConfig(CONFIG_USER_NAME, ByteString.copyFromUtf8("Jane Doe"))
                        .build())
                .setRepository(repository.getId());

        MockResponseObserver<RpcStreamFragment> responseObserver = new MockResponseObserver<>();
        StreamObserver<RpcCallStdinFragment> streamObserver = stub().callStdin(responseObserver);

        streamObserver.onNext(RpcCallStdinFragment.newBuilder()
                .setRequest(builder)
                .build());

        streamObserver.onNext(RpcCallStdinFragment.newBuilder()
                .setStdin(RpcStreamChunk.newBuilder()
                        .setData(ByteString.copyFromUtf8(note))
                        .build())
                .build());

        streamObserver.onCompleted();
        responseObserver.await();

        // Show the notes entry
        builder = createCallRequestBuilder("notes")
                .addArguments(ByteString.copyFromUtf8("show"))
                .addArguments(ByteString.copyFromUtf8(object))
                .setRepository(repository.getId());

        String output = call(builder, new StringStdioHandler());

        assertThat(output, equalTo(note));
    }

    @Test
    public void testCallStdinThrowsForBlockedCommand() {
        StreamFragmentResponseObserver responseObserver = new StreamFragmentResponseObserver();
        StreamObserver<RpcCallStdinFragment> requestObserver = stub().callStdin(responseObserver);

        //Send a command that _should_ be rejected
        requestObserver.onNext(RpcCallStdinFragment.newBuilder()
                .setRequest(RpcCallRequest.newBuilder()
                        .setRepository(repository.getId())
                        .setCommand("credential")
                        .addArguments(ByteString.copyFromUtf8("fill")))
                .build());
        try {
            requestObserver.onNext(RpcCallStdinFragment.newBuilder()
                    .setStdin(RpcStreamChunk.newBuilder()
                            .setClosed(true)
                            .setData(ByteString.copyFrom(
                                    "protocol=https\nhost=stash.dev.internal.atlassian.com\npath=scm/stash/stash.git\n\n",
                                    UTF_8)))
                    .build());
            requestObserver.onCompleted();
        } catch (RuntimeException e) {
            //The second request will throw if the first has already been rejected, but it may get through before the
            //server closes the RPC. Regardless, we ignore the error here and verify that the server has terminated
            //with the expected error. We only send the 2nd message to ensure the command finishes, even when not
            //rejected
        }

        //Wait for the command to complete
        assertThrows(StatusRuntimeException.class, () -> responseObserver.await(3, TimeUnit.SECONDS));

        //Verify that the command has been rejected
        Throwable throwable = responseObserver.getThrowable();
        assertNotNull(throwable);
        assertUnsupportedCommand("git credential fill", throwable);
        assertEquals(0, responseObserver.getFragments().size());
        assertFalse(responseObserver.isCompleted());
    }

    @Test
    public void testCallStdinUpdateRefFailsForReadOnlyRepository() {
        FragmentResponseObserver<List<String>> responseObserver =
                new FragmentResponseObserver<>(new LinesStdioHandler(), new GitProcessCompletionHandler());
        StreamObserver<RpcCallStdinFragment> requestObserver = stub().callStdin(responseObserver);

        requestObserver.onNext(RpcCallStdinFragment.newBuilder()
                .setRequest(RpcCallRequest.newBuilder()
                        .setRepository(repository.getId())
                        .setCommand("update-ref")
                        .addArguments(ByteString.copyFromUtf8("--stdin")))
                .build());

        repository.setReadOnly(true);
        try {
            requestObserver.onNext(RpcCallStdinFragment.newBuilder()
                    .setStdin(RpcStreamChunk.newBuilder()
                            .setClosed(true)
                            .setData(ByteString.copyFrom(
                                    "create refs/heads/read-only-test 0a943a29376f2336b78312d99e65da17048951db\n",
                                    UTF_8)))
                    .build());
            requestObserver.onCompleted();

            assertThrows(RepositoryReadOnlyException.class, responseObserver::asResult);
            assertNull(agent.resolveBranch(repository, "refs/heads/read-only-test"));
        } finally {
            repository.setReadOnly(false);
        }
    }

    @Test
    public void testCallSymbolicRefAllowedForBitbucketServerRepository() {
        RpcCallRequest.Builder builder = createCallRequestBuilder("symbolic-ref")
                .addArguments(ByteString.copyFromUtf8("refs/heads/symbolic-test"))
                .addArguments(ByteString.copyFromUtf8("refs/heads/master"))
                .setRepository(bitbucketServer.getId());

        try {
            FragmentResponseObserver<List<String>> observer =
                    new FragmentResponseObserver<>(new LinesStdioHandler(), new GitProcessCompletionHandler());
            stub().call(builder.build(), observer);
            observer.asResult();
            logListener.assertWarn("'git symbolic-ref' is deprecated");
        } finally {
            MoreFiles.deleteQuietly(bitbucketServer.getPath().resolve("refs/heads/symbolic-test"));
        }
    }

    @Test
    public void testCallSymbolicRefBlockedForManagedRepository() {
        RpcCallRequest.Builder builder = createCallRequestBuilder("symbolic-ref")
                .addArguments(ByteString.copyFromUtf8("refs/heads/symbolic-test"))
                .addArguments(ByteString.copyFromUtf8("refs/heads/master"))
                .setRepository(repository.getId());

        FragmentResponseObserver<List<String>> observer =
                new FragmentResponseObserver<>(new LinesStdioHandler(), new GitProcessCompletionHandler());
        stub().call(builder.build(), observer);

        StatusRuntimeException thrown = assertThrows(StatusRuntimeException.class, observer::asResult);
        assertSame(Status.Code.INVALID_ARGUMENT, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.unsupportedCommand("git symbolic-ref refs/heads/symbolic-test refs/heads/master"));
    }

    @Test
    public void testCallThrowsForBlockedCommand() {
        assertThrows(StatusRuntimeException.class, () -> {
            try {
                blockingStub().call(RpcCallRequest.newBuilder()
                        .setRepository(repository.getId())
                        .setCommand("web--browse")
                        .addArguments(ByteString.copyFromUtf8("https://www.atlassian.com"))
                        .build())
                        .forEachRemaining(fragment -> fail("Error was expected!"));
            } catch (StatusRuntimeException e) {
                assertUnsupportedCommand("git web--browse https://www.atlassian.com", e);
                throw e;
            }
        });
    }

    @Test
    public void testCallUpdateRefFailsForReadOnlyRepository() {
        String branch = "refs/heads/read-only-test";
        RpcCallRequest.Builder builder = createCallRequestBuilder("update-ref")
                .addArguments(ByteString.copyFromUtf8(branch))
                .addArguments(ByteString.copyFromUtf8("0a943a29376f2336b78312d99e65da17048951db"))
                .setRepository(repository.getId());

        try {
            repository.setReadOnly(true);
            assertThrows(RepositoryReadOnlyException.class, () -> call(builder, new LinesStdioHandler()));
            assertNull(agent.resolveBranch(repository, branch));
        } finally {
            repository.setReadOnly(false);
        }
    }

    @Test
    public void testCatFileBatchCheck() {
        InfoBatchCheckCatFilesStdioHandler handler =
                new InfoBatchCheckCatFilesStdioHandler(ImmutableList.of(
                        "493021b1c9e0ecfebcd5ebce646a44138bc60b39",
                        "b2b3ad5a3bd8bddb201b1d2677f6322dfe9b0973",
                        "d6edcbf924697ab811a867421dab60d954ccad99",
                        "12ebe2a58367347cd39f19f5a72f3cbec7b8f9a9"));

        Map<String, GitObjectInfo> objectInfo = catFileBatchCheck(handler);

        assertNotNull(objectInfo);
        assertEquals(4, objectInfo.size());
        assertObjectInfo(objectInfo.get("493021b1c9e0ecfebcd5ebce646a44138bc60b39"),
                "493021b1c9e0ecfebcd5ebce646a44138bc60b39", 20);
        assertObjectInfo(objectInfo.get("b2b3ad5a3bd8bddb201b1d2677f6322dfe9b0973"),
                "b2b3ad5a3bd8bddb201b1d2677f6322dfe9b0973", 40);
        assertObjectInfo(objectInfo.get("d6edcbf924697ab811a867421dab60d954ccad99"),
                "d6edcbf924697ab811a867421dab60d954ccad99", 277);
        assertObjectInfo(objectInfo.get("12ebe2a58367347cd39f19f5a72f3cbec7b8f9a9"),
                "12ebe2a58367347cd39f19f5a72f3cbec7b8f9a9", 706);
    }

    @Test
    public void testCatFileBatchCheckMissing() {
        String result = catFileBatchCheck("0000000000000000000000000000000000000000\n" +
                "30bda1ba1e8e99781b4fafcc450da84f97a29630");
        assertEquals("0000000000000000000000000000000000000000 missing\n" +
                "30bda1ba1e8e99781b4fafcc450da84f97a29630 blob 30", result);
    }

    @Test
    public void testCatFileBatchCheckMultiple() {
        String result = catFileBatchCheck("30bda1ba1e8e99781b4fafcc450da84f97a29630\n" +
                "493021b1c9e0ecfebcd5ebce646a44138bc60b39");
        assertEquals("30bda1ba1e8e99781b4fafcc450da84f97a29630 blob 30\n" +
                        "493021b1c9e0ecfebcd5ebce646a44138bc60b39 blob 20",
                result);
    }

    @Test
    public void testCatFileBatchMissing() {
        String result = catFileBatch("0000000000000000000000000000000000000000\n" +
                "30bda1ba1e8e99781b4fafcc450da84f97a29630");
        assertEquals("0000000000000000000000000000000000000000 missing\n" +
                "30bda1ba1e8e99781b4fafcc450da84f97a29630 blob 30\n" +
                "out of order commits scenario\n", result);
    }

    @Test
    public void testCatFileBatchMultipleFiles() {
        String result = catFileBatch("30bda1ba1e8e99781b4fafcc450da84f97a29630\n" +
                "493021b1c9e0ecfebcd5ebce646a44138bc60b39");
        assertEquals("30bda1ba1e8e99781b4fafcc450da84f97a29630 blob 30\n" +
                        "out of order commits scenario\n\n" +
                        "493021b1c9e0ecfebcd5ebce646a44138bc60b39 blob 20\n" +
                        "this is a test file\n",
                result);
    }

    @Test
    public void testCatFileBatchOneFile() {
        String result = catFileBatch("30bda1ba1e8e99781b4fafcc450da84f97a29630");
        assertEquals("30bda1ba1e8e99781b4fafcc450da84f97a29630 blob 30\n" +
                        "out of order commits scenario\n",
                result);
    }

    @Test
    public void testCatFilePrettyWithFile() {
        RpcCatFileRequest.Builder builder = RpcCatFileRequest.newBuilder()
                .setPretty(RpcCatFilePretty.newBuilder()
                        .setObjectId(ByteString.copyFromUtf8(
                                "bf3346620ec9161977fc23dee1240a7991e64ca1:modification/mod_file.txt")))
                .setRepository(repository.getId());

        FragmentResponseObserver<String> observer =
                new FragmentResponseObserver<>(new StringStdioHandler(), new GitProcessCompletionHandler());

        stub().catFile(builder.build(), observer);

        assertEquals("this is a test file\nanother line", observer.asResult());
    }

    @Test
    public void testCatFilePrettyWithFileOnCancel() {
        FragmentResponseObserver<String> observer =
                new FragmentResponseObserver<>(new FirstLineStdioHandler(), new GitProcessCompletionHandler());

        RpcCatFileRequest.Builder builder = RpcCatFileRequest.newBuilder()
                .setPretty(RpcCatFilePretty.newBuilder()
                        .setObjectId(ByteString.copyFromUtf8("master:modification/mod_file.txt")))
                .setRepository(repository.getId());

        stub().catFile(builder.build(), observer);

        assertEquals("this is a test file", observer.asResult());
    }

    @Test
    public void testCatFileTypeWithBlob() {
        assertSame(GitObjectType.BLOB, getType(repository, "master:modification/mod_file.txt"));
    }

    @Test
    public void testCatFileTypeWithBlobWithTrailingWhitespace() {
        assertSame(GitObjectType.BLOB, getType(qaResources, "master:spaces/file.txt "));
    }

    @Test
    public void testCatFileTypeWithCommit() {
        assertSame(GitObjectType.COMMIT, getType(repository, "master"));
    }

    @Test
    public void testCatFileTypeWithTag() {
        assertSame(GitObjectType.TAG, getType(repository, "signed_tag"));
    }

    @Test
    public void testCatFileTypeWithTree() {
        assertSame(GitObjectType.TREE, getType(repository, "master:"));
    }

    @Test
    public void testCatFileTypeWithTreeWithTrailingWhitespace() {
        assertSame(GitObjectType.TREE, getType(qaResources, "master:spaces/trailing-space "));
    }

    @Test
    public void testForEachRefBranch() {
        RpcForEachRefRequest.Builder builder = RpcForEachRefRequest.newBuilder()
                .addPatterns(ByteString.copyFromUtf8(HEADS.getPath()))
                .setFormat(FORMAT)
                .setSort(HEADS.sort(MODIFICATION));

        List<Branch> branches = forEachRef(builder, branchHandler(null));
        assertNotNull(branches);
        assertEquals(4, branches.size());

        assertBranch("master", "0a943a29376f2336b78312d99e65da17048951db", true, branches.get(0));
        assertBranch("branch_mod_merge", "6053a1eaa1c009dd11092d09a72f3c41af1b59ad", branches.get(1));
        assertBranch("basic_branching", "d6edcbf924697ab811a867421dab60d954ccad99", branches.get(2));
        assertBranch("out_of_order_branch", "2e10dd2d1d5eea9291b296e78312e8a703964a95", branches.get(3));

        builder = builder.setSort(HEADS.sort(ALPHABETICAL));
        branches = forEachRef(builder, branchHandler(null));
        assertNotNull(branches);
        assertEquals(4, branches.size());

        assertBranch("basic_branching", "d6edcbf924697ab811a867421dab60d954ccad99", branches.get(0));
        assertBranch("branch_mod_merge", "6053a1eaa1c009dd11092d09a72f3c41af1b59ad", branches.get(1));
        assertBranch("master", "0a943a29376f2336b78312d99e65da17048951db", true, branches.get(2));
        assertBranch("out_of_order_branch", "2e10dd2d1d5eea9291b296e78312e8a703964a95", branches.get(3));

        branches = forEachRef(builder, branchHandler("br"));
        assertNotNull(branches);
        assertEquals(3, branches.size());

        assertBranch("basic_branching", "d6edcbf924697ab811a867421dab60d954ccad99", branches.get(0));
        assertBranch("branch_mod_merge", "6053a1eaa1c009dd11092d09a72f3c41af1b59ad", branches.get(1));
        assertBranch("out_of_order_branch", "2e10dd2d1d5eea9291b296e78312e8a703964a95", branches.get(2));

        branches = forEachRef(builder, branchHandler("ba"));
        assertNotNull(branches);
        assertEquals(1, branches.size());

        assertBranch("basic_branching", "d6edcbf924697ab811a867421dab60d954ccad99", branches.get(0));

        branches = forEachRef(builder, branchHandler("ban"));
        assertNotNull(branches);
        assertTrue(branches.isEmpty());
    }

    @Test
    public void testForEachRefBranchWithCountLimit() {
        RpcForEachRefRequest.Builder builder = RpcForEachRefRequest.newBuilder()
                .addPatterns(ByteString.copyFromUtf8(HEADS.getPath()))
                .setCount(2)
                .setFormat(FORMAT)
                .setSort(HEADS.sort(ALPHABETICAL));

        List<Branch> branches = forEachRef(builder, branchHandler(null));

        assertNotNull(branches);
        assertEquals(2, branches.size());

        assertBranch("basic_branching", "d6edcbf924697ab811a867421dab60d954ccad99", branches.get(0));
        assertBranch("branch_mod_merge", "6053a1eaa1c009dd11092d09a72f3c41af1b59ad", branches.get(1));
    }

    @Test
    public void testForEachRefResolveRefs() {
        Map<String, Ref> refsById = resolveRefs(
                //Matches tag by name
                "refs/tags/signed_tag", "tags/signed_tag", "signed_tag",
                //Matches signed tag by hash
                "80aa5be5ecf39660f798858482254f7a2ae9110e", "80aa5be5ecf3", "80aa5be",
                //Matches both retagged_signed_tag and signed_tag
                "12ebe2a58367347cd39f19f5a72f3cbec7b8f9a9",
                //Matches both signed_tag (by commit hash) and the head of the master branch
                "0a943a29376f2336b78312d99e65da17048951db",
                //Matches by commit hash
                "2e10dd2d1d5eea9291b296e78312e8a703964a95",
                //Matches by branch name
                "refs/heads/master", "heads/master", "master",
                //Doesn't match any branch or tag
                "nonexistent_object",
                //Doesn't match branches by partial name
                "out_of", "order_branch",
                //Doesn't match tags by partial name
                "signed", "_tag",
                //Minimum short hash required is 7 characters
                "93",
                //Matches signed_tag and master hashes in the middle (contains check), but doesn't match from the beginning
                "9376f23",
                //Matches signed_tag hash at the end, but not from the beginning
                "de44e84",
                //Matches every ref from the beginning, but not from the end
                "refs");

        //Matches tag by name
        assertRefIsTag("signed_tag", "0a943a29376f2336b78312d99e65da17048951db",
                refsById.get("refs/tags/signed_tag"));
        assertRefIsTag("signed_tag", "0a943a29376f2336b78312d99e65da17048951db",
                refsById.get("tags/signed_tag"));
        assertRefIsTag("signed_tag", "0a943a29376f2336b78312d99e65da17048951db",
                refsById.get("signed_tag"));

        //Matches signed tag by hash
        assertRefIsTag("backdated_annotated_tag", "57150c54c38d6570b2fd5e6d6cfc19476de44e84",
                refsById.get("80aa5be5ecf39660f798858482254f7a2ae9110e"));
        assertRefIsTag("backdated_annotated_tag", "57150c54c38d6570b2fd5e6d6cfc19476de44e84",
                refsById.get("80aa5be5ecf3"));
        assertRefIsTag("backdated_annotated_tag", "57150c54c38d6570b2fd5e6d6cfc19476de44e84",
                refsById.get("80aa5be"));

        //Matches both retagged_signed_tag and signed_tag, but should return retagged_signed_tag because its name
        //occurs first alphabetically
        assertRefIsTag("retagged_signed_tag", "12ebe2a58367347cd39f19f5a72f3cbec7b8f9a9",
                refsById.get("12ebe2a58367347cd39f19f5a72f3cbec7b8f9a9"));

        //Matches both signed_tag (by commit hash) and the head of the master branch--should choose the tag
        assertRefIsTag("signed_tag", "0a943a29376f2336b78312d99e65da17048951db",
                refsById.get("0a943a29376f2336b78312d99e65da17048951db"));

        //Matches by commit hash
        assertRefIsBranch("out_of_order_branch", "2e10dd2d1d5eea9291b296e78312e8a703964a95",
                refsById.get("2e10dd2d1d5eea9291b296e78312e8a703964a95"));

        //Matches by branch name
        assertRefIsBranch("master", "0a943a29376f2336b78312d99e65da17048951db",
                refsById.get("refs/heads/master"));
        assertRefIsBranch("master", "0a943a29376f2336b78312d99e65da17048951db",
                refsById.get("heads/master"));
        assertRefIsBranch("master", "0a943a29376f2336b78312d99e65da17048951db",
                refsById.get("master"));

        //Doesn't match any branch or tag
        assertNull(refsById.get("nonexistent_object"));
        //Doesn't match branches by partial name
        assertNull(refsById.get("out_of"));
        assertNull(refsById.get("order_branch"));
        //Doesn't match tags by partial name
        assertNull(refsById.get("signed"));
        assertNull(refsById.get("_tag"));
        //Minimum short hash required is 7 characters
        assertNull(refsById.get("93"));
        //Matches signed_tag and master hashes in the middle (contains check), but doesn't match from the beginning
        assertNull(refsById.get("9376f23"));
        //Matches signed_tag hash at the end, but not from the beginning
        assertNull(refsById.get("de44e84"));
        //Matches every ref from the beginning, but not from the end
        assertNull(refsById.get("refs"));
    }

    @Test
    public void testForEachRefTags() {
        RpcForEachRefRequest.Builder builder = RpcForEachRefRequest.newBuilder()
                .addPatterns(ByteString.copyFromUtf8(TAGS.getPath()))
                .setFormat(FORMAT)
                .setSort(TAGS.sort(MODIFICATION));
        List<Tag> tags = forEachRef(builder, tagHandler(null));
        assertNotNull(tags);
        assertEquals(3, tags.size());

        assertTag("retagged_signed_tag", "9b4e781dea0769888fe270e06ad76675f73851b1",
                "12ebe2a58367347cd39f19f5a72f3cbec7b8f9a9", tags.get(0));
        assertTag("signed_tag", "12ebe2a58367347cd39f19f5a72f3cbec7b8f9a9",
                "0a943a29376f2336b78312d99e65da17048951db", tags.get(1));
        assertTag("backdated_annotated_tag", "80aa5be5ecf39660f798858482254f7a2ae9110e",
                "57150c54c38d6570b2fd5e6d6cfc19476de44e84", tags.get(2));

        builder = builder.setSort(TAGS.sort(ALPHABETICAL));
        tags = forEachRef(builder, tagHandler(null));
        assertNotNull(tags);
        assertEquals(3, tags.size());

        assertTag("backdated_annotated_tag", "80aa5be5ecf39660f798858482254f7a2ae9110e",
                "57150c54c38d6570b2fd5e6d6cfc19476de44e84", tags.get(0));
        assertTag("retagged_signed_tag", "9b4e781dea0769888fe270e06ad76675f73851b1",
                "12ebe2a58367347cd39f19f5a72f3cbec7b8f9a9", tags.get(1));
        assertTag("signed_tag", "12ebe2a58367347cd39f19f5a72f3cbec7b8f9a9",
                "0a943a29376f2336b78312d99e65da17048951db", tags.get(2));

        tags = forEachRef(builder, tagHandler("b"));
        assertNotNull(tags);
        assertEquals(1, tags.size());

        assertTag("backdated_annotated_tag", "80aa5be5ecf39660f798858482254f7a2ae9110e",
                "57150c54c38d6570b2fd5e6d6cfc19476de44e84", tags.get(0));

        tags = forEachRef(builder, tagHandler("be"));
        assertNotNull(tags);
        assertEquals(0, tags.size());
    }

    @Test
    public void testForEachRefTagsWithCountLimit() {
        RpcForEachRefRequest.Builder builder = RpcForEachRefRequest.newBuilder()
                .addPatterns(ByteString.copyFromUtf8(TAGS.getPath()))
                .setCount(2)
                .setFormat(FORMAT)
                .setSort(HEADS.sort(ALPHABETICAL));

        List<Tag> tags = forEachRef(builder, tagHandler(null));
        assertNotNull(tags);
        assertEquals(2, tags.size());

        assertTag("backdated_annotated_tag", "80aa5be5ecf39660f798858482254f7a2ae9110e",
                "57150c54c38d6570b2fd5e6d6cfc19476de44e84", tags.get(0));
        assertTag("retagged_signed_tag", "9b4e781dea0769888fe270e06ad76675f73851b1",
                "12ebe2a58367347cd39f19f5a72f3cbec7b8f9a9", tags.get(1));
    }

    @Test
    public void testDiffTreeAgainstNullTree() {
        List<Change> changes = diffTree(createDiffTreeRequest("57150c54c38d6570b2fd5e6d6cfc19476de44e84", NULL_TREE),
                repository, null);

        assertNotNull(changes);
        assertEquals(4, changes.size());

        //verify that all files are additions (all the files at the commit are additions compared to the null tree)
        for (Change change : changes) {
            assertSame(GitChangeType.ADD, change.getType());
            assertSame(GitNodeType.FILE, change.getNodeType());
            assertEquals(-1, change.getPercentUnchanged());
            assertNull(change.getSrcExecutable());
            assertNull(change.getSrcPath());
        }

        Change first = changes.get(0);
        assertEquals("493021b1c9e0ecfebcd5ebce646a44138bc60b39", first.getContentId());
        assertEquals(NULL_SHA1, first.getFromContentId());
        assertEquals("add_file/add_file.txt", first.getPath());
        assertSame(Boolean.FALSE, first.getExecutable());
        Change second = changes.get(1);
        assertEquals("b1d99e581f0bb8138b8997f9268294e41faee05b", second.getContentId());
        assertEquals(NULL_SHA1, second.getFromContentId());
        assertEquals("modification/mod_file.txt", second.getPath());
        assertSame(Boolean.FALSE, second.getExecutable());
        Change third = changes.get(2);
        assertEquals("f49abb7d6c366e6f83470e36439bc162234d7963", third.getContentId());
        assertEquals(NULL_SHA1, third.getFromContentId());
        assertEquals("mv_file/moved_file.txt", third.getPath());
        assertSame(Boolean.FALSE, third.getExecutable());
        Change fourth = changes.get(3);
        assertEquals("2e6b03cbbe90d3c9afcfc98967a5a71b4c9cff1a", fourth.getContentId());
        assertEquals(NULL_SHA1, fourth.getFromContentId());
        assertEquals("out_of_order/file.txt", fourth.getPath());
        assertSame(Boolean.FALSE, fourth.getExecutable());
    }

    @Test
    public void testDiffTreeContextLines() {
        //Use StringStdioHandler when we call diffTree with context lines, as the result
        //is a git patch and the diff-tree handlers are not expecting that format.
        FragmentResponseObserver<String> observer =
                new FragmentResponseObserver<>(new StringStdioHandler(), new GitProcessCompletionHandler());

        RpcDiffTreeRequest request = createDiffTreeRequest("409a2180fd95b744df8d817e770b7f8c853f78dc")
                .setDiffOptions(DEFAULT_DIFF_OPTIONS.toBuilder()
                        .setContextLines(3)
                        .setWhitespace(RpcDiffWhitespace.WHITESPACE_IGNORE_ALL))
                .setRepository(repository.getId())
                .build();

        stub().diffTree(request, observer);

        assertEquals("409a2180fd95b744df8d817e770b7f8c853f78dc\n" +
                "diff --git a/out_of_order/file.txt b/out_of_order/file.txt\n" +
                "new file mode 100644\n" +
                "index 0000000..30bda1b\n" +
                "--- /dev/null\n" +
                "+++ b/out_of_order/file.txt\n" +
                "@@ -0,0 +1 @@\n" +
                "+out of order commits scenario", observer.asResult());
    }

    @Test
    public void testDiffTreeFilteringByPathsOnBuilder() {
        RpcDiffTreeRequest.Builder builder = createDiffTreeRequest("57150c54c38d6570b2fd5e6d6cfc19476de44e84", NULL_TREE)
                .addPaths(ByteString.copyFromUtf8("add_file/add_file.txt"))
                .addPaths(ByteString.copyFromUtf8("out_of_order/file.txt"));

        List<Change> changes = diffTree(builder, repository, null);

        assertNotNull(changes);
        assertEquals(2, changes.size());

        //Full details for these same Changes are verified in testAgainstNullTree, and that verification is
        //not duplicated here. This test is focused on filtering, so it only verifies the paths
        assertEquals("add_file/add_file.txt", changes.get(0).getPath());
        assertEquals("out_of_order/file.txt", changes.get(1).getPath());
    }

    @Test
    public void testDiffTreeFilteringByPathsOnHandler() {
        List<Change> changes = diffTree(createDiffTreeRequest("57150c54c38d6570b2fd5e6d6cfc19476de44e84", NULL_TREE),
                repository, ImmutableSet.of("add_file/add_file.txt", "out_of_order/file.txt"));

        assertNotNull(changes);
        assertEquals(2, changes.size());

        //Full details for these same Changes are verified in testAgainstNullTree, and that verification is
        //not duplicated here. This test is focused on filtering, so it only verifies the paths
        assertEquals("add_file/add_file.txt", changes.get(0).getPath());
        assertEquals("out_of_order/file.txt", changes.get(1).getPath());
    }

    @Test
    public void testDiffTreeFilteringBySrcPath() {
        RpcDiffTreeRequest.Builder builder =
                createDiffTreeRequest("0a943a29376f2336b78312d99e65da17048951db", "e0a3f6d8c804c375f49d7609328111e8dc75a4bc")
                        .setDiffOptions(DEFAULT_DIFF_OPTIONS.toBuilder()
                                .setRenames(RpcDiffRenames.RENAMES_MORE_COPIES));

        List<Change> changes = diffTree(builder, repository, ImmutableSet.of("binary/C.zip"));

        assertNotNull(changes);
        assertEquals(1, changes.size());

        //Full details for this Change is verified in testForCopy, and that verification is not duplicated here.
        //This test is focused on filtering, so it only verifies the paths
        Change change = changes.get(0);
        assertEquals("binary/D.zip", change.getPath());
        assertEquals("binary/C.zip", change.getSrcPath());
    }

    @Test
    public void testDiffTreeFilteringWithNoMatches() {
        //This diff-tree isn't actually empty, but it doesn't include any matches for the paths we're restricting
        //to, so it shouldn't return any Changes
        List<Change> changes = diffTree(createDiffTreeRequest("05c78271ed4d7a158fe6789f6b27e3a47631faba",
                "57150c54c38d6570b2fd5e6d6cfc19476de44e84"), repository, ImmutableSet.of("non/existent.txt"));

        assertNotNull(changes);
        assertTrue(changes.isEmpty());
    }

    @Test
    public void testDiffTreeForAdd() {
        List<Change> changes = diffTree(createDiffTreeRequest("05c78271ed4d7a158fe6789f6b27e3a47631faba",
                "57150c54c38d6570b2fd5e6d6cfc19476de44e84"), repository, null);

        assertNotNull(changes);
        assertEquals(2, changes.size());

        //Note: The first change here is for a symbolic link. Symbolic links are _always_ executable, because the
        //      real permission is only known at the target
        Change first = changes.get(0);
        assertEquals("4cbb553f3f4ac2ee7b01ff6c951d6bf583c39c15", first.getContentId());
        assertEquals(NULL_SHA1, first.getFromContentId());
        assertEquals("symlink/link.txt", first.getPath());
        assertEquals(-1, first.getPercentUnchanged());
        assertNull(first.getSrcExecutable());
        assertNull(first.getSrcPath());
        assertSame(GitChangeType.ADD, first.getType());
        assertSame(Boolean.TRUE, first.getExecutable());
        Change second = changes.get(1);
        assertEquals("57a3592bb2cf8e76e2faef7660945a2fe0e24360", second.getContentId());
        assertEquals(NULL_SHA1, second.getFromContentId());
        assertEquals("symlink/target.txt", second.getPath());
        assertEquals(-1, second.getPercentUnchanged());
        assertSame(Boolean.FALSE, second.getExecutable());
        assertNull(second.getSrcExecutable());
        assertNull(second.getSrcPath());
        assertSame(GitChangeType.ADD, second.getType());
    }

    @Test
    public void testDiffTreeForCopy() {
        RpcDiffTreeRequest.Builder builder = createDiffTreeRequest("0a943a29376f2336b78312d99e65da17048951db",
                "e0a3f6d8c804c375f49d7609328111e8dc75a4bc")
                .setDiffOptions(DEFAULT_DIFF_OPTIONS.toBuilder()
                        .setRenames(RpcDiffRenames.RENAMES_MORE_COPIES));

        List<Change> changes = diffTree(builder, repository, null);

        assertNotNull(changes);
        assertEquals(1, changes.size());

        Change change = changes.get(0);
        assertEquals("72555d02a139700be930a6734b57c5fab84512b9", change.getContentId());
        assertEquals("72555d02a139700be930a6734b57c5fab84512b9", change.getFromContentId());
        assertEquals("binary/D.zip", change.getPath());
        assertEquals("binary/C.zip", change.getSrcPath());
        assertEquals(100, change.getPercentUnchanged());
        assertSame(Boolean.FALSE, change.getExecutable());
        assertSame(Boolean.FALSE, change.getSrcExecutable());
        assertSame(GitChangeType.COPY, change.getType());
    }

    @Test
    public void testDiffTreeForDelete() {
        List<Change> changes = diffTree(createDiffTreeRequest("6d8e9736357657738d15cd29069f21bf44553e19",
                "4f9290ae1a9fcde7acd56664a22cc65eb76540f3"), repository, null);

        assertNotNull(changes);
        assertEquals(1, changes.size());

        Change change = changes.get(0);
        assertEquals("bdea48ee65c869eb0b86b1283069d76cce0a7254", change.getContentId());
        assertEquals("bdea48ee65c869eb0b86b1283069d76cce0a7254", change.getFromContentId());
        assertEquals("del_file/del_file.txt", change.getPath());
        assertEquals(-1, change.getPercentUnchanged());
        assertSame(Boolean.FALSE, change.getSrcExecutable());
        assertNull(change.getExecutable());
        assertNull(change.getSrcPath());
        assertSame(GitChangeType.DELETE, change.getType());
    }

    @Test
    public void testDiffTreeForEmptyDiff() {
        List<Change> changes = diffTree(createDiffTreeRequest("62abb7aa35f8928baa23be2e6cf5cbf1ca43dfdb"),
                diffTree, null);

        assertNotNull(changes);
        assertTrue(changes.isEmpty());
    }

    @Test
    public void testDiffTreeForModify() {
        List<Change> changes = diffTree(createDiffTreeRequest("6053a1eaa1c009dd11092d09a72f3c41af1b59ad",
                "042fb0cc198e7320b59a025689b45a2cb52f4684"), repository, null);

        assertNotNull(changes);
        assertEquals(1, changes.size());

        Change change = changes.get(0);
        assertEquals("668076c308990933c2df66efb87b946704252486", change.getContentId());
        assertEquals("8e66897a7bfcbb4dbc3be09fbfd0c300e2f2844b", change.getFromContentId());
        assertEquals("branch_mod_merge/file.txt", change.getPath());
        assertEquals(-1, change.getPercentUnchanged());
        assertSame(Boolean.FALSE, change.getExecutable());
        assertSame(Boolean.FALSE, change.getSrcExecutable());
        assertNull(change.getSrcPath());
        assertSame(GitChangeType.MODIFY, change.getType());
    }

    @Test
    public void testDiffTreeForRename() {
        List<Change> changes = diffTree(createDiffTreeRequest("57150c54c38d6570b2fd5e6d6cfc19476de44e84",
                "9fbc34a0d905950131d73f352abe68520c6db2a3"), repository, null);

        assertNotNull(changes);
        assertEquals(1, changes.size());

        Change change = changes.get(0);
        assertEquals("f49abb7d6c366e6f83470e36439bc162234d7963", change.getContentId());
        assertEquals("f49abb7d6c366e6f83470e36439bc162234d7963", change.getFromContentId());
        assertEquals("mv_file/moved_file.txt", change.getPath());
        assertEquals("mv_file/mv_file.txt", change.getSrcPath());
        assertEquals(100, change.getPercentUnchanged());
        assertSame(Boolean.FALSE, change.getExecutable());
        assertSame(Boolean.FALSE, change.getSrcExecutable());
        assertSame(GitChangeType.RENAME, change.getType());
    }

    @Test
    public void testDiffTreeForRootCommit() {
        List<Change> changes = diffTree(createDiffTreeRequest("409a2180fd95b744df8d817e770b7f8c853f78dc"),
                repository, null);

        assertNotNull(changes);
        assertEquals(1, changes.size());

        Change change = changes.get(0);
        assertEquals("30bda1ba1e8e99781b4fafcc450da84f97a29630", change.getContentId());
        assertEquals(NULL_SHA1, change.getFromContentId());
        assertEquals("out_of_order/file.txt", change.getPath());
        assertEquals(-1, change.getPercentUnchanged());
        assertSame(Boolean.FALSE, change.getExecutable());
        assertNull(change.getSrcExecutable());
        assertNull(change.getSrcPath());
        assertSame(GitChangeType.ADD, change.getType());
    }

    @Test
    public void testDiffTreeIgnoreWhitespace() {
        RpcDiffTreeRequest.Builder builder = createDiffTreeRequest("409a2180fd95b744df8d817e770b7f8c853f78dc")
                .setDiffOptions(DEFAULT_DIFF_OPTIONS.toBuilder()
                        .setWhitespace(RpcDiffWhitespace.WHITESPACE_IGNORE_ALL));

        List<Change> changes = diffTree(builder, repository, null);

        Change change = changes.get(0);
        assertEquals("30bda1ba1e8e99781b4fafcc450da84f97a29630", change.getContentId());
        assertEquals(NULL_SHA1, change.getFromContentId());
        assertEquals("out_of_order/file.txt", change.getPath());
        assertEquals(-1, change.getPercentUnchanged());
        assertSame(Boolean.FALSE, change.getExecutable());
        assertNull(change.getSrcExecutable());
        assertNull(change.getSrcPath());
        assertSame(GitChangeType.ADD, change.getType());
    }

    @Test
    public void testDiffTreeInvalidAncestor() {
        StatusRuntimeException thrown = assertThrows(StatusRuntimeException.class,
                () -> diffTree(createDiffTreeRequest("HEAD", "--root"), repository, null));

        assertSame(Status.Code.INVALID_ARGUMENT, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.invalidCommitish("--root"));
    }

    @Test
    public void testDiffTreeInvalidAncestorWithLeadingSpace() {
        StatusRuntimeException thrown = assertThrows(StatusRuntimeException.class,
                () -> diffTree(createDiffTreeRequest("HEAD", " --root"), repository, null));

        assertSame(Status.Code.INVALID_ARGUMENT, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.invalidCommitish("--root"));
    }

    @Test
    public void testDiffTreeInvalidRef() {
        StatusRuntimeException thrown = assertThrows(StatusRuntimeException.class,
                () -> diffTree(createDiffTreeRequest("--root", "HEAD"), repository, null));

        assertSame(Status.Code.INVALID_ARGUMENT, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.invalidCommitish("--root"));
    }

    @Test
    public void testDiffTreeInvalidRefWithLeadingSpace() {
        StatusRuntimeException thrown = assertThrows(StatusRuntimeException.class,
                () -> diffTree(createDiffTreeRequest(" --root", "HEAD"), repository, null));

        assertSame(Status.Code.INVALID_ARGUMENT, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.invalidCommitish("--root"));
    }

    @Test
    public void testDiffTreeNoSuchCommitForChanges() {
        CommitNotFoundException thrown = assertThrows(CommitNotFoundException.class,
                () -> diffTree(createDiffTreeRequest("57150c54c38d6570b2fd5e6d6cfc19476de44e83",
                        "9fbc34a0d905950131d73f352abe68520c6db2a3"), repository, null));

        assertSame(Status.Code.NOT_FOUND, thrown.getStatus().getCode());
    }

    @Test
    public void testDiffTreeNoSuchParent() {
        CommitNotFoundException thrown = assertThrows(CommitNotFoundException.class,
                () -> diffTree(createDiffTreeRequest("57150c54c38d6570b2fd5e6d6cfc19476de44e84",
                        "9fbc34a0d905950131d73f352abe68520c6db2a4"), repository, null));

        assertSame(Status.Code.NOT_FOUND, thrown.getStatus().getCode());
    }

    @Test
    public void testDiffTreePathWithTrailingWhitespace() {
        RpcDiffTreeRequest.Builder builder = createDiffTreeRequest()
                .addPaths(ByteString.copyFromUtf8("spaces/trailing-space "))
                .setAncestor(ByteString.copyFromUtf8("effbbd10c64eeb5caf3dd1724d17a1ed935c7c62^"))
                .setRecursive(true)
                .setRev(ByteString.copyFromUtf8("effbbd10c64eeb5caf3dd1724d17a1ed935c7c62"));
        List<Change> changes = diffTree(builder, qaResources, null);

        assertEquals(1, changes.size());

        Change change = changes.get(0);
        assertEquals("spaces/trailing-space /file.txt", change.getPath());
        assertEquals("48dc8eadc74441d64c0def3d7ccfbb1fb3fcf572", change.getContentId());
        assertNull(change.getSrcPath());
        assertSame(GitChangeType.ADD, change.getType());
    }

    @Test
    public void testPackObjects() throws IOException, TimeoutException, InterruptedException {
        RpcPackObjectsRequest.Builder builder = RpcPackObjectsRequest.newBuilder()
                //Include initial commit from the 'merge' repository
                .addRevs(ByteString.copyFromUtf8("298924b8c403240eaf89dcda0cce7271620ab2f6"))
                .addRevs(ByteString.copyFromUtf8("^017caf31eca7c46eb3d1800fcac431cfa7147a01"))
                //Include a commit range from 'repository'
                .addRevs(ByteString.copyFromUtf8("c2608f5dff150e2b26b4b68d9e22369581b39b0c"))
                .addRevs(ByteString.copyFromUtf8("^409a2180fd95b744df8d817e770b7f8c853f78dc"))
                .setOptions(RpcGitOptions.newBuilder()
                        .addAlternates(merge.getId()))
                .setThin(false);

        byte[] packBytes = packObjects(builder, repository);

        assertNotEquals("No pack data was received", 0, packBytes.length);

        verifyPackContains(packBytes, "298924b8c403240eaf89dcda0cce7271620ab2f6",
                "c2608f5dff150e2b26b4b68d9e22369581b39b0c");
    }

    @Test
    public void testPackObjectsStdin() throws IOException, TimeoutException, InterruptedException {
        RpcPackObjectsRequest.Builder builder = RpcPackObjectsRequest.newBuilder()
                .setOptions(RpcGitOptions.newBuilder()
                        .addAlternates(merge.getId()))
                .setThin(false);

        String chunk1 = "298924b8c403240eaf89dcda0cce7271620ab2f6\n^017caf31eca7c46eb3d1800fcac431cfa7147a01\n";
        String chunk2 = "c2608f5dff150e2b26b4b68d9e22369581b39b0c\n^409a2180fd95b744df8d817e770b7f8c853f78dc\n";

        byte[] packBytes = packObjectsStdin(builder, repository, chunk1, chunk2);

        assertNotEquals("No pack data was received", 0, packBytes.length);

        verifyPackContains(packBytes, "298924b8c403240eaf89dcda0cce7271620ab2f6",
                "c2608f5dff150e2b26b4b68d9e22369581b39b0c");
    }

    @Test
    public void testPackObjectsThrowsOnMissingCommits() {
        RpcPackObjectsRequest.Builder builder = RpcPackObjectsRequest.newBuilder()
                //Include initial commit from the 'merge' repository, but don't include an alternate for merge
                .addRevs(ByteString.copyFromUtf8("298924b8c403240eaf89dcda0cce7271620ab2f6"))
                .addRevs(ByteString.copyFromUtf8("^017caf31eca7c46eb3d1800fcac431cfa7147a01"))
                //Include a commit range from 'repository'
                .addRevs(ByteString.copyFromUtf8("c2608f5dff150e2b26b4b68d9e22369581b39b0c"))
                .addRevs(ByteString.copyFromUtf8("^409a2180fd95b744df8d817e770b7f8c853f78dc"))
                .setThin(false);

        //Since "merge" is not an alternate, pack-objects should fail
        assertThrows(CommandFailedException.class, () -> packObjects(builder, repository));
    }

    /**
     * Ensures {@link GitRevListBuilder#rev} can still be used with {@link GitRevListBuilder#all}. In this case
     * we're ensuring we can specify an exclude, but there are reasons you might want to include things even if
     * {@code --all} is being passed. One example might be to include one or more unreachable SHAs while also
     * including all refs as well.
     */
    @Test
    public void testRevListAllWithRef() {
        SingleCommitRevListStdioHandler handler =
                new SingleCommitRevListStdioHandler(null, true, false, UNLIMITED_MESSAGE_LENGTH);

        RpcRevListRequest.Builder builder = RpcRevListRequest.newBuilder()
                .setRepository(repository.getId())
                .setRevListOptions(RpcRevListOptions.newBuilder()
                        .addRevs(ByteString.copyFromUtf8("^master"))
                        .setAll(true)
                        .setFormat(handler.getFormat()));

        Commit commit = revList(builder, handler);

        assertNotNull(commit);
        assertEquals("d6edcbf924697ab811a867421dab60d954ccad99", commit.getId());
    }

    @Test
    public void testRevListAmbiguousCommit() {
        SingleCommitRevListStdioHandler handler =
                new SingleCommitRevListStdioHandler(null, true, false, UNLIMITED_MESSAGE_LENGTH);

        RpcRevListRequest.Builder builder = RpcRevListRequest.newBuilder()
                .setRepository(repository.getId())
                .setRevListOptions(RpcRevListOptions.newBuilder()
                        .addRevs(ByteString.copyFromUtf8("FOO")));

        CommitNotFoundException thrown = assertThrows(CommitNotFoundException.class, () -> revList(builder, handler));
        assertSame(Status.Code.NOT_FOUND, thrown.getStatus().getCode());
    }

    @Test
    public void testRevListBranches() {
        List<Commit> commits = new ArrayList<>();
        CallbackRevListStdioHandler handler =
                new CallbackRevListStdioHandler((commit, refChange) -> commits.add(commit),
                        true, UNLIMITED_MESSAGE_LENGTH, Collections.emptyList());

        RpcRevListRequest.Builder builder = RpcRevListRequest.newBuilder()
                .setRepository(repository.getId())
                .setRevListOptions(RpcRevListOptions.newBuilder()
                        .setBranches(true)
                        .setFormat(handler.getFormat())
                        .setWalk(RpcRevListWalk.WALK_NO));

        revList(builder, handler);

        assertEquals(4, commits.size());
        assertEquals("0a943a29376f2336b78312d99e65da17048951db", commits.get(0).getId());
        assertEquals("6053a1eaa1c009dd11092d09a72f3c41af1b59ad", commits.get(1).getId());
        assertEquals("d6edcbf924697ab811a867421dab60d954ccad99", commits.get(2).getId());
        assertEquals("2e10dd2d1d5eea9291b296e78312e8a703964a95", commits.get(3).getId());
    }

    @Test
    public void testRevListCommitsBetween() {
        List<Commit> commits = new ArrayList<>();
        CallbackRevListStdioHandler handler =
                new CallbackRevListStdioHandler((commit, refChange) -> commits.add(commit),
                        true, UNLIMITED_MESSAGE_LENGTH, Collections.emptyList());

        RpcRevListRequest.Builder builder = RpcRevListRequest.newBuilder()
                .setRepository(repository.getId())
                .setRevListOptions(RpcRevListOptions.newBuilder()
                        .addRevs(ByteString.copyFromUtf8("2d8897c9ac29ce42c3442cf80ac977057045e7f6"))
                        .addRevs(ByteString.copyFromUtf8("^6053a1eaa1c009dd11092d09a72f3c41af1b59ad"))
                        .setFormat(handler.getFormat())
                        .setIgnoreMissing(true));

        revList(builder, handler);

        // test 1 commit fully to ensure that output parsing and commit construction is correct
        Commit commit = commits.get(0);
        assertCommit(commit, "Anna Buttfield", "abuttfield@cascade.sydney.atlassian.com",
                1296709182, "2d8897c9ac29ce42c3442cf80ac977057045e7f6", "Rename A.zip to B.zip\n",
                "9c05f43f859375e392d90d23a13717c16d0fdcda");

        // now check the ids on all the others
        assertEquals("9c05f43f859375e392d90d23a13717c16d0fdcda", commits.get(1).getId());
    }

    @Test
    public void testRevListCommitsBetweenRepositories() {
        List<Commit> commits = new ArrayList<>();
        CallbackRevListStdioHandler handler =
                new CallbackRevListStdioHandler((commit, refChange) -> commits.add(commit),
                        true, UNLIMITED_MESSAGE_LENGTH, Collections.emptyList());

        RpcRevListRequest.Builder builder = RpcRevListRequest.newBuilder()
                .setOptions(RpcGitOptions.newBuilder()
                        .addAlternates(drift.getId())
                        .addAlternates(merge.getId()))
                .setRepository(repository.getId())
                .setRevListOptions(RpcRevListOptions.newBuilder()
                        .addAllRevs(toByteStringsLazily(ImmutableList.of(
                                "2d8897c9ac29ce42c3442cf80ac977057045e7f6",
                                "74de5497dfca9731e455d60552f9a8906e5dc1ac",
                                "^6053a1eaa1c009dd11092d09a72f3c41af1b59ad",
                                "^017caf31eca7c46eb3d1800fcac431cfa7147a01")))
                        .setFormat(handler.getFormat())
                        .setIgnoreMissing(true));

        revList(builder, handler);

        assertEquals(4, commits.size()); // 2 commits from 'repo', 2 from 'merge'

        // test 1 commit fully to ensure that output parsing and commit construction is correct
        Commit commit = commits.get(2);
        assertCommit(commit, "Anna Buttfield", "abuttfield@cascade.sydney.atlassian.com",
                1296709182, "2d8897c9ac29ce42c3442cf80ac977057045e7f6",
                "Rename A.zip to B.zip\n", "9c05f43f859375e392d90d23a13717c16d0fdcda");

        // now check the ids on all the others
        assertEquals("74de5497dfca9731e455d60552f9a8906e5dc1ac", commits.get(0).getId());
        assertEquals("3528cf690cb37f6adb85b7bd40cc7a6118d4b598", commits.get(1).getId());
        assertEquals("9c05f43f859375e392d90d23a13717c16d0fdcda", commits.get(3).getId());
    }

    @Test
    public void testRevListCommitsBetweenWithoutMessages() {
        List<Commit> commits = new ArrayList<>();
        CallbackRevListStdioHandler handler =
                new CallbackRevListStdioHandler((commit, refChange) -> commits.add(commit),
                        false, 0, Collections.emptyList());

        RpcRevListRequest.Builder builder = RpcRevListRequest.newBuilder()
                .setRepository(repository.getId())
                .setRevListOptions(RpcRevListOptions.newBuilder()
                        .addRevs(ByteString.copyFromUtf8("2d8897c9ac29ce42c3442cf80ac977057045e7f6"))
                        .addRevs(ByteString.copyFromUtf8("^6053a1eaa1c009dd11092d09a72f3c41af1b59ad"))
                        .setFormat(handler.getFormat())
                        .setIgnoreMissing(true));

        revList(builder, handler);

        assertEquals(2, commits.size());

        // test 1 commit fully to ensure that output parsing and commit construction is correct
        Commit commit = commits.get(0);
        assertCommit(commit, "Anna Buttfield", "abuttfield@cascade.sydney.atlassian.com",
                1296709182, "2d8897c9ac29ce42c3442cf80ac977057045e7f6", null,
                "9c05f43f859375e392d90d23a13717c16d0fdcda");

        // now check the ids on all the others
        assertEquals("9c05f43f859375e392d90d23a13717c16d0fdcda", commits.get(1).getId());
    }

    @Test
    public void testRevListCommitsForPath() {
        List<Commit> commits = new ArrayList<>();
        CallbackRevListStdioHandler handler =
                new CallbackRevListStdioHandler((commit, refChange) -> commits.add(commit),
                        true, UNLIMITED_MESSAGE_LENGTH, Collections.emptyList());

        RpcRevListOptions.Builder optionsBuilder = RpcRevListOptions.newBuilder()
                .addPaths(ByteString.copyFromUtf8("modification/mod_file.txt"))
                .addRevs(ByteString.copyFromUtf8("HEAD"))
                .setFormat(handler.getFormat());
        RpcRevListRequest.Builder builder = RpcRevListRequest.newBuilder()
                .setRepository(repository.getId())
                .setRevListOptions(optionsBuilder);

        revList(builder, handler);

        assertNotNull(commits);
        assertEquals(2, commits.size());

        assertCommit(commits.get(0), "Michael Heemskerk", "mheemskerk@atlassian.com",
                1292816091, "bf3346620ec9161977fc23dee1240a7991e64ca1",
                "edited modification/mod_file.txt\n", "e26cab95a50f6e99d2c46c498a23cee307cc8e27");
        assertCommit(commits.get(1), "Michael Heemskerk", "mheemskerk@atlassian.com",
                1292816091, "e26cab95a50f6e99d2c46c498a23cee307cc8e27",
                "plain modification, initial add\n", "2e10dd2d1d5eea9291b296e78312e8a703964a95");
    }

    @Test
    public void testRevListCommitsForPathWithTrailingWhitespace() {
        List<Commit> commits = new ArrayList<>();
        CallbackRevListStdioHandler handler =
                new CallbackRevListStdioHandler((commit, refChange) -> commits.add(commit),
                        true, UNLIMITED_MESSAGE_LENGTH, Collections.emptyList());

        RpcRevListRequest.Builder builder = RpcRevListRequest.newBuilder()
                .setRepository(qaResources.getId())
                .setRevListOptions(RpcRevListOptions.newBuilder()
                        .addPaths(ByteString.copyFromUtf8("spaces/trailing-space "))
                        .addRevs(ByteString.copyFromUtf8("HEAD"))
                        .setFormat(handler.getFormat()));

        revList(builder, handler);

        assertEquals(1, commits.size());

        assertCommit(commits.get(0), "Bryan Turner", "bturner@atlassian.com",
                1547858544, "effbbd10c64eeb5caf3dd1724d17a1ed935c7c62",
                "BSERV-9123 Add directories with whitespace in their names\n",
                "03b585639e86076d30a0b3284bf4f0c90b8c7ae5");
    }

    @Test
    public void testRevListCommitsForPathWithoutMessages() {
        List<Commit> commits = new ArrayList<>();
        CallbackRevListStdioHandler handler =
                new CallbackRevListStdioHandler((commit, refChange) -> commits.add(commit),
                        false, UNLIMITED_MESSAGE_LENGTH, Collections.emptyList());

        RpcRevListOptions.Builder optionsBuilder = RpcRevListOptions.newBuilder()
                .addPaths(ByteString.copyFromUtf8("modification/mod_file.txt"))
                .addRevs(ByteString.copyFromUtf8("HEAD"))
                .setFormat(handler.getFormat());
        RpcRevListRequest.Builder builder = RpcRevListRequest.newBuilder()
                .setRepository(repository.getId())
                .setRevListOptions(optionsBuilder);

        revList(builder, handler);

        assertEquals(2, commits.size());

        assertCommit(commits.get(0), "Michael Heemskerk", "mheemskerk@atlassian.com",
                1292816091, "bf3346620ec9161977fc23dee1240a7991e64ca1", null,
                "e26cab95a50f6e99d2c46c498a23cee307cc8e27");
        assertCommit(commits.get(1), "Michael Heemskerk", "mheemskerk@atlassian.com",
                1292816091, "e26cab95a50f6e99d2c46c498a23cee307cc8e27", null,
                "2e10dd2d1d5eea9291b296e78312e8a703964a95");
    }

    @Test
    public void testRevListCommitsSince() {
        List<Commit> commits = new ArrayList<>();
        CallbackRevListStdioHandler handler =
                new CallbackRevListStdioHandler((commit, refChange) -> commits.add(commit),
                        true, UNLIMITED_MESSAGE_LENGTH, Collections.emptyList());

        RpcRevListRequest.Builder builder = RpcRevListRequest.newBuilder()
                .setRepository(repository.getId())
                .setRevListOptions(RpcRevListOptions.newBuilder()
                        .addRevs(ByteString.copyFromUtf8("^6053a1eaa1c009dd11092d09a72f3c41af1b59ad"))
                        .setAll(true)
                        .setFormat(handler.getFormat())
                        .setIgnoreMissing(true));

        revList(builder, handler);

        Commit commit = commits.get(0);
        assertCommit(commit, "Anna Buttfield", "abuttfield@cascade.sydney.atlassian.com",
                1296709267, "0a943a29376f2336b78312d99e65da17048951db", "Copy C.zip as D.zip\n",
                "e0a3f6d8c804c375f49d7609328111e8dc75a4bc");

        // now check the ids on all the others
        assertEquals("e0a3f6d8c804c375f49d7609328111e8dc75a4bc", commits.get(1).getId());
        assertEquals("2d8897c9ac29ce42c3442cf80ac977057045e7f6", commits.get(2).getId());
        assertEquals("9c05f43f859375e392d90d23a13717c16d0fdcda", commits.get(3).getId());
        assertEquals("d6edcbf924697ab811a867421dab60d954ccad99", commits.get(4).getId());
    }

    @Test
    public void testRevListExcludeMergeCommits() {
        List<Commit> commits = new ArrayList<>();
        CallbackRevListStdioHandler handler =
                new CallbackRevListStdioHandler((commit, refChange) -> commits.add(commit),
                        true, UNLIMITED_MESSAGE_LENGTH, Collections.emptyList());

        RpcRevListRequest.Builder builder = RpcRevListRequest.newBuilder()
                .setRepository(mergeCommits.getId())
                .setRevListOptions(RpcRevListOptions.newBuilder()
                        .setAll(true)
                        .setFormat(handler.getFormat())
                        .setMerges(RpcRevListMerges.MERGES_EXCLUDE));

        revList(builder, handler);

        assertEquals(7, commits.size());
        assertEquals("8dd91d1f6d846ec80045699d7ad1ee85395e22ef", commits.get(0).getId());
        assertEquals("2061acf612f3b1f4ac6d50e99688f41fbbb6c59c", commits.get(1).getId());
        assertEquals("88221b3b195a998a4bf3aecbdb02f9efd69103ae", commits.get(2).getId());
        assertEquals("2d8b41bf8c4a216177b5303f845c226232f454f0", commits.get(3).getId());
        assertEquals("af7ad1f16d32ad11f44bbad1236808d405eb48f9", commits.get(4).getId());
        assertEquals("29646867dfdedb2b139c13b1f383d8f6134a3624", commits.get(5).getId());
        assertEquals("274e479b517365266ed43bf83678b28c2862f2b5", commits.get(6).getId());
    }

    @Test
    public void testRevListFirstCommit() {
        SingleCommitRevListStdioHandler handler =
                new SingleCommitRevListStdioHandler(null, true, false, UNLIMITED_MESSAGE_LENGTH);

        RpcRevListRequest.Builder builder = RpcRevListRequest.newBuilder()
                .setRepository(repository.getId())
                .setRevListOptions(RpcRevListOptions.newBuilder()
                        .addRevs(ByteString.copyFromUtf8("042fb0cc198e7320b59a025689b45a2cb52f4684"))
                        .setFormat(handler.getFormat())
                        .setLimit(1));

        Commit commit = revList(builder, handler);

        assertNotNull(commit);
        assertCommit(commit, "Michael Heemskerk", "mheemskerk@atlassian.com", 1292816475,
                "042fb0cc198e7320b59a025689b45a2cb52f4684", "branch, mod and merge scenario\n",
                "8fb8538799b2481750eac52716931d895d029b7c");
    }

    @Test
    public void testRevListFirstCommitForNonexistentCommitId() {
        SingleCommitRevListStdioHandler handler =
                new SingleCommitRevListStdioHandler(null, true, false, UNLIMITED_MESSAGE_LENGTH);

        RpcRevListRequest.Builder builder = RpcRevListRequest.newBuilder()
                .setRepository(repository.getId())
                .setRevListOptions(RpcRevListOptions.newBuilder()
                        .addRevs(ByteString.copyFromUtf8("042fb0cc198e7320b59a025689b45a2cb52f4683"))
                        .setLimit(1));

        CommitNotFoundException thrown = assertThrows(CommitNotFoundException.class, () -> revList(builder, handler));
        assertSame(Status.Code.NOT_FOUND, thrown.getStatus().getCode());
    }

    @Test
    public void testRevListFirstCommitForNonexistentPath() {
        SingleCommitRevListStdioHandler handler =
                new SingleCommitRevListStdioHandler("modification/mod_file2.txt", true, true,
                        UNLIMITED_MESSAGE_LENGTH);

        RpcRevListRequest.Builder builder = RpcRevListRequest.newBuilder()
                .setRevListOptions(RpcRevListOptions.newBuilder()
                        .addPaths(ByteString.copyFromUtf8("modification/mod_file2.txt"))
                        .addRevs(ByteString.copyFromUtf8("HEAD"))
                        .setLimit(1))
                .setRepository(repository.getId());

        assertNull(revList(builder, handler));
    }

    @Test
    public void testRevListFirstCommitForPath() {
        SingleCommitRevListStdioHandler handler =
                new SingleCommitRevListStdioHandler("modification/mod_file.txt", true, true,
                        UNLIMITED_MESSAGE_LENGTH);

        RpcRevListRequest.Builder builder = RpcRevListRequest.newBuilder()
                .setRepository(repository.getId())
                .setRevListOptions(RpcRevListOptions.newBuilder()
                        .addPaths(ByteString.copyFromUtf8("modification/mod_file.txt"))
                        .addRevs(ByteString.copyFromUtf8("HEAD"))
                        .setFormat(handler.getFormat())
                        .setLimit(1));

        Commit commit = revList(builder, handler);

        assertNotNull(commit);
        assertCommit(commit, "Michael Heemskerk", "mheemskerk@atlassian.com", 1292816091,
                "bf3346620ec9161977fc23dee1240a7991e64ca1", "edited modification/mod_file.txt\n",
                "e26cab95a50f6e99d2c46c498a23cee307cc8e27");
    }

    @Test
    public void testRevListFirstCommitForPathWithInvalidCommitId() {
        thrown.expect(CommitNotFoundException.class);

        SingleCommitRevListStdioHandler handler =
                new SingleCommitRevListStdioHandler("modification/mod_file.txt", true, false,
                        UNLIMITED_MESSAGE_LENGTH);

        RpcRevListRequest.Builder builder = RpcRevListRequest.newBuilder()
                .setRepository(repository.getId())
                .setRevListOptions(RpcRevListOptions.newBuilder()
                        .addPaths(ByteString.copyFromUtf8("modification/mod_file.txt"))
                        .addRevs(ByteString.copyFromUtf8("invalid"))
                        .setLimit(1));

        revList(builder, handler);
    }

    @Test
    public void testRevListFirstCommitForRootCommit() {
        SingleCommitRevListStdioHandler handler =
                new SingleCommitRevListStdioHandler(null, true, false, UNLIMITED_MESSAGE_LENGTH);

        RpcRevListRequest.Builder builder = RpcRevListRequest.newBuilder()
                .setRepository(repository.getId())
                .setRevListOptions(RpcRevListOptions.newBuilder()
                        .addRevs(ByteString.copyFromUtf8("409a2180fd95b744df8d817e770b7f8c853f78dc"))
                        .setFormat(handler.getFormat()));

        Commit commit = revList(builder, handler);

        assertNotNull(commit);
        //When this commit was created, GIT_AUTHOR_DATE was exported to use a date in the past, but
        //GIT_COMMITTER_DATE was not exported. As a result, this commit has distinct timestamps
        assertCommit(commit, "Michael Heemskerk", "mheemskerk@atlassian.com", 1263078610,
                "Michael Heemskerk", "mheemskerk@atlassian.com", 1292812705,
                "409a2180fd95b744df8d817e770b7f8c853f78dc", "out of order commits scenario, initial commit\n");

        Collection<String> parents = commit.getParents();
        assertNotNull(parents);
        assertTrue(parents.isEmpty());
    }

    @Test
    public void testRevListIncludeMergeCommits() {
        List<Commit> commits = new ArrayList<>();
        CallbackRevListStdioHandler handler =
                new CallbackRevListStdioHandler((commit, refChange) -> commits.add(commit),
                        true, UNLIMITED_MESSAGE_LENGTH, Collections.emptyList());

        RpcRevListRequest.Builder builder = RpcRevListRequest.newBuilder()
                .setRepository(mergeCommits.getId())
                .setRevListOptions(RpcRevListOptions.newBuilder()
                        .setAll(true)
                        .setFormat(handler.getFormat())
                        .setMerges(RpcRevListMerges.MERGES_INCLUDE));

        revList(builder, handler);

        assertEquals(12, commits.size());
        assertEquals("e1c0ebbac1079a14b14fb05e67339ffbcca1894c", commits.get(0).getId());
        assertEquals("8dd91d1f6d846ec80045699d7ad1ee85395e22ef", commits.get(1).getId());
        assertEquals("2061acf612f3b1f4ac6d50e99688f41fbbb6c59c", commits.get(2).getId());
        assertEquals("d62cf48ad69b3b6957ab4d5ffdb76a24a2e2d1e6", commits.get(3).getId());
        assertEquals("6d7f1f78419d3dda0e5374d4d6b630e8fc7902ac", commits.get(4).getId());
        assertEquals("4856ffa5289244f02c017100d0d24d92d8df7888", commits.get(5).getId());
        assertEquals("d73fb0b2b117b3f92f9b0160d413b9d8299e2034", commits.get(6).getId());
        assertEquals("88221b3b195a998a4bf3aecbdb02f9efd69103ae", commits.get(7).getId());
        assertEquals("2d8b41bf8c4a216177b5303f845c226232f454f0", commits.get(8).getId());
        assertEquals("af7ad1f16d32ad11f44bbad1236808d405eb48f9", commits.get(9).getId());
        assertEquals("29646867dfdedb2b139c13b1f383d8f6134a3624", commits.get(10).getId());
        assertEquals("274e479b517365266ed43bf83678b28c2862f2b5", commits.get(11).getId());
    }

    @Test
    public void testRevListInvalidRev() {
        RpcRevListRequest.Builder builder = RpcRevListRequest.newBuilder()
                .setRepository(repository.getId())
                .setRevListOptions(RpcRevListOptions.newBuilder()
                        .addRevs(ByteString.copyFromUtf8("--topo-order"))
                        .addRevs(ByteString.copyFromUtf8("HEAD")));

        StatusRuntimeException thrown = assertThrows(StatusRuntimeException.class,
                () -> revList(builder, new StringStdioHandler()));
        assertSame(Status.Code.INVALID_ARGUMENT, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.invalidCommitish("--topo-order"));
    }

    @Test
    public void testRevListInvalidRevWithLeadingSpace() {
        RpcRevListRequest.Builder builder = RpcRevListRequest.newBuilder()
                .setRevListOptions(RpcRevListOptions.newBuilder()
                        .addRevs(ByteString.copyFromUtf8(" --topo-order"))
                        .addRevs(ByteString.copyFromUtf8("HEAD")));

        StatusRuntimeException thrown = assertThrows(StatusRuntimeException.class,
                () -> revList(builder, new StringStdioHandler()));
        assertSame(Status.Code.INVALID_ARGUMENT, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.invalidCommitish("--topo-order"));
    }

    @Test
    public void testRevListNoOptions() {
        //A request with no options should list all commits from HEAD in the default order
        //using the default format (which is just the commit ID)
        String stdout =
                revList(RpcRevListRequest.newBuilder().setRepository(repository.getId()), new StringStdioHandler());

        assertNotNull(stdout);

        String[] commitIds = stdout.split("\n");
        assertEquals(20, commitIds.length);
        assertEquals("0a943a29376f2336b78312d99e65da17048951db", commitIds[0]);
        assertEquals("409a2180fd95b744df8d817e770b7f8c853f78dc", commitIds[commitIds.length - 1]);
    }

    /**
     * When no revs are specified, the request should automatically add "HEAD"
     */
    @Test
    public void testRevListNoRevs() {
        SingleCommitRevListStdioHandler handler =
                new SingleCommitRevListStdioHandler(null, true, false, UNLIMITED_MESSAGE_LENGTH);

        RpcRevListRequest.Builder builder = RpcRevListRequest.newBuilder()
                .setRepository(repository.getId())
                .setRevListOptions(RpcRevListOptions.newBuilder()
                        .setFormat(handler.getFormat())
                        .setLimit(1));

        Commit commit = revList(builder, handler);

        assertNotNull(commit);
        assertEquals("0a943a29376f2336b78312d99e65da17048951db", commit.getId());
    }

    @Test
    public void testRevListNoWalk() {
        List<Commit> commits = new ArrayList<>();
        CallbackRevListStdioHandler handler =
                new CallbackRevListStdioHandler((commit, refChange) -> commits.add(commit),
                        true, UNLIMITED_MESSAGE_LENGTH, Collections.emptyList());

        RpcRevListRequest.Builder builder = RpcRevListRequest.newBuilder()
                .setRepository(repository.getId())
                .setRevListOptions(RpcRevListOptions.newBuilder()
                        .setAll(true)
                        .setFormat(handler.getFormat())
                        .setWalk(RpcRevListWalk.WALK_NO));

        revList(builder, handler);

        assertEquals(5, commits.size());
        assertEquals("0a943a29376f2336b78312d99e65da17048951db", commits.get(0).getId());
        assertEquals("6053a1eaa1c009dd11092d09a72f3c41af1b59ad", commits.get(1).getId());
        assertEquals("d6edcbf924697ab811a867421dab60d954ccad99", commits.get(2).getId());
        assertEquals("57150c54c38d6570b2fd5e6d6cfc19476de44e84", commits.get(3).getId());
        assertEquals("2e10dd2d1d5eea9291b296e78312e8a703964a95", commits.get(4).getId());
    }

    @Test
    public void testRevListOnlyMergeCommits() {
        List<Commit> commits = new ArrayList<>();
        CallbackRevListStdioHandler handler =
                new CallbackRevListStdioHandler((commit, refChange) -> commits.add(commit),
                        true, UNLIMITED_MESSAGE_LENGTH, Collections.emptyList());

        RpcRevListRequest.Builder builder = RpcRevListRequest.newBuilder()
                .setRepository(mergeCommits.getId())
                .setRevListOptions(RpcRevListOptions.newBuilder()
                        .setAll(true)
                        .setFormat(handler.getFormat())
                        .setMerges(RpcRevListMerges.MERGES_ONLY));

        revList(builder, handler);

        assertEquals(5, commits.size());
        assertEquals("e1c0ebbac1079a14b14fb05e67339ffbcca1894c", commits.get(0).getId());
        assertEquals("d62cf48ad69b3b6957ab4d5ffdb76a24a2e2d1e6", commits.get(1).getId());
        assertEquals("6d7f1f78419d3dda0e5374d4d6b630e8fc7902ac", commits.get(2).getId());
        assertEquals("4856ffa5289244f02c017100d0d24d92d8df7888", commits.get(3).getId());
        assertEquals("d73fb0b2b117b3f92f9b0160d413b9d8299e2034", commits.get(4).getId());
    }

    @Test
    public void testRevListParents() {
        CollectingTraversalCallback callback = new CollectingTraversalCallback();

        RpcRevListRequest.Builder builder = RpcRevListRequest.newBuilder()
                .setRepository(repository.getId())
                .setRevListOptions(RpcRevListOptions.newBuilder()
                        .setAll(true)
                        .setParents(true));

        revList(builder, new TraversalRevListStdioHandler(callback));

        List<CommitGraphNode> nodes = callback.nodes;
        assertNotNull(nodes);
        assertEquals(21, nodes.size());

        assertEquals("0a943a29376f2336b78312d99e65da17048951db", nodes.get(0).getId());
        assertEquals(1, nodes.get(0).getParentIds().size());
        assertEquals("409a2180fd95b744df8d817e770b7f8c853f78dc", nodes.get(20).getId());
        assertEquals(0, nodes.get(20).getParentIds().size());
    }

    @Test
    public void testRevListStdin() {
        List<Commit> commits = new ArrayList<>();
        CallbackRevListStdioHandler handler =
                new CallbackRevListStdioHandler((commit, refChange) -> commits.add(commit),
                        true, UNLIMITED_MESSAGE_LENGTH, Collections.emptyList(),
                        Stream.of("2d8897c9ac29ce42c3442cf80ac977057045e7f6",
                                "74de5497dfca9731e455d60552f9a8906e5dc1ac",
                                "^6053a1eaa1c009dd11092d09a72f3c41af1b59ad",
                                "^017caf31eca7c46eb3d1800fcac431cfa7147a01"));

        RpcRevListRequest.Builder builder = RpcRevListRequest.newBuilder()
                .setOptions(RpcGitOptions.newBuilder()
                        .addAlternates(drift.getId())
                        .addAlternates(merge.getId()))
                .setRepository(repository.getId())
                .setRevListOptions(RpcRevListOptions.newBuilder()
                        .setFormat(handler.getFormat())
                        .setIgnoreMissing(true));

        revListStdin(builder, handler);

        assertEquals(4, commits.size()); // 2 commits from 'repo', 2 from 'merge'

        // test 1 commit fully to ensure that output parsing and commit construction is correct
        Commit commit = commits.get(2);
        assertCommit(commit, "Anna Buttfield", "abuttfield@cascade.sydney.atlassian.com",
                1296709182, "2d8897c9ac29ce42c3442cf80ac977057045e7f6",
                "Rename A.zip to B.zip\n", "9c05f43f859375e392d90d23a13717c16d0fdcda");

        // now check the ids on all the others
        assertEquals("74de5497dfca9731e455d60552f9a8906e5dc1ac", commits.get(0).getId());
        assertEquals("3528cf690cb37f6adb85b7bd40cc7a6118d4b598", commits.get(1).getId());
        assertEquals("9c05f43f859375e392d90d23a13717c16d0fdcda", commits.get(3).getId());
    }

    @Test
    public void testRevListStdinWithInvalidRev() {
        RpcRevListRequest.Builder builder = RpcRevListRequest.newBuilder()
                .setRepository(repository.getId())
                .setRevListOptions(RpcRevListOptions.newBuilder()
                        .addRevs(ByteString.copyFromUtf8("--topo-order"))
                        .setLimit(1));

        StatusRuntimeException thrown = assertThrows(StatusRuntimeException.class, () ->
                revListStdin(builder,
                        new SingleCommitRevListStdioHandler(null, true, false,
                                UNLIMITED_MESSAGE_LENGTH)));

        assertSame(Status.Code.INVALID_ARGUMENT, thrown.getStatus().getCode());
        assertThat(thrown, HasGitError.invalidCommitish("--topo-order"));
    }

    @Test
    public void testRevListTags() {
        List<Commit> commits = new ArrayList<>();
        CallbackRevListStdioHandler handler =
                new CallbackRevListStdioHandler((commit, refChange) -> commits.add(commit),
                        true, UNLIMITED_MESSAGE_LENGTH, Collections.emptyList());

        RpcRevListRequest.Builder builder = RpcRevListRequest.newBuilder()
                .setRepository(repository.getId())
                .setRevListOptions(RpcRevListOptions.newBuilder()
                        .setFormat(handler.getFormat())
                        .setTags(true)
                        .setWalk(RpcRevListWalk.WALK_NO));

        revList(builder, handler);

        assertEquals(2, commits.size());
        assertEquals("0a943a29376f2336b78312d99e65da17048951db", commits.get(0).getId());
        assertEquals("57150c54c38d6570b2fd5e6d6cfc19476de44e84", commits.get(1).getId());
    }

    private static void assertBranch(String name, String latestCommit, Branch branch) {
        assertBranch(name, latestCommit, false, branch);
    }

    private static void assertBranch(String displayId, String latestCommit, boolean isDefault, Branch branch) {
        assertRef(HEADS, displayId, latestCommit, branch);
        assertEquals(isDefault, branch.isDefault());
    }

    public static void assertCommit(Commit commit, String authorName, String authorEmail, int authorTimestamp,
                                    String id, String message, String... parentIds) {
        assertCommit(commit, authorName, authorEmail, authorTimestamp, authorName,
                authorEmail, authorTimestamp, id, message, parentIds);
    }

    public static void assertCommit(Commit commit, String authorName, String authorEmail, int authorTimestamp,
                                    String committerName, String committerEmail, int committerTimestamp,
                                    String id, String message, String... parentIds) {
        assertCommit(commit, authorName, authorEmail, authorTimestamp * 1000L, committerName,
                committerEmail, committerTimestamp * 1000L, id, message, parentIds);
    }

    public static void assertCommit(Commit commit, String authorName, String authorEmail, long authorTimestamp,
                                    String committerName, String committerEmail, long committerTimestamp,
                                    String id, String message, String... parentIds) {
        assertNotNull(commit);
        assertEquals(authorName, commit.getAuthor().getName());
        assertEquals(authorEmail, commit.getAuthor().getEmail());
        assertEquals(authorTimestamp, commit.getAuthorTimestamp());
        assertEquals(committerName, commit.getCommitter().getName());
        assertEquals(committerEmail, commit.getCommitter().getEmail());
        assertEquals(committerTimestamp, commit.getCommitterTimestamp());
        assertEquals(id, commit.getId());
        assertEquals(message, commit.getMessage());

        List<String> parents = (List<String>) commit.getParents();
        assertNotNull(parents);
        assertEquals(parentIds.length, parents.size());
    }

    private static void assertObjectInfo(GitObjectInfo objectInfo, String objectId, long size) {
        assertNotNull("Missing change for " + objectId, objectInfo);
        assertEquals("Unexpected object ID", objectId, objectInfo.getObjectId());
        assertEquals("Incorrect size", size, objectInfo.getSize());
    }

    private static void assertRef(GitRefPattern type, String displayId, String latestCommit, Ref ref) {
        assertTrue(ref.getId().contains(displayId));
        assertEquals(type.qualify(displayId), ref.getId());
        assertEquals(latestCommit, ref.getLatestCommit());
    }

    private static void assertRefIsBranch(String displayId, String latestCommit, Ref ref) {
        assertTrue(ref instanceof Branch);
        assertRef(HEADS, displayId, latestCommit, ref);
    }

    private static void assertRefIsTag(String displayId, String latestCommit, Ref ref) {
        assertTrue(ref instanceof Tag);
        assertRef(TAGS, displayId, latestCommit, ref);
    }

    private static void assertTag(String displayId, String tagHash, String latestCommit, Tag tag) {
        assertRef(TAGS, displayId, latestCommit, tag);
        assertEquals(tagHash, tag.getHash());
    }

    private static void assertUnsupportedCommand(String expectedCommandLine, Throwable throwable) {
        RpcGitError error = RpcGitErrorUtils.fromThrowable(throwable);
        assertNotNull(error);
        assertTrue(error.hasUnsupportedCommand());
        assertEquals(expectedCommandLine, error.getUnsupportedCommand().getCommandLine().toStringUtf8());
    }

    private static RpcCallRequest.Builder createCallRequestBuilder(String command) {
        return RpcCallRequest.newBuilder().setCommand(command);
    }

    private static ListForEachRefStdioHandler<Branch> branchHandler(String filterText) {
        return new ListForEachRefStdioHandler<>(new ForEachBranchParser(HEAD), filterText);
    }

    private static ListForEachRefStdioHandler<Tag> tagHandler(String filterText) {
        return new ListForEachRefStdioHandler<>(ForEachTagParser.INSTANCE, filterText);
    }

    private PlumbingServiceGrpc.PlumbingServiceBlockingStub blockingStub() {
        return PlumbingServiceGrpc.newBlockingStub(managedChannel);
    }

    private <T> T call(RpcCallRequest.Builder builder, AbstractProcessStdioHandler<T> handler) {
        FragmentResponseObserver<T> observer =
                new FragmentResponseObserver<>(handler, new GitProcessCompletionHandler());
        stub().call(builder.build(), observer);
        return observer.asResult();
    }

    private String catFileBatch(String input) {
        FragmentResponseObserver<String> responseObserver =
                new FragmentResponseObserver<>(new StringStdioHandler(), new GitProcessCompletionHandler());
        StreamObserver<RpcCatFileStdinFragment> observer = stub().catFileBatch(responseObserver);

        observer.onNext(RpcCatFileStdinFragment.newBuilder()
                .setRequest(RpcCatFileBatchRequest.newBuilder().setRepository(repository.getId()))
                .build());

        observer.onNext(RpcCatFileStdinFragment.newBuilder()
                .setStdin(RpcStreamChunk.newBuilder()
                        .setData(ByteString.copyFromUtf8(input))
                        .build())
                .build());

        observer.onCompleted();
        return responseObserver.asResult();
    }

    private String catFileBatchCheck(String input) {
        FragmentResponseObserver<String> responseObserver =
                new FragmentResponseObserver<>(new StringStdioHandler(), new GitProcessCompletionHandler());
        StreamObserver<RpcCatFileStdinFragment> observer = stub().catFileBatchCheck(responseObserver);

        observer.onNext(RpcCatFileStdinFragment.newBuilder()
                .setRequest(RpcCatFileBatchRequest.newBuilder().setRepository(repository.getId()))
                .build());

        observer.onNext(RpcCatFileStdinFragment.newBuilder()
                .setStdin(RpcStreamChunk.newBuilder()
                        .setData(ByteString.copyFromUtf8(input))
                        .build())
                .build());

        observer.onCompleted();
        return responseObserver.asResult();
    }

    private Map<String, GitObjectInfo> catFileBatchCheck(InfoBatchCheckCatFilesStdioHandler handler) {
        BidirectionalFragmentResponseObserver<RpcCallStdinFragment, Map<String, GitObjectInfo>> responseObserver =
                new BidirectionalFragmentResponseObserver<>(handler,
                        new GitProcessCompletionHandler(),
                        new RequestFragmentCreator<RpcCallStdinFragment>() {

                            @Nonnull
                            @Override
                            public RpcCallStdinFragment forStdin(@Nonnull RpcStreamChunk stdin) {
                                requireNonNull(stdin, "stdin");

                                return RpcCallStdinFragment.newBuilder()
                                        .setStdin(stdin)
                                        .build();
                            }

                            @Nonnull
                            @Override
                            public RpcCallStdinFragment getInitial() {
                                return RpcCallStdinFragment.newBuilder()
                                        .setRequest(RpcCallRequest.newBuilder()
                                                .setRepository(repository.getId())
                                                .build())
                                        .build();
                            }
                        });

        stub().catFileBatchCheck(responseObserver);
        return responseObserver.asResult();
    }

    private RpcDiffTreeRequest.Builder createDiffTreeRequest() {
        return RpcDiffTreeRequest.newBuilder()
                .setDiffOptions(DEFAULT_DIFF_OPTIONS.toBuilder());
    }

    private RpcDiffTreeRequest.Builder createDiffTreeRequest(String rev) {
        return createDiffTreeRequest(rev, null);
    }

    private RpcDiffTreeRequest.Builder createDiffTreeRequest(String rev, String ancestor) {
        RpcDiffTreeRequest.Builder builder = createDiffTreeRequest();
        if (StringUtils.isBlank(ancestor)) {
            builder.setAlways(true)
                    .setRoot(true);
        } else {
            builder.setAncestor(ByteString.copyFromUtf8(ancestor));
        }

        return builder.setRecursive(true)
                .setDiffOptions(DEFAULT_DIFF_OPTIONS.toBuilder()
                        .setRenames(RpcDiffRenames.RENAMES_COPIES))
                .setRev(ByteString.copyFromUtf8(rev));
    }

    private List<Change> diffTree(RpcDiffTreeRequest.Builder builder, Repository repository, Set<String> paths) {
        List<Change> changes = new ArrayList<>();
        CallbackChangeDiffTreeStdioHandler handler = new CallbackChangeDiffTreeStdioHandler(changes::add, Integer.MAX_VALUE);
        if (paths != null) {
            handler.restrictPaths(paths);
        }

        FragmentResponseObserver<Void> observer = new FragmentResponseObserver<>(
                handler, new GitProcessCompletionHandler());

        stub().diffTree(builder.setRepository(repository.getId()).build(), observer);
        observer.asResult();

        return changes;
    }

    private GitObjectType getType(Repository repository, String objectId) {
        RpcCatFileRequest.Builder builder = RpcCatFileRequest.newBuilder()
                .setType(RpcCatFileType.newBuilder()
                        .setObjectId(ByteString.copyFromUtf8(objectId)))
                .setRepository(repository.getId());

        FragmentResponseObserver<String> observer =
                new FragmentResponseObserver<>(new StringStdioHandler(), new GitProcessCompletionHandler());
        stub().catFile(builder.build(), observer);

        return GitObjectType.fromObjectType(observer.asResult());
    }

    private <T extends Ref> List<T> forEachRef(RpcForEachRefRequest.Builder requestBuilder,
                                               ListForEachRefStdioHandler<T> handler) {
        FragmentResponseObserver<List<T>> observer =
                new FragmentResponseObserver<>(handler, new GitProcessCompletionHandler());

        stub().forEachRef(requestBuilder.setRepository(repository.getId()).build(), observer);

        return observer.asResult();
    }

    private byte[] packObjects(RpcPackObjectsRequest.Builder builder, Repository repository)
            throws TimeoutException, InterruptedException {
        StreamFragmentResponseObserver responseObserver = new StreamFragmentResponseObserver();

        stub().packObjects(builder.setRepository(repository.getId()).build(), responseObserver);

        responseObserver.await(5, TimeUnit.SECONDS);

        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        StringBuilder stderr = new StringBuilder();
        for (RpcStreamFragment fragment : responseObserver.getFragments()) {
            switch (fragment.getFragmentOneofCase()) {
                case RESULT:
                    int exitCode = fragment.getResult().getExitCode();
                    if (exitCode != 0) {
                        throw new CommandFailedException("pack-objects failed remotely: " + stderr,
                                new ProcessSummary.Builder("pack-objects")
                                        .exitCode(exitCode)
                                        .stderr(stderr.toString())
                                        .build());
                    }
                    break;
                case STDOUT:
                    try {
                        fragment.getStdout().getData().writeTo(bos);
                    } catch (IOException e) {
                        fail("IOException " + e);
                    }
                    break;
                case STDERR:
                    stderr.append(fragment.getStderr().getData().toStringUtf8());
                    break;
            }
        }

        return bos.toByteArray();
    }

    private byte[] packObjectsStdin(RpcPackObjectsRequest.Builder builder, Repository repository, String... stdinChunks)
            throws TimeoutException, InterruptedException {
        StreamFragmentResponseObserver responseObserver = new StreamFragmentResponseObserver();

        StreamObserver<RpcPackObjectsStdinFragment> requestObserver = stub().packObjectsStdin(responseObserver);

        requestObserver.onNext(RpcPackObjectsStdinFragment.newBuilder()
                .setRequest(builder.setRepository(repository.getId()))
                .build());
        for (int i = 0; i < stdinChunks.length; ++i) {
            requestObserver.onNext(RpcPackObjectsStdinFragment.newBuilder()
                    .setStdin(RpcStreamChunk.newBuilder()
                            .setData(ByteString.copyFromUtf8(stdinChunks[i]))
                            .setClosed(i == stdinChunks.length - 1))
                    .build());
        }
        requestObserver.onCompleted();

        responseObserver.await(5, TimeUnit.SECONDS);

        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        StringBuilder stderr = new StringBuilder();
        for (RpcStreamFragment fragment : responseObserver.getFragments()) {
            switch (fragment.getFragmentOneofCase()) {
                case RESULT:
                    int exitCode = fragment.getResult().getExitCode();
                    if (exitCode != 0) {
                        throw new CommandFailedException("pack-objects failed remotely: " + stderr,
                                new ProcessSummary.Builder("pack-objects")
                                        .exitCode(exitCode)
                                        .stderr(stderr.toString())
                                        .build());
                    }
                    break;
                case STDOUT:
                    try {
                        fragment.getStdout().getData().writeTo(bos);
                    } catch (IOException e) {
                        fail("IOException " + e);
                    }
                    break;
                case STDERR:
                    stderr.append(fragment.getStderr().getData().toStringUtf8());
                    break;
            }
        }

        return bos.toByteArray();
    }

    private Map<String, Ref> resolveRefs(String... ids) {
        ResolveRefsForEachRefStdioHandler handler =
                new ResolveRefsForEachRefStdioHandler(HEAD, ImmutableSet.copyOf(ids));

        RpcForEachRefRequest.Builder builder = RpcForEachRefRequest.newBuilder()
                .addPatterns(ByteString.copyFromUtf8(HEADS.getPath()))
                .addPatterns(ByteString.copyFromUtf8(TAGS.getPath()))
                .setFormat(handler.getFormat())
                .setRepository(repository.getId())
                .setSort("-objecttype");

        FragmentResponseObserver<Map<String, Ref>> observer =
                new FragmentResponseObserver<>(handler, new GitProcessCompletionHandler());

        stub().forEachRef(builder.build(), observer);

        return observer.asResult();
    }

    private <T> T revList(RpcRevListRequest.Builder builder, ProcessStdioHandler<T> handler) {
        FragmentResponseObserver<T> responseObserver = new FragmentResponseObserver<>(
                handler, new GitProcessCompletionHandler());

        stub().revList(builder.build(), responseObserver);
        return responseObserver.asResult();
    }

    private <T> void revListStdin(RpcRevListRequest.Builder builder, ProcessStdioHandler<T> handler) {
        BidirectionalFragmentResponseObserver<RpcRevListStdinFragment, T> responseObserver =
                new BidirectionalFragmentResponseObserver<>(handler,
                        new GitProcessCompletionHandler(),
                        new RequestFragmentCreator<RpcRevListStdinFragment>() {

                            @Nonnull
                            @Override
                            public RpcRevListStdinFragment forStdin(@Nonnull RpcStreamChunk stdin) {
                                return RpcRevListStdinFragment.newBuilder()
                                        .setStdin(stdin)
                                        .build();
                            }

                            @Nonnull
                            @Override
                            public RpcRevListStdinFragment getInitial() {
                                return RpcRevListStdinFragment.newBuilder()
                                        .setRequest(builder)
                                        .build();
                            }
                        });

        stub().revListStdin(responseObserver);
        responseObserver.asResult();
    }

    private PlumbingServiceGrpc.PlumbingServiceStub stub() {
        return PlumbingServiceGrpc.newStub(managedChannel);
    }

    private void verifyPackContains(byte[] pack, String... commitIds) throws IOException {
        Path targetRepo = temporaryDirectory.newFolder("repository");
        builderFactory.builder().init()
                .bare(true)
                .directory(targetRepo)
                .build()
                .run();

        //run index-pack to unpack the pack into a new repository
        builderFactory.builder().indexPack()
                .stdin(true)
                .workDir(targetRepo)
                .build(new InputStdioHandler(pack))
                .run();

        //Verify that the hashes exist
        for (String commitId : commitIds) {
            assertEquals(commitId + " is not a commit", "commit", builderFactory.builder().catFile()
                    .type()
                    .object(commitId)
                    .workDir(targetRepo)
                    .build(new StringStdioHandler())
                    .run());
        }
    }

    private static class CollectingTraversalCallback implements TraversalCallback {

        private final List<CommitGraphNode> nodes = new ArrayList<>();

        @Nonnull
        @Override
        public TraversalStatus onNode(@Nonnull CommitGraphNode node) {
            nodes.add(node);
            return TraversalStatus.CONTINUE;
        }
    }

    private static class FirstLineStdioHandler extends AbstractLineStdioHandler<String> {

        private String line;

        private FirstLineStdioHandler() {
            super(UTF_8);
        }

        @Override
        protected boolean onStdout(@Nonnull String line, boolean truncated) {
            this.line = line;

            return false;
        }

        @Override
        public String getResult() {
            return line;
        }
    }

    private static class ListForEachRefStdioHandler<T extends Ref>
            extends AbstractLineStdioHandler<List<T>>
            implements ForEachRefStdioHandler<List<T>> {

        private final Predicate<Ref> filter;
        private final ForEachRefParser<T> parser;
        private final List<T> refs;

        private ListForEachRefStdioHandler(ForEachRefParser<T> parser, String filterText) {
            super(UTF_8);

            this.parser = parser;

            filter = RefNameFilter.filterBy(filterText);
            refs = new ArrayList<>();
        }

        @Nonnull
        @Override
        public String getFormat() {
            return parser.getFormat();
        }

        @Override
        public List<T> getResult() {
            return refs;
        }

        @Override
        protected boolean onStdout(@Nonnull String line, boolean truncated) {
            T ref = parser.parse(line);

            if (filter.test(ref)) {
                refs.add(ref);
            }

            return true;
        }
    }
}
