Repository navigation
Conversation
Add `aether.installer.hardLink` configuration property (default `true`) that makes `DefaultInstaller` create a hard link from the build output to the local repository instead of copying the file. This avoids redundant I/O for large JARs built locally. When hard linking is not possible (cross-device, unsupported filesystem), the installer automatically falls back to a regular file copy so the build is never broken. The property can be set to `false` to opt out and always use copy. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
gnodet
left a comment
There was a problem hiding this comment.
This PR adds hard-link support to the local repository installer as an opt-in (defaulting to true), which is a useful optimization. The implementation is mostly correct but has a few issues that should be fixed before merging.
Findings summary:
@since 2.0.9is wrong everywhere — the POM is at2.0.25-SNAPSHOTAtomicMoveNotSupportedExceptionin the Javadoc is inaccurate forFiles.createLink()IOException("destination is a directory")is silently swallowed by the fallback handlerConfigUtils.getBoolean()is called once per artifact instead of once per sessiontestHardLinkInstallCreatesHardLinkdegenerates to a content-only test in CI (cross-device)testHardLinkDisabledFallsThroughToCopydoesn't assert thatcreateLinkwas NOT called
This review was generated by an AI agent, Hermès on behalf of @gnodet.
…tion (Jimfs/provider mismatch)
gnodet
left a comment
There was a problem hiding this comment.
Re-review after latest commits.
A few issues with the new code:
Corrected @SInCE tags — PREFIX_INSTALLER, INSTALLER_HARD_LINK, and DEFAULT_INSTALLER_HARD_LINK are all tagged @since 2.0.9, but the current POM is 2.0.25-SNAPSHOT. Should be @since 2.0.25.
IOException silently swallowed — the most critical finding: throw new IOException("destination is a directory") at line 220 is inside the same try block whose catch at line 224 catches IOException. The guard you added to detect a directory destination is immediately swallowed, causing a silent fallback to copyWithTimestamp(srcPath, dstPath) — which will also fail because the destination is a directory, but with a less informative error. The guard must be outside the try block, or the catch must exclude this specific case.
ConfigUtils.getBoolean() called per-artifact — this reads the same session config property on every artifact install. For a multi-module build with hundreds of artifacts, that's hundreds of map lookups on the same immutable session. Consider reading it once in the outer install(session, request) method and passing the boolean down.
createDirectories missing from the copy branch — Files.createDirectories(dstPath.getParent()) is called only inside the hard-link try block. If hardLink=false, the copy branch skips directory creation entirely. Worth confirming that pathProcessor.copyWithTimestamp() creates parent directories internally; if it doesn't, that's a latent NPE.
This review was generated by an AI agent, Hermès on behalf of @gnodet.
… hoisting, log level - Fix @SInCE 2.0.9 → @SInCE 2.0.25 on PREFIX_INSTALLER, INSTALLER_HARD_LINK, DEFAULT_INSTALLER_HARD_LINK - Move isDirectory check outside the inner try/catch so IOException propagates correctly instead of being silently swallowed as a 'hard link not supported' fallback - Hoist ConfigUtils.getBoolean() to install(SyncContext,session,request) so it is read once per request instead of once per artifact - Upgrade fallback log from DEBUG to WARN so cross-device link fallback is visible
gnodet
left a comment
There was a problem hiding this comment.
Re-review after 9cfecea.
Addressed ✅ (6 findings from prior two reviews all fixed):
@since 2.0.25on all three new constants- Javadoc exception types corrected (
UnsupportedOperationException,IllegalArgumentException,IOException) isDirectoryguard moved before the innertryblock —IOExceptionnow propagates correctly toInstallationExceptionConfigUtils.getBoolean()hoisted to outerinstall(SyncContext,…), read once per request- Log level raised to
WARNfor fallback (wasDEBUG) pathProcessor.copyWithTimestamp()confirmed to create parent directories internally
Still open (2 findings): Test coverage gaps — both new test methods degenerate to content-only assertions in CI environments.
New finding (1): logger.warn fires per-artifact in environments where hard-linking always fails, producing one WARN line per artifact in a multi-module build.
This review was generated by an AI agent, Hermès on behalf of @gnodet.
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Copilot review overview
4 open findings
Deleting the destination before the hard-link attempt can cause data loss if both the hard-link and… · New The explicit directory check throws anIOExceptionwith a clear message, but that exception is… · New The explicit directory check throws anIOExceptionwith a clear message, but that exception is… · New The PR description states the installer will ‘silently’ fall back to copy, but the implementation… · New
What changed in this PR
Adds optional hard-link based installation to DefaultInstaller (enabled by default via aether.installer.hardLink) to reduce local disk I/O when installing artifacts, with tests covering both default behavior and opt-out.
Changes:
- Introduces
aether.installer.hardLink(+ default constant) inConfigurationProperties. - Updates
DefaultInstallerto attemptFiles.createLinkwith fallback tocopyWithTimestamp. - Adds regression tests validating hard-link behavior (when possible) and copy fallback when disabled.
| File | Description |
|---|---|
| maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultInstaller.java | Attempts hard-link artifact installation by default; falls back to copying based on config. |
| maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java | Defines new installer config key + default for hard-link installation. |
| maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/DefaultInstallerTest.java | Adds tests for default hard-link attempt and explicit opt-out behavior. |
🧠 Review effort: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Interaction with Windows IDE-lock fix (PR #2205)When However, PR #2205 fixes The degradation chain in
So this PR's fallback degradation is correct but incomplete without #2205. |
…sertions to tests - Replace per-artifact logger.warn with accumulate-then-log pattern: collect first link failure in outer loop, addSuppressed() for subsequent ones, log once at WARN after all artifacts are processed — avoids N WARN lines in multi-module builds where ~/.m2 is on a different mount - Change private install(..., hardLink) return type from void to Exception (the link failure, null on success) so the outer loop can collect failures - Add CountingPathProcessor in tests: counts copyWithTimestamp invocations, allowing testHardLinkInstallCreatesHardLink to assert copy was NOT called on same-device FS and WAS called on cross-device, and testHardLinkDisabledFallsThroughToCopy to assert copy IS called when hardLink=false (addresses review feedback on test coverage gaps)
gnodet-bot
left a comment
There was a problem hiding this comment.
Re-review after 5fdca38.
Addressed ✅ (2 findings from prior review fixed):
- Per-artifact
WARNspam replaced with accumulate-and-log-once pattern usingaddSuppressed()✅ testHardLinkDisabledFallsThroughToCopy:assertEquals(1, countingProcessor.copyWithTimestampCount, ...)added after the try block — always runs ✅
Still open (1 finding): testHardLinkInstallCreatesHardLink — counting assertions still silently skipped on non-POSIX FS
The CountingPathProcessor counting assertions are inside the try { } catch (UnsupportedOperationException | IllegalArgumentException ignored) block. On non-POSIX filesystems (Windows, or a Jimfs-based test), Files.getAttribute(srcPath, "unix:dev") throws UnsupportedOperationException, the catch fires, and all counting assertions are silently skipped — the test degenerates to content-only.
Maven-resolver CI is Linux-only (so the cross-device else branch now fires correctly on CI), but a Windows developer running the suite locally would get zero behavioral coverage from this test.
Fix: add a counting assertion in the catch block (or move a FS-agnostic count check after the try-catch):
This review was generated by an AI agent, Hermès on behalf of @gnodet.
…nstallCreatesHardLink On non-POSIX filesystems (Windows, Jimfs), the unix:dev attribute lookup throws UnsupportedOperationException, causing all counting assertions inside the try block to be silently skipped. Add a minimal assertion in the catch block: copyWithTimestampCount must be <= 1 (either the hard link succeeded with count=0, or the fallback fired exactly once with count=1). This ensures the test provides some behavioral coverage even when inode/device checks are unavailable.
gnodet-bot
left a comment
There was a problem hiding this comment.
Re-review after 8d475cd.
Addressed ✅ — one finding from prior review resolved:
testHardLinkInstallCreatesHardLink: the counting assertion is now present inside the catch (UnsupportedOperationException | IllegalArgumentException ignored) block. On non-POSIX filesystems where Files.getAttribute(srcPath, "unix:dev") throws, the test now asserts copyWithTimestampCount <= 1. This is the tightest assertion possible without mocking Files.createLink at the syscall level — with a single artifact install, the correct outcome is either 0 (hard link succeeded) or 1 (fallback copy), and any double-copy regression would fire this assertion.
LGTM.
This review was generated by an AI agent, Hermès on behalf of @gnodet.


Summary
Add
aether.installer.hardLinkconfiguration property (defaulttrue) to makeDefaultInstallercreate a hard link from the build output file to its destination in the local repository, instead of copying it.This saves significant disk I/O for large JARs when building locally: instead of reading and writing the full file, the OS simply adds a new directory entry pointing to the same inode.
Motivation
This was raised as a follow-up to apache/maven-install-plugin#468. The install plugin only assembles the
ArtifactInstallerRequest— the right place to implement hard-link installation is here inDefaultInstaller, which owns the file-copy step.The feature is analogous to
FileTransporter.WriteOp.HARDLINKwhich already exists forfile://remote repositories.Behaviour
aether.installer.hardLink=true— hard link attempted first.Files.createLinkthrowsUnsupportedOperationException(unsupported filesystem) orIOException(cross-device link,EXDEV), the installer silently falls back to a regular file copy. The build is never broken.aether.installer.hardLink=false— always copy, same as the previous behaviour.Design notes
ConfigurationPropertieswith full@configurationSource/@configurationType/@configurationDefaultValue/@configurationRepoIdSuffixannotations following the established pattern.PREFIX_INSTALLER = "aether.installer."added alongside the existing prefix constants.target/, so this is safe in practice.copyWithTimestamp(which will fail as before), preserving existing error semantics.Testing
testHardLinkInstallCreatesHardLink— verifies that install succeeds and (on same-device filesystems) produces a hard link (same inode). Gracefully skips the inode assertion on cross-device environments (common in CI containers).testHardLinkDisabledFallsThroughToCopy— setsaether.installer.hardLink=falseand verifies install still succeeds with correct content.maven-resolver-implpass unchanged.Configuration
To opt out globally, add to
~/.m2/settings.xml:<!-- in Maven 4: via project properties or system property -->Or pass on the command line:
Hermès Agent (Claude Sonnet 4.6) on behalf of Guillaume Nodet