gnodet commented on code in PR #2198:
URL: https://github.com/apache/maven-resolver/pull/2198#discussion_r4237645604


##########
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. <em>For internal use 
only.</em>
+     *
+     * @since 2.0.9

Review Comment:
   ⚠️ **Wrong `@since` version**: the current POM snapshot is 
`2.0.25-SNAPSHOT`, so this should be `@since 2.0.25`, not `@since 2.0.9`.



##########
maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java:
##########
@@ -689,6 +696,36 @@ 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.
+     * <p>
+     * Hard links require that the source and target paths reside on the 
<strong>same filesystem volume</strong>.
+     * When they do not (e.g. {@code ~/.m2} is on a separate mount), the JVM 
throws
+     * {@link java.nio.file.UnsupportedOperationException} or {@link 
java.nio.file.AtomicMoveNotSupportedException}

Review Comment:
   ⚠️ **Inaccurate exception reference**: `AtomicMoveNotSupportedException` is 
only thrown by `Files.move()` with `ATOMIC_MOVE`. `Files.createLink()` throws 
`UnsupportedOperationException` (unsupported FS) or `IOException` (e.g. EXDEV 
on cross-device). Please replace with:
   
   ```suggestion
        * When they do not (e.g. {@code ~/.m2} is on a separate mount), the JVM 
throws
        * {@link java.nio.file.UnsupportedOperationException} or {@link 
java.io.IOException}
   ```



