package org.jenkinsci.plugins.gitclient;

import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.commons.lang3.StringUtils.removeStart;
import static org.eclipse.jgit.api.ResetCommand.ResetType.HARD;
import static org.eclipse.jgit.api.ResetCommand.ResetType.MIXED;
import static org.eclipse.jgit.lib.Constants.HEAD;
import static org.eclipse.jgit.lib.Constants.R_HEADS;
import static org.eclipse.jgit.lib.Constants.R_REMOTES;
import static org.eclipse.jgit.lib.Constants.R_TAGS;
import static org.eclipse.jgit.lib.Constants.typeString;
import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.OK;
import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.UP_TO_DATE;
import static org.jenkinsci.plugins.gitclient.CliGitAPIImpl.TIMEOUT;
import static org.jenkinsci.plugins.gitclient.CliGitAPIImpl.TIMEOUT_LOG_PREFIX;

import com.cloudbees.jenkins.plugins.sshcredentials.SSHUserPrivateKey;
import com.cloudbees.plugins.credentials.common.StandardCredentials;
import com.cloudbees.plugins.credentials.common.StandardUsernameCredentials;
import com.cloudbees.plugins.credentials.common.UsernameCredentials;
import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import hudson.FilePath;
import hudson.Util;
import hudson.model.TaskListener;
import hudson.plugins.git.Branch;
import hudson.plugins.git.GitException;
import hudson.plugins.git.GitLockFailedException;
import hudson.plugins.git.GitObject;
import hudson.plugins.git.IndexEntry;
import hudson.plugins.git.Revision;
import hudson.util.Secret;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.Serial;
import java.io.StringWriter;
import java.io.Writer;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Predicate;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import jenkins.util.SystemProperties;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.SystemUtils;
import org.apache.commons.lang3.time.FastDateFormat;
import org.apache.sshd.common.util.security.SecurityUtils;
import org.eclipse.jgit.api.AddNoteCommand;
import org.eclipse.jgit.api.CommitCommand;
import org.eclipse.jgit.api.CreateBranchCommand.SetupUpstreamMode;
import org.eclipse.jgit.api.FetchCommand;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.ListBranchCommand;
import org.eclipse.jgit.api.LsRemoteCommand;
import org.eclipse.jgit.api.MergeCommand.FastForwardMode;
import org.eclipse.jgit.api.MergeResult;
import org.eclipse.jgit.api.RebaseCommand.Operation;
import org.eclipse.jgit.api.RebaseResult;
import org.eclipse.jgit.api.ResetCommand;
import org.eclipse.jgit.api.ShowNoteCommand;
import org.eclipse.jgit.api.SubmoduleUpdateCommand;
import org.eclipse.jgit.api.TransportCommand;
import org.eclipse.jgit.api.TransportConfigCallback;
import org.eclipse.jgit.api.errors.CanceledException;
import org.eclipse.jgit.api.errors.CheckoutConflictException;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.api.errors.JGitInternalException;
import org.eclipse.jgit.diff.DiffEntry;
import org.eclipse.jgit.diff.DiffEntry.ChangeType;
import org.eclipse.jgit.diff.RenameDetector;
import org.eclipse.jgit.errors.InvalidPatternException;
import org.eclipse.jgit.errors.LockFailedException;
import org.eclipse.jgit.errors.NotSupportedException;
import org.eclipse.jgit.errors.TransportException;
import org.eclipse.jgit.fnmatch.FileNameMatcher;
import org.eclipse.jgit.internal.storage.file.FileRepository;
import org.eclipse.jgit.internal.transport.ssh.OpenSshConfigFile;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectLoader;
import org.eclipse.jgit.lib.ObjectReader;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.RefDatabase;
import org.eclipse.jgit.lib.RefUpdate;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.lib.RepositoryBuilder;
import org.eclipse.jgit.lib.StoredConfig;
import org.eclipse.jgit.merge.MergeStrategy;
import org.eclipse.jgit.notes.Note;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevFlag;
import org.eclipse.jgit.revwalk.RevFlagSet;
import org.eclipse.jgit.revwalk.RevObject;
import org.eclipse.jgit.revwalk.RevSort;
import org.eclipse.jgit.revwalk.RevTree;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.revwalk.filter.MaxCountRevFilter;
import org.eclipse.jgit.revwalk.filter.RevFilter;
import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
import org.eclipse.jgit.submodule.SubmoduleWalk;
import org.eclipse.jgit.transport.CredentialsProvider;
import org.eclipse.jgit.transport.FetchConnection;
import org.eclipse.jgit.transport.PushResult;
import org.eclipse.jgit.transport.RefSpec;
import org.eclipse.jgit.transport.RemoteConfig;
import org.eclipse.jgit.transport.RemoteRefUpdate;
import org.eclipse.jgit.transport.SshConstants;
import org.eclipse.jgit.transport.SshTransport;
import org.eclipse.jgit.transport.TagOpt;
import org.eclipse.jgit.transport.Transport;
import org.eclipse.jgit.transport.TransportHttp;
import org.eclipse.jgit.transport.URIish;
import org.eclipse.jgit.transport.sshd.SshdSessionFactory;
import org.eclipse.jgit.transport.sshd.SshdSessionFactoryBuilder;
import org.eclipse.jgit.treewalk.TreeWalk;
import org.eclipse.jgit.treewalk.filter.TreeFilter;
import org.jenkinsci.plugins.gitclient.jgit.PreemptiveAuthHttpClientConnectionFactory;
import org.jenkinsci.plugins.gitclient.jgit.SmartCredentialsProvider;
import org.jenkinsci.plugins.gitclient.verifier.HostKeyVerifierFactory;

