package org.jenkinsci.plugins.gitclient;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.anyOf;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.not;
import static org.junit.jupiter.api.Assertions.*;

import hudson.EnvVars;
import hudson.FilePath;
import hudson.Util;
import hudson.model.TaskListener;
import hudson.plugins.git.Branch;
import hudson.plugins.git.GitException;
import hudson.plugins.git.IGitAPI;
import hudson.util.StreamTaskListener;
import java.io.File;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Random;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.jgit.api.Status;
import org.eclipse.jgit.internal.storage.file.FileRepository;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.transport.RemoteConfig;
import org.eclipse.jgit.transport.URIish;
import org.eclipse.jgit.util.SystemReader;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.junit.jupiter.params.Parameter;
import org.junit.jupiter.params.ParameterizedClass;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.jvnet.hudson.test.Issue;

/**
 * Git API tests implemented in JUnit 5.
 */
@ParameterizedClass(name = "{0}")
@MethodSource("gitObjects")
class GitAPITest {

    @RegisterExtension
    private final GitClientSampleRepoRule repo = new GitClientSampleRepoRule();

    @RegisterExtension
    private final GitClientSampleRepoRule secondRepo = new GitClientSampleRepoRule();

    @RegisterExtension
    private final GitClientSampleRepoRule thirdRepo = new GitClientSampleRepoRule();

    private int logCount = 0;
    private final Random random = new Random();
    private static final String LOGGING_STARTED = "Logging started";
    private static final String DEFAULT_MIRROR_BRANCH_NAME = "mast" + "er";
    private LogHandler handler = null;
    private TaskListener listener;

    @Parameter(0)
    private String gitImplName;

    /**
     * Tests that need the default branch name can use this variable.
     */
    private static String defaultBranchName = "mast" + "er"; // Intentionally split string

    private int checkoutTimeout = -1;

    private WorkspaceWithRepo workspace;

    private GitClient testGitClient;
    private File testGitDir;

    public static EnvVars getConfigNoSystemEnvVars() {
        EnvVars envVars = new EnvVars();
        envVars.put("GIT_CONFIG_NOSYSTEM", "1");
        return envVars;
    }

    static List<Arguments> gitObjects() {
        List<Arguments> arguments = new ArrayList<>();
        String[] gitImplNames = {"git", "jgit", "jgitapache"};
        for (String gitImplName : gitImplNames) {
            Arguments item = Arguments.of(gitImplName);
            arguments.add(item);
        }
        return arguments;
    }

    @BeforeAll
    static void loadLocalMirror() throws Exception {
        SystemReader.getInstance().getUserConfig().clear();
        /* Prime the local mirror cache before other tests run */
        /* Allow 2-6 second delay before priming the cache */
        /* Allow other tests a better chance to prime the cache */
        /* 2-6 second delay is small compared to execution time of this test */
        Random random = new Random();
        Thread.sleep(2000L + random.nextInt(4000)); // Wait 2-6 seconds before priming the cache
        TaskListener mirrorListener = StreamTaskListener.fromStdout();
        File tempDir = Files.createTempDirectory("PrimeGitAPITest").toFile();
        WorkspaceWithRepo cache = new WorkspaceWithRepo(tempDir, "git", mirrorListener);
        cache.localMirror();
        Util.deleteRecursive(tempDir);
    }

    /**
     * Determine the global default branch name.
     * Command line git is moving towards more inclusive naming.
     * Git 2.32.0 honors the configuration variable `init.defaultBranch` and uses it for the name of the initial branch.
     * This method reads the global configuration and uses it to set the value of `defaultBranchName`.
     */
    @BeforeAll
    static void computeDefaultBranchName() throws Exception {
        File configDir = Files.createTempDirectory("readGitConfig").toFile();
        CliGitCommand getDefaultBranchNameCmd =
                new CliGitCommand(Git.with(TaskListener.NULL, getConfigNoSystemEnvVars())
                        .in(configDir)
                        .using("git")
                        .getClient());
        String[] output = getDefaultBranchNameCmd.runWithoutAssert("config", "--get", "init.defaultBranch");
        for (String s : output) {
            String result = s.trim();
            if (result != null && !result.isEmpty()) {
                defaultBranchName = result;
            }
        }
        assertTrue(configDir.delete(), "Failed to delete temporary readGitConfig directory");
    }

