package com.atlassian.stash.internal.scm.git;

import com.atlassian.bitbucket.commit.*;
import com.atlassian.bitbucket.content.NoSuchPathException;
import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.bitbucket.i18n.KeyedMessage;
import com.atlassian.bitbucket.repository.Branch;
import com.atlassian.bitbucket.repository.Ref;
import com.atlassian.bitbucket.repository.Repository;
import com.atlassian.bitbucket.scm.*;
import com.atlassian.bitbucket.scm.annotation.DirtiesRepo;
import com.atlassian.bitbucket.scm.annotation.Repo;
import com.atlassian.bitbucket.scm.git.command.GitCommandBuilderFactory;
import com.atlassian.bitbucket.scm.git.command.GitCommandFactory;
import com.atlassian.bitbucket.scm.git.event.GitMergeRequestedEvent;
import com.atlassian.bitbucket.user.ApplicationUser;
import com.atlassian.bitbucket.util.FileUtils;
import com.atlassian.event.api.EventPublisher;
import com.atlassian.stash.internal.repository.InternalRepository;
import com.atlassian.stash.internal.scm.git.command.SharedForkCommand;
import com.atlassian.stash.internal.scm.git.merge.GitMergeStrategy;
import com.atlassian.stash.internal.scm.git.merge.MergeStrategyFactory;
import com.atlassian.stash.internal.scm.git.porcelain.GitPorcelain;
import com.atlassian.stash.internal.scm.git.util.ConfigurableGitScmConfig;
import com.google.common.collect.Collections2;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import org.apache.commons.lang3.StringUtils;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;

import javax.annotation.Nonnull;
import javax.annotation.Resource;
import java.io.File;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ScheduledExecutorService;

import static com.atlassian.stash.internal.scm.git.GitFactoriesTestHelper.isBareRepository;
import static java.util.Optional.of;
import static java.util.Optional.ofNullable;
import static org.hamcrest.Matchers.hasItems;
import static org.hamcrest.Matchers.startsWith;
import static org.mockito.Matchers.isA;
import static org.mockito.Mockito.*;

/**
 * Contains {@link DefaultGitExtendedCommandFactory} tests which actually execute {@code git} commands
 * against real {@link Repository} instances and verify actual output.
 * <p>
 * Not all tests will need to execute real commands. Some will be able to mock properties on the
 * {@link DefaultGitExtendedCommandFactory} and still perform useful testing. Such tests should
 * be added to {@link DefaultGitExtendedCommandFactoryMockTest} instead.
 */
public class DefaultGitExtendedCommandFactoryTest extends AbstractConfiguredGitTest {

    @Rule
    public TemporaryFolder folder = new TemporaryFolder();

    @Resource
    private InternalGitAgent agent;
    @Mock
    protected ApplicationUser author;
    @Resource
    private GitCommandBuilderFactory builderFactory;
    @Resource
    private GitCommandFactory commandFactory;
    @Resource
    private ConfigurableGitScmConfig config;
    @Mock
    private EventPublisher eventPublisher;
    @Resource
    private ScheduledExecutorService executorService;
    private DefaultGitExtendedCommandFactory extendedCommandFactory;
    @Resource
    private I18nService i18nService;
    @Resource
    private MergeStrategyFactory mergeStrategyFactory;
    @Repo("classpath:git/merge.zip")
    private Repository merge;
    @Resource
    private GitPorcelain porcelain;
    @Repo
    private Repository repository;
    @Resource
    private GitRepositoryConfig repositoryConfig;

    @Before
    public void setup() {
        when(author.getDisplayName()).thenReturn("John Doe");
        when(author.getEmailAddress()).thenReturn("jdoe@test.com");

        // Creating custom factory instance for this test using a mixture of resources autowired into the test class
        // and mocks, primarily for easier testing of events
        extendedCommandFactory = new DefaultGitExtendedCommandFactory(agent, builderFactory, config,
                eventPublisher, executorService, i18nService, mergeStrategyFactory, porcelain, repositoryConfig);
    }