/**
 * GitClient pure Java implementation using JGit.
 * Goal is to eventually get a full java implementation for GitClient
 * <b>
 * For internal use only, don't use directly. See {@link org.jenkinsci.plugins.gitclient.Git}
 * </b>
 *
 * @author <a href="mailto:nicolas.deloof@gmail.com">Nicolas De Loof</a>
 * @author Kohsuke Kawaguchi
 */
public class JGitAPIImpl extends LegacyCompatibleGitAPIImpl {
    @Serial
    private static final long serialVersionUID = 1L;

    private static final Logger LOGGER = Logger.getLogger(JGitAPIImpl.class.getName());

    private final TaskListener listener;
    private PersonIdent author, committer;

    private final HostKeyVerifierFactory hostKeyVerifierFactory;
    private transient CredentialsProvider provider;

    public static final String SSH_CONFIG_PATH = JGitAPIImpl.class + ".sshConfigPath";

    JGitAPIImpl(File workspace, TaskListener listener) {
        /* If workspace is null, then default to current directory to match
         * CliGitAPIImpl behavior */
        this(workspace, listener, (HostKeyVerifierFactory) null);
    }

    @Deprecated
    JGitAPIImpl(
            File workspace,
            TaskListener listener,
            final PreemptiveAuthHttpClientConnectionFactory httpConnectionFactory) {
        this(workspace, listener, httpConnectionFactory, null);
    }

    @Deprecated
    JGitAPIImpl(
            File workspace,
            TaskListener listener,
            final PreemptiveAuthHttpClientConnectionFactory httpConnectionFactory,
            HostKeyVerifierFactory hostKeyFactory) {
        /* If workspace is null, then default to current directory to match
         * CliGitAPIImpl behavior */
        super(workspace == null ? new File(".") : workspace, hostKeyFactory);
        this.listener = listener;
        hostKeyVerifierFactory = hostKeyFactory;
    }

    JGitAPIImpl(File workspace, TaskListener listener, HostKeyVerifierFactory hostKeyFactory) {
        /* If workspace is null, then default to current directory to match
         * CliGitAPIImpl behavior */
        super(workspace == null ? new File(".") : workspace, hostKeyFactory);
        this.listener = listener;
        hostKeyVerifierFactory = hostKeyFactory;
    }