    @BeforeEach
    void setUpRepositories() throws Exception {
        checkoutTimeout = -1;

        Logger logger = Logger.getLogger(this.getClass().getPackage().getName() + "-" + logCount++);
        handler = new LogHandler();
        handler.setLevel(Level.ALL);
        logger.setUseParentHandlers(false);
        logger.addHandler(handler);
        logger.setLevel(Level.ALL);
        listener = new hudson.util.LogTaskListener(logger, Level.ALL);
        listener.getLogger().println(LOGGING_STARTED);

        workspace = new WorkspaceWithRepo(repo.getRoot(), gitImplName, listener);

        testGitClient = workspace.getGitClient();
        testGitDir = workspace.getGitFileDir();
        workspace.initializeWorkspace();
    }

    @AfterEach
    void afterTearDown() {
        try {
            String messages = StringUtils.join(handler.getMessages(), ";");
            assertTrue(handler.containsMessageSubstring(LOGGING_STARTED), "Logging not started: " + messages);
            assertCheckoutTimeout();
        } finally {
            handler.close();
        }
    }

    private void assertCheckoutTimeout() {
        if (checkoutTimeout > 0) {
            assertSubstringTimeout("git checkout", checkoutTimeout);
        }
    }

    private void assertSubstringTimeout(final String substring, int expectedTimeout) {
        if (!(testGitClient instanceof CliGitAPIImpl)) { // Timeout only implemented in CliGitAPIImpl
            return;
        }
        List<String> messages = handler.getMessages();
        List<String> substringMessages = new ArrayList<>();
        List<String> substringTimeoutMessages = new ArrayList<>();
        final String messageRegEx = ".*\\b" + substring + "\\b.*"; // the expected substring
        final String timeoutRegEx = messageRegEx + " [#] timeout=" + expectedTimeout + "\\b.*"; // # timeout=<value>
        for (String message : messages) {
            if (message.matches(messageRegEx)) {
                substringMessages.add(message);
            }
            if (message.matches(timeoutRegEx)) {
                substringTimeoutMessages.add(message);
            }
        }
        assertThat(messages, is(not(empty())));
        assertThat(substringMessages, is(not(empty())));
        assertThat(substringTimeoutMessages, is(not(empty())));
        assertEquals(substringMessages, substringTimeoutMessages);
    }

    private Collection<String> getBranchNames(Collection<Branch> branches) {
        return branches.stream().map(Branch::getName).toList();
    }

    private void assertBranchesExist(Set<Branch> branches, String... names) {
        Collection<String> branchNames = getBranchNames(branches);
        for (String name : names) {
            assertThat(branchNames, hasItem(name));
        }
    }

    @Test
    void testGetRemoteUrl() throws Exception {
        workspace.launchCommand("git", "remote", "add", "origin", "https://github.com/jenkinsci/git-client-plugin.git");
        workspace.launchCommand("git", "remote", "add", "ndeloof", "git@github.com:ndeloof/git-client-plugin.git");
        String remoteUrl = workspace.getGitClient().getRemoteUrl("origin");
        assertEquals(
                "https://github.com/jenkinsci/git-client-plugin.git", remoteUrl, "unexpected remote URL " + remoteUrl);
    }

    @Test
    void testEmptyComment() throws Exception {
        workspace.commitEmpty("init-empty-comment-to-tag-fails-on-windows");
        if (isWindows()) {
            testGitClient.tag("non-empty-comment", "empty-tag-comment-fails-on-windows");
        } else {
            testGitClient.tag("empty-comment", "");
        }
    }