    @Test
    public void testFork() throws Exception {
        Repository fork = mock(Repository.class);
        when(fork.getHierarchyId()).thenReturn(repository.getHierarchyId());
        when(fork.getId()).thenReturn(10000);
        when(fork.getProject()).thenReturn(repository.getProject());
        when(fork.getSlug()).thenReturn("fork-repo");

        config.setRepositoryDir(fork, folder.getRoot());

        extendedCommandFactory.fork(repository, new ForkCommandParameters.Builder(fork).build()).call();
        assertTrue(isBareRepository(folder.getRoot()));
        assertTrue(FileUtils.construct(folder.getRoot(), InternalGitConstants.SUBPATH_ALTERNATES).isFile());
        assertEquals(agent.resolveHead(repository), agent.resolveHead(fork));
    }

    //Alternates has a hard-coded recursion limit in git, so we apply a soft limit. That is validated here
    @Test(expected = CommandFailedException.class)
    public void testForkWithDeepHierarchy() throws Exception {
        Repository parent = repository;
        Branch head = agent.resolveHead(repository);

        CommitCommandParameters parameters = new CommitCommandParameters.Builder()
                .commitId(head.getId())
                .build();
        for (int i = 0; i <= SharedForkCommand.MAX_DEPTH; ++i) { //<= here, instead of <, to exceed the max by 1
            Repository fork = mock(Repository.class);
            when(fork.getHierarchyId()).thenReturn(repository.getHierarchyId());
            when(fork.getId()).thenReturn(10000 + i);
            when(fork.getOrigin()).thenReturn(parent);
            when(fork.getProject()).thenReturn(repository.getProject());
            when(fork.getSlug()).thenReturn("deep-fork-level-" + i);

            File cloneDir = folder.newFolder();
            config.setRepositoryDir(fork, cloneDir);

            try {
                extendedCommandFactory.fork(parent, new ForkCommandParameters.Builder(fork).build()).call();
                assertTrue(isBareRepository(cloneDir));
                assertTrue(FileUtils.construct(cloneDir, InternalGitConstants.SUBPATH_ALTERNATES).isFile());

                //Just for a little dash of added verification, ensure the heads align. Note that this does _not_ use
                //GitAgent, but instead forks a git process. Since the real goal of this test is to ensure forks don't
                //hit git's internal recursion limits for alternates, the most comprehensive approach for validating
                //our expectations is to run a git process which, given there are no objects in any of the forks, has
                //to traverse the entire alternate hierarchy to the root to resolve the commit.
                Commit commit = commandFactory.commit(fork, parameters).call();
                assertNotNull(commit);
                assertEquals(head.getLatestCommit(), commit.getId());
            } catch (CommandFailedException e) {
                if (i == SharedForkCommand.MAX_DEPTH) {
                    //If we're at 5 deep, an exception _should_ have been thrown. Make sure it's for the right reason
                    assertTrue(e.getMessageKey().startsWith("bitbucket.git.fork.toodeep"));

                    throw e;
                }


                fail("A fork at depth " + i + " could not be created, but should have been");
            }

            //For the next pass through the loop, this fork will become the parent
            parent = fork;
        }
    }

