diff --git a/docs/external-resources.md b/docs/external-resources.md
index 1880b24..1844884 100644
--- a/docs/external-resources.md
+++ b/docs/external-resources.md
@@ -18,6 +18,7 @@
## Third Party
- [Mustache Templates](https://github.com/mustache/mustache.github.com)
- [JGit Authentication](https://www.codeaffine.com/2014/12/09/jgit-authentication/)
+- [SVNKit](https://svnkit.com/documentation.html)
## Others
- [Git documentation](https://git-scm.com/doc)
diff --git a/semver-maven-plugin/src/main/java/io/github/sam42r/semver/SemanticReleaseMojo.java b/semver-maven-plugin/src/main/java/io/github/sam42r/semver/SemanticReleaseMojo.java
index b86aad7..cdaf6b6 100644
--- a/semver-maven-plugin/src/main/java/io/github/sam42r/semver/SemanticReleaseMojo.java
+++ b/semver-maven-plugin/src/main/java/io/github/sam42r/semver/SemanticReleaseMojo.java
@@ -219,7 +219,7 @@ private LatestReleaseInfo getLatestRelease(SCMProvider scmProvider) throws MojoE
var tags = scmProvider.readTags();
var commits = scmProvider.readCommits(null);
- var latestTagOpt = tags.max(new TagVersionComparator(tagFormat));
+ var latestTagOpt = tags.filter(t -> Version.matchesPattern(t.getName(), tagFormat)).max(new TagVersionComparator(tagFormat));
var latestCommitOpt = latestTagOpt.map(Tag::getCommitId)
.or(() -> commits.min(Comparator.comparing(Commit::getTimestamp)).map(Commit::getId));
diff --git a/semver-maven-plugin/src/main/java/io/github/sam42r/semver/model/Version.java b/semver-maven-plugin/src/main/java/io/github/sam42r/semver/model/Version.java
index 3e8e1f0..6ca6a63 100644
--- a/semver-maven-plugin/src/main/java/io/github/sam42r/semver/model/Version.java
+++ b/semver-maven-plugin/src/main/java/io/github/sam42r/semver/model/Version.java
@@ -4,12 +4,14 @@
import org.apache.commons.text.StringSubstitutor;
import java.util.Map;
+import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* @author Sam42R
*/
@Getter
+@EqualsAndHashCode
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public final class Version {
@@ -42,6 +44,18 @@ public static Version of(@NonNull String version) {
}
public static Version of(@NonNull String version, @NonNull String tagFormat) {
+ var matcher = getMatcher(version, tagFormat);
+ if (matcher.find()) {
+ return new Version(
+ Integer.parseInt(matcher.group(Type.MAJOR.name())),
+ Integer.parseInt(matcher.group(Type.MINOR.name())),
+ Integer.parseInt(matcher.group(Type.PATCH.name())),
+ tagFormat);
+ }
+ throw new IllegalArgumentException("Could not create version for '%s' with tag format '%s'".formatted(version, tagFormat));
+ }
+
+ private static Matcher getMatcher(String version, String tagFormat) {
if (!tagFormat.contains(VERSION_PLACEHOLDER)) {
throw new IllegalArgumentException(
"Given tag format '%s' does not contain required version placeholder '%s'".formatted(
@@ -55,15 +69,7 @@ public static Version of(@NonNull String version, @NonNull String tagFormat) {
PLACEHOLDER_SUFFIX);
var pattern = Pattern.compile(regex);
- var matcher = pattern.matcher(version);
- if (matcher.find()) {
- return new Version(
- Integer.parseInt(matcher.group(Type.MAJOR.name())),
- Integer.parseInt(matcher.group(Type.MINOR.name())),
- Integer.parseInt(matcher.group(Type.PATCH.name())),
- tagFormat);
- }
- throw new IllegalArgumentException("Could not create version for '%s' with tag format '%s'".formatted(version, tagFormat));
+ return pattern.matcher(version);
}
public static Version of(@NonNull int major, int minor, int patch) {
@@ -74,6 +80,10 @@ public static Version of(@NonNull int major, int minor, int patch, String tagFor
return new Version(major, minor, patch, tagFormat);
}
+ public static boolean matchesPattern(String version, String tagFormat) {
+ return getMatcher(version, tagFormat).find();
+ }
+
public void increment(@NonNull Type type) {
if (Type.MAJOR.equals(type)) {
this.major++;
diff --git a/semver-maven-plugin/src/test/java/io/github/sam42r/semver/model/VersionTest.java b/semver-maven-plugin/src/test/java/io/github/sam42r/semver/model/VersionTest.java
index 4173e58..958c35c 100644
--- a/semver-maven-plugin/src/test/java/io/github/sam42r/semver/model/VersionTest.java
+++ b/semver-maven-plugin/src/test/java/io/github/sam42r/semver/model/VersionTest.java
@@ -2,6 +2,8 @@
import org.junit.jupiter.api.Test;
+import java.util.List;
+
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -52,4 +54,19 @@ void shouldIncrement() {
assertThat(actual).hasToString("2.0.0");
assertThat(actual.toTag()).isEqualTo("v2.0.0");
}
+
+ @Test
+ void shouldFilterInvalidVersions() {
+ var tags = List.of("v0.0.1", "r-0-0-1", "test", "v0.0.2");
+
+ var actual = tags.stream()
+ .filter(v -> Version.matchesPattern(v, Version.TAG_FORMAT_DEFAULT))
+ .map(Version::of)
+ .toList();
+
+ assertThat(actual).containsExactlyInAnyOrder(
+ Version.of(0,0,1),
+ Version.of(0,0,2)
+ );
+ }
}
diff --git a/semver-scm-api/README.md b/semver-scm-api/README.md
new file mode 100644
index 0000000..1cba498
--- /dev/null
+++ b/semver-scm-api/README.md
@@ -0,0 +1,11 @@
+# SCM provider distribution
+
+```mermaid
+%%{init: {'theme': 'base', 'themeVariables': {'pie1': 'lightsteelblue', 'pie2': 'goldenrod', 'pie3': 'ghostwhite'}}}%%
+pie title "SCM provider ditribution"
+ "git": 70
+ "svn": 15
+ "others": 15
+```
+
+source: [worldmetrics.org](https://worldmetrics.org/version-control-systems-industry-statistics/)
\ No newline at end of file
diff --git a/semver-scm-api/pom.xml b/semver-scm-api/pom.xml
index 9b3e4b5..dc55bd4 100644
--- a/semver-scm-api/pom.xml
+++ b/semver-scm-api/pom.xml
@@ -42,4 +42,21 @@
test
+
+
+
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+ 2.4
+
+
+
+ test-jar
+
+
+
+
+
+
diff --git a/semver-scm-api/src/main/java/io/github/sam42r/semver/scm/AbstractScmProvider.java b/semver-scm-api/src/main/java/io/github/sam42r/semver/scm/AbstractScmProvider.java
index 951d562..6fffb84 100644
--- a/semver-scm-api/src/main/java/io/github/sam42r/semver/scm/AbstractScmProvider.java
+++ b/semver-scm-api/src/main/java/io/github/sam42r/semver/scm/AbstractScmProvider.java
@@ -7,10 +7,7 @@
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NonNull;
-import org.apache.maven.scm.ChangeSet;
-import org.apache.maven.scm.ScmException;
-import org.apache.maven.scm.ScmFileSet;
-import org.apache.maven.scm.ScmRevision;
+import org.apache.maven.scm.*;
import org.apache.maven.scm.command.changelog.ChangeLogScmRequest;
import org.apache.maven.scm.manager.BasicScmManager;
import org.apache.maven.scm.manager.NoSuchScmProviderException;
@@ -20,6 +17,10 @@
import org.apache.maven.scm.repository.ScmRepositoryException;
import java.nio.file.Path;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Comparator;
+import java.util.Date;
import java.util.Optional;
import java.util.function.Predicate;
import java.util.stream.Stream;
@@ -32,6 +33,8 @@
abstract class AbstractScmProvider implements SCMProvider {
private static final Predicate hasTag = changeSet -> changeSet.getTags() != null && !changeSet.getTags().isEmpty();
+ private static final Predicate hasTagPath = changeSet -> changeSet.getFiles() != null &&
+ !changeSet.getFiles().isEmpty() && changeSet.getFiles().get(0).getName().contains("/tags/");
private final Path path;
private final String username;
@@ -57,6 +60,10 @@ protected AbstractScmProvider(
this.scmManager.setScmProvider(providerType, provider);
}
+ protected Path getFileBase() {
+ return getPath();
+ }
+
@Override
public @NonNull Stream readCommits(String fromCommitId) throws SCMException {
return readCommits(fromCommitId, null);
@@ -69,19 +76,23 @@ protected AbstractScmProvider(
var changeLogScmRequest = new ChangeLogScmRequest(repository, new ScmFileSet(path.toFile()));
if (fromCommitId != null) {
changeLogScmRequest.setStartRevision(new ScmRevision(fromCommitId));
+ } else {
+ changeLogScmRequest.setStartDate(Date.from(Instant.EPOCH));
}
if (toCommitId != null) {
changeLogScmRequest.setEndRevision(new ScmRevision(toCommitId));
}
var changeLogScmResult = scmManager.changeLog(changeLogScmRequest);
+ check(changeLogScmResult);
return changeLogScmResult.getChangeLog().getChangeSets().stream()
+ .filter(v -> v.getComment() != null)
.map(v -> Commit.builder()
.id(v.getRevision())
- .timestamp(v.getDate().toInstant())
+ .timestamp(v.getDate().toInstant().truncatedTo(ChronoUnit.SECONDS))
.author(v.getAuthor())
- .message(v.getComment())
+ .message(v.getComment().trim())
.build());
} catch (ScmException e) {
throw new SCMException(e);
@@ -94,12 +105,15 @@ protected AbstractScmProvider(
var repository = getScmRepository();
var changeLogScmRequest = new ChangeLogScmRequest(repository, new ScmFileSet(path.toFile()));
+ changeLogScmRequest.setStartDate(Date.from(Instant.EPOCH));
+
var changeLogScmResult = scmManager.changeLog(changeLogScmRequest);
+ check(changeLogScmResult);
return changeLogScmResult.getChangeLog().getChangeSets().stream()
- .filter(hasTag)
+ .filter(hasTag.or(hasTagPath))
.map(v -> Tag.builder()
- .name(v.getTags().get(0))
+ .name(getTag(v))
.commitId(v.getRevision())
.build());
} catch (ScmException e) {
@@ -107,13 +121,23 @@ protected AbstractScmProvider(
}
}
+ private String getTag(@NonNull ChangeSet changeSet) {
+ if (hasTag.test(changeSet)) {
+ return changeSet.getTags().get(0);
+ } else if (hasTagPath.test(changeSet)) {
+ var filename = changeSet.getFiles().get(0).getName();
+ return filename.substring(filename.lastIndexOf("/") + 1);
+ }
+ return null;
+ }
+
@Override
public void addFile(@NonNull Path file) throws SCMException {
try {
var repository = getScmRepository();
- var addScmResult = scmManager.add(repository, new ScmFileSet(path.toFile(), file.toFile()));
- assert addScmResult.isSuccess();
+ var addScmResult = scmManager.add(repository, new ScmFileSet(getFileBase().toFile(), getFileBase().relativize(file).toFile()));
+ check(addScmResult);
} catch (ScmException e) {
throw new SCMException(e);
}
@@ -125,9 +149,9 @@ public void addFile(@NonNull Path file) throws SCMException {
var repository = getScmRepository();
var checkInScmResult = scmManager.checkIn(repository, new ScmFileSet(path.toFile()), message);
- var scmRevision = checkInScmResult.getScmRevision();
+ check(checkInScmResult);
- return readCommits(scmRevision, scmRevision).findFirst().orElseThrow();
+ return readCommits(null).max(Comparator.comparing(Commit::getTimestamp)).orElseThrow();
} catch (ScmException e) {
throw new SCMException(e);
}
@@ -138,8 +162,8 @@ public void addFile(@NonNull Path file) throws SCMException {
try {
var repository = getScmRepository();
- var tagScmResult = scmManager.tag(repository, new ScmFileSet(path.toFile()), name);
- assert tagScmResult.isSuccess();
+ var tagScmResult = scmManager.tag(repository, new ScmFileSet(getFileBase().toFile()), name);
+ check(tagScmResult);
return readTags().filter(v -> name.equals(v.getName())).findFirst().orElseThrow();
} catch (ScmException e) {
@@ -163,5 +187,11 @@ protected ScmRepository getScmRepository() throws SCMException {
}
}
+ private void check(@NonNull ScmResult scmResult) throws SCMException {
+ if (!scmResult.isSuccess()) {
+ throw new SCMException(scmResult.getProviderMessage(), new IllegalStateException(scmResult.getCommandOutput()));
+ }
+ }
+
protected abstract Optional getRemoteUrl() throws SCMException;
}
diff --git a/semver-scm-api/src/test/java/io/github/sam42r/semver/scm/AbstractScmProviderTest.java b/semver-scm-api/src/test/java/io/github/sam42r/semver/scm/AbstractScmProviderTest.java
new file mode 100644
index 0000000..2e25543
--- /dev/null
+++ b/semver-scm-api/src/test/java/io/github/sam42r/semver/scm/AbstractScmProviderTest.java
@@ -0,0 +1,33 @@
+package io.github.sam42r.semver.scm;
+
+import io.github.sam42r.semver.scm.AbstractScmProvider;
+import io.github.sam42r.semver.scm.SCMException;
+import io.github.sam42r.semver.scm.SCMProvider;
+import org.apache.maven.scm.ScmException;
+import org.apache.maven.scm.ScmFileSet;
+import org.apache.maven.scm.command.status.StatusScmResult;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Path;
+
+public abstract class AbstractScmProviderTest {
+
+ @TempDir
+ protected Path tempDirectory;
+ protected SCMProvider uut;
+
+ protected StatusScmResult status() throws SCMException {
+ if (uut instanceof AbstractScmProvider abstractScmProvider) {
+ var path = abstractScmProvider.getPath();
+ var scmManager = abstractScmProvider.getScmManager();
+ var scmRepository = abstractScmProvider.getScmRepository();
+
+ try {
+ return scmManager.status(scmRepository, new ScmFileSet(path.toFile()));
+ } catch (ScmException e) {
+ throw new SCMException(e);
+ }
+ }
+ throw new IllegalArgumentException("Invalid SCMProvider '%s'".formatted(uut.getClass()));
+ }
+}
diff --git a/semver-scm-api/src/test/java/io/github/sam42r/semver/scm/StatusScmResultAssert.java b/semver-scm-api/src/test/java/io/github/sam42r/semver/scm/StatusScmResultAssert.java
new file mode 100644
index 0000000..bbc1e92
--- /dev/null
+++ b/semver-scm-api/src/test/java/io/github/sam42r/semver/scm/StatusScmResultAssert.java
@@ -0,0 +1,43 @@
+package io.github.sam42r.semver.scm;
+
+import org.apache.maven.scm.ScmFile;
+import org.apache.maven.scm.command.status.StatusScmResult;
+import org.assertj.core.api.AbstractAssert;
+import org.assertj.core.api.Assertions;
+
+public class StatusScmResultAssert extends AbstractAssert {
+
+ protected StatusScmResultAssert(StatusScmResult statusScmResult) {
+ super(statusScmResult, StatusScmResultAssert.class);
+ }
+
+ public static StatusScmResultAssert assertThat(StatusScmResult statusScmResult) {
+ return new StatusScmResultAssert(statusScmResult);
+ }
+
+ public StatusScmResultAssert isSuccess() {
+ if (!actual.isSuccess()) {
+ failWithMessage("Expected result to be successful");
+ }
+ return this;
+ }
+
+ public StatusScmResultAssert hasChangedFiles() {
+ if (actual.getChangedFiles().isEmpty()) {
+ failWithMessage("Expected changed files to be not empty");
+ }
+ return this;
+ }
+
+ public StatusScmResultAssert hasNoChangedFiles() {
+ if (!actual.getChangedFiles().isEmpty()) {
+ failWithMessage("Expected changed files to be empty");
+ }
+ return this;
+ }
+
+ public StatusScmResultAssert containsChangedFilesExactlyInAnyOrder(ScmFile ... changedFiles) {
+ Assertions.assertThat(actual.getChangedFiles()).containsExactlyInAnyOrder(changedFiles);
+ return this;
+ }
+}
diff --git a/semver-scm-subversion/README.md b/semver-scm-subversion/README.md
new file mode 100644
index 0000000..f5b69a2
--- /dev/null
+++ b/semver-scm-subversion/README.md
@@ -0,0 +1,58 @@
+# SCM Subversion Provider
+
+TODO general notes and limitations
+
+## Public SVN repositories
+> Info: Seems that working copies checked out with HTTPS causing HTTP-400
+
+### Apache-Software-Foundation (ASF)
+The [Apache-Software-Foundation](https://www.apache.org/) serves [SVN](https://svn.apache.org/) repositories
+for all their [projects](https://www.apache.org/index.html#projects-list).
+
+```bash
+$ svn checkout https://svn.apache.org/repos/asf/avro avro-trunk
+```
+
+### Source-Forge (SF)
+[SourceForge](https://sourceforge.net/) serves [SVN](https://sourceforge.net/p/forge/documentation/SVN%20Overview/) repositories
+for a lot of open source projects.
+
+HTTPS
+```bash
+$ svn checkout https://svn.code.sf.net/p/keepass/code/trunk keepass-code
+```
+SVN
+```bash
+svn checkout svn://svn.code.sf.net/p/keepass/code/trunk keepass-code
+```
+
+## Local SVN repositories
+
+[SVN Chaet Sheet](https://www.perforce.com/blog/vcs/svn-commands-cheat-sheet)
+
+### Create local repository
+```bash
+$ svnadmin create sample
+```
+
+### Create working copy
+```bash
+$ svn checkout file:///$PWD/sample working
+```
+
+### Create test repository
+```bash
+$ svnadmin create junit
+$ svn checkout file:///$PWD/junit junit-working
+$ cd junit-working
+$ mkdir trunk
+$ mkdir tags
+$ mkdir branches
+$ echo "# JUnit" > trunk/README.md
+$ svn add branches tags trunk
+$ svn commit -m "chore: initial project setup"
+$ svn copy trunk/ tags/v0.0.1
+$ svn commit -m "release: v0.0.1"
+$ cd ..
+$ svnadmin dump junit > junit.dmp
+```
\ No newline at end of file
diff --git a/semver-scm-subversion/pom.xml b/semver-scm-subversion/pom.xml
index a4e0ea4..3de7c21 100644
--- a/semver-scm-subversion/pom.xml
+++ b/semver-scm-subversion/pom.xml
@@ -29,10 +29,58 @@
lombok
provided
+
+
+ com.google.code.maven-scm-provider-svnjava
+ maven-scm-provider-svnjava
+ 2.3.0
+
+
org.apache.commons
commons-lang3
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+ org.assertj
+ assertj-core
+ test
+
+
+ io.github.sam42r
+ semver-scm-api
+ ${project.version}
+ tests
+ test
+
+
+
+
+
+ org.tmatesoft.svnkit
+ svnkit
+ 1.10.11
+
+
+
+ org.apache.maven.scm
+ maven-scm-api
+ 2.1.0
+
+
+
+ org.apache.maven.scm
+ maven-scm-privider-svn-commons
+ 2.1.0
+
+
+
+
diff --git a/semver-scm-subversion/src/main/java/io/github/sam42r/semver/scm/SvnProvider.java b/semver-scm-subversion/src/main/java/io/github/sam42r/semver/scm/SvnProvider.java
index 2786c1c..f71edda 100644
--- a/semver-scm-subversion/src/main/java/io/github/sam42r/semver/scm/SvnProvider.java
+++ b/semver-scm-subversion/src/main/java/io/github/sam42r/semver/scm/SvnProvider.java
@@ -1,66 +1,77 @@
package io.github.sam42r.semver.scm;
-import io.github.sam42r.semver.scm.model.Commit;
-import io.github.sam42r.semver.scm.model.Remote;
-import io.github.sam42r.semver.scm.model.Tag;
import lombok.NonNull;
-import org.apache.commons.lang3.NotImplementedException;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.maven.scm.ScmException;
+import org.apache.maven.scm.ScmFile;
+import org.apache.maven.scm.ScmFileSet;
+import org.apache.maven.scm.ScmFileStatus;
+import org.apache.maven.scm.provider.svn.svnjava.SvnJavaScmProvider;
+import org.tmatesoft.svn.core.SVNException;
+import org.tmatesoft.svn.core.wc.SVNClientManager;
+import org.tmatesoft.svn.core.wc.SVNRevision;
+import java.nio.file.Files;
import java.nio.file.Path;
-import java.util.stream.Stream;
+import java.util.Optional;
/**
- * {@link SCMProvider} for subversion.
- * TODO implement
+ * {@link SCMProvider} for subversion.
+ * Subversion's Recommended Repository Layout
+ * is required (trunk, branches, tags).
*
* @author Sam42R
*/
-public class SvnProvider implements SCMProviderFactory, SCMProvider {
+@Slf4j
+public class SvnProvider extends AbstractScmProvider {
- private static final String PROVIDER_NAME = "Subversion";
-
- @Override
- public @NonNull String getProviderName() {
- return PROVIDER_NAME;
- }
-
- @Override
- public @NonNull SvnProvider getInstance(@NonNull Path path, String username, String password) {
- throw new NotImplementedException();
- }
-
- @Override
- public @NonNull Stream readCommits(String fromCommitId) throws SCMException {
- throw new SCMException(new NotImplementedException());
+ public SvnProvider(Path path, String username, String password) {
+ super("javasvn", new SvnJavaScmProvider(), path, username, password);
}
@Override
- public @NonNull Stream readTags() throws SCMException {
- throw new SCMException(new NotImplementedException());
+ protected Path getFileBase() {
+ return getPath().resolve("trunk");
}
@Override
public void addFile(@NonNull Path file) throws SCMException {
- throw new SCMException(new NotImplementedException());
- }
-
- @Override
- public @NonNull Commit commit(@NonNull String message) throws SCMException {
- throw new SCMException(new NotImplementedException());
+ try {
+ var statusResult = getScmManager().status(getScmRepository(), new ScmFileSet(getPath().toFile(), getPath().relativize(file).toFile()));
+ var scmFileStatus = statusResult.getChangedFiles().stream()
+ .filter(v -> file.endsWith(Path.of(v.getPath())))
+ .map(ScmFile::getStatus)
+ .findAny()
+ .orElseThrow();
+ if (ScmFileStatus.UNKNOWN.equals(scmFileStatus)) {
+ super.addFile(file);
+ }
+ } catch (ScmException e) {
+ throw new SCMException(e);
+ }
}
@Override
- public @NonNull Tag tag(@NonNull String name) throws SCMException {
- throw new SCMException(new NotImplementedException());
+ protected Optional getRemoteUrl() throws SCMException {
+ if (isWorkingCopy()) {
+ try {
+ var info = SVNClientManager.newInstance().getWCClient().doInfo(getPath().toFile(), SVNRevision.HEAD);
+ return Optional.ofNullable(info.getRepositoryRootURL().toDecodedString());
+ } catch (SVNException e) {
+ throw new SCMException(e);
+ }
+ } else {
+ return Optional.of("file://%s".formatted(getPath().toUri().getPath()));
+ }
}
@Override
- public String push(boolean force) throws SCMException {
- throw new SCMException(new NotImplementedException());
+ public @NonNull String push(boolean force) throws SCMException {
+ // SVN does NOT support push on demand (no distributed version control system)
+ return "changes already pushed to repository";
}
- @Override
- public @NonNull Remote getRemote() throws SCMException {
- throw new SCMException(new NotImplementedException());
+ private boolean isWorkingCopy() {
+ return Files.exists(getPath().resolve(".svn"));
}
}
diff --git a/semver-scm-subversion/src/main/java/io/github/sam42r/semver/scm/SvnProviderFactory.java b/semver-scm-subversion/src/main/java/io/github/sam42r/semver/scm/SvnProviderFactory.java
new file mode 100644
index 0000000..f1fae3c
--- /dev/null
+++ b/semver-scm-subversion/src/main/java/io/github/sam42r/semver/scm/SvnProviderFactory.java
@@ -0,0 +1,20 @@
+package io.github.sam42r.semver.scm;
+
+import lombok.NonNull;
+
+import java.nio.file.Path;
+
+public class SvnProviderFactory implements SCMProviderFactory {
+
+ private static final String PROVIDER_NAME = "Subversion";
+
+ @Override
+ public @NonNull String getProviderName() {
+ return PROVIDER_NAME;
+ }
+
+ @Override
+ public @NonNull SvnProvider getInstance(@NonNull Path path, String username, String password) {
+ return new SvnProvider(path, username, password);
+ }
+}
diff --git a/semver-scm-subversion/src/main/resources/META-INF/services/io.github.sam42r.semver.scm.SCMProviderFactory b/semver-scm-subversion/src/main/resources/META-INF/services/io.github.sam42r.semver.scm.SCMProviderFactory
index 9606ef9..ca61c70 100644
--- a/semver-scm-subversion/src/main/resources/META-INF/services/io.github.sam42r.semver.scm.SCMProviderFactory
+++ b/semver-scm-subversion/src/main/resources/META-INF/services/io.github.sam42r.semver.scm.SCMProviderFactory
@@ -1 +1 @@
-io.github.sam42r.semver.scm.SvnProvider
+io.github.sam42r.semver.scm.SvnProviderFactory
diff --git a/semver-scm-subversion/src/test/java/io/github/sam42r/semver/scm/SvnProviderTest.java b/semver-scm-subversion/src/test/java/io/github/sam42r/semver/scm/SvnProviderTest.java
new file mode 100644
index 0000000..bb317e8
--- /dev/null
+++ b/semver-scm-subversion/src/test/java/io/github/sam42r/semver/scm/SvnProviderTest.java
@@ -0,0 +1,165 @@
+package io.github.sam42r.semver.scm;
+
+import io.github.sam42r.semver.scm.model.Commit;
+import io.github.sam42r.semver.scm.model.Remote;
+import io.github.sam42r.semver.scm.model.Tag;
+import org.apache.maven.scm.ScmFile;
+import org.apache.maven.scm.ScmFileSet;
+import org.apache.maven.scm.ScmFileStatus;
+import org.apache.maven.scm.manager.BasicScmManager;
+import org.apache.maven.scm.provider.svn.svnjava.SvnJavaScmProvider;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.tmatesoft.svn.core.wc.SVNClientManager;
+
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.time.Instant;
+
+import static io.github.sam42r.semver.scm.StatusScmResultAssert.assertThat;
+import static org.assertj.core.api.Assertions.assertThat;
+
+class SvnProviderTest extends AbstractScmProviderTest {
+
+ @BeforeEach
+ void setup() throws Exception {
+ var repository = tempDirectory.resolve("repository");
+ var workingCopy = tempDirectory.resolve("working-copy");
+ initializeLocal(repository, workingCopy);
+
+ uut = new SvnProviderFactory().getInstance(workingCopy, null, null);
+ }
+
+ @Test
+ void shouldReadCommits() throws Exception {
+ var actual = uut.readCommits(null);
+
+ assertThat(actual).usingRecursiveFieldByFieldElementComparatorIgnoringFields("timestamp").containsExactly(
+ Commit.builder()
+ .id("1")
+ .timestamp(Instant.EPOCH) // ignored
+ .author("r_ric")
+ .message("chore: initial project setup")
+ .build(),
+ Commit.builder()
+ .id("2")
+ .timestamp(Instant.EPOCH)
+ .author("r_ric")
+ .message("release: v0.0.1")
+ .build()
+ );
+ }
+
+ @Test
+ void shouldReadTags() throws Exception {
+ var actual = uut.readTags();
+
+ assertThat(actual).containsExactly(
+ Tag.builder()
+ .name("v0.0.1")
+ .commitId("2")
+ .build()
+ );
+ }
+
+ @Test
+ void shouldAddAndCommitFiles() throws Exception {
+ // update file
+ var readme = ((AbstractScmProvider) uut).getFileBase().resolve("README.md");
+ Files.writeString(readme, "JUnit test", StandardOpenOption.APPEND);
+ // create file (e.g. Changelog.md)
+ var changelog = ((AbstractScmProvider) uut).getFileBase().resolve("Changelog.md");
+ Files.writeString(changelog, "# Changelog", StandardOpenOption.CREATE_NEW);
+
+ uut.addFile(readme);
+ uut.addFile(changelog);
+
+ assertThat(status())
+ .isSuccess()
+ .containsChangedFilesExactlyInAnyOrder(
+ new ScmFile("trunk/Changelog.md", ScmFileStatus.ADDED),
+ new ScmFile("trunk/README.md", ScmFileStatus.MODIFIED)
+ );
+
+ var actual = uut.commit("test: commit files to repository");
+ assertThat(actual.getId()).isNotNull();
+ assertThat(actual.getMessage()).isEqualTo("test: commit files to repository");
+
+ assertThat(status())
+ .isSuccess()
+ .hasNoChangedFiles();
+ }
+
+ @Test
+ void shouldTag() throws SCMException {
+ var actual = uut.readTags();
+ assertThat(actual).hasSize(1);
+
+ uut.tag("v0.0.2");
+
+ actual = uut.readTags();
+
+ assertThat(actual)
+ .hasSize(2)
+ .contains(Tag.builder()
+ .name("v0.0.2")
+ .commitId("3")
+ .build()
+ );
+ }
+
+ @Test
+ void shouldGetRemote() throws Exception {
+ var workingCopy = tempDirectory.resolve("working-copy-remote");
+ checkoutWorkingCopyRemote(URI.create("https://svn.code.sf.net/p/keepass/code/trunk"), workingCopy);
+
+ uut = new SvnProviderFactory().getInstance(workingCopy, null, null);
+
+ var actual = uut.getRemote();
+
+ assertThat(actual).isEqualTo(
+ Remote.builder()
+ .url("https://svn.code.sf.net/p/keepass/code")
+ .scheme("https")
+ .host("svn.code.sf.net")
+ .group("p/keepass")
+ .project("code")
+ .build()
+ );
+ }
+
+ private void initializeLocal(Path repository, Path workingCopy) throws Exception {
+ initializeRepositoryLocal(repository);
+ checkoutWorkingCopyLocal(repository, workingCopy);
+ }
+
+ private void initializeRepositoryLocal(Path repository) throws Exception {
+ var svnAdminClient = SVNClientManager.newInstance().getAdminClient();
+ try (var inputStream = SvnProviderTest.class.getClassLoader().getResourceAsStream("junit.dmp")) {
+ svnAdminClient.doCreateRepository(repository.toFile(), null, true, true);
+ svnAdminClient.doLoad(repository.toFile(), inputStream);
+ }
+ }
+
+ private void checkoutWorkingCopyLocal(Path repository, Path workingCopy) throws Exception {
+ var scmManager = new BasicScmManager();
+ scmManager.setScmProvider("javasvn", new SvnJavaScmProvider());
+
+ var scmRepository = scmManager.makeScmRepository("scm:javasvn:%s".formatted(repository.toUri()));
+
+ var checkOutResult = scmManager.checkOut(scmRepository, new ScmFileSet(workingCopy.toFile()));
+ assertThat(checkOutResult.isSuccess()).isTrue();
+ }
+
+ private void checkoutWorkingCopyRemote(URI repository, Path workingCopy) throws Exception {
+ var scmManager = new BasicScmManager();
+ scmManager.setScmProvider("javasvn", new SvnJavaScmProvider());
+
+ var scmRepository = scmManager.makeScmRepository("scm:javasvn:%s".formatted(repository));
+
+ var checkOutResult = scmManager.checkOut(scmRepository, new ScmFileSet(workingCopy.toFile()));
+ assertThat(checkOutResult.isSuccess()).isTrue();
+ }
+}
diff --git a/semver-scm-subversion/src/test/resources/junit.dmp b/semver-scm-subversion/src/test/resources/junit.dmp
new file mode 100644
index 0000000..aa69766
--- /dev/null
+++ b/semver-scm-subversion/src/test/resources/junit.dmp
@@ -0,0 +1,97 @@
+SVN-fs-dump-format-version: 2
+
+UUID: 2ff3aad0-4ff7-d14d-a396-6c0b991586e8
+
+Revision-number: 0
+Prop-content-length: 56
+Content-length: 56
+
+K 8
+svn:date
+V 27
+2025-01-26T09:00:46.354900Z
+PROPS-END
+
+Revision-number: 1
+Prop-content-length: 128
+Content-length: 128
+
+K 10
+svn:author
+V 5
+r_ric
+K 8
+svn:date
+V 27
+2025-01-26T09:04:49.946444Z
+K 7
+svn:log
+V 28
+chore: initial project setup
+PROPS-END
+
+Node-path: branches
+Node-kind: dir
+Node-action: add
+Prop-content-length: 10
+Content-length: 10
+
+PROPS-END
+
+
+Node-path: tags
+Node-kind: dir
+Node-action: add
+Prop-content-length: 10
+Content-length: 10
+
+PROPS-END
+
+
+Node-path: trunk
+Node-kind: dir
+Node-action: add
+Prop-content-length: 10
+Content-length: 10
+
+PROPS-END
+
+
+Node-path: trunk/README.md
+Node-kind: file
+Node-action: add
+Text-content-md5: 818a729d088a42248bfd42bf240afd54
+Text-content-sha1: 6a4e24dc1e2e832583b4d70a751caa73978ca611
+Prop-content-length: 10
+Text-content-length: 8
+Content-length: 18
+
+PROPS-END
+# JUnit
+
+
+Revision-number: 2
+Prop-content-length: 115
+Content-length: 115
+
+K 10
+svn:author
+V 5
+r_ric
+K 8
+svn:date
+V 27
+2025-01-26T09:06:28.901810Z
+K 7
+svn:log
+V 15
+release: v0.0.1
+PROPS-END
+
+Node-path: tags/v0.0.1
+Node-kind: dir
+Node-action: add
+Node-copyfrom-rev: 1
+Node-copyfrom-path: trunk
+
+