    public SshdSessionFactory buildSshdSessionFactory(@NonNull final HostKeyVerifierFactory hostKeyVerifierFactory) {
        if (Files.notExists(hostKeyVerifierFactory.getKnownHostsFile().toPath())) {
            try {
                Files.createDirectories(hostKeyVerifierFactory
                        .getKnownHostsFile()
                        .getParentFile()
                        .toPath());
                Files.createFile(hostKeyVerifierFactory.getKnownHostsFile().toPath());
            } catch (IOException e) {
                LOGGER.log(Level.SEVERE, "could not create known hosts file", e);
            }
        }

        SmartCredentialsProvider smartCredentialsProvider = getProvider();
        // credentials have been added for the target repo only so we assume we have only the good one here
        Optional<SSHUserPrivateKey> sshUserPrivateKey = smartCredentialsProvider.getCredentials().values().stream()
                .filter(standardCredentials -> standardCredentials instanceof SSHUserPrivateKey)
                .map(SSHUserPrivateKey.class::cast)
                .findFirst();

        // if no ssh key,  username can be from StandardUsernameCredentials but not ssh
        String user = sshUserPrivateKey
                .map(UsernameCredentials::getUsername)
                .orElseGet(() -> smartCredentialsProvider.getCredentials().values().stream()
                        .filter(standardCredentials -> standardCredentials instanceof StandardUsernameCredentials)
                        .map(StandardUsernameCredentials.class::cast)
                        .findFirst()
                        .map(UsernameCredentials::getUsername)
                        .orElse(null));

        SshdSessionFactoryBuilder builder = new SshdSessionFactoryBuilder()
                .setHomeDirectory(SystemUtils.getUserHome())
                .setServerKeyDatabase(
                        (file, file2) -> hostKeyVerifierFactory.forJGit(null).getServerKeyDatabase())
                .setSshDirectory(hostKeyVerifierFactory.getKnownHostsFile().getParentFile())
                .setConfigStoreFactory((homeDir, configFile, localUserName) -> {
                    String configFilePath = SystemProperties.getString(SSH_CONFIG_PATH);
                    if (configFilePath != null) {
                        Path path = Path.of(configFilePath);
                        homeDir = path.toFile().getParentFile();
                        configFile = path.toFile();
                    }
                    return new JenkinsOpenSshConfigFile(homeDir, configFile, user);
                })
                .setDefaultKeysProvider(file -> {
                    if (sshUserPrivateKey.isPresent()) {
                        try {
                            String keys = String.join(
                                    System.lineSeparator(),
                                    sshUserPrivateKey.get().getPrivateKeys());
                            return SecurityUtils.loadKeyPairIdentities(
                                    null,
                                    () -> "key",
                                    IOUtils.toInputStream(keys, StandardCharsets.UTF_8),
                                    (session, resourceKey, retryIndex) -> Optional.ofNullable(
                                                    sshUserPrivateKey.get().getPassphrase())
                                            .map(Secret::getPlainText)
                                            .orElse(null));
                        } catch (IOException | GeneralSecurityException e) {
                            throw new RuntimeException(e.getMessage(), e);
                        }
                    }
                    return Collections.emptyList();
                })
                .setPreferredAuthentications("publickey,password");

        return builder.build(null);
    }

    private static class JenkinsOpenSshConfigFile extends OpenSshConfigFile {

        private String userName;

        public JenkinsOpenSshConfigFile(File home, File config, String userName) {
            super(home, config, userName);
            this.userName = userName;
        }

        @Override
        public HostEntry lookup(String hostName, int port, String userName) {
            HostEntry hostEntry = super.lookup(hostName, port, userName);
            // forcing username here otherwise we end up with SshSessionFactory#getLocalUserName which is
            // current user
            Optional.ofNullable(this.userName).ifPresent(s -> hostEntry.setValue(SshConstants.USER, s));
            return hostEntry;
        }

        @Override
        public String getLocalUserName() {
            return this.userName;
        }
    }

    /**
     * clearCredentials.
     */
    @Override
    public void clearCredentials() {
        asSmartCredentialsProvider().clearCredentials();
    }

    /** {@inheritDoc} */
    @Override
    public void addCredentials(String url, StandardCredentials credentials) {
        asSmartCredentialsProvider().addCredentials(url, credentials);
    }

    /** {@inheritDoc} */
    @Override
    public void addDefaultCredentials(StandardCredentials credentials) {
        asSmartCredentialsProvider().addDefaultCredentials(credentials);
    }

