package com.atlassian.bitbucket.mesh.git;

import com.atlassian.bitbucket.mesh.AbstractWiredExecutionContextTest;
import com.atlassian.bitbucket.mesh.git.binary.GitBinary;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.builder.repack.GitRepackAll;
import com.atlassian.bitbucket.mesh.git.exception.*;
import com.atlassian.bitbucket.mesh.git.ref.Branch;
import com.atlassian.bitbucket.mesh.git.ref.Ref;
import com.atlassian.bitbucket.mesh.git.ref.RefType;
import com.atlassian.bitbucket.mesh.io.MoreFiles;
import com.atlassian.bitbucket.mesh.io.UnderLock;
import com.atlassian.bitbucket.mesh.junit.TemporaryDirectory;
import com.atlassian.bitbucket.mesh.process.handler.LoggingStdioHandler;
import com.atlassian.bitbucket.mesh.process.handler.NullStdioHandler;
import com.atlassian.bitbucket.mesh.process.handler.StringStdioHandler;
import com.atlassian.bitbucket.mesh.repository.DefaultPathRepository;
import com.atlassian.bitbucket.mesh.repository.PathRepository;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.atlassian.bitbucket.mesh.test.annotation.DirtiesRepo;
import com.atlassian.bitbucket.mesh.test.annotation.Repo;
import com.atlassian.bitbucket.mesh.util.Version;
import com.google.common.collect.ImmutableList;
import org.junit.Rule;
import org.junit.Test;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Map;
import java.util.function.Consumer;

import static com.atlassian.bitbucket.mesh.git.GitConstants.PACKED_REFS;
import static com.atlassian.bitbucket.mesh.git.GitRefPattern.HEADS;
import static com.atlassian.bitbucket.mesh.git.GitRefPattern.TAGS;
import static org.hamcrest.Matchers.*;
import static org.junit.Assume.assumeThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

public class RawGitAgentTest extends AbstractWiredExecutionContextTest {

    private static final String PATH_TRAVERSAL_REF = "refs/heads/../../../../../../../../../../../../etc/private.txt";

    @Rule
    public final TemporaryDirectory tempDir = new TemporaryDirectory();

    @Autowired
    private RawGitAgent agent;
    @Autowired
    private GitBinary binary;
    @Autowired
    private GitProcessBuilderFactory builderFactory;
    @Repo("classpath:git/merge.zip")
    private Repository merge;
    @Repo
    private Repository repository;

    /**
     * Git 1.8.4.3 has a bug which causes repositories with more than a handful of symbolic refs to cause protocol
     * errors which break the ref advertisement. This test checks for that bug by creating 20 symbolic refs and then
     * running ls-remote. If the bug is present, this test throw an exception.
     */
    @Test
    @DirtiesRepo
    public void testCreateAndCloneSymbolicRefs() {
        Branch master = agent.resolveBranch(repository, "master");
        assertNotNull(master);

        for (int i = 0; i < 20; ++i) {
            agent.createSymbolicRef(repository, "refs/heads/symbolic-refs/" + (i + 1), master.getId());
        }

        //If the symbolic refs bug is present, ls-remote will exit 128 and a CommandFailedException will be thrown
        String refs = builderFactory.builder(repository).command("ls-remote")
                .argument(".")
                .build(new StringStdioHandler())
                .run();
        assertTrue(refs.contains(master.getLatestCommit() + "\tHEAD\n"));
        assertTrue(refs.contains(master.getLatestCommit() + "\trefs/heads/master\n"));
        for (int i = 0; i < 20; ++i) {
            //Verify the presence of all of our symbolic refs in the ref advertisement
            assertTrue(refs.contains(master.getLatestCommit() + "\trefs/heads/symbolic-refs/" + (i + 1) + "\n"));
        }
    }