    //When not using alternates, there is no limit to the fork depth
    @Test
    public void testForkWithDeepHierarchyWithoutAlternates() throws Exception {
        runForkWithoutAlternates(() -> {
            Repository parent = repository;
            Branch head = agent.resolveHead(repository);

            CommitCommandParameters parameters = new CommitCommandParameters.Builder()
                    .commitId(head.getId())
                    .build();
            for (int i = 0; i <= SharedForkCommand.MAX_DEPTH; ++i) { //<= here, instead of <, to exceed the max by 1
                Repository fork = mock(Repository.class);
                when(fork.getHierarchyId()).thenReturn(repository.getHierarchyId());
                when(fork.getId()).thenReturn(10050 + i);
                when(fork.getOrigin()).thenReturn(parent);
                when(fork.getProject()).thenReturn(repository.getProject());
                when(fork.getSlug()).thenReturn("deep-fork-na-level-" + i);

                File cloneDir = folder.newFolder();
                config.setRepositoryDir(fork, cloneDir);

                extendedCommandFactory.fork(parent, new ForkCommandParameters.Builder(fork).build()).call();
                assertTrue(isBareRepository(cloneDir));
                assertFalse(FileUtils.construct(cloneDir, InternalGitConstants.SUBPATH_ALTERNATES).isFile());

                //Just for a little dash of added verification, ensure the heads align. Note that this does _not_
                //use GitAgent but instead forks a git process. Since the real goal of this test is to ensure forks
                //without alternates work correctly at great depth, the most comprehensive approach for validating
                //our expectations is to run a git process.
                Commit commit = commandFactory.commit(fork, parameters).call();
                assertNotNull(commit);
                assertEquals(head.getLatestCommit(), commit.getId());

                //For the next pass through the loop, this fork will become the parent
                parent = fork;
            }
        });
    }

    @Test
    public void testForkWithoutAlternates() throws Exception {
        runForkWithoutAlternates(() -> {
            Repository fork = mock(Repository.class);
            when(fork.getHierarchyId()).thenReturn(repository.getHierarchyId());
            when(fork.getId()).thenReturn(10000);
            when(fork.getProject()).thenReturn(repository.getProject());
            when(fork.getSlug()).thenReturn("fork-repo");

            config.setRepositoryDir(fork, folder.getRoot());

            extendedCommandFactory.fork(repository, new ForkCommandParameters.Builder(fork).build()).call();
            assertTrue(isBareRepository(folder.getRoot()));
            assertFalse(FileUtils.construct(folder.getRoot(), InternalGitConstants.SUBPATH_ALTERNATES).isFile());
            assertEquals(agent.resolveHead(repository), agent.resolveHead(fork));
        });
    }

    @Test
    public void testLastModified() throws Exception {
        CollectingLastModifiedCallback callback = spy(new CollectingLastModifiedCallback());

        extendedCommandFactory.lastModified(
                repository,
                new LastModifiedCommandParameters.Builder("0a943a29376f2336b78312d99e65da17048951db")
                        .path("binary")
                        .build(),
                callback)
                .call();

        verify(callback).onStart(isA(LastModifiedContext.class));
        verify(callback, times(3)).onFile(anyString(), isA(Commit.class));
        verify(callback).onEnd(isA(LastModifiedSummary.class));
        verifyNoMoreInteractions(callback);

        assertEquals("0a943a29376f2336b78312d99e65da17048951db", callback.context.getCommitId());
        assertEquals(of("binary"), callback.context.getPath());

        assertEquals(3, callback.files.size());
        assertEquals("2d8897c9ac29ce42c3442cf80ac977057045e7f6", callback.files.get("B.zip").getId());
        assertEquals("e0a3f6d8c804c375f49d7609328111e8dc75a4bc", callback.files.get("C.zip").getId());
        assertEquals("0a943a29376f2336b78312d99e65da17048951db", callback.files.get("D.zip").getId());

        assertEquals(ofNullable(callback.files.get("D.zip")), callback.summary.getLatestCommit());
        assertSame(CommandResult.SUCCEEDED, callback.summary.getResult());
    }

    @Test(expected = NoSuchCommitException.class)
    public void testLastModifiedWithNonexistentCommit() throws Exception {
        LastModifiedCallback callback = mock(LastModifiedCallback.class);

        try {
            extendedCommandFactory.lastModified(
                    repository,
                    new LastModifiedCommandParameters.Builder("nonexistent-commit").build(),
                    callback).call();
        } finally {
            verifyZeroInteractions(callback);
        }
    }

