package com.atlassian.bitbucket.mesh.git.commit;

import com.atlassian.bitbucket.mesh.AbstractWiredExecutionContextTest;
import com.atlassian.bitbucket.mesh.git.GitChangeType;
import com.atlassian.bitbucket.mesh.git.GitRepositories;
import com.atlassian.bitbucket.mesh.git.builder.revlist.GitRevListMerges;
import com.atlassian.bitbucket.mesh.git.builder.revlist.GitRevListOrder;
import com.atlassian.bitbucket.mesh.git.builder.revlist.GitRevListWalk;
import com.atlassian.bitbucket.mesh.git.content.MinimalChange;
import com.atlassian.bitbucket.mesh.git.exception.CommitNotFoundException;
import com.atlassian.bitbucket.mesh.git.exception.NoMergeBaseException;
import com.atlassian.bitbucket.mesh.git.exception.PathNotFoundException;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.atlassian.bitbucket.mesh.test.annotation.Repo;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.mockito.ArgumentCaptor;

import javax.annotation.Nonnull;
import javax.annotation.Resource;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;

import static org.hamcrest.collection.IsEmptyCollection.emptyCollectionOf;
import static org.hamcrest.collection.IsIterableContainingInOrder.contains;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

public class DefaultCommitManagerTest extends AbstractWiredExecutionContextTest {

    @Rule
    public final ExpectedException thrown = ExpectedException.none();

    @Resource
    private CommitManager commitManager;
    @Repo(GitRepositories.MERGE_COMMITS)
    private Repository mergeCommits;
    @Repo(GitRepositories.PULL_REQUESTS)
    private Repository pullRequests;

    @Repo(GitRepositories.QA_RESOURCES)
    private Repository qaResources;
    @Repo
    private Repository repository;

    @Test
    public void testGetCommit() {
        Commit commit = commitManager.getCommit(
                new GetCommitParameters.Builder(repository, "0a943a29376f2336b78312d99e65da17048951db")
                        .maxMessageLength(1000)
                        .build());
        assertNotNull(commit);
        assertEquals("0a943a29376f2336b78312d99e65da17048951db", commit.getId());
        assertEquals("Anna Buttfield", commit.getAuthor().getName());
        assertEquals("abuttfield@cascade.sydney.atlassian.com", commit.getAuthor().getEmail());
        assertEquals("Copy C.zip as D.zip\n", commit.getMessage());
        assertThat(commit.getParents(), contains("e0a3f6d8c804c375f49d7609328111e8dc75a4bc"));
    }

    @Test
    public void testGetCommitByPartialId() {
        Commit commit = commitManager.getCommit(
                new GetCommitParameters.Builder(repository, "409a2180fd9")
                        .maxMessageLength(1000)
                        .path("out_of_order")
                        .timeout(10L)
                        .build());
        assertNotNull(commit);
        assertEquals("409a2180fd95b744df8d817e770b7f8c853f78dc", commit.getId());
        assertEquals("Michael Heemskerk", commit.getAuthor().getName());
        assertEquals("mheemskerk@atlassian.com", commit.getAuthor().getEmail());
        assertEquals("out of order commits scenario, initial commit\n", commit.getMessage());
        assertThat(commit.getParents(), emptyCollectionOf(String.class));
    }

    @Test
    public void testGetCommitForMovedFile() {
        Commit commit = commitManager.getCommit(
                new GetCommitParameters.Builder(repository, "0a943a29376f2336b78312d99e65da17048951db")
                        .path("binary/A.zip")
                        .build());
        assertNotNull(commit);
        assertEquals("2d8897c9ac29ce42c3442cf80ac977057045e7f6", commit.getId());
        assertEquals("Anna Buttfield", commit.getAuthor().getName());
    }

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

