jenkins.scm.impl.subversion.SubversionSCMSource.java Source code

Java tutorial

Introduction

Here is the source code for jenkins.scm.impl.subversion.SubversionSCMSource.java

Source

/*
 * The MIT License
 *
 * Copyright (c) 2013, CloudBees, Inc., Stephen Connolly.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */
package jenkins.scm.impl.subversion;

import com.cloudbees.jenkins.plugins.sshcredentials.SSHUserPrivateKey;
import com.cloudbees.plugins.credentials.Credentials;
import com.cloudbees.plugins.credentials.CredentialsMatchers;
import com.cloudbees.plugins.credentials.CredentialsNameProvider;
import com.cloudbees.plugins.credentials.CredentialsProvider;
import com.cloudbees.plugins.credentials.common.StandardCertificateCredentials;
import com.cloudbees.plugins.credentials.common.StandardCredentials;
import com.cloudbees.plugins.credentials.common.StandardListBoxModel;
import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials;
import com.cloudbees.plugins.credentials.domains.DomainRequirement;
import com.cloudbees.plugins.credentials.domains.URIRequirementBuilder;
import edu.umd.cs.findbugs.annotations.CheckForNull;
import edu.umd.cs.findbugs.annotations.NonNull;
import hudson.Extension;
import hudson.Functions;
import hudson.Util;
import hudson.model.Item;
import hudson.model.TaskListener;
import hudson.scm.CredentialsSVNAuthenticationProviderImpl;
import hudson.scm.FilterSVNAuthenticationManager;
import hudson.scm.SubversionRepositoryStatus;
import hudson.scm.SubversionSCM;
import hudson.scm.subversion.SvnHelper;
import hudson.security.ACL;
import hudson.util.EditDistance;
import hudson.util.FormValidation;
import hudson.util.ListBoxModel;
import jenkins.scm.api.SCMHead;
import jenkins.scm.api.SCMHeadObserver;
import jenkins.scm.api.SCMRevision;
import jenkins.scm.api.SCMSource;
import jenkins.scm.api.SCMSourceCriteria;
import jenkins.scm.api.SCMSourceDescriptor;
import jenkins.scm.api.SCMSourceOwner;
import jenkins.scm.api.SCMSourceOwners;
import net.jcip.annotations.GuardedBy;
import org.acegisecurity.Authentication;
import org.acegisecurity.context.SecurityContextHolder;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.IOCase;
import org.apache.commons.lang.StringUtils;
import org.kohsuke.stapler.AncestorInPath;
import org.kohsuke.stapler.DataBoundConstructor;
import org.kohsuke.stapler.QueryParameter;
import org.kohsuke.stapler.StaplerRequest;
import org.tmatesoft.svn.core.SVNDirEntry;
import org.tmatesoft.svn.core.SVNException;
import org.tmatesoft.svn.core.SVNNodeKind;
import org.tmatesoft.svn.core.SVNURL;
import org.tmatesoft.svn.core.auth.ISVNAuthenticationManager;
import org.tmatesoft.svn.core.internal.util.SVNPathUtil;
import org.tmatesoft.svn.core.io.ISVNSession;
import org.tmatesoft.svn.core.io.SVNRepository;
import org.tmatesoft.svn.core.io.SVNRepositoryFactory;
import org.tmatesoft.svn.core.wc.SVNRevision;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
import java.util.Set;
import java.util.SortedMap;
import java.util.SortedSet;
import java.util.StringTokenizer;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
import java.util.regex.Pattern;

/**
 * A {@link SCMSource} for Subversion.
 *
 * @author Stephen Connolly
 */
public class SubversionSCMSource extends SCMSource {

    private static final String DEFAULT_INCLUDES = "trunk,branches/*,tags/*,sandbox/*";

    private static final String DEFAULT_EXCLUDES = "";

    public static final StringListComparator COMPARATOR = new StringListComparator();

    public static final Logger LOGGER = Logger.getLogger(SubversionSCMSource.class.getName());

    private final String remoteBase;

    private final String credentialsId;

    private final String includes;

    private final String excludes;

    @GuardedBy("this")
    private transient String uuid;