    @Test
    void testCreateBranch() throws Exception {
        workspace.commitEmpty("init");
        testGitClient.branch("test");
        String branches = workspace.launchCommand("git", "branch", "-l");
        assertTrue(branches.contains(defaultBranchName), "default branch not listed");
        assertTrue(branches.contains("test"), "test branch not listed");
    }

    @Test
    void testDeleteBranch() throws Exception {
        workspace.commitEmpty("init");
        testGitClient.branch("test");
        testGitClient.deleteBranch("test");
        String branches = workspace.launchCommand("git", "branch", "-l");
        assertFalse(branches.contains("test"), "deleted test branch still present");

        if (testGitClient instanceof JGitAPIImpl) {
            // JGit does not throw an exception
            testGitClient.deleteBranch("test");
        } else {
            Exception exception = assertThrows(GitException.class, () -> testGitClient.deleteBranch("test"));
            assertThat(exception.getMessage(), is("Could not delete branch test"));
        }
    }

    @Test
    void testDeleteTag() throws Exception {
        workspace.commitEmpty("init");
        workspace.tag("test");
        workspace.tag("another");
        testGitClient.deleteTag("test");
        String tags = workspace.launchCommand("git", "tag");
        assertFalse(tags.contains("test"), "deleted test tag still present");
        assertTrue(tags.contains("another"), "expected tag not listed");
        if (testGitClient instanceof JGitAPIImpl) {
            // JGit does not throw an exception
            testGitClient.deleteTag("test");
        } else {
            Exception exception = assertThrows(GitException.class, () -> testGitClient.deleteTag("test"));
            assertThat(exception.getMessage(), is("Could not delete tag test"));
        }
    }

    @Test
    void testListTagsWithFilter() throws Exception {
        workspace.commitEmpty("init");
        workspace.tag("test");
        workspace.tag("another_test");
        workspace.tag("yet_another");
        Set<String> tags = testGitClient.getTagNames("*test");
        assertTrue(tags.contains("test"), "expected tag test not listed");
        assertTrue(tags.contains("another_test"), "expected tag another_tag not listed");
        assertFalse(tags.contains("yet_another"), "unexpected tag yet_another listed");
    }

    @Test
    void testListTagsWithoutFilter() throws Exception {
        workspace.commitEmpty("init");
        workspace.tag("test");
        workspace.tag("another_test");
        workspace.tag("yet_another");
        Set<String> allTags = testGitClient.getTagNames(null);
        assertTrue(allTags.contains("test"), "tag 'test' not listed");
        assertTrue(allTags.contains("another_test"), "tag 'another_test' not listed");
        assertTrue(allTags.contains("yet_another"), "tag 'yet_another' not listed");
    }

    @Issue("JENKINS-37794")
    @Test
    void testGetTagNamesSupportsSlashesInTagNames() throws Exception {
        workspace.commitEmpty("init-getTagNames-supports-slashes");
        testGitClient.tag("no-slash", "Tag without a /");
        Set<String> tags = testGitClient.getTagNames(null);
        assertThat(tags, hasItem("no-slash"));
        assertThat(tags, not(hasItem("slashed/sample")));
        assertThat(tags, not(hasItem("slashed/sample-with-short-comment")));

        testGitClient.tag("slashed/sample", "Tag slashed/sample includes a /");
        testGitClient.tag("slashed/sample-with-short-comment", "short comment");

        for (String matchPattern : Arrays.asList("n*", "no-*", "*-slash", "*/sl*sa*", "*/sl*/sa*")) {
            Set<String> latestTags = testGitClient.getTagNames(matchPattern);
            assertThat(tags, hasItem("no-slash"));
            assertThat(latestTags, not(hasItem("slashed/sample")));
            assertThat(latestTags, not(hasItem("slashed/sample-with-short-comment")));
        }

        for (String matchPattern : Arrays.asList("s*", "slashed*", "sl*sa*", "slashed/*", "sl*/sa*", "slashed/sa*")) {
            Set<String> latestTags = testGitClient.getTagNames(matchPattern);
            assertThat(latestTags, hasItem("slashed/sample"));
            assertThat(latestTags, hasItem("slashed/sample-with-short-comment"));
        }
    }