    @Test
    @DirtiesRepo
    public void testCreateRef() {
        Branch branch = agent.resolveBranch(repository, "master");
        assertNotNull(branch);

        String newRef = branch.getId() + System.currentTimeMillis();
        agent.createRef(repository, newRef, branch.getLatestCommit());

        assertEquals(branch.getLatestCommit(),
                builderFactory.builder(repository).command("rev-list")
                        .argument("-1").argument(newRef).build(new StringStdioHandler()).run());
    }

    @DirtiesRepo
    @Test
    public void testCreateRefLocksRef() throws IOException {
        Branch branch = agent.resolveBranch(repository, "master");
        assertNotNull(branch);

        String newRef = branch.getId() + System.currentTimeMillis();
        Path path = repository.getPath().resolve(newRef);
        try (UnderLock.LockedFile ignored = UnderLock.forFile(path).lock()) {
            assertThrows(CreateRefFailedException.class,
                    () -> agent.createRef(repository, newRef, branch.getLatestCommit()));
        }
    }

    @Test(expected = IllegalArgumentException.class)
    public void testCreateRefPathTraversalFails() {
        Branch branch = agent.resolveBranch(repository, "master");
        assertNotNull(branch);

        agent.createRef(repository, PATH_TRAVERSAL_REF, branch.getLatestCommit());
    }

    @Test
    @DirtiesRepo
    public void testCreateSymbolicRef() {
        Branch branch = agent.resolveBranch(repository, "basic_branching");
        assertNotNull(branch);

        agent.createSymbolicRef(repository, "TEST_HEAD", branch.getId());

        assertEquals(branch.getLatestCommit(),
                builderFactory.builder(repository).command("rev-list")
                        .argument("-1").argument("TEST_HEAD").build(new StringStdioHandler()).run());
    }

    @Test(expected = IllegalArgumentException.class)
    public void testCreateSymbolicRefPathTraversalFails() {
        Branch branch = agent.resolveBranch(repository, "basic_branching");
        assertNotNull(branch);

        agent.createSymbolicRef(repository, PATH_TRAVERSAL_REF, branch.getId());
    }

    @DirtiesRepo
    @Test
    public void testDeleteLooseRef() {
        assertTrue(agent.deleteLooseRef(repository, "refs/heads/basic_branching"));

        assertNull(agent.resolveBranch(repository, "basic_branching"));
    }

    @DirtiesRepo
    @Test
    public void testDeleteLooseRefLocksRefBeforeDelete() throws IOException {
        Path ref = repository.getPath().resolve("refs/heads/basic_branching");
        //Lock the ref before attempting the delete. This should trigger a DeleteRefFailedException
        try (UnderLock.LockedFile ignored = UnderLock.forFile(ref).lock()) {
            assertThrows(DeleteRefFailedException.class,
                    () -> agent.deleteLooseRef(repository, "refs/heads/basic_branching"));
        }
    }

    @Test
    public void testDeleteLooseRefThatDoesNotExist() {
        //Deleting a ref that does not exist shouldn't fail
        assertFalse(agent.deleteLooseRef(repository, "refs/heads/no-such-ref"));
    }

    @Test
    public void testDeleteLooseRefPathTraversalFails() {
        assertThrows(IllegalArgumentException.class, () -> agent.deleteLooseRef(repository, PATH_TRAVERSAL_REF));
    }

    @Test
    @DirtiesRepo
    public void testEnableReflog() {
        // create and enable reflog for ref
        Branch head = agent.resolveHead(repository);
        String ref = "refs/heads/testreflog";
        agent.createRef(repository, ref, head.getLatestCommit());
        agent.enableReflog(repository, ref);

        testRefUpdateWithLogging(ref);
    }

    @Test
    @DirtiesRepo
    public void testEnableReflogForNonExistentRef() {
        Branch branch = agent.resolveBranch(repository, "master");
        assertNotNull(branch);

        String ref = branch.getId() + "nonexistent";
        agent.enableReflog(repository, ref);

        assertNull(agent.resolveBranch(repository, ref));
    }

