From ca713012cd7c42f965dac2ddb4fac2ac2db27d32 Mon Sep 17 00:00:00 2001 From: Josh Friend Date: Fri, 1 May 2026 14:26:55 -0400 Subject: [PATCH 1/2] Lazily initialize GitRatchetGradle to avoid loading JGit at configuration time GitRatchetGradle is instantiated eagerly when SpotlessTaskService is created, which happens during Gradle configuration. Its static initializer installs a custom JGit SystemReader that reads ~/.gitconfig. This causes Gradle's configuration cache to fingerprint the file even when no Spotless tasks are requested. Defer instantiation to getRatchet(), which is only called during task execution. This avoids loading JGit classes and reading git config files at configuration time. --- .../gradle/spotless/SpotlessTaskService.java | 9 +++- .../spotless/ConfigurationCacheTest.java | 50 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java index 0aa7b596a7..a35344df49 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java @@ -99,9 +99,12 @@ void registerApplyAlreadyRan(SpotlessApply task) { } // - private final GitRatchetGradle ratchet = new GitRatchetGradle(); + private GitRatchetGradle ratchet; GitRatchetGradle getRatchet() { + if (ratchet == null) { + ratchet = new GitRatchetGradle(); + } return ratchet; } @@ -112,7 +115,9 @@ public void onFinish(FinishEvent var1) { @Override public void close() throws Exception { - ratchet.close(); + if (ratchet != null) { + ratchet.close(); + } } // diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/ConfigurationCacheTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/ConfigurationCacheTest.java index bc47ad54e1..979bf9ffa9 100644 --- a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/ConfigurationCacheTest.java +++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/ConfigurationCacheTest.java @@ -15,8 +15,12 @@ */ package com.diffplug.gradle.spotless; +import java.io.File; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.assertj.core.api.Assertions; import org.gradle.testkit.runner.GradleRunner; import org.junit.jupiter.api.Test; @@ -60,6 +64,52 @@ public void helpConfiguresIfTasksAreCreated() throws IOException { gradleRunner().withArguments("help").build(); } + @Test + public void configurationCacheNotInvalidatedByGitconfig() throws IOException { + // ~/.gitconfig is read by JGit at class-load time via the SystemReader. + // If GitRatchetGradle is loaded eagerly during configuration, Gradle's + // configuration cache fingerprints ~/.gitconfig. When its content changes + // (e.g. CI workers inject per-build auth tokens), the cache is invalidated. + // This test verifies that changing ~/.gitconfig between runs does not + // invalidate the configuration cache. + Path gitconfig = Path.of(System.getProperty("user.home"), ".gitconfig"); + boolean existed = Files.exists(gitconfig); + String originalContent = existed ? Files.readString(gitconfig) : null; + try { + Files.writeString(gitconfig, "[user]\n\tname = test\n"); + + setFile("build.gradle").toLines( + "plugins {", + " id 'com.diffplug.spotless'", + "}", + "repositories { mavenCentral() }", + "apply plugin: 'java'", + "spotless {", + " java {", + " googleJavaFormat()", + " }", + "}"); + setFile("src/main/java/test.java").toResource("java/googlejavaformat/JavaCodeUnformatted.test"); + + // first run stores the configuration cache + gradleRunner().withArguments("help").build(); + + // change ~/.gitconfig content between runs + Files.writeString(gitconfig, "[user]\n\tname = test\n[http]\n\textraheader = changed\n"); + + // second run must reuse the configuration cache despite the change + String output = gradleRunner().withArguments("help").build().getOutput(); + Assertions.assertThat(output).contains("Reusing configuration cache."); + } finally { + // restore original ~/.gitconfig + if (originalContent != null) { + Files.writeString(gitconfig, originalContent); + } else if (Files.exists(gitconfig)) { + Files.delete(gitconfig); + } + } + } + @Test public void multipleRuns() throws IOException { setFile("build.gradle").toLines( From 058962ce378689705e482dcd79939587d8491de8 Mon Sep 17 00:00:00 2001 From: Josh Friend Date: Fri, 1 May 2026 17:09:02 -0400 Subject: [PATCH 2/2] Use ValueSource for git-aware line ending resolution to fix configuration cache The default GIT_ATTRIBUTES_FAST_ALLSAME line ending policy reads ~/.gitconfig via JGit during configuration. Gradle's configuration cache fingerprints this read, so when CI workers inject per-build auth tokens into ~/.gitconfig, the cache is invalidated every build. Wrap the git config resolution in a Gradle ValueSource. File reads inside ValueSource.obtain() are not tracked as configuration cache inputs; only the returned value is fingerprinted. This means ~/.gitconfig changes that do not affect the resolved line ending (e.g. auth tokens) no longer invalidate the cache. --- .../gradle/spotless/FormatExtension.java | 14 ++- .../GitConfigLineEndingValueSource.java | 108 ++++++++++++++++++ .../gradle/spotless/SpotlessTaskService.java | 9 +- .../spotless/ConfigurationCacheTest.java | 64 +++++------ 4 files changed, 151 insertions(+), 44 deletions(-) create mode 100644 plugin-gradle/src/main/java/com/diffplug/gradle/spotless/GitConfigLineEndingValueSource.java diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java index e17f37b0fa..8ce2d7feba 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java @@ -1096,8 +1096,18 @@ protected void setupTask(SpotlessTask task) { task.setSteps(steps); Directory projectDir = getProject().getLayout().getProjectDirectory(); LineEnding lineEndings = getLineEndings(); - task.setLineEndingsPolicy( - getProject().provider(() -> lineEndings.createPolicy(projectDir.getAsFile(), () -> totalTarget))); + if (lineEndings == LineEnding.GIT_ATTRIBUTES_FAST_ALLSAME || lineEndings == LineEnding.GIT_ATTRIBUTES) { + // Wrap git-aware line ending resolution in a ValueSource so that file reads + // (e.g. ~/.gitconfig) are not tracked as configuration cache inputs; + // only the resolved line ending string is fingerprinted. + task.setLineEndingsPolicy( + getProject().getProviders().of(GitConfigLineEndingValueSource.class, spec -> { + spec.getParameters().getProjectDir().set(projectDir); + })); + } else { + task.setLineEndingsPolicy( + getProject().provider(() -> lineEndings.createPolicy(projectDir.getAsFile(), () -> totalTarget))); + } spotless.getSpotlessTaskService().get().hookSubprojectTask(getProject(), task); task.setupRatchet(getRatchetFrom() != null ? getRatchetFrom() : ""); } diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/GitConfigLineEndingValueSource.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/GitConfigLineEndingValueSource.java new file mode 100644 index 0000000000..c564c99531 --- /dev/null +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/GitConfigLineEndingValueSource.java @@ -0,0 +1,108 @@ +/* + * 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. + * 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 com.diffplug.gradle.spotless; + +import java.io.File; + +import javax.annotation.Nullable; + +import org.eclipse.jgit.lib.Config; +import org.eclipse.jgit.lib.ConfigConstants; +import org.eclipse.jgit.lib.Constants; +import org.eclipse.jgit.lib.CoreConfig.AutoCRLF; +import org.eclipse.jgit.lib.CoreConfig.EOL; +import org.eclipse.jgit.storage.file.FileBasedConfig; +import org.eclipse.jgit.util.FS; +import org.eclipse.jgit.util.SystemReader; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.provider.ValueSource; +import org.gradle.api.provider.ValueSourceParameters; + +import com.diffplug.common.base.Errors; +import com.diffplug.spotless.LineEnding; + +/** + * A Gradle {@link ValueSource} that resolves the default line ending from git config. + * + *

