]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-13792 Embed sonar-scm-svn
authorDuarte Meneses <duarte.meneses@sonarsource.com>
Tue, 25 Aug 2020 19:25:04 +0000 (14:25 -0500)
committersonartech <sonartech@sonarsource.com>
Fri, 28 Aug 2020 20:06:52 +0000 (20:06 +0000)
31 files changed:
build.gradle
sonar-application/bundled_plugins.gradle
sonar-scanner-engine/build.gradle
sonar-scanner-engine/src/main/java/org/sonar/scanner/scan/ProjectScanContainer.java
sonar-scanner-engine/src/main/java/org/sonar/scm/git/GitScmSupport.java
sonar-scanner-engine/src/main/java/org/sonar/scm/svn/AnnotationHandler.java [new file with mode: 0644]
sonar-scanner-engine/src/main/java/org/sonar/scm/svn/ChangedLinesComputer.java [new file with mode: 0644]
sonar-scanner-engine/src/main/java/org/sonar/scm/svn/FindFork.java [new file with mode: 0644]
sonar-scanner-engine/src/main/java/org/sonar/scm/svn/ForkPoint.java [new file with mode: 0644]
sonar-scanner-engine/src/main/java/org/sonar/scm/svn/SvnBlameCommand.java [new file with mode: 0644]
sonar-scanner-engine/src/main/java/org/sonar/scm/svn/SvnConfiguration.java [new file with mode: 0644]
sonar-scanner-engine/src/main/java/org/sonar/scm/svn/SvnScmProvider.java [new file with mode: 0644]
sonar-scanner-engine/src/main/java/org/sonar/scm/svn/SvnScmSupport.java [new file with mode: 0644]
sonar-scanner-engine/src/main/java/org/sonar/scm/svn/package-info.java [new file with mode: 0644]
sonar-scanner-engine/src/test/java/org/sonar/scm/git/GitScmSupportTest.java
sonar-scanner-engine/src/test/java/org/sonar/scm/svn/ChangedLinesComputerTest.java [new file with mode: 0644]
sonar-scanner-engine/src/test/java/org/sonar/scm/svn/FindForkTest.java [new file with mode: 0644]
sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnBlameCommandTest.java [new file with mode: 0644]
sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnConfigurationTest.java [new file with mode: 0644]
sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnScmProviderTest.java [new file with mode: 0644]
sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnScmSupportTest.java [new file with mode: 0644]
sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnTester.java [new file with mode: 0644]
sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnTesterTest.java [new file with mode: 0644]
sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.6/repo-svn-with-merge.zip [new file with mode: 0644]
sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.6/repo-svn.zip [new file with mode: 0644]
sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.7/repo-svn-with-merge.zip [new file with mode: 0644]
sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.7/repo-svn.zip [new file with mode: 0644]
sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.8/repo-svn-with-merge.zip [new file with mode: 0644]
sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.8/repo-svn.zip [new file with mode: 0644]
sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.9/repo-svn-with-merge.zip [new file with mode: 0644]
sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.9/repo-svn.zip [new file with mode: 0644]