    @Test(expected = IllegalArgumentException.class)
    public void testEnableReflogPathTraversalFails() {
        agent.enableReflog(repository, PATH_TRAVERSAL_REF);
    }

    @Test
    @DirtiesRepo
    public void testEnableReflogTwice() {
        Branch head = agent.resolveHead(repository);
        assertNotNull(head);

        String ref = "refs/heads/testreflog";
        agent.createRef(repository, ref, head.getLatestCommit());
        agent.enableReflog(repository, ref);

        // update ref
        builderFactory.builder(repository).command("update-ref")
                .argument("-m").argument("reset to HEAD parent")
                .argument(ref).argument("HEAD^").build(new NullStdioHandler<>()).run();

        // 're-enable' reflog
        agent.enableReflog(repository, ref);

        // check the reflog was not cleared
        Branch branch = agent.resolveBranch(repository, ref, true);
        assertNotNull(branch);
        assertReflog(ref, branch.getLatestCommit(), "reset to HEAD parent");
    }

    @Test
    public void testGetHead() {
        //The HEAD value should be returned unmodified from how it is present in the file. The file only contains
        //absolute refs, which greatly simplifies the logic for resolving HEAD.
        assertEquals("refs/heads/master", agent.getHead(repository));
    }

    @Test(expected = DetachedHeadException.class)
    @DirtiesRepo
    public void testGetDetachedHead() {
        Branch branch = agent.resolveBranch(repository, "master");
        assertNotNull(branch);

        // create a detached head
        builderFactory.builder(repository).command("update-ref")
                .argument(GitAgent.HEAD).argument(branch.getLatestCommit()).argument("--no-deref").build(new NullStdioHandler<>()).run();
        agent.getHead(repository);
    }

    @Test
    public void testHasObjectsEmptyRepository() throws IOException {
        Path repositoryDir = tempDir.newFolder("empty");

        builderFactory.builder().init()
                .bare(true)
                .directory(repositoryDir)
                .build()
                .run();

        Repository repository = mock(Repository.class);
        when(repository.getPath()).thenReturn(repositoryDir);
        assertFalse(agent.hasObjects(repository));
    }

    @Test
    public void testHasObjectsNonExistentRepository() {
        Repository repository = mock(Repository.class);
        when(repository.getPath()).thenReturn(tempDir.getRoot().resolve("does-not-exist"));
        assertFalse(agent.hasObjects(repository));
    }

    @Test
    public void testHasObjectsOnlyLoose() {
        assertTrue(agent.hasObjects(repository));
    }

    @DirtiesRepo("repository")
    @Test
    public void testHasObjectsOnlyPack() {
        builderFactory.builder(repository).repack()
                .all(GitRepackAll.REACHABLE)
                .prune(true)
                .build(new LoggingStdioHandler())
                .run();

        assertTrue(agent.hasObjects(repository));
    }

    @Test
    public void testHasObjectsSharedFork() throws IOException {
        Path repositoryDir = tempDir.newFolder("empty");

        builderFactory.builder().clone()
                .bare()
                .directory(repositoryDir)
                .origin(repository)
                .shared(true)
                .build()
                .run();

        Repository fork = mock(Repository.class);
        when(fork.getPath()).thenReturn(repositoryDir);
        assertFalse(agent.hasObjects(fork));
    }

    /**
     * Tests whether {@link RawGitAgent#isEmpty(Repository)} works on repositories that have no loose or packed refs,
     * but do have a stale lock from a failed (for whatever reason) attempt to create a loose ref.
     */
    @Test
    public void testIsEmptyIgnoresLocks() throws IOException {
        Path repositoryDir = tempDir.newFolder("empty");

        builderFactory.builder().init()
                .bare(true)
                .directory(repositoryDir)
                .build()
                .run();

        Path lock = MoreFiles.resolve(repositoryDir, "refs", "heads", "master.lock");
        MoreFiles.touch(lock); //Just needs to exist

        PathRepository repository = new DefaultPathRepository(repositoryDir);
        assertTrue("Locks should not count as refs", agent.isEmpty(repository));
    }