    @Test(expected = NoSuchPathException.class)
    public void testLastModifiedWithNonexistentPath() throws Exception {
        LastModifiedCallback callback = mock(LastModifiedCallback.class);

        try {
            extendedCommandFactory.lastModified(
                    repository,
                    new LastModifiedCommandParameters.Builder("master")
                            .path("nonexistent-path")
                            .build(),
                    callback).call();
        } finally {
            verifyZeroInteractions(callback);
        }
    }

    @Test
    public void testLastModifiedWithNoFiles() throws Exception {
        CollectingLastModifiedCallback callback = spy(new CollectingLastModifiedCallback());

        //There are no files in the root directory of the default repository; they're all in subdirectories
        extendedCommandFactory.lastModified(
                repository,
                new LastModifiedCommandParameters.Builder("master").build(),
                callback)
                .call();

        verify(callback).onStart(isA(LastModifiedContext.class));
        verify(callback, never()).onFile(anyString(), any(Commit.class));
        verify(callback).onEnd(isA(LastModifiedSummary.class));

        assertEquals("master", callback.context.getCommitId());
        assertFalse(callback.context.getPath().isPresent());

        assertEquals(of("0a943a29376f2336b78312d99e65da17048951db"),
                callback.summary.getLatestCommit().map(Commit::getId));
        assertSame(CommandResult.SUCCEEDED, callback.summary.getResult());
    }

    @Test
    public void testLastModifiedWithSingleFile() throws Exception {
        CollectingLastModifiedCallback callback = spy(new CollectingLastModifiedCallback());

        extendedCommandFactory.lastModified(
                repository,
                new LastModifiedCommandParameters.Builder("HEAD")
                        .path("mv_file")
                        .build(),
                callback)
                .call();

        verify(callback).onStart(isA(LastModifiedContext.class));
        verify(callback).onFile(eq("moved_file.txt"), isA(Commit.class));
        verify(callback).onEnd(isA(LastModifiedSummary.class));

        assertEquals("HEAD", callback.context.getCommitId());
        assertEquals(of("mv_file"), callback.context.getPath());

        assertEquals(1, callback.files.size());
        assertEquals("57150c54c38d6570b2fd5e6d6cfc19476de44e84", callback.files.get("moved_file.txt").getId());

        assertEquals(ofNullable(callback.files.get("moved_file.txt")), callback.summary.getLatestCommit());
        assertSame(CommandResult.SUCCEEDED, callback.summary.getResult());
    }

    /**
     * Merge scenarios are covered in {@link com.atlassian.stash.internal.scm.git.command.merge.MergeCommandTest}. This test is
     * included to verify that fetching the resulting merge back to the bare clone works as expected. Testing with the
     * fetch is only performed here to reduce the number of times the repository must be re-unpacked (since the fetch
     * dirties the repository by updating the branch).
     */
    @DirtiesRepo("merge")
    @Test
    public void testMerge() {
        Branch target = runMergeWithStrategy("branch_that_has_modified_file_no_merge_conflict_src",
                "1a572938f1ebffaaca33c86a57deb2efcddabb90", "branch_that_has_modified_file_no_merge_conflict_trgt",
                "e15bebda897605b1b8ab324e3c8339e4d1814c59", GitMergeStrategy.NO_FF);

        CommitCommandParameters commitParameters = new CommitCommandParameters.Builder()
                .commitId(target.getLatestCommit())
                .build();
        Commit commit = commandFactory.commit(merge, commitParameters).call();
        assertNotNull("The resolved merge commit could not be loaded", commit);
        //Starts with, not equals, because git merge --log adds commit-related details to the provided message
        assertTrue(StringUtils.startsWith(commit.getMessage(), "This merge should succeed and also be pushed"));
        assertAuthor(author, commit.getAuthor());

        List<MinimalCommit> parents = ImmutableList.copyOf(commit.getParents());
        assertTrue(parents.size() == 2);
        assertEquals("e15bebda897605b1b8ab324e3c8339e4d1814c59", parents.get(0).getId());
        assertEquals("1a572938f1ebffaaca33c86a57deb2efcddabb90", parents.get(1).getId());
    }