    @Test
    void testListBranchesContainingRef() throws Exception {
        workspace.commitEmpty("init");
        testGitClient.branch("test");
        testGitClient.branch("another");
        Set<Branch> branches = testGitClient.getBranches();
        assertBranchesExist(branches, defaultBranchName, "test", "another");
        assertEquals(3, branches.size());
    }

    @Test
    void testListTagsStarFilter() throws Exception {
        workspace.commitEmpty("init");
        workspace.tag("test");
        workspace.tag("another_test");
        workspace.tag("yet_another");
        Set<String> allTags = testGitClient.getTagNames("*");
        assertTrue(allTags.contains("test"), "tag 'test' not listed");
        assertTrue(allTags.contains("another_test"), "tag 'another_test' not listed");
        assertTrue(allTags.contains("yet_another"), "tag 'yet_another' not listed");
    }

    @Test
    void testTagExists() throws Exception {
        workspace.commitEmpty("init");
        workspace.tag("test");
        assertTrue(testGitClient.tagExists("test"));
        assertFalse(testGitClient.tagExists("unknown"));
    }

    @Test
    void testGetTagMessage() throws Exception {
        workspace.commitEmpty("init");
        workspace.launchCommand("git", "tag", "test", "-m", "this-is-a-test");
        assertEquals("this-is-a-test", testGitClient.getTagMessage("test"));
    }

    @Test
    void testGetTagMessageMultiLine() throws Exception {
        workspace.commitEmpty("init");
        workspace.launchCommand("git", "tag", "test", "-m", "test 123!\n* multi-line tag message\n padded ");

        // Leading four spaces from each line should be stripped,
        // but not the explicit single space before "padded",
        // and the final errant space at the end should be trimmed
        assertEquals("test 123!\n* multi-line tag message\n padded", testGitClient.getTagMessage("test"));
    }

    @Test
    void testCreateRef() throws Exception {
        workspace.commitEmpty("init");
        testGitClient.ref("refs/testing/testref");
        assertTrue(workspace.launchCommand("git", "show-ref").contains("refs/testing/testref"), "test ref not created");
    }

    @Test
    void testDeleteRef() throws Exception {
        workspace.commitEmpty("init");
        testGitClient.ref("refs/testing/testref");
        testGitClient.ref("refs/testing/anotherref");
        testGitClient.deleteRef("refs/testing/testref");
        String refs = workspace.launchCommand("git", "show-ref");
        assertFalse(refs.contains("refs/testing/testref"), "deleted test tag still present");
        assertTrue(refs.contains("refs/testing/anotherref"), "expected tag not listed");
        testGitClient.deleteRef("refs/testing/testref"); // Double-deletes do nothing.
    }

    @Test
    void testListRefsWithPrefix() throws Exception {
        workspace.commitEmpty("init");
        testGitClient.ref("refs/testing/testref");
        testGitClient.ref("refs/testing/nested/anotherref");
        testGitClient.ref("refs/testing/nested/yetanotherref");
        Set<String> refs = testGitClient.getRefNames("refs/testing/nested/");
        assertFalse(refs.contains("refs/testing/testref"), "ref testref listed");
        assertTrue(refs.contains("refs/testing/nested/anotherref"), "ref anotherref not listed");
        assertTrue(refs.contains("refs/testing/nested/yetanotherref"), "ref yetanotherref not listed");
    }