    /**
     * Tests whether {@link RawGitAgent#isEmpty(Repository)} works on repositories with no packed refs, only loose refs.
     */
    @Test
    public void testIsEmptyWithLooseRepository() {
        assertFalse("The default repository has been packed, invalidating this test",
                Files.exists(repository.getPath().resolve(PACKED_REFS)));

        assertFalse(agent.isEmpty(repository));
    }

    /**
     * Tests whether {@link RawGitAgent#isEmpty(Repository)} works on repositories that do not exist on disk.
     */
    @Test
    public void testIsEmptyWithNonexistentRepositoryDir() {
        PathRepository nonexistent = new DefaultPathRepository(tempDir.getRoot().resolve("nonexistent"));

        assertTrue(agent.isEmpty(nonexistent));
    }

    /**
     * Tests whether {@link RawGitAgent#isEmpty(Repository)} works on repositories with no loose refs, only packed refs.
     */
    @DirtiesRepo
    @Test
    public void testIsEmptyWithPackedRepository() {
        //Run garbage collection on the repository. This will pack all loose refs, ensuring there are no loose refs.
        builderFactory.builder(repository).gc()
                .build()
                .run();

        assertFalse(agent.isEmpty(repository));
    }

    @Test
    @DirtiesRepo
    public void testNoAccessToFilesOutsideRepositoryGit2_38AndLater() {
        // As of Git 2.38 Git rejects the symbolic ref that could potentially be used to get access to files outside
        // the repository in the manner this test used previously. See https://github.com/git/git/commit/04ede97211c
        assumeThat(binary.getVersion(), greaterThanOrEqualTo(new Version(2, 38)));

        CommandFailedException e = assertThrows(CommandFailedException.class, () -> builderFactory.builder(repository)
                .symbolicRef()
                .set("refs/heads/naughty", "../protectedfile")
                .build()
                .run());

        assertEquals("fatal: Refusing to set 'refs/heads/naughty' to invalid ref '../protectedfile'", e.getStderr());
        assertNotEquals(0, e.getExitCode());
    }

    @Test
    @DirtiesRepo
    public void testNoAccessToFilesOutsideRepositoryPreGit2_38() throws IOException {
        assumeThat(binary.getVersion(), lessThan(new Version(2, 38)));

        // Create a ref which contains a value which is not a real hash
        String invalidRef = "refs/heads/notreallyahash";
        byte[] invalidRefValue = "2262e1d6a34fcda869dcab38e4d60fb6100383".getBytes();
        Path repositoryDir = repository.getPath();
        Path invalidRefPath = repositoryDir.resolve(invalidRef);
        Files.write(invalidRefPath, invalidRefValue, StandardOpenOption.CREATE);

        // Create a test file outside of the repo path
        String notInRepoFile = "../protectedfile";
        Path notInRepository = repositoryDir.resolve(notInRepoFile);
        Files.write(notInRepository, "YOU CAN'T TOUCH THIS".getBytes(), StandardOpenOption.CREATE);
        MoreFiles.deleteOnExit(notInRepository);

        String masterRef = "refs/heads/master";
        String protectedFileRef = "refs/heads/naughty";

        builderFactory.builder(repository).symbolicRef().set(protectedFileRef, notInRepoFile).build().run();

        Consumer<Boolean> runTest = ignored -> {
            Map<String, Ref> resolved = agent.shallowResolveRefs(repository,
                    ImmutableList.of(notInRepoFile, masterRef, protectedFileRef, invalidRef, PATH_TRAVERSAL_REF), true);
            assertThat(resolved.keySet(), containsInAnyOrder(masterRef, protectedFileRef, invalidRef));
        };

        // first test with loose refs - this should return Ref instances that do not have their commitId resolved
        runTest.accept(null);

        // now pack the refs and re-run the test - this should return Tag instances
        builderFactory.builder(repository).packRefs()
                .all(true)
                .build(new LoggingStdioHandler(LoggerFactory.getLogger(RawGitAgent.class)))
                .run();

        runTest.accept(null);
    }