    private synchronized SmartCredentialsProvider asSmartCredentialsProvider() {
        if (!(provider instanceof SmartCredentialsProvider)) {
            provider = new SmartCredentialsProvider(listener);
        }
        return (SmartCredentialsProvider) provider;
    }

    /**
     * setCredentialsProvider.
     *
     * @param prov a {@link org.eclipse.jgit.transport.CredentialsProvider} object.
     */
    public synchronized void setCredentialsProvider(CredentialsProvider prov) {
        provider = prov;
    }

    public SmartCredentialsProvider getProvider() {
        return asSmartCredentialsProvider();
    }

    /** {@inheritDoc} */
    @Override
    public GitClient subGit(String subdir) {
        return new JGitAPIImpl(new File(workspace, subdir), listener);
    }

    /** {@inheritDoc} */
    @Override
    public void setAuthor(String name, String email) throws GitException {
        author = new PersonIdent(name, email);
    }

    /** {@inheritDoc} */
    @Override
    public void setCommitter(String name, String email) throws GitException {
        committer = new PersonIdent(name, email);
    }

    /**
     * init.
     *
     * @throws hudson.plugins.git.GitException if underlying git operation fails.
     * @throws java.lang.InterruptedException if interrupted.
     */
    @Override
    public void init() throws GitException, InterruptedException {
        init_().workspace(workspace.getAbsolutePath()).execute();
    }

    private void doInit(String workspace, boolean bare) throws GitException {
        try {
            Git.init().setBare(bare).setDirectory(new File(workspace)).call();
        } catch (GitAPIException e) {
            throw new GitException(e);
        }
    }

    /**
     * checkout.
     *
     * @return a {@link org.jenkinsci.plugins.gitclient.CheckoutCommand} object.
     */
    @Override
    public CheckoutCommand checkout() {
        return new CheckoutCommand() {
            private String ref;
            private String branch;
            private boolean deleteBranch;
            private List<String> sparseCheckoutPaths = Collections.emptyList();

            @Override
            public CheckoutCommand ref(String ref) {
                this.ref = ref;
                return this;
            }

            @Override
            public CheckoutCommand branch(String branch) {
                this.branch = branch;
                return this;
            }

            @Override
            public CheckoutCommand deleteBranchIfExist(boolean deleteBranch) {
                this.deleteBranch = deleteBranch;
                return this;
            }

            @Override
            public CheckoutCommand sparseCheckoutPaths(List<String> sparseCheckoutPaths) {
                this.sparseCheckoutPaths = sparseCheckoutPaths == null ? Collections.emptyList() : sparseCheckoutPaths;
                return this;
            }

            @Override
            public CheckoutCommand timeout(Integer timeout) {
                // noop in jgit
                return this;
            }

            private CheckoutCommand lfsCheckoutIsNotSupported() {
                listener.getLogger().println("[WARNING] JGit doesn't support LFS checkout. This flag is ignored.");
                return this;
            }

            @Override
            public CheckoutCommand lfsRemote(String lfsRemote) {
                return lfsCheckoutIsNotSupported();
            }

            @Override
            public CheckoutCommand lfsCredentials(StandardCredentials lfsCredentials) {
                return lfsCheckoutIsNotSupported();
            }

            @Override
            public void execute() throws GitException {

                if (!sparseCheckoutPaths.isEmpty()) {
                    listener.getLogger().println("[ERROR] JGit doesn't support sparse checkout.");
                    throw new UnsupportedOperationException("not implemented yet");
                }

                if (branch == null) {
                    doCheckoutWithResetAndRetry(ref);
                } else if (deleteBranch) {
                    doCheckoutWithResetAndRetryAndCleanBranch(branch, ref);
                } else {
                    doCheckout(ref, branch);
                }
            }
        };
    }

