Skip to content
2 changes: 2 additions & 0 deletions plugin-gradle/CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (starting after version `3.27.0`).

## [Unreleased]
### Added
- Partial support for isolated projects. They work if predeclared dependencies are not used. ([#2854](https://github.com/diffplug/spotless/pull/2854))
### Fixed
- Fix the ability to specify a wildcard version (`*`) for external formatter executables, which did not work. ([#2848](https://github.com/diffplug/spotless/pull/2848))

Expand Down
2 changes: 2 additions & 0 deletions plugin-gradle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1918,6 +1918,8 @@ Alternatively, you can also use `predeclareDepsFromBuildscript()` to resolve the

If you use this feature, you will get an error if you use a formatter in a subproject which is not declared in the `spotlessPredeclare` block.

Note that this feature is also incompatible with Isolated projects, because every project must reference the root project.

<a name="preview"></a>

## How do I preview what `spotlessApply` will do?
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2023-2025 DiffPlug
* Copyright 2023-2026 DiffPlug
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -25,6 +25,8 @@

import javax.annotation.Nullable;

import org.gradle.api.file.Directory;

import com.diffplug.common.collect.ImmutableList;
import com.diffplug.common.collect.ImmutableSortedMap;
import com.diffplug.spotless.FileSignature;
Expand Down Expand Up @@ -160,7 +162,9 @@ private KtlintConfig(
Map<String, Object> editorConfigOverride,
List<String> customRuleSets) throws IOException {
Objects.requireNonNull(version);
File defaultEditorConfig = getProject().getRootProject().file(".editorconfig");
@SuppressWarnings("UnstableApiUsage")
Directory rootProjectDir = getProject().getIsolated().getRootProject().getProjectDirectory();
File defaultEditorConfig = rootProjectDir.file(".editorconfig").getAsFile();
FileSignature editorConfigPath = defaultEditorConfig.exists() ? FileSignature.signAsList(defaultEditorConfig) : null;
this.version = version;
this.editorConfigPath = editorConfigPath;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,11 @@ public FormatExtension(SpotlessExtension spotless) {
}

protected final Provisioner provisioner() {
return spotless.getRegisterDependenciesTask().getTaskService().get().provisionerFor(spotless);
return spotless.getSpotlessTaskService().get().provisionerFor(spotless);
}

protected final P2Provisioner p2Provisioner() {
return spotless.getRegisterDependenciesTask().getTaskService().get().p2ProvisionerFor(spotless);
return spotless.getSpotlessTaskService().get().p2ProvisionerFor(spotless);
}

private String formatName() {
Expand Down Expand Up @@ -1098,7 +1098,7 @@ protected void setupTask(SpotlessTask task) {
LineEnding lineEndings = getLineEndings();
task.setLineEndingsPolicy(
getProject().provider(() -> lineEndings.createPolicy(projectDir.getAsFile(), () -> totalTarget)));
spotless.getRegisterDependenciesTask().hookSubprojectTask(task);
spotless.getSpotlessTaskService().get().hookSubprojectTask(getProject(), task);
task.setupRatchet(getRatchetFrom() != null ? getRatchetFrom() : "");
}

Expand Down Expand Up @@ -1134,7 +1134,7 @@ public TaskProvider<SpotlessApply> createIndependentApplyTaskLazy(String taskNam
"Task name must not end with " + SpotlessExtension.APPLY);
TaskProvider<SpotlessTaskImpl> spotlessTask = spotless.project.getTasks()
.register(taskName + SpotlessTaskService.INDEPENDENT_HELPER, SpotlessTaskImpl.class, task -> {
task.init(spotless.getRegisterDependenciesTask().getTaskService());
task.init(spotless.getSpotlessTaskService());
setupTask(task);
// clean removes the SpotlessCache, so we have to run after clean
task.mustRunAfter(BasePlugin.CLEAN_TASK_NAME);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2025 DiffPlug
* Copyright 2025-2026 DiffPlug
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -18,24 +18,23 @@
import javax.annotation.Nullable;

import org.gradle.api.Project;
import org.gradle.api.plugins.ExtraPropertiesExtension;

public final class GradleCompat {
private GradleCompat() {}

@Nullable public static String findOptionalProperty(Project project, String propertyName) {
@Nullable String value = project.getProviders().gradleProperty(propertyName).getOrNull();
if (value != null) {
return value;
}
@Nullable Object property = project.findProperty(propertyName);
if (property != null) {
return property.toString();
ExtraPropertiesExtension extras = project.getExtensions().getByType(ExtraPropertiesExtension.class);
if (extras.has(propertyName)) {
@Nullable Object property = extras.get(propertyName);
if (property != null) {
return property.toString();
}
}
return null;
}

public static boolean isPropertyPresent(Project project, String propertyName) {
return project.getProviders().gradleProperty(propertyName).isPresent() ||
project.hasProperty(propertyName);
return project.getExtensions().getByType(ExtraPropertiesExtension.class).has(propertyName);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2016-2023 DiffPlug
* Copyright 2016-2026 DiffPlug
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -25,7 +25,6 @@

import org.gradle.api.DefaultTask;
import org.gradle.api.provider.Provider;
import org.gradle.api.services.BuildServiceRegistry;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.Internal;
import org.gradle.api.tasks.OutputFile;
Expand Down Expand Up @@ -64,8 +63,7 @@ void hookSubprojectTask(SpotlessTask task) {
void setup() {
Preconditions.checkArgument(getProject().getRootProject() == getProject(), "Can only be used on the root project");
String compositeBuildSuffix = getName().substring(TASK_NAME.length()); // see https://github.com/diffplug/spotless/pull/1001
BuildServiceRegistry buildServices = getProject().getGradle().getSharedServices();
taskService = buildServices.registerIfAbsent("SpotlessTaskService" + compositeBuildSuffix, SpotlessTaskService.class, spec -> {});
taskService = SpotlessTaskService.registerIfAbsent(getProject(), compositeBuildSuffix);
usesService(taskService);
getBuildEventsListenerRegistry().onTaskCompletion(taskService);
unitOutput = new File(getProject().getLayout().getBuildDirectory().getAsFile().get(), "tmp/spotless-register-dependencies");
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2016-2025 DiffPlug
* Copyright 2016-2026 DiffPlug
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -27,15 +27,14 @@
import org.gradle.api.Action;
import org.gradle.api.GradleException;
import org.gradle.api.Project;
import org.gradle.api.tasks.TaskContainer;
import org.gradle.api.tasks.TaskProvider;
import org.gradle.api.provider.Provider;
import org.gradle.language.base.plugins.LifecycleBasePlugin;

import com.diffplug.spotless.LineEnding;

public abstract class SpotlessExtension {
final Project project;
private final RegisterDependenciesTask registerDependenciesTask;
private final Provider<SpotlessTaskService> spotlessTaskService;

protected static final String TASK_GROUP = LifecycleBasePlugin.VERIFICATION_GROUP;
protected static final String BUILD_SETUP_TASK_GROUP = "build setup";
Expand All @@ -52,11 +51,11 @@ public abstract class SpotlessExtension {

protected SpotlessExtension(Project project) {
this.project = requireNonNull(project);
this.registerDependenciesTask = findRegisterDepsTask().get();
this.spotlessTaskService = SpotlessTaskService.registerIfAbsent(project, "");
}

RegisterDependenciesTask getRegisterDependenciesTask() {
return registerDependenciesTask;
Provider<SpotlessTaskService> getSpotlessTaskService() {
return spotlessTaskService;
}

/** Line endings (if any). */
Expand Down Expand Up @@ -303,27 +302,6 @@ <T extends FormatExtension> T instantiateFormatExtension(Class<T> clazz) {

protected abstract void createFormatTasks(String name, FormatExtension formatExtension);

TaskProvider<RegisterDependenciesTask> findRegisterDepsTask() {
try {
return findRegisterDepsTask(RegisterDependenciesTask.TASK_NAME);
} catch (Exception e) {
// in a composite build there can be multiple Spotless plugins on the classpath, and they will each try to register
// a task on the root project with the same name. That will generate casting errors, which we can catch and try again
// with an identity-specific identifier.
// https://github.com/diffplug/spotless/pull/1001 for details
return findRegisterDepsTask(RegisterDependenciesTask.TASK_NAME + System.identityHashCode(RegisterDependenciesTask.class));
}
}

private TaskProvider<RegisterDependenciesTask> findRegisterDepsTask(String taskName) {
TaskContainer rootProjectTasks = project.getRootProject().getTasks();
if (!rootProjectTasks.getNames().contains(taskName)) {
return rootProjectTasks.register(taskName, RegisterDependenciesTask.class, RegisterDependenciesTask::setup);
} else {
return rootProjectTasks.named(taskName, RegisterDependenciesTask.class);
}
}

public void predeclareDepsFromBuildscript() {
if (project.getRootProject() != project) {
throw new GradleException("predeclareDepsFromBuildscript can only be called from the root project");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ protected void createFormatTasks(String name, FormatExtension formatExtension) {
// create the SpotlessTask
String taskName = EXTENSION + SpotlessPlugin.capitalize(name);
TaskProvider<SpotlessTaskImpl> spotlessTask = tasks.register(taskName, SpotlessTaskImpl.class, task -> {
task.init(getRegisterDependenciesTask().getTaskService());
task.init(getSpotlessTaskService());
task.setGroup(TASK_GROUP);
task.getIdeHookState().set(ideHook);
// clean removes the SpotlessCache, so we have to run after clean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,28 @@

import org.gradle.api.Action;
import org.gradle.api.Project;
import org.gradle.api.tasks.TaskProvider;

import com.diffplug.spotless.LazyForwardingEquality;

public class SpotlessExtensionPredeclare extends SpotlessExtension {
private final SortedMap<String, FormatExtension> toSetup = new TreeMap<>();
private final RegisterDependenciesTask registerDependenciesTask;

public SpotlessExtensionPredeclare(Project project, GradleProvisioner.Policy policy) {
super(project);
getRegisterDependenciesTask().getTaskService().get().predeclaredProvisioner = policy.dedupingProvisioner(project);
getRegisterDependenciesTask().getTaskService().get().predeclaredP2Provisioner = policy.dedupingP2Provisioner(project);
this.registerDependenciesTask = findRegisterDepsTask().get();
SpotlessTaskService taskService = getSpotlessTaskService().get();
taskService.isUsingPredeclared = true;
taskService.predeclaredProvisioner = policy.dedupingProvisioner(project);
taskService.predeclaredP2Provisioner = policy.dedupingP2Provisioner(project);
project.afterEvaluate(unused -> toSetup.forEach((name, formatExtension) -> {
for (Action<FormatExtension> lazyAction : formatExtension.lazyActions) {
lazyAction.execute(formatExtension);
}
getRegisterDependenciesTask().steps.addAll(formatExtension.steps);
registerDependenciesTask.steps.addAll(formatExtension.steps);
// needed to fix Deemon memory leaks (#1194), but this line came from https://github.com/diffplug/spotless/pull/1206
LazyForwardingEquality.unlazy(getRegisterDependenciesTask().steps);
LazyForwardingEquality.unlazy(registerDependenciesTask.steps);
}));
}

Expand All @@ -49,4 +54,21 @@ protected void createFormatTasks(String name, FormatExtension formatExtension) {
protected void predeclare(GradleProvisioner.Policy policy) {
throw new UnsupportedOperationException("predeclare can't be called from within `" + EXTENSION_PREDECLARE + "`");
}

private TaskProvider<RegisterDependenciesTask> findRegisterDepsTask() {
try {
return findRegisterDepsTask(RegisterDependenciesTask.TASK_NAME);
} catch (Exception e) {
// in a composite build there can be multiple Spotless plugins on the classpath, and they will each try to register
// a task on the root project with the same name. That will generate casting errors, which we can catch and try again
// with an identity-specific identifier.
// https://github.com/diffplug/spotless/pull/1001 for details
return findRegisterDepsTask(RegisterDependenciesTask.TASK_NAME + System.identityHashCode(RegisterDependenciesTask.class));
}
}

private TaskProvider<RegisterDependenciesTask> findRegisterDepsTask(String taskName) {
return project.getTasks().register(taskName, RegisterDependenciesTask.class, RegisterDependenciesTask::setup);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import javax.inject.Inject;

import org.gradle.api.DefaultTask;
import org.gradle.api.Project;
import org.gradle.api.file.ConfigurableFileTree;
import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.file.FileVisitDetails;
Expand Down Expand Up @@ -55,6 +56,7 @@
* apply already did).
*/
public abstract class SpotlessTaskService implements BuildService<BuildServiceParameters.None>, AutoCloseable, OperationCompletionListener {
protected boolean isUsingPredeclared = false;
private final Map<String, SpotlessApply> apply = Collections.synchronizedMap(new HashMap<>());
private final Map<String, SpotlessTask> source = Collections.synchronizedMap(new HashMap<>());
private final Map<String, Provisioner> provisioner = Collections.synchronizedMap(new HashMap<>());
Expand Down Expand Up @@ -126,6 +128,21 @@ static void usesServiceTolerateTestFailure(DefaultTask task, Provider<SpotlessTa
}
}

public void hookSubprojectTask(Project project, SpotlessTask task) {
// This check allows isolated projects support by not accessing the root project tasks unless really needed
if (!isUsingPredeclared)
return;

project.getRootProject().getTasks().withType(RegisterDependenciesTask.class, (registerTask) -> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling getRootProject().getTasks() looks unsafe for IP.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's the only incompatibility remaining (hence the partial support in the title), I added a comment about it in line 132.

This affects only the predeclared dependencies, which I think should not be used alongside isolated projects.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All right. Let's leave it here. I couldn't find a replacement for it either.

registerTask.hookSubprojectTask(task);
});
}

public static Provider<SpotlessTaskService> registerIfAbsent(Project project, String suffix) {
return project.getGradle().getSharedServices()
.registerIfAbsent("SpotlessTaskService" + suffix, SpotlessTaskService.class, spec -> {});
}

abstract static class ClientTask extends DefaultTask {
@Internal
abstract Property<File> getSpotlessCleanDirectory();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2016-2025 DiffPlug
* Copyright 2016-2026 DiffPlug
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -136,10 +136,9 @@ private void expectSuccess() throws Exception {
private StringSelfie expectFailureAndConsoleToBe() throws Exception {
BuildResult result = gradleRunner().withArguments("check").buildAndFail();
String output = result.getOutput();
int register = output.indexOf(":spotlessInternalRegisterDependencies");
int firstNewlineAfterThat = output.indexOf('\n', register + 1);
int firstTask = output.indexOf("> Task");
int firstTry = output.indexOf("\n* Try:");
String useThisToMatch = output.substring(firstNewlineAfterThat, firstTry).trim();
String useThisToMatch = output.substring(firstTask, firstTry).trim();
return Selfie.expectSelfie(useThisToMatch);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2016-2025 DiffPlug
* Copyright 2016-2026 DiffPlug
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -197,7 +197,7 @@ private void taskIsUpToDate(String task, boolean upToDate) throws IOException {

List<String> expected = outcomes(buildResult, upToDate ? TaskOutcome.UP_TO_DATE : TaskOutcome.SUCCESS);
List<String> notExpected = outcomes(buildResult, upToDate ? TaskOutcome.SUCCESS : TaskOutcome.UP_TO_DATE);
boolean everythingAsExpected = !expected.isEmpty() && notExpected.isEmpty() && buildResult.getTasks().size() - 1 == expected.size();
boolean everythingAsExpected = !expected.isEmpty() && notExpected.isEmpty() && buildResult.getTasks().size() == expected.size();
if (!everythingAsExpected) {
fail("Expected all tasks to be " + (upToDate ? TaskOutcome.UP_TO_DATE : TaskOutcome.SUCCESS) + ", but instead was\n" + buildResultToString(buildResult));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2016-2025 DiffPlug
* Copyright 2016-2026 DiffPlug
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -151,4 +151,38 @@ public void predeclaredUndeclared() throws IOException {
Assertions.assertThat(gradleRunner().withArguments("spotlessApply").buildAndFail().getOutput())
.contains("Could not find method spotlessPredeclare() for arguments");
}

@Test
void nonPredeclaredSupportsIsolatedProjects() throws IOException {
setFile("build.gradle").toLines(
"plugins {",
" id 'com.diffplug.spotless'",
"}",
"repositories { mavenCentral() }",
"",
"spotless {",
" java {",
" target file('test.java')",
" googleJavaFormat('1.17.0')",
" }",
"}");
createNSubprojects();
gradleRunner().withArguments("spotlessApply", "-Dorg.gradle.unsafe.isolated-projects=true").build();
}

@Test
void predeclaredRequiresNonIsolatedProjects() throws IOException {
setFile("build.gradle").toLines(
"plugins {",
" id 'com.diffplug.spotless'",
"}",
"repositories { mavenCentral() }",
"spotless { predeclareDeps() }",
"spotlessPredeclare {",
" java { googleJavaFormat('1.17.0') }",
"}");
createNSubprojects();
Assertions.assertThat(gradleRunner().withArguments("spotlessApply", "-Dorg.gradle.unsafe.isolated-projects=true").buildAndFail().getOutput())
.containsAnyOf("Cannot access project", "cannot access 'Project.tasks'");
}
}
Loading
Loading