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();
+ }
+ }
+}