    private void doCheckoutWithResetAndRetry(String ref) throws GitException {
        boolean retried = false;
        while (true) {
            try (Repository repo = getRepository()) {
                try {
                    // force in Jgit is "-B" in Git CLI, meaning no forced switch,
                    // but forces recreation of the branch.
                    // we need to take back all open changes to get the equivalent
                    // of git checkout -f
                    git(repo).reset().setMode(HARD).call();
                } catch (GitAPIException e) {
                    throw new GitException("Could not reset the workspace before checkout of " + ref, e);
                } catch (JGitInternalException e) {
                    if (e.getCause() instanceof LockFailedException) {
                        throw new GitLockFailedException("Could not lock repository. Please try again", e);
                    } else {
                        throw e;
                    }
                }

                if (repo.resolve(ref) != null) {
                    // ref is either an existing reference or a shortcut to a tag or branch (without refs/heads/)
                    git(repo).checkout().setName(ref).setForceRefUpdate(true).call();
                    return;
                }

                List<String> remoteTrackingBranches = new ArrayList<>();
                for (String remote : repo.getRemoteNames()) {
                    // look for exactly ONE remote tracking branch
                    String matchingRemoteBranch = R_REMOTES + remote + "/" + ref;
                    if (repo.exactRef(matchingRemoteBranch) != null) {
                        remoteTrackingBranches.add(matchingRemoteBranch);
                    }
                }

                if (remoteTrackingBranches.isEmpty()) {
                    throw new GitException("No matching revision for " + ref + " found.");
                }
                if (remoteTrackingBranches.size() > 1) {
                    throw new GitException("Found more than one matching remote tracking branches for  " + ref + " : "
                            + remoteTrackingBranches);
                }

                String matchingRemoteBranch = remoteTrackingBranches.get(0);
                listener.getLogger()
                        .format(
                                "[WARNING] Automatically creating a local branch '%s' tracking remote branch '%s'",
                                ref, removeStart(matchingRemoteBranch, R_REMOTES));

                git(repo)
                        .checkout()
                        .setCreateBranch(true)
                        .setName(ref)
                        .setUpstreamMode(SetupUpstreamMode.SET_UPSTREAM)
                        .setStartPoint(matchingRemoteBranch)
                        .call();
                return;
            } catch (CheckoutConflictException e) {
                // "git checkout -f" seems to overwrite local untracked files but git CheckoutCommand doesn't.
                // see the test case GitAPITest.testLocalCheckoutConflict. so in this case we manually
                // clean up the conflicts and try it again

                if (retried) {
                    throw new GitException("Could not checkout " + ref, e);
                }
                retried = true;
                try (Repository repoCleaner = getRepository()) {
                    for (String path : e.getConflictingPaths()) {
                        File conflict = new File(repoCleaner.getWorkTree(), path);
                        if (!conflict.delete() && conflict.exists()) {
                            listener.getLogger().println("[WARNING] conflicting path " + conflict + " not deleted");
                        }
                    }
                }
            } catch (IOException | GitAPIException e) {
                throw new GitException("Could not checkout " + ref, e);
            } catch (JGitInternalException e) {
                if (Pattern.matches("Cannot lock.+", e.getMessage())) {
                    throw new GitLockFailedException("Could not lock repository. Please try again", e);
                } else {
                    throw e;
                }
            }
        }
    }

    private void doCheckout(String ref, String branch) throws GitException {
        try (Repository repo = getRepository()) {
            git(repo)
                    .checkout()
                    .setName(branch)
                    .setCreateBranch(true)
                    .setForceRefUpdate(true)
                    .setStartPoint(ref)
                    .call();
        } catch (GitAPIException e) {
            throw new GitException("Could not checkout " + branch + " with start point " + ref, e);
        }
    }

    private void doCheckoutWithResetAndRetryAndCleanBranch(String branch, String ref) throws GitException {
        try (Repository repo = getRepository()) {
            RefUpdate refUpdate = repo.updateRef(R_HEADS + branch);
            refUpdate.setNewObjectId(repo.resolve(ref));
            switch (refUpdate.forceUpdate()) {
                case NOT_ATTEMPTED:
                case LOCK_FAILURE:
                case REJECTED:
                case REJECTED_CURRENT_BRANCH:
                case IO_FAILURE:
                case RENAMED:
                    throw new GitException("Could not update " + branch + " to " + ref);
            }

            doCheckoutWithResetAndRetry(branch);
        } catch (IOException e) {
            throw new GitException("Could not checkout " + branch + " with start point " + ref, e);
        }
    }

