diff --git a/CHANGES.md b/CHANGES.md index eb93716703..3a84f1d7d4 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,8 @@ This document is intended for Spotless developers. We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (starting after version `1.27.0`). ## [Unreleased] +### Added +- Add `GitIgnoredFiles` to `lib-extra`, which answers whether git ignores a file using the same rules as `git status`. ([#2443](https://github.com/diffplug/spotless/issues/2443)) ### Fixed - GrEclipse no longer leaves a `file%3A...` directory in the working directory. ([#2019](https://github.com/diffplug/spotless/issues/2019)) diff --git a/lib-extra/src/main/java/com/diffplug/spotless/extra/GitIgnoredFiles.java b/lib-extra/src/main/java/com/diffplug/spotless/extra/GitIgnoredFiles.java new file mode 100644 index 0000000000..0c910ef294 --- /dev/null +++ b/lib-extra/src/main/java/com/diffplug/spotless/extra/GitIgnoredFiles.java @@ -0,0 +1,166 @@ +/* + * Copyright 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.spotless.extra; + +import java.io.File; +import java.io.IOException; +import java.io.Serializable; +import java.nio.file.Path; +import java.util.Objects; +import java.util.Set; + +import javax.annotation.Nullable; + +import org.eclipse.jgit.lib.IndexDiff; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.treewalk.FileTreeIterator; +import org.eclipse.jgit.treewalk.filter.PathFilter; + +import com.diffplug.spotless.FileSignature; + +/** + * Answers whether git considers a file to be ignored, using the same rules as {@code git status}: + * a file is ignored if it matches a pattern from any {@code .gitignore}, from {@code .git/info/exclude} + * or from the {@code core.excludesFile} configured in git, and it is not tracked in the index. + * Tracked files are never ignored, even when they match an ignore pattern. + *

