You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sling.apache.org by ro...@apache.org on 2026/07/04 13:04:23 UTC
(sling-org-apache-sling-committer-cli) 01/01: SLING-13253 - release update-dist command using embedded SVNKit
This is an automated email from the ASF dual-hosted git repository.
royteeuwen pushed a commit to branch feature/SLING-13253-update-dist
in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-committer-cli.git
commit 69d004e9412051fd9570993ec8595604a8e84114
Author: Roy Teeuwen <ro...@apache.org>
AuthorDate: Sat Jul 4 15:04:17 2026 +0200
SLING-13253 - release update-dist command using embedded SVNKit
Adds 'release update-dist' to move release artifacts from dist/dev to
dist/release on dist.apache.org and remove the previous release, using the
pure-Java SVNKit client over https instead of shelling out to svn/svnmucc.
SVNKit is embedded into the sling-cli bundle via bnd (its OSGi bundles are
published only to the Eclipse P2 update site, not Maven Central); only the jars
needed for https/DAV url operations are embedded, and the svn+ssh:// stack is
marked optional so the bundle resolves in Felix.
Part of splitting PR #28 into per-command changes; builds on the list PR.
---
bnd.bnd | 28 +-
pom.xml | 39 +++
.../sling/cli/impl/release/UpdateDistCommand.java | 265 +++++++++++++++++
.../cli/impl/release/UpdateDistCommandTest.java | 328 +++++++++++++++++++++
4 files changed, 659 insertions(+), 1 deletion(-)
diff --git a/bnd.bnd b/bnd.bnd
index 78e45e3..658fa39 100644
--- a/bnd.bnd
+++ b/bnd.bnd
@@ -1 +1,27 @@
-Import-Package: !org.fusesource.jansi, *
+# Embed SVNKit and the minimal transitive jars it needs for https/DAV url operations into this
+# bundle (see pom.xml). Everything lands on a single classloader so SVNKit's internal reflection
+# and service lookups resolve without cross-bundle wiring.
+-includeresource: \
+ @svnkit-[0-9.]*.jar, \
+ @sequence-library-[0-9.]*.jar, \
+ @sqljet-[0-9.]*.jar, \
+ @antlr-runtime-[0-9.]*.jar
+
+# The svn+ssh:// / native / server-side packages are intentionally not embedded; SVNKit references
+# them but never loads them on the https url code path, so import them optionally to keep the bundle
+# resolvable in the Felix runtime.
+Import-Package: \
+ !org.fusesource.jansi, \
+ org.apache.sshd.*;resolution:=optional, \
+ com.sun.jna.*;resolution:=optional, \
+ com.trilead.*;resolution:=optional, \
+ com.jcraft.*;resolution:=optional, \
+ net.i2p.crypto.*;resolution:=optional, \
+ net.jpountz.*;resolution:=optional, \
+ javax.servlet.*;resolution:=optional, \
+ org.tigris.subversion.javahl.*;resolution:=optional, \
+ org.antlr.stringtemplate.*;resolution:=optional, \
+ jcifs.*;resolution:=optional, \
+ de.rbri.*;resolution:=optional, \
+ javax.security.sasl;resolution:=optional, \
+ *
diff --git a/pom.xml b/pom.xml
index 1a4c18c..9cba820 100644
--- a/pom.xml
+++ b/pom.xml
@@ -131,6 +131,45 @@
<version>72.1</version>
<scope>provided</scope>
</dependency>
+ <!--
+ SVNKit is the only pure-Java Subversion client; its OSGi bundles are published only to the
+ Eclipse P2 update site, not to Maven Central. We therefore embed it (and the minimal set of
+ transitive jars needed for https/DAV url operations) into the sling-cli bundle via bnd. The
+ svn+ssh:// stack (sshd, jna, trilead, jsch-agentproxy, eddsa, lz4) is excluded and its
+ packages are marked optional in bnd.bnd.
+ -->
+ <dependency>
+ <groupId>org.tmatesoft.svnkit</groupId>
+ <artifactId>svnkit</artifactId>
+ <version>1.10.11</version>
+ <scope>provided</scope>
+ <exclusions>
+ <exclusion>
+ <groupId>org.apache.sshd</groupId>
+ <artifactId>*</artifactId>
+ </exclusion>
+ <exclusion>
+ <groupId>net.i2p.crypto</groupId>
+ <artifactId>*</artifactId>
+ </exclusion>
+ <exclusion>
+ <groupId>net.java.dev.jna</groupId>
+ <artifactId>*</artifactId>
+ </exclusion>
+ <exclusion>
+ <groupId>com.trilead</groupId>
+ <artifactId>*</artifactId>
+ </exclusion>
+ <exclusion>
+ <groupId>com.jcraft</groupId>
+ <artifactId>*</artifactId>
+ </exclusion>
+ <exclusion>
+ <groupId>org.lz4</groupId>
+ <artifactId>*</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
diff --git a/src/main/java/org/apache/sling/cli/impl/release/UpdateDistCommand.java b/src/main/java/org/apache/sling/cli/impl/release/UpdateDistCommand.java
new file mode 100644
index 0000000..4ff653d
--- /dev/null
+++ b/src/main/java/org/apache/sling/cli/impl/release/UpdateDistCommand.java
@@ -0,0 +1,265 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sling.cli.impl.release;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+
+import org.apache.sling.cli.impl.Command;
+import org.apache.sling.cli.impl.Credentials;
+import org.apache.sling.cli.impl.CredentialsService;
+import org.apache.sling.cli.impl.InputOption;
+import org.apache.sling.cli.impl.UserInput;
+import org.apache.sling.cli.impl.nexus.Artifact;
+import org.apache.sling.cli.impl.nexus.RepositoryService;
+import org.apache.sling.cli.impl.nexus.StagingRepository;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.tmatesoft.svn.core.SVNDirEntry;
+import org.tmatesoft.svn.core.SVNException;
+import org.tmatesoft.svn.core.SVNURL;
+import org.tmatesoft.svn.core.auth.BasicAuthenticationManager;
+import org.tmatesoft.svn.core.internal.io.dav.DAVRepositoryFactory;
+import org.tmatesoft.svn.core.io.SVNRepository;
+import org.tmatesoft.svn.core.io.SVNRepositoryFactory;
+import org.tmatesoft.svn.core.wc.SVNRevision;
+import org.tmatesoft.svn.core.wc2.SvnCopySource;
+import org.tmatesoft.svn.core.wc2.SvnOperationFactory;
+import org.tmatesoft.svn.core.wc2.SvnRemoteCopy;
+import org.tmatesoft.svn.core.wc2.SvnRemoteDelete;
+import org.tmatesoft.svn.core.wc2.SvnTarget;
+import picocli.CommandLine;
+
+/**
+ * Moves release artifacts from dist/dev to dist/release on dist.apache.org and removes the
+ * previous release, using SVNKit's pure-Java Subversion client over https.
+ * Requires PMC membership to commit to dist.apache.org.
+ */
+@Component(
+ service = Command.class,
+ property = {
+ Command.PROPERTY_NAME_COMMAND_GROUP + "=" + UpdateDistCommand.GROUP,
+ Command.PROPERTY_NAME_COMMAND_NAME + "=" + UpdateDistCommand.NAME
+ })
+@CommandLine.Command(
+ name = UpdateDistCommand.NAME,
+ description = "Moves release artifacts from dist/dev to dist/release on dist.apache.org and removes the"
+ + " previous release. Requires PMC membership.",
+ subcommands = CommandLine.HelpCommand.class)
+public class UpdateDistCommand implements Command {
+
+ static final String GROUP = "release";
+ static final String NAME = "update-dist";
+
+ static final String DIST_DEV_URL = "https://dist.apache.org/repos/dist/dev/sling/";
+ static final String DIST_RELEASE_URL = "https://dist.apache.org/repos/dist/release/sling/";
+
+ static {
+ // register the http(s):// DAV repository factory used by all SVNKit operations below
+ DAVRepositoryFactory.setup();
+ }
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(UpdateDistCommand.class);
+
+ @CommandLine.Option(
+ names = {"-r", "--repository"},
+ description = "Nexus staging repository id",
+ required = true)
+ private Integer repositoryId;
+
+ @CommandLine.Option(
+ names = {"--previous-version"},
+ description = "Previous release version to remove from dist/release (e.g. 1.0.0)."
+ + " Optional: if omitted, all older versions currently in dist/release are removed.")
+ private String previousVersion;
+
+ @CommandLine.Mixin
+ private ReusableCLIOptions reusableCLIOptions;
+
+ @Reference
+ private RepositoryService repositoryService;
+
+ @Reference
+ private CredentialsService credentialsService;
+
+ @Override
+ public Integer call() {
+ try {
+ StagingRepository repository = repositoryService.find(repositoryId);
+ Set<Artifact> artifacts = repositoryService.getArtifacts(repository);
+ Artifact primary = artifacts.stream()
+ .filter(a -> "pom".equals(a.getType()))
+ .findFirst()
+ .orElseThrow(() -> new IllegalStateException("No POM artifact found in staging repository"));
+ String artifactId = primary.getArtifactId();
+ String newVersion = primary.getVersion();
+
+ List<String> newFiles = listDistFiles(DIST_DEV_URL, artifactId + "-" + newVersion);
+ List<String> oldFiles = listPreviousReleaseFiles(artifactId, newVersion, previousVersion);
+
+ if (newFiles.isEmpty()) {
+ LOGGER.warn("No files found in {} matching {}-{}", DIST_DEV_URL, artifactId, newVersion);
+ LOGGER.warn("Ensure 'mvn release:perform' has staged source-release artifacts to dist/dev.");
+ return CommandLine.ExitCode.USAGE;
+ }
+
+ switch (reusableCLIOptions.executionMode) {
+ case DRY_RUN:
+ LOGGER.info("Would move {} file(s) from dist/dev to dist/release:", newFiles.size());
+ newFiles.forEach(f -> LOGGER.info(" mv {} -> {}", DIST_DEV_URL + f, DIST_RELEASE_URL + f));
+ if (!oldFiles.isEmpty()) {
+ LOGGER.info("Would remove {} old file(s) from dist/release:", oldFiles.size());
+ oldFiles.forEach(f -> LOGGER.info(" rm {}", DIST_RELEASE_URL + f));
+ }
+ break;
+ case INTERACTIVE:
+ String question = String.format(
+ "Move %d file(s) for %s %s to dist/release and remove %d older file(s) for %s?",
+ newFiles.size(), artifactId, newVersion, oldFiles.size(), artifactId);
+ InputOption answer = UserInput.yesNo(question, InputOption.YES);
+ if (InputOption.YES.equals(answer)) {
+ commitDistUpdate(
+ artifactId, newVersion, newFiles, oldFiles, credentialsService.getAsfCredentials());
+ } else {
+ LOGGER.info("Aborted.");
+ }
+ break;
+ case AUTO:
+ commitDistUpdate(
+ artifactId, newVersion, newFiles, oldFiles, credentialsService.getAsfCredentials());
+ break;
+ }
+ } catch (IOException e) {
+ LOGGER.warn("Failed executing command", e);
+ return CommandLine.ExitCode.SOFTWARE;
+ }
+ return CommandLine.ExitCode.OK;
+ }
+
+ /**
+ * Moves {@code newFiles} from {@code dist/dev} to {@code dist/release} and removes {@code oldFiles}
+ * from {@code dist/release}, using SVNKit over https. The move (copy + delete of the sources) is a
+ * single atomic commit; the removal of superseded files is a second commit. Ordering is deliberate:
+ * the new release is fully published before the previous one is removed, so an interrupted run never
+ * leaves {@code dist/release} without a released version.
+ */
+ static void commitDistUpdate(
+ String artifactId, String newVersion, List<String> newFiles, List<String> oldFiles, Credentials credentials)
+ throws IOException {
+ Logger logger = LoggerFactory.getLogger(UpdateDistCommand.class);
+ SvnOperationFactory svnOperationFactory = new SvnOperationFactory();
+ svnOperationFactory.setAuthenticationManager(
+ new BasicAuthenticationManager(credentials.getUsername(), credentials.getPassword()));
+ try {
+ SvnRemoteCopy move = svnOperationFactory.createRemoteCopy();
+ move.setMove(true);
+ move.setFailWhenDstExists(false);
+ move.setCommitMessage("Release " + artifactId + " " + newVersion);
+ move.setSingleTarget(SvnTarget.fromURL(SVNURL.parseURIEncoded(stripTrailingSlash(DIST_RELEASE_URL))));
+ for (String file : newFiles) {
+ move.addCopySource(SvnCopySource.create(
+ SvnTarget.fromURL(SVNURL.parseURIEncoded(DIST_DEV_URL + file)), SVNRevision.HEAD));
+ }
+ logger.info("Moving {} file(s) from dist/dev to dist/release...", newFiles.size());
+ move.run();
+
+ if (!oldFiles.isEmpty()) {
+ SvnRemoteDelete delete = svnOperationFactory.createRemoteDelete();
+ delete.setCommitMessage("Remove superseded release of " + artifactId);
+ for (String file : oldFiles) {
+ delete.addTarget(SvnTarget.fromURL(SVNURL.parseURIEncoded(DIST_RELEASE_URL + file)));
+ }
+ logger.info("Removing {} superseded file(s) from dist/release...", oldFiles.size());
+ delete.run();
+ }
+ logger.info("Done. dist.apache.org has been updated.");
+ } catch (SVNException e) {
+ throw new IOException("Failed to update dist.apache.org", e);
+ } finally {
+ svnOperationFactory.dispose();
+ }
+ }
+
+ /**
+ * Determines which files to remove from {@code dist/release} when publishing {@code newVersion}
+ * of {@code artifactId}. When {@code explicitPreviousVersion} is given, only that version's files
+ * are returned; otherwise every older version currently present in {@code dist/release} for this
+ * artifact is returned (the release directory holds only the latest release per ASF policy).
+ */
+ static List<String> listPreviousReleaseFiles(String artifactId, String newVersion, String explicitPreviousVersion)
+ throws IOException {
+ if (explicitPreviousVersion != null && !explicitPreviousVersion.isBlank()) {
+ return listDistFiles(DIST_RELEASE_URL, artifactId + "-" + explicitPreviousVersion);
+ }
+ return listDistFiles(DIST_RELEASE_URL, artifactId + "-").stream()
+ // keep only versioned files for this exact artifact (a numeric version component
+ // right after the prefix excludes sibling artifacts such as artifactId-extra-...)
+ .filter(f -> isVersionedArtifactFile(f, artifactId))
+ // never remove the version we are about to publish
+ .filter(f -> !belongsToVersion(f, artifactId, newVersion))
+ .toList();
+ }
+
+ private static boolean isVersionedArtifactFile(String fileName, String artifactId) {
+ String prefix = artifactId + "-";
+ return fileName.length() > prefix.length()
+ && fileName.startsWith(prefix)
+ && Character.isDigit(fileName.charAt(prefix.length()));
+ }
+
+ private static boolean belongsToVersion(String fileName, String artifactId, String version) {
+ String prefix = artifactId + "-" + version;
+ if (!fileName.startsWith(prefix)) {
+ return false;
+ }
+ // the version must be the whole name, or be followed by an extension dot or a classifier dash;
+ // this avoids matching version 1.0.14 against the longer 1.0.140
+ if (fileName.length() == prefix.length()) {
+ return true;
+ }
+ char next = fileName.charAt(prefix.length());
+ return next == '.' || next == '-';
+ }
+
+ static List<String> listDistFiles(String baseUrl, String prefix) throws IOException {
+ List<String> files = new ArrayList<>();
+ try {
+ SVNRepository repository = SVNRepositoryFactory.create(SVNURL.parseURIEncoded(baseUrl));
+ long revision = repository.getLatestRevision();
+ Collection<SVNDirEntry> entries = new ArrayList<>();
+ repository.getDir("", revision, null, entries);
+ entries.stream()
+ .map(SVNDirEntry::getName)
+ .filter(name -> name.startsWith(prefix))
+ .forEach(files::add);
+ } catch (SVNException e) {
+ throw new IOException("Failed to list " + baseUrl, e);
+ }
+ return files;
+ }
+
+ private static String stripTrailingSlash(String url) {
+ return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
+ }
+}
diff --git a/src/test/java/org/apache/sling/cli/impl/release/UpdateDistCommandTest.java b/src/test/java/org/apache/sling/cli/impl/release/UpdateDistCommandTest.java
new file mode 100644
index 0000000..2caf612
--- /dev/null
+++ b/src/test/java/org/apache/sling/cli/impl/release/UpdateDistCommandTest.java
@@ -0,0 +1,328 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sling.cli.impl.release;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Set;
+
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.sling.cli.impl.Command;
+import org.apache.sling.cli.impl.Credentials;
+import org.apache.sling.cli.impl.CredentialsService;
+import org.apache.sling.cli.impl.ExecutionMode;
+import org.apache.sling.cli.impl.InputOption;
+import org.apache.sling.cli.impl.UserInput;
+import org.apache.sling.cli.impl.junit.LogCapture;
+import org.apache.sling.cli.impl.nexus.Artifact;
+import org.apache.sling.cli.impl.nexus.RepositoryService;
+import org.apache.sling.cli.impl.nexus.StagingRepository;
+import org.apache.sling.testing.mock.osgi.junit.OsgiContext;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.MockedStatic;
+import picocli.CommandLine;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.when;
+
+public class UpdateDistCommandTest {
+
+ private static final String ARTIFACT = "org.apache.sling.feature.launcher";
+
+ @Rule
+ public final OsgiContext osgiContext = new OsgiContext();
+
+ @Rule
+ public final LogCapture logCapture = new LogCapture(UpdateDistCommand.class);
+
+ // ---- auto-deduce of the previous release files (listPreviousReleaseFiles) ----
+
+ @Test
+ public void testAutoDeducePreviousFilesExcludesNewVersionAndSiblings() throws Exception {
+ List<String> releaseDir = List.of(
+ ARTIFACT + "-1.3.4.pom",
+ ARTIFACT + "-1.3.4.pom.asc",
+ ARTIFACT + "-1.3.4-source-release.zip",
+ ARTIFACT + "-1.3.4-source-release.zip.asc",
+ ARTIFACT + "-1.3.6.pom", // the new version - must be kept (not removed)
+ ARTIFACT + "-1.3.6.pom.asc",
+ ARTIFACT + "-extra-1.0.0.pom" // a sibling artifact - must be ignored
+ );
+ try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS)) {
+ dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), anyString()))
+ .thenReturn(releaseDir);
+
+ List<String> old = UpdateDistCommand.listPreviousReleaseFiles(ARTIFACT, "1.3.6", null);
+
+ assertEquals(4, old.size());
+ assertTrue(old.contains(ARTIFACT + "-1.3.4.pom"));
+ assertTrue(old.contains(ARTIFACT + "-1.3.4-source-release.zip"));
+ // the version being published is never removed
+ assertFalse(old.contains(ARTIFACT + "-1.3.6.pom"));
+ assertFalse(old.contains(ARTIFACT + "-1.3.6.pom.asc"));
+ // sibling artifact with a non-numeric component is ignored
+ assertFalse(old.contains(ARTIFACT + "-extra-1.0.0.pom"));
+ }
+ }
+
+ @Test
+ public void testAutoDeduceDoesNotConfuseVersionPrefixes() throws Exception {
+ // publishing 1.0.14 must not treat 1.0.140 as the same version
+ List<String> releaseDir = List.of(ARTIFACT + "-1.0.140.pom", ARTIFACT + "-1.0.14.pom");
+ try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS)) {
+ dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), anyString()))
+ .thenReturn(releaseDir);
+
+ List<String> old = UpdateDistCommand.listPreviousReleaseFiles(ARTIFACT, "1.0.14", null);
+
+ // 1.0.140 is a different version and is removed; 1.0.14 (being published) is kept
+ assertEquals(List.of(ARTIFACT + "-1.0.140.pom"), old);
+ }
+ }
+
+ @Test
+ public void testExplicitPreviousVersionWins() throws Exception {
+ try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS)) {
+ dist.when(() -> UpdateDistCommand.listDistFiles(
+ eq(UpdateDistCommand.DIST_RELEASE_URL), eq(ARTIFACT + "-1.3.4")))
+ .thenReturn(List.of(ARTIFACT + "-1.3.4.pom"));
+
+ List<String> old = UpdateDistCommand.listPreviousReleaseFiles(ARTIFACT, "1.3.6", "1.3.4");
+
+ assertEquals(List.of(ARTIFACT + "-1.3.4.pom"), old);
+ // when an explicit version is given, the directory is not enumerated with the bare prefix
+ dist.verify(
+ () -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), eq(ARTIFACT + "-")),
+ never());
+ }
+ }
+
+ @Test
+ public void testAutoDeduceKeepsNewVersionWithClassifierAndExtension() throws Exception {
+ // files for the version being published (with both an extension '.' and a classifier '-' right
+ // after the version) must be kept, exercising belongsToVersion's trailing-character check
+ List<String> releaseDir = List.of(
+ ARTIFACT + "-1.3.6", // exact match: filename equals the version prefix with no extension
+ ARTIFACT + "-1.3.6.pom",
+ ARTIFACT + "-1.3.6-source-release.zip",
+ ARTIFACT + "-1.3.4.pom");
+ try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS)) {
+ dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), anyString()))
+ .thenReturn(releaseDir);
+
+ List<String> old = UpdateDistCommand.listPreviousReleaseFiles(ARTIFACT, "1.3.6", null);
+
+ assertEquals(List.of(ARTIFACT + "-1.3.4.pom"), old);
+ assertFalse(old.contains(ARTIFACT + "-1.3.6"));
+ assertFalse(old.contains(ARTIFACT + "-1.3.6.pom"));
+ assertFalse(old.contains(ARTIFACT + "-1.3.6-source-release.zip"));
+ }
+ }
+
+ // ---- full command flow ----
+
+ @Test
+ public void testDryRunDescribesMoveAndRemoveWithoutCommitting() throws Exception {
+ prepareRepositoryService();
+ try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS)) {
+ dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_DEV_URL), anyString()))
+ .thenReturn(List.of(ARTIFACT + "-1.3.6-source-release.zip"));
+ dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), anyString()))
+ .thenReturn(List.of(ARTIFACT + "-1.3.4.pom"));
+
+ Command command = createCommand(ExecutionMode.DRY_RUN, null);
+ assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+
+ assertTrue(logCapture.containsMessage("Would move 1 file(s) from dist/dev to dist/release:"));
+ assertTrue(logCapture.containsMessage("Would remove 1 old file(s) from dist/release:"));
+ dist.verify(() -> UpdateDistCommand.commitDistUpdate(any(), any(), any(), any(), any()), never());
+ }
+ }
+
+ @Test
+ public void testAutoCommitsViaSvnMucc() throws Exception {
+ prepareRepositoryService();
+ try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS)) {
+ dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_DEV_URL), anyString()))
+ .thenReturn(List.of(ARTIFACT + "-1.3.6-source-release.zip"));
+ dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), anyString()))
+ .thenReturn(List.of(ARTIFACT + "-1.3.4.pom"));
+ // do not actually shell out to svnmucc
+ dist.when(() -> UpdateDistCommand.commitDistUpdate(any(), any(), any(), any(), any()))
+ .thenAnswer(invocation -> null);
+
+ Command command = createCommand(ExecutionMode.AUTO, null);
+ assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+
+ dist.verify(() -> UpdateDistCommand.commitDistUpdate(eq(ARTIFACT), eq("1.3.6"), any(), any(), any()));
+ }
+ }
+
+ @Test
+ public void testNoNewFilesReturnsUsage() throws Exception {
+ prepareRepositoryService();
+ try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS)) {
+ dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_DEV_URL), anyString()))
+ .thenReturn(List.of());
+ // the release directory listing is computed before the empty-new-files check; stub it so
+ // the real `svn` is never invoked
+ dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), anyString()))
+ .thenReturn(List.of());
+
+ Command command = createCommand(ExecutionMode.AUTO, null);
+ assertEquals(CommandLine.ExitCode.USAGE, (int) command.call());
+ dist.verify(() -> UpdateDistCommand.commitDistUpdate(any(), any(), any(), any(), any()), never());
+ }
+ }
+
+ @Test
+ public void testInteractiveYesCommitsViaSvnMucc() throws Exception {
+ prepareRepositoryService();
+ try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS);
+ MockedStatic<UserInput> userInput = mockStatic(UserInput.class)) {
+ dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_DEV_URL), anyString()))
+ .thenReturn(List.of(ARTIFACT + "-1.3.6-source-release.zip"));
+ dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), anyString()))
+ .thenReturn(List.of(ARTIFACT + "-1.3.4.pom"));
+ dist.when(() -> UpdateDistCommand.commitDistUpdate(any(), any(), any(), any(), any()))
+ .thenAnswer(invocation -> null);
+ userInput
+ .when(() -> UserInput.yesNo(anyString(), eq(InputOption.YES)))
+ .thenReturn(InputOption.YES);
+
+ Command command = createCommand(ExecutionMode.INTERACTIVE, null);
+ assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+
+ dist.verify(() -> UpdateDistCommand.commitDistUpdate(eq(ARTIFACT), eq("1.3.6"), any(), any(), any()));
+ }
+ }
+
+ @Test
+ public void testInteractiveNoAborts() throws Exception {
+ prepareRepositoryService();
+ try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS);
+ MockedStatic<UserInput> userInput = mockStatic(UserInput.class)) {
+ dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_DEV_URL), anyString()))
+ .thenReturn(List.of(ARTIFACT + "-1.3.6-source-release.zip"));
+ dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), anyString()))
+ .thenReturn(List.of(ARTIFACT + "-1.3.4.pom"));
+ userInput
+ .when(() -> UserInput.yesNo(anyString(), eq(InputOption.YES)))
+ .thenReturn(InputOption.NO);
+
+ Command command = createCommand(ExecutionMode.INTERACTIVE, null);
+ assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+
+ assertTrue(logCapture.containsMessage("Aborted."));
+ dist.verify(() -> UpdateDistCommand.commitDistUpdate(any(), any(), any(), any(), any()), never());
+ }
+ }
+
+ @Test
+ public void testExplicitPreviousVersionFullFlow() throws Exception {
+ prepareRepositoryService();
+ try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS)) {
+ dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_DEV_URL), anyString()))
+ .thenReturn(List.of(ARTIFACT + "-1.3.6-source-release.zip"));
+ dist.when(() -> UpdateDistCommand.listDistFiles(
+ eq(UpdateDistCommand.DIST_RELEASE_URL), eq(ARTIFACT + "-1.3.4")))
+ .thenReturn(List.of(ARTIFACT + "-1.3.4.pom"));
+ dist.when(() -> UpdateDistCommand.commitDistUpdate(any(), any(), any(), any(), any()))
+ .thenAnswer(invocation -> null);
+
+ Command command = createCommand(ExecutionMode.AUTO, "1.3.4");
+ assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+
+ dist.verify(() -> UpdateDistCommand.commitDistUpdate(eq(ARTIFACT), eq("1.3.6"), any(), any(), any()));
+ }
+ }
+
+ @Test
+ public void testIOExceptionReturnsSoftware() throws Exception {
+ prepareRepositoryService();
+ try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS)) {
+ dist.when(() -> UpdateDistCommand.listDistFiles(any(), anyString())).thenThrow(new IOException("svn boom"));
+
+ Command command = createCommand(ExecutionMode.AUTO, null);
+ assertEquals(CommandLine.ExitCode.SOFTWARE, (int) command.call());
+ assertTrue(logCapture.containsMessage("Failed executing command"));
+ }
+ }
+
+ @Test
+ public void testNoPomArtifactThrows() throws Exception {
+ StagingRepository repository = mock(StagingRepository.class);
+ RepositoryService repositoryService = mock(RepositoryService.class);
+ when(repositoryService.find(123)).thenReturn(repository);
+ // a staging repository without a POM artifact triggers the orElseThrow guard
+ Artifact jar = new Artifact(repository, "org.apache.sling", ARTIFACT, "1.3.6", null, "jar");
+ when(repositoryService.getArtifacts(repository)).thenReturn(Set.of(jar));
+ CredentialsService credentialsService = mock(CredentialsService.class);
+ osgiContext.registerService(RepositoryService.class, repositoryService);
+ osgiContext.registerService(CredentialsService.class, credentialsService);
+
+ Command command = createCommand(ExecutionMode.AUTO, null);
+ try {
+ command.call();
+ org.junit.Assert.fail("Expected an IllegalStateException when no POM artifact is present.");
+ } catch (IllegalStateException e) {
+ assertTrue(e.getMessage().contains("No POM artifact"));
+ }
+ }
+
+ private void prepareRepositoryService() throws Exception {
+ StagingRepository repository = mock(StagingRepository.class);
+ RepositoryService repositoryService = mock(RepositoryService.class);
+ when(repositoryService.find(123)).thenReturn(repository);
+ Artifact pom = new Artifact(repository, "org.apache.sling", ARTIFACT, "1.3.6", null, "pom");
+ when(repositoryService.getArtifacts(repository)).thenReturn(Set.of(pom));
+
+ CredentialsService credentialsService = mock(CredentialsService.class);
+ when(credentialsService.getAsfCredentials()).thenReturn(new Credentials("johndoe", "secret"));
+
+ osgiContext.registerService(RepositoryService.class, repositoryService);
+ osgiContext.registerService(CredentialsService.class, credentialsService);
+ }
+
+ private Command createCommand(ExecutionMode executionMode, String previousVersion) throws IllegalAccessException {
+ UpdateDistCommand updateDistCommand = spy(new UpdateDistCommand());
+ FieldUtils.writeField(updateDistCommand, "repositoryId", 123, true);
+ FieldUtils.writeField(updateDistCommand, "previousVersion", previousVersion, true);
+ ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class);
+ FieldUtils.writeField(reusableCLIOptions, "executionMode", executionMode, true);
+ FieldUtils.writeField(updateDistCommand, "reusableCLIOptions", reusableCLIOptions, true);
+ osgiContext.registerInjectActivateService(updateDistCommand);
+ Command result = osgiContext.getService(Command.class);
+ assertTrue(
+ "Expected to retrieve the UpdateDistCommand from the mocked OSGi environment.",
+ result instanceof UpdateDistCommand);
+ return result;
+ }
+}