    @DataBoundConstructor
    @SuppressWarnings("unused") // by stapler
    public SubversionSCMSource(String id, String remoteBase, String credentialsId, String includes,
            String excludes) {
        super(id);
        this.remoteBase = StringUtils.removeEnd(remoteBase, "/") + "/";
        this.credentialsId = credentialsId;
        this.includes = StringUtils.defaultIfEmpty(includes, DEFAULT_INCLUDES);
        this.excludes = StringUtils.defaultIfEmpty(excludes, DEFAULT_EXCLUDES);
    }

    /**
     * Gets the credentials id.
     *
     * @return the credentials id.
     */
    @SuppressWarnings("unused") // by stapler
    public String getCredentialsId() {
        return credentialsId;
    }

    /**
     * Gets the comma separated list of exclusions.
     *
     * @return the comma separated list of exclusions.
     */
    @SuppressWarnings("unused") // by stapler
    public String getExcludes() {
        return excludes;
    }

    /**
     * Gets the comma separated list of inclusions.
     *
     * @return the comma separated list of inclusions.
     */
    @SuppressWarnings("unused") // by stapler
    public String getIncludes() {
        return includes;
    }

    /**
     * Gets the base SVN URL of the project.
     *
     * @return the base SVN URL of the project.
     */
    @SuppressWarnings("unused") // by stapler
    public String getRemoteBase() {
        return remoteBase;
    }

    @CheckForNull
    public synchronized String getUuid() {
        if (uuid == null) {
            SVNRepositoryView repository = null;
            try {
                SVNURL repoURL = SVNURL.parseURIEncoded(remoteBase);
                repository = openSession(repoURL);
                uuid = repository.getUuid();
            } catch (SVNException e) {
                LOGGER.log(Level.WARNING,
                        "Could not connect to remote repository " + remoteBase + " to determine UUID", e);
            } catch (IOException e) {
                LOGGER.log(Level.WARNING,
                        "Could not connect to remote repository " + remoteBase + " to determine UUID", e);
            } finally {
                closeSession(repository);
            }

        }
        return uuid;
    }