    /**
     * Since cross-repository merges now have a different codepath than single repository merges, it's important to
     * test both types.
     * <p>
     * This cross-repository merge represents something the system would never allow at runtime, merging between two
     * different hierarchies. However, this represents a very good punishment case for testing the merge because it
     * has to fetch across <i>all</i> of the objects from the source repository.
     */
    @DirtiesRepo("merge")
    @Test
    public void testMergeBetweenRepositories() {
        Ref start = agent.resolveBranch(merge, "branch_that_has_modified_file_no_merge_conflict_trgt");
        assertNotNull("branch_that_has_modified_file_no_merge_conflict_trgt could not be resolved (1)", start);
        assertEquals("e15bebda897605b1b8ab324e3c8339e4d1814c59", start.getLatestCommit());

        MergeCommandParameters mergeParameters = new MergeCommandParameters.Builder()
                .author(author)
                .fromBranch("refs/heads/master")
                .fromCommitId("0a943a29376f2336b78312d99e65da17048951db")
                .fromRepository(new InternalRepository.Builder((InternalRepository) repository)
                        .hierarchyId(merge.getHierarchyId()) //Spoof the hierarchyId so we can perform the merge
                        .build())
                .toBranch("refs/heads/branch_that_has_modified_file_no_merge_conflict_trgt")
                .message("Testing simple merge-and-push operation")
                .build();
        extendedCommandFactory.merge(merge, mergeParameters).call();

        verify(eventPublisher).publish(isA(GitMergeRequestedEvent.class));

        Ref end = agent.resolveBranch(merge, "branch_that_has_modified_file_no_merge_conflict_trgt");
        assertNotNull("branch_that_has_modified_file_no_merge_conflict_trgt could not be resolved (2)", end);
        assertFalse("A new commit should have been pushed to branch_that_has_modified_file_no_merge_conflict_trgt",
                start.getLatestCommit().equals(end.getLatestCommit()));

        CommitCommandParameters commitParameters = new CommitCommandParameters.Builder()
                .commitId(end.getLatestCommit())
                .build();
        Commit commit = commandFactory.commit(merge, commitParameters).call();
        assertNotNull("The resolved merge commit could not be loaded", commit);
        //Starts with, not equals, because git merge --log adds commit-related details to the provided message
        assertTrue(StringUtils.startsWith(commit.getMessage(), "Testing simple merge-and-push operation"));
        assertAuthor(author, commit.getAuthor());

        List<MinimalCommit> parents = ImmutableList.copyOf(commit.getParents());
        assertTrue(parents.size() == 2);
        assertEquals("e15bebda897605b1b8ab324e3c8339e4d1814c59", parents.get(0).getId());
        assertEquals("0a943a29376f2336b78312d99e65da17048951db", parents.get(1).getId());
    }

