/** * Copyright 2019 Association for the promotion of open-source insurance software and for the establishment of open interface standards in the insurance industry (Verein zur Förderung quelloffener Versicherungssoftware und Etablierung offener Schnittstellenstandards in der Versicherungsbranche) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.aposin.mergeprocessor.model.svn; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.net.MalformedURLException; import java.net.URL; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Objects; import java.util.function.Consumer; import java.util.logging.Level; import java.util.stream.Collectors; import javax.inject.Inject; import org.apache.commons.io.IOUtils; import org.aposin.mergeprocessor.configuration.ConfigurationException; import org.aposin.mergeprocessor.configuration.IConfiguration; import org.aposin.mergeprocessor.model.ICredentialProvider; import org.aposin.mergeprocessor.model.ICredentialProvider.AuthenticationException; import org.aposin.mergeprocessor.model.svn.ISvnClient.SvnDiff.SvnDiffAction; import org.aposin.mergeprocessor.model.svn.ISvnClient.SvnLog.SvnLogAction; import org.aposin.mergeprocessor.model.svn.ISvnClient.SvnLog.SvnLogEntry; import org.aposin.mergeprocessor.utils.LogUtil; import org.tigris.subversion.svnclientadapter.ISVNClientAdapter; import org.tigris.subversion.svnclientadapter.ISVNDirEntry; import org.tigris.subversion.svnclientadapter.ISVNInfo; import org.tigris.subversion.svnclientadapter.ISVNLogMessage; import org.tigris.subversion.svnclientadapter.ISVNLogMessageChangePath; import org.tigris.subversion.svnclientadapter.ISVNNotifyListener; import org.tigris.subversion.svnclientadapter.ISVNStatus; import org.tigris.subversion.svnclientadapter.SVNClientAdapterFactory; import org.tigris.subversion.svnclientadapter.SVNClientException; import org.tigris.subversion.svnclientadapter.SVNDiffSummary; import org.tigris.subversion.svnclientadapter.SVNDiffSummary.SVNDiffKind; import org.tigris.subversion.svnclientadapter.SVNNodeKind; import org.tigris.subversion.svnclientadapter.SVNRevision; import org.tigris.subversion.svnclientadapter.SVNRevision.Number; import org.tigris.subversion.svnclientadapter.SVNRevisionRange; import org.tigris.subversion.svnclientadapter.SVNStatusKind; import org.tigris.subversion.svnclientadapter.javahl.JhlClientAdapterFactory; import org.tigris.subversion.svnclientadapter.utils.Depth; /** * Implementation of {@link ISvnClient} using JavaHl. * * @author Stefan Weiser * */ public class SvnClientJavaHl extends AbstractSvnClient { private final ISVNClientAdapter client; private final List<CommandLineListener> listeners = new ArrayList<>(); private boolean isClosed = false; /** * @param provider to authenticate when required * @param configuration the configuration to get and set the username and * password * @throws SvnClientException */ @Inject public SvnClientJavaHl(ICredentialProvider provider, IConfiguration configuration) throws SvnClientException { try { if (!SVNClientAdapterFactory.isSVNClientAvailable(JhlClientAdapterFactory.JAVAHL_CLIENT)) { JhlClientAdapterFactory.setup(); } client = SVNClientAdapterFactory.createSVNClient(JhlClientAdapterFactory.JAVAHL_CLIENT); final String username = configuration.getSvnUsername(); if (username != null) { client.setUsername(username); } final String password = configuration.getSvnPassword(); if (password != null) { client.setPassword(password); } client.addPasswordCallback(new SVNPromptUserPassword(provider, configuration, client)); } catch (SVNClientException | ConfigurationException e) { throw new SvnClientException(e); } } /** * {@inheritDoc} */ @Override public String cat(URL url) throws SvnClientException { try { try (final InputStream content = client.getContent(toSVNUrl(url), SVNRevision.HEAD)) { return IOUtils.toString(content, StandardCharsets.UTF_8); } } catch (IOException | SVNClientException e) { throw new SvnClientException(e); } } /** * {@inheritDoc} */ @Override public List<SvnDiff> diff(final URL url, long fromRevision, long toRevision) throws SvnClientException { final List<SvnDiff> list = new ArrayList<>(); try { final SVNDiffSummary[] diffSummarize = client.diffSummarize(toSVNUrl(url), new Number(fromRevision), toSVNUrl(url), new Number(toRevision), Depth.infinity, true); for (final SVNDiffSummary svnDiffSummary : diffSummarize) { final SvnDiffAction action; if (svnDiffSummary.getDiffKind() == SVNDiffKind.ADDED) { action = SvnDiffAction.ADDED; } else if (svnDiffSummary.getDiffKind() == SVNDiffKind.DELETED) { action = SvnDiffAction.DELETED; } else if (svnDiffSummary.getDiffKind() == SVNDiffKind.MODIFIED) { action = SvnDiffAction.MODIFIED; } else if (svnDiffSummary.getDiffKind() == SVNDiffKind.NORMAL) { if (svnDiffSummary.propsChanged()) { action = SvnDiffAction.PROPERTY_CHANGED; } else { throw LogUtil .throwing(new SvnClientException("Unknown state of SVNDiffSummary " + svnDiffSummary)); } } else { throw LogUtil .throwing(new SvnClientException("Unknown SvnDiffAction " + svnDiffSummary.getDiffKind())); } list.add(new SvnDiff(action, new URL(convertURLToString(url) + '/' + svnDiffSummary.getPath()))); } } catch (MalformedURLException | SVNClientException e) { throw new SvnClientException(e); } return list; } /** * {@inheritDoc} * * @throws SvnClientException */ @Override public long showRevision(URL url) throws SvnClientException { try { final ISVNInfo info = client.getInfo(toSVNUrl(url)); return info.getRevision().getNumber(); } catch (MalformedURLException | SVNClientException e) { throw new SvnClientException(String.format("Exception occurred on showRevision(URL) with '%s'.", url), e); } } /** * {@inheritDoc} */ @Override public List<SvnLog> log(URL url, long fromRevision, long toRevision, String author) throws SvnClientException { final List<SvnLog> logs = new ArrayList<>(); try { final ISVNLogMessage[] logMessages = client.getLogMessages(toSVNUrl(url), new Number(fromRevision), new Number(toRevision)); for (final ISVNLogMessage logMessage : logMessages) { if (author == null || Objects.equals(logMessage.getAuthor(), author)) { final List<SvnLogEntry> entries = new ArrayList<>(); for (final ISVNLogMessageChangePath changePath : logMessage.getChangedPaths()) { final SvnLogAction action; switch (changePath.getAction()) { case 'A': action = SvnLogAction.ADDED; break; case 'D': action = SvnLogAction.DELETED; break; case 'R': action = SvnLogAction.REPLACED; break; case 'M': action = SvnLogAction.MODIFIED; break; default: throw LogUtil.throwing(new SvnClientException( String.format("Unknown action character '%s'", changePath.getAction()))); } entries.add(new SvnLogEntry(action, new URL(url.toString() + changePath.getPath()))); } final LocalDateTime dateTime = LocalDateTime .ofInstant(Instant.ofEpochMilli(logMessage.getTimeMillis()), ZoneId.systemDefault()); logs.add(new SvnLog(logMessage.getRevision().getNumber(), entries, logMessage.getMessage(), dateTime, logMessage.getAuthor())); } } } catch (MalformedURLException | SVNClientException e) { throw new SvnClientException(e); } return logs; } /** * {@inheritDoc} */ @Override public List<String> listDirectories(URL url) throws SvnClientException { try { final ISVNDirEntry[] list = client.getList(toSVNUrl(url), SVNRevision.HEAD, false); return Arrays.stream(list) // .filter(entry -> entry.getNodeKind() == SVNNodeKind.DIR) // only directories .map(ISVNDirEntry::getPath) // get path of entry .collect(Collectors.toList()); } catch (MalformedURLException | SVNClientException e) { throw new SvnClientException(e); } } /** * {@inheritDoc} */ @Override public long[] updateEmpty(List<Path> paths) throws SvnClientException { /* * ISVNClientAdapter#update(File[], SVNRevision, int, boolean, boolean, boolean) * does not work as expected */ long[] result = new long[paths.size()]; for (int i = 0; i < paths.size(); i++) { try { /* * setDepth = false : Otherwise the depth is set to the local checked out * repository and files are deleted existing in the directory. We don't want to * modify the checked out hierarchy, only update the file. */ result[i] = client.update(paths.get(i).toFile(), SVNRevision.HEAD, Depth.empty, /* setDepth */false, false, true); } catch (SVNClientException e) { LogUtil.getLogger().log(Level.WARNING, String.format("Could not update '%s'.", paths.get(i).toFile()), e); } } return result; } /** * {@inheritDoc} */ @Override public void checkoutEmpty(Path path, URL url) throws SvnClientException { Objects.requireNonNull(url); checkPath(path); try { client.checkout(toSVNUrl(url), path.toFile(), SVNRevision.HEAD, Depth.empty, false, false); } catch (MalformedURLException | SVNClientException e) { throw new SvnClientException(e); } } /** * Checks the given path to be valid and throws an {@link Exception} if * something is wrong. * * @param path the path to check * @throws SvnClientException */ private static void checkPath(final Path path) { Objects.requireNonNull(path); if (path.toFile().exists()) { if (!path.toFile().isDirectory()) { throw new IllegalArgumentException("The given path already exists and is not a directory"); } } else { throw new IllegalArgumentException("The given path does not exist."); } } /** * {@inheritDoc} */ @Override public void merge(Path path, URL url, long revision, boolean recursivly, boolean recordOnly) throws SvnClientException { try { final SVNRevisionRange[] revisionRange = new SVNRevisionRange[] { new SVNRevisionRange(new Number(revision - 1), new Number(revision)) }; client.merge(toSVNUrl(url), // SVN URL SVNRevision.HEAD, // pegRevision revisionRange, // revisions to merge (must be in the form N-1:M) path.toFile(), // target local path false, // force recursivly ? Depth.infinity : Depth.empty, // how deep to traverse into subdirectories false, // ignoreAncestry false, // dryRun recordOnly); // recordOnly } catch (MalformedURLException | SVNClientException e) { throw new SvnClientException(e); } } /** * {@inheritDoc} */ @Override public void commit(Path path, String message) throws SvnClientException { try { client.commit(new File[] { path.toFile() }, message, true); } catch (SVNClientException e) { throw new SvnClientException(e); } } /** * {@inheritDoc} */ @Override public List<String> getConflicts(Path path) throws SvnClientException { try { final ISVNStatus[] statusArray = client.getStatus(path.toFile(), true, true); return Arrays.stream(statusArray) // .filter(status -> status.hasTreeConflict() || status.getConflictWorking() != null || status.getTextStatus() == SVNStatusKind.CONFLICTED) // only conflicts of interest .map(SvnClientJavaHl::getConflictPath) // get path of conflicted file .collect(Collectors.toList()); } catch (SVNClientException e) { throw new SvnClientException(e); } } private static String getConflictPath(ISVNStatus status) { if (status.hasTreeConflict()) { return status.getConflictDescriptor().getPath(); } else if (status.getConflictWorking() != null) { return status.getConflictWorking().toString(); } else if (status.getTextStatus() == SVNStatusKind.CONFLICTED) { return status.getFile().toString(); } else { return null; } } /** * {@inheritDoc} */ @Override public void update(Path path) throws SvnClientException { try { client.update(path.toFile(), SVNRevision.HEAD, true); } catch (SVNClientException e) { throw new SvnClientException(e); } } /** * {@inheritDoc} */ @Override public URL getSvnUrl(Path path) throws SvnClientException { try { final ISVNInfo info = client.getInfoFromWorkingCopy(path.toFile()); return new URL(info.getUrlString()); } catch (MalformedURLException | SVNClientException e) { throw new SvnClientException(e); } } /** * {@inheritDoc} */ @Override public URL getRepositoryUrl(Path path) throws SvnClientException { try { final ISVNInfo info = client.getInfoFromWorkingCopy(path.toFile()); return new URL(info.getRepository().toString()); } catch (MalformedURLException | SVNClientException e) { throw new SvnClientException(e); } } /** * {@inheritDoc} */ @Override public boolean hasModifications(Path path) throws SvnClientException { try { final ISVNStatus[] status = client.getStatus(path.toFile(), true, false, false); return Arrays.stream(status).parallel().filter(SvnClientJavaHl::isModified).findAny().isPresent(); } catch (SVNClientException e) { throw new SvnClientException(e); } } /** * Checks the given {@link ISVNStatus} if modifications exist. * * @param status the {@link ISVNStatus} * @return {@code true} if modifications exist */ private static boolean isModified(ISVNStatus status) { final SVNStatusKind textStatus = status.getTextStatus(); if (textStatus == SVNStatusKind.ADDED || textStatus == SVNStatusKind.CONFLICTED || textStatus == SVNStatusKind.DELETED || textStatus == SVNStatusKind.MERGED || textStatus == SVNStatusKind.MODIFIED || textStatus == SVNStatusKind.REPLACED) { return true; } else { final SVNStatusKind propStatus = status.getPropStatus(); return propStatus == SVNStatusKind.ADDED || propStatus == SVNStatusKind.CONFLICTED || propStatus == SVNStatusKind.DELETED || propStatus == SVNStatusKind.MERGED || propStatus == SVNStatusKind.MODIFIED || propStatus == SVNStatusKind.REPLACED; } } /** * {@inheritDoc} */ @Override public void addCommandLineListener(Consumer<String> consumer) { final CommandLineListener listener = new CommandLineListener(consumer); listeners.add(listener); client.addNotifyListener(listener); } /** * {@inheritDoc} */ @Override public void removeCommandLineListener(Consumer<String> consumer) { listeners.stream() // .filter(p -> p.consumer == consumer) // Find listener with the consumer instance .findAny() // Any match is OK, even if more are existing .ifPresent(listener -> { // If existing remove it listeners.remove(listener); client.removeNotifyListener(listener); }); } /** * {@inheritDoc} */ @Override public void close() { if (!isClosed) { client.dispose(); isClosed = true; } } /** * When username or password are required, the given {@link ICredentialProvider} * is asked the input. After calling {@link ICredentialProvider#authenticate()} * the result is set to the {@link IConfiguration}. * * @author Stefan Weiser * */ private static class SVNPromptUserPassword extends SVNPromptUserPasswordAdapter { private final ICredentialProvider credentialProvider; private final IConfiguration configuration; private final ISVNClientAdapter client; private String username; private String password; /** * @param credentialProvider the {@link ICredentialProvider} * @param configuration the {@link IConfiguration} * @param client the {@link ISVNClientAdapter} working on */ private SVNPromptUserPassword(final ICredentialProvider credentialProvider, final IConfiguration configuration, final ISVNClientAdapter client) { this.credentialProvider = credentialProvider; this.configuration = configuration; this.client = client; } /** * {@inheritDoc} */ @Override public boolean userAllowedSave() { // No SVN cache return false; } /** * {@inheritDoc} */ @Override public boolean promptUser(String arg0, String arg1, boolean arg2) { return authenticate(); } /** * {@inheritDoc} */ @Override public boolean prompt(String arg0, String arg1, boolean arg2) { return authenticate(); } /** * Authenticate with the {@link ICredentialProvider} and set the result to the * {@link IConfiguration}. * * @return {@code true} if user authenticated, {@code false} on * {@link Exception} */ private boolean authenticate() { try { final String[] authenticate = credentialProvider.authenticate(); username = authenticate[0]; password = authenticate[1]; configuration.setSvnUsername(username); configuration.setSvnPassword(password); client.setUsername(username); client.setPassword(password); return true; } catch (AuthenticationException | ConfigurationException e) { LogUtil.throwing(e); return false; } } /** * {@inheritDoc} */ @Override public String getUsername() { return username; } /** * {@inheritDoc} */ @Override public String getPassword() { return password; } } /** * This implementation of {@link ISVNNotifyListener} delegates all command line * logs, called in {@link ISVNNotifyListener#logCommandLine(String)}, to a given * {@link Consumer}. * * @author Stefan Weiser * */ private static class CommandLineListener extends SVNNotifyListener { private final Consumer<String> consumer; /** * @param consumer the consumer to delegate to */ private CommandLineListener(final Consumer<String> consumer) { this.consumer = consumer; } /** * {@inheritDoc} */ @Override public void logCommandLine(String arg0) { consumer.accept(arg0); } } }