    /** {@inheritDoc} */
    @Override
    public void add(String filePattern) throws GitException {
        try (Repository repo = getRepository()) {
            git(repo).add().addFilepattern(filePattern).call();
        } catch (GitAPIException e) {
            throw new GitException(e);
        }
    }

    private Git git(Repository repo) {
        return Git.wrap(repo);
    }

    /** {@inheritDoc} */
    @Override
    public void commit(String message) throws GitException {
        try (Repository repo = getRepository()) {
            CommitCommand cmd = git(repo).commit().setMessage(message).setAuthor(author);
            if (committer != null) {
                cmd.setCommitter(new PersonIdent(committer, new Date()));
            }
            cmd.call();
        } catch (GitAPIException e) {
            throw new GitException(e);
        }
    }

    /** {@inheritDoc} */
    @Override
    public void branch(String name) throws GitException {
        try (Repository repo = getRepository()) {
            git(repo).branchCreate().setName(name).call();
        } catch (GitAPIException e) {
            throw new GitException(e);
        }
    }

    /** {@inheritDoc} */
    @Override
    public void deleteBranch(String name) throws GitException {
        try (Repository repo = getRepository()) {
            git(repo).branchDelete().setForce(true).setBranchNames(name).call();
        } catch (GitAPIException e) {
            throw new GitException(e);
        }
    }

    /**
     * getBranches.
     *
     * @return a {@link java.util.Set} object.
     * @throws hudson.plugins.git.GitException if underlying git operation fails.
     */
    @Override
    public Set<Branch> getBranches() throws GitException {
        return getBranchesInternal(ListBranchCommand.ListMode.ALL);
    }

    /**
     * getRemoteBranches.
     *
     * @return a {@link java.util.Set} object.
     * @throws hudson.plugins.git.GitException if underlying git operation fails.
     */
    @Override
    public Set<Branch> getRemoteBranches() throws GitException {
        return getBranchesInternal(ListBranchCommand.ListMode.REMOTE);
    }

    public Set<Branch> getBranchesInternal(ListBranchCommand.ListMode mode) throws GitException {
        try (Repository repo = getRepository()) {
            List<Ref> refs = git(repo).branchList().setListMode(mode).call();
            Set<Branch> branches = new HashSet<>(refs.size());
            for (Ref ref : refs) {
                branches.add(new Branch(ref));
            }
            return branches;
        } catch (GitAPIException e) {
            throw new GitException(e);
        }
    }

    /** {@inheritDoc} */
    @Override
    public void tag(String name, String message) throws GitException {
        try (Repository repo = getRepository()) {
            git(repo)
                    .tag()
                    .setName(name)
                    .setMessage(message)
                    .setForceUpdate(true)
                    .call();
        } catch (GitAPIException e) {
            throw new GitException(e);
        }
    }

    /** {@inheritDoc} */
    @Override
    public boolean tagExists(String tagName) throws GitException {
        try (Repository repo = getRepository()) {
            Ref tag = repo.exactRef(R_TAGS + tagName);
            return tag != null;
        } catch (IOException e) {
            throw new GitException(e);
        }
    }

    static final int MAX_TIMEOUT = 1 << 15; // Avoid JGit integer overflow

    private void setTransportTimeout(TransportCommand transport, String operationName, Integer timeoutInMinutes) {
        if (timeoutInMinutes == null) {
            /* Use the default timeout */
            timeoutInMinutes = TIMEOUT;
        }

        /* Range check for timeoutInMinutes */
        if (timeoutInMinutes > MAX_TIMEOUT) {
            listener.getLogger()
                    .println("[WARNING] JGit ignoring timeout " + timeoutInMinutes + " > " + MAX_TIMEOUT + " for "
                            + operationName);
            timeoutInMinutes = MAX_TIMEOUT;
        } else if (timeoutInMinutes < 1) {
            listener.getLogger()
                    .println("[WARNING] JGit ignoring timeout " + timeoutInMinutes + " < 1 for " + operationName);
            timeoutInMinutes = TIMEOUT; /* Use system default timeout */
        }

        /* Convert timeoutInMinutes to timeoutInSeconds */
        int timeoutInSeconds = 10 * 60;
        try {
            timeoutInSeconds = Math.multiplyExact(timeoutInMinutes, 60);
        } catch (ArithmeticException ae) {
            listener.getLogger()
                    .println("[WARNING] JGit ignoring excessive " + timeoutInMinutes + " minute timeout for "
                            + operationName);
        }

        transport.setTimeout(timeoutInSeconds);
        listener.getLogger().println(" > JGit " + operationName + TIMEOUT_LOG_PREFIX + timeoutInMinutes);
    }