+ * The ignored paths below the root directory are computed once, lazily, on the first call to + * {@link #isIgnored(File)} and are cached for the lifetime of this instance. Ignored directories are not + * descended into, so large ignored folders such as {@code node_modules} or {@code build} are cheap. + *

+ * Instances are thread-safe. They are {@link Serializable} so that build tools can cache their configuration, + * the computed state is not serialized and is recomputed after deserialization. + */ +public final class GitIgnoredFiles implements Serializable { + private static final long serialVersionUID = 1L; + + private final File rootDir; + private transient volatile @Nullable State state; + + private GitIgnoredFiles(File rootDir) { + this.rootDir = Objects.requireNonNull(rootDir).getAbsoluteFile(); + } + + /** + * Creates an instance which answers questions about files below {@code rootDir}, which must be inside + * a git working tree. The repository is not read until {@link #isIgnored(File)} is first called. + */ + public static GitIgnoredFiles under(File rootDir) { + return new GitIgnoredFiles(rootDir); + } + + /** The directory whose files this instance answers questions about. */ + public File getRootDir() { + return rootDir; + } + + /** + * Returns true if git ignores the given file. Files outside of the git working tree, or outside of + * {@link #getRootDir()}, are never considered ignored. + * + * @throws IllegalArgumentException if {@link #getRootDir()} is not inside a git working tree + */ + public boolean isIgnored(File file) { + return state().isIgnored(file); + } + + /** Negation of {@link #isIgnored(File)}, handy as a method reference for filtering. */ + public boolean isNotIgnored(File file) { + return !isIgnored(file); + } + + private State state() { + State result = state; + if (result == null) { + synchronized (this) { + result = state; + if (result == null) { + try { + result = State.compute(rootDir); + } catch (IOException e) { + throw new IllegalStateException("Unable to determine git-ignored files below " + rootDir, e); + } + state = result; + } + } + } + return result; + } + + private static final class State { + private final Path workTree; + /** Unix-style paths relative to the work tree, which are either ignored files or fully-ignored directories. */ + private final Set ignoredPaths; + + private State(Path workTree, Set ignoredPaths) { + this.workTree = workTree; + this.ignoredPaths = ignoredPaths; + } + + static State compute(File rootDir) throws IOException { + GitWorkarounds.RepositorySpecificResolver resolver = GitWorkarounds.fileRepositoryResolverForProject(rootDir); + if (resolver.getGitDir() == null) { + throw new IllegalArgumentException("Respecting .gitignore requires a git repository, but none was found in " + + rootDir + " or any of its parent directories"); + } + try (Repository repo = resolver.build()) { + if (repo.isBare()) { + throw new IllegalArgumentException("Respecting .gitignore requires a git working tree, but " + repo.getDirectory() + " is bare"); + } + Path workTree = repo.getWorkTree().toPath().toAbsolutePath().normalize(); + // ObjectId == null: compare index to working tree only, we don't need HEAD + IndexDiff indexDiff = new IndexDiff(repo, (ObjectId) null, new FileTreeIterator(repo)); + String subPath = toUnixRelative(workTree, rootDir.toPath()); + if (subPath != null && !subPath.isEmpty()) { + indexDiff.setFilter(PathFilter.create(subPath)); + } + indexDiff.diff(); + return new State(workTree, Set.copyOf(indexDiff.getIgnoredNotInIndex())); + } + } + + boolean isIgnored(File file) { + if (ignoredPaths.isEmpty()) { + return false; + } + String path = toUnixRelative(workTree, file.toPath()); + if (path == null || path.isEmpty()) { + return false; + } + // the file itself, or any of its parent directories, may be the ignored entry + while (true) { + if (ignoredPaths.contains(path)) { + return true; + } + int lastSlash = path.lastIndexOf('/'); + if (lastSlash == -1) { + return false; + } + path = path.substring(0, lastSlash); + } + } + + /** Returns the unix-style relative path of {@code file} within {@code workTree}, or null if it is outside. */ + private static @Nullable String toUnixRelative(Path workTree, Path file) { + Path absolute = file.toAbsolutePath().normalize(); + if (!absolute.startsWith(workTree)) { + return null; + } + return FileSignature.pathNativeToUnix(workTree.relativize(absolute).toString()); + } + } +} diff --git a/lib-extra/src/test/java/com/diffplug/spotless/extra/GitIgnoredFilesTest.java b/lib-extra/src/test/java/com/diffplug/spotless/extra/GitIgnoredFilesTest.java new file mode 100644 index 0000000000..0c049f74e5 --- /dev/null +++ b/lib-extra/src/test/java/com/diffplug/spotless/extra/GitIgnoredFilesTest.java @@ -0,0 +1,233 @@ +/* + * Copyright 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.spotless.extra; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.lib.StoredConfig; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.diffplug.spotless.ClearGitConfig; +import com.diffplug.spotless.ResourceHarness; + +@ClearGitConfig +class GitIgnoredFilesTest extends ResourceHarness { + + private Git initRepo() throws GitAPIException { + return Git.init().setDirectory(rootFolder()).call(); + } + + private static void addAndCommit(Git git) throws GitAPIException { + git.add().addFilepattern(".").call(); + git.commit().setSign(false).setMessage("commit").call(); + } + + private GitIgnoredFiles underRoot() { + return GitIgnoredFiles.under(rootFolder()); + } + + @Nested + @DisplayName("given a root .gitignore") + class RootGitignore { + @Test + @DisplayName("when an untracked file matches a pattern, then it is ignored") + void untrackedMatchingFileIsIgnored() throws Exception { + // given + try (Git git = initRepo()) { + setFile(".gitignore").toContent("*.log\n"); + addAndCommit(git); + File log = setFile("debug.log").toContent("x"); + // when / then + assertThat(underRoot().isIgnored(log)).isTrue(); + assertThat(underRoot().isNotIgnored(log)).isFalse(); + } + } + + @Test + @DisplayName("when an untracked file does not match any pattern, then it is not ignored") + void untrackedNonMatchingFileIsNotIgnored() throws Exception { + try (Git git = initRepo()) { + setFile(".gitignore").toContent("*.log\n"); + addAndCommit(git); + File java = setFile("src/Main.java").toContent("class Main {}"); + assertThat(underRoot().isIgnored(java)).isFalse(); + } + } + + @Test + @DisplayName("when a file is deep inside an ignored directory, then it is ignored") + void fileInsideIgnoredDirectoryIsIgnored() throws Exception { + try (Git git = initRepo()) { + setFile(".gitignore").toContent("node_modules/\n"); + addAndCommit(git); + File dep = setFile("web/node_modules/lib/deep/index.js").toContent("x"); + assertThat(underRoot().isIgnored(dep)).isTrue(); + } + } + + @Test + @DisplayName("when a negation pattern re-includes a file, then it is not ignored") + void negatedPatternIsNotIgnored() throws Exception { + try (Git git = initRepo()) { + setFile(".gitignore").toContent("*.log\n!keep.log\n"); + addAndCommit(git); + File keep = setFile("keep.log").toContent("x"); + File drop = setFile("drop.log").toContent("x"); + GitIgnoredFiles ignored = underRoot(); + assertThat(ignored.isIgnored(keep)).isFalse(); + assertThat(ignored.isIgnored(drop)).isTrue(); + } + } + + @Test + @DisplayName("when a file matching a pattern is tracked, then it is NOT ignored (same as git status)") + void trackedMatchingFileIsNotIgnored() throws Exception { + try (Git git = initRepo()) { + setFile("generated/Tracked.java").toContent("class Tracked {}"); + addAndCommit(git); + setFile(".gitignore").toContent("generated/\n"); + addAndCommit(git); + File untrackedSibling = setFile("generated/Untracked.java").toContent("class Untracked {}"); + + GitIgnoredFiles ignored = underRoot(); + assertThat(ignored.isIgnored(newFile("generated/Tracked.java"))).isFalse(); + assertThat(ignored.isIgnored(untrackedSibling)).isTrue(); + } + } + } + + @Nested + @DisplayName("given ignore rules from other sources") + class OtherSources { + @Test + @DisplayName("when a nested .gitignore has a pattern, then it applies only below its directory") + void nestedGitignoreIsScoped() throws Exception { + try (Git git = initRepo()) { + setFile("sub/.gitignore").toContent("*.tmp\n"); + addAndCommit(git); + File inSub = setFile("sub/a.tmp").toContent("x"); + File atRoot = setFile("b.tmp").toContent("x"); + GitIgnoredFiles ignored = underRoot(); + assertThat(ignored.isIgnored(inSub)).isTrue(); + assertThat(ignored.isIgnored(atRoot)).isFalse(); + } + } + + @Test + @DisplayName("when .git/info/exclude has a pattern, then matching files are ignored") + void infoExcludeIsHonored() throws Exception { + try (Git git = initRepo()) { + setFile(".git/info/exclude").toContent("secret.txt\n"); + File secret = setFile("secret.txt").toContent("x"); + assertThat(underRoot().isIgnored(secret)).isTrue(); + } + } + + @Test + @DisplayName("when core.excludesFile points to a file with a pattern, then matching files are ignored") + void coreExcludesFileIsHonored() throws Exception { + try (Git git = initRepo()) { + File excludes = setFile("../global-gitignore").toContent("*.bak\n"); + StoredConfig config = git.getRepository().getConfig(); + config.setString("core", null, "excludesFile", excludes.getAbsolutePath()); + config.save(); + File bak = setFile("old.bak").toContent("x"); + assertThat(underRoot().isIgnored(bak)).isTrue(); + } + } + } + + @Nested + @DisplayName("given the root dir is a subproject of the repository") + class Subproject { + @Test + @DisplayName("when the repository root .gitignore matches a subproject file, then it is ignored") + void parentRulesApplyToSubproject() throws Exception { + try (Git git = initRepo()) { + setFile(".gitignore").toContent("build/\n"); + addAndCommit(git); + File generated = setFile("moduleA/build/Gen.java").toContent("class Gen {}"); + File source = setFile("moduleA/src/Main.java").toContent("class Main {}"); + GitIgnoredFiles ignored = GitIgnoredFiles.under(newFile("moduleA")); + assertThat(ignored.isIgnored(generated)).isTrue(); + assertThat(ignored.isIgnored(source)).isFalse(); + } + } + + @Test + @DisplayName("when a file lies outside the root dir, then it is not ignored") + void fileOutsideRootIsNotIgnored() throws Exception { + try (Git git = initRepo()) { + setFile(".gitignore").toContent("*.log\n"); + addAndCommit(git); + setFile("moduleA/keep.txt").toContent("x"); + File elsewhere = setFile("moduleB/x.log").toContent("x"); + assertThat(GitIgnoredFiles.under(newFile("moduleA")).isIgnored(elsewhere)).isFalse(); + } + } + } + + @Nested + @DisplayName("given edge cases") + class EdgeCases { + @Test + @DisplayName("when the root dir is not inside a git repository, then a clear error is thrown") + void notAGitRepositoryFails() throws IOException { + File file = setFile("a.txt").toContent("x"); + assertThatThrownBy(() -> underRoot().isIgnored(file)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("requires a git repository"); + } + + @Test + @DisplayName("when the instance is serialized and deserialized, then it still answers correctly") + void survivesSerialization() throws Exception { + try (Git git = initRepo()) { + setFile(".gitignore").toContent("*.log\n"); + addAndCommit(git); + File log = setFile("debug.log").toContent("x"); + GitIgnoredFiles original = underRoot(); + assertThat(original.isIgnored(log)).isTrue(); // computes and caches state + + GitIgnoredFiles copy = roundTrip(original); + assertThat(copy.getRootDir()).isEqualTo(original.getRootDir()); + assertThat(copy.isIgnored(log)).isTrue(); + } + } + } + + private static GitIgnoredFiles roundTrip(GitIgnoredFiles original) throws IOException, ClassNotFoundException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ObjectOutputStream out = new ObjectOutputStream(bytes)) { + out.writeObject(original); + } + try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) { + return (GitIgnoredFiles) in.readObject(); + } + } +} diff --git a/plugin-gradle/CHANGES.md b/plugin-gradle/CHANGES.md index 5aaef36403..67551899a8 100644 --- a/plugin-gradle/CHANGES.md +++ b/plugin-gradle/CHANGES.md @@ -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 +- Add `respectGitignore()` to skip files which git ignores, globally or per-format. ([#2443](https://github.com/diffplug/spotless/issues/2443)) ### Fixed - GrEclipse no longer leaves a `file%3A...` directory in the working directory. ([#2019](https://github.com/diffplug/spotless/issues/2019)) diff --git a/plugin-gradle/README.md b/plugin-gradle/README.md index 019abb7008..b1721948ef 100644 --- a/plugin-gradle/README.md +++ b/plugin-gradle/README.md @@ -108,6 +108,7 @@ Spotless supports all of Gradle's built-in performance features (incremental bui - [Generic steps](#generic-steps) - [License header](#license-header) ([slurp year from git](#retroactively-slurp-years-from-git-history)) - [How can I enforce formatting gradually? (aka "ratchet")](#ratchet) + - [How can I skip files ignored by git?](#respect-gitignore) - [`spotless:off` and `spotless:on`](#spotlessoff-and-spotlesson) - [Line endings and encodings (invisible stuff)](#line-endings-and-encodings-invisible-stuff) - [Custom steps](#custom-steps) @@ -1967,6 +1968,24 @@ Many popular CI systems (GitHub, GitLab, BitBucket, and Travis) use a "shallow c - calling `git fetch origin main` before you call Spotless - disabling the shallow clone [like so](https://github.com/diffplug/spotless/issues/710) + + +## How can I skip files ignored by git? + +If your targets are broad (e.g. `target '**/*.md'`), they can pick up files which git ignores, such as generated sources or `node_modules`. Rather than repeating your `.gitignore` in `targetExclude`, you can tell Spotless to skip them: + +```gradle +spotless { + respectGitignore() // skip files which git ignores, in every format + format 'misc', { + target '**/*.md' + respectGitignore(false) // or override per-format + } +} +``` + +A file is skipped exactly when `git status` would consider it ignored: it matches a pattern from any `.gitignore`, from `.git/info/exclude` or from `core.excludesFile`, **and** it is not tracked. Tracked files are always formatted, even when they match an ignore pattern. Changes to `.gitignore` are picked up by the next build, also with the configuration cache. The project must be inside a git repository, otherwise the build fails. + ## `spotless:off` and `spotless:on` Sometimes there is a chunk of code which you have carefully handcrafted, and you would like to exclude just this one little part from getting clobbered by the autoformat. Some formatters have a way to do this, many don't, but who cares. If you setup your spotless like this: 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 e1cc198067..ba9e3efdf0 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 @@ -50,6 +50,7 @@ import org.gradle.api.file.FileSystemLocation; import org.gradle.api.plugins.BasePlugin; import org.gradle.api.provider.Provider; +import org.gradle.api.specs.Spec; import org.gradle.api.tasks.TaskProvider; import org.gradle.util.GradleVersion; import org.slf4j.Logger; @@ -66,6 +67,7 @@ import com.diffplug.spotless.SerializedFunction; import com.diffplug.spotless.cpp.ClangFormatStep; import com.diffplug.spotless.extra.EclipseBasedStepBuilder; +import com.diffplug.spotless.extra.GitIgnoredFiles; import com.diffplug.spotless.extra.P2Provisioner; import com.diffplug.spotless.extra.wtp.EclipseWtpFormatterStep; import com.diffplug.spotless.generic.EndWithNewlineStep; @@ -175,6 +177,31 @@ public void ratchetFrom(String ratchetFrom) { setRatchetFrom(ratchetFrom); } + private @Nullable Boolean respectGitignore; + + /** @see #setRespectGitignore(boolean) */ + public boolean isRespectGitignore() { + return respectGitignore == null ? spotless.isRespectGitignore() : respectGitignore; + } + + /** + * Allows you to override the value from the parent + * {@link SpotlessExtension#setRespectGitignore(boolean)} for this specific format. + */ + public void setRespectGitignore(boolean respectGitignore) { + this.respectGitignore = respectGitignore; + } + + /** @see #setRespectGitignore(boolean) */ + public void respectGitignore(boolean respectGitignore) { + setRespectGitignore(respectGitignore); + } + + /** Shorthand for {@code respectGitignore(true)}. @see #setRespectGitignore(boolean) */ + public void respectGitignore() { + setRespectGitignore(true); + } + /** * Sets the encoding to use (defaults to * {@link SpotlessExtensionImpl#getEncoding()}. @@ -1093,7 +1120,10 @@ public void toggleOffOnDisable() { protected void setupTask(SpotlessTask task) { task.setEncoding(getEncoding().name()); task.setLintSuppressions(lintSuppressions); - FileCollection totalTarget = targetExclude == null ? target : target.minus(targetExclude); + FileCollection targetMinusExclude = targetExclude == null ? target : target.minus(targetExclude); + FileCollection totalTarget = isRespectGitignore() + ? targetMinusExclude.filter(new NotGitIgnored(GitIgnoredFiles.under(getProject().getProjectDir()))) + : targetMinusExclude; task.setTarget(totalTarget); List steps; if (toggleFence != null) { @@ -1116,6 +1146,25 @@ protected void setupTask(SpotlessTask task) { task.setupRatchet(getRatchetFrom() != null ? getRatchetFrom() : ""); } + /** + * Configuration-cache friendly filter, the git state inside {@link GitIgnoredFiles} is transient and + * therefore recomputed in every build, so changes to {@code .gitignore} are always picked up. + */ + private static final class NotGitIgnored implements Spec, Serializable { + private static final long serialVersionUID = 1L; + + private final GitIgnoredFiles ignored; + + NotGitIgnored(GitIgnoredFiles ignored) { + this.ignored = ignored; + } + + @Override + public boolean isSatisfiedBy(File file) { + return ignored.isNotIgnored(file); + } + } + /** Returns the project that this extension is attached to. */ protected Project getProject() { return spotless.project; diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtension.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtension.java index 83c317f7e7..749a8de037 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtension.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtension.java @@ -120,6 +120,32 @@ public void ratchetFrom(String ratchetFrom) { setRatchetFrom(ratchetFrom); } + private boolean respectGitignore; + + /** + * If {@code true}, files which git ignores (via {@code .gitignore}, {@code .git/info/exclude} or + * {@code core.excludesFile}) and which are not tracked are removed from every format's target. + * Defaults to {@code false}. Can be overridden per format with {@link FormatExtension#setRespectGitignore(boolean)}. + */ + public void setRespectGitignore(boolean respectGitignore) { + this.respectGitignore = respectGitignore; + } + + /** @see #setRespectGitignore(boolean) */ + public boolean isRespectGitignore() { + return respectGitignore; + } + + /** @see #setRespectGitignore(boolean) */ + public void respectGitignore(boolean respectGitignore) { + setRespectGitignore(respectGitignore); + } + + /** Shorthand for {@code respectGitignore(true)}. @see #setRespectGitignore(boolean) */ + public void respectGitignore() { + setRespectGitignore(true); + } + final Map formats = new LinkedHashMap<>(); /** Configures the special java-specific extension. */ diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/RespectGitignoreTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/RespectGitignoreTest.java new file mode 100644 index 0000000000..c7123a2911 --- /dev/null +++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/RespectGitignoreTest.java @@ -0,0 +1,141 @@ +/* + * Copyright 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 static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.gradle.testkit.runner.BuildResult; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import com.diffplug.spotless.ClearGitConfig; + +/** Integration tests for {@code respectGitignore()}, see https://github.com/diffplug/spotless/issues/2443 */ +@ClearGitConfig +class RespectGitignoreTest extends GradleIntegrationHarness { + private static final String IGNORED = "src/generated/Ignored.md"; + private static final String TRACKED = "src/main/Tracked.md"; + + private void givenBuild(boolean configCache, String global, String format) throws IOException { + if (configCache) { + setFile("gradle.properties").toContent("org.gradle.configuration-cache=true"); + } + setFile("build.gradle").toLines( + "plugins {", + " id 'com.diffplug.spotless'", + "}", + "spotless {", + " " + global, + " format 'misc', {", + " target 'src/**/*.md'", + " " + format, + " replace 'lowercase hello', 'HELLO', 'hello'", + " }", + "}"); + } + + /** Repo with a committed .gitignore for src/generated/, a clean tracked file, and a dirty ignored file. */ + private Git givenRepoWithDirtyIgnoredFile() throws GitAPIException, IOException { + Git git = Git.init().setDirectory(rootFolder()).call(); + setFile(".gitignore").toContent("src/generated/\n.gradle/\nbuild/\n"); + setFile(TRACKED).toContent("hello"); + git.add().addFilepattern(".gitignore").addFilepattern(TRACKED).call(); + git.commit().setSign(false).setMessage("init").call(); + setFile(IGNORED).toContent("HELLO"); + return git; + } + + @ParameterizedTest(name = "configCache={0}") + @ValueSource(booleans = {false, true}) + @DisplayName("given respectGitignore is not configured, when checking, then ignored files are still formatted (unchanged default)") + void defaultStillFormatsIgnoredFiles(boolean configCache) throws Exception { + try (Git git = givenRepoWithDirtyIgnoredFile()) { + givenBuild(configCache, "", ""); + gradleRunner().withArguments("spotlessCheck").buildAndFail(); + } + } + + @ParameterizedTest(name = "configCache={0}") + @ValueSource(booleans = {false, true}) + @DisplayName("given global respectGitignore(), when checking and applying, then ignored files are skipped but others are fixed") + void globalFlagSkipsIgnoredFiles(boolean configCache) throws Exception { + try (Git git = givenRepoWithDirtyIgnoredFile()) { + givenBuild(configCache, "respectGitignore()", ""); + gradleRunner().withArguments("spotlessCheck").build(); + + setFile(TRACKED).toContent("HELLO"); + gradleRunner().withArguments("spotlessCheck").buildAndFail(); + gradleRunner().withArguments("spotlessApply").build(); + assertFile(TRACKED).hasContent("hello"); + assertFile(IGNORED).hasContent("HELLO"); + } + } + + @ParameterizedTest(name = "configCache={0}") + @ValueSource(booleans = {false, true}) + @DisplayName("given respectGitignore() only on the format, when checking, then ignored files are skipped") + void formatFlagSkipsIgnoredFiles(boolean configCache) throws Exception { + try (Git git = givenRepoWithDirtyIgnoredFile()) { + givenBuild(configCache, "", "respectGitignore()"); + gradleRunner().withArguments("spotlessCheck").build(); + } + } + + @ParameterizedTest(name = "configCache={0}") + @ValueSource(booleans = {false, true}) + @DisplayName("given global true but format respectGitignore(false), when checking, then the format-level value wins") + void formatFlagOverridesGlobal(boolean configCache) throws Exception { + try (Git git = givenRepoWithDirtyIgnoredFile()) { + givenBuild(configCache, "respectGitignore()", "respectGitignore(false)"); + BuildResult result = gradleRunner().withArguments("spotlessCheck").buildAndFail(); + // the violation message lists paths with native separators + assertThat(result.getOutput()).contains(IGNORED.replace('/', File.separatorChar)); + } + } + + @ParameterizedTest(name = "configCache={0}") + @ValueSource(booleans = {false, true}) + @DisplayName("given respectGitignore(), when .gitignore changes between builds, then the next build honors the new rules") + void gitignoreChangeIsPickedUp(boolean configCache) throws Exception { + try (Git git = givenRepoWithDirtyIgnoredFile()) { + givenBuild(configCache, "respectGitignore()", ""); + setFile("src/other/Dirty.md").toContent("HELLO"); + gradleRunner().withArguments("spotlessCheck").buildAndFail(); + + setFile(".gitignore").toContent("src/generated/\nsrc/other/\n.gradle/\nbuild/\n"); + gradleRunner().withArguments("spotlessCheck").build(); + + setFile(".gitignore").toContent(".gradle/\nbuild/\n"); + gradleRunner().withArguments("spotlessCheck").buildAndFail(); + } + } + + @Test + @DisplayName("given respectGitignore() outside of a git repository, when checking, then the build fails with a clear message") + void notAGitRepositoryFails() throws Exception { + givenBuild(false, "respectGitignore()", ""); + setFile(TRACKED).toContent("hello"); + BuildResult result = gradleRunner().withArguments("spotlessCheck").buildAndFail(); + assertThat(result.getOutput()).contains("requires a git repository"); + } +} diff --git a/plugin-maven/CHANGES.md b/plugin-maven/CHANGES.md index 4081c6671e..fab630603e 100644 --- a/plugin-maven/CHANGES.md +++ b/plugin-maven/CHANGES.md @@ -3,6 +3,8 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (starting after version `1.27.0`). ## [Unreleased] +### Added +- Add `true` to skip files which git ignores, globally or per-format. ([#2443](https://github.com/diffplug/spotless/issues/2443)) ### Fixed - GrEclipse no longer leaves a `file%3A...` directory in the working directory. ([#2019](https://github.com/diffplug/spotless/issues/2019)) diff --git a/plugin-maven/README.md b/plugin-maven/README.md index 3fae9b808d..f5fa36d0ec 100644 --- a/plugin-maven/README.md +++ b/plugin-maven/README.md @@ -68,6 +68,7 @@ user@machine repo % mvn spotless:check - [Generic steps](#generic-steps) - [License header](#license-header) ([slurp year from git](#retroactively-slurp-years-from-git-history)) - [How can I enforce formatting gradually? (aka "ratchet")](#ratchet) + - [How can I skip files ignored by git?](#respect-gitignore) - [`spotless:off` and `spotless:on`](#spotlessoff-and-spotlesson) - [Line endings and encodings (invisible stuff)](#line-endings-and-encodings-invisible-stuff) - [Disabling warnings and error messages](#disabling-warnings-and-error-messages) @@ -2128,6 +2129,23 @@ Many popular CI systems (GitHub, GitLab, BitBucket, and Travis) use a "shallow c - calling `git fetch origin main` before you call Spotless - disabling the shallow clone [like so](https://github.com/diffplug/spotless/issues/710) + + +## How can I skip files ignored by git? + +If your includes are broad (e.g. `**/*.md`), they can pick up files which git ignores, such as generated sources or `node_modules`. Rather than repeating your `.gitignore` in ``, you can tell Spotless to skip them: + +```xml + + true + + + false + ... +``` + +A file is skipped exactly when `git status` would consider it ignored: it matches a pattern from any `.gitignore`, from `.git/info/exclude` or from `core.excludesFile`, **and** it is not tracked. Tracked files are always formatted, even when they match an ignore pattern. It can be combined with [`ratchetFrom`](#ratchet). The project must be inside a git repository, otherwise the build fails. + ## `spotless:off` and `spotless:on` Sometimes there is a chunk of code which you have carefully handcrafted, and you would like to exclude just this one little part from getting clobbered by the autoformat. Some formatters have a way to do this, many don't, but who cares. If you setup your spotless like this: diff --git a/plugin-maven/src/main/java/com/diffplug/spotless/maven/AbstractSpotlessMojo.java b/plugin-maven/src/main/java/com/diffplug/spotless/maven/AbstractSpotlessMojo.java index 58c876300d..8c93f4cda3 100644 --- a/plugin-maven/src/main/java/com/diffplug/spotless/maven/AbstractSpotlessMojo.java +++ b/plugin-maven/src/main/java/com/diffplug/spotless/maven/AbstractSpotlessMojo.java @@ -62,6 +62,7 @@ import com.diffplug.spotless.LintState; import com.diffplug.spotless.LintSuppression; import com.diffplug.spotless.Provisioner; +import com.diffplug.spotless.extra.GitIgnoredFiles; import com.diffplug.spotless.extra.P2Provisioner; import com.diffplug.spotless.maven.antlr4.Antlr4; import com.diffplug.spotless.maven.asciidoc.Asciidoc; @@ -145,6 +146,15 @@ public abstract class AbstractSpotlessMojo extends AbstractMojo { @Parameter private String ratchetFrom; + /** + * If {@code true}, files which git ignores (via {@code .gitignore}, {@code .git/info/exclude} or + * {@code core.excludesFile}) and which are not tracked are skipped. Can be overridden per format. + */ + @Parameter(defaultValue = "false") + private boolean respectGitignore; + + private GitIgnoredFiles gitIgnoredFiles; + @Parameter private LicenseHeader licenseHeader; @@ -314,12 +324,15 @@ protected boolean isGoalSpecificSkip() { private List collectFiles(FormatterFactory formatterFactory, FormatterConfig config) { Optional ratchetFrom = formatterFactory.ratchetFrom(config); try { - final List files; + List files; if (ratchetFrom.isPresent()) { files = collectFilesFromGit(formatterFactory, ratchetFrom.orElseThrow()); } else { files = collectFilesFromFormatterFactory(formatterFactory); } + if (formatterFactory.respectGitignore(respectGitignore)) { + files = withoutGitIgnored(files); + } if (isNullOrEmpty(filePatterns)) { return files; } @@ -340,6 +353,22 @@ private List collectFiles(FormatterFactory formatterFactory, FormatterConf } } + private List withoutGitIgnored(List files) { + GitIgnoredFiles ignored = gitIgnoredFiles(); + try { + return files.stream().filter(ignored::isNotIgnored).collect(toList()); + } catch (IllegalArgumentException e) { + throw new PluginException("Unable to apply : " + e.getMessage(), e); + } + } + + private synchronized GitIgnoredFiles gitIgnoredFiles() { + if (gitIgnoredFiles == null) { + gitIgnoredFiles = GitIgnoredFiles.under(baseDir); + } + return gitIgnoredFiles; + } + private List collectFilesFromGit(FormatterFactory formatterFactory, String ratchetFrom) { MatchPatterns includePatterns = MatchPatterns.from( withNormalizedFileSeparators(getIncludes(formatterFactory))); diff --git a/plugin-maven/src/main/java/com/diffplug/spotless/maven/FormatterFactory.java b/plugin-maven/src/main/java/com/diffplug/spotless/maven/FormatterFactory.java index 83579b9744..aa3ccd6887 100644 --- a/plugin-maven/src/main/java/com/diffplug/spotless/maven/FormatterFactory.java +++ b/plugin-maven/src/main/java/com/diffplug/spotless/maven/FormatterFactory.java @@ -63,6 +63,10 @@ public abstract class FormatterFactory { @Parameter private String ratchetFrom = RATCHETFROM_NOT_SET_AT_FORMAT_LEVEL; + /** Overrides the global {@code respectGitignore} for this format when set. */ + @Parameter + private Boolean respectGitignore; + @Parameter private String[] includes; @@ -181,6 +185,10 @@ Optional ratchetFrom(FormatterConfig config) { .filter(ratchet -> !RATCHETFROM_NONE.equals(ratchet)); } + boolean respectGitignore(boolean globalDefault) { + return respectGitignore == null ? globalDefault : respectGitignore; + } + private Optional optionalRatchetFrom() { return Optional.ofNullable(ratchetFrom) .filter(ratchet -> !RATCHETFROM_NOT_SET_AT_FORMAT_LEVEL.equals(ratchet)); diff --git a/plugin-maven/src/test/java/com/diffplug/spotless/maven/RespectGitignoreTest.java b/plugin-maven/src/test/java/com/diffplug/spotless/maven/RespectGitignoreTest.java new file mode 100644 index 0000000000..8322edb4ac --- /dev/null +++ b/plugin-maven/src/test/java/com/diffplug/spotless/maven/RespectGitignoreTest.java @@ -0,0 +1,124 @@ +/* + * Copyright 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.spotless.maven; + +import java.io.IOException; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.diffplug.common.base.StringPrinter; +import com.diffplug.spotless.ClearGitConfig; + +/** Integration tests for {@code }, see https://github.com/diffplug/spotless/issues/2443 */ +@ClearGitConfig +class RespectGitignoreTest extends MavenIntegrationHarness { + private static final String IGNORED = "src/generated/Ignored.md"; + private static final String TRACKED = "src/main/Tracked.md"; + + private static String pom(String globalFlag, String formatFlag, String ratchet) { + return StringPrinter.buildStringFromLines( + globalFlag, + ratchet, + "", + " ", + formatFlag, + " ", + " src/**/*.md", + " ", + " ", + " Lowercase hello", + " HELLO", + " hello", + " ", + " ", + ""); + } + + /** Repo with a committed .gitignore for src/generated/, a clean tracked file, and a dirty ignored file. */ + private Git givenRepoWithDirtyIgnoredFile() throws GitAPIException, IOException { + Git git = Git.init().setDirectory(rootFolder()).call(); + setFile(".gitignore").toContent("src/generated/\n"); + setFile(TRACKED).toContent("hello"); + git.add().addFilepattern(".gitignore").addFilepattern(TRACKED).call(); + git.commit().setSign(false).setMessage("init").call(); + git.tag().setName("baseline").call(); + setFile(IGNORED).toContent("HELLO"); + return git; + } + + @Test + @DisplayName("given respectGitignore is not configured, when checking, then ignored files are still formatted (unchanged default)") + void defaultStillFormatsIgnoredFiles() throws Exception { + try (Git git = givenRepoWithDirtyIgnoredFile()) { + writePom(pom("", "", "")); + mavenRunner().withArguments("spotless:check").runHasError(); + } + } + + @Test + @DisplayName("given global respectGitignore, when checking and applying, then ignored files are skipped") + void globalFlagSkipsIgnoredFiles() throws Exception { + try (Git git = givenRepoWithDirtyIgnoredFile()) { + writePom(pom("true", "", "")); + mavenRunner().withArguments("spotless:check").runNoError(); + mavenRunner().withArguments("spotless:apply").runNoError(); + assertFile(IGNORED).hasContent("HELLO"); + } + } + + @Test + @DisplayName("given global respectGitignore, when a non-ignored file is dirty, then it is still reported and fixed") + void globalFlagStillFormatsNonIgnoredFiles() throws Exception { + try (Git git = givenRepoWithDirtyIgnoredFile()) { + writePom(pom("true", "", "")); + setFile(TRACKED).toContent("HELLO"); + mavenRunner().withArguments("spotless:check").runHasError(); + mavenRunner().withArguments("spotless:apply").runNoError(); + assertFile(TRACKED).hasContent("hello"); + assertFile(IGNORED).hasContent("HELLO"); + } + } + + @Test + @DisplayName("given respectGitignore only on the format, when checking, then ignored files are skipped") + void formatFlagSkipsIgnoredFiles() throws Exception { + try (Git git = givenRepoWithDirtyIgnoredFile()) { + writePom(pom("", "true", "")); + mavenRunner().withArguments("spotless:check").runNoError(); + } + } + + @Test + @DisplayName("given global true but format false, when checking, then the format-level value wins") + void formatFlagOverridesGlobal() throws Exception { + try (Git git = givenRepoWithDirtyIgnoredFile()) { + writePom(pom("true", "false", "")); + mavenRunner().withArguments("spotless:check").runHasError(); + } + } + + @Test + @DisplayName("given respectGitignore with ratchetFrom, when an ignored file is new, then it is skipped") + void combinesWithRatchetFrom() throws Exception { + try (Git git = givenRepoWithDirtyIgnoredFile()) { + writePom(pom("true", "", "baseline")); + mavenRunner().withArguments("spotless:check").runNoError(); + } + } +}