    @Test
    @DirtiesRepo
    public void testOverwriteExistingRef() {
        Branch branch1 = agent.resolveBranch(repository, "master");
        assertNotNull(branch1);

        Branch branch2 = agent.resolveBranch(repository, "basic_branching");
        assertNotNull(branch2);

        agent.createRef(repository, branch1.getId(), branch2.getLatestCommit());

        Branch resolved = agent.resolveBranch(repository, branch1.getId());
        assertNotNull(resolved);
        assertEquals(branch2.getLatestCommit(), resolved.getLatestCommit());
    }

    @Test
    public void testResolveBranch() {
        String branchName = "basic_branching";
        Branch branch = agent.resolveBranch(repository, branchName);
        assertBranch(HEADS.qualify(branchName), "d6edcbf924697ab811a867421dab60d954ccad99", branch);

        branchName = "branch_mod_merge";
        branch = agent.resolveBranch(repository, branchName);
        assertBranch(HEADS.qualify(branchName), "6053a1eaa1c009dd11092d09a72f3c41af1b59ad", branch);

        branchName = "master";
        branch = agent.resolveBranch(repository, branchName);
        assertBranch(HEADS.qualify(branchName), "0a943a29376f2336b78312d99e65da17048951db", true, branch);

        branchName = "out_of_order_branch";
        branch = agent.resolveBranch(repository, branchName);
        assertBranch(HEADS.qualify("out_of_order_branch"), "2e10dd2d1d5eea9291b296e78312e8a703964a95", branch);

        assertNull(agent.resolveBranch(repository, "nonexistent_branch"));
    }

    @Test(expected = InvalidRefNameException.class)
    public void testResolveBranchPathTraversalFails() {
        agent.resolveBranch(repository, PATH_TRAVERSAL_REF);
    }

    @Test
    public void testResolveHead() {
        Branch head = agent.resolveHead(repository);
        assertBranch(HEADS.qualify("master"), "0a943a29376f2336b78312d99e65da17048951db", true, head);
    }

    @DirtiesRepo
    @Test
    public void testResolveHeadThatDoesNotExist() {
        Path repoDir = repository.getPath();

        agent.setHead(repoDir, "refs/heads/doesnotexist");
        try {
            agent.resolveHead(repository);
            fail("NoDefaultBranchException expected");
        } catch (NoDefaultBranchException e) {
            assertEquals("refs/heads/doesnotexist", e.getBranchId());
        }
    }


    //Nothing in the default repository is packed, so everything parsed here is loose only
    @Test
    public void testRevParseLoose() {
        Path repositoryDir = repository.getPath();
        //revParse should recursively peel
        assertEquals("0a943a29376f2336b78312d99e65da17048951db", agent.revParse(repositoryDir, "HEAD"));
        assertEquals("0a943a29376f2336b78312d99e65da17048951db", agent.revParse(repositoryDir, "refs/heads/master"));
        //Unresolvable values should be returned as-is
        assertEquals("foo", agent.revParse(repositoryDir, "foo"));
        assertEquals("0a943a29376f2336b78312d99e65da17048951db",
                agent.revParse(repositoryDir, "0a943a29376f2336b78312d99e65da17048951db"));
    }

    //Everything in the merge repository is packed (aside from HEAD, which is never packed)
    @Test
    public void testRevParsePacked() {
        Path repositoryDir = merge.getPath();
        //This first resolves "HEAD" loose, then resolves refs/heads/master packed
        assertEquals("017caf31eca7c46eb3d1800fcac431cfa7147a01", agent.revParse(repositoryDir, "HEAD"));
        assertEquals("017caf31eca7c46eb3d1800fcac431cfa7147a01", agent.revParse(repositoryDir, "refs/heads/master"));
    }