index 9d5d86773f49a089c1cb80b1b46439a28aa02750..1211e587defe32415cedf4bf99b4a4175bbb9b49 100644 (file)
@@ -182,7 +182,6 @@ subprojects {
       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
@@ -317,6 +316,7 @@ subprojects {
       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'
index 934e4c07e6d3254c828b62409229884f14c603ac..5bcbf92af7f1b034febe4ed2783fae4f27d9bf9e 100644 (file)
@@ -12,7 +12,6 @@ dependencies {
     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'
 }
index a555eb9756121506ccae3a39dbc8c7340540aa78..45cc8d25ecd327fb0ea9cc488f8fc92ebd9a12d3 100644 (file)
@@ -29,6 +29,7 @@ dependencies {
   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'
index ce29d00a358e89605aaa1e49329f35b4daa59dc5..30aac02d9f157900459c72184aaa4c4c1f28e743 100644 (file)
@@ -132,6 +132,7 @@ import org.sonar.scanner.sensor.ProjectSensorExtensionDictionnary;
 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;
@@ -302,7 +303,8 @@ public class ProjectScanContainer extends ComponentContainer {
 
       AnalysisObservers.class);
 
-    add(GitScmSupport.getClasses());
+    add(GitScmSupport.getObjects());
+    add(SvnScmSupport.getObjects());
 
     addIfMissing(DefaultProjectSettingsLoader.class, ProjectSettingsLoader.class);
     addIfMissing(DefaultRulesLoader.class, RulesLoader.class);
index b5a845edc9aa3461434b2a59c6ac116578a6326a..2e0928d275f11e0f7cc79f9425c94dd4bac2328e 100644 (file)
@@ -24,7 +24,11 @@ import java.util.List;
 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,
diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/AnnotationHandler.java b/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/AnnotationHandler.java
new file mode 100644 (file)
index 0000000..81d0f00
--- /dev/null
@@ -0,0 +1,63 @@
+/*
+ * 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;
+  }
+
+}
diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/ChangedLinesComputer.java b/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/ChangedLinesComputer.java
new file mode 100644 (file)
index 0000000..9a737e8
--- /dev/null
@@ -0,0 +1,163 @@
+/*
+ * 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;
+    }
+  }
+}
diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/FindFork.java b/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/FindFork.java
new file mode 100644 (file)
index 0000000..534dd36
--- /dev/null
@@ -0,0 +1,118 @@
+/*
+ * 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;
+    }
+  }
+}
diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/ForkPoint.java b/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/ForkPoint.java
new file mode 100644 (file)
index 0000000..8569ce4
--- /dev/null
@@ -0,0 +1,48 @@
+/*
+ * 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 +
+      '}';
+  }
+}
diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/SvnBlameCommand.java b/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/SvnBlameCommand.java
new file mode 100644 (file)
index 0000000..bb328f1
--- /dev/null
@@ -0,0 +1,118 @@
+/*
+ * 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;
+  }
+}
diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/SvnConfiguration.java b/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/SvnConfiguration.java
new file mode 100644 (file)
index 0000000..83e0cc7
--- /dev/null
@@ -0,0 +1,117 @@
+/*
+ * 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);
+  }
+
+}
diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/SvnScmProvider.java b/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/SvnScmProvider.java
new file mode 100644 (file)
index 0000000..1e44f89
--- /dev/null
@@ -0,0 +1,215 @@
+/*
+ * 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);
+  }
+}
diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/SvnScmSupport.java b/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/SvnScmSupport.java
new file mode 100644 (file)
index 0000000..03182cd
--- /dev/null
@@ -0,0 +1,62 @@
+/*
+ * 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());
+  }
+}
diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/package-info.java b/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/package-info.java
new file mode 100644 (file)
index 0000000..3af724b
--- /dev/null
@@ -0,0 +1,24 @@
+/*
+ * 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;
+
index 803b02088be0c948c47fb26d56048b599ab2b744..10c33f9af38d0c97f279dacf352f680e313729f7 100644 (file)
@@ -27,7 +27,7 @@ public class GitScmSupportTest {
 
   @Test
   public void getClasses() {
-    assertThat(GitScmSupport.getClasses()).hasSize(3);
+    assertThat(GitScmSupport.getObjects()).hasSize(3);
   }
 
 }
diff --git a/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/ChangedLinesComputerTest.java b/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/ChangedLinesComputerTest.java
new file mode 100644 (file)
index 0000000..bef5eb6
--- /dev/null
@@ -0,0 +1,195 @@
+/*
+ * 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);
+    }
+  }
+}
diff --git a/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/FindForkTest.java b/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/FindForkTest.java
new file mode 100644 (file)
index 0000000..5bbc7c4
--- /dev/null
@@ -0,0 +1,143 @@
+/*
+ * 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");
+  }
+}
diff --git a/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnBlameCommandTest.java b/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnBlameCommandTest.java
new file mode 100644 (file)
index 0000000..930853b
--- /dev/null
@@ -0,0 +1,321 @@
+/*
+ * 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));
+  }
+}
diff --git a/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnConfigurationTest.java b/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnConfigurationTest.java
new file mode 100644 (file)
index 0000000..7a765b2
--- /dev/null
@@ -0,0 +1,68 @@
+/*
+ * 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 ");
+    }
+  }
+}
diff --git a/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnScmProviderTest.java b/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnScmProviderTest.java
new file mode 100644 (file)
index 0000000..c815fdd
--- /dev/null
@@ -0,0 +1,325 @@
+/*
+ * 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);
+  }
+}
diff --git a/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnScmSupportTest.java b/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnScmSupportTest.java
new file mode 100644 (file)
index 0000000..33c3bd3
--- /dev/null
@@ -0,0 +1,51 @@
+/*
+ * 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();
+  }
+
+}
diff --git a/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnTester.java b/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnTester.java
new file mode 100644 (file)
index 0000000..ec00ca5
--- /dev/null
@@ -0,0 +1,135 @@
+/*
+ * 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);
+  }
+}
diff --git a/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnTesterTest.java b/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnTesterTest.java
new file mode 100644 (file)
index 0000000..0740bb3
--- /dev/null
@@ -0,0 +1,68 @@
+/*
+ * 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");
+  }
+}
diff --git a/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.6/repo-svn-with-merge.zip b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.6/repo-svn-with-merge.zip
new file mode 100644 (file)
index 0000000..d3c3ee5
Binary files /dev/null and b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.6/repo-svn-with-merge.zip differ
diff --git a/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.6/repo-svn.zip b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.6/repo-svn.zip
new file mode 100644 (file)
index 0000000..291d4fb
Binary files /dev/null and b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.6/repo-svn.zip differ
diff --git a/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.7/repo-svn-with-merge.zip b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.7/repo-svn-with-merge.zip
new file mode 100644 (file)
index 0000000..d3c3ee5
Binary files /dev/null and b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.7/repo-svn-with-merge.zip differ
diff --git a/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.7/repo-svn.zip b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.7/repo-svn.zip
new file mode 100644 (file)
index 0000000..291d4fb
Binary files /dev/null and b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.7/repo-svn.zip differ
diff --git a/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.8/repo-svn-with-merge.zip b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.8/repo-svn-with-merge.zip
new file mode 100644 (file)
index 0000000..852a05e
Binary files /dev/null and b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.8/repo-svn-with-merge.zip differ
diff --git a/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.8/repo-svn.zip b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.8/repo-svn.zip
new file mode 100644 (file)
index 0000000..f31352c
Binary files /dev/null and b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.8/repo-svn.zip differ
diff --git a/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.9/repo-svn-with-merge.zip b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.9/repo-svn-with-merge.zip
new file mode 100644 (file)
index 0000000..ca672fd
Binary files /dev/null and b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.9/repo-svn-with-merge.zip differ
diff --git a/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.9/repo-svn.zip b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.9/repo-svn.zip
new file mode 100644 (file)
index 0000000..39b241c
Binary files /dev/null and b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.9/repo-svn.zip differ