    @Test
    void testListRefsWithoutPrefix() throws Exception {
        workspace.commitEmpty("init");
        testGitClient.ref("refs/testing/testref");
        testGitClient.ref("refs/testing/nested/anotherref");
        testGitClient.ref("refs/testing/nested/yetanotherref");
        Set<String> allRefs = testGitClient.getRefNames("");
        assertTrue(allRefs.contains("refs/testing/testref"), "ref testref not listed");
        assertTrue(allRefs.contains("refs/testing/nested/anotherref"), "ref anotherref not listed");
        assertTrue(allRefs.contains("refs/testing/nested/yetanotherref"), "ref yetanotherref not listed");
    }

    @Test
    void testRefExists() throws Exception {
        workspace.commitEmpty("init");
        testGitClient.ref("refs/testing/testref");
        assertTrue(testGitClient.refExists("refs/testing/testref"));
        assertFalse(testGitClient.refExists("refs/testing/testref_notfound"));
        assertFalse(testGitClient.refExists("refs/testing2/yetanother"));
    }

    @Test
    void testHasGitRepoWithValidGitRepo() throws Exception {
        assertTrue(testGitClient.hasGitRepo(), "Valid Git repo reported as invalid");
    }

    @Test
    void testCleanWithParameter() throws Exception {
        workspace.commitEmpty("init");

        final String dirName1 = "dir1";
        final String fileName1 = dirName1 + File.separator + "fileName1";
        final String fileName2 = "fileName2";
        assertTrue(workspace.file(dirName1).mkdir(), "Did not create dir " + dirName1);
        workspace.touch(workspace.getGitFileDir(), fileName1, "");
        workspace.touch(workspace.getGitFileDir(), fileName2, "");

        final String dirName3 = "dir-with-submodule";
        File submodule = workspace.file(dirName3);
        assertTrue(submodule.mkdir(), "Did not create dir " + dirName3);
        WorkspaceWithRepo workspace1 = new WorkspaceWithRepo(submodule, gitImplName, TaskListener.NULL);
        workspace1.initializeWorkspace();
        workspace1.commitEmpty("init");

        testGitClient.clean();
        assertFalse(workspace.exists(dirName1));
        assertFalse(workspace.exists(fileName1));
        assertFalse(workspace.exists(fileName2));
        assertTrue(workspace.exists(dirName3));

        testGitClient.clean(true);
        assertFalse(workspace.exists(dirName3));
    }