    /**
     * Returns true if protocol in url is not supported by Jenkins.
     * @return true if protocol in url is not supported by Jenkins.
     */
    private boolean unsupportedProtocol(String url) {
        // SECURITY-3950 reported that an attacker could enumerate the
        // controller file system with the Amazon S3 protocol in JGit.
        // Command line git does not support amazon-s3 protocol.
        // Command line git is the reference implementation for the plugin.
        // Do not call JGit with amazon-s3 protocol URL.
        // Do not report null URL as unsupported since null was allowed previously
        return url != null && url.startsWith("amazon-s3:");
    }

    private boolean unsupportedProtocol(URIish url) {
        return url != null && unsupportedProtocol(url.toString());
    }

    /**
     * fetch_.
     *
     * @return a {@link org.jenkinsci.plugins.gitclient.FetchCommand} object.
     */
    @Override
    public org.jenkinsci.plugins.gitclient.FetchCommand fetch_() {
        return new org.jenkinsci.plugins.gitclient.FetchCommand() {
            private URIish url;
            private List<RefSpec> refspecs;
            private boolean shallow;
            private boolean shouldPrune = false;
            private Integer timeout;
            private boolean tags = true;
            private Integer depth = 1;

            @Override
            public org.jenkinsci.plugins.gitclient.FetchCommand from(URIish remote, List<RefSpec> refspecs) {
                this.url = remote;
                this.refspecs = refspecs;
                return this;
            }

            @Deprecated
            @Override
            public org.jenkinsci.plugins.gitclient.FetchCommand prune() {
                return prune(true);
            }

            @Override
            public org.jenkinsci.plugins.gitclient.FetchCommand prune(boolean prune) {
                shouldPrune = prune;
                return this;
            }

            @Override
            public org.jenkinsci.plugins.gitclient.FetchCommand shallow(boolean shallow) {
                this.shallow = shallow;
                return this;
            }

            @Override
            public org.jenkinsci.plugins.gitclient.FetchCommand timeout(Integer timeout) {
                this.timeout = timeout;
                return this;
            }

            @Override
            public org.jenkinsci.plugins.gitclient.FetchCommand tags(boolean tags) {
                this.tags = tags;
                return this;
            }

            @Override
            public org.jenkinsci.plugins.gitclient.FetchCommand depth(Integer depth) {
                this.depth = depth;
                return this;
            }

            @Override
            public void execute() throws GitException {
                try (Repository repo = getRepository()) {
                    Git git = git(repo);

                    List<RefSpec> allRefSpecs = new ArrayList<>();
                    if (refspecs != null) {
                        for (RefSpec rs : refspecs) {
                            if (rs != null) {
                                allRefSpecs.add(rs);
                            }
                        }
                    }

                    FetchCommand fetch = git.fetch();
                    fetch.setTagOpt(tags ? TagOpt.FETCH_TAGS : TagOpt.NO_TAGS);
                    /* JGit 4.5 required a work around that the tags refspec had to be passed in addition to setting
                     * the FETCH_TAGS tagOpt.  JGit 4.9.0 fixed that bug.
                     * However, JGit 4.9 and later will not accept an empty refspec.
                     * If the refspec is empty and tag fetch is requested, must add the tags refspec to fetch.
                     */
                    if (allRefSpecs.isEmpty() && tags) {
                        allRefSpecs.add(new RefSpec("+refs/tags/*:refs/tags/*"));
                    }