        commitManager.getCommit(new GetCommitParameters.Builder(repository, "nonexistent")
                .timeout(10L)
                .build());
    }

    @Test
    public void testGetCommitForNonexistentPath() {
        thrown.expect(PathNotFoundException.class);

        commitManager.getCommit(new GetCommitParameters.Builder(repository, "0a943a29376f2336b78312d99e65da17048951db")
                .path("nonexistent")
                .timeout(10L)
                .build());
    }

    @Test
    public void testGetCommitOmitsMessageForMaxLengthZero() {
        Commit commit = commitManager.getCommit(
                new GetCommitParameters.Builder(repository, "409a2180fd95b744df8d817e770b7f8c853f78dc")
                        .maxMessageLength(0)
                        .path("out_of_order")
                        .timeout(10L)
                        .build());
        assertNotNull(commit);
        assertEquals("409a2180fd95b744df8d817e770b7f8c853f78dc", commit.getId());
        assertEquals("Michael Heemskerk", commit.getAuthor().getName());
        assertEquals("mheemskerk@atlassian.com", commit.getAuthor().getEmail());
        assertNull(commit.getMessage());
        assertThat(commit.getParents(), emptyCollectionOf(String.class));
    }

    @Test
    public void testGetCommitReturnsFullMessageForNegativeLength() {
        Commit commit = commitManager.getCommit(
                new GetCommitParameters.Builder(repository, "409a2180fd95b744df8d817e770b7f8c853f78dc")
                        .maxMessageLength(-1)
                        .path("out_of_order")
                        .timeout(10L)
                        .build());
        assertNotNull(commit);
        assertEquals("409a2180fd95b744df8d817e770b7f8c853f78dc", commit.getId());
        assertEquals("Michael Heemskerk", commit.getAuthor().getName());
        assertEquals("mheemskerk@atlassian.com", commit.getAuthor().getEmail());
        assertEquals("out of order commits scenario, initial commit\n", commit.getMessage());
        assertThat(commit.getParents(), emptyCollectionOf(String.class));
    }

    @Test
    public void testGetCommitTruncatesMessageAtMaxLength() {
        Commit commit = commitManager.getCommit(
                new GetCommitParameters.Builder(repository, "409a2180fd95b744df8d817e770b7f8c853f78dc")
                        .maxMessageLength(4)
                        .path("out_of_order")
                        .timeout(10L)
                        .build());
        assertNotNull(commit);
        assertEquals("409a2180fd95b744df8d817e770b7f8c853f78dc", commit.getId());
        assertEquals("Michael Heemskerk", commit.getAuthor().getName());
        assertEquals("mheemskerk@atlassian.com", commit.getAuthor().getEmail());
        assertEquals("out ", commit.getMessage());
        assertThat(commit.getParents(), emptyCollectionOf(String.class));
    }

    @Test
    public void testGetCommitWithPath() {
        Commit commit = commitManager.getCommit(
                new GetCommitParameters.Builder(repository, "409a2180fd95b744df8d817e770b7f8c853f78dc")
                        .maxMessageLength(1000)
                        .path("out_of_order")
                        .timeout(10L)
                        .build());
        assertNotNull(commit);
        assertEquals("409a2180fd95b744df8d817e770b7f8c853f78dc", commit.getId());
        assertEquals("Michael Heemskerk", commit.getAuthor().getName());
        assertEquals("mheemskerk@atlassian.com", commit.getAuthor().getEmail());
        assertEquals("out of order commits scenario, initial commit\n", commit.getMessage());
        assertThat(commit.getParents(), emptyCollectionOf(String.class));
    }

    @Test
    public void testGetMergeBase() {
        String mergeBase = commitManager.getMergeBase(new GetMergeBaseParameters.Builder(repository)
                .commitId("d6edcbf924697ab811a867421dab60d954ccad99") // basic_branching
                .commitId("0a943a29376f2336b78312d99e65da17048951db") // master
                .commitId("9b4e781dea0769888fe270e06ad76675f73851b1") // resigned_signed_tag
                .timeout(10L)
                .build());
        assertEquals("8fb8538799b2481750eac52716931d895d029b7c", mergeBase);
    }

    @Test
    public void testGetMergeBaseForSingleCommit() {
        String mergeBase = commitManager.getMergeBase(new GetMergeBaseParameters.Builder(repository)
                .commitId("d6edcbf924697ab811a867421dab60d954ccad99")
                .timeout(10L)
                .build());
        assertEquals("d6edcbf924697ab811a867421dab60d954ccad99", mergeBase);
    }

    @Test
    public void testGetMergeBaseWithUnconnectedHistories() {
        String mergeBase = commitManager.getMergeBase(new GetMergeBaseParameters.Builder(repository)
                .alternate(qaResources)
                .commitId("d6edcbf924697ab811a867421dab60d954ccad99") // from repository
                .commitId("50d24ffe3877c91013952ba13d595bf8779a3514") // from qaResources
                .timeout(10L)
                .build());
        assertNull(mergeBase);
    }

    @Test
    public void testGetMergeBaseWithUnconnectedHistoriesAndRequired() {
        thrown.expect(NoMergeBaseException.class);

        commitManager.getMergeBase(new GetMergeBaseParameters.Builder(repository)
                .alternate(qaResources)
                .commitId("d6edcbf924697ab811a867421dab60d954ccad99") // from repository
                .commitId("50d24ffe3877c91013952ba13d595bf8779a3514") // from qaResources
                .required(true)
                .timeout(10L)
                .build());
    }

    @Test
    public void testGetMergeBaseWithoutCommits() {
        String mergeBase = commitManager.getMergeBase(new GetMergeBaseParameters.Builder(repository).build());
        assertNull(mergeBase);
    }

    @Test
    public void testGetMergeBaseWithoutCommitsAndRequired() {
        thrown.expect(NoMergeBaseException.class);

        commitManager.getMergeBase(new GetMergeBaseParameters.Builder(repository)
                .required(true)
                .build());
    }

    @Test
    public void testResolveCommits() {
        ResolveCommitsParameters parameters = new ResolveCommitsParameters.Builder(repository)
                .commitishes("signed_tag",
                        "80aa5be5ecf39660f798858482254f7a2ae9110e", //backdated_annotated_tag
                        "9b4e781dea0769888fe270e06ad7", //retagged_signed_tag (short hash)
                        "0a943a29376f2336b78312d99e65da17048951db", // a regular commit
                        "master^^", // complex pattern
                        "7a18441e9b7a2fd86287db2d219050a926113b2b", // a tree object
                        "493021b1c9e0ecfebcd5ebce646a44138bc60b39", // a blob object
                        "deadbeef") // does not exist
                .build();

        Map<String, String> result = commitManager.resolveCommits(parameters);

        assertEquals(ImmutableMap.of(
                "signed_tag", "0a943a29376f2336b78312d99e65da17048951db",
                "80aa5be5ecf39660f798858482254f7a2ae9110e", "57150c54c38d6570b2fd5e6d6cfc19476de44e84",
                "9b4e781dea0769888fe270e06ad7", "0a943a29376f2336b78312d99e65da17048951db",
                "0a943a29376f2336b78312d99e65da17048951db", "0a943a29376f2336b78312d99e65da17048951db",
                "master^^", "2d8897c9ac29ce42c3442cf80ac977057045e7f6"), result);
    }

    @Test
    public void testStreamCommitsPagedFollowingRenames() {
        CollectingCommitCallback callback = new CollectingCommitCallback();
        StreamCommitsParameters.Builder builder = new StreamCommitsParameters.Builder(repository)
                .followRenames(true)
                .limit(2)
                .path("binary/B.zip");

        commitManager.streamCommits(builder.build(), callback);

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

        Commit commit = callback.commits.get(0);
        assertEquals("2d8897c9ac29ce42c3442cf80ac977057045e7f6", commit.getId());
        assertEquals("Anna Buttfield", commit.getAuthor().getName());
        assertEquals("abuttfield@cascade.sydney.atlassian.com", commit.getAuthor().getEmail());
        assertEquals("Rename A.zip to B.zip\n", commit.getMessage());
        assertEquals(1, commit.getParents().size());
        assertEquals("9c05f43f859375e392d90d23a13717c16d0fdcda", commit.getParents().iterator().next());
        assertRenameChange(
                callback.renames.get(commit.getId()), GitChangeType.RENAME, "binary/B.zip", "binary/A.zip", 100);

        commit = callback.commits.get(1);
        assertEquals("9c05f43f859375e392d90d23a13717c16d0fdcda", commit.getId());
        assertEquals("Anna Buttfield", commit.getAuthor().getName());
        assertEquals("abuttfield@cascade.sydney.atlassian.com", commit.getAuthor().getEmail());
        assertEquals("Add binary file A.zip\n", commit.getMessage());
        assertEquals(1, commit.getParents().size());
        assertEquals("6053a1eaa1c009dd11092d09a72f3c41af1b59ad", commit.getParents().iterator().next());
        assertRenameChange(callback.renames.get(commit.getId()), GitChangeType.ADD, "binary/A.zip");

        //Rerun the streamCommits, but now with skip=1
        callback = new CollectingCommitCallback();
        commitManager.streamCommits(builder.skip(1).build(), callback);

        assertEquals(1, callback.commits.size());

        commit = callback.commits.get(0);
        assertEquals("9c05f43f859375e392d90d23a13717c16d0fdcda", commit.getId());

        //Rerun the streamCommits, but now with skip=2, this should return no results
        callback = new CollectingCommitCallback();
        commitManager.streamCommits(builder.skip(2).build(), callback);

        assertTrue(callback.commits.isEmpty());
    }

    @Test
    public void testStreamCommitsPagedFollowingRenamesQaResources() {
        //This is a variation on testStreamCommitsPagedFollowingRenames, which focuses more on the paging logic and
        //whether we correctly work around git issues that arise when you try to mix --follow and --skip
        CollectingCommitCallback callback = new CollectingCommitCallback();
        StreamCommitsParameters.Builder builder = new StreamCommitsParameters.Builder(qaResources)
                .followRenames(true)
                .limit(20)
                .path("file1.txt");

        //Get the first page - this should never suffer from a problem in the paging logic (because there is no --skip)
        commitManager.streamCommits(builder.build(), callback);

        assertEquals(20, callback.commits.size());

        List<String> expectedCommitIds = ImmutableList.of(
                "b4d378388c2dc3840ff5dcf64b4ac0f1ffc17cb9", "c7c7497cb7ec1a5d645f891c066a8d876f42459b",
                "f73d58c33612b1f6da0f101254a0312f3f5ad906", "407d2495a04f5030ae3a12f9525f789b9a6c2cd6",
                "242f5433bc06faa53a717eb7341716ba12acde18", "ad621733a6dec16f426ea7c194c8f4e24e3171ee",
                "b967c311a4b07b84b2072ab4ce3e89c6085d566e", "48d50ee58289540686bd60d6da7356f00547558c",
                "3aa949f90df2df62a88c5fa2005db490f9a0e1e2", "7dbaf601c2766a0de26ab034f1c7cfbf18c0387b",
                "9379b13b9f779d9b127933cea4ea5238acd3b4aa", "0dabc9ccadbf12f51e1099a3204baa0150060671",
                "b7db979a508002a448928cb6dc40f837096496e1", "182ba4c374cc6d7ae7c6d02c9ea9f0bd64926647",
                "1a03f20556f0beee3a3e664609cc08040e883de0", "0a1db503dccf77c879bd521a8b8202a195221f7a",
                "09507f2315d3f23f36eaf5279806b56d5529de14", "984891ae27cbbc0cc0cc5001f4794d9951f628b5",
                "d363803edcbd3c9fec3852a7ff7887723a6f7306", "790493e6a231b0cc9c184568b18087c4dc65c0bc");

        assertEquals(expectedCommitIds, callback.commits.stream().map(Commit::getId).collect(Collectors.toList()));

        //Now get the same results, but paged in pages of 4 to verify that the we get the same commits, in the same
        //order as before.
        builder.limit(4);
        for (int skip = 0; skip < 20; skip += 4) {
            //Retrieve the next page
            callback = new CollectingCommitCallback();
            commitManager.streamCommits(builder.skip(skip).build(), callback);

            assertEquals("Page mismatch for [" + skip + ", " + (skip + 4) + "]",
                    expectedCommitIds.subList(skip, skip + 4),
                    callback.commits.stream().map(Commit::getId).collect(Collectors.toList()));
        }
    }

    @Test
    public void testStreamCommitsNoWalk() {
        CollectingCommitCallback callback = new CollectingCommitCallback();
        commitManager.streamCommits(new StreamCommitsParameters.Builder(repository)
                .include("0a943a29376f2336b78312d99e65da17048951db")
                .walk(GitRevListWalk.NO_WALK)
                .build(), callback);

        assertEquals(1, callback.commits.size());

        Commit commit = callback.commits.get(0);
        assertEquals("0a943a29376f2336b78312d99e65da17048951db", commit.getId());
        assertEquals("Anna Buttfield", commit.getAuthor().getName());
        assertEquals("abuttfield@cascade.sydney.atlassian.com", commit.getAuthor().getEmail());
        assertEquals("Copy C.zip as D.zip\n", commit.getMessage());
        assertEquals(1, commit.getParents().size());
        assertEquals("e0a3f6d8c804c375f49d7609328111e8dc75a4bc", commit.getParents().iterator().next());
    }

    @Test
    public void testStreamCommitsNoWalkFollowingMultipleCopiesFromUnicodePath() {
        CollectingCommitCallback callback = new CollectingCommitCallback();
        commitManager.streamCommits(new StreamCommitsParameters.Builder(qaResources)
                .followRenames(true)
                .include("d86c2faf589df293bf8f891c8f116a454fb59881")
                .include("2d44e0593487b151006b80cc0ab300e3f114b91e")
                .include("7f26ba7c44d354e81d9ab7bf67239e8a83c050b2")
                .include("50d24ffe3877c91013952ba13d595bf8779a3514") // no change to the path
                .path("国際交流基金海外運営専門員.txt")
                .walk(GitRevListWalk.NO_WALK_UNSORTED)
                .build(), callback);

        assertEquals(3, callback.commits.size());

        Commit commit = callback.commits.get(0);
        assertEquals("d86c2faf589df293bf8f891c8f116a454fb59881", commit.getId());
        assertRenameChange(callback.renames.get(commit.getId()), GitChangeType.COPY, "国際交流基金海外運営専門員.txt",
                "SourceView/%E2 t%EAst f%EDl%E9.txt", 98);

        commit = callback.commits.get(1);
        assertEquals("2d44e0593487b151006b80cc0ab300e3f114b91e", commit.getId());
        assertRenameChange(callback.renames.get(commit.getId()), GitChangeType.COPY,
                "SourceView/%E2 t%EAst f%EDl%E9.txt", "SourceView/intlstrings_20001lines.txt", 100);

        commit = callback.commits.get(2);
        assertEquals("7f26ba7c44d354e81d9ab7bf67239e8a83c050b2", commit.getId());
        assertRenameChange(callback.renames.get(commit.getId()), GitChangeType.ADD,
                "SourceView/intlstrings_20001lines.txt");
    }

    @Test
    public void testStreamCommitsNoWalkFollowingRenames() {
        CollectingCommitCallback callback = new CollectingCommitCallback();
        commitManager.streamCommits(new StreamCommitsParameters.Builder(repository)
                .followRenames(true)
                .include("2d8897c9ac29ce42c3442cf80ac977057045e7f6")
                .include("9c05f43f859375e392d90d23a13717c16d0fdcda")
                .include("57150c54c38d6570b2fd5e6d6cfc19476de44e84")
                .path("binary/B.zip")
                .walk(GitRevListWalk.NO_WALK)
                .build(), callback);

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

        List<Commit> commits = callback.commits;
        assertEquals(2, commits.size());

        Commit commit = commits.get(0);
        assertEquals("2d8897c9ac29ce42c3442cf80ac977057045e7f6", commit.getId());
        assertEquals("Anna Buttfield", commit.getAuthor().getName());
        assertEquals("abuttfield@cascade.sydney.atlassian.com", commit.getAuthor().getEmail());
        assertEquals("Rename A.zip to B.zip\n", commit.getMessage());
        assertEquals(1, commit.getParents().size());
        assertEquals("9c05f43f859375e392d90d23a13717c16d0fdcda", commit.getParents().iterator().next());
        assertRenameChange(callback.renames.get(commit.getId()), GitChangeType.RENAME,
                "binary/B.zip", "binary/A.zip", 100);

        commit = commits.get(1);
        assertEquals("9c05f43f859375e392d90d23a13717c16d0fdcda", commit.getId());
        assertEquals("Anna Buttfield", commit.getAuthor().getName());
        assertEquals("abuttfield@cascade.sydney.atlassian.com", commit.getAuthor().getEmail());
        assertEquals("Add binary file A.zip\n", commit.getMessage());
        assertEquals(1, commit.getParents().size());
        assertEquals("6053a1eaa1c009dd11092d09a72f3c41af1b59ad", commit.getParents().iterator().next());
        assertRenameChange(callback.renames.get(commit.getId()), GitChangeType.ADD, "binary/A.zip");
    }

    @Test(expected = CommitNotFoundException.class)
    public void testStreamCommitsNoWalkFollowingRenamesBadCommit() {
        CollectingCommitCallback callback = new CollectingCommitCallback();
        commitManager.streamCommits(new StreamCommitsParameters.Builder(repository)
                .followRenames(true)
                .include("bad")
                .path("binary/B.zip")
                .walk(GitRevListWalk.NO_WALK)
                .build(), callback);
    }

    //Previously this test would verify that the returned page included 2 commits. However, that was actually
    //relying on a bug in git log where, if --ignore-missing was used with --stdin and all the provided commits
    //were missing, it would incorrectly default to HEAD as if no commits were supplied at all. However, if the
    //same nonexistent commits were provided via the command line, it would _not_ default to HEAD.
    //See https://lore.kernel.org/git/CAGyf7-G_ciVpgvvOiH1Fq9kNuWunCpM1fhv3ao_RMXBB0K=HMA@mail.gmail.com/T/#u
    @Test
    public void testStreamCommitsNoWalkFollowingRenamesBadCommitIgnoreMissing() {
        CollectingCommitCallback callback = new CollectingCommitCallback();
        commitManager.streamCommits(new StreamCommitsParameters.Builder(repository)
                .followRenames(true)
                .ignoreMissing(true)
                .include("bad")
                .path("binary/B.zip")
                .walk(GitRevListWalk.NO_WALK)
                .build(), callback);

        assertEquals(0, callback.commits.size());
    }

    @Test
    public void testStreamCommitsNoWalkIncludingRenamesWithSecondaryRepository() {
        CollectingCommitCallback callback = new CollectingCommitCallback();
        commitManager.streamCommits(new StreamCommitsParameters.Builder(mergeCommits)
                .alternate(repository)
                .followRenames(true)
                .include("2d8897c9ac29ce42c3442cf80ac977057045e7f6") //These commits do not exist in mergeCommits
                .include("9c05f43f859375e392d90d23a13717c16d0fdcda")
                .include("57150c54c38d6570b2fd5e6d6cfc19476de44e84")
                .path("binary/B.zip")
                .walk(GitRevListWalk.NO_WALK)
                .build(), callback);

        List<Commit> commits = callback.commits;
        assertEquals(2, commits.size());

        assertEquals("2d8897c9ac29ce42c3442cf80ac977057045e7f6", commits.get(0).getId());
        assertEquals("9c05f43f859375e392d90d23a13717c16d0fdcda", commits.get(1).getId());
    }

    @Test
    public void testStreamCommitsNoWalkWithSecondaryRepository() {
        CollectingCommitCallback callback = new CollectingCommitCallback();
        commitManager.streamCommits(new StreamCommitsParameters.Builder(repository)
                .alternate(mergeCommits)
                .include("e1c0ebbac1079a14b14fb05e67339ffbcca1894c") //This commit ID only exists in mergeCommits
                .include("042fb0cc198e7320b59a025689b45a2cb52f4684") //This commit ID only exists in repository
                .walk(GitRevListWalk.NO_WALK)
                .build(), callback);

        List<Commit> commits = callback.commits;
        assertEquals(2, commits.size());
        assertEquals("e1c0ebbac1079a14b14fb05e67339ffbcca1894c", commits.get(0).getId());
        assertEquals("042fb0cc198e7320b59a025689b45a2cb52f4684", commits.get(1).getId());
    }

    @Test
    public void testStreamCommitsNoWalkRespectsOrder() {
        List<String> unsorted = streamQaCommitsNoWalkInOrder(true);
        List<String> sorted = streamQaCommitsNoWalkInOrder(false);

        assertEquals(unsorted.size(), sorted.size());

        assertEquals("18426679abca0aa9fe6e3adb4f90585cb143d362", unsorted.get(unsorted.size() - 1));
        assertEquals("7f26ba7c44d354e81d9ab7bf67239e8a83c050b2", sorted.get(sorted.size() - 1));
    }

    @Test
    public void testStreamCommitsNoWalkWithBadRefAndIgnoreMissing() {
        CollectingCommitCallback callback = new CollectingCommitCallback();
        commitManager.streamCommits(new StreamCommitsParameters.Builder(repository)
                .ignoreMissing(true)
                .include("bad")
                .walk(GitRevListWalk.NO_WALK)
                .build(), callback);

        assertEquals(0, callback.commits.size());
    }

    @Test
    public void testStreamCommitsSinceUsingRevList() {
        CollectingCommitCallback callback = new CollectingCommitCallback();
        commitManager.streamCommits(new StreamCommitsParameters.Builder(repository)
                .followRenames(false)
                .since(Instant.ofEpochSecond(1296709181L))
                .build(), callback);

        List<Commit> commits = callback.commits;
        assertEquals(3, commits.size());
        assertEquals("0a943a29376f2336b78312d99e65da17048951db", commits.get(0).getId());
        assertEquals("e0a3f6d8c804c375f49d7609328111e8dc75a4bc", commits.get(1).getId());
        assertEquals("2d8897c9ac29ce42c3442cf80ac977057045e7f6", commits.get(2).getId());
    }

    @Test
    public void testStreamCommitsSinceUsingLog() {
        CollectingCommitCallback callback = new CollectingCommitCallback();
        commitManager.streamCommits(new StreamCommitsParameters.Builder(repository)
                .followRenames(true)
                .path("*")
                .since(Instant.ofEpochSecond(1296709181L))
                .build(), callback);

        List<Commit> commits = callback.commits;
        assertEquals(3, commits.size());
        assertEquals("0a943a29376f2336b78312d99e65da17048951db", commits.get(0).getId());
        assertEquals("e0a3f6d8c804c375f49d7609328111e8dc75a4bc", commits.get(1).getId());
        assertEquals("2d8897c9ac29ce42c3442cf80ac977057045e7f6", commits.get(2).getId());
    }

    @Test(expected = CommitNotFoundException.class)
    public void testStreamCommitsNoWalkWithBadRefAndAutoIgnoreMissing() {
        streamCommitsNoWalk(false, "bad");
    }

    @Test(expected = CommitNotFoundException.class)
    public void testStreamCommitsNoWalkWithBadRefAndNotIgnoreMissing() {
        streamCommitsNoWalk(false, "bad");
    }

    @Test(expected = CommitNotFoundException.class)
    public void testStreamCommitsNoWalkWithBadRefs() {
        streamCommitsNoWalk(false, "bad", "worse"); //triggers --ignore-missing
    }

    @Test
    public void testStreamCommitsNoWalkWithBadAndGoodRef() {
        streamCommitsNoWalk(true, "bad", "e0a3f6d8c804c375f49d7609328111e8dc75a4bc");
    }

    @Test
    public void testStreamCommitsNoWalkWithBadAndGoodRefsDoesNotThrow() {
        streamCommitsNoWalk(true, "0a943a29376f2336b78312d99e65da17048951db", "bad",
                "e0a3f6d8c804c375f49d7609328111e8dc75a4bc", "worse");
    }

    @Test
    public void testStreamLastModified() {
        CollectingLastModifiedCallback callback = new CollectingLastModifiedCallback();

        commitManager.streamLastModified(
                new StreamLastModifiedParameters.Builder(repository, "0a943a29376f2336b78312d99e65da17048951db")
                        .path("binary")
                        .timeout(10L)
                        .build(),
                callback);

        //Verify we get the expected commit for each file in the directory
        assertEquals("2d8897c9ac29ce42c3442cf80ac977057045e7f6", callback.commitsByFile.get("B.zip").getId());
        assertEquals("e0a3f6d8c804c375f49d7609328111e8dc75a4bc", callback.commitsByFile.get("C.zip").getId());
        assertEquals("0a943a29376f2336b78312d99e65da17048951db", callback.commitsByFile.get("D.zip").getId());
        assertEquals(3, callback.commitsByFile.size());
        //Verify we get the expected commit for the containing directory
        assertEquals("0a943a29376f2336b78312d99e65da17048951db", callback.latestCommit.getId());
    }

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

        LastModifiedCallback callback = mock(LastModifiedCallback.class);
        try {
            commitManager.streamLastModified(
                    new StreamLastModifiedParameters.Builder(repository, "nonexistent")
                            .timeout(10L)
                            .build(),
                    callback);
        } finally {
            verifyNoInteractions(callback);
        }
    }

    @Test
    public void testStreamLastModifiedForNonexistentPath() {
        thrown.expect(PathNotFoundException.class);

        LastModifiedCallback callback = mock(LastModifiedCallback.class);
        try {
            commitManager.streamLastModified(
                    new StreamLastModifiedParameters.Builder(repository, "master")
                            .path("nonexistent")
                            .timeout(10L)
                            .build(),
                    callback);
        } finally {
            verifyNoInteractions(callback);
        }
    }

    @Test
    public void testStreamLastModifiedWithSingleFile() {
        CollectingLastModifiedCallback callback = new CollectingLastModifiedCallback();

        //There are no files in the root directory of the default repository; they're all in subdirectories
        commitManager.streamLastModified(
                new StreamLastModifiedParameters.Builder(repository, "HEAD")
                        .path("mv_file")
                        .timeout(10L)
                        .build(),
                callback);

        assertEquals("57150c54c38d6570b2fd5e6d6cfc19476de44e84", callback.commitsByFile.get("moved_file.txt").getId());
        assertEquals("57150c54c38d6570b2fd5e6d6cfc19476de44e84", callback.latestCommit.getId());
    }

    @Test
    public void testStreamLastModifiedWithoutFiles() {
        CollectingLastModifiedCallback callback = new CollectingLastModifiedCallback();

        //There are no files in the root directory of the default repository; they're all in subdirectories
        commitManager.streamLastModified(
                new StreamLastModifiedParameters.Builder(repository, "master")
                        .timeout(10L)
                        .build(),
                callback);

        assertEquals("0a943a29376f2336b78312d99e65da17048951db", callback.latestCommit.getId());
        assertTrue(callback.commitsByFile.isEmpty());
    }

    @Test
    public void testTraverseCommits() {
        TraversalCallback callback = mock(TraversalCallback.class);
        when(callback.onNode(any())).thenReturn(TraversalStatus.CONTINUE);

        TraverseCommitsParameters parameters = new TraverseCommitsParameters.Builder(repository)
                .include("d6edcbf924697ab811a867421dab60d954ccad99", "0a943a29376f2336b78312d99e65da17048951db")
                .build();

        commitManager.traverseCommits(parameters, callback);

        ArgumentCaptor<CommitGraphNode> nodeCaptor = ArgumentCaptor.forClass(CommitGraphNode.class);

        verify(callback, times(21)).onNode(nodeCaptor.capture());

        List<CommitGraphNode> nodes = nodeCaptor.getAllValues();
        assertEquals(21, nodes.size());
        assertRevNode(nodes.get(0), "0a943a29376f2336b78312d99e65da17048951db", "e0a3f6d8c804c375f49d7609328111e8dc75a4bc");
        assertRevNode(nodes.get(1), "e0a3f6d8c804c375f49d7609328111e8dc75a4bc", "2d8897c9ac29ce42c3442cf80ac977057045e7f6");
        assertRevNode(nodes.get(2), "2d8897c9ac29ce42c3442cf80ac977057045e7f6", "9c05f43f859375e392d90d23a13717c16d0fdcda");
        assertRevNode(nodes.get(3), "9c05f43f859375e392d90d23a13717c16d0fdcda", "6053a1eaa1c009dd11092d09a72f3c41af1b59ad");
        assertRevNode(nodes.get(4), "6053a1eaa1c009dd11092d09a72f3c41af1b59ad", "042fb0cc198e7320b59a025689b45a2cb52f4684");
        assertRevNode(nodes.get(5), "042fb0cc198e7320b59a025689b45a2cb52f4684", "8fb8538799b2481750eac52716931d895d029b7c");
        assertRevNode(nodes.get(6), "d6edcbf924697ab811a867421dab60d954ccad99", "8fb8538799b2481750eac52716931d895d029b7c");
        assertRevNode(nodes.get(7), "8fb8538799b2481750eac52716931d895d029b7c", "53979b3304bece75a3852a3d250dbaed64d45430");
        assertRevNode(nodes.get(8), "53979b3304bece75a3852a3d250dbaed64d45430", "ed69be6064dcef2d0b08b35192f581613e48411b");
        assertRevNode(nodes.get(9), "ed69be6064dcef2d0b08b35192f581613e48411b", "05c78271ed4d7a158fe6789f6b27e3a47631faba");
        assertRevNode(nodes.get(10), "05c78271ed4d7a158fe6789f6b27e3a47631faba", "57150c54c38d6570b2fd5e6d6cfc19476de44e84");
        assertRevNode(nodes.get(11), "57150c54c38d6570b2fd5e6d6cfc19476de44e84", "9fbc34a0d905950131d73f352abe68520c6db2a3");
        assertRevNode(nodes.get(12), "9fbc34a0d905950131d73f352abe68520c6db2a3", "6d8e9736357657738d15cd29069f21bf44553e19");
        assertRevNode(nodes.get(13), "6d8e9736357657738d15cd29069f21bf44553e19", "4f9290ae1a9fcde7acd56664a22cc65eb76540f3");
        assertRevNode(nodes.get(14), "4f9290ae1a9fcde7acd56664a22cc65eb76540f3", "8a71bdc3c67e49729db5f17f0477cee857fd7ae7");
        assertRevNode(nodes.get(15), "8a71bdc3c67e49729db5f17f0477cee857fd7ae7", "bf3346620ec9161977fc23dee1240a7991e64ca1");
        assertRevNode(nodes.get(16), "bf3346620ec9161977fc23dee1240a7991e64ca1", "e26cab95a50f6e99d2c46c498a23cee307cc8e27");
        assertRevNode(nodes.get(17), "e26cab95a50f6e99d2c46c498a23cee307cc8e27", "2e10dd2d1d5eea9291b296e78312e8a703964a95");
        assertRevNode(nodes.get(18), "2e10dd2d1d5eea9291b296e78312e8a703964a95", "c2608f5dff150e2b26b4b68d9e22369581b39b0c");
        assertRevNode(nodes.get(19), "c2608f5dff150e2b26b4b68d9e22369581b39b0c", "409a2180fd95b744df8d817e770b7f8c853f78dc");
        assertRevNode(nodes.get(20), "409a2180fd95b744df8d817e770b7f8c853f78dc");
    }

    @Test
    public void testTraverseCommitsAbortsWhenCallbackReturnsFinish() {
        TraversalCallback callback = mock(TraversalCallback.class);
        when(callback.onNode(any())).thenReturn(TraversalStatus.FINISH);

        TraverseCommitsParameters parameters = new TraverseCommitsParameters.Builder(repository)
                .include("d6edcbf924697ab811a867421dab60d954ccad99", "0a943a29376f2336b78312d99e65da17048951db")
                .build();

        commitManager.traverseCommits(parameters, callback);

        ArgumentCaptor<CommitGraphNode> nodeCaptor = ArgumentCaptor.forClass(CommitGraphNode.class);

        verify(callback).onNode(nodeCaptor.capture());

        List<CommitGraphNode> nodes = nodeCaptor.getAllValues();
        assertEquals(1, nodes.size());
        assertRevNode(nodes.get(0), "0a943a29376f2336b78312d99e65da17048951db", "e0a3f6d8c804c375f49d7609328111e8dc75a4bc");
    }

    @Test
    public void testTraverseCommitsWithAlternates() {
        TraversalCallback callback = mock(TraversalCallback.class);
        when(callback.onNode(any())).thenReturn(TraversalStatus.CONTINUE);

        commitManager.traverseCommits(new TraverseCommitsParameters.Builder(repository)
                .alternate(mergeCommits, pullRequests)
                .ignoreMissing(false)
                .include("a031d5845bfb8ef60f102986f98641733a3df9ba") // pullRequests HEAD, 1 commit, 101 ancestors
                .include("017caf31eca7c46eb3d1800fcac431cfa7147a01") // merge HEAD, 1 commit (also in pullRequests), no ancestors
                .include("0a943a29376f2336b78312d99e65da17048951db") // repository HEAD, 1 commit, 19 ancestors
                .build(), callback);

        verify(callback, times(122)).onNode(any());
    }

    @Test
    public void testTraverseCommitsIgnoringMissing() {
        TraversalCallback callback = mock(TraversalCallback.class);
        when(callback.onNode(any())).thenReturn(TraversalStatus.CONTINUE);

        commitManager.traverseCommits(new TraverseCommitsParameters.Builder(repository)
                .include("bad")
                .include("409a2180fd95b744df8d817e770b7f8c853f78dc")
                .ignoreMissing(true)
                .build(), callback);

        ArgumentCaptor<CommitGraphNode> nodeCaptor = ArgumentCaptor.forClass(CommitGraphNode.class);

        verify(callback, times(1)).onNode(nodeCaptor.capture());

        List<CommitGraphNode> nodes = nodeCaptor.getAllValues();
        assertEquals(1, nodes.size());
        assertRevNode(nodes.get(0), "409a2180fd95b744df8d817e770b7f8c853f78dc");
    }

    @Test(expected = CommitNotFoundException.class)
    public void testTraverseCommitsNotIgnoringMissing() {
        TraversalCallback callback = mock(TraversalCallback.class);
        when(callback.onNode(any())).thenReturn(TraversalStatus.CONTINUE);

        commitManager.traverseCommits(new TraverseCommitsParameters.Builder(repository)
                .include("bad")
                .include("9c05f43f859375e392d90d23a13717c16d0fdcda")
                .ignoreMissing(false)
                .build(), callback);
    }

    @Test(expected = IllegalStateException.class)
    public void testTraverseRequiresIncludes() {
         new TraverseCommitsParameters.Builder(repository).build();
    }

    @Test
    public void testStreamCommits() {
        CollectingCommitCallback callback = new CollectingCommitCallback();
        commitManager.streamCommits(new StreamCommitsParameters.Builder(repository)
                .exclude("e0a3f6d8c804c375f49d7609328111e8dc75a4bc")
                .include("0a943a29376f2336b78312d99e65da17048951db")
                .build(), callback);
        
        assertEquals(1, callback.commits.size());

        Commit commit = callback.commits.get(0);
        assertEquals("0a943a29376f2336b78312d99e65da17048951db", commit.getId());
        assertEquals("Anna Buttfield", commit.getAuthor().getName());
        assertEquals("abuttfield@cascade.sydney.atlassian.com", commit.getAuthor().getEmail());
        assertEquals("Copy C.zip as D.zip\n", commit.getMessage());
        assertEquals(1, commit.getParents().size());
        assertEquals("e0a3f6d8c804c375f49d7609328111e8dc75a4bc", commit.getParents().iterator().next());
    }

    @Test
    public void testStreamCommitsExcludeMerges() {
        CollectingCommitCallback callback = new CollectingCommitCallback();
        commitManager.streamCommits(new StreamCommitsParameters.Builder(mergeCommits)
                .merges(GitRevListMerges.EXCLUDE)
                .build(), callback);
        
        assertEquals(7, callback.commits.size());

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

    @Test
    public void testStreamCommitsFollowingMultipleCopiesFromUnicodePath() {
        CollectingCommitCallback callback = new CollectingCommitCallback();
        commitManager.streamCommits(new StreamCommitsParameters.Builder(qaResources)
                .followRenames(true)
                .path("国際交流基金海外運営専門員.txt")
                .build(), callback);

        assertEquals(3, callback.commits.size());

        Commit commit = callback.commits.get(0);
        assertEquals("d86c2faf589df293bf8f891c8f116a454fb59881", commit.getId());
        assertRenameChange(callback.renames.get(commit.getId()), GitChangeType.COPY, "国際交流基金海外運営専門員.txt",
                "SourceView/%E2 t%EAst f%EDl%E9.txt", 98);

        commit = callback.commits.get(1);
        assertEquals("2d44e0593487b151006b80cc0ab300e3f114b91e", commit.getId());
        assertRenameChange(callback.renames.get(commit.getId()), GitChangeType.COPY,
                "SourceView/%E2 t%EAst f%EDl%E9.txt", "SourceView/intlstrings_20001lines.txt", 100);

        commit = callback.commits.get(2);
        assertEquals("7f26ba7c44d354e81d9ab7bf67239e8a83c050b2", commit.getId());
        assertRenameChange(callback.renames.get(commit.getId()), GitChangeType.ADD,
                "SourceView/intlstrings_20001lines.txt");
    }

    @Test
    public void testStreamCommitsFollowingRenames() {
        CollectingCommitCallback callback = new CollectingCommitCallback();
        commitManager.streamCommits(new StreamCommitsParameters.Builder(repository)
                .followRenames(true)
                .path("binary/B.zip")
                .build(), callback);

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

        List<Commit> commits = callback.commits;
        assertEquals(2, commits.size());

        Commit commit = commits.get(0);
        assertEquals("2d8897c9ac29ce42c3442cf80ac977057045e7f6", commit.getId());
        assertEquals("Anna Buttfield", commit.getAuthor().getName());
        assertEquals("abuttfield@cascade.sydney.atlassian.com", commit.getAuthor().getEmail());
        assertEquals("Rename A.zip to B.zip\n", commit.getMessage());
        assertEquals(1, commit.getParents().size());
        assertEquals("9c05f43f859375e392d90d23a13717c16d0fdcda", commit.getParents().iterator().next());
        assertRenameChange(callback.renames.get(commit.getId()), GitChangeType.RENAME,
                "binary/B.zip", "binary/A.zip", 100);

        commit = commits.get(1);
        assertEquals("9c05f43f859375e392d90d23a13717c16d0fdcda", commit.getId());
        assertEquals("Anna Buttfield", commit.getAuthor().getName());
        assertEquals("abuttfield@cascade.sydney.atlassian.com", commit.getAuthor().getEmail());
        assertEquals("Add binary file A.zip\n", commit.getMessage());
        assertEquals(1, commit.getParents().size());
        assertEquals("6053a1eaa1c009dd11092d09a72f3c41af1b59ad", commit.getParents().iterator().next());
        assertRenameChange(callback.renames.get(commit.getId()), GitChangeType.ADD, "binary/A.zip");
    }

    @Test(expected = CommitNotFoundException.class)
    public void testStreamCommitsFollowingRenamesBadCommit() {
        CollectingCommitCallback callback = new CollectingCommitCallback();
        commitManager.streamCommits(new StreamCommitsParameters.Builder(repository)
                .followRenames(true)
                .include("bad")
                .path("binary/B.zip")
                .build(), callback);
    }

    //Previously this test would verify that the returned page included 2 commits. However, that was actually
    //relying on a bug in git log where, if --ignore-missing was used with --stdin and all the provided commits
    //were missing, it would incorrectly default to HEAD as if no commits were supplied at all. However, if the
    //same nonexistent commits were provided via the command line, it would _not_ default to HEAD.
    //See https://lore.kernel.org/git/CAGyf7-G_ciVpgvvOiH1Fq9kNuWunCpM1fhv3ao_RMXBB0K=HMA@mail.gmail.com/T/#u
    @Test
    public void testStreamCommitsFollowingRenamesBadCommitIgnoreMissing() {
        CollectingCommitCallback callback = new CollectingCommitCallback();
        commitManager.streamCommits(new StreamCommitsParameters.Builder(repository)
                .followRenames(true)
                .ignoreMissing(true)
                .include("bad")
                .path("binary/B.zip")
                .build(), callback);
        
        assertEquals(0, callback.commits.size());
    }

    @Test
    public void testStreamCommitsIncludeMerges() {
        CollectingCommitCallback callback = new CollectingCommitCallback();
        commitManager.streamCommits(new StreamCommitsParameters.Builder(mergeCommits)
                .merges(GitRevListMerges.INCLUDE)
                .build(), callback);

        List<Commit> commits = callback.commits;
        assertNotNull(commits);
        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 testStreamCommitsIncludingRenamesWithSecondaryRepository() {
        CollectingCommitCallback callback = new CollectingCommitCallback();
        commitManager.streamCommits(new StreamCommitsParameters.Builder(mergeCommits)
                .alternate(repository)
                .followRenames(true)
                .include("0a943a29376f2336b78312d99e65da17048951db") //This commit ID does not exist in mergeCommits
                .path("binary/B.zip")
                .build(), callback);
        
        List<Commit> commits = callback.commits;
        assertEquals(2, commits.size());

        assertEquals("2d8897c9ac29ce42c3442cf80ac977057045e7f6", commits.get(0).getId());
        assertEquals("9c05f43f859375e392d90d23a13717c16d0fdcda", commits.get(1).getId());
    }

    @Test
    public void testStreamCommitsOnlyMerges() {
        CollectingCommitCallback callback = new CollectingCommitCallback();
        commitManager.streamCommits(new StreamCommitsParameters.Builder(mergeCommits)
                .merges(GitRevListMerges.ONLY)
                .build(), callback);

        List<Commit> commits = callback.commits;
        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 testStreamCommitsWithSecondaryRepository() {
        CollectingCommitCallback callback = new CollectingCommitCallback();
        commitManager.streamCommits(new StreamCommitsParameters.Builder(mergeCommits)
                .alternate(mergeCommits)
                .include("e1c0ebbac1079a14b14fb05e67339ffbcca1894c") //This commit ID does not exist in repository
                .build(), callback);

        List<Commit> commits = callback.commits;
        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 testStreamCommitsRespectsOrder() {
        List<String> defaultOrder = streamQaCommitsInOrder(GitRevListOrder.DEFAULT);
        List<String> topoOrder = streamQaCommitsInOrder(GitRevListOrder.TOPOLOGICAL);

        assertEquals(defaultOrder.size(), topoOrder.size());

        assertEquals("18426679abca0aa9fe6e3adb4f90585cb143d362", defaultOrder.get(defaultOrder.size() - 1));
        assertEquals("6b6776befdde82a1310edfb440206475b3bbc65c", topoOrder.get(topoOrder.size() - 1));
    }
    
    @Test
    public void testStreamCommitsWithBadRefAndIgnoreMissing() {
        CollectingCommitCallback callback = new CollectingCommitCallback();
        commitManager.streamCommits(new StreamCommitsParameters.Builder(repository)
                .ignoreMissing(true)
                .include("bad")
                .build(), callback);

        assertEquals(0, callback.commits.size());
    }

    @Test(expected = CommitNotFoundException.class)
    public void testStreamCommitsWithBadRefAndAutoIgnoreMissing() {
        streamCommits(false, "bad");
    }

    @Test(expected = CommitNotFoundException.class)
    public void testStreamCommitsWithBadRefAndNotIgnoreMissing() {
        streamCommits(false, "bad");
    }

    @Test(expected = CommitNotFoundException.class)
    public void testStreamCommitsWithBadRefs() {
        streamCommits(false, "bad", "worse"); //triggers --ignore-missing
    }

    @Test(expected = CommitNotFoundException.class)
    public void testStreamCommitsWithAmbiguousRef() {
        streamCommits(qaResources, false, "6fad");
    }

    @Test
    public void testStreamCommitsWithBadAndGoodRef() {
        streamCommits(true, "bad", "e0a3f6d8c804c375f49d7609328111e8dc75a4bc");
    }

    @Test
    public void testStreamCommitsWithBadAndGoodRefWithExcludesIgnoreMissing() {
        CollectingCommitCallback callback = new CollectingCommitCallback();
        commitManager.streamCommits(new StreamCommitsParameters.Builder(repository)
                .ignoreMissing(true)
                .include("e0a3f6d8c804c375f49d7609328111e8dc75a4bc")
                .exclude("bad")
                .build(), callback);
        assertEquals(19, callback.commits.size());
    }

    @Test(expected = CommitNotFoundException.class)
    public void testStreamCommitsWithBadAndGoodRefWithExcludesNoIgnoreMissing() {
        CollectingCommitCallback callback = new CollectingCommitCallback();
        commitManager.streamCommits(new StreamCommitsParameters.Builder(repository)
                .ignoreMissing(false)
                .include("e0a3f6d8c804c375f49d7609328111e8dc75a4bc")
                .exclude("bad")
                .build(), callback);
    }

    @Test
    public void testStreamCommitsWithBadAndGoodRefsDoesNotThrow() {
        streamCommits(true, "0a943a29376f2336b78312d99e65da17048951db", "bad",
                "e0a3f6d8c804c375f49d7609328111e8dc75a4bc", "worse");
    }

    private static void assertRenameChange(MinimalChange change, GitChangeType changeType, String path) {
        assertRenameChange(change, changeType, path, null, -1);
    }

    private static void assertRenameChange(MinimalChange change, GitChangeType changeType, String path,
                                           String srcPath, int percentUnchanged) {
        assertNotNull("No \"change\" data is associated with the commit", change);
        assertSame(changeType, change.getType());
        assertEquals(path, change.getPath());
        assertEquals(srcPath, change.getSrcPath());
        assertEquals(percentUnchanged, change.getPercentUnchanged());
    }

    public static void assertRevNode(CommitGraphNode node, String commitId, String... parentIds) {
        assertNotNull(node);
        assertEquals(commitId, node.getId());

        List<String> nodeParents = node.getParentIds();
        assertNotNull(nodeParents);
        if (parentIds == null || parentIds.length == 0) {
            assertTrue(nodeParents.isEmpty());
        } else {
            List<String> expectedIds = Arrays.asList(parentIds);
            assertEquals("Parent IDs did not match - expected " + expectedIds.size() + " parents, found " + nodeParents.size(),
                    expectedIds, nodeParents);
        }
    }

    private void streamCommits(Boolean ignoreMissing, String commit, String... commits) {
        streamCommits(repository, ignoreMissing, commit, commits);
    }

    private void streamCommits(Repository repository, Boolean ignoreMissing, String commit, String... commits) {
        CollectingCommitCallback callback = new CollectingCommitCallback();
        StreamCommitsParameters.Builder builder = new StreamCommitsParameters.Builder(repository)
                .include(commit)
                .includes(ImmutableList.copyOf(commits));
        if (ignoreMissing != null) {
            builder.ignoreMissing(ignoreMissing);
        }

        commitManager.streamCommits(builder.build(), callback);
    }

    private void streamCommitsNoWalk(Boolean ignoreMissing, String commit, String... commits) {
        CollectingCommitCallback callback = new CollectingCommitCallback();
        StreamCommitsParameters.Builder builder = new StreamCommitsParameters.Builder(repository)
                .include(commit)
                .includes(ImmutableList.copyOf(commits))
                .walk(GitRevListWalk.NO_WALK);
        if (ignoreMissing != null) {
            builder.ignoreMissing(ignoreMissing);
        }

        commitManager.streamCommits(builder.build(), callback);
    }

    private List<String> streamQaCommitsInOrder(GitRevListOrder order) {
        CollectingCommitCallback callback = new CollectingCommitCallback();
        commitManager.streamCommits(new StreamCommitsParameters.Builder(qaResources)
                .include("50d24ffe3877c91013952ba13d595bf8779a3514")
                .exclude("d86c2faf589df293bf8f891c8f116a454fb59881")
                .order(order)
                .build(), callback);

        return callback.commits.stream().map(Commit::getId).collect(Collectors.toList());
    }

    private List<String> streamQaCommitsNoWalkInOrder(boolean unsorted) {
        CollectingCommitCallback callback = new CollectingCommitCallback();
        commitManager.streamCommits(new StreamCommitsParameters.Builder(qaResources)
                .include("50d24ffe3877c91013952ba13d595bf8779a3514")
                .include("d86c2faf589df293bf8f891c8f116a454fb59881")
                .include("6b6776befdde82a1310edfb440206475b3bbc65c")
                .include("2d44e0593487b151006b80cc0ab300e3f114b91e")
                .include("7f26ba7c44d354e81d9ab7bf67239e8a83c050b2")
                .include("18426679abca0aa9fe6e3adb4f90585cb143d362")
                .walk(unsorted ? GitRevListWalk.NO_WALK_UNSORTED : GitRevListWalk.NO_WALK)
                .build(), callback);

        return callback.commits.stream().map(Commit::getId).collect(Collectors.toList());
    }

    private static class CollectingCommitCallback implements CommitCallback {

        private final List<Commit> commits = new ArrayList<>();
        private final Map<String, MinimalChange> renames = new HashMap<>();
        
        @Override
        public boolean onCommit(@Nonnull Commit commit, MinimalChange renameChange) {
            commits.add(commit);
            if (renameChange != null) {
                renames.put(commit.getId(), renameChange);
            }
            return true;
        }
    }

    private static class CollectingLastModifiedCallback implements LastModifiedCallback {

        private final Map<String, Commit> commitsByFile = new HashMap<>();

        private Commit latestCommit;

        @Override
        public void onFileModified(@Nonnull String file, @Nonnull Commit latestCommit) {
            commitsByFile.put(file, latestCommit);
        }

        @Override
        public void onLatestCommit(@Nonnull Commit latestCommit) {
            this.latestCommit = latestCommit;
        }
    }
}