    @Issue({"JENKINS-20410", "JENKINS-27910", "JENKINS-22434"})
    @Test
    void testClean() throws Exception {
        workspace.commitEmpty("init");

        /* String starts with a surrogate character, mathematical
         * double struck small t as the first character of the file
         * name. The last three characters of the file name are three
         * different forms of the a-with-ring character. Refer to
         * https://unicode.org/reports/tr15/#Detecting_Normalization_Forms
         * for the source of those example characters.
         */
        final String fileName = "\uD835\uDD65-\u5c4f\u5e55\u622a\u56fe-\u0041\u030a-\u00c5-\u212b-fileName.xml";
        workspace.touch(testGitDir, fileName, "content " + fileName);
        testGitClient.add(fileName);
        testGitClient.commit(fileName);

        /* JENKINS-27910 reported that certain cyrillic file names
         * failed to delete if the encoding was not UTF-8.
         */
        final String fileNameSwim =
                "\u00d0\u00bf\u00d0\u00bb\u00d0\u00b0\u00d0\u00b2\u00d0\u00b0\u00d0\u00bd\u00d0\u00b8\u00d0\u00b5-swim.png";
        workspace.touch(testGitDir, fileNameSwim, "content " + fileNameSwim);
        testGitClient.add(fileNameSwim);
        testGitClient.commit(fileNameSwim);

        final String fileNameFace = "\u00d0\u00bb\u00d0\u00b8\u00d1\u2020\u00d0\u00be-face.png";
        workspace.touch(testGitDir, fileNameFace, "content " + fileNameFace);
        testGitClient.add(fileNameFace);
        testGitClient.commit(fileNameFace);

        workspace.touch(testGitDir, ".gitignore", ".test");
        testGitClient.add(".gitignore");
        testGitClient.commit("ignore");

        final String dirName1 = "\u5c4f\u5e55\u622a\u56fe-dir-not-added";
        final String fileName1 = dirName1 + File.separator + "\u5c4f\u5e55\u622a\u56fe-fileName1-not-added.xml";
        final String fileName2 = ".test-\u00f8\u00e4\u00fc\u00f6-fileName2-not-added";
        assertTrue(workspace.file(dirName1).mkdir(), "Did not create dir " + dirName1);
        workspace.touch(testGitDir, fileName1, "");
        workspace.touch(testGitDir, fileName2, "");
        workspace.touch(testGitDir, fileName, "new content");

        testGitClient.clean();
        assertFalse(workspace.exists(dirName1));
        assertFalse(workspace.exists(fileName1));
        assertFalse(workspace.exists(fileName2));
        assertEquals("content " + fileName, workspace.contentOf(fileName));
        assertEquals("content " + fileNameFace, workspace.contentOf(fileNameFace));
        assertEquals("content " + fileNameSwim, workspace.contentOf(fileNameSwim));
        String status = workspace.launchCommand("git", "status");
        assertTrue(
                status.contains("working directory clean") || status.contains("working tree clean"),
                "unexpected status " + status);

        /* A few poorly placed tests of hudson.FilePath - testing JENKINS-22434 */
        FilePath fp = new FilePath(workspace.file(fileName));
        assertTrue(fp.exists(), fp + " missing");

        assertTrue(workspace.file(dirName1).mkdir(), "mkdir " + dirName1 + " failed");
        assertTrue(workspace.file(dirName1).isDirectory(), "dir " + dirName1 + " missing");
        FilePath dir1 = new FilePath(workspace.file(dirName1));
        workspace.touch(testGitDir, fileName1, "");
        assertTrue(workspace.file(fileName1).exists(), "Did not create file " + fileName1);

        assertTrue(dir1.exists(), dir1 + " missing");
        dir1.deleteRecursive(); /* Fails on Linux JDK 7 with LANG=C, ok with LANG=en_US.UTF-8 */
        /* Java reports "Malformed input or input contains unmappable characters" */
        assertFalse(workspace.file(fileName1).exists(), "Did not delete file " + fileName1);
        assertFalse(dir1.exists(), dir1 + " not deleted");

        workspace.touch(testGitDir, fileName2, "");
        FilePath fp2 = new FilePath(workspace.file(fileName2));

        assertTrue(fp2.exists(), fp2 + " missing");
        fp2.delete();
        assertFalse(fp2.exists(), fp2 + " not deleted");

        String dirContents = Arrays.toString((new File(testGitDir.getAbsolutePath())).listFiles());
        String finalStatus = workspace.launchCommand("git", "status");
        assertTrue(
                finalStatus.contains("working directory clean") || finalStatus.contains("working tree clean"),
                "unexpected final status " + finalStatus + " dir contents: " + dirContents);
    }

