diff --git a/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java b/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java
index b8d676c95..91aaff4aa 100644
--- a/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java
+++ b/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java
@@ -88,6 +88,13 @@ public final class ConfigurationProperties {
*/
public static final String PREFIX_UTIL = PREFIX_AETHER + "util.";
+ /**
+ * Prefix for installer related configurations. For internal use only.
+ *
+ * @since 2.0.25
+ */
+ public static final String PREFIX_INSTALLER = PREFIX_AETHER + "installer.";
+
/**
* Prefix for transport related configurations. For internal use only.
*
@@ -689,6 +696,37 @@ public enum HttpVersion {
*/
public static final boolean DEFAULT_HTTP_SEND_RFC9457_ACCEPT = true;
+ /**
+ * A flag indicating whether artifact installation should use hard links instead of copying files. When enabled,
+ * the installer creates a hard link from the source artifact (build output) to its destination in the local
+ * repository, avoiding a full file copy and saving disk I/O and space for large JARs.
+ *
+ * Hard links require that the source and target paths reside on the same filesystem volume.
+ * When they do not (e.g. {@code ~/.m2} is on a separate mount, or a custom in-memory filesystem such as
+ * Jimfs is used for testing), the JVM throws {@link java.nio.file.UnsupportedOperationException},
+ * {@link IllegalArgumentException} (provider mismatch), or {@link java.io.IOException} (cross-device),
+ * and the installer automatically falls back to a regular file copy, so the build is never broken.
+ *
+ * Note: hard links share an inode. Any tool that overwrites the build output file
+ * in-place (rather than via an atomic rename) would silently corrupt the cached artifact.
+ * Standard Maven build tooling always replaces output via rename, so this is generally safe. Set this
+ * property to {@code false} if your environment performs in-place writes to build output files.
+ *
+ * @since 2.0.25
+ * @configurationSource {@link RepositorySystemSession#getConfigProperties()}
+ * @configurationType {@link java.lang.Boolean}
+ * @configurationDefaultValue {@link #DEFAULT_INSTALLER_HARD_LINK}
+ * @configurationRepoIdSuffix No
+ */
+ public static final String INSTALLER_HARD_LINK = PREFIX_INSTALLER + "hardLink";
+
+ /**
+ * The default value for {@link #INSTALLER_HARD_LINK}: {@code true}.
+ *
+ * @since 2.0.25
+ */
+ public static final boolean DEFAULT_INSTALLER_HARD_LINK = true;
+
private ConfigurationProperties() {
// hide constructor
}
diff --git a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultInstaller.java b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultInstaller.java
index 5533c480a..0a02e8289 100644
--- a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultInstaller.java
+++ b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultInstaller.java
@@ -22,6 +22,8 @@
import javax.inject.Named;
import javax.inject.Singleton;
+import java.io.IOException;
+import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
@@ -32,6 +34,7 @@
import java.util.ListIterator;
import java.util.Map;
+import org.eclipse.aether.ConfigurationProperties;
import org.eclipse.aether.RepositoryEvent;
import org.eclipse.aether.RepositoryEvent.EventType;
import org.eclipse.aether.RepositorySystemSession;
@@ -55,6 +58,7 @@
import org.eclipse.aether.spi.artifact.transformer.ArtifactTransformer;
import org.eclipse.aether.spi.io.PathProcessor;
import org.eclipse.aether.spi.synccontext.SyncContextFactory;
+import org.eclipse.aether.util.ConfigUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -147,6 +151,12 @@ private InstallResult install(SyncContext syncContext, RepositorySystemSession s
result.addMetadata(metadata);
}
+ boolean hardLink = ConfigUtils.getBoolean(
+ session,
+ ConfigurationProperties.DEFAULT_INSTALLER_HARD_LINK,
+ ConfigurationProperties.INSTALLER_HARD_LINK);
+
+ Exception linkFailure = null;
for (ListIterator iterator = artifacts.listIterator(); iterator.hasNext(); ) {
Artifact artifact = iterator.next();
@@ -156,11 +166,22 @@ private InstallResult install(SyncContext syncContext, RepositorySystemSession s
iterator.set(artifact);
- install(session, trace, artifact);
+ Exception failure = install(session, trace, artifact, hardLink);
+ if (failure != null && linkFailure == null) {
+ linkFailure = failure;
+ } else if (failure != null) {
+ linkFailure.addSuppressed(failure);
+ }
if (artifact.getProperty(ArtifactGeneratorFactory.ARTIFACT_GENERATOR_ID, null) == null) {
result.addArtifact(artifact);
}
}
+ if (linkFailure != null) {
+ logger.warn(
+ "Hard link not supported, falling back to copy for all artifacts: {}",
+ linkFailure.getMessage(),
+ linkFailure);
+ }
metadatas = Utils.finishMetadata(metadataGenerators, artifacts);
@@ -191,7 +212,12 @@ private InstallResult install(SyncContext syncContext, RepositorySystemSession s
}
}
- private void install(RepositorySystemSession session, RequestTrace trace, Artifact artifact)
+ /**
+ * Installs a single artifact. Returns the link failure exception if hard-linking was attempted but failed
+ * (caller logs it once after the loop), or {@code null} if the artifact was installed successfully via hard link
+ * or if {@code hardLink} is {@code false}.
+ */
+ private Exception install(RepositorySystemSession session, RequestTrace trace, Artifact artifact, boolean hardLink)
throws InstallationException {
final LocalRepositoryManager lrm = session.getLocalRepositoryManager();
final Path srcPath = artifact.getPath();
@@ -199,13 +225,29 @@ private void install(RepositorySystemSession session, RequestTrace trace, Artifa
artifactInstalling(session, trace, artifact, dstPath);
+ Exception linkFailure = null;
Exception exception = null;
try {
if (dstPath.equals(srcPath)) {
throw new IllegalStateException("cannot install " + dstPath + " to same path");
}
- pathProcessor.copyWithTimestamp(srcPath, dstPath);
+ if (hardLink) {
+ Files.createDirectories(dstPath.getParent());
+ if (Files.isDirectory(dstPath)) {
+ throw new IOException("destination is a directory: " + dstPath);
+ }
+ try {
+ Files.deleteIfExists(dstPath);
+ Files.createLink(dstPath, srcPath);
+ } catch (UnsupportedOperationException | IllegalArgumentException | IOException e) {
+ // cross-device link (EXDEV), provider mismatch (e.g. Jimfs), or unsupported FS — fall back to copy
+ linkFailure = e;
+ pathProcessor.copyWithTimestamp(srcPath, dstPath);
+ }
+ } else {
+ pathProcessor.copyWithTimestamp(srcPath, dstPath);
+ }
lrm.add(session, new LocalArtifactRegistration(artifact));
} catch (Exception e) {
exception = e;
@@ -213,6 +255,7 @@ private void install(RepositorySystemSession session, RequestTrace trace, Artifa
} finally {
artifactInstalled(session, trace, artifact, dstPath, exception);
}
+ return linkFailure;
}
private void install(RepositorySystemSession session, RequestTrace trace, Metadata metadata)
diff --git a/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/DefaultInstallerTest.java b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/DefaultInstallerTest.java
index e6fa5e84f..f840520e2 100644
--- a/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/DefaultInstallerTest.java
+++ b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/DefaultInstallerTest.java
@@ -20,10 +20,12 @@
import java.io.File;
import java.io.IOException;
+import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
+import org.eclipse.aether.ConfigurationProperties;
import org.eclipse.aether.DefaultRepositorySystemSession;
import org.eclipse.aether.RepositoryEvent;
import org.eclipse.aether.RepositoryEvent.EventType;
@@ -377,4 +379,96 @@ void testSetArtifactTimestamps() throws InstallationException {
localArtifactFile.lastModified(),
"artifact timestamp was not set to src file");
}
+
+ @Test
+ void testHardLinkInstallCreatesHardLink() throws InstallationException, IOException {
+ // default: hardLink=true — installer attempts a hard link, falls back to copy on cross-device
+ //
+ // Use a counting PathProcessor to detect whether the fallback copy path was taken.
+ // On same-device filesystems: hard link is created (copyWithTimestamp NOT called).
+ // On cross-device (common in CI containers): Files.createLink throws, copy IS taken.
+ CountingPathProcessor countingProcessor = new CountingPathProcessor();
+ installer = new DefaultInstaller(
+ countingProcessor,
+ new StubRepositoryEventDispatcher(),
+ Collections.emptyMap(),
+ Collections.emptyMap(),
+ Collections.emptyMap(),
+ new StubSyncContextFactory());
+
+ request.addArtifact(artifact);
+ installer.install(session, request);
+
+ assertTrue(localArtifactFile.exists());
+ assertEquals("artifact", TestFileUtils.readString(localArtifactFile));
+
+ // On same-device filesystems, verify a hard link was created (same inode, no copy).
+ // On cross-device (common in CI containers where /tmp and project dir are on different mounts),
+ // Files.createLink throws IOException and the installer falls back to copy — both are correct.
+ Path srcPath = artifact.getFile().toPath();
+ Path dstPath = localArtifactFile.toPath();
+ try {
+ boolean sameDevice =
+ Files.getAttribute(srcPath, "unix:dev").equals(Files.getAttribute(dstPath, "unix:dev"));
+ if (sameDevice) {
+ // hard link must have been created — same inode expected, copy NOT taken
+ assertEquals(
+ Files.getAttribute(srcPath, "unix:ino"),
+ Files.getAttribute(dstPath, "unix:ino"),
+ "installed artifact should be a hard link to the source on same-device FS");
+ assertEquals(
+ 0,
+ countingProcessor.copyWithTimestampCount,
+ "copyWithTimestamp must NOT be called on same-device FS");
+ } else {
+ // cross-device: fallback to copy must have been taken
+ assertEquals(
+ 1,
+ countingProcessor.copyWithTimestampCount,
+ "copyWithTimestamp must be called as fallback on cross-device FS");
+ }
+ } catch (UnsupportedOperationException | IllegalArgumentException ignored) {
+ // non-POSIX filesystem — inode/device check not available
+ // but we can still assert that copyWithTimestamp was called at most once:
+ // either the hard link succeeded (count=0) or the fallback fired exactly once (count=1)
+ assertTrue(
+ countingProcessor.copyWithTimestampCount <= 1,
+ "copyWithTimestamp must be called at most once on non-POSIX FS (got "
+ + countingProcessor.copyWithTimestampCount + ")");
+ }
+ }
+
+ @Test
+ void testHardLinkDisabledFallsThroughToCopy() throws InstallationException, IOException {
+ // set hardLink=false — installer must copy, not hard-link
+ CountingPathProcessor countingProcessor = new CountingPathProcessor();
+ installer = new DefaultInstaller(
+ countingProcessor,
+ new StubRepositoryEventDispatcher(),
+ Collections.emptyMap(),
+ Collections.emptyMap(),
+ Collections.emptyMap(),
+ new StubSyncContextFactory());
+
+ session.setConfigProperty(ConfigurationProperties.INSTALLER_HARD_LINK, false);
+ request.addArtifact(artifact);
+ installer.install(session, request);
+
+ assertTrue(localArtifactFile.exists());
+ assertEquals("artifact", TestFileUtils.readString(localArtifactFile));
+ // Verify copyWithTimestamp was called (hardLink=false must NOT attempt Files.createLink)
+ assertEquals(
+ 1, countingProcessor.copyWithTimestampCount, "copyWithTimestamp must be called when hardLink=false");
+ }
+
+ /** PathProcessor wrapper that counts copyWithTimestamp invocations. */
+ private static class CountingPathProcessor extends PathProcessorSupport {
+ int copyWithTimestampCount = 0;
+
+ @Override
+ public void copyWithTimestamp(Path src, Path dst) throws IOException {
+ copyWithTimestampCount++;
+ super.copyWithTimestamp(src, dst);
+ }
+ }
}