    /**
     * {@inheritDoc}
     */
    @NonNull
    @Override
    protected void retrieve(@NonNull final SCMHeadObserver observer, @NonNull TaskListener listener)
            throws IOException {
        SVNRepositoryView repository = null;
        try {
            listener.getLogger().println("Opening conection to " + remoteBase);
            SVNURL repoURL = SVNURL.parseURIEncoded(remoteBase);
            repository = openSession(repoURL);

            String repoPath = SubversionSCM.DescriptorImpl.getRelativePath(repoURL, repository.getRepository());
            List<String> prefix = Collections.emptyList();
            fetch(listener, repository, -1, repoPath, toPaths(splitCludes(includes)), prefix, prefix,
                    toPaths(splitCludes(excludes)), getCriteria(), observer);
        } catch (SVNException e) {
            e.printStackTrace(listener.error("Could not communicate with Subversion server"));
            throw new IOException(e);
        } finally {
            closeSession(repository);
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    protected SCMRevision retrieve(@NonNull SCMHead head, @NonNull TaskListener listener) throws IOException {
        SVNRepositoryView repository = null;
        try {
            listener.getLogger().println("Opening connection to " + remoteBase);
            SVNURL repoURL = SVNURL.parseURIEncoded(remoteBase);
            repository = openSession(repoURL);
            String repoPath = SubversionSCM.DescriptorImpl.getRelativePath(repoURL, repository.getRepository());
            String path = SVNPathUtil.append(repoPath, head.getName());
            SVNRepositoryView.NodeEntry svnEntry = repository.getNode(path, -1);
            return new SCMRevisionImpl(head, svnEntry.getRevision());
        } catch (SVNException e) {
            throw new IOException(e);
        } finally {
            closeSession(repository);
        }
    }

    private static void closeSession(@CheckForNull SVNRepositoryView repository) {
        if (repository != null) {
            repository.close();
        }
    }

    private SVNRepositoryView openSession(SVNURL repoURL) throws SVNException, IOException {
        return new SVNRepositoryView(repoURL,
                credentialsId == null ? null
                        : CredentialsMatchers.firstOrNull(
                                CredentialsProvider.lookupCredentials(StandardCredentials.class, getOwner(),
                                        ACL.SYSTEM, URIRequirementBuilder.fromUri(repoURL.toString()).build()),
                                CredentialsMatchers.allOf(CredentialsMatchers.withId(credentialsId),
                                        CredentialsMatchers.anyOf(
                                                CredentialsMatchers.instanceOf(StandardCredentials.class),
                                                CredentialsMatchers.instanceOf(SSHUserPrivateKey.class)))));
    }

    void fetch(@NonNull TaskListener listener, @NonNull final SVNRepositoryView repository, long rev,
            @NonNull final String repoPath, @NonNull SortedSet<List<String>> paths, @NonNull List<String> prefix,
            @NonNull List<String> realPath, @NonNull SortedSet<List<String>> excludedPaths,
            @CheckForNull SCMSourceCriteria branchCriteria, @NonNull SCMHeadObserver observer)
            throws IOException, SVNException {
        String svnPath = SVNPathUtil.append(repoPath, StringUtils.join(realPath, '/'));
        assert prefix.size() == realPath.size();
        assert wildcardStartsWith(realPath, prefix);
        SortedMap<List<String>, SortedSet<List<String>>> includePaths = groupPaths(paths, prefix);
        listener.getLogger().println("Checking directory " + svnPath + (rev > -1 ? "@" + rev : "@HEAD"));
        SVNRepositoryView.NodeEntry node = repository.getNode(svnPath, rev);
        if (!SVNNodeKind.DIR.equals(node.getType()) || node.getChildren() == null) {
            return;
        }
        for (Map.Entry<List<String>, SortedSet<List<String>>> entry : includePaths.entrySet()) {
            for (List<String> path : entry.getValue()) {
                String name = path.get(prefix.size());
                SVNRepositoryView.ChildEntry[] children = node.getChildren().clone();
                Arrays.sort(children, new Comparator<SVNRepositoryView.ChildEntry>() {
                    public int compare(SVNRepositoryView.ChildEntry o1, SVNRepositoryView.ChildEntry o2) {
                        long diff = o2.getRevision() - o1.getRevision();
                        return diff < 0 ? -1 : diff > 0 ? 1 : 0;
                    }
                });
                for (final SVNRepositoryView.ChildEntry svnEntry : children) {
                    if (svnEntry.getType() == SVNNodeKind.DIR && isMatch(svnEntry.getName(), name)) {
                        List<String> childPrefix = copyAndAppend(prefix, name);
                        List<String> childRealPath = copyAndAppend(realPath, svnEntry.getName());
                        if (wildcardStartsWith(childRealPath, excludedPaths)) {
                            continue;
                        }
                        if (path.equals(childPrefix)) {
                            final String childPath = StringUtils.join(childRealPath, '/');
                            final String candidateRootPath = SVNPathUtil.append(repoPath, childPath);
                            final long candidateRevision = svnEntry.getRevision();
                            final long lastModified = svnEntry.getLastModified();
                            listener.getLogger().println(
                                    "Checking candidate branch " + candidateRootPath + "@" + candidateRevision);
                            if (branchCriteria == null || branchCriteria.isHead(new SCMSourceCriteria.Probe() {
                                @Override
                                public String name() {
                                    return childPath;
                                }

                                @Override
                                public long lastModified() {
                                    return lastModified;
                                }

                                @Override
                                public boolean exists(@NonNull String path) throws IOException {
                                    try {
                                        return repository.checkPath(SVNPathUtil.append(candidateRootPath, path),
                                                candidateRevision) != SVNNodeKind.NONE;
                                    } catch (SVNException e) {
                                        throw new IOException(e);
                                    }
                                }
                            }, listener)) {
                                listener.getLogger().println("Met criteria");
                                SCMHead head = new SCMHead(childPath);
                                observer.observe(head, new SCMRevisionImpl(head, svnEntry.getRevision()));
                                if (!observer.isObserving()) {
                                    return;
                                }
                            } else {
                                listener.getLogger().println("Does not meet criteria");
                            }
                        } else {
                            fetch(listener, repository, svnEntry.getRevision(), repoPath, paths, childPrefix,
                                    childRealPath, excludedPaths, branchCriteria, observer);
                        }
                    }
                }
            }
        }
    }

    /**
     * Copies a list and appends some more values.
     *
     * @param list   the list.
     * @param values the values to append.
     * @param <T>    the type of values to append.
     * @return the list.
     */
    @NonNull
    private static <T> List<T> copyAndAppend(@NonNull List<T> list, T... values) {
        List<T> childPrefix = new ArrayList<T>(list.size() + values.length);
        childPrefix.addAll(list);
        childPrefix.addAll(Arrays.asList(values));
        return childPrefix;
    }

    /**
     * Groups a set of path segments based on a supplied prefix.
     *
     * @param pathSegments the input path segments.
     * @param prefix       the prefix to group on.
     * @return a map, all keys will {@link #startsWith(java.util.List, java.util.List)} the input prefix and be longer
     *         than the input prefix, all values will {@link #startsWith(java.util.List,
     *         java.util.List)} their corresponding key.
     */
    @NonNull
    static SortedMap<List<String>, SortedSet<List<String>>> groupPaths(
            @NonNull SortedSet<List<String>> pathSegments, @NonNull List<String> prefix) {
        // ensure pre-condition is valid and ensure we are using a copy
        pathSegments = filterPaths(pathSegments, prefix);

        SortedMap<List<String>, SortedSet<List<String>>> result = new TreeMap<List<String>, SortedSet<List<String>>>(
                COMPARATOR);
        while (!pathSegments.isEmpty()) {
            List<String> longestPrefix = null;
            int longestIndex = -1;
            for (List<String> pathSegment : pathSegments) {
                if (longestPrefix == null) {
                    longestPrefix = pathSegment;
                    longestIndex = indexOfNextWildcard(pathSegment, prefix.size());

                } else {
                    int index = indexOfNextWildcard(pathSegment, prefix.size());
                    if (index > longestIndex) {
                        longestPrefix = pathSegment;
                        longestIndex = index;
                    }
                }
            }
            assert longestPrefix != null;
            longestPrefix = new ArrayList<String>(longestPrefix.subList(0, longestIndex));
            SortedSet<List<String>> group = filterPaths(pathSegments, longestPrefix);
            result.put(longestPrefix, group);
            pathSegments.removeAll(group);
        }
        String optimization;
        while (null != (optimization = getOptimizationPoint(result.keySet(), prefix.size()))) {
            List<String> optimizedPrefix = copyAndAppend(prefix, optimization);
            SortedSet<List<String>> optimizedGroup = new TreeSet<List<String>>(COMPARATOR);
            for (Iterator<Map.Entry<List<String>, SortedSet<List<String>>>> iterator = result.entrySet()
                    .iterator(); iterator.hasNext();) {
                Map.Entry<List<String>, SortedSet<List<String>>> entry = iterator.next();
                if (startsWith(entry.getKey(), optimizedPrefix)) {
                    iterator.remove();
                    optimizedGroup.addAll(entry.getValue());
                }
            }
            result.put(optimizedPrefix, optimizedGroup);
        }
        return result;
    }

    /**
     * Returns the string that has multiple matches and can therefore be used to optimize the set of prefixes.
     *
     * @param newPrefixes   the proposed set of prefixes.
     * @param oldPrefixSize the length of the old prefix.
     * @return either a string that when appended to the old prefix will give a prefix with multiple matches in the
     *         supplied set of new prefixes or {@code null} if there is no such string.
     */
    @CheckForNull
    static String getOptimizationPoint(@NonNull Set<List<String>> newPrefixes, int oldPrefixSize) {
        Set<String> set = new HashSet<String>();
        for (List<String> p : newPrefixes) {
            if (p.size() <= oldPrefixSize) {
                continue;
            }
            String value = p.get(oldPrefixSize);
            if (set.contains(value)) {
                return value;
            }
            set.add(value);
        }
        return null;
    }

    /**
     * Returns the index of the next wildcard segment on or after the specified start index.
     *
     * @param pathSegment the path segments.
     * @param startIndex  the first index to check.
     * @return the index with a wildcard or {@code -1} if none exist.
     */
    static int indexOfNextWildcard(@NonNull List<String> pathSegment, int startIndex) {
        int index = startIndex;
        ListIterator<String> i = pathSegment.listIterator(index);
        while (i.hasNext()) {
            String segment = i.next();
            if (segment.indexOf('*') != -1 || segment.indexOf('?') != -1) {
                break;
            }
            index++;
        }
        return index;
    }

    /**
     * Returns {@code true} if and only if the value starts with the supplied prefix.
     *
     * @param value  the value.
     * @param prefix the candidate prefix.
     * @return {@code true} if and only if the value starts with the supplied prefix.
     */
    static boolean startsWith(@NonNull List<String> value, @NonNull List<String> prefix) {
        if (value.size() < prefix.size()) {
            return false;
        }
        ListIterator<String> i1 = value.listIterator();
        ListIterator<String> i2 = prefix.listIterator();
        while (i1.hasNext() && i2.hasNext()) {
            if (!i1.next().equals(i2.next())) {
                return false;
            }
        }
        return true;
    }

    /**
     * Returns {@code true} if and only if the value starts with the supplied prefix.
     *
     * @param value          the value.
     * @param wildcardPrefix the candidate prefix.
     * @return {@code true} if and only if the value starts with the supplied prefix.
     */
    static boolean wildcardStartsWith(@NonNull List<String> value, @NonNull List<String> wildcardPrefix) {
        if (value.size() < wildcardPrefix.size()) {
            return false;
        }
        ListIterator<String> i1 = value.listIterator();
        ListIterator<String> i2 = wildcardPrefix.listIterator();
        while (i1.hasNext() && i2.hasNext()) {
            if (!isMatch(i1.next(), i2.next())) {
                return false;
            }
        }
        return true;
    }

    /**
     * Returns {@code true} if and only if the value starts with any of the supplied prefixes.
     *
     * @param value            the value.
     * @param wildcardPrefixes the candidate prefixes.
     * @return {@code true} if and only if the value starts with any of the supplied prefixes.
     */
    static boolean wildcardStartsWith(@NonNull List<String> value,
            @NonNull Collection<List<String>> wildcardPrefixes) {
        for (List<String> wildcardPrefix : wildcardPrefixes) {
            if (wildcardStartsWith(value, wildcardPrefix)) {
                return true;
            }
        }
        return false;
    }

    /**
     * Filters the set of path segments, retaining only those that start with the supplied prefix.
     *
     * @param pathSegments the set of path segments.
     * @param prefix       the prefix.
     * @return a new set of path segments.
     */
    @NonNull
    static SortedSet<List<String>> filterPaths(@NonNull SortedSet<List<String>> pathSegments,
            @NonNull List<String> prefix) {
        SortedSet<List<String>> result = new TreeSet<List<String>>(COMPARATOR);
        for (List<String> pathSegment : pathSegments) {
            if (startsWith(pathSegment, prefix)) {
                result.add(pathSegment);
            }
        }
        return result;
    }

    /**
     * Splits a set of path strings into a set of lists of path segments.
     *
     * @param pathStrings the set of path strings.
     * @return the set of lists of path segments.
     */
    @NonNull
    static SortedSet<List<String>> toPaths(@NonNull SortedSet<String> pathStrings) {
        SortedSet<List<String>> result = new TreeSet<List<String>>(COMPARATOR);
        for (String clude : pathStrings) {
            result.add(Arrays.asList(clude.split("/")));
        }
        return result;
    }

    /**
     * Split a comma separated set of includes/excludes into a set of strings.
     *
     * @param cludes a comma separated set of includes/excludes.
     * @return a set of strings.
     */
    @NonNull
    static SortedSet<String> splitCludes(@CheckForNull String cludes) {
        TreeSet<String> result = new TreeSet<String>();
        StringTokenizer tokenizer = new StringTokenizer(StringUtils.defaultString(cludes), ",");
        while (tokenizer.hasMoreTokens()) {
            String clude = tokenizer.nextToken().trim();
            if (StringUtils.isNotEmpty(clude)) {
                result.add(clude.trim());
            }
        }
        return result;
    }

    /**
     * Checks if we have a match against a wildcard matcher.
     *
     * @param value           the value
     * @param wildcareMatcher the wildcard matcher
     * @return {@code true} if and only if the value matches the wildcard matcher.
     */
    static boolean isMatch(@NonNull String value, @NonNull String wildcareMatcher) {
        return FilenameUtils.wildcardMatch(value, wildcareMatcher, IOCase.SENSITIVE);
    }

    /**
     * {@inheritDoc}
     */
    @NonNull
    @Override
    public SubversionSCM build(@NonNull SCMHead head, @CheckForNull SCMRevision revision) {
        if (revision != null && !head.equals(revision.getHead())) {
            revision = null;
        }
        if (revision != null && !(revision instanceof SCMRevisionImpl)) {
            revision = null;
        }
        StringBuilder remote = new StringBuilder(remoteBase);
        if (!remoteBase.endsWith("/")) {
            remote.append('/');
        }
        remote.append(head.getName());
        if (revision != null) {
            remote.append('@').append(((SCMRevisionImpl) revision).getRevision());
        } else if (remote.indexOf("@") != -1) {
            // name contains an @ so need to ensure there is an @ at the end of the name
            remote.append('@');
        }
        return new SubversionSCM(remote.toString(), credentialsId, ".");
    }

    /**
     * Our implementation.
     */
    public static class SCMRevisionImpl extends SCMRevision {

        /**
         * The subversion revision.
         */
        private long revision;

        public SCMRevisionImpl(SCMHead head, long revision) {
            super(head);
            this.revision = revision;
        }

        public long getRevision() {
            return revision;
        }

        /**
         * {@inheritDoc}
         */
        @Override
        public boolean equals(Object o) {
            if (this == o) {
                return true;
            }
            if (o == null || getClass() != o.getClass()) {
                return false;
            }

            SCMRevisionImpl that = (SCMRevisionImpl) o;

            return revision == that.revision && getHead().equals(that.getHead());

        }

        /**
         * {@inheritDoc}
         */
        @Override
        public int hashCode() {
            return (int) (revision ^ (revision >>> 32));
        }
    }

    static class StringListComparator implements Comparator<List<String>> {
        public int compare(List<String> o1, List<String> o2) {
            ListIterator<String> e1 = o1.listIterator();
            ListIterator<String> e2 = o2.listIterator();
            while (e1.hasNext() && e2.hasNext()) {
                String s1 = e1.next();
                String s2 = e2.next();
                int rv = s1.compareTo(s2);
                if (rv != 0) {
                    return rv;
                }
            }
            if (e1.hasNext()) {
                return -1;
            }
            if (e2.hasNext()) {
                return 1;
            }
            return 0;
        }
    }

    @Extension
    @SuppressWarnings("unused") // by jenkins
    public static class DescriptorImpl extends SCMSourceDescriptor {
        static final Pattern URL_PATTERN = Pattern.compile("(https?|svn(\\+[a-z0-9]+)?|file)://.+");

        /**
         * {@inheritDoc}
         */
        @Override
        public String getDisplayName() {
            return Messages.SubversionSCMSource_DisplayName();
        }

        /**
         * Stapler helper method.
         *
         * @param context    the context.
         * @param remoteBase the remote base.
         * @return list box model.
         */
        @SuppressWarnings("unused") // by stapler
        public ListBoxModel doFillCredentialsIdItems(@AncestorInPath SCMSourceOwner context,
                @QueryParameter String remoteBase) {
            if (context == null || !context.hasPermission(Item.EXTENDED_READ)) {
                return new StandardListBoxModel();
            }
            List<DomainRequirement> domainRequirements;
            domainRequirements = URIRequirementBuilder.fromUri(remoteBase.trim()).build();
            return new StandardListBoxModel().withEmptySelection().withMatching(
                    CredentialsMatchers.anyOf(
                            CredentialsMatchers.instanceOf(StandardUsernamePasswordCredentials.class),
                            CredentialsMatchers.instanceOf(StandardCertificateCredentials.class),
                            CredentialsMatchers.instanceOf(SSHUserPrivateKey.class)),
                    CredentialsProvider.lookupCredentials(StandardCredentials.class, context, ACL.SYSTEM,
                            domainRequirements));
        }

        /**
         * validate the value for a remote (repository) location.
         */
        public FormValidation doCheckCredentialsId(StaplerRequest req, @AncestorInPath SCMSourceOwner context,
                @QueryParameter String remoteBase, @QueryParameter String value) {
            // TODO suspiciously similar to SubversionSCM.ModuleLocation.DescriptorImpl.checkCredentialsId; refactor into shared method?
            // Test the connection only if we may use the credentials
            if (context == null || !context.hasPermission(CredentialsProvider.USE_ITEM)) {
                return FormValidation.ok();
            }

            // if check remote is reporting an issue then we don't need to
            String url = Util.fixEmptyAndTrim(remoteBase);
            if (url == null)
                return FormValidation.ok();

            if (!URL_PATTERN.matcher(url).matches())
                return FormValidation.ok();

            try {
                String urlWithoutRevision = SvnHelper.getUrlWithoutRevision(url);

                SVNURL repoURL = SVNURL.parseURIDecoded(urlWithoutRevision);

                StandardCredentials credentials = value == null ? null
                        : CredentialsMatchers.firstOrNull(
                                CredentialsProvider.lookupCredentials(StandardCredentials.class, context,
                                        ACL.SYSTEM, URIRequirementBuilder.fromUri(repoURL.toString()).build()),
                                CredentialsMatchers.withId(value));
                if (checkRepositoryPath(context, repoURL, credentials) != SVNNodeKind.NONE) {
                    // something exists; now check revision if any

                    SVNRevision revision = getRevisionFromRemoteUrl(url);
                    if (revision != null && !revision.isValid()) {
                        return FormValidation.errorWithMarkup(
                                hudson.scm.subversion.Messages.SubversionSCM_doCheckRemote_invalidRevision());
                    }

                    return FormValidation.ok();
                }

                SVNRepository repository = null;
                try {
                    repository = getRepository(context, repoURL, credentials,
                            Collections.<String, Credentials>emptyMap(), null);
                    long rev = repository.getLatestRevision();
                    // now go back the tree and find if there's anything that exists
                    String repoPath = getRelativePath(repoURL, repository);
                    String p = repoPath;
                    while (p.length() > 0) {
                        p = SVNPathUtil.removeTail(p);
                        if (repository.checkPath(p, rev) == SVNNodeKind.DIR) {
                            // found a matching path
                            List<SVNDirEntry> entries = new ArrayList<SVNDirEntry>();
                            repository.getDir(p, rev, false, entries);

                            // build up the name list
                            List<String> paths = new ArrayList<String>();
                            for (SVNDirEntry e : entries)
                                if (e.getKind() == SVNNodeKind.DIR)
                                    paths.add(e.getName());

                            String head = SVNPathUtil.head(repoPath.substring(p.length() + 1));
                            String candidate = EditDistance.findNearest(head, paths);

                            return FormValidation.error(
                                    hudson.scm.subversion.Messages.SubversionSCM_doCheckRemote_badPathSuggest(p,
                                            head, candidate != null ? "/" + candidate : ""));
                        }
                    }

                    return FormValidation
                            .error(hudson.scm.subversion.Messages.SubversionSCM_doCheckRemote_badPath(repoPath));
                } finally {
                    if (repository != null)
                        repository.closeSession();
                }
            } catch (SVNException e) {
                LOGGER.log(Level.INFO, "Failed to access subversion repository " + url, e);
                String message = hudson.scm.subversion.Messages.SubversionSCM_doCheckRemote_exceptionMsg1(
                        Util.escape(url), Util.escape(e.getErrorMessage().getFullMessage()),
                        "javascript:document.getElementById('svnerror').style.display='block';"
                                + "document.getElementById('svnerrorlink').style.display='none';" + "return false;")
                        + "<br/><pre id=\"svnerror\" style=\"display:none\">" + Functions.printThrowable(e)
                        + "</pre>";
                return FormValidation.errorWithMarkup(message);
            }
        }

        public SVNNodeKind checkRepositoryPath(SCMSourceOwner context, SVNURL repoURL,
                StandardCredentials credentials) throws SVNException {
            SVNRepository repository = null;

            try {
                repository = getRepository(context, repoURL, credentials,
                        Collections.<String, Credentials>emptyMap(), null);
                repository.testConnection();

                long rev = repository.getLatestRevision();
                String repoPath = getRelativePath(repoURL, repository);
                return repository.checkPath(repoPath, rev);
            } catch (SVNException e) {
                if (LOGGER.isLoggable(Level.FINE)) {
                    LogRecord lr = new LogRecord(Level.FINE,
                            "Could not check repository path {0} using credentials {1} ({2})");
                    lr.setThrown(e);
                    lr.setParameters(new Object[] { repoURL,
                            credentials == null ? null : CredentialsNameProvider.name(credentials), credentials });
                    LOGGER.log(lr);
                }
                throw e;
            } finally {
                if (repository != null)
                    repository.closeSession();
            }
        }

        protected SVNRepository getRepository(SCMSourceOwner context, SVNURL repoURL,
                StandardCredentials credentials, Map<String, Credentials> additionalCredentials,
                ISVNSession session) throws SVNException {
            SVNRepository repository = SVNRepositoryFactory.create(repoURL, session);

            ISVNAuthenticationManager sam = SubversionSCM.createSvnAuthenticationManager(
                    new CredentialsSVNAuthenticationProviderImpl(credentials, additionalCredentials));
            sam = new FilterSVNAuthenticationManager(sam) {
                // If there's no time out, the blocking read operation may hang forever, because TCP itself
                // has no timeout. So always use some time out. If the underlying implementation gives us some
                // value (which may come from ~/.subversion), honor that, as long as it sets some timeout value.
                @Override
                public int getReadTimeout(SVNRepository repository) {
                    int r = super.getReadTimeout(repository);
                    if (r <= 0)
                        r = SubversionSCM.DEFAULT_TIMEOUT;
                    return r;
                }
            };
            repository.setTunnelProvider(SubversionSCM.createDefaultSVNOptions());
            repository.setAuthenticationManager(sam);

            return repository;
        }

        public static String getRelativePath(SVNURL repoURL, SVNRepository repository) throws SVNException {
            String repoPath = repoURL.getPath().substring(repository.getRepositoryRoot(true).getPath().length());
            if (!repoPath.startsWith("/"))
                repoPath = "/" + repoPath;
            return repoPath;
        }

        /**
         * Gets the revision from a remote URL - i.e. the part after '@' if any
         *
         * @return the revision or null
         */
        private static SVNRevision getRevisionFromRemoteUrl(String remoteUrlPossiblyWithRevision) {
            int idx = remoteUrlPossiblyWithRevision.lastIndexOf('@');
            int slashIdx = remoteUrlPossiblyWithRevision.lastIndexOf('/');
            if (idx > 0 && idx > slashIdx) {
                String n = remoteUrlPossiblyWithRevision.substring(idx + 1);
                return SVNRevision.parse(n);
            }

            return null;
        }

    }

    /**
     * We need to listen out for post-commit hooks
     */
    @Extension
    @SuppressWarnings("unused") // instantiated by Jenkins
    public static class ListenerImpl extends SubversionRepositoryStatus.Listener {

        /**
         * Maximum number of repositories to retain... since we should only ever have 1-2 relevant, this size
         * shouldn't matter much, but keep it finite to prevent memory stealing.
         */
        public static final int RECENT_SIZE = 64;

        /**
         * Guard against repeated calls by poorly configured hook scripts.
         */
        @GuardedBy("itself")
        private final Map<String, Long> recentUpdates = new LinkedHashMap<String, Long>(RECENT_SIZE) {
            @Override
            protected boolean removeEldestEntry(Map.Entry<String, Long> eldest) {
                return size() >= RECENT_SIZE;
            }
        };

        /**
         * {@inheritDoc}
         */
        @Override
        public boolean onNotify(UUID uuid, long revision, Set<String> paths) {
            final String id = uuid.toString();
            synchronized (recentUpdates) {
                Long recentUpdate = recentUpdates.get(id);
                if (recentUpdate != null && revision == recentUpdate) {
                    LOGGER.log(Level.FINE,
                            "Received duplicate post-commit hook from {0} for revision {1} on paths {2}",
                            new Object[] { uuid, revision, paths });
                    return false;
                }
                recentUpdates.put(id, revision);
            }
            LOGGER.log(Level.INFO, "Received post-commit hook from {0} for revision {1} on paths {2}",
                    new Object[] { uuid, revision, paths });
            boolean notified = false;
            // run in high privilege to see all the projects anonymous users don't see.
            // this is safe because when we actually schedule a build, it's a build that can
            // happen at some random time anyway.
            Authentication old = SecurityContextHolder.getContext().getAuthentication();
            SecurityContextHolder.getContext().setAuthentication(ACL.SYSTEM);
            try {
                for (SCMSourceOwner owner : SCMSourceOwners.all()) {
                    for (SCMSource source : owner.getSCMSources()) {
                        if (source instanceof SubversionSCMSource) {
                            if (id.equals(((SubversionSCMSource) source).getUuid())) {
                                LOGGER.log(Level.INFO, "SCM changes detected relevant to {0}. Notifying update",
                                        owner.getFullDisplayName());
                                owner.onSCMSourceUpdated(source);
                                notified = true;
                            }
                        }
                    }
                }
            } finally {
                SecurityContextHolder.getContext().setAuthentication(old);
            }
            if (!notified) {
                LOGGER.log(Level.INFO, "No subversion consumers for UUID {0}", uuid);
            }
            return notified;
        }
    }

}