    @Test
    void testPushTags() throws Exception {
        /* Working Repo with commit */
        final String fileName1 = "file1";
        workspace.touch(testGitDir, fileName1, fileName1 + " content " + java.util.UUID.randomUUID());
        testGitClient.add(fileName1);
        testGitClient.commit("commit1");
        ObjectId commit1 = workspace.head();

        /* Clone working repo to bare repo */
        WorkspaceWithRepo bare = new WorkspaceWithRepo(secondRepo.getRoot(), gitImplName, TaskListener.NULL);
        bare.initBareRepo(testGitClient, true);
        testGitClient.setRemoteUrl("origin", bare.getGitFileDir().getAbsolutePath());
        Set<Branch> remoteBranchesEmpty = testGitClient.getRemoteBranches();
        assertThat(remoteBranchesEmpty, is(empty()));
        //        testGitClient.push().ref(defaultBranchName).to(new URIish("origin")).execute();
        testGitClient.push("origin", defaultBranchName);
        ObjectId bareCommit1 =
                bare.getGitClient().getHeadRev(bare.getGitFileDir().getAbsolutePath(), defaultBranchName);
        assertEquals(commit1, bareCommit1, "bare != working");
        assertEquals(
                commit1,
                bare.getGitClient()
                        .getHeadRev(bare.getGitFileDir().getAbsolutePath(), "refs/heads/" + defaultBranchName));

        /* Add tag1 to working repo without pushing it to bare repo */
        workspace.tag("tag1");
        assertTrue(testGitClient.tagExists("tag1"), "tag1 wasn't created");
        assertEquals(commit1, testGitClient.revParse("tag1"), "tag1 points to wrong commit");
        testGitClient
                .push()
                .ref(defaultBranchName)
                .to(new URIish(bare.getGitFileDir().getAbsolutePath()))
                .tags(false)
                .execute();
        assertFalse(bare.launchCommand("git", "tag").contains("tag1"), "tag1 pushed unexpectedly");

        /* Push tag1 to bare repo */
        testGitClient
                .push()
                .ref(defaultBranchName)
                .to(new URIish(bare.getGitFileDir().getAbsolutePath()))
                .tags(true)
                .execute();
        assertTrue(bare.launchCommand("git", "tag").contains("tag1"), "tag1 not pushed");

        /* Create a new commit, move tag1 to that commit, attempt push */
        workspace.touch(testGitDir, fileName1, fileName1 + " content " + java.util.UUID.randomUUID());
        testGitClient.add(fileName1);
        testGitClient.commit("commit2");
        ObjectId commit2 = workspace.head();
        workspace.tag("tag1", true); /* Tag already exists, move from commit1 to commit2 */
        assertTrue(testGitClient.tagExists("tag1"), "tag1 wasn't created");
        assertEquals(commit2, testGitClient.revParse("tag1"), "tag1 points to wrong commit");
        if (testGitClient instanceof CliGitAPIImpl) {
            // Modern CLI git should throw exception pushing a change to existing tag
            Exception exception = assertThrows(GitException.class, () -> testGitClient
                    .push()
                    .ref(defaultBranchName)
                    .to(new URIish(bare.getGitFileDir().getAbsolutePath()))
                    .tags(true)
                    .execute());
            assertThat(exception.getMessage(), containsString("already exists"));
        } else {
            testGitClient
                    .push()
                    .ref(defaultBranchName)
                    .to(new URIish(bare.getGitFileDir().getAbsolutePath()))
                    .tags(true)
                    .execute();
        }

        if (testGitClient instanceof CliGitAPIImpl) {
            /* CliGit throws exception updating existing tag */
            Exception exception = assertThrows(GitException.class, () -> testGitClient
                    .push()
                    .ref(defaultBranchName)
                    .to(new URIish(bare.getGitFileDir().getAbsolutePath()))
                    .tags(true)
                    .force(false)
                    .execute());
            assertThat(exception.getMessage(), containsString("already exists"));
        } else {
            /* JGit does not throw exception updating existing tag - ugh */
            testGitClient
                    .push()
                    .ref(defaultBranchName)
                    .to(new URIish(bare.getGitFileDir().getAbsolutePath()))
                    .tags(true)
                    .force(false)
                    .execute();
        }

        testGitClient
                .push()
                .ref(defaultBranchName)
                .to(new URIish(bare.getGitFileDir().getAbsolutePath()))
                .tags(true)
                .force()
                .execute();

        /* Add tag to working repo without pushing it to the bare
         * repo, tests the default behavior when tags() is not added
         * to PushCommand.
         */
        workspace.tag("tag3");
        assertTrue(testGitClient.tagExists("tag3"), "tag3 wasn't created");
        testGitClient
                .push()
                .ref(defaultBranchName)
                .to(new URIish(bare.getGitFileDir().getAbsolutePath()))
                .execute();
        assertFalse(bare.launchCommand("git", "tag").contains("tag3"), "tag3 was pushed");

        /* Add another tag to working repo and push tags to the bare repo */
        final String fileName2 = "file2";
        workspace.touch(testGitDir, fileName2, fileName2 + " content " + java.util.UUID.randomUUID());
        testGitClient.add(fileName2);
        testGitClient.commit("commit2");
        workspace.tag("tag2");
        assertTrue(testGitClient.tagExists("tag2"), "tag2 wasn't created");
        testGitClient
                .push()
                .ref(defaultBranchName)
                .to(new URIish(bare.getGitFileDir().getAbsolutePath()))
                .tags(true)
                .execute();
        assertTrue(bare.launchCommand("git", "tag").contains("tag1"), "tag1 wasn't pushed");
        assertTrue(bare.launchCommand("git", "tag").contains("tag2"), "tag2 wasn't pushed");
        assertTrue(bare.launchCommand("git", "tag").contains("tag3"), "tag3 wasn't pushed");
    }