    @DirtiesRepo("merge") // only ref update is prevented, the merge commit is still fetched to the 'merge' repo
    @Test(expected = MergeCanceledException.class)
    public void testMergeThrowsWhenCanceled() {
        doAnswer(invocation -> {
            GitMergeRequestedEvent event = (GitMergeRequestedEvent) invocation.getArguments()[0];
            event.cancel(new KeyedMessage("bitbucket.scm.git.test.mergecanceled",
                    "Testing merge cancellation to ensure an exception is thrown.",
                    "Testing merge cancellation to ensure an exception is thrown."));

            return null;
        }).when(eventPublisher).publish(isA(GitMergeRequestedEvent.class));

        Ref start = agent.resolveBranch(merge, "branch_that_has_modified_file_no_merge_conflict_trgt");
        assertNotNull("branch_that_has_modified_file_no_merge_conflict_trgt could not be resolved (1)", start);
        assertEquals("e15bebda897605b1b8ab324e3c8339e4d1814c59", start.getLatestCommit());

        MergeCommandParameters mergeParameters = new MergeCommandParameters.Builder()
                .author(author)
                .fromBranch("branch_that_has_modified_file_no_merge_conflict_src")
                .toBranch("branch_that_has_modified_file_no_merge_conflict_trgt")
                .message("Testing merge cancellation - should be fetched")
                .build();
        try {
            extendedCommandFactory.merge(merge, mergeParameters).call();
        } finally {
            //Verify that the canceled merge did not actually update the target branch
            Branch target = agent.resolveBranch(merge, "branch_that_has_modified_file_no_merge_conflict_trgt");
            assertNotNull(target);
            assertEquals("e15bebda897605b1b8ab324e3c8339e4d1814c59", target.getLatestCommit());

            //Verify that the merge was fetched back into the repository and that it can be retrieved. Plugins which
            //integrate with this event will likely need to be able to retrieve the merge to decide whether or not to
            //cancel merging the pull request
            ArgumentCaptor<GitMergeRequestedEvent> eventCaptor = ArgumentCaptor.forClass(GitMergeRequestedEvent.class);
            verify(eventPublisher).publish(eventCaptor.capture());

            GitMergeRequestedEvent event = eventCaptor.getValue();
            Commit mergeCommit = commandFactory.commit(merge,
                    new CommitCommandParameters.Builder().commitId(event.getMergeHash()).build()).call();
            assertNotNull(mergeCommit);
            assertThat(mergeCommit.getMessage(), startsWith("Testing merge cancellation - should be fetched"));
            assertAuthor(author, mergeCommit.getAuthor());

            Set<String> parents = ImmutableSet.copyOf(Collections2.transform(mergeCommit.getParents(), MinimalCommit::getId));
            assertEquals(2, parents.size());
            assertThat(parents, hasItems(
                    "e15bebda897605b1b8ab324e3c8339e4d1814c59",
                    "1a572938f1ebffaaca33c86a57deb2efcddabb90"
            ));
        }
    }

    @DirtiesRepo("merge")
    @Test
    public void testMergeWithFastForwardOnlyStrategy() {
        Branch target = runMergeWithStrategy("branch_that_has_same_text_file_modified_on_both_clean_merge_src",
                "306d584aa574fa74901d3e288b2931b2c4f0e927", "master",
                "017caf31eca7c46eb3d1800fcac431cfa7147a01", GitMergeStrategy.FF_ONLY);

        //The source commit is fast-forward from the target, so it should "merge" without creating a merge commit
        CommitCommandParameters parameters = new CommitCommandParameters.Builder()
                .commitId(target.getLatestCommit())
                .build();
        Commit commit = commandFactory.commit(merge, parameters).call();
        assertNotNull(commit);
        assertEquals("306d584aa574fa74901d3e288b2931b2c4f0e927", commit.getId());
        verify(eventPublisher).publish(isA(GitMergeRequestedEvent.class));
    }

    @Test(expected = MergeException.class)
    public void testMergeWithFastForwardOnlyStrategyThrowsInsteadOfMerging() {
        //git merge --ff-only should exit non-zero on non-fast-forward merges, triggering a GitMergeException
        runMergeWithStrategy("branch_that_has_same_text_file_modified_on_both_clean_merge_src",
                "306d584aa574fa74901d3e288b2931b2c4f0e927",
                "branch_that_has_same_text_file_modified_on_both_clean_merge_trgt",
                "7fa77c6e34c6c53de0bb880fc2b0b7924a601cd2", GitMergeStrategy.FF_ONLY);
    }