File reads inside {@code obtain()} are not tracked as configuration cache inputs; + * only the returned value is fingerprinted. This prevents {@code ~/.gitconfig} changes + * (e.g. CI-injected auth tokens) from invalidating the configuration cache, while still + * correctly invalidating when the resolved line ending actually changes. + */ +public abstract class GitConfigLineEndingValueSource implements ValueSource { + + public interface Params extends ValueSourceParameters { + DirectoryProperty getProjectDir(); + } + + @Override + public @Nullable LineEnding.Policy obtain() { + File projectDir = getParameters().getProjectDir().get().getAsFile(); + + FS.DETECTED.setGitSystemConfig(new File("no-global-git-config-for-spotless")); + + FileBasedConfig systemConfig = SystemReader.getInstance().openSystemConfig(null, FS.DETECTED); + Errors.log().run(systemConfig::load); + FileBasedConfig userConfig = SystemReader.getInstance().openUserConfig(systemConfig, FS.DETECTED); + Errors.log().run(userConfig::load); + + // Read repo-specific config if we're in a git repo + Config config = userConfig; + File gitDir = findGitDir(projectDir); + if (gitDir != null) { + FileBasedConfig repoConfig = new FileBasedConfig(userConfig, new File(gitDir, Constants.CONFIG), FS.DETECTED); + Errors.log().run(repoConfig::load); + config = repoConfig; + } + + return defaultLineEnding(config).createPolicy(); + } + + /** Walks up from projectDir looking for a .git directory. */ + private static @Nullable File findGitDir(File dir) { + while (dir != null) { + File dotGit = new File(dir, Constants.DOT_GIT); + if (dotGit.isDirectory()) { + return dotGit; + } + dir = dir.getParentFile(); + } + return null; + } + + private static LineEnding defaultLineEnding(Config config) { + AutoCRLF autoCRLF = config.getEnum(ConfigConstants.CONFIG_CORE_SECTION, null, ConfigConstants.CONFIG_KEY_AUTOCRLF, AutoCRLF.FALSE); + if (autoCRLF == AutoCRLF.TRUE) { + return LineEnding.WINDOWS; + } else if (autoCRLF == AutoCRLF.INPUT) { + return LineEnding.UNIX; + } else if (autoCRLF == AutoCRLF.FALSE) { + EOL eol = config.getEnum(ConfigConstants.CONFIG_CORE_SECTION, null, ConfigConstants.CONFIG_KEY_EOL, EOL.NATIVE); + switch (eol) { + case CRLF: + return LineEnding.WINDOWS; + case LF: + return LineEnding.UNIX; + case NATIVE: + return LineEnding.PLATFORM_NATIVE; + default: + throw new IllegalArgumentException("Unknown eol " + eol); + } + } else { + throw new IllegalStateException("Unexpected value for autoCRLF " + autoCRLF); + } + } +} diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java index a35344df49..0aa7b596a7 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java @@ -99,12 +99,9 @@ void registerApplyAlreadyRan(SpotlessApply task) { } // - private GitRatchetGradle ratchet; + private final GitRatchetGradle ratchet = new GitRatchetGradle(); GitRatchetGradle getRatchet() { - if (ratchet == null) { - ratchet = new GitRatchetGradle(); - } return ratchet; } @@ -115,9 +112,7 @@ public void onFinish(FinishEvent var1) { @Override public void close() throws Exception { - if (ratchet != null) { - ratchet.close(); - } + ratchet.close(); } // diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/ConfigurationCacheTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/ConfigurationCacheTest.java index 979bf9ffa9..1eeacc006d 100644 --- a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/ConfigurationCacheTest.java +++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/ConfigurationCacheTest.java @@ -18,7 +18,6 @@ import java.io.File; import java.io.IOException; import java.nio.file.Files; -import java.nio.file.Path; import org.assertj.core.api.Assertions; import org.gradle.testkit.runner.GradleRunner; @@ -66,46 +65,41 @@ public void helpConfiguresIfTasksAreCreated() throws IOException { @Test public void configurationCacheNotInvalidatedByGitconfig() throws IOException { - // ~/.gitconfig is read by JGit at class-load time via the SystemReader. - // If GitRatchetGradle is loaded eagerly during configuration, Gradle's - // configuration cache fingerprints ~/.gitconfig. When its content changes - // (e.g. CI workers inject per-build auth tokens), the cache is invalidated. - // This test verifies that changing ~/.gitconfig between runs does not - // invalidate the configuration cache. - Path gitconfig = Path.of(System.getProperty("user.home"), ".gitconfig"); - boolean existed = Files.exists(gitconfig); - String originalContent = existed ? Files.readString(gitconfig) : null; - try { - Files.writeString(gitconfig, "[user]\n\tname = test\n"); + // The default GIT_ATTRIBUTES_FAST_ALLSAME line ending policy reads + // ~/.gitconfig via JGit to resolve core.eol / core.autocrlf. This test + // verifies that changing ~/.gitconfig between runs does not invalidate + // the configuration cache, because the git config reads happen inside a + // ValueSource whose file accesses are not tracked as config cache inputs. + File gitconfig = new File(System.getProperty("user.home"), ".gitconfig"); + byte[] originalContent = gitconfig.exists() ? Files.readAllBytes(gitconfig.toPath()) : null; - setFile("build.gradle").toLines( - "plugins {", - " id 'com.diffplug.spotless'", - "}", - "repositories { mavenCentral() }", - "apply plugin: 'java'", - "spotless {", - " java {", - " googleJavaFormat()", - " }", - "}"); - setFile("src/main/java/test.java").toResource("java/googlejavaformat/JavaCodeUnformatted.test"); + setFile("build.gradle").toLines( + "plugins {", + " id 'com.diffplug.spotless'", + "}", + "repositories { mavenCentral() }", + "apply plugin: 'java'", + "spotless {", + " java {", + " googleJavaFormat()", + " }", + "}"); + setFile("src/main/java/test.java").toResource("java/googlejavaformat/JavaCodeFormatted.test"); - // first run stores the configuration cache - gradleRunner().withArguments("help").build(); + try { + Files.writeString(gitconfig.toPath(), "[user]\n\tname = test\n"); + gradleRunner().withArguments("spotlessCheck").build(); - // change ~/.gitconfig content between runs - Files.writeString(gitconfig, "[user]\n\tname = test\n[http]\n\textraheader = changed\n"); + // change .gitconfig content between runs (simulates CI auth token injection) + Files.writeString(gitconfig.toPath(), "[user]\n\tname = test\n[http]\n\textraheader = changed\n"); - // second run must reuse the configuration cache despite the change - String output = gradleRunner().withArguments("help").build().getOutput(); - Assertions.assertThat(output).contains("Reusing configuration cache."); + String output = gradleRunner().withArguments("spotlessCheck").build().getOutput(); + Assertions.assertThat(output).contains("Reusing configuration cache"); } finally { - // restore original ~/.gitconfig if (originalContent != null) { - Files.writeString(gitconfig, originalContent); - } else if (Files.exists(gitconfig)) { - Files.delete(gitconfig); + Files.write(gitconfig.toPath(), originalContent); + } else { + Files.deleteIfExists(gitconfig.toPath()); } } }