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: + *
    + *
  1. super-pom builds first (no dependencies)
  2. + *
  3. root pom and module-a..d start in parallel (all depend on super-pom)
  4. + *
  5. root pom's clean phase runs maven-clean-plugin which deletes target/
  6. + *
  7. modules complete and install artifacts into target/project-local-repo
  8. + *
+ * 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; }