    @DirtiesRepo("merge")
    @Test
    public void testMergeWithFastForwardStrategy() {
        Branch target = runMergeWithStrategy("branch_that_has_same_text_file_modified_on_both_clean_merge_src",
                "306d584aa574fa74901d3e288b2931b2c4f0e927", "master",
                "017caf31eca7c46eb3d1800fcac431cfa7147a01", GitMergeStrategy.FF);

        //The source commit is fast-forward from the target, so it should "merge" without creating a merge commit
        CommitCommandParameters parameters = new CommitCommandParameters.Builder()
                .commitId(target.getLatestCommit())
                .build();
        Commit commit = commandFactory.commit(merge, parameters).call();
        assertNotNull(commit);
        assertEquals("306d584aa574fa74901d3e288b2931b2c4f0e927", commit.getId());

        verify(eventPublisher).publish(isA(GitMergeRequestedEvent.class));
    }

    @DirtiesRepo("merge")
    @Test
    public void testMergeWithFastForwardStrategyMergesIfNecessary() {
        Branch target = runMergeWithStrategy("branch_that_has_same_text_file_modified_on_both_clean_merge_src",
                "306d584aa574fa74901d3e288b2931b2c4f0e927",
                "branch_that_has_same_text_file_modified_on_both_clean_merge_trgt",
                "7fa77c6e34c6c53de0bb880fc2b0b7924a601cd2", GitMergeStrategy.FF);
        assertFalse("306d584aa574fa74901d3e288b2931b2c4f0e927".equals(target.getLatestCommit()));
        assertFalse("7fa77c6e34c6c53de0bb880fc2b0b7924a601cd2".equals(target.getLatestCommit()));

        //The source and target commits are not fast-forward, so a merge commit should be created
        CommitCommandParameters parameters = new CommitCommandParameters.Builder()
                .commitId(target.getLatestCommit())
                .build();
        Commit commit = commandFactory.commit(merge, parameters).call();
        assertNotNull(commit);

        List<MinimalCommit> parents = ImmutableList.copyOf(commit.getParents());
        assertEquals(2, parents.size());
        assertEquals("7fa77c6e34c6c53de0bb880fc2b0b7924a601cd2", parents.get(0).getId());
        assertEquals("306d584aa574fa74901d3e288b2931b2c4f0e927", parents.get(1).getId());

        verify(eventPublisher).publish(isA(GitMergeRequestedEvent.class));
    }

    @DirtiesRepo("merge")
    @Test
    public void testMergeWithSquashFastForwardOnlyStrategy() {
        Branch target = runMergeWithStrategy("branch_that_has_same_text_file_modified_on_both_clean_merge_src",
                "306d584aa574fa74901d3e288b2931b2c4f0e927", "master",
                "017caf31eca7c46eb3d1800fcac431cfa7147a01", GitMergeStrategy.SQUASH_FF_ONLY);

        CommitCommandParameters parameters = new CommitCommandParameters.Builder()
                .commitId(target.getLatestCommit())
                .build();
        Commit commit = commandFactory.commit(merge, parameters).call();
        assertNotNull(commit);
        assertAuthor(author, commit.getAuthor());
        assertEquals(1, commit.getParents().size());
        assertEquals("017caf31eca7c46eb3d1800fcac431cfa7147a01",
                Iterables.getOnlyElement(commit.getParents()).getId());
        //Assert that even though --ff-only was used the squash merge still created a new commit
        assertNotEquals("306d584aa574fa74901d3e288b2931b2c4f0e927", commit.getId());
        //Assert that the passed-in message was included in the commit message
        assertTrue(commit.getMessage().startsWith("This merge should succeed and also be pushed"));
        //Assert that the SQUASH_MSG, which details the squashed commits, was included in the commit message
        assertTrue(commit.getMessage().contains("306d584aa574fa74901d3e288b2931b2c4f0e927"));

        verify(eventPublisher).publish(isA(GitMergeRequestedEvent.class));
    }

