package org.jenkinsci.plugins.gitclient;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.greaterThan;
import static org.hamcrest.Matchers.hasEntry;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.hasItems;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.startsWith;
import static org.hamcrest.io.FileMatchers.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assumptions.assumeFalse;
import static org.junit.jupiter.api.Assumptions.assumeTrue;

import com.cloudbees.plugins.credentials.common.StandardCredentials;
import hudson.EnvVars;
import hudson.FilePath;
import hudson.model.TaskListener;
import hudson.plugins.git.Branch;
import hudson.plugins.git.GitException;
import hudson.plugins.git.GitObject;
import hudson.plugins.git.IGitAPI;
import hudson.plugins.git.IndexEntry;
import hudson.plugins.git.Revision;
import java.io.File;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;
import java.util.UUID;
import org.apache.commons.io.FileUtils;
import org.eclipse.jgit.api.errors.JGitInternalException;
import org.eclipse.jgit.internal.storage.file.FileRepository;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.StoredConfig;
import org.eclipse.jgit.transport.RefSpec;
import org.eclipse.jgit.transport.URIish;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.CleanupMode;
import org.junit.jupiter.api.io.TempDir;
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 Client tests, intended as an eventual replacement for CliGitAPIImplTest,
 * JGitAPIImplTest, and JGitApacheAPIImplTest.
 *
 * @author Mark Waite
 */
@ParameterizedClass(name = "{0}")
@MethodSource("gitObjects")
class GitClientTest {

    /* Git implementation name, either "git", "jgit", or "jgitapache". */
    @Parameter(0)
    private String gitImplName;

    /* Git client plugin repository directory. */
    private static File srcRepoDir = null;

    /* GitClient for plugin development repository. */
    private GitClient srcGitClient;

    /* commit known to exist in upstream. */
    private final ObjectId upstreamCommit = ObjectId.fromString("f75720d5de9d79ab4be2633a21de23b3ccbf8ce3");
    private final String upstreamCommitAuthor = "Teubel György";
    private final String upstreamCommitEmail = "<tgyurci@freemail.hu>";
    private final ObjectId upstreamCommitPredecessor = ObjectId.fromString("867e5f148377fd5a6d96e5aafbdaac132a117a5a");

    /* URL of upstream (GitHub) repository. */
    private final String upstreamRepoURL = "https://github.com/jenkinsci/git-client-plugin";

    /* URL of GitHub test repository with large file support. */
    private final String lfsTestRepoURL = "https://github.com/MarkEWaite/jenkins-pipeline-utils";

    /* Instance of object under test */
    private GitClient gitClient = null;

    /* Instance of object of another test class*/
    private final CliGitAPIImplTest cliGitAPIImplTest = new CliGitAPIImplTest();

    /* Capabilities of command line git in current environment */
    private boolean CLI_GIT_HAS_GIT_LFS;
    private boolean CLI_GIT_HAS_GIT_LFS_CONFIGURED;

    /* Credential ID for tests of large file support */
    private static StandardCredentials lfsCredential = null;

    @TempDir(cleanup = CleanupMode.NEVER)
    private File tempFolder;