    @Test(expected = IllegalArgumentException.class)
    public void testRevParsePathTraversalFails() {
        Path repositoryDir = merge.getPath();
        agent.revParse(repositoryDir, PATH_TRAVERSAL_REF);
    }

    @Test
    @DirtiesRepo
    public void testSetHead() {
        Path repoDir = repository.getPath();

        agent.setHead(repoDir, "refs/heads/doesnotexist");
        assertEquals("refs/heads/doesnotexist", agent.getHead(repository));

        agent.setHead(repoDir, "refs/heads/master");
        assertEquals("refs/heads/master", agent.getHead(repository));
    }

    @Test(expected = IllegalArgumentException.class)
    public void testSetHeadPathTraversalFails() {
        Path repoDir = repository.getPath();

        try {
            agent.setHead(repoDir, PATH_TRAVERSAL_REF);
        } finally {
            assertEquals("refs/heads/master", agent.getHead(repository));
        }
    }

    @Test
    @DirtiesRepo
    public void testShallowResolve() {
        // create a lightweight tag for testing
        builderFactory.builder(repository).tag()
                .create("lightweight-tag")
                .startPoint("05c78271ed4d7a158fe6789f6b27e3a47631faba")
                .type(GitTagType.LIGHTWEIGHT)
                .build()
                .run();

        // create two symbolic refs for testing. symbol1 -> symbol2 -> master
        builderFactory.builder(repository).symbolicRef().set("refs/heads/symbol2", "refs/heads/master").build().run();
        builderFactory.builder(repository).symbolicRef().set("refs/heads/symbol1", "refs/heads/symbol2").build().run();

        // create a branch with the same name as a tag. This should be ignored
        builderFactory.builder(repository).branch()
                .create("retagged_signed_tag")
                .startPoint("05c78271ed4d7a158fe6789f6b27e3a47631faba")
                .build().run();

        Consumer<Boolean> runTest = expectingFullTags -> {
            Map<String, Ref> resolved = agent.shallowResolveRefs(repository, ImmutableList.of(
                    "backdated_annotated_tag", "retagged_signed_tag", "lightweight-tag", "does_not_exist",
                    "master", "heads/basic_branching", "refs/heads/branch_mod_merge", "branch_mod_merge",
                    "symbol1", "symbol2", "heads"), false);

            assertShallowTag(TAGS.qualify("backdated_annotated_tag"), "80aa5be5ecf39660f798858482254f7a2ae9110e",
                    resolved.get("backdated_annotated_tag"));
            assertShallowTag(TAGS.qualify("retagged_signed_tag"), "9b4e781dea0769888fe270e06ad76675f73851b1",
                    resolved.get("retagged_signed_tag"));
            assertShallowTag(TAGS.qualify("lightweight-tag"), "05c78271ed4d7a158fe6789f6b27e3a47631faba",
                    resolved.get("lightweight-tag"));

            assertRefIsBranch(HEADS.qualify("master"), "0a943a29376f2336b78312d99e65da17048951db", true, resolved.get("master"));
            assertRefIsBranch(HEADS.qualify("basic_branching"), "d6edcbf924697ab811a867421dab60d954ccad99",
                    resolved.get("heads/basic_branching"));
            assertRefIsBranch(HEADS.qualify("branch_mod_merge"), "6053a1eaa1c009dd11092d09a72f3c41af1b59ad",
                    resolved.get("refs/heads/branch_mod_merge"));
            assertRefIsBranch(HEADS.qualify("branch_mod_merge"), "6053a1eaa1c009dd11092d09a72f3c41af1b59ad",
                    resolved.get("branch_mod_merge"));
            assertRefIsBranch(HEADS.qualify("symbol1"), "0a943a29376f2336b78312d99e65da17048951db", false, resolved.get("symbol1"));
            assertRefIsBranch(HEADS.qualify("symbol2"), "0a943a29376f2336b78312d99e65da17048951db", false, resolved.get("symbol2"));

            assertNull(resolved.get("does_not_exist"));
        };

        // first test with loose refs - this should return Ref instances that do not have their commitId resolved
        runTest.accept(false);

        // now pack the refs and re-run the test - this should return Tag instances
        builderFactory.builder(repository).packRefs()
                .all(true)
                .build(new LoggingStdioHandler(LoggerFactory.getLogger(RawGitAgent.class)))
                .run();

        // re-create a branch with the same name as a tag as a loose branch. This should still be ignored
        builderFactory.builder(repository).branch()
                .create("retagged_signed_tag")
                .startPoint("0a943a29376f2336b78312d99e65da17048951db")
                .force(true)
                .build().run();

        runTest.accept(true);
    }

