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 <[email protected]>
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
+        })
[email protected](
+        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;
+    }
+}


Reply via email to