    private File repoRoot = null;

    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;
    }

    /**
     * Mirror the git-client-plugin repo so that the tests have a reasonable and
     * repeatable set of commits, tags, and branches.
     */
    private static File mirrorParent = null;

    @BeforeAll
    static void mirrorUpstreamRepositoryLocally() throws Exception {
        File currentDir = new File(".");
        CliGitAPIImpl currentDirCliGit = (CliGitAPIImpl) Git.with(TaskListener.NULL, new EnvVars())
                .in(currentDir)
                .using("git")
                .getClient();
        boolean currentDirIsShallow = currentDirCliGit.isShallowRepository();

        mirrorParent = Files.createTempDirectory("mirror").toFile();
        /* Clone mirror into mirrorParent/git-client-plugin.git as a bare repo */
        CliGitCommand mirrorParentGitCmd = new CliGitCommand(Git.with(TaskListener.NULL, new EnvVars())
                .in(mirrorParent)
                .using("git")
                .getClient());
        if (currentDirIsShallow) {
            mirrorParentGitCmd.run(
                    "clone",
                    // "--reference", currentDir.getAbsolutePath(), // --reference of shallow repo fails
                    "--mirror",
                    "https://github.com/jenkinsci/git-client-plugin");
        } else {
            mirrorParentGitCmd.run(
                    "clone",
                    "--reference",
                    currentDir.getAbsolutePath(),
                    "--mirror",
                    "https://github.com/jenkinsci/git-client-plugin");
        }
        File mirrorDir = new File(mirrorParent, "git-client-plugin.git");
        assertTrue(mirrorDir.exists(), "Git client mirror repo not created at " + mirrorDir.getAbsolutePath());
        GitClient mirrorClient = Git.with(TaskListener.NULL, new EnvVars())
                .in(mirrorDir)
                .using("git")
                .getClient();
        assertThat(mirrorClient.getTagNames("git-client-1.6.3"), contains("git-client-1.6.3"));

        /* Clone from bare mirrorParent/git-client-plugin.git to working mirrorParent/git-client-plugin */
        mirrorParentGitCmd.run("clone", mirrorDir.getAbsolutePath());
        srcRepoDir = new File(mirrorParent, "git-client-plugin");
    }

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

    /**
     * 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, new EnvVars())
                .in(configDir)
                .using("git")
                .getClient());
        String[] output = getDefaultBranchNameCmd.runWithoutAssert("config", "--get", "init.defaultBranch");
        for (String s : output) {
            String result = s.trim();
            if (!result.isEmpty()) {
                defaultBranchName = result;
            }
        }
        assertTrue(configDir.delete(), "Failed to delete temporary readGitConfig directory");
    }

    @BeforeAll
    static void addLocalGitConfigChanges() throws Exception {
        File currentDir = new File(".");
        CliGitAPIImpl currentDirCliGit = (CliGitAPIImpl) Git.with(TaskListener.NULL, new EnvVars())
                .in(currentDir)
                .using("git")
                .getClient();
        CliGitCommand gitCmd = new CliGitCommand(currentDirCliGit);
        gitCmd.initializeRepository();
    }

    @AfterAll
    static void removeLocalGitConfigChanges() throws Exception {
        File currentDir = new File(".");
        CliGitAPIImpl currentDirCliGit = (CliGitAPIImpl) Git.with(TaskListener.NULL, new EnvVars())
                .in(currentDir)
                .using("git")
                .getClient();
        CliGitCommand gitCmd = new CliGitCommand(currentDirCliGit);
        gitCmd.removeRepositorySettings();
    }

    @AfterAll
    static void removeMirrorAndSrcRepos() {
        try {
            FileUtils.deleteDirectory(mirrorParent);
        } catch (IOException ioe) {
            System.out.println("Ignored cleanup failure on " + mirrorParent);
        }
    }

    @BeforeEach
    void setGitClient() throws Exception {
        repoRoot = newFolder(tempFolder, "junit-" + System.nanoTime());
        gitClient = Git.with(TaskListener.NULL, new EnvVars())
                .in(repoRoot)
                .using(gitImplName)
                .getClient();
        File gitDir = gitClient.withRepository((repo, channel) -> repo.getDirectory());
        assertFalse(gitDir.isDirectory(), "Already found " + gitDir);
        gitClient.init_().workspace(repoRoot.getAbsolutePath()).execute();
        assertTrue(gitDir.isDirectory(), "Missing " + gitDir);
        gitClient.setRemoteUrl("origin", srcRepoDir.getAbsolutePath());
        CliGitCommand gitCmd = new CliGitCommand(gitClient);
        gitCmd.initializeRepository("Vojtěch GitClientTest Zweibrücken-Šafařík", "email.from.git.client@example.com");

        this.srcGitClient = Git.with(TaskListener.NULL, new EnvVars())
                .in(srcRepoDir)
                .using(gitImplName)
                .getClient();

        CliGitAPIImpl cliGitClient;
        if (this.srcGitClient instanceof CliGitAPIImpl impl) {
            cliGitClient = impl;
        } else {
            cliGitClient = (CliGitAPIImpl) Git.with(TaskListener.NULL, new EnvVars())
                    .in(srcRepoDir)
                    .using("git")
                    .getClient();
        }

        boolean gitLFSExists;
        try {
            // If git-lfs is installed then the version string should look like this:
            // git-lfs/1.5.6 (GitHub; linux amd64; go 1.7.4)
            String lfsVersionOutput =
                    cliGitClient.launchCommand("lfs", "version").trim();
            gitLFSExists = lfsVersionOutput.startsWith("git-lfs");
            // Avoid test failures on ci.jenkins.io agents by calling `git lfs install`
            // Intentionally ignores the return value, assumes that failure will throw an exception
            // and disable the git LFS tests
            cliGitClient.launchCommand("lfs", "install");
        } catch (GitException exception) {
            // This is expected when git-lfs is not installed.
            gitLFSExists = false;
        }
        CLI_GIT_HAS_GIT_LFS = gitLFSExists;

        boolean gitLFSConfigured;
        try {
            // If git-lfs is configured then the smudge filter will not be empty
            gitLFSConfigured =
                    cliGitClient.launchCommand("config", "filter.lfs.smudge").contains("git-lfs");
        } catch (GitException exception) {
            // This is expected when git-lfs is not installed.
            gitLFSConfigured = false;
        }
        CLI_GIT_HAS_GIT_LFS_CONFIGURED = gitLFSConfigured;
    }

    /**
     * Allow local git clones to use the file:// protocol by setting
     * protocol.file.allow=always on the git command line of the
     * GitClient argument that is passed.
     * <p>
     * Command line git 2.38.1 and patches to earlier versions
     * disallow local git submodule cloning with the file:// protocol.
     * The change resolves a security issue but that security issue is
     * not a threat to these tests.
     */
    private void allowFileProtocol(GitClient client) {
        if (client instanceof CliGitAPIImpl cliGit) {
            cliGit.allowFileProtocol();
        }
    }

    private static final String COMMITTED_ONE_TEXT_FILE = "Committed one text file";

    private ObjectId commitOneFile() throws Exception {
        return commitOneFile(COMMITTED_ONE_TEXT_FILE);
    }

    private ObjectId commitOneFile(final String commitMessage) throws Exception {
        final String content = "A random UUID: %s\n".formatted(UUID.randomUUID());
        return commitFile("One-File.txt", content, commitMessage);
    }

    private ObjectId commitFile(final String path, final String content, final String commitMessage) throws Exception {
        createFile(path, content);
        gitClient.add(path);
        gitClient.commit(commitMessage);
        List<ObjectId> headList = gitClient.revList(Constants.HEAD);
        assertThat(headList.size(), is(greaterThan(0)));
        return headList.get(0);
    }

    private void createFile(String path, String content) throws Exception {
        File aFile = new File(repoRoot, path);
        File parentDir = aFile.getParentFile();
        if (parentDir != null) {
            parentDir.mkdirs();
        }
        try (PrintWriter writer = new PrintWriter(aFile, StandardCharsets.UTF_8)) {
            writer.printf(content);
        }
    }

    private final Random random = new Random();

    private String randomName() {
        final String[] names = {
            "Cinda Bückmaster",
            "Miloš Obrenović",
            "Vojtěch Šafařík",
            "Pfalzgraf Wolfgang von Zweibrücken",
            "Johann Friedrich Konrad Carl Eduard Horst Arnold Matthias Prinz von Sachsen-Meiningen Herzog zu Sachsen",
            "Al-Mu'tamid",
            "Øresund Bridge",
            "Caterina œil Perrault",
            "Kesha Ríckel",
            "Shawnda Bœlter",
            "Hans Gǿr",
            "Thu Null"
        };
        return names[random.nextInt(names.length)];
    }

    private String randomEmail(String name) {
        return name.replaceAll(" ", ".") + "@middle.earth";
    }

    /**
     * Changelog was formatted on word boundary prior to
     * 72 characters with git client plugin 2.0+ when using CLI git.
     * Was not truncated by git client plugin using JGit (And Apache version).
     * Rely on caller to truncate first line if desired.
     * Matching change will be included in git plugin 4.0.0
     * to retain existing truncation behavior.
     */
    @Test
    @Issue("JENKINS-29977")
    void testChangelogVeryLong() throws Exception {

        final String gitMessage = """
                Uno Dos Tres Cuatro Cinco Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut \
                posuere tellus eu efficitur tristique. In iaculis neque in dolor vulputate\
                sollicitudin eget a quam. Donec finibus sapien quis lectus euismod facilisis. Integer\
                massa purus, scelerisque id iaculis ut, blandit vitae velit. Pellentesque lobortis\
                aliquet felis, vel laoreet ipsum tincidunt at. Mauris tellus est, cursus vitae ex\
                eget, venenatis auctor eros. Sed sagittis porta odio. Donec ut interdum massa. Aliquam\
                sagittis, mi sit amet sollicitudin elementum, velit quam eleifend nisl, in rhoncus\
                felis nibh eu nibh. Class aptent taciti sociosqu ad litora torquent per conubia \
                nostra, per inceptos himenaeos.
                seis

                asfasfasfasf
                """;
        final String content = "A random UUID: %s\n".formatted(UUID.randomUUID());
        ObjectId message = commitFile("One-File.txt", content, gitMessage);

        ChangelogCommand changelog = gitClient.changelog();
        StringWriter changelogStringWriter = new StringWriter();
        changelog.includes(message).to(changelogStringWriter).execute();
        assertThat(changelogStringWriter.toString(), containsString("Ut posuere"));
        assertThat(changelogStringWriter.toString(), containsString("conubia nostra"));
    }

    // Diagnostics of ChangelogCommand were insufficient
    @Test
    @Issue("JENKINS-39832")
    void testChangelogExceptionMessage() throws Exception {
        final ObjectId commitA = commitOneFile();
        ChangelogCommand changelog = gitClient.changelog();
        StringWriter changelogStringWriter = new StringWriter();
        changelog.includes(commitA).to(changelogStringWriter).execute();
        assertThat(changelogStringWriter.toString(), containsString(COMMITTED_ONE_TEXT_FILE));

        final String missingSHA1 = "ca11ab1edeededacecadebadebeaddeadcedeade";

        // Confirm includes exception is as expected
        changelog = gitClient.changelog();
        changelogStringWriter = new StringWriter();
        try {
            changelog.includes(missingSHA1).to(changelogStringWriter).execute();
            fail("Did not throw expected exception");
        } catch (GitException ge) {
            // Check that directory and SHA1 are included in exception message
            assertThat(ge.getMessage(), containsString(missingSHA1));
            assertThat(ge.getMessage(), containsString(" in " + repoRoot.getAbsolutePath()));
        }

        // Confirm excludes exception is as expected
        changelog = gitClient.changelog();
        changelogStringWriter = new StringWriter();
        try {
            changelog.excludes(missingSHA1).to(changelogStringWriter).execute();
            fail("Did not throw expected exception");
        } catch (GitException ge) {
            // Check that directory and SHA1 are included in exception message
            assertThat(ge.getMessage(), containsString(missingSHA1));
            assertThat(ge.getMessage(), containsString(" in " + repoRoot.getAbsolutePath()));
        }

        final ObjectId missingObject = ObjectId.fromString(missingSHA1);

        // Confirm includes exception is as expected
        changelog = gitClient.changelog();
        changelogStringWriter = new StringWriter();
        try {
            changelog.includes(missingObject).to(changelogStringWriter).execute();
            fail("Did not throw expected exception");
        } catch (GitException ge) {
            // Check that directory and SHA1 are included in exception message
            assertThat(ge.getMessage(), containsString(missingSHA1));
            assertThat(ge.getMessage(), containsString(" in " + repoRoot.getAbsolutePath()));
        }

        // Confirm excludes exception is as expected
        changelog = gitClient.changelog();
        changelogStringWriter = new StringWriter();
        try {
            changelog.excludes(missingObject).to(changelogStringWriter).execute();
            fail("Did not throw expected exception");
        } catch (GitException ge) {
            // Check that directory and SHA1 are included in exception message
            assertThat(ge.getMessage(), containsString(missingSHA1));
            assertThat(ge.getMessage(), containsString(" in " + repoRoot.getAbsolutePath()));
        }
    }

    @Test
    void testNullChangelogDestinationIncludes() throws Exception {
        final ObjectId commitA = commitOneFile();
        ChangelogCommand changelog = gitClient.changelog();
        changelog.includes(commitA);
        assertThrows(IllegalStateException.class, changelog::execute);
    }

    @Test
    void testNullChangelogDestinationExcludes() throws Exception {
        final ObjectId commitA = commitOneFile();
        ChangelogCommand changelog = gitClient.changelog();
        changelog.excludes(commitA);
        assertThrows(IllegalStateException.class, changelog::execute);
    }

    @Test
    @Issue("JENKINS-43198")
    void testCleanSubdirGitignore() throws Exception {
        final String filename1 = "this_is/not_ok/more/subdirs/file.txt";
        final String filename2 = "this_is_also/not_ok_either/more/subdirs/file.txt";
        commitFile(".gitignore", "/this_is/not_ok\n/this_is_also/not_ok_either\n", "set up gitignore");
        createFile(filename1, "hi there");
        createFile(filename2, "hi there");
        assertFileInWorkingDir(gitClient, filename1);
        assertFileInWorkingDir(gitClient, filename2);
        gitClient.clean();
        assertDirNotInWorkingDir(gitClient, "this_is");
        assertDirNotInWorkingDir(gitClient, "this_is_also");
    }

    @Test
    @Issue("JENKINS-37794")
    void tagWithSlashes() throws Exception {
        commitOneFile();
        gitClient.tag("has/a/slash", "This tag has a slash ('/')");
        assertThat(gitClient.getTagMessage("has/a/slash"), is("This tag has a slash ('/')"));
        assertThat(gitClient.getTagNames(null), hasItem("has/a/slash"));
    }

    private void assertLogContains(ObjectId commitA, ObjectId commitB, String prefix, String expected)
            throws Exception {
        boolean found = false;
        StringBuilder builder = new StringBuilder();
        for (String revisionMessage : gitClient.showRevision(commitA, commitB)) {
            builder.append(revisionMessage);
            if (revisionMessage.startsWith(prefix)) {
                assertThat(revisionMessage, containsString(expected));
                found = true;
            }
        }
        assertTrue(found, "no " + prefix + ", expected: '" + expected + "' in " + builder);
    }

    private void assertAuthor(ObjectId commitA, ObjectId commitB, String name, String email) throws Exception {
        final String prefix = "author ";
        final String expected = prefix + name + " <" + email + ">";
        assertLogContains(commitA, commitB, prefix, expected);
    }

    private void assertCommitter(ObjectId commitA, ObjectId commitB, String name, String email) throws Exception {
        final String prefix = "committer ";
        final String expected = prefix + name + " <" + email + ">";
        assertLogContains(commitA, commitB, prefix, expected);
    }

    @Test
    void testSetAuthor_String_String() throws Exception {
        final ObjectId commitA = commitOneFile();
        final String name = randomName();
        final String email = randomEmail(name);
        gitClient.setAuthor(name, email);
        final ObjectId commitB = commitOneFile();
        assertAuthor(commitA, commitB, name, email);
    }

    @Test
    void testCommitNotFoundException() {
        assertThrows(
                GitException.class,
                () ->
                        /* Search wrong repository for a commit */
                        assertAuthor(
                                upstreamCommitPredecessor, upstreamCommit, upstreamCommitAuthor, upstreamCommitEmail));
    }

    @Test
    void testSetAuthor_PersonIdent() throws Exception {
        final ObjectId commitA = commitOneFile();
        final String name = randomName();
        final String email = randomEmail(name);
        gitClient.setAuthor(new PersonIdent(name, email));
        final ObjectId commitB = commitOneFile();
        assertAuthor(commitA, commitB, name, email);
    }

    @Test
    void testGetWorkTree() {
        assertThat(gitClient.getWorkTree(), is(new FilePath(repoRoot)));
    }

    @Test
    void testSetCommitter_String_String() throws Exception {
        final ObjectId commitA = commitOneFile();
        final String name = randomName();
        final String email = randomEmail(name);
        gitClient.setCommitter(name, email);
        final ObjectId commitB = commitOneFile();
        assertCommitter(commitA, commitB, name, email);
    }

    @Test
    void testSetCommitter_PersonIdent() throws Exception {
        final ObjectId commitA = commitOneFile();
        final String name = randomName();
        final String email = randomEmail(name);
        gitClient.setAuthor(new PersonIdent(name, email));
        final ObjectId commitB = commitOneFile();
        assertAuthor(commitA, commitB, name, email);
    }

    @Test
    void testGetRepository() {
        File expectedRepo = new File(repoRoot, ".git");
        assertEquals(expectedRepo, gitClient.getRepository().getDirectory());
    }

    @Test
    void testInit() throws Exception {
        File gitDir = gitClient.withRepository((repo, channel) -> repo.getDirectory());
        gitClient.init();
        assertTrue(gitDir.isDirectory(), "init did not create " + gitDir);
    }

    @Test
    void testInit_Bare() throws Exception {
        File repoRootTemp = newFolder(tempFolder, "junit-" + System.nanoTime());
        GitClient gitClientTemp = Git.with(TaskListener.NULL, new EnvVars())
                .in(repoRootTemp)
                .using(gitImplName)
                .getClient();
        File tempDir = gitClientTemp.withRepository((repo, channel) -> repo.getDirectory());
        assertFalse(tempDir.isDirectory(), "Missing");
        gitClientTemp.init_().workspace(repoRootTemp.getPath()).bare(true).execute();
        // Bare git_init contains no working tree file
        tempDir = gitClientTemp.withRepository((repo, channel) -> repo.getWorkTree());
        assertFalse(tempDir.isFile(), ".refs not found");
    }

    @Test
    void testInitFailureWindows() throws Exception {
        if (!isWindows()) {
            return;
        }
        String badDirName = "CON:";
        File badDir = new File(badDirName);
        GitClient badGitClient = Git.with(TaskListener.NULL, new EnvVars())
                .in(badDir)
                .using(gitImplName)
                .getClient();
        Class<? extends Exception> expectedExceptionClass =
                gitImplName.equals("git") ? GitException.class : JGitInternalException.class;
        assertThrows(expectedExceptionClass, () -> badGitClient
                .init_()
                .bare(random.nextBoolean())
                .workspace(badDirName)
                .execute());
    }

    @Test
    void testInitFailureNotWindowsNotSuperUser() throws Exception {
        if (isWindows() || (new File("/").canWrite())) { // Windows or running as root
            return;
        }
        String badDirName = "/this/directory/is/not/accessible";
        File badDir = new File(badDirName);
        GitClient badGitClient = Git.with(TaskListener.NULL, new EnvVars())
                .in(badDir)
                .using(gitImplName)
                .getClient();
        Class<? extends Exception> expectedExceptionClass =
                gitImplName.equals("git") ? GitException.class : JGitInternalException.class;
        assertThrows(expectedExceptionClass, () -> badGitClient
                .init_()
                .bare(random.nextBoolean())
                .workspace(badDirName)
                .execute());
    }

    @Test
    void testAdd() throws Exception {
        final ObjectId commitA = commitOneFile();
        assertNotNull(commitA);
    }

    @Test
    void testCommit_String() throws Exception {
        final ObjectId commitA = commitOneFile();
        final String name = randomName();
        final String email = randomEmail(name);
        gitClient.setAuthor(new PersonIdent(name, email));
        final String expectedCommitMessage = "This is commit B's expected message";
        final ObjectId commitB = commitOneFile(expectedCommitMessage);
        assertLogContains(commitA, commitB, "    ", expectedCommitMessage);
    }

    @Test
    void testCommit_3args() throws Exception {
        final ObjectId commitA = commitOneFile();
        final String authorName = randomName();
        final String authorEmail = randomEmail(authorName);
        gitClient.setAuthor(new PersonIdent(authorName, authorEmail));
        final String committerName = randomName();
        final String committerEmail = randomEmail(committerName);
        gitClient.setCommitter(new PersonIdent(committerName, committerEmail));
        final String expectedCommitMessage = "This is commit B's expected message";
        final ObjectId commitB = commitOneFile(expectedCommitMessage);
        assertLogContains(commitA, commitB, "    ", expectedCommitMessage);
        assertAuthor(commitA, commitB, authorName, authorEmail);
        assertCommitter(commitA, commitB, committerName, committerEmail);
    }

    @Test
    void testHasGitRepo() throws Exception {
        assertTrue(gitClient.hasGitRepo(), "Test repo '" + repoRoot.getAbsolutePath() + "' not initialized");
        StringBuilder fileList = new StringBuilder();
        for (File file : srcRepoDir.listFiles()) {
            fileList.append(file.getAbsolutePath());
            fileList.append(" ");
        }
        assertTrue(
                srcGitClient.hasGitRepo(),
                "Source repo '" + srcRepoDir.getAbsolutePath() + "' not initialized, contains " + fileList);

        File emptyDir = newFolder(tempFolder, "junit-" + System.nanoTime());
        assertTrue(emptyDir.exists());
        GitClient emptyClient = Git.with(TaskListener.NULL, new EnvVars())
                .in(emptyDir)
                .using(gitImplName)
                .getClient();
        assertFalse(emptyClient.hasGitRepo(), "Empty repo '" + emptyDir.getAbsolutePath() + "' initialized");
    }

    @Test
    void testHasGitRepoFalse() throws Exception {
        /* Use system temp directory so that no parent directory has a git repository */
        Path tempDir = Files.createTempDirectory("git-client-hasGitRepo");
        GitClient noRepoClient = Git.with(TaskListener.NULL, new EnvVars())
                .in(tempDir.toFile())
                .using(gitImplName)
                .getClient();
        assertFalse(noRepoClient.hasGitRepo(), "New empty temp dir has a git repo(1)");
        assertFalse(noRepoClient.hasGitRepo(false), "New empty temp dir has a git repo(2)");
        assertFalse(noRepoClient.hasGitRepo(true), "New empty temp dir has a git repo(3)");
        tempDir.toFile().delete(); // Remove the temporary directory
    }

    @Issue("JENKINS-38699")
    @Test
    void testHasGitRepoNestedDir() throws Exception {
        File childDir = newFolder(tempFolder, "parentDir", "childDir-" + System.nanoTime());
        File parentDir = childDir.getParentFile();

        GitClient parentDirClient = Git.with(TaskListener.NULL, new EnvVars())
                .in(parentDir)
                .using(gitImplName)
                .getClient();
        assertFalse(parentDirClient.hasGitRepo(), "Unexpected has git repo before init(1)");
        assertFalse(parentDirClient.hasGitRepo(true), "Unexpected has git repo before init(2)");
        assertFalse(parentDirClient.hasGitRepo(false), "Unexpected has git repo before init(3)");

        parentDirClient.init();
        assertTrue(parentDirClient.hasGitRepo(), "Missing git repo after init(1)");
        assertTrue(parentDirClient.hasGitRepo(true), "Missing git repo after init(2)");
        assertTrue(parentDirClient.hasGitRepo(false), "Missing git repo after init(3)");

        GitClient childDirClient = Git.with(TaskListener.NULL, new EnvVars())
                .in(childDir)
                .using(gitImplName)
                .getClient();
        assertFalse(childDirClient.hasGitRepo(), "Unexpected has child git repo before child init(1)");
        assertFalse(childDirClient.hasGitRepo(true), "Unexpected has child git repo before child init(2)");
        assertFalse(childDirClient.hasGitRepo(false), "Unexpected has child git repo before child init(3)");

        File childGitDir = new File(childDir, ".git");
        boolean dirCreated = childGitDir.mkdir();
        assertTrue(dirCreated, "Failed to create empty .git dir in childDir");
        if (gitImplName.equals("git")) {
            // JENKINS-38699 - if an empty .git directory exists, CLI git searches upwards to perform operations
            assertTrue(childDirClient.hasGitRepo(), "Missing parent git repo before child init(1)");
        } else {
            // JENKINS-38699 - if an empty .git directory exists, JGit does NOT search upwards to perform operations
            assertFalse(
                    childDirClient.hasGitRepo(), "Unexpected parent git repo detected by JGit before child init(1)");
        }