    @DirtiesRepo("repository")
    @Test
    public void testShallowResolveRefsWithInvalidUtf8() throws IOException {
        //This is _almost_ compatible with UTF-8, but the ¦ character is "c2a6" in UTF-8 and just "a6" in ISO-8859-1
        String brokenBarTag = "12ebe2a58367347cd39f19f5a72f3cbec7b8f9a9 refs/tags/z-¦-test\n" +
                "^0a943a29376f2336b78312d99e65da17048951db\n";

        //Create a packed-refs file, since the default repository doesn't have one, and write our broken tag
        Path packedRefs = repository.getPath().resolve(PACKED_REFS);
        Files.write(packedRefs, "# pack-refs with: peeled fully-peeled\n".getBytes(StandardCharsets.UTF_8));
        Files.write(packedRefs, brokenBarTag.getBytes(StandardCharsets.ISO_8859_1), StandardOpenOption.APPEND);

        Map<String, Ref> resolved = agent.shallowResolveRefs(repository, ImmutableList.of(
                "refs/tags/z-¦-test", "refs/tags/z-\uFFFD-test"), true);
        assertNotNull(resolved);
        //z-¦-test will not resolve because it's encoded incorrectly
        assertNull(resolved.get("refs/tags/z-¦-test"));
        //¦ is replaced with the UTF-8 replacement character, so z-�-test should resolve
        assertShallowTag(TAGS.qualify("z-\uFFFD-test"), "12ebe2a58367347cd39f19f5a72f3cbec7b8f9a9",
                resolved.get("refs/tags/z-\uFFFD-test"));
    }

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

    private static void assertRefIsBranch(String name, String latestCommit, Ref branch) {
        assertRefIsBranch(name, latestCommit, false, branch);
    }

    private static void assertRefIsBranch(String name, String latestCommit, boolean isDefault, Ref branch) {
        assertRef(name, latestCommit, branch);
        assertEquals(RefType.BRANCH, branch.getType());
        if (branch instanceof Branch) {
            assertEquals(isDefault, ((Branch) branch).isDefault());
        }
    }

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

    private static void assertRef(String name, String latestCommit, Ref ref) {
        assertNotNull(ref);
        assertEquals(name, ref.getId());
        assertEquals(latestCommit, ref.getLatestCommit());
    }

    private void assertReflog(String ref, String commitId, String message) {
        String reflog = builderFactory.builder(repository).command("reflog")
                .argument("-1").argument(ref).build(new StringStdioHandler()).run();
        //Note: The short hash here is produced by _git_ when the ref is updated, so it will be 7 characters not 11
        assertThat(reflog, allOf(containsString(ref), containsString(message), containsString(commitId.substring(0, 7))));
    }

    private void assertShallowTag(String name, String hash, Ref ref) {
        assertRef(name, hash, ref);
        assertEquals(RefType.TAG, ref.getType());
    }

    private void testRefUpdateWithLogging(String ref) {
        Branch branch = agent.resolveBranch(repository, "out_of_order_branch");
        assertNotNull(branch);

        // update ref
        String commitId = branch.getLatestCommit();
        builderFactory.builder(repository).command("update-ref")
                .argument("-m").argument("test update")
                .argument(ref).argument(commitId)
                .build(new NullStdioHandler<>())
                .run();

        // check reflog
        assertReflog(ref, commitId, "test update");
    }
}