    @Issue("JENKINS-34309")
    @Test
    void testListBranches() throws Exception {
        Set<Branch> branches = testGitClient.getBranches();
        assertEquals(0, branches.size()); // empty repo should have 0 branches
        workspace.commitEmpty("init");

        testGitClient.branch("test");
        workspace.touch(testGitDir, "test-branch.txt", "");
        testGitClient.add("test-branch.txt");
        // JGit commit doesn't end commit message with Ctrl-M, even when passed
        final String testBranchCommitMessage = "test branch commit ends in Ctrl-M";
        workspace.jgit().commit(testBranchCommitMessage + "\r");

        testGitClient.branch("another");
        workspace.touch(testGitDir, "another-branch.txt", "");
        testGitClient.add("another-branch.txt");
        // CliGit commit doesn't end commit message with Ctrl-M, even when passed
        final String anotherBranchCommitMessage = "test branch commit ends in Ctrl-M";
        workspace.cgit().commit(anotherBranchCommitMessage + "\r");

        branches = testGitClient.getBranches();
        assertBranchesExist(branches, defaultBranchName, "test", "another");
        assertEquals(3, branches.size());
        String output = workspace.launchCommand("git", "branch", "-v", "--no-abbrev");
        assertTrue(
                output.contains(testBranchCommitMessage),
                "git branch -v --no-abbrev missing test commit msg: '" + output + "'");
        assertTrue(
                output.contains(anotherBranchCommitMessage),
                "git branch -v --no-abbrev missing another commit msg: '" + output + "'");
        if (workspace.cgit().isAtLeastVersion(2, 13, 0, 0) && !workspace.cgit().isAtLeastVersion(2, 30, 0, 0)) {
            assertTrue(output.contains("\r"), "git branch -v --no-abbrev missing Ctrl-M: '" + output + "'");
            assertTrue(
                    output.contains(testBranchCommitMessage + "\r"),
                    "git branch -v --no-abbrev missing test commit msg Ctrl-M: '" + output + "'");
            assertTrue(
                    output.contains(anotherBranchCommitMessage + "\r"),
                    "git branch -v --no-abbrev missing another commit msg Ctrl-M: '" + output + "'");
        } else {
            assertFalse(output.contains("\r"), "git branch -v --no-abbrev contains Ctrl-M: '" + output + "'");
            assertFalse(
                    output.contains(testBranchCommitMessage + "\r"),
                    "git branch -v --no-abbrev contains test commit msg Ctrl-M: '" + output + "'");
            assertFalse(
                    output.contains(anotherBranchCommitMessage + "\r"),