##########
maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java:
##########
@@ -689,6 +696,36 @@ 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.
+     * <p>
+     * Hard links require that the source and target paths reside on the 
<strong>same filesystem volume</strong>.
+     * When they do not (e.g. {@code ~/.m2} is on a separate mount), the JVM 
throws
+     * {@link java.nio.file.UnsupportedOperationException} or {@link 
java.nio.file.AtomicMoveNotSupportedException}
+     * and the installer automatically falls back to a regular file copy, so 
the build is never broken.
+     * <p>
+     * <strong>Note:</strong> hard links share an inode. Any tool that 
overwrites the build output file
+     * <em>in-place</em> (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.9

Review Comment:
   ⚠️ **Wrong `@since` version**: should be `@since 2.0.25` (current snapshot 
is `2.0.25-SNAPSHOT`).
   
   ```suggestion
        * @since 2.0.25
   ```



##########
maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java:
##########
@@ -689,6 +696,36 @@ 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.
+     * <p>
+     * Hard links require that the source and target paths reside on the 
<strong>same filesystem volume</strong>.
+     * When they do not (e.g. {@code ~/.m2} is on a separate mount), the JVM 
throws
+     * {@link java.nio.file.UnsupportedOperationException} or {@link 
java.nio.file.AtomicMoveNotSupportedException}
+     * and the installer automatically falls back to a regular file copy, so 
the build is never broken.
+     * <p>
+     * <strong>Note:</strong> hard links share an inode. Any tool that 
overwrites the build output file
+     * <em>in-place</em> (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.9
+     * @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.9

Review Comment:
   ⚠️ **Wrong `@since` version**: should be `@since 2.0.25`.
   
   ```suggestion
        * @since 2.0.25
   ```



##########
maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultInstaller.java:
##########
@@ -205,7 +209,26 @@ private void install(RepositorySystemSession session, 
RequestTrace trace, Artifa
                 throw new IllegalStateException("cannot install " + dstPath + 
" to same path");
             }
 
-            pathProcessor.copyWithTimestamp(srcPath, dstPath);
+            boolean hardLink = ConfigUtils.getBoolean(
+                    session,
+                    ConfigurationProperties.DEFAULT_INSTALLER_HARD_LINK,
+                    ConfigurationProperties.INSTALLER_HARD_LINK);
+            if (hardLink) {
+                try {
+                    Files.createDirectories(dstPath.getParent());
+                    if (Files.isDirectory(dstPath)) {
+                        throw new IOException("destination is a directory: " + 
dstPath);
+                    }
+                    Files.deleteIfExists(dstPath);
+                    Files.createLink(dstPath, srcPath);
+                } catch (UnsupportedOperationException | IOException e) {

Review Comment:
   🐛 **`IOException` from directory check is silently swallowed**: `throw new 
IOException("destination is a directory: " + dstPath)` at line 220 is inside 
the `try` block, so it's caught by `catch (UnsupportedOperationException | 
IOException e)` at line 224 and treated as a "hard link not supported" 
condition — the fallback copies to the directory path, which either silently 
creates a file named by the directory or succeeds incorrectly.
   
   The `isDirectory` check should be moved **before** the `try` block (or 
rethrown as an `IllegalStateException`) so it isn't masked:
   
   ```suggestion
                       if (Files.isDirectory(dstPath)) {
                           throw new IllegalStateException("destination is a 
directory: " + dstPath);
                       }
                       Files.deleteIfExists(dstPath);
                       Files.createLink(dstPath, srcPath);
                   } catch (UnsupportedOperationException | IOException e) {
   ```



##########
maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultInstaller.java:
##########
@@ -205,7 +209,26 @@ private void install(RepositorySystemSession session, 
RequestTrace trace, Artifa
                 throw new IllegalStateException("cannot install " + dstPath + 
" to same path");
             }
 
-            pathProcessor.copyWithTimestamp(srcPath, dstPath);
+            boolean hardLink = ConfigUtils.getBoolean(
+                    session,
+                    ConfigurationProperties.DEFAULT_INSTALLER_HARD_LINK,
+                    ConfigurationProperties.INSTALLER_HARD_LINK);

Review Comment:
   💡 **Config read per artifact**: `ConfigUtils.getBoolean()` is called inside 
the per-artifact `install()` method, so it's invoked once per artifact per 
session. This property is session-scoped and never changes mid-install. 
Consider reading it once in the caller and passing it in (or reading it at the 
top of the outer `install(RepositorySystemSession, InstallRequest)` method).



##########
maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultInstaller.java:
##########
@@ -205,7 +209,26 @@ private void install(RepositorySystemSession session, 
RequestTrace trace, Artifa
                 throw new IllegalStateException("cannot install " + dstPath + 
" to same path");
             }
 
-            pathProcessor.copyWithTimestamp(srcPath, dstPath);
+            boolean hardLink = ConfigUtils.getBoolean(
+                    session,
+                    ConfigurationProperties.DEFAULT_INSTALLER_HARD_LINK,
+                    ConfigurationProperties.INSTALLER_HARD_LINK);
+            if (hardLink) {
+                try {
+                    Files.createDirectories(dstPath.getParent());
+                    if (Files.isDirectory(dstPath)) {
+                        throw new IOException("destination is a directory: " + 
dstPath);
+                    }
+                    Files.deleteIfExists(dstPath);
+                    Files.createLink(dstPath, srcPath);
+                } catch (UnsupportedOperationException | IOException e) {
+                    // cross-device link (EXDEV) or unsupported FS — fall back 
to copy
+                    logger.debug("Hard link not supported for {}, falling back 
to copy: {}", dstPath, e.getMessage());

Review Comment:
   💡 **Log level**: `DEBUG` means users won't see when the fallback is 
triggered unless they enable debug logging. Since the fallback changes IO 
behavior (copy vs. hard-link), this could matter for diagnosing slow builds or 
unexpected disk usage. Consider `WARN` so it's visible by default, or at least 
`INFO`.
   
   ```suggestion
                       logger.warn("Hard link not supported for {}, falling 
back to copy: {}", dstPath, e.getMessage());
   ```



##########
maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/DefaultInstallerTest.java:
##########
@@ -377,4 +379,47 @@ 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
+        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).
+        // 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
+                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");
+            }
+            // cross-device: fallback to copy is correct — content already 
asserted above
+        } catch (UnsupportedOperationException | IllegalArgumentException 
ignored) {

Review Comment:
   ⚠️ **Test degenerates in CI**: in most CI environments (Docker containers, 
GitHub Actions), `/tmp` and the project directory are on different mounts 
(`tmpfs` vs. overlay), so `Files.createLink()` throws and the installer falls 
back to copy. In that case, `sameDevice` is `false`, the inode assertion is 
skipped, and the test only checks content — identical to 
`testSetArtifactTimestamps`. This means CI won't catch a regression that 
removes the `Files.createLink()` call entirely.
   
   Consider using a `@TempDir` on the same filesystem as the project, or spying 
on `Files.createLink` via a test subclass of `DefaultInstaller` that overrides 
the link attempt to record whether it was called.



##########
maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/DefaultInstallerTest.java:
##########
@@ -377,4 +379,47 @@ 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
+        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).
+        // 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
+                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");
+            }
+            // cross-device: fallback to copy is correct — content already 
asserted above
+        } catch (UnsupportedOperationException | IllegalArgumentException 
ignored) {
+            // non-POSIX filesystem — inode/device check not available; 
content assertion is sufficient
+        }
+    }
+
+    @Test
+    void testHardLinkDisabledFallsThroughToCopy() throws 
InstallationException, IOException {
+        // set hardLink=false — installer must copy, not hard-link
+        session.setConfigProperty(ConfigurationProperties.INSTALLER_HARD_LINK, 
false);
+        request.addArtifact(artifact);
+        installer.install(session, request);
+
+        assertTrue(localArtifactFile.exists());
+        assertEquals("artifact", TestFileUtils.readString(localArtifactFile));
+        // With copy the content is correct; we cannot assert different inodes 
portably
+        // but we verify the install succeeds and produces the correct content
+    }
 }

Review Comment:
   💡 **Test coverage gap**: this test only asserts the install succeeds and 
produces correct content when `hardLink=false`. It doesn't assert that 
`Files.createLink` was NOT called. Without a spy or mock, this test would still 
pass even if `hardLink=false` was completely ignored and a hard link was 
created. Consider adding a test subclass that overrides or intercepts the link 
attempt to verify the copy path is taken.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to