dependency 'org.sonarsource.javascript:sonar-javascript-plugin:6.3.0.12464' // bundled_plugin:javascript:SonarJS
dependency 'org.sonarsource.php:sonar-php-plugin:3.5.0.5655' // bundled_plugin:php:sonar-php
dependency 'org.sonarsource.python:sonar-python-plugin:2.13.0.7236' // bundled_plugin:python:sonar-python
- dependency 'org.sonarsource.scm.svn:sonar-scm-svn-plugin:1.10.0.1917' // bundled_plugin:scmsvn:sonar-scm-svn
dependency 'org.sonarsource.slang:sonar-go-plugin:1.6.0.719' // bundled_plugin:go:slang-enterprise
dependency 'org.sonarsource.slang:sonar-kotlin-plugin:1.5.0.315' // bundled_plugin:kotlin:slang-enterprise
dependency 'org.sonarsource.slang:sonar-ruby-plugin:1.5.0.315' // bundled_plugin:ruby:slang-enterprise
dependency 'org.elasticsearch:mocksocket:1.0'
dependency 'org.codelibs.elasticsearch.module:analysis-common:6.8.4'
dependency 'org.eclipse.jgit:org.eclipse.jgit:5.7.0.202003110725-r'
+ dependency 'org.tmatesoft.svnkit:svnkit:1.10.1'
dependency 'org.hamcrest:hamcrest-all:1.3'
dependency 'org.jsoup:jsoup:1.13.1'
dependency 'org.mindrot:jbcrypt:0.4'
bundledPlugin 'org.sonarsource.slang:sonar-go-plugin@jar'
bundledPlugin "org.sonarsource.slang:sonar-kotlin-plugin@jar"
bundledPlugin "org.sonarsource.slang:sonar-ruby-plugin@jar"
- bundledPlugin 'org.sonarsource.scm.svn:sonar-scm-svn-plugin@jar'
bundledPlugin "org.sonarsource.slang:sonar-scala-plugin@jar"
bundledPlugin 'org.sonarsource.xml:sonar-xml-plugin@jar'
}
compile 'org.codehaus.woodstox:stax2-api'
compile 'org.codehaus.woodstox:woodstox-core-lgpl'
compile 'org.eclipse.jgit:org.eclipse.jgit'
+ compile 'org.tmatesoft.svnkit:svnkit'
compile 'org.picocontainer:picocontainer'
compile 'org.slf4j:jcl-over-slf4j'
compile 'org.slf4j:jul-to-slf4j'
import org.sonar.scanner.sensor.ProjectSensorOptimizer;
import org.sonar.scanner.sensor.ProjectSensorsExecutor;
import org.sonar.scm.git.GitScmSupport;
+import org.sonar.scm.svn.SvnScmSupport;
import static org.sonar.api.batch.InstantiationStrategy.PER_BATCH;
import static org.sonar.core.extension.CoreExtensionsInstaller.noExtensionFilter;
AnalysisObservers.class);
- add(GitScmSupport.getClasses());
+ add(GitScmSupport.getObjects());
+ add(SvnScmSupport.getObjects());
addIfMissing(DefaultProjectSettingsLoader.class, ProjectSettingsLoader.class);
addIfMissing(DefaultRulesLoader.class, RulesLoader.class);
import org.eclipse.jgit.util.FS;
public final class GitScmSupport {
- public static List<Class<?>> getClasses() {
+ private GitScmSupport() {
+ // static only
+ }
+
+ public static List<Object> getObjects() {
FS.FileStoreAttributes.setBackground(true);
return Arrays.asList(
JGitBlameCommand.class,
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.scm.svn;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import org.sonar.api.batch.scm.BlameLine;
+import org.tmatesoft.svn.core.SVNException;
+import org.tmatesoft.svn.core.wc.ISVNAnnotateHandler;
+
+public class AnnotationHandler implements ISVNAnnotateHandler {
+
+ private List<BlameLine> lines = new ArrayList<>();
+
+ @Override
+ public void handleEOF() {
+ // Not used
+ }
+
+ @Override
+ public void handleLine(Date date, long revision, String author, String line) throws SVNException {
+ // deprecated
+ }
+
+ @Override
+ public void handleLine(Date date, long revision, String author, String line, Date mergedDate,
+ long mergedRevision, String mergedAuthor, String mergedPath, int lineNumber) throws SVNException {
+ lines.add(new BlameLine().date(mergedDate).revision(Long.toString(mergedRevision)).author(mergedAuthor));
+ }
+
+ @Override
+ public boolean handleRevision(Date date, long revision, String author, File contents) throws SVNException {
+ /*
+ * We do not want our file to be annotated for each revision of the range, but only for the last
+ * revision of it, so we return false
+ */
+ return false;
+ }
+
+ public List<BlameLine> getLines() {
+ return lines;
+ }
+
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.scm.svn;
+
+import java.io.OutputStream;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+class ChangedLinesComputer {
+
+ private final Tracker tracker;
+
+ private final OutputStream receiver = new OutputStream() {
+ StringBuilder sb = new StringBuilder();
+
+ @Override
+ public void write(int b) {
+ sb.append((char) b);
+ if (b == '\n') {
+ tracker.parseLine(sb.toString());
+ sb.setLength(0);
+ }
+ }
+ };
+
+ ChangedLinesComputer(Path rootBaseDir, Set<Path> included) {
+ this.tracker = new Tracker(rootBaseDir, included);
+ }
+
+ /**
+ * The OutputStream to pass to svnkit's diff command.
+ */
+ OutputStream receiver() {
+ return receiver;
+ }
+
+ /**
+ * From a stream of svn-style unified diff lines,
+ * compute the line numbers that should be considered changed.
+ *
+ * Example input:
+ * <pre>
+ * Index: path/to/file
+ * ===================================================================
+ * --- lao 2002-02-21 23:30:39.942229878 -0800
+ * +++ tzu 2002-02-21 23:30:50.442260588 -0800
+ * @@ -1,7 +1,6 @@
+ * -The Way that can be told of is not the eternal Way;
+ * -The name that can be named is not the eternal name.
+ * The Nameless is the origin of Heaven and Earth;
+ * -The Named is the mother of all things.
+ * +The named is the mother of all things.
+ * +
+ * Therefore let there always be non-being,
+ * so we may see their subtlety,
+ * And let there always be being,
+ * @@ -9,3 +8,6 @@
+ * The two are the same,
+ * But after they are produced,
+ * they have different names.
+ * +They both may be called deep and profound.
+ * +Deeper and more profound,
+ * +The door of all subtleties!
+ * </pre>
+ *
+ * See also: http://www.gnu.org/software/diffutils/manual/html_node/Example-Unified.html#Example-Unified
+ */
+ Map<Path, Set<Integer>> changedLines() {
+ return tracker.changedLines();
+ }
+
+ private static class Tracker {
+
+ private static final Pattern START_LINE_IN_TARGET = Pattern.compile(" \\+(\\d+)");
+ private static final String ENTRY_START_PREFIX = "Index: ";
+
+ private final Map<Path, Set<Integer>> changedLines = new HashMap<>();
+ private final Set<Path> included;
+ private final Path rootBaseDir;
+
+ private int lineNumInTarget;
+ private Path currentPath = null;
+ private int skipCount = 0;
+
+ Tracker(Path rootBaseDir, Set<Path> included) {
+ this.rootBaseDir = rootBaseDir;
+ this.included = included;
+ }
+
+ private void parseLine(String line) {
+ if (line.startsWith(ENTRY_START_PREFIX)) {
+ currentPath = Paths.get(line.substring(ENTRY_START_PREFIX.length()).trim());
+ if (!currentPath.isAbsolute()) {
+ currentPath = rootBaseDir.resolve(currentPath);
+ }
+ if (!included.contains(currentPath)) {
+ return;
+ }
+ skipCount = 3;
+ return;
+ }
+
+ if (!included.contains(currentPath)) {
+ return;
+ }
+
+ if (skipCount > 0) {
+ skipCount--;
+ return;
+ }
+
+ if (line.startsWith("@@ ")) {
+ Matcher matcher = START_LINE_IN_TARGET.matcher(line);
+ if (!matcher.find()) {
+ throw new IllegalStateException("Invalid block header: " + line);
+ }
+ lineNumInTarget = Integer.parseInt(matcher.group(1));
+ return;
+ }
+
+ parseContent(line);
+ }
+
+ private void parseContent(String line) {
+ char firstChar = line.charAt(0);
+ if (firstChar == ' ') {
+ lineNumInTarget++;
+ } else if (firstChar == '+') {
+ changedLines
+ .computeIfAbsent(currentPath, path -> new HashSet<>())
+ .add(lineNumInTarget);
+ lineNumInTarget++;
+ }
+ }
+
+ Map<Path, Set<Integer>> changedLines() {
+ return changedLines;
+ }
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.scm.svn;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.Optional;
+import javax.annotation.CheckForNull;
+import org.sonar.api.scanner.ScannerSide;
+import org.sonar.api.utils.log.Logger;
+import org.sonar.api.utils.log.Loggers;
+import org.tmatesoft.svn.core.ISVNLogEntryHandler;
+import org.tmatesoft.svn.core.SVNException;
+import org.tmatesoft.svn.core.SVNLogEntry;
+import org.tmatesoft.svn.core.SVNLogEntryPath;
+import org.tmatesoft.svn.core.wc.SVNClientManager;
+import org.tmatesoft.svn.core.wc.SVNRevision;
+import org.tmatesoft.svn.core.wc.SVNStatus;
+
+import static org.sonar.scm.svn.SvnScmSupport.newSvnClientManager;
+
+@ScannerSide
+public class FindFork {
+ private static final Logger LOG = Loggers.get(FindFork.class);
+
+ private final SvnConfiguration configuration;
+
+ public FindFork(SvnConfiguration configuration) {
+ this.configuration = configuration;
+ }
+
+ @CheckForNull
+ public Instant findDate(Path location, String referenceBranch) throws SVNException {
+ ForkPoint forkPoint = find(location, referenceBranch);
+ if (forkPoint != null) {
+ return forkPoint.date();
+ }
+ return null;
+ }
+
+ @CheckForNull
+ public ForkPoint find(Path location, String referenceBranch) throws SVNException {
+ SVNClientManager clientManager = newSvnClientManager(configuration);
+ SVNRevision revision = getSvnRevision(location, clientManager);
+ LOG.debug("latest revision is " + revision);
+ String svnRefBranch = "/" + referenceBranch;
+
+ SVNLogEntryHolder handler = new SVNLogEntryHolder();
+ SVNRevision endRevision = SVNRevision.create(1);
+ SVNRevision startRevision = SVNRevision.create(revision.getNumber());
+
+ do {
+ clientManager.getLogClient().doLog(new File[] {location.toFile()}, startRevision, endRevision, true, true, -1, handler);
+ SVNLogEntry lastEntry = handler.getLastEntry();
+ Optional<SVNLogEntryPath> copyFromReference = lastEntry.getChangedPaths().values().stream()
+ .filter(e -> e.getCopyPath() != null && e.getCopyPath().equals(svnRefBranch))
+ .findFirst();
+
+ if (copyFromReference.isPresent()) {
+ return new ForkPoint(String.valueOf(copyFromReference.get().getCopyRevision()), Instant.ofEpochMilli(lastEntry.getDate().getTime()));
+ }
+
+ if (lastEntry.getChangedPaths().isEmpty()) {
+ // shouldn't happen since it should only stop in revisions with changed paths
+ return null;
+ }
+
+ SVNLogEntryPath firstChangedPath = lastEntry.getChangedPaths().values().iterator().next();
+ if (firstChangedPath.getCopyPath() == null) {
+ // we walked the history to the root, and the last commit found had no copy reference. Must be the trunk, there is no fork point
+ return null;
+ }
+
+ // TODO Looks like a revision can have multiple changed paths. Should we iterate through all of them?
+ startRevision = SVNRevision.create(firstChangedPath.getCopyRevision());
+ } while (true);
+
+ }
+
+ private static SVNRevision getSvnRevision(Path location, SVNClientManager clientManager) throws SVNException {
+ SVNStatus svnStatus = clientManager.getStatusClient().doStatus(location.toFile(), false);
+ return svnStatus.getRevision();
+ }
+
+ /**
+ * Handler keeping only the last entry, and count how many entries have been seen.
+ */
+ private static class SVNLogEntryHolder implements ISVNLogEntryHandler {
+ SVNLogEntry value;
+
+ public SVNLogEntry getLastEntry() {
+ return value;
+ }
+
+ @Override
+ public void handleLogEntry(SVNLogEntry svnLogEntry) {
+ this.value = svnLogEntry;
+ }
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.scm.svn;
+
+import java.time.Instant;
+
+public class ForkPoint {
+ private String commit;
+ private Instant date;
+
+ public ForkPoint(String commit, Instant date) {
+ this.commit = commit;
+ this.date = date;
+ }
+
+ public String commit() {
+ return commit;
+ }
+
+ public Instant date() {
+ return date;
+ }
+
+ @Override
+ public String toString() {
+ return "ForkPoint{" +
+ "commit='" + commit + '\'' +
+ ", date=" + date +
+ '}';
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.scm.svn;
+
+import java.util.List;
+import org.sonar.api.batch.fs.FileSystem;
+import org.sonar.api.batch.fs.InputFile;
+import org.sonar.api.batch.scm.BlameCommand;
+import org.sonar.api.batch.scm.BlameLine;
+import org.sonar.api.utils.log.Logger;
+import org.sonar.api.utils.log.Loggers;
+import org.tmatesoft.svn.core.SVNErrorCode;
+import org.tmatesoft.svn.core.SVNException;
+import org.tmatesoft.svn.core.wc.SVNClientManager;
+import org.tmatesoft.svn.core.wc.SVNDiffOptions;
+import org.tmatesoft.svn.core.wc.SVNLogClient;
+import org.tmatesoft.svn.core.wc.SVNRevision;
+import org.tmatesoft.svn.core.wc.SVNStatus;
+import org.tmatesoft.svn.core.wc.SVNStatusClient;
+import org.tmatesoft.svn.core.wc.SVNStatusType;
+
+import static org.sonar.scm.svn.SvnScmSupport.newSvnClientManager;
+
+public class SvnBlameCommand extends BlameCommand {
+
+ private static final Logger LOG = Loggers.get(SvnBlameCommand.class);
+ private final SvnConfiguration configuration;
+
+ public SvnBlameCommand(SvnConfiguration configuration) {
+ this.configuration = configuration;
+ }
+
+ @Override
+ public void blame(final BlameInput input, final BlameOutput output) {
+ FileSystem fs = input.fileSystem();
+ LOG.debug("Working directory: " + fs.baseDir().getAbsolutePath());
+ SVNClientManager clientManager = null;
+ try {
+ clientManager = newSvnClientManager(configuration);
+ for (InputFile inputFile : input.filesToBlame()) {
+ blame(clientManager, inputFile, output);
+ }
+ } finally {
+ if (clientManager != null) {
+ try {
+ clientManager.dispose();
+ } catch (Exception e) {
+ LOG.warn("Unable to dispose SVN ClientManager", e);
+ }
+ }
+ }
+ }
+
+ private static void blame(SVNClientManager clientManager, InputFile inputFile, BlameOutput output) {
+ String filename = inputFile.relativePath();
+
+ LOG.debug("Process file {}", filename);
+
+ AnnotationHandler handler = new AnnotationHandler();
+ try {
+ if (!checkStatus(clientManager, inputFile)) {
+ return;
+ }
+ SVNLogClient logClient = clientManager.getLogClient();
+ logClient.setDiffOptions(new SVNDiffOptions(true, true, true));
+ logClient.doAnnotate(inputFile.file(), SVNRevision.UNDEFINED, SVNRevision.create(1), SVNRevision.BASE, true, true, handler, null);
+ } catch (SVNException e) {
+ throw new IllegalStateException("Error when executing blame for file " + filename, e);
+ }
+
+ List<BlameLine> lines = handler.getLines();
+ if (lines.size() == inputFile.lines() - 1) {
+ // SONARPLUGINS-3097 SVN do not report blame on last empty line
+ lines.add(lines.get(lines.size() - 1));
+ }
+ output.blameResult(inputFile, lines);
+ }
+
+ private static boolean checkStatus(SVNClientManager clientManager, InputFile inputFile) throws SVNException {
+ SVNStatusClient statusClient = clientManager.getStatusClient();
+ try {
+ SVNStatus status = statusClient.doStatus(inputFile.file(), false);
+ if (status == null) {
+ LOG.debug("File {} returns no svn state. Skipping it.", inputFile);
+ return false;
+ }
+ if (status.getContentsStatus() != SVNStatusType.STATUS_NORMAL) {
+ LOG.debug("File {} is not versionned or contains local modifications. Skipping it.", inputFile);
+ return false;
+ }
+ } catch (SVNException e) {
+ if (SVNErrorCode.WC_PATH_NOT_FOUND.equals(e.getErrorMessage().getErrorCode())
+ || SVNErrorCode.WC_NOT_WORKING_COPY.equals(e.getErrorMessage().getErrorCode())) {
+ LOG.debug("File {} is not versionned. Skipping it.", inputFile);
+ return false;
+ }
+ throw e;
+ }
+ return true;
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.scm.svn;
+
+import java.io.File;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import javax.annotation.CheckForNull;
+import org.sonar.api.CoreProperties;
+import org.sonar.api.PropertyType;
+import org.sonar.api.config.Configuration;
+import org.sonar.api.config.PropertyDefinition;
+import org.sonar.api.resources.Qualifiers;
+import org.sonar.api.scanner.ScannerSide;
+import org.sonar.api.utils.MessageException;
+
+@ScannerSide
+public class SvnConfiguration {
+
+ private static final String CATEGORY_SVN = "SVN";
+ public static final String USER_PROP_KEY = "sonar.svn.username";
+ public static final String PRIVATE_KEY_PATH_PROP_KEY = "sonar.svn.privateKeyPath";
+ public static final String PASSWORD_PROP_KEY = "sonar.svn.password.secured";
+ public static final String PASSPHRASE_PROP_KEY = "sonar.svn.passphrase.secured";
+ private final Configuration config;
+
+ public SvnConfiguration(Configuration config) {
+ this.config = config;
+ }
+
+ public static List<PropertyDefinition> getProperties() {
+ return Arrays.asList(
+ PropertyDefinition.builder(USER_PROP_KEY)
+ .name("Username")
+ .description("Username to be used for SVN server or SVN+SSH authentication")
+ .type(PropertyType.STRING)
+ .onQualifiers(Qualifiers.PROJECT)
+ .category(CoreProperties.CATEGORY_SCM)
+ .subCategory(CATEGORY_SVN)
+ .index(0)
+ .build(),
+ PropertyDefinition.builder(PASSWORD_PROP_KEY)
+ .name("Password")
+ .description("Password to be used for SVN server or SVN+SSH authentication")
+ .type(PropertyType.PASSWORD)
+ .onQualifiers(Qualifiers.PROJECT)
+ .category(CoreProperties.CATEGORY_SCM)
+ .subCategory(CATEGORY_SVN)
+ .index(1)
+ .build(),
+ PropertyDefinition.builder(PRIVATE_KEY_PATH_PROP_KEY)
+ .name("Path to private key file")
+ .description("Can be used instead of password for SVN+SSH authentication")
+ .type(PropertyType.STRING)
+ .onQualifiers(Qualifiers.PROJECT)
+ .category(CoreProperties.CATEGORY_SCM)
+ .subCategory(CATEGORY_SVN)
+ .index(2)
+ .build(),
+ PropertyDefinition.builder(PASSPHRASE_PROP_KEY)
+ .name("Passphrase")
+ .description("Optional passphrase of your private key file")
+ .type(PropertyType.PASSWORD)
+ .onQualifiers(Qualifiers.PROJECT)
+ .category(CoreProperties.CATEGORY_SCM)
+ .subCategory(CATEGORY_SVN)
+ .index(3)
+ .build());
+ }
+
+ @CheckForNull
+ public String username() {
+ return config.get(USER_PROP_KEY).orElse(null);
+ }
+
+ @CheckForNull
+ public String password() {
+ return config.get(PASSWORD_PROP_KEY).orElse(null);
+ }
+
+ @CheckForNull
+ public File privateKey() {
+ Optional<String> privateKeyOpt = config.get(PRIVATE_KEY_PATH_PROP_KEY);
+ if (privateKeyOpt.isPresent()) {
+ File privateKeyFile = new File(privateKeyOpt.get());
+ if (!privateKeyFile.exists() || !privateKeyFile.isFile() || !privateKeyFile.canRead()) {
+ throw MessageException.of("Unable to read private key from '" + privateKeyFile + "'");
+ }
+ return privateKeyFile;
+ }
+ return null;
+ }
+
+ @CheckForNull
+ public String passPhrase() {
+ return config.get(PASSPHRASE_PROP_KEY).orElse(null);
+ }
+
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.scm.svn;
+
+import java.io.File;
+import java.net.MalformedURLException;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.Instant;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import javax.annotation.CheckForNull;
+import org.sonar.api.batch.scm.BlameCommand;
+import org.sonar.api.batch.scm.ScmProvider;
+import org.sonar.api.utils.log.Logger;
+import org.sonar.api.utils.log.Loggers;
+import org.tmatesoft.svn.core.SVNDepth;
+import org.tmatesoft.svn.core.SVNException;
+import org.tmatesoft.svn.core.SVNLogEntryPath;
+import org.tmatesoft.svn.core.SVNNodeKind;
+import org.tmatesoft.svn.core.SVNURL;
+import org.tmatesoft.svn.core.wc.SVNClientManager;
+import org.tmatesoft.svn.core.wc.SVNDiffClient;
+import org.tmatesoft.svn.core.wc.SVNInfo;
+import org.tmatesoft.svn.core.wc.SVNLogClient;
+import org.tmatesoft.svn.core.wc.SVNRevision;
+import org.tmatesoft.svn.core.wc.SVNWCClient;
+
+import static org.sonar.scm.svn.SvnScmSupport.newSvnClientManager;
+
+public class SvnScmProvider extends ScmProvider {
+
+ private static final Logger LOG = Loggers.get(SvnScmProvider.class);
+
+ private final SvnConfiguration configuration;
+ private final SvnBlameCommand blameCommand;
+ private final FindFork findFork;
+
+ public SvnScmProvider(SvnConfiguration configuration, SvnBlameCommand blameCommand, FindFork findFork) {
+ this.configuration = configuration;
+ this.blameCommand = blameCommand;
+ this.findFork = findFork;
+ }
+
+ @Override
+ public String key() {
+ return "svn";
+ }
+
+ @Override
+ public boolean supports(File baseDir) {
+ File folder = baseDir;
+ while (folder != null) {
+ if (new File(folder, ".svn").exists()) {
+ return true;
+ }
+ folder = folder.getParentFile();
+ }
+ return false;
+ }
+
+ @Override
+ public BlameCommand blameCommand() {
+ return blameCommand;
+ }
+
+ @CheckForNull
+ @Override
+ public Set<Path> branchChangedFiles(String targetBranchName, Path rootBaseDir) {
+ SVNClientManager clientManager = null;
+ try {
+ clientManager = newSvnClientManager(configuration);
+ return computeChangedPaths(rootBaseDir, clientManager);
+ } catch (SVNException e) {
+ LOG.warn(e.getMessage());
+ } finally {
+ if (clientManager != null) {
+ try {
+ clientManager.dispose();
+ } catch (Exception e) {
+ LOG.warn("Unable to dispose SVN ClientManager", e);
+ }
+ }
+ }
+
+ return null;
+ }
+
+ static Set<Path> computeChangedPaths(Path projectBasedir, SVNClientManager clientManager) throws SVNException {
+ SVNWCClient wcClient = clientManager.getWCClient();
+ SVNInfo svnInfo = wcClient.doInfo(projectBasedir.toFile(), null);
+
+ // SVN path of the repo root, for example: /C:/Users/JANOSG~1/AppData/Local/Temp/x/y
+ Path svnRootPath = toPath(svnInfo.getRepositoryRootURL());
+
+ // the svn root path may be "" for urls like http://svnserver/
+ // -> set it to "/" to avoid crashing when using Path.relativize later
+ if (svnRootPath.equals(Paths.get(""))) {
+ svnRootPath = Paths.get("/");
+ }
+
+ // SVN path of projectBasedir, for example: /C:/Users/JANOSG~1/AppData/Local/Temp/x/y/branches/b1
+ Path svnProjectPath = toPath(svnInfo.getURL());
+ // path of projectBasedir, as "absolute path within the SVN repo", for example: /branches/b1
+ Path inRepoProjectPath = Paths.get("/").resolve(svnRootPath.relativize(svnProjectPath));
+
+ // We inspect "svn log" from latest revision until copy-point.
+ // The same path may appear in multiple commits, the ordering of changes and removals is important.
+ Set<Path> paths = new HashSet<>();
+ Set<Path> removed = new HashSet<>();
+
+ SVNLogClient svnLogClient = clientManager.getLogClient();
+ svnLogClient.doLog(new File[] {projectBasedir.toFile()}, null, null, null, true, true, 0, svnLogEntry ->
+ svnLogEntry.getChangedPaths().values().forEach(entry -> {
+ if (entry.getKind().equals(SVNNodeKind.FILE)) {
+ Path path = projectBasedir.resolve(inRepoProjectPath.relativize(Paths.get(entry.getPath())));
+ if (isModified(entry)) {
+ // Skip if the path is removed in a more recent commit
+ if (!removed.contains(path)) {
+ paths.add(path);
+ }
+ } else if (entry.getType() == SVNLogEntryPath.TYPE_DELETED) {
+ removed.add(path);
+ }
+ }
+ }));
+ return paths;
+ }
+
+ private static Path toPath(SVNURL svnUrl) {
+ if ("file".equals(svnUrl.getProtocol())) {
+ try {
+ return Paths.get(new URL("file", svnUrl.getHost(), svnUrl.getPath()).toURI());
+ } catch (URISyntaxException | MalformedURLException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+ return Paths.get(svnUrl.getURIEncodedPath());
+ }
+
+ private static boolean isModified(SVNLogEntryPath entry) {
+ return entry.getType() == SVNLogEntryPath.TYPE_ADDED
+ || entry.getType() == SVNLogEntryPath.TYPE_MODIFIED;
+ }
+
+ @CheckForNull
+ @Override
+ public Map<Path, Set<Integer>> branchChangedLines(String targetBranchName, Path rootBaseDir, Set<Path> changedFiles) {
+ SVNClientManager clientManager = null;
+ try {
+ clientManager = newSvnClientManager(configuration);
+
+ // find reference revision number: the copy point
+ SVNLogClient svnLogClient = clientManager.getLogClient();
+ long[] revisionCounter = {0};
+ svnLogClient.doLog(new File[] {rootBaseDir.toFile()}, null, null, null, true, true, 0,
+ svnLogEntry -> revisionCounter[0] = svnLogEntry.getRevision());
+
+ long startRev = revisionCounter[0];
+
+ SVNDiffClient svnDiffClient = clientManager.getDiffClient();
+ File path = rootBaseDir.toFile();
+ ChangedLinesComputer computer = newChangedLinesComputer(rootBaseDir, changedFiles);
+ svnDiffClient.doDiff(path, SVNRevision.create(startRev), path, SVNRevision.WORKING, SVNDepth.INFINITY, false, computer.receiver(), null);
+ return computer.changedLines();
+ } catch (Exception e) {
+ LOG.warn("Failed to get changed lines from Subversion", e);
+ } finally {
+ if (clientManager != null) {
+ try {
+ clientManager.dispose();
+ } catch (Exception e) {
+ LOG.warn("Unable to dispose SVN ClientManager", e);
+ }
+ }
+ }
+
+ return null;
+ }
+
+ @CheckForNull
+ @Override
+ public Instant forkDate(String referenceBranch, Path rootBaseDir) {
+ try {
+ return findFork.findDate(rootBaseDir, referenceBranch);
+ } catch (SVNException e) {
+ LOG.warn("Unable to find fork date with '" + referenceBranch + "'", e);
+ return null;
+ }
+ }
+
+ ChangedLinesComputer newChangedLinesComputer(Path rootBaseDir, Set<Path> changedFiles) {
+ return new ChangedLinesComputer(rootBaseDir, changedFiles);
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.scm.svn;
+
+import java.util.Arrays;
+import java.util.List;
+import javax.annotation.CheckForNull;
+import javax.annotation.Nullable;
+import org.tmatesoft.svn.core.auth.ISVNAuthenticationManager;
+import org.tmatesoft.svn.core.wc.ISVNOptions;
+import org.tmatesoft.svn.core.wc.SVNClientManager;
+import org.tmatesoft.svn.core.wc.SVNWCUtil;
+
+public class SvnScmSupport {
+ private SvnScmSupport() {
+ // static only
+ }
+
+ static SVNClientManager newSvnClientManager(SvnConfiguration configuration) {
+ ISVNOptions options = SVNWCUtil.createDefaultOptions(true);
+ final char[] passwordValue = getCharsOrNull(configuration.password());
+ final char[] passPhraseValue = getCharsOrNull(configuration.passPhrase());
+ ISVNAuthenticationManager authManager = SVNWCUtil.createDefaultAuthenticationManager(
+ null,
+ configuration.username(),
+ passwordValue,
+ configuration.privateKey(),
+ passPhraseValue,
+ false);
+ return SVNClientManager.newInstance(options, authManager);
+ }
+
+ @CheckForNull
+ private static char[] getCharsOrNull(@Nullable String s) {
+ return s != null ? s.toCharArray() : null;
+ }
+
+ public static List<Object> getObjects() {
+ return Arrays.asList(SvnScmProvider.class,
+ SvnBlameCommand.class,
+ SvnConfiguration.class,
+ FindFork.class,
+ SvnConfiguration.getProperties());
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+@ParametersAreNonnullByDefault
+package org.sonar.scm.svn;
+
+import javax.annotation.ParametersAreNonnullByDefault;
+
@Test
public void getClasses() {
- assertThat(GitScmSupport.getClasses()).hasSize(3);
+ assertThat(GitScmSupport.getObjects()).hasSize(3);
}
}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.scm.svn;
+
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import org.junit.Test;
+
+import static java.util.Collections.singleton;
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class ChangedLinesComputerTest {
+
+ private final Path rootBaseDir = Paths.get("/foo");
+ private final ChangedLinesComputer underTest = new ChangedLinesComputer(rootBaseDir, new HashSet<>(Arrays.asList(
+ rootBaseDir.resolve("sample1"),
+ rootBaseDir.resolve("sample2"),
+ rootBaseDir.resolve("sample3"),
+ rootBaseDir.resolve("sample4"))));
+
+ @Test
+ public void do_not_count_deleted_line() throws IOException {
+ String example = "Index: sample1\n"
+ + "===================================================================\n"
+ + "--- a/sample1\n"
+ + "+++ b/sample1\n"
+ + "@@ -1 +0,0 @@\n"
+ + "-deleted line\n";
+
+ printDiff(example);
+ assertThat(underTest.changedLines()).isEmpty();
+ }
+
+ @Test
+ public void count_single_added_line() throws IOException {
+ String example = "Index: sample1\n"
+ + "===================================================================\n"
+ + "--- a/sample1\n"
+ + "+++ b/sample1\n"
+ + "@@ -0,0 +1 @@\n"
+ + "+added line\n";
+
+ printDiff(example);
+ assertThat(underTest.changedLines()).isEqualTo(Collections.singletonMap(rootBaseDir.resolve("sample1"), singleton(1)));
+ }
+
+ @Test
+ public void count_multiple_added_lines() throws IOException {
+ String example = "Index: sample1\n"
+ + "===================================================================\n"
+ + "--- a/sample1\n"
+ + "+++ b/sample1\n"
+ + "@@ -1 +1,3 @@\n"
+ + " same line\n"
+ + "+added line 1\n"
+ + "+added line 2\n";
+
+ printDiff(example);
+ assertThat(underTest.changedLines()).isEqualTo(Collections.singletonMap(rootBaseDir.resolve("sample1"), new HashSet<>(Arrays.asList(2, 3))));
+ }
+
+ @Test
+ public void handle_index_using_absolute_paths() throws IOException {
+ String example = "Index: /foo/sample1\n"
+ + "===================================================================\n"
+ + "--- a/sample1\n"
+ + "+++ b/sample1\n"
+ + "@@ -1 +1,3 @@\n"
+ + " same line\n"
+ + "+added line 1\n"
+ + "+added line 2\n";
+
+ printDiff(example);
+ assertThat(underTest.changedLines()).isEqualTo(Collections.singletonMap(rootBaseDir.resolve("sample1"), new HashSet<>(Arrays.asList(2, 3))));
+ }
+
+ @Test
+ public void compute_from_multiple_hunks() throws IOException {
+ String example = "Index: sample1\n"
+ + "===================================================================\n"
+ + "--- lao\t2002-02-21 23:30:39.942229878 -0800\n"
+ + "+++ tzu\t2002-02-21 23:30:50.442260588 -0800\n"
+ + "@@ -1,7 +1,6 @@\n"
+ + "-The Way that can be told of is not the eternal Way;\n"
+ + "-The name that can be named is not the eternal name.\n"
+ + " The Nameless is the origin of Heaven and Earth;\n"
+ + "-The Named is the mother of all things.\n"
+ + "+The named is the mother of all things.\n"
+ + "+\n"
+ + " Therefore let there always be non-being,\n"
+ + " so we may see their subtlety,\n"
+ + " And let there always be being,\n"
+ + "@@ -9,3 +8,6 @@\n"
+ + " The two are the same,\n"
+ + " But after they are produced,\n"
+ + " they have different names.\n"
+ + "+They both may be called deep and profound.\n"
+ + "+Deeper and more profound,\n"
+ + "+The door of all subtleties!\n";
+ printDiff(example);
+ assertThat(underTest.changedLines()).isEqualTo(Collections.singletonMap(rootBaseDir.resolve("sample1"), new HashSet<>(Arrays.asList(2, 3, 11, 12, 13))));
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void crash_on_invalid_start_line_format() throws IOException {
+ String example = "Index: sample1\n"
+ + "===================================================================\n"
+ + "--- a/sample1\n"
+ + "+++ b/sample1\n"
+ + "@@ -1 +x1,3 @@\n"
+ + " same line\n"
+ + "+added line 1\n"
+ + "+added line 2\n";
+
+ printDiff(example);
+ underTest.changedLines();
+ }
+
+ @Test
+ public void parse_diff_with_multiple_files() throws IOException {
+ String example = "Index: sample1\n"
+ + "===================================================================\n"
+ + "--- a/sample1\n"
+ + "+++ b/sample1\n"
+ + "@@ -1 +0,0 @@\n"
+ + "-deleted line\n"
+ + "Index: sample2\n"
+ + "===================================================================\n"
+ + "--- a/sample2\n"
+ + "+++ b/sample2\n"
+ + "@@ -0,0 +1 @@\n"
+ + "+added line\n"
+ + "Index: sample3\n"
+ + "===================================================================\n"
+ + "--- a/sample3\n"
+ + "+++ b/sample3\n"
+ + "@@ -0,0 +1,2 @@\n"
+ + "+added line 1\n"
+ + "+added line 2\n"
+ + "Index: sample3-not-included\n"
+ + "===================================================================\n"
+ + "--- a/sample3-not-included\n"
+ + "+++ b/sample3-not-included\n"
+ + "@@ -0,0 +1,2 @@\n"
+ + "+added line 1\n"
+ + "+added line 2\n"
+ + "Index: sample4\n"
+ + "===================================================================\n"
+ + "--- a/sample4\n"
+ + "+++ b/sample4\n"
+ + "@@ -1 +1,3 @@\n"
+ + " same line\n"
+ + "+added line 1\n"
+ + "+added line 2\n";
+
+ printDiff(example);
+ Map<Path, Set<Integer>> expected = new HashMap<>();
+ expected.put(rootBaseDir.resolve("sample2"), Collections.singleton(1));
+ expected.put(rootBaseDir.resolve("sample3"), new HashSet<>(Arrays.asList(1, 2)));
+ expected.put(rootBaseDir.resolve("sample4"), new HashSet<>(Arrays.asList(2, 3)));
+
+ assertThat(underTest.changedLines()).isEqualTo(expected);
+ }
+
+ private void printDiff(String unifiedDiff) throws IOException {
+ try (OutputStreamWriter writer = new OutputStreamWriter(underTest.receiver())) {
+ writer.write(unifiedDiff);
+ }
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.scm.svn;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.ClassRule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.tmatesoft.svn.core.SVNException;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+public class FindForkTest {
+
+ @ClassRule
+ public static TemporaryFolder temp = new TemporaryFolder();
+
+ private static SvnTester svnTester;
+
+ private static Path trunk;
+ private static Path b1;
+ private static Path b2;
+
+ private FindFork findFork;
+
+ @BeforeClass
+ public static void before() throws IOException, SVNException {
+ svnTester = new SvnTester(temp.newFolder().toPath());
+
+ trunk = temp.newFolder("trunk").toPath();
+ svnTester.checkout(trunk, "trunk");
+ createAndCommitFile(trunk, "file-1-commit-in-trunk.xoo");
+ createAndCommitFile(trunk, "file-2-commit-in-trunk.xoo");
+ createAndCommitFile(trunk, "file-3-commit-in-trunk.xoo");
+ svnTester.checkout(trunk, "trunk");
+
+ svnTester.createBranch("b1");
+ b1 = temp.newFolder("branches", "b1").toPath();
+ svnTester.checkout(b1, "branches/b1");
+ createAndCommitFile(b1, "file-1-commit-in-b1.xoo");
+ createAndCommitFile(b1, "file-2-commit-in-b1.xoo");
+ createAndCommitFile(b1, "file-3-commit-in-b1.xoo");
+ svnTester.checkout(b1, "branches/b1");
+
+ svnTester.createBranch("branches/b1", "b2");
+ b2 = temp.newFolder("branches", "b2").toPath();
+ svnTester.checkout(b2, "branches/b2");
+
+ createAndCommitFile(b2, "file-1-commit-in-b2.xoo");
+ createAndCommitFile(b2, "file-2-commit-in-b2.xoo");
+ createAndCommitFile(b2, "file-3-commit-in-b2.xoo");
+ svnTester.checkout(b2, "branches/b2");
+ }
+
+ @Before
+ public void setUp() {
+ SvnConfiguration configurationMock = mock(SvnConfiguration.class);
+ findFork = new FindFork(configurationMock);
+ }
+
+ @Test
+ public void testEmptyBranch() throws SVNException, IOException {
+ svnTester.createBranch("empty");
+ Path empty = temp.newFolder("branches", "empty").toPath();
+
+ svnTester.checkout(empty, "branches/empty");
+ ForkPoint forkPoint = findFork.find(empty, "unknown");
+ assertThat(forkPoint).isNull();
+ }
+
+ @Test
+ public void returnNoDate() throws SVNException {
+ FindFork findFork = new FindFork(mock(SvnConfiguration.class)) {
+ @Override
+ public ForkPoint find(Path location, String referenceBranch) {
+ return null;
+ }
+ };
+
+ assertThat(findFork.findDate(Paths.get(""), "branch")).isNull();
+ }
+
+ @Test
+ public void testTrunk() throws SVNException {
+ ForkPoint forkPoint = findFork.find(trunk, "unknown");
+ assertThat(forkPoint).isNull();
+ }
+
+ @Test
+ public void testB1() throws SVNException {
+ ForkPoint forkPoint = findFork.find(b1, "trunk");
+ assertThat(forkPoint.commit()).isEqualTo("5");
+ }
+
+ @Test
+ public void testB2() throws SVNException {
+ ForkPoint forkPoint = findFork.find(b2, "branches/b1");
+ assertThat(forkPoint.commit()).isEqualTo("9");
+ }
+
+ @Test
+ public void testB2Date() throws SVNException {
+ assertThat(findFork.findDate(b2, "branches/b1")).isNotNull();
+ }
+
+ @Test
+ public void testB2FromTrunk() throws SVNException {
+ ForkPoint forkPoint = findFork.find(b2, "trunk");
+ assertThat(forkPoint.commit()).isEqualTo("5");
+ }
+
+ private static void createAndCommitFile(Path worktree, String filename, String content) throws IOException, SVNException {
+ svnTester.createFile(worktree, filename, content);
+ svnTester.add(worktree, filename);
+ svnTester.commit(worktree);
+ }
+
+ private static void createAndCommitFile(Path worktree, String filename) throws IOException, SVNException {
+ createAndCommitFile(worktree, filename, filename + "\n");
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.scm.svn;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardOpenOption;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.stream.IntStream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameters;
+import org.mockito.ArgumentCaptor;
+import org.sonar.api.batch.fs.FileSystem;
+import org.sonar.api.batch.fs.internal.DefaultInputFile;
+import org.sonar.api.batch.fs.internal.TestInputFileBuilder;
+import org.sonar.api.batch.scm.BlameCommand.BlameInput;
+import org.sonar.api.batch.scm.BlameCommand.BlameOutput;
+import org.sonar.api.batch.scm.BlameLine;
+import org.tmatesoft.svn.core.SVNDepth;
+import org.tmatesoft.svn.core.SVNURL;
+import org.tmatesoft.svn.core.auth.ISVNAuthenticationManager;
+import org.tmatesoft.svn.core.internal.wc2.compat.SvnCodec;
+import org.tmatesoft.svn.core.wc.ISVNOptions;
+import org.tmatesoft.svn.core.wc.SVNClientManager;
+import org.tmatesoft.svn.core.wc.SVNRevision;
+import org.tmatesoft.svn.core.wc.SVNUpdateClient;
+import org.tmatesoft.svn.core.wc.SVNWCUtil;
+import org.tmatesoft.svn.core.wc2.SvnCheckout;
+import org.tmatesoft.svn.core.wc2.SvnTarget;
+
+import static java.util.Collections.singletonList;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+@RunWith(Parameterized.class)
+public class SvnBlameCommandTest {
+
+ /*
+ * Note about SONARSCSVN-11: The case of a project baseDir is in a subFolder of working copy is part of method tests by default
+ */
+
+ private static final String DUMMY_JAVA = "src/main/java/org/dummy/Dummy.java";
+
+ @Rule
+ public TemporaryFolder temp = new TemporaryFolder();
+
+ @Rule
+ public ExpectedException thrown = ExpectedException.none();
+
+ private FileSystem fs;
+ private BlameInput input;
+ private String serverVersion;
+ private int wcVersion;
+
+ @Parameters(name = "SVN server version {0}, WC version {1}")
+ public static Iterable<Object[]> data() {
+ return Arrays.asList(new Object[][] {{"1.6", 10}, {"1.7", 29}, {"1.8", 31}, {"1.9", 31}});
+ }
+
+ public SvnBlameCommandTest(String serverVersion, int wcVersion) {
+ this.serverVersion = serverVersion;
+ this.wcVersion = wcVersion;
+ }
+
+ @Before
+ public void prepare() {
+ fs = mock(FileSystem.class);
+ input = mock(BlameInput.class);
+ when(input.fileSystem()).thenReturn(fs);
+ }
+
+ @Test
+ public void testParsingOfOutput() throws Exception {
+ File repoDir = unzip("repo-svn.zip");
+
+ String scmUrl = "file:///" + unixPath(new File(repoDir, "repo-svn"));
+ File baseDir = new File(checkout(scmUrl), "dummy-svn");
+
+ when(fs.baseDir()).thenReturn(baseDir);
+ DefaultInputFile inputFile = new TestInputFileBuilder("foo", DUMMY_JAVA)
+ .setLines(27)
+ .setModuleBaseDir(baseDir.toPath())
+ .build();
+
+ BlameOutput blameResult = mock(BlameOutput.class);
+ when(input.filesToBlame()).thenReturn(singletonList(inputFile));
+
+ newSvnBlameCommand().blame(input, blameResult);
+ ArgumentCaptor<List> captor = ArgumentCaptor.forClass(List.class);
+ verify(blameResult).blameResult(eq(inputFile), captor.capture());
+ List<BlameLine> result = captor.getValue();
+ assertThat(result).hasSize(27);
+ Date commitDate = new Date(1342691097393L);
+ BlameLine[] expected = IntStream.rangeClosed(1, 27).mapToObj(i -> new BlameLine().date(commitDate).revision("2").author("dgageot")).toArray(BlameLine[]::new);
+ assertThat(result).containsExactly(expected);
+ }
+
+ private File unzip(String repoName) throws IOException {
+ File repoDir = temp.newFolder();
+ try {
+ javaUnzip(Paths.get(this.getClass().getResource("test-repos").toURI()).resolve(serverVersion).resolve(repoName).toFile(), repoDir);
+ return repoDir;
+ } catch (URISyntaxException e) {
+ throw new IOException(e);
+ }
+ }
+
+ private File checkout(String scmUrl) throws Exception {
+ ISVNOptions options = SVNWCUtil.createDefaultOptions(true);
+ ISVNAuthenticationManager isvnAuthenticationManager = SVNWCUtil.createDefaultAuthenticationManager(null, null, (char[]) null, false);
+ SVNClientManager svnClientManager = SVNClientManager.newInstance(options, isvnAuthenticationManager);
+ File out = temp.newFolder();
+ SVNUpdateClient updateClient = svnClientManager.getUpdateClient();
+ SvnCheckout co = updateClient.getOperationsFactory().createCheckout();
+ co.setUpdateLocksOnDemand(updateClient.isUpdateLocksOnDemand());
+ co.setSource(SvnTarget.fromURL(SVNURL.parseURIEncoded(scmUrl), SVNRevision.HEAD));
+ co.setSingleTarget(SvnTarget.fromFile(out));
+ co.setRevision(SVNRevision.HEAD);
+ co.setDepth(SVNDepth.INFINITY);
+ co.setAllowUnversionedObstructions(false);
+ co.setIgnoreExternals(updateClient.isIgnoreExternals());
+ co.setExternalsHandler(SvnCodec.externalsHandler(updateClient.getExternalsHandler()));
+ co.setTargetWorkingCopyFormat(wcVersion);
+ co.run();
+ return out;
+ }
+
+ @Test
+ public void testParsingOfOutputWithMergeHistory() throws Exception {
+ File repoDir = unzip("repo-svn-with-merge.zip");
+
+ String scmUrl = "file:///" + unixPath(new File(repoDir, "repo-svn"));
+ File baseDir = new File(checkout(scmUrl), "dummy-svn/trunk");
+
+ when(fs.baseDir()).thenReturn(baseDir);
+ DefaultInputFile inputFile = new TestInputFileBuilder("foo", DUMMY_JAVA)
+ .setLines(27)
+ .setModuleBaseDir(baseDir.toPath())
+ .build();
+
+ BlameOutput blameResult = mock(BlameOutput.class);
+ when(input.filesToBlame()).thenReturn(singletonList(inputFile));
+
+ newSvnBlameCommand().blame(input, blameResult);
+ ArgumentCaptor<List> captor = ArgumentCaptor.forClass(List.class);
+ verify(blameResult).blameResult(eq(inputFile), captor.capture());
+ List<BlameLine> result = captor.getValue();
+ assertThat(result).hasSize(27);
+ Date commitDate = new Date(1342691097393L);
+ Date revision6Date = new Date(1415262184300L);
+
+ BlameLine[] expected = IntStream.rangeClosed(1, 27).mapToObj(i -> {
+ if (i == 2 || i == 24) {
+ return new BlameLine().date(revision6Date).revision("6").author("henryju");
+ } else {
+ return new BlameLine().date(commitDate).revision("2").author("dgageot");
+ }
+ }).toArray(BlameLine[]::new);
+
+ assertThat(result).containsExactly(expected);
+ }
+
+ @Test
+ public void shouldNotFailIfFileContainsLocalModification() throws Exception {
+ File repoDir = unzip("repo-svn.zip");
+
+ String scmUrl = "file:///" + unixPath(new File(repoDir, "repo-svn"));
+ File baseDir = new File(checkout(scmUrl), "dummy-svn");
+
+ when(fs.baseDir()).thenReturn(baseDir);
+ DefaultInputFile inputFile = new TestInputFileBuilder("foo", DUMMY_JAVA)
+ .setLines(28)
+ .setModuleBaseDir(baseDir.toPath())
+ .build();
+
+ Files.write(baseDir.toPath().resolve(DUMMY_JAVA), "\n//foo".getBytes(), StandardOpenOption.APPEND);
+
+ BlameOutput blameResult = mock(BlameOutput.class);
+ when(input.filesToBlame()).thenReturn(singletonList(inputFile));
+
+ newSvnBlameCommand().blame(input, blameResult);
+ verifyNoInteractions(blameResult);
+ }
+
+ // SONARSCSVN-7
+ @Test
+ public void shouldNotFailOnWrongFilename() throws Exception {
+ File repoDir = unzip("repo-svn.zip");
+
+ String scmUrl = "file:///" + unixPath(new File(repoDir, "repo-svn"));
+ File baseDir = new File(checkout(scmUrl), "dummy-svn");
+
+ when(fs.baseDir()).thenReturn(baseDir);
+ DefaultInputFile inputFile = new TestInputFileBuilder("foo", DUMMY_JAVA.toLowerCase())
+ .setLines(27)
+ .setModuleBaseDir(baseDir.toPath())
+ .build();
+
+ BlameOutput blameResult = mock(BlameOutput.class);
+ when(input.filesToBlame()).thenReturn(singletonList(inputFile));
+
+ newSvnBlameCommand().blame(input, blameResult);
+ verifyNoInteractions(blameResult);
+ }
+
+ @Test
+ public void shouldNotFailOnUncommitedFile() throws Exception {
+ File repoDir = unzip("repo-svn.zip");
+
+ String scmUrl = "file:///" + unixPath(new File(repoDir, "repo-svn"));
+ File baseDir = new File(checkout(scmUrl), "dummy-svn");
+
+ when(fs.baseDir()).thenReturn(baseDir);
+ String relativePath = "src/main/java/org/dummy/Dummy2.java";
+ DefaultInputFile inputFile = new TestInputFileBuilder("foo", relativePath)
+ .setLines(28)
+ .setModuleBaseDir(baseDir.toPath())
+ .build();
+
+ Files.write(baseDir.toPath().resolve(relativePath), "package org.dummy;\npublic class Dummy2 {}".getBytes());
+
+ BlameOutput blameResult = mock(BlameOutput.class);
+ when(input.filesToBlame()).thenReturn(singletonList(inputFile));
+
+ newSvnBlameCommand().blame(input, blameResult);
+ verifyNoInteractions(blameResult);
+ }
+
+ @Test
+ public void shouldNotFailOnUncommitedDir() throws Exception {
+ File repoDir = unzip("repo-svn.zip");
+
+ String scmUrl = "file:///" + unixPath(new File(repoDir, "repo-svn"));
+ File baseDir = new File(checkout(scmUrl), "dummy-svn");
+
+ when(fs.baseDir()).thenReturn(baseDir);
+ String relativePath = "src/main/java/org/dummy2/dummy/Dummy.java";
+ DefaultInputFile inputFile = new TestInputFileBuilder("foo", relativePath)
+ .setLines(28)
+ .setModuleBaseDir(baseDir.toPath())
+ .build();
+
+ Path filepath = new File(baseDir, relativePath).toPath();
+ Files.createDirectories(filepath.getParent());
+ Files.write(filepath, "package org.dummy;\npublic class Dummy {}".getBytes());
+
+ BlameOutput blameResult = mock(BlameOutput.class);
+ when(input.filesToBlame()).thenReturn(singletonList(inputFile));
+
+ newSvnBlameCommand().blame(input, blameResult);
+ verifyNoInteractions(blameResult);
+ }
+
+ private static void javaUnzip(File zip, File toDir) {
+ try {
+ try (ZipFile zipFile = new ZipFile(zip)) {
+ Enumeration<? extends ZipEntry> entries = zipFile.entries();
+ while (entries.hasMoreElements()) {
+ ZipEntry entry = entries.nextElement();
+ File to = new File(toDir, entry.getName());
+ if (entry.isDirectory()) {
+ Files.createDirectories(to.toPath());
+ } else {
+ File parent = to.getParentFile();
+ if (parent != null) {
+ Files.createDirectories(parent.toPath());
+ }
+
+ Files.copy(zipFile.getInputStream(entry), to.toPath());
+ }
+ }
+ }
+ } catch (Exception e) {
+ throw new IllegalStateException("Fail to unzip " + zip + " to " + toDir, e);
+ }
+ }
+
+ private static String unixPath(File file) {
+ return file.getAbsolutePath().replace('\\', '/');
+ }
+
+ private SvnBlameCommand newSvnBlameCommand() {
+ return new SvnBlameCommand(mock(SvnConfiguration.class));
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.scm.svn;
+
+import java.io.File;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.sonar.api.config.PropertyDefinitions;
+import org.sonar.api.config.internal.MapSettings;
+import org.sonar.api.utils.System2;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.fail;
+
+public class SvnConfigurationTest {
+
+ @Rule
+ public TemporaryFolder temp = new TemporaryFolder();
+
+ @Test
+ public void sanityCheck() throws Exception {
+ MapSettings settings = new MapSettings(new PropertyDefinitions(System2.INSTANCE, SvnConfiguration.getProperties()));
+ SvnConfiguration config = new SvnConfiguration(settings.asConfig());
+
+ assertThat(config.username()).isNull();
+ assertThat(config.password()).isNull();
+
+ settings.setProperty(SvnConfiguration.USER_PROP_KEY, "foo");
+ assertThat(config.username()).isEqualTo("foo");
+
+ settings.setProperty(SvnConfiguration.PASSWORD_PROP_KEY, "pwd");
+ assertThat(config.password()).isEqualTo("pwd");
+
+ settings.setProperty(SvnConfiguration.PASSPHRASE_PROP_KEY, "pass");
+ assertThat(config.passPhrase()).isEqualTo("pass");
+
+ assertThat(config.privateKey()).isNull();
+ File fakeKey = temp.newFile();
+ settings.setProperty(SvnConfiguration.PRIVATE_KEY_PATH_PROP_KEY, fakeKey.getAbsolutePath());
+ assertThat(config.privateKey()).isEqualTo(fakeKey);
+
+ settings.setProperty(SvnConfiguration.PRIVATE_KEY_PATH_PROP_KEY, "/not/exists");
+ try {
+ config.privateKey();
+ fail("Expected exception");
+ } catch (Exception e) {
+ assertThat(e).hasMessageContaining("Unable to read private key from ");
+ }
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.scm.svn;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.rules.TemporaryFolder;
+import org.sonar.api.batch.scm.ScmProvider;
+import org.tmatesoft.svn.core.SVNCancelException;
+import org.tmatesoft.svn.core.SVNException;
+import org.tmatesoft.svn.core.SVNURL;
+import org.tmatesoft.svn.core.wc.SVNClientManager;
+import org.tmatesoft.svn.core.wc.SVNInfo;
+import org.tmatesoft.svn.core.wc.SVNLogClient;
+import org.tmatesoft.svn.core.wc.SVNWCClient;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class SvnScmProviderTest {
+
+ // Sample content for unified diffs
+ // http://www.gnu.org/software/diffutils/manual/html_node/Example-Unified.html#Example-Unified
+ private static final String CONTENT_LAO = "The Way that can be told of is not the eternal Way;\n"
+ + "The name that can be named is not the eternal name.\n"
+ + "The Nameless is the origin of Heaven and Earth;\n"
+ + "The Named is the mother of all things.\n"
+ + "Therefore let there always be non-being,\n"
+ + " so we may see their subtlety,\n"
+ + "And let there always be being,\n"
+ + " so we may see their outcome.\n"
+ + "The two are the same,\n"
+ + "But after they are produced,\n"
+ + " they have different names.\n";
+
+ private static final String CONTENT_TZU = "The Nameless is the origin of Heaven and Earth;\n"
+ + "The named is the mother of all things.\n"
+ + "\n"
+ + "Therefore let there always be non-being,\n"
+ + " so we may see their subtlety,\n"
+ + "And let there always be being,\n"
+ + " so we may see their outcome.\n"
+ + "The two are the same,\n"
+ + "But after they are produced,\n"
+ + " they have different names.\n"
+ + "They both may be called deep and profound.\n"
+ + "Deeper and more profound,\n"
+ + "The door of all subtleties!";
+
+ @Rule
+ public TemporaryFolder temp = new TemporaryFolder();
+
+ @Rule
+ public ExpectedException thrown = ExpectedException.none();
+
+ private FindFork findFork = mock(FindFork.class);
+ private SvnConfiguration config = mock(SvnConfiguration.class);
+ private SvnTester svnTester;
+
+ @Before
+ public void before() throws IOException, SVNException {
+ svnTester = new SvnTester(temp.newFolder().toPath());
+
+ Path worktree = temp.newFolder().toPath();
+ svnTester.checkout(worktree, "trunk");
+ createAndCommitFile(worktree, "file-in-first-commit.xoo");
+ }
+
+ @Test
+ public void sanityCheck() {
+ SvnBlameCommand blameCommand = new SvnBlameCommand(config);
+ SvnScmProvider svnScmProvider = new SvnScmProvider(config, blameCommand, findFork);
+ assertThat(svnScmProvider.key()).isEqualTo("svn");
+ assertThat(svnScmProvider.blameCommand()).isEqualTo(blameCommand);
+ }
+
+ @Test
+ public void testAutodetection() throws IOException {
+ ScmProvider scmBranchProvider = newScmProvider();
+
+ File baseDirEmpty = temp.newFolder();
+ assertThat(scmBranchProvider.supports(baseDirEmpty)).isFalse();
+
+ File svnBaseDir = temp.newFolder();
+ Files.createDirectory(svnBaseDir.toPath().resolve(".svn"));
+ assertThat(scmBranchProvider.supports(svnBaseDir)).isTrue();
+
+ File svnBaseDirSubFolder = temp.newFolder();
+ Files.createDirectory(svnBaseDirSubFolder.toPath().resolve(".svn"));
+ File projectBaseDir = new File(svnBaseDirSubFolder, "folder");
+ Files.createDirectory(projectBaseDir.toPath());
+ assertThat(scmBranchProvider.supports(projectBaseDir)).isTrue();
+ }
+
+ @Test
+ public void branchChangedFiles_and_lines_from_diverged() throws IOException, SVNException {
+ Path trunk = temp.newFolder().toPath();
+ svnTester.checkout(trunk, "trunk");
+ createAndCommitFile(trunk, "file-m1.xoo");
+ createAndCommitFile(trunk, "file-m2.xoo");
+ createAndCommitFile(trunk, "file-m3.xoo");
+ createAndCommitFile(trunk, "lao.txt", CONTENT_LAO);
+
+ // create branch from trunk
+ svnTester.createBranch("b1");
+
+ // still on trunk
+ appendToAndCommitFile(trunk, "file-m3.xoo");
+ createAndCommitFile(trunk, "file-m4.xoo");
+
+ Path b1 = temp.newFolder().toPath();
+ svnTester.checkout(b1, "branches/b1");
+ Files.createDirectories(b1.resolve("sub"));
+ createAndCommitFile(b1, "sub/file-b1.xoo");
+ appendToAndCommitFile(b1, "file-m1.xoo");
+ deleteAndCommitFile(b1, "file-m2.xoo");
+
+ createAndCommitFile(b1, "file-m5.xoo");
+ deleteAndCommitFile(b1, "file-m5.xoo");
+
+ svnCopyAndCommitFile(b1, "file-m1.xoo", "file-m1-copy.xoo");
+ appendToAndCommitFile(b1, "file-m1.xoo");
+
+ // modify file without committing it -> should not be included (think generated files)
+ svnTester.appendToFile(b1, "file-m3.xoo");
+
+ svnTester.update(b1);
+
+ Set<Path> changedFiles = newScmProvider().branchChangedFiles("trunk", b1);
+ assertThat(changedFiles)
+ .containsExactlyInAnyOrder(
+ b1.resolve("sub/file-b1.xoo"),
+ b1.resolve("file-m1.xoo"),
+ b1.resolve("file-m1-copy.xoo"));
+
+ // use a subset of changed files for .branchChangedLines to verify only requested files are returned
+ assertThat(changedFiles.remove(b1.resolve("sub/file-b1.xoo"))).isTrue();
+
+ // generate common sample diff
+ createAndCommitFile(b1, "lao.txt", CONTENT_TZU);
+ changedFiles.add(b1.resolve("lao.txt"));
+
+ // a file that should not yield any results
+ changedFiles.add(b1.resolve("nonexistent"));
+
+ // modify file without committing to it
+ svnTester.appendToFile(b1, "file-m1.xoo");
+
+ Map<Path, Set<Integer>> expected = new HashMap<>();
+ expected.put(b1.resolve("lao.txt"), new HashSet<>(Arrays.asList(2, 3, 11, 12, 13)));
+ expected.put(b1.resolve("file-m1.xoo"), new HashSet<>(Arrays.asList(2, 3, 4)));
+ expected.put(b1.resolve("file-m1-copy.xoo"), new HashSet<>(Arrays.asList(1, 2)));
+
+ assertThat(newScmProvider().branchChangedLines("trunk", b1, changedFiles))
+ .isEqualTo(expected);
+
+ assertThat(newScmProvider().branchChangedLines("trunk", b1, Collections.singleton(b1.resolve("nonexistent"))))
+ .isEmpty();
+ }
+
+ @Test
+ public void branchChangedFiles_should_return_empty_when_no_local_changes() throws IOException, SVNException {
+ Path b1 = temp.newFolder().toPath();
+ svnTester.createBranch("b1");
+ svnTester.checkout(b1, "branches/b1");
+
+ assertThat(newScmProvider().branchChangedFiles("b1", b1)).isEmpty();
+ }
+
+ @Test
+ public void branchChangedFiles_should_return_null_when_repo_nonexistent() throws IOException {
+ assertThat(newScmProvider().branchChangedFiles("trunk", temp.newFolder().toPath())).isNull();
+ }
+
+ @Test
+ public void branchChangedFiles_should_return_null_when_dir_nonexistent() {
+ assertThat(newScmProvider().branchChangedFiles("trunk", temp.getRoot().toPath().resolve("nonexistent"))).isNull();
+ }
+
+ @Test
+ public void branchChangedLines_should_return_null_when_repo_nonexistent() throws IOException {
+ assertThat(newScmProvider().branchChangedLines("trunk", temp.newFolder().toPath(), Collections.emptySet())).isNull();
+ }
+
+ @Test
+ public void branchChangedLines_should_return_null_when_dir_nonexistent() {
+ assertThat(newScmProvider().branchChangedLines("trunk", temp.getRoot().toPath().resolve("nonexistent"), Collections.emptySet())).isNull();
+ }
+
+ @Test
+ public void branchChangedLines_should_return_empty_when_no_local_changes() throws IOException, SVNException {
+ Path b1 = temp.newFolder().toPath();
+ svnTester.createBranch("b1");
+ svnTester.checkout(b1, "branches/b1");
+
+ assertThat(newScmProvider().branchChangedLines("b1", b1, Collections.emptySet())).isEmpty();
+ }
+
+ @Test
+ public void branchChangedLines_should_return_null_when_invalid_diff_format() throws IOException, SVNException {
+ Path b1 = temp.newFolder().toPath();
+ svnTester.createBranch("b1");
+ svnTester.checkout(b1, "branches/b1");
+
+ SvnScmProvider scmProvider = new SvnScmProvider(config, new SvnBlameCommand(config), findFork) {
+ @Override
+ ChangedLinesComputer newChangedLinesComputer(Path rootBaseDir, Set<Path> changedFiles) {
+ throw new IllegalStateException("crash");
+ }
+ };
+ assertThat(scmProvider.branchChangedLines("b1", b1, Collections.emptySet())).isNull();
+ }
+
+ @Test
+ public void forkDate_returns_null_if_no_fork_found() {
+ assertThat(new SvnScmProvider(config, new SvnBlameCommand(config), findFork).forkDate("branch", Paths.get(""))).isNull();
+ }
+
+ @Test
+ public void forkDate_returns_instant_if_fork_found() throws SVNException {
+ Path rootBaseDir = Paths.get("");
+ String referenceBranch = "branch";
+ Instant forkDate = Instant.ofEpochMilli(123456789L);
+ SvnScmProvider provider = new SvnScmProvider(config, new SvnBlameCommand(config), findFork);
+ when(findFork.findDate(rootBaseDir, referenceBranch)).thenReturn(forkDate);
+
+ assertThat(provider.forkDate(referenceBranch, rootBaseDir)).isEqualTo(forkDate);
+ }
+
+ @Test
+ public void forkDate_returns_null_if_exception_occurs() throws SVNException {
+ Path rootBaseDir = Paths.get("");
+ String referenceBranch = "branch";
+ SvnScmProvider provider = new SvnScmProvider(config, new SvnBlameCommand(config), findFork);
+ when(findFork.findDate(rootBaseDir, referenceBranch)).thenThrow(new SVNCancelException());
+
+ assertThat(provider.forkDate(referenceBranch, rootBaseDir)).isNull();
+ }
+
+ @Test
+ public void computeChangedPaths_should_not_crash_when_getRepositoryRootURL_getPath_is_empty() throws SVNException {
+ // verify assumptions about what SVNKit returns as svn root path for urls like http://svnserver/
+ assertThat(SVNURL.parseURIEncoded("http://svnserver/").getPath()).isEmpty();
+ assertThat(SVNURL.parseURIEncoded("http://svnserver").getPath()).isEmpty();
+
+ SVNClientManager svnClientManagerMock = mock(SVNClientManager.class);
+
+ SVNWCClient svnwcClientMock = mock(SVNWCClient.class);
+ when(svnClientManagerMock.getWCClient()).thenReturn(svnwcClientMock);
+
+ SVNLogClient svnLogClient = mock(SVNLogClient.class);
+ when(svnClientManagerMock.getLogClient()).thenReturn(svnLogClient);
+
+ SVNInfo svnInfoMock = mock(SVNInfo.class);
+ when(svnwcClientMock.doInfo(any(), any())).thenReturn(svnInfoMock);
+
+ // Simulate repository root on /, SVNKIT then returns an repository root url WITHOUT / at the end.
+ when(svnInfoMock.getRepositoryRootURL()).thenReturn(SVNURL.parseURIEncoded("http://svnserver"));
+ when(svnInfoMock.getURL()).thenReturn(SVNURL.parseURIEncoded("http://svnserver/myproject/trunk/"));
+
+ assertThat(SvnScmProvider.computeChangedPaths(Paths.get("/"), svnClientManagerMock)).isEmpty();
+ }
+
+ private void createAndCommitFile(Path worktree, String filename, String content) throws IOException, SVNException {
+ svnTester.createFile(worktree, filename, content);
+ svnTester.add(worktree, filename);
+ svnTester.commit(worktree);
+ }
+
+ private void createAndCommitFile(Path worktree, String filename) throws IOException, SVNException {
+ createAndCommitFile(worktree, filename, filename + "\n");
+ }
+
+ private void appendToAndCommitFile(Path worktree, String filename) throws IOException, SVNException {
+ svnTester.appendToFile(worktree, filename);
+ svnTester.commit(worktree);
+ }
+
+ private void deleteAndCommitFile(Path worktree, String filename) throws IOException, SVNException {
+ svnTester.deleteFile(worktree, filename);
+ svnTester.commit(worktree);
+ }
+
+ private void svnCopyAndCommitFile(Path worktree, String src, String dst) throws SVNException {
+ svnTester.copy(worktree, src, dst);
+ svnTester.commit(worktree);
+ }
+
+ private SvnScmProvider newScmProvider() {
+ return new SvnScmProvider(config, new SvnBlameCommand(config), findFork);
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.scm.svn;
+
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.sonar.scm.svn.SvnScmSupport.newSvnClientManager;
+
+public class SvnScmSupportTest {
+ @Test
+ public void getExtensions() {
+ assertThat(SvnScmSupport.getObjects()).isNotEmpty();
+ }
+
+ @Test
+ public void newSvnClientManager_with_auth() {
+ SvnConfiguration config = mock(SvnConfiguration.class);
+ when(config.password()).thenReturn("password");
+ when(config.passPhrase()).thenReturn("passPhrase");
+ assertThat(newSvnClientManager(config)).isNotNull();
+ }
+
+ @Test
+ public void newSvnClientManager_without_auth() {
+ SvnConfiguration config = mock(SvnConfiguration.class);
+ assertThat(config.password()).isNull();
+ assertThat(config.passPhrase()).isNull();
+ assertThat(newSvnClientManager(config)).isNotNull();
+ }
+
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.scm.svn;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+import org.tmatesoft.svn.core.SVNDepth;
+import org.tmatesoft.svn.core.SVNException;
+import org.tmatesoft.svn.core.SVNURL;
+import org.tmatesoft.svn.core.io.SVNRepositoryFactory;
+import org.tmatesoft.svn.core.wc.SVNClientManager;
+import org.tmatesoft.svn.core.wc.SVNCopyClient;
+import org.tmatesoft.svn.core.wc.SVNCopySource;
+import org.tmatesoft.svn.core.wc.SVNRevision;
+import org.tmatesoft.svn.core.wc.SVNUpdateClient;
+import org.tmatesoft.svn.core.wc2.SvnList;
+import org.tmatesoft.svn.core.wc2.SvnOperationFactory;
+import org.tmatesoft.svn.core.wc2.SvnRemoteMkDir;
+import org.tmatesoft.svn.core.wc2.SvnTarget;
+
+public class SvnTester {
+ private final SVNClientManager manager = SVNClientManager.newInstance(new SvnOperationFactory());
+
+ private final SVNURL localRepository;
+
+ public SvnTester(Path root) throws SVNException, IOException {
+ localRepository = SVNRepositoryFactory.createLocalRepository(root.toFile(), false, false);
+ mkdir("trunk");
+ mkdir("branches");
+ }
+
+ private void mkdir(String relpath) throws IOException, SVNException {
+ SvnRemoteMkDir remoteMkDir = manager.getOperationFactory().createRemoteMkDir();
+ remoteMkDir.addTarget(SvnTarget.fromURL(localRepository.appendPath(relpath, false)));
+ remoteMkDir.run();
+ }
+
+ public void createBranch(String branchName) throws IOException, SVNException {
+ SVNCopyClient copyClient = manager.getCopyClient();
+ SVNCopySource source = new SVNCopySource(SVNRevision.HEAD, SVNRevision.HEAD, localRepository.appendPath("trunk", false));
+ copyClient.doCopy(new SVNCopySource[] {source}, localRepository.appendPath("branches/" + branchName, false), false, false, true, "Create branch", null);
+ }
+
+ public void createBranch(String branchSource, String branchName) throws IOException, SVNException {
+ SVNCopyClient copyClient = manager.getCopyClient();
+ SVNCopySource source = new SVNCopySource(SVNRevision.HEAD, SVNRevision.HEAD, localRepository.appendPath(branchSource, false));
+ copyClient.doCopy(new SVNCopySource[] {source}, localRepository.appendPath("branches/" + branchName, false), false, false, true, "Create branch", null);
+ }
+
+ public void checkout(Path worktree, String path) throws SVNException {
+ SVNUpdateClient updateClient = manager.getUpdateClient();
+ updateClient.doCheckout(localRepository.appendPath(path, false),
+ worktree.toFile(), null, null, SVNDepth.INFINITY, false);
+ }
+
+ public void add(Path worktree, String filename) throws SVNException {
+ manager.getWCClient().doAdd(worktree.resolve(filename).toFile(), true, false, false, SVNDepth.INFINITY, false, false, true);
+ }
+
+ public void copy(Path worktree, String src, String dst) throws SVNException {
+ SVNCopyClient copyClient = manager.getCopyClient();
+ SVNCopySource source = new SVNCopySource(SVNRevision.HEAD, SVNRevision.HEAD, worktree.resolve(src).toFile());
+ copyClient.doCopy(new SVNCopySource[]{source}, worktree.resolve(dst).toFile(), false, false, true);
+ }
+
+ public void commit(Path worktree) throws SVNException {
+ manager.getCommitClient().doCommit(new File[] {worktree.toFile()}, false, "commit " + worktree, null, null, false, false, SVNDepth.INFINITY);
+ }
+
+ public void update(Path worktree) throws SVNException {
+ manager.getUpdateClient().doUpdate(new File[] {worktree.toFile()}, SVNRevision.HEAD, SVNDepth.INFINITY, false, false);
+ }
+
+ public Collection<String> list(String... paths) throws SVNException {
+ Set<String> results = new HashSet<>();
+
+ SvnList list = manager.getOperationFactory().createList();
+ if (paths.length == 0) {
+ list.addTarget(SvnTarget.fromURL(localRepository));
+ } else {
+ for (String path : paths) {
+ list.addTarget(SvnTarget.fromURL(localRepository.appendPath(path, false)));
+ }
+ }
+ list.setDepth(SVNDepth.INFINITY);
+ list.setReceiver((svnTarget, svnDirEntry) -> {
+ String path = svnDirEntry.getRelativePath();
+ if (!path.isEmpty()) {
+ results.add(path);
+ }
+ });
+ list.run();
+
+ return results;
+ }
+
+ public void createFile(Path worktree, String filename, String content) throws IOException {
+ Files.write(worktree.resolve(filename), content.getBytes());
+ }
+
+ public void createFile(Path worktree, String filename) throws IOException {
+ createFile(worktree, filename, filename + "\n");
+ }
+
+ public void appendToFile(Path worktree, String filename) throws IOException {
+ Files.write(worktree.resolve(filename), (filename + "\n").getBytes(), StandardOpenOption.APPEND);
+ }
+
+ public void deleteFile(Path worktree, String filename) throws SVNException {
+ manager.getWCClient().doDelete(worktree.resolve(filename).toFile(), false, false);
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.scm.svn;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.tmatesoft.svn.core.SVNException;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class SvnTesterTest {
+ @Rule
+ public TemporaryFolder temp = new TemporaryFolder();
+
+ private SvnTester tester;
+
+ @Before
+ public void before() throws IOException, SVNException {
+ tester = new SvnTester(temp.newFolder().toPath());
+ }
+
+ @Test
+ public void test_init() throws SVNException {
+ assertThat(tester.list()).containsExactlyInAnyOrder("trunk", "branches");
+ }
+
+ @Test
+ public void test_add_and_commit() throws IOException, SVNException {
+ assertThat(tester.list("trunk")).isEmpty();
+
+ Path worktree = temp.newFolder().toPath();
+ tester.checkout(worktree, "trunk");
+ tester.createFile(worktree, "file1");
+
+ tester.add(worktree, "file1");
+ tester.commit(worktree);
+
+ assertThat(tester.list("trunk")).containsOnly("file1");
+ }
+
+ @Test
+ public void test_createBranch() throws IOException, SVNException {
+ tester.createBranch("b1");
+ assertThat(tester.list()).containsExactlyInAnyOrder("trunk", "branches", "branches/b1");
+ assertThat(tester.list("branches")).containsOnly("b1");
+ }
+}