From 047b71588fe4c5bb45f628433a481299919b9c79 Mon Sep 17 00:00:00 2001
From: Guillaume Nodet
Date: Fri, 31 Jul 2026 15:51:05 +0200
Subject: [PATCH 1/5] Fix #12646: project-local-repo clean race condition when
root pom has parent
When the root pom.xml has a parent (super-pom) in the reactor,
MultiThreadedBuilder schedules the super-pom first, then builds the
root pom and sibling modules in parallel. The root pom's clean phase
(maven-clean-plugin) deletes the entire target/ directory, which
includes project-local-repo. Concurrently, sibling modules that
complete successfully write artifacts into target/project-local-repo
via ReactorReader.installIntoProjectLocalRepository(). This race
causes maven-clean-plugin to fail because the directory is being
written to while it tries to delete it.
Fix: Add a ReentrantReadWriteLock to ReactorReader that coordinates
access to the project-local-repo directory between clean and install
operations. When the project whose build directory contains the
project-local-repo enters its clean phase, a write lock is acquired
to block concurrent installs. The write lock is released when the
clean mojo completes (succeeds or fails). Install operations acquire
a read lock, allowing multiple concurrent installs but blocking while
the owning project's clean is running.
Co-Authored-By: Claude Opus 4.6
---
.../java/org/apache/maven/ReactorReader.java | 43 +++++++++++++-
...Tgh12646ProjectLocalRepoCleanRaceTest.java | 59 +++++++++++++++++++
.../.mvn/.gitkeep | 0
.../module-a/pom.xml | 29 +++++++++
.../module-a/src/main/java/a/A.java | 7 +++
.../module-b/pom.xml | 29 +++++++++
.../module-b/src/main/java/b/B.java | 7 +++
.../module-c/pom.xml | 29 +++++++++
.../module-c/src/main/java/c/C.java | 7 +++
.../module-d/pom.xml | 29 +++++++++
.../module-d/src/main/java/d/D.java | 7 +++
.../pom.xml | 38 ++++++++++++
.../super-pom/pom.xml | 25 ++++++++
13 files changed, 307 insertions(+), 2 deletions(-)
create mode 100644 its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12646ProjectLocalRepoCleanRaceTest.java
create mode 100644 its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/.mvn/.gitkeep
create mode 100644 its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-a/pom.xml
create mode 100644 its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-a/src/main/java/a/A.java
create mode 100644 its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-b/pom.xml
create mode 100644 its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-b/src/main/java/b/B.java
create mode 100644 its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-c/pom.xml
create mode 100644 its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-c/src/main/java/c/C.java
create mode 100644 its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-d/pom.xml
create mode 100644 its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-d/src/main/java/d/D.java
create mode 100644 its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/pom.xml
create mode 100644 its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/super-pom/pom.xml
diff --git a/impl/maven-core/src/main/java/org/apache/maven/ReactorReader.java b/impl/maven-core/src/main/java/org/apache/maven/ReactorReader.java
index db4882e38696..bd6fa348d942 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/ReactorReader.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/ReactorReader.java
@@ -40,6 +40,7 @@
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -82,6 +83,11 @@ class ReactorReader implements MavenWorkspaceReader {
private Path projectLocalRepository;
// projectId -> Deque
private final Map> lifecycles = new ConcurrentHashMap<>();
+ // Coordinates access to project-local-repo between clean and install operations.
+ // When the project that owns the project-local-repo directory is being cleaned
+ // (maven-clean-plugin deletes its entire target/ directory), the write lock prevents
+ // concurrent installs from writing into the directory being deleted.
+ private final ReentrantReadWriteLock projectLocalRepoLock = new ReentrantReadWriteLock();
@Inject
ReactorReader(MavenSession session) {
@@ -352,6 +358,11 @@ private File findInProjectLocalRepository(Artifact artifact) {
* The mojo started event is also captured to determine the lifecycle
* phases the project has been through.
*
+ * When the project that owns the project-local-repo directory enters its clean phase,
+ * we acquire a write lock to prevent concurrent installs from writing into the directory
+ * while maven-clean-plugin is deleting it. The lock is released when the clean mojo
+ * succeeds or fails.
+ *
* @param event the execution event
*/
private void processEvent(ExecutionEvent event) {
@@ -360,6 +371,9 @@ private void processEvent(ExecutionEvent event) {
case MojoStarted:
String phase = event.getMojoExecution().getLifecyclePhase();
if (phase != null) {
+ if ("clean".equals(phase) && isProjectLocalRepoOwner(project)) {
+ projectLocalRepoLock.writeLock().lock();
+ }
Deque phases = getLifecycles(project);
if (!Objects.equals(phase, phases.peekLast())) {
phases.addLast(phase);
@@ -371,10 +385,24 @@ private void processEvent(ExecutionEvent event) {
}
}
break;
+ case MojoSucceeded:
+ case MojoFailed:
+ String endedPhase = event.getMojoExecution().getLifecyclePhase();
+ if ("clean".equals(endedPhase) && isProjectLocalRepoOwner(project)) {
+ if (projectLocalRepoLock.isWriteLockedByCurrentThread()) {
+ projectLocalRepoLock.writeLock().unlock();
+ }
+ }
+ break;
case ProjectSucceeded:
case ForkedProjectSucceeded:
- synchronized (project) {
- installIntoProjectLocalRepository(project);
+ projectLocalRepoLock.readLock().lock();
+ try {
+ synchronized (project) {
+ installIntoProjectLocalRepository(project);
+ }
+ } finally {
+ projectLocalRepoLock.readLock().unlock();
}
break;
default:
@@ -386,6 +414,17 @@ private Deque getLifecycles(MavenProject project) {
return lifecycles.computeIfAbsent(project.getId(), k -> new ArrayDeque<>());
}
+ /**
+ * Checks whether the given project's build directory contains the project-local-repo.
+ * When this project's clean phase runs, maven-clean-plugin will delete the build directory,
+ * which would race with concurrent installs writing to project-local-repo.
+ */
+ private boolean isProjectLocalRepoOwner(MavenProject project) {
+ Path projectLocalRepo = getProjectLocalRepo();
+ Path buildDir = Paths.get(project.getBuild().getDirectory());
+ return projectLocalRepo.startsWith(buildDir);
+ }
+
/**
* Copy packaged and attached artifacts from this project to the
* project local repository.
diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12646ProjectLocalRepoCleanRaceTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12646ProjectLocalRepoCleanRaceTest.java
new file mode 100644
index 000000000000..bc06787f36e0
--- /dev/null
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12646ProjectLocalRepoCleanRaceTest.java
@@ -0,0 +1,59 @@
+/*
+ * 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.maven.it;
+
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * This is a test set for GH-12646.
+ *
+ * Verifies that parallel builds with {@code clean install} do not fail with a race condition
+ * when the root pom has a parent (super-pom) and other modules write to
+ * {@code target/project-local-repo} while the root project's clean phase is running.
+ *
+ * @since 4.0.0-rc-6
+ */
+class MavenITgh12646ProjectLocalRepoCleanRaceTest extends AbstractMavenIntegrationTestCase {
+
+ /**
+ * Verify that a parallel {@code clean install} succeeds when the root pom has a parent
+ * that is also part of the reactor. In this scenario:
+ *
+ * - super-pom builds first (no dependencies)
+ * - root pom and module-a..d start in parallel (all depend on super-pom)
+ * - root pom's clean phase runs maven-clean-plugin which deletes target/
+ * - modules complete and install artifacts into target/project-local-repo
+ *
+ * Without the fix, step 3 and 4 race, causing maven-clean-plugin to fail because
+ * target/project-local-repo is being written to while it tries to delete target/.
+ */
+ @Test
+ void testParallelCleanInstallWithParentPom() throws Exception {
+ Path testDir = extractResources("gh-12646-project-local-repo-clean-race");
+
+ Verifier verifier = newVerifier(testDir);
+ verifier.addCliArgument("-T");
+ verifier.addCliArgument("4");
+ verifier.addCliArguments("clean", "install");
+ verifier.execute();
+ verifier.verifyErrorFreeLog();
+ }
+}
diff --git a/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/.mvn/.gitkeep b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/.mvn/.gitkeep
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-a/pom.xml b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-a/pom.xml
new file mode 100644
index 000000000000..0bd336766545
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-a/pom.xml
@@ -0,0 +1,29 @@
+
+
+
+
+ org.apache.maven.its.gh12646
+ super-pom
+ 1.0-SNAPSHOT
+ ../super-pom
+
+
+ module-a
+
diff --git a/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-a/src/main/java/a/A.java b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-a/src/main/java/a/A.java
new file mode 100644
index 000000000000..4f3732128374
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-a/src/main/java/a/A.java
@@ -0,0 +1,7 @@
+package a;
+
+public class A {
+ public String greet() {
+ return "Hello from A";
+ }
+}
diff --git a/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-b/pom.xml b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-b/pom.xml
new file mode 100644
index 000000000000..cc2481ca8048
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-b/pom.xml
@@ -0,0 +1,29 @@
+
+
+
+
+ org.apache.maven.its.gh12646
+ super-pom
+ 1.0-SNAPSHOT
+ ../super-pom
+
+
+ module-b
+
diff --git a/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-b/src/main/java/b/B.java b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-b/src/main/java/b/B.java
new file mode 100644
index 000000000000..0dda156c7e65
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-b/src/main/java/b/B.java
@@ -0,0 +1,7 @@
+package b;
+
+public class B {
+ public String greet() {
+ return "Hello from B";
+ }
+}
diff --git a/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-c/pom.xml b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-c/pom.xml
new file mode 100644
index 000000000000..118156c2f73a
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-c/pom.xml
@@ -0,0 +1,29 @@
+
+
+
+
+ org.apache.maven.its.gh12646
+ super-pom
+ 1.0-SNAPSHOT
+ ../super-pom
+
+
+ module-c
+
diff --git a/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-c/src/main/java/c/C.java b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-c/src/main/java/c/C.java
new file mode 100644
index 000000000000..55e1d95b650c
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-c/src/main/java/c/C.java
@@ -0,0 +1,7 @@
+package c;
+
+public class C {
+ public String greet() {
+ return "Hello from C";
+ }
+}
diff --git a/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-d/pom.xml b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-d/pom.xml
new file mode 100644
index 000000000000..88e8aa163899
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-d/pom.xml
@@ -0,0 +1,29 @@
+
+
+
+
+ org.apache.maven.its.gh12646
+ super-pom
+ 1.0-SNAPSHOT
+ ../super-pom
+
+
+ module-d
+
diff --git a/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-d/src/main/java/d/D.java b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-d/src/main/java/d/D.java
new file mode 100644
index 000000000000..fba6e63f6878
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-d/src/main/java/d/D.java
@@ -0,0 +1,7 @@
+package d;
+
+public class D {
+ public String greet() {
+ return "Hello from D";
+ }
+}
diff --git a/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/pom.xml b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/pom.xml
new file mode 100644
index 000000000000..9c80f90a35f3
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/pom.xml
@@ -0,0 +1,38 @@
+
+
+
+
+ org.apache.maven.its.gh12646
+ super-pom
+ 1.0-SNAPSHOT
+ super-pom
+
+
+ root
+ pom
+
+
+ super-pom
+ module-a
+ module-b
+ module-c
+ module-d
+
+
diff --git a/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/super-pom/pom.xml b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/super-pom/pom.xml
new file mode 100644
index 000000000000..9df54d716ac8
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/super-pom/pom.xml
@@ -0,0 +1,25 @@
+
+
+
+ org.apache.maven.its.gh12646
+ super-pom
+ 1.0-SNAPSHOT
+ pom
+
From e1110f7d56d44376c26cecaaf6114dbd5e580c77 Mon Sep 17 00:00:00 2001
From: Guillaume Nodet
Date: Fri, 31 Jul 2026 23:26:30 +0200
Subject: [PATCH 2/5] Fix #12646: move project-local-repo from target/ to .mvn/
to eliminate clean race
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Move the project-local-repo directory from target/project-local-repo to
.mvn/project-local-repo so it is not affected by maven-clean-plugin's
deletion of target/. This eliminates the race condition in parallel builds
where maven-clean-plugin deletes target/ while sibling modules concurrently
write artifacts into project-local-repo.
The previous lock-based approach prevented crashes but could not guarantee
ordering between clean and install operations. Moving the directory outside
target/ makes the race structurally impossible — ReactorReader fully owns
the lifecycle of project-local-repo (per-GAV cleanup on clean, install on
project success).
Co-Authored-By: Claude Opus 4.6
---
.../java/org/apache/maven/ReactorReader.java | 70 ++++---------------
1 file changed, 15 insertions(+), 55 deletions(-)
diff --git a/impl/maven-core/src/main/java/org/apache/maven/ReactorReader.java b/impl/maven-core/src/main/java/org/apache/maven/ReactorReader.java
index bd6fa348d942..8217c743e48a 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/ReactorReader.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/ReactorReader.java
@@ -40,7 +40,6 @@
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -83,11 +82,6 @@ class ReactorReader implements MavenWorkspaceReader {
private Path projectLocalRepository;
// projectId -> Deque
private final Map> lifecycles = new ConcurrentHashMap<>();
- // Coordinates access to project-local-repo between clean and install operations.
- // When the project that owns the project-local-repo directory is being cleaned
- // (maven-clean-plugin deletes its entire target/ directory), the write lock prevents
- // concurrent installs from writing into the directory being deleted.
- private final ReentrantReadWriteLock projectLocalRepoLock = new ReentrantReadWriteLock();
@Inject
ReactorReader(MavenSession session) {
@@ -358,10 +352,11 @@ private File findInProjectLocalRepository(Artifact artifact) {
* The mojo started event is also captured to determine the lifecycle
* phases the project has been through.
*
- * When the project that owns the project-local-repo directory enters its clean phase,
- * we acquire a write lock to prevent concurrent installs from writing into the directory
- * while maven-clean-plugin is deleting it. The lock is released when the clean mojo
- * succeeds or fails.
+ * When a project enters its clean phase, its artifacts are cleaned from the
+ * project-local-repo (per-GAV scope). Since the project-local-repo is located
+ * under {@code .mvn/} (not under {@code target/}), it is not affected by
+ * maven-clean-plugin's deletion of {@code target/}, eliminating the race
+ * condition between clean and install operations in parallel builds.
*
* @param event the execution event
*/
@@ -371,38 +366,19 @@ private void processEvent(ExecutionEvent event) {
case MojoStarted:
String phase = event.getMojoExecution().getLifecyclePhase();
if (phase != null) {
- if ("clean".equals(phase) && isProjectLocalRepoOwner(project)) {
- projectLocalRepoLock.writeLock().lock();
- }
Deque phases = getLifecycles(project);
if (!Objects.equals(phase, phases.peekLast())) {
phases.addLast(phase);
if ("clean".equals(phase)) {
- synchronized (project) {
- cleanProjectLocalRepository(project);
- }
+ cleanProjectLocalRepository(project);
}
}
}
break;
- case MojoSucceeded:
- case MojoFailed:
- String endedPhase = event.getMojoExecution().getLifecyclePhase();
- if ("clean".equals(endedPhase) && isProjectLocalRepoOwner(project)) {
- if (projectLocalRepoLock.isWriteLockedByCurrentThread()) {
- projectLocalRepoLock.writeLock().unlock();
- }
- }
- break;
case ProjectSucceeded:
case ForkedProjectSucceeded:
- projectLocalRepoLock.readLock().lock();
- try {
- synchronized (project) {
- installIntoProjectLocalRepository(project);
- }
- } finally {
- projectLocalRepoLock.readLock().unlock();
+ synchronized (project) {
+ installIntoProjectLocalRepository(project);
}
break;
default:
@@ -414,17 +390,6 @@ private Deque getLifecycles(MavenProject project) {
return lifecycles.computeIfAbsent(project.getId(), k -> new ArrayDeque<>());
}
- /**
- * Checks whether the given project's build directory contains the project-local-repo.
- * When this project's clean phase runs, maven-clean-plugin will delete the build directory,
- * which would race with concurrent installs writing to project-local-repo.
- */
- private boolean isProjectLocalRepoOwner(MavenProject project) {
- Path projectLocalRepo = getProjectLocalRepo();
- Path buildDir = Paths.get(project.getBuild().getDirectory());
- return projectLocalRepo.startsWith(buildDir);
- }
-
/**
* Copy packaged and attached artifacts from this project to the
* project local repository.
@@ -441,6 +406,12 @@ private void installIntoProjectLocalRepository(MavenProject project) {
}
}
+ /**
+ * Cleans the project-local-repo artifacts for the given project's GAV coordinates.
+ * Since the project-local-repo is under {@code .mvn/} and not {@code target/},
+ * it is not affected by maven-clean-plugin's deletion of {@code target/},
+ * so there is no race between clean and install operations in parallel builds.
+ */
private void cleanProjectLocalRepository(MavenProject project) {
try {
Path artifactPath = getProjectLocalRepo()
@@ -544,18 +515,7 @@ private Path getArtifactPath(
private Path getProjectLocalRepo() {
if (projectLocalRepository == null) {
Path root = session.getRequest().getRootDirectory();
- List projects = session.getProjects();
- if (projects != null) {
- projectLocalRepository = projects.stream()
- .filter(project -> Objects.equals(root.toFile(), project.getBasedir()))
- .findFirst()
- .map(project -> project.getBuild().getDirectory())
- .map(Paths::get)
- .orElseGet(() -> root.resolve("target"))
- .resolve(PROJECT_LOCAL_REPO);
- } else {
- return root.resolve("target").resolve(PROJECT_LOCAL_REPO);
- }
+ projectLocalRepository = root.resolve(".mvn").resolve(PROJECT_LOCAL_REPO);
}
return projectLocalRepository;
}
From f0ea27a012106262f6e136314837fc35f0a63592 Mon Sep 17 00:00:00 2001
From: Guillaume Nodet
Date: Tue, 4 Aug 2026 08:41:39 +0200
Subject: [PATCH 3/5] Use .mvn/target/ as the parent directory for
project-local-repo
Place project-local-repo under .mvn/target/ instead of .mvn/ directly,
establishing a clean semantic split: .mvn/ for config (tracked in git),
.mvn/target/ for build output (gitignored). This convention can also
host other cross-module build artifacts in the future.
Co-Authored-By: Claude Opus 4.6
---
.../java/org/apache/maven/ReactorReader.java | 16 +++++++++-------
1 file changed, 9 insertions(+), 7 deletions(-)
diff --git a/impl/maven-core/src/main/java/org/apache/maven/ReactorReader.java b/impl/maven-core/src/main/java/org/apache/maven/ReactorReader.java
index 8217c743e48a..c852dff4ba7e 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/ReactorReader.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/ReactorReader.java
@@ -354,9 +354,10 @@ private File findInProjectLocalRepository(Artifact artifact) {
*
* When a project enters its clean phase, its artifacts are cleaned from the
* project-local-repo (per-GAV scope). Since the project-local-repo is located
- * under {@code .mvn/} (not under {@code target/}), it is not affected by
- * maven-clean-plugin's deletion of {@code target/}, eliminating the race
- * condition between clean and install operations in parallel builds.
+ * under {@code .mvn/target/} (not under the project's own {@code target/}),
+ * it is not affected by maven-clean-plugin's deletion of {@code target/},
+ * eliminating the race condition between clean and install operations in
+ * parallel builds.
*
* @param event the execution event
*/
@@ -408,9 +409,10 @@ private void installIntoProjectLocalRepository(MavenProject project) {
/**
* Cleans the project-local-repo artifacts for the given project's GAV coordinates.
- * Since the project-local-repo is under {@code .mvn/} and not {@code target/},
- * it is not affected by maven-clean-plugin's deletion of {@code target/},
- * so there is no race between clean and install operations in parallel builds.
+ * Since the project-local-repo is under {@code .mvn/target/} and not the project's
+ * own {@code target/}, it is not affected by maven-clean-plugin's deletion of
+ * {@code target/}, so there is no race between clean and install operations
+ * in parallel builds.
*/
private void cleanProjectLocalRepository(MavenProject project) {
try {
@@ -515,7 +517,7 @@ private Path getArtifactPath(
private Path getProjectLocalRepo() {
if (projectLocalRepository == null) {
Path root = session.getRequest().getRootDirectory();
- projectLocalRepository = root.resolve(".mvn").resolve(PROJECT_LOCAL_REPO);
+ projectLocalRepository = root.resolve(".mvn").resolve("target").resolve(PROJECT_LOCAL_REPO);
}
return projectLocalRepository;
}
From 210d5819d807e940986dbc171690ac129b2d9b7c Mon Sep 17 00:00:00 2001
From: Guillaume Nodet
Date: Wed, 5 Aug 2026 23:24:09 +0200
Subject: [PATCH 4/5] Update integration tests for
.mvn/target/project-local-repo path
Update all ITs that assert on project-local-repo file paths to use
the new .mvn/target/project-local-repo location. Add .mvn directories
to test resources that were missing them to ensure proper rootDirectory
resolution. Add .mvn/target cleanup to tests with manual directory
deletion to prevent cross-test-method contamination.
Co-Authored-By: Claude Opus 4.6
---
...venITBomConsumerPomPropertyResolutionTest.java | 1 +
...MavenITConsumerPomBomFromSettingsRepoTest.java | 1 +
.../it/MavenITgh11162ConsumerPomScopesTest.java | 1 +
.../it/MavenITgh11456MixinsConsumerPomTest.java | 2 ++
...nITmng3043BestEffortReactorResolutionTest.java | 5 +++--
.../apache/maven/it/MavenITmng5102MixinsTest.java | 15 +++++++++------
...enITmng8414ConsumerPomWithNewFeaturesTest.java | 2 ++
...ng8645ConsumerPomDependencyManagementTest.java | 1 +
.../.mvn/.gitkeep | 0
.../gh-11162-consumer-pom-scopes/.mvn/.gitkeep | 0
.../.mvn/.gitkeep | 0
.../src/test/resources/mng-3043/.mvn/.gitkeep | 0
.../classifier/project/.mvn/.gitkeep | 0
.../mng-5102-mixins/gav/project/.mvn/.gitkeep | 0
.../resources/mng-5102-mixins/path/.mvn/.gitkeep | 0
.../.mvn/.gitkeep | 0
.../mng-8645-consumer-pom-dep-mgmt/.mvn/.gitkeep | 0
17 files changed, 20 insertions(+), 8 deletions(-)
create mode 100644 its/core-it-suite/src/test/resources/bom-consumer-pom-property-resolution/.mvn/.gitkeep
create mode 100644 its/core-it-suite/src/test/resources/gh-11162-consumer-pom-scopes/.mvn/.gitkeep
create mode 100644 its/core-it-suite/src/test/resources/gh-11767-consumer-pom-bom-from-settings-repo/.mvn/.gitkeep
create mode 100644 its/core-it-suite/src/test/resources/mng-3043/.mvn/.gitkeep
create mode 100644 its/core-it-suite/src/test/resources/mng-5102-mixins/classifier/project/.mvn/.gitkeep
create mode 100644 its/core-it-suite/src/test/resources/mng-5102-mixins/gav/project/.mvn/.gitkeep
create mode 100644 its/core-it-suite/src/test/resources/mng-5102-mixins/path/.mvn/.gitkeep
create mode 100644 its/core-it-suite/src/test/resources/mng-8414-consumer-pom-with-new-features/.mvn/.gitkeep
create mode 100644 its/core-it-suite/src/test/resources/mng-8645-consumer-pom-dep-mgmt/.mvn/.gitkeep
diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITBomConsumerPomPropertyResolutionTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITBomConsumerPomPropertyResolutionTest.java
index eaca1545f515..de0a4e5d3e83 100644
--- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITBomConsumerPomPropertyResolutionTest.java
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITBomConsumerPomPropertyResolutionTest.java
@@ -156,6 +156,7 @@ void testConsumerPomInProjectLocalRepo() throws Exception {
// Check the consumer POM in the project-local-repo
Path consumerPom = basedir.resolve(Path.of(
+ ".mvn",
"target",
"project-local-repo",
"org.apache.maven.its.bom-property",
diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITConsumerPomBomFromSettingsRepoTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITConsumerPomBomFromSettingsRepoTest.java
index 05be1fa3c7ff..50194b86f203 100644
--- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITConsumerPomBomFromSettingsRepoTest.java
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITConsumerPomBomFromSettingsRepoTest.java
@@ -75,6 +75,7 @@ void testConsumerPomWithBomFromSettingsProfileRepo() throws Exception {
// Verify the consumer POM was generated
Path consumerPom = basedir.resolve(Path.of(
+ ".mvn",
"target",
"project-local-repo",
"org.apache.maven.its.cpbom",
diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh11162ConsumerPomScopesTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh11162ConsumerPomScopesTest.java
index 5787c88f8d78..89d5c93fcf82 100644
--- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh11162ConsumerPomScopesTest.java
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh11162ConsumerPomScopesTest.java
@@ -49,6 +49,7 @@ void testConsumerPomFiltersScopes() throws Exception {
verifier.verifyErrorFreeLog();
Path consumerPom = basedir.resolve(Paths.get(
+ ".mvn",
"target",
"project-local-repo",
"org.apache.maven.its.gh11162",
diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh11456MixinsConsumerPomTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh11456MixinsConsumerPomTest.java
index 352b4083acca..8a770499f9f5 100644
--- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh11456MixinsConsumerPomTest.java
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh11456MixinsConsumerPomTest.java
@@ -71,6 +71,7 @@ void testMixinsWithFlattening() throws Exception {
// Verify consumer POM was created
Path consumerPom = basedir.resolve(Paths.get(
+ ".mvn",
"target",
"project-local-repo",
"org.apache.maven.its.gh11456",
@@ -104,6 +105,7 @@ void testMixinsWithPreserveModelVersion() throws Exception {
// Verify consumer POM was created
Path consumerPom = basedir.resolve(Paths.get(
+ ".mvn",
"target",
"project-local-repo",
"org.apache.maven.its.gh11456",
diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng3043BestEffortReactorResolutionTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng3043BestEffortReactorResolutionTest.java
index d0ccbee40d86..84c969a0e48d 100644
--- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng3043BestEffortReactorResolutionTest.java
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng3043BestEffortReactorResolutionTest.java
@@ -18,7 +18,6 @@
*/
package org.apache.maven.it;
-import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
@@ -44,11 +43,11 @@ public class MavenITmng3043BestEffortReactorResolutionTest extends AbstractMaven
@Test
public void testitTestPhase() throws Exception {
Path testDir = extractResources("mng-3043");
- Files.createDirectories(testDir.resolve(".mvn"));
Verifier verifier = newVerifier(testDir);
verifier.setAutoclean(false);
verifier.deleteDirectory("target");
+ verifier.deleteDirectory(".mvn/target");
verifier.deleteDirectory("consumer-a/target");
verifier.deleteDirectory("consumer-b/target");
verifier.deleteDirectory("consumer-c/target");
@@ -105,6 +104,7 @@ public void testitPackagePhase() throws Exception {
Verifier verifier = newVerifier(testDir);
verifier.setAutoclean(false);
verifier.deleteDirectory("target");
+ verifier.deleteDirectory(".mvn/target");
verifier.deleteDirectory("consumer-a/target");
verifier.deleteDirectory("consumer-b/target");
verifier.deleteDirectory("consumer-c/target");
@@ -165,6 +165,7 @@ public void testitPackagePhasesSlitted() throws Exception {
Verifier verifier = newVerifier(testDir);
verifier.setAutoclean(false);
verifier.deleteDirectory("target");
+ verifier.deleteDirectory(".mvn/target");
verifier.deleteDirectory("consumer-a/target");
verifier.deleteDirectory("consumer-b/target");
verifier.deleteDirectory("consumer-c/target");
diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng5102MixinsTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng5102MixinsTest.java
index 32a03ac0010c..206e35b5e20f 100644
--- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng5102MixinsTest.java
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng5102MixinsTest.java
@@ -47,6 +47,7 @@ public void testWithPath() throws Exception {
Verifier verifier = newVerifier(testDir);
verifier.setAutoclean(false);
verifier.deleteDirectory("target");
+ verifier.deleteDirectory(".mvn/target");
verifier.deleteArtifacts("org.apache.maven.its.mng5102");
verifier.addCliArguments("install", "-Dmaven.consumer.pom.flatten=true");
verifier.execute();
@@ -63,9 +64,9 @@ public void testWithPath() throws Exception {
assertEquals("true", props.getProperty("project.properties.mixin3"));
verifier.verifyFilePresent(
- "target/project-local-repo/org.apache.maven.its.mng5102/child/0.1/child-0.1-consumer.pom");
+ ".mvn/target/project-local-repo/org.apache.maven.its.mng5102/child/0.1/child-0.1-consumer.pom");
List lines = verifier.loadLines(
- "target/project-local-repo/org.apache.maven.its.mng5102/child/0.1/child-0.1-consumer.pom");
+ ".mvn/target/project-local-repo/org.apache.maven.its.mng5102/child/0.1/child-0.1-consumer.pom");
assertTrue(lines.stream().noneMatch(l -> l.contains("")));
}
@@ -90,6 +91,7 @@ public void testWithGav() throws Exception {
verifier = newVerifier(testDir.resolve("project"));
verifier.setAutoclean(false);
verifier.deleteDirectory("target");
+ verifier.deleteDirectory(".mvn/target");
verifier.addCliArguments("install", "-Dmaven.consumer.pom.flatten");
verifier.execute();
verifier.verifyErrorFreeLog();
@@ -99,9 +101,9 @@ public void testWithGav() throws Exception {
assertEquals("true", props.getProperty("project.properties.mixin2"));
verifier.verifyFilePresent(
- "target/project-local-repo/org.apache.maven.its.mng5102/gav/0.1/gav-0.1-consumer.pom");
+ ".mvn/target/project-local-repo/org.apache.maven.its.mng5102/gav/0.1/gav-0.1-consumer.pom");
List lines = verifier.loadLines(
- "target/project-local-repo/org.apache.maven.its.mng5102/gav/0.1/gav-0.1-consumer.pom");
+ ".mvn/target/project-local-repo/org.apache.maven.its.mng5102/gav/0.1/gav-0.1-consumer.pom");
assertTrue(lines.stream().anyMatch(l -> l.contains("")));
}
@@ -126,6 +128,7 @@ public void testWithClassifier() throws Exception {
verifier = newVerifier(testDir.resolve("project"));
verifier.setAutoclean(false);
verifier.deleteDirectory("target");
+ verifier.deleteDirectory(".mvn/target");
verifier.addCliArguments("install", "-Dmaven.consumer.pom.flatten");
verifier.execute();
verifier.verifyErrorFreeLog();
@@ -135,9 +138,9 @@ public void testWithClassifier() throws Exception {
assertEquals("true", props.getProperty("project.properties.mixin4"));
verifier.verifyFilePresent(
- "target/project-local-repo/org.apache.maven.its.mng5102/classifier/0.1/classifier-0.1-consumer.pom");
+ ".mvn/target/project-local-repo/org.apache.maven.its.mng5102/classifier/0.1/classifier-0.1-consumer.pom");
List lines = verifier.loadLines(
- "target/project-local-repo/org.apache.maven.its.mng5102/classifier/0.1/classifier-0.1-consumer.pom");
+ ".mvn/target/project-local-repo/org.apache.maven.its.mng5102/classifier/0.1/classifier-0.1-consumer.pom");
assertTrue(lines.stream().anyMatch(l -> l.contains("")));
}
}
diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8414ConsumerPomWithNewFeaturesTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8414ConsumerPomWithNewFeaturesTest.java
index 51c2f5e24c60..a5bb72d3b8fb 100644
--- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8414ConsumerPomWithNewFeaturesTest.java
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8414ConsumerPomWithNewFeaturesTest.java
@@ -53,6 +53,7 @@ void testNotPreserving() throws Exception {
"The consumer POM for org.apache.maven.its:mng-8414:jar:1.0.0-SNAPSHOT cannot be downgraded to 4.0.0.");
Path consumerPom = basedir.resolve(Paths.get(
+ ".mvn",
"target",
"project-local-repo",
"org.apache.maven.its",
@@ -83,6 +84,7 @@ void testPreserving() throws Exception {
verifier.verifyTextNotInLog("cannot be downgraded to 4.0.0.");
Path consumerPom = basedir.resolve(Paths.get(
+ ".mvn",
"target",
"project-local-repo",
"org.apache.maven.its",
diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8645ConsumerPomDependencyManagementTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8645ConsumerPomDependencyManagementTest.java
index 43a115967081..2642da02c087 100644
--- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8645ConsumerPomDependencyManagementTest.java
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8645ConsumerPomDependencyManagementTest.java
@@ -52,6 +52,7 @@ void testIt() throws Exception {
// validate consumer pom
Path consumerPom = basedir.resolve(Paths.get(
+ ".mvn",
"target",
"project-local-repo",
"org.apache.maven.its.mng8645",
diff --git a/its/core-it-suite/src/test/resources/bom-consumer-pom-property-resolution/.mvn/.gitkeep b/its/core-it-suite/src/test/resources/bom-consumer-pom-property-resolution/.mvn/.gitkeep
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/its/core-it-suite/src/test/resources/gh-11162-consumer-pom-scopes/.mvn/.gitkeep b/its/core-it-suite/src/test/resources/gh-11162-consumer-pom-scopes/.mvn/.gitkeep
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/its/core-it-suite/src/test/resources/gh-11767-consumer-pom-bom-from-settings-repo/.mvn/.gitkeep b/its/core-it-suite/src/test/resources/gh-11767-consumer-pom-bom-from-settings-repo/.mvn/.gitkeep
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/its/core-it-suite/src/test/resources/mng-3043/.mvn/.gitkeep b/its/core-it-suite/src/test/resources/mng-3043/.mvn/.gitkeep
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/its/core-it-suite/src/test/resources/mng-5102-mixins/classifier/project/.mvn/.gitkeep b/its/core-it-suite/src/test/resources/mng-5102-mixins/classifier/project/.mvn/.gitkeep
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/its/core-it-suite/src/test/resources/mng-5102-mixins/gav/project/.mvn/.gitkeep b/its/core-it-suite/src/test/resources/mng-5102-mixins/gav/project/.mvn/.gitkeep
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/its/core-it-suite/src/test/resources/mng-5102-mixins/path/.mvn/.gitkeep b/its/core-it-suite/src/test/resources/mng-5102-mixins/path/.mvn/.gitkeep
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/its/core-it-suite/src/test/resources/mng-8414-consumer-pom-with-new-features/.mvn/.gitkeep b/its/core-it-suite/src/test/resources/mng-8414-consumer-pom-with-new-features/.mvn/.gitkeep
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/its/core-it-suite/src/test/resources/mng-8645-consumer-pom-dep-mgmt/.mvn/.gitkeep b/its/core-it-suite/src/test/resources/mng-8645-consumer-pom-dep-mgmt/.mvn/.gitkeep
new file mode 100644
index 000000000000..e69de29bb2d1
From 331741e2c25912d573bd5ef15652152b4828628c Mon Sep 17 00:00:00 2001
From: Guillaume Nodet
Date: Wed, 5 Aug 2026 23:41:52 +0200
Subject: [PATCH 5/5] Add maven.project.local.repo property to configure
project-local-repo location
Introduce a user property (maven.project.local.repo) that allows
overriding the project-local repository path. The default remains
.mvn/target/project-local-repo relative to the root directory.
The property accepts absolute paths or paths relative to rootDirectory,
and is documented in maven-user.properties.
Co-Authored-By: Claude Opus 4.6
---
.../src/assembly/maven/conf/maven-user.properties | 10 ++++++++++
.../main/java/org/apache/maven/api/Constants.java | 15 +++++++++++++++
.../main/java/org/apache/maven/ReactorReader.java | 9 ++++++++-
3 files changed, 33 insertions(+), 1 deletion(-)
diff --git a/apache-maven/src/assembly/maven/conf/maven-user.properties b/apache-maven/src/assembly/maven/conf/maven-user.properties
index c664d06cc5e5..1c93283a3b2c 100644
--- a/apache-maven/src/assembly/maven/conf/maven-user.properties
+++ b/apache-maven/src/assembly/maven/conf/maven-user.properties
@@ -30,6 +30,16 @@
${includes} = ?"${maven.user.conf}/maven-user.properties", \
?"${maven.project.conf}/maven-user.properties"
+#
+# Project-Local Repository
+#
+# Location of the project-local repository used by the reactor to share artifacts between
+# modules during a build. This repository enables partial and resumable builds (e.g.
+# mvn verify -r :module) without requiring install. The path may be absolute or relative
+# to the root directory.
+#
+# maven.project.local.repo = ${maven.rootDirectory}/.mvn/target/project-local-repo
+
#
# Maven Cache Configuration
#
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/Constants.java b/api/maven-api-core/src/main/java/org/apache/maven/api/Constants.java
index 4e799dc3061a..ef10ffa6b45e 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/Constants.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/Constants.java
@@ -379,6 +379,21 @@ public final class Constants {
@Config
public static final String MAVEN_VERSION_FILTER = "maven.session.versionFilter";
+ /**
+ * User property for overriding the project-local repository path used by the reactor reader to share
+ * artifacts between modules during a build. This repository enables partial and resumable builds
+ * (e.g. {@code mvn verify -r :module}) without requiring {@code install}.
+ *
+ * The path may be absolute or relative to the root directory. If relative, it is resolved against
+ * the root directory of the project.
+ *
+ * Default value: ${maven.rootDirectory}/.mvn/target/project-local-repo.
+ *
+ * @since 4.0.0
+ */
+ @Config(defaultValue = "${maven.rootDirectory}/.mvn/target/project-local-repo")
+ public static final String MAVEN_PROJECT_LOCAL_REPO = "maven.project.local.repo";
+
/**
* User property for chained LRM: the new "head" local repository to use, and "push" the existing into tail.
* Similar to maven.repo.local.tail, this property may contain comma separated list of paths to be
diff --git a/impl/maven-core/src/main/java/org/apache/maven/ReactorReader.java b/impl/maven-core/src/main/java/org/apache/maven/ReactorReader.java
index c852dff4ba7e..194ae1cf67e0 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/ReactorReader.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/ReactorReader.java
@@ -43,6 +43,7 @@
import java.util.stream.Collectors;
import java.util.stream.Stream;
+import org.apache.maven.api.Constants;
import org.apache.maven.api.model.Model;
import org.apache.maven.api.services.Lookup;
import org.apache.maven.eventspy.EventSpy;
@@ -517,7 +518,13 @@ private Path getArtifactPath(
private Path getProjectLocalRepo() {
if (projectLocalRepository == null) {
Path root = session.getRequest().getRootDirectory();
- projectLocalRepository = root.resolve(".mvn").resolve("target").resolve(PROJECT_LOCAL_REPO);
+ String userPath = session.getRequest().getUserProperties().getProperty(Constants.MAVEN_PROJECT_LOCAL_REPO);
+ if (userPath != null && !userPath.isEmpty()) {
+ Path path = Paths.get(userPath);
+ projectLocalRepository = path.isAbsolute() ? path : root.resolve(path);
+ } else {
+ projectLocalRepository = root.resolve(".mvn").resolve("target").resolve(PROJECT_LOCAL_REPO);
+ }
}
return projectLocalRepository;
}