    @Test(expected = MergeException.class)
    public void testMergeWithSquashFastForwardOnlyStrategyThrowsInsteadOfMerging() {
        //git merge --ff-only should exit non-zero on non-fast-forward merges, triggering a GitMergeException
        runMergeWithStrategy("branch_that_has_same_text_file_modified_on_both_clean_merge_src",
                "306d584aa574fa74901d3e288b2931b2c4f0e927",
                "branch_that_has_same_text_file_modified_on_both_clean_merge_trgt",
                "7fa77c6e34c6c53de0bb880fc2b0b7924a601cd2", GitMergeStrategy.SQUASH_FF_ONLY);
    }

    @DirtiesRepo("merge")
    @Test
    public void testMergeWithSquashStrategy() {
        Branch target = runMergeWithStrategy("branch_that_has_same_text_file_modified_on_both_clean_merge_src",
                "306d584aa574fa74901d3e288b2931b2c4f0e927",
                "branch_that_has_same_text_file_modified_on_both_clean_merge_trgt",
                "7fa77c6e34c6c53de0bb880fc2b0b7924a601cd2", GitMergeStrategy.SQUASH);

        CommitCommandParameters parameters = new CommitCommandParameters.Builder()
                .commitId(target.getLatestCommit())
                .build();
        Commit commit = commandFactory.commit(merge, parameters).call();
        assertNotNull(commit);
        assertAuthor(author, commit.getAuthor());
        assertEquals(1, commit.getParents().size());
        assertEquals("7fa77c6e34c6c53de0bb880fc2b0b7924a601cd2",
                Iterables.getOnlyElement(commit.getParents()).getId());
        //Assert that the passed-in message was included in the commit message
        assertTrue(commit.getMessage().startsWith("This merge should succeed and also be pushed"));
        //Assert that the SQUASH_MSG, which details the squashed commits, was included in the commit message
        assertTrue(commit.getMessage().contains("306d584aa574fa74901d3e288b2931b2c4f0e927"));

        verify(eventPublisher).publish(isA(GitMergeRequestedEvent.class));
    }

    //Note: Squash merges use a different MergeStrategy, so this test is necessary to assure the proper error handling
    //      is still performed by the output handler being used.
    @Test(expected = MergeException.class)
    public void testMergeWithSquashStrategyThrowsOnConflicts() {
        runMergeWithStrategy("branch_that_has_same_text_file_modified_src",
                "5bb214e6dd15643dabfe4b6b0739becfb1e59d9f",
                "branch_that_has_same_text_file_modified_trgt",
                "c1654113b22f65f988f5a570d8cf32c7288ee7d2", GitMergeStrategy.SQUASH);
    }

    private Branch runMergeWithStrategy(String fromBranch, String fromHash, String toBranch, String toHash,
                                        GitMergeStrategy strategy) {
        MergeCommandParameters parameters = new MergeCommandParameters.Builder()
                .author(author)
                .fromBranch(fromBranch)
                .fromCommitId(fromHash)
                .message("This merge should succeed and also be pushed")
                .strategyId(strategy.getId())
                .toBranch(toBranch)
                .build();
        Branch target = extendedCommandFactory.merge(merge, parameters).call();
        assertNotNull(target);
        assertFalse(toHash.equals(target.getLatestCommit()));

        return target;
    }

    private <E extends Exception> void runForkWithoutAlternates(ConfiguredTest<E> test) throws E {
        runWithConfig(DefaultGitScmConfig.PROP_USE_ALTERNATES, "false", test);
    }

    private static class CollectingLastModifiedCallback implements LastModifiedCallback {

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

        private LastModifiedContext context;
        private LastModifiedSummary summary;

        @Override
        public void onEnd(@Nonnull LastModifiedSummary summary) {
            this.summary = summary;
        }

        @Override
        public boolean onFile(@Nonnull String file, @Nonnull Commit commit) {
            assertNull("File [" + file + "] was reported multiple times", files.put(file, commit));

            return true;
        }

        @Override
        public void onStart(@Nonnull LastModifiedContext context) {
            this.context = context;
        }
    }
}
