diff --git a/CHANGES.md b/CHANGES.md index 733abfd3d9..e5faa59090 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -12,6 +12,7 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format ( ## [Unreleased] ### Added - Add a `groupArtifact` option to `PalantirJavaFormatStep`. ([#3179](https://github.com/diffplug/spotless/pull/3179)) +- 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)) - DBeaver SQL formatter no longer prints an `IndexOutOfBoundsException` when a file starts with a keyword like `SET`. ([#1552](https://github.com/diffplug/spotless/issues/1552)) 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..2bd5fe8d42 --- /dev/null +++ b/lib-extra/src/main/java/com/diffplug/spotless/extra/GitIgnoredFiles.java @@ -0,0 +1,192 @@ +/* + * 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.nio.file.Path; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; + +import javax.annotation.Nullable; + +import org.eclipse.jgit.dircache.DirCacheIterator; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.treewalk.FileTreeIterator; +import org.eclipse.jgit.treewalk.TreeWalk; +import org.eclipse.jgit.treewalk.WorkingTreeIterator; +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, so an instance must not + * outlive a build. The computation only lists directories and evaluates ignore rules, it never reads + * the content of a file. Ignored directories without tracked files are not descended into, so large + * ignored folders such as {@code node_modules} or {@code build} are cheap. + *

+ * Instances are thread-safe. + */ +public final class GitIgnoredFiles { + private final File rootDir; + private 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("Excluding files using .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("Excluding files using .gitignore requires a git working tree, but " + repo.getDirectory() + " is bare"); + } + Path workTree = repo.getWorkTree().toPath().toAbsolutePath().normalize(); + String subPath = toUnixRelative(workTree, rootDir.toPath()); + return new State(workTree, Set.copyOf(ignoredPaths(repo, subPath))); + } + } + + /** + * Walks the index and the working tree side by side, and collects every untracked path which is ignored. + * An ignored directory is only entered if the index has entries below it, because git never ignores a tracked file. + */ + private static Set ignoredPaths(Repository repo, @Nullable String subPath) throws IOException { + Set ignoredPaths = new HashSet<>(); + try (TreeWalk walk = new TreeWalk(repo)) { + int indexTree = walk.addTree(new DirCacheIterator(repo.readDirCache())); + FileTreeIterator workingTreeIterator = new FileTreeIterator(repo); + workingTreeIterator.setDirCacheIterator(walk, indexTree); + int workingTree = walk.addTree(workingTreeIterator); + walk.setRecursive(false); + if (subPath != null && !subPath.isEmpty()) { + walk.setFilter(PathFilter.create(subPath)); + } + while (walk.next()) { + WorkingTreeIterator onDisk = walk.getTree(workingTree, WorkingTreeIterator.class); + if (onDisk == null) { + // only in the index, e.g. deleted from the working tree + continue; + } + boolean tracked = walk.getTree(indexTree, DirCacheIterator.class) != null; + if (!tracked && onDisk.isEntryIgnored()) { + ignoredPaths.add(walk.getPathString()); + } else if (walk.isSubtree()) { + walk.enterSubtree(); + } + } + } + return ignoredPaths; + } + + 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..bc24ca0fec --- /dev/null +++ b/lib-extra/src/test/java/com/diffplug/spotless/extra/GitIgnoredFilesTest.java @@ -0,0 +1,232 @@ +/* + * 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.File; +import java.io.IOException; + +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 itself lies inside an ignored directory, then its files are ignored") + void rootDirInsideIgnoredDirectory() throws Exception { + try (Git git = initRepo()) { + setFile(".gitignore").toContent("scratch/\n"); + addAndCommit(git); + File file = setFile("scratch/moduleA/src/Main.java").toContent("class Main {}"); + assertThat(GitIgnoredFiles.under(newFile("scratch/moduleA")).isIgnored(file)).isTrue(); + } + } + + @Test + @DisplayName("when a tracked file was deleted from the working tree, then its untracked siblings are still answered correctly") + void trackedFileDeletedFromWorkingTree() throws Exception { + try (Git git = initRepo()) { + setFile(".gitignore").toContent("*.log\n"); + File deleted = setFile("dir/Deleted.java").toContent("class Deleted {}"); + addAndCommit(git); + assertThat(deleted.delete()).isTrue(); + File log = setFile("dir/debug.log").toContent("x"); + File java = setFile("dir/Main.java").toContent("class Main {}"); + + GitIgnoredFiles ignored = underRoot(); + assertThat(ignored.isIgnored(log)).isTrue(); + assertThat(ignored.isIgnored(java)).isFalse(); + assertThat(ignored.isIgnored(deleted)).isFalse(); + } + } + + @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"); + } + } +} diff --git a/plugin-gradle/CHANGES.md b/plugin-gradle/CHANGES.md index 27c78dde65..cf1ae9500f 100644 --- a/plugin-gradle/CHANGES.md +++ b/plugin-gradle/CHANGES.md @@ -6,6 +6,7 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format ( ### Added - Add `groupArtifact(String)` to `palantirJavaFormat()`. ([#3179](https://github.com/diffplug/spotless/pull/3179)) - `SpotlessApply` writes a report of the files it rewrote to `build/spotless/-changed-files.txt`, configurable via `changedFilesReport`. The report is a task output, so tasks which take it as an input automatically depend on the apply task. ([#2715](https://github.com/diffplug/spotless/issues/2715)) +- Add `targetExcludeUsingGitignore()` to exclude the files of a format which git ignores. ([#2443](https://github.com/diffplug/spotless/issues/2443)) ### Changes - `SpotlessApply` now only reports that it did work (and is otherwise `UP-TO-DATE`) when it actually rewrote a file. ([#2715](https://github.com/diffplug/spotless/issues/2715)) ### Fixed diff --git a/plugin-gradle/README.md b/plugin-gradle/README.md index e762f1d5c5..4a21c0e561 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?](#exclude-using-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) @@ -1969,6 +1970,25 @@ 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? + +The fastest way to keep Spotless away from generated sources or `node_modules` is a tight `target`, e.g. `target 'src/**/*.md'` rather than `target '**/*.md'`. If that is not possible, add a `targetExclude`: patterns ending in `/**` (e.g. `targetExclude '**/node_modules/**'`) are not even walked. Only if the ignored files cannot be described with a few globs, you can ask git instead: + +```gradle +spotless { + format 'misc', { + target '**/*.md' + targetExcludeUsingGitignore() // in addition to targetExclude + } +} +``` + +A file is excluded 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. + +This is the slowest of the three options, which is why it has to be set per format: the ignored files are still found by `target` first, and Spotless then has to list the working tree of the project once per build to evaluate git's ignore rules. + ## `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 19ea0ac5a3..cc424390b4 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 @@ -53,6 +53,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; @@ -246,6 +247,9 @@ public void encoding(String charset) { /** The rest of {@link #targetExclude}, or null if it consists only of prunable patterns. */ @Nullable private FileCollection targetExcludeRest; + /** If true, files which git ignores are excluded, see {@link #targetExcludeUsingGitignore()}. */ + protected boolean targetExcludeUsingGitignore; + /** The value from which files will be excluded if their content contain it. */ @Nullable protected String targetExcludeContentPattern; @@ -317,6 +321,22 @@ private static boolean endsWithDoubleStarSegment(String pattern) { return pattern.equals("**") || pattern.endsWith("/**") || pattern.endsWith("\\**"); } + /** + * Excludes all files which git ignores, in addition to {@link #targetExclude(Object...)}. + *

+ * A file is excluded exactly when {@code git status} would consider it ignored: it matches a pattern + * from any {@code .gitignore}, from {@code .git/info/exclude} or from {@code core.excludesFile}, + * and it is not tracked. The project must be inside a git repository. + *

+ * This is the slowest way to narrow a target, because the ignored files are still found by + * {@link #target(Object...)} first, and git's ignore rules have to be evaluated on top. Prefer a + * tight {@link #target(Object...)}, then {@link #targetExclude(Object...)}, and use this only when + * the ignored files cannot be described with a few globs. + */ + public void targetExcludeUsingGitignore() { + this.targetExcludeUsingGitignore = true; + } + /** * Excludes all files whose content contains {@code string}. *

@@ -1149,7 +1169,10 @@ public void toggleOffOnDisable() { protected void setupTask(SpotlessTask task) { task.setEncoding(getEncoding().name()); task.setLintSuppressions(lintSuppressions); - FileCollection totalTarget = targetExclude == null ? target : excludeFrom(target); + FileCollection targetMinusExclude = targetExclude == null ? target : excludeFrom(target); + FileCollection totalTarget = targetExcludeUsingGitignore + ? targetMinusExclude.filter(new NotGitIgnored(spotless.getSpotlessTaskService(), getProject().getProjectDir())) + : targetMinusExclude; task.setTarget(totalTarget); List steps; if (toggleFence != null) { @@ -1194,6 +1217,26 @@ private FileCollection excludeFrom(FileCollection fromTarget) { } } + /** + * Keeps the files which git does not ignore. The git state lives in the {@link SpotlessTaskService}, + * so it is computed at most once per project and build, no matter how many formats use it, and + * changes to {@code .gitignore} are picked up by the next build, also with the configuration cache. + */ + private static final class NotGitIgnored implements Spec { + private final Provider taskService; + private final File projectDir; + + NotGitIgnored(Provider taskService, File projectDir) { + this.taskService = taskService; + this.projectDir = projectDir; + } + + @Override + public boolean isSatisfiedBy(File file) { + return taskService.get().gitIgnoredFilesFor(projectDir).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/SpotlessTaskService.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java index c6fd993d20..2d01873b09 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 @@ -22,6 +22,7 @@ import java.util.List; import java.util.Map; import java.util.TreeMap; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; import javax.annotation.Nullable; @@ -47,6 +48,7 @@ import com.diffplug.common.base.Unhandled; import com.diffplug.spotless.Lint; import com.diffplug.spotless.Provisioner; +import com.diffplug.spotless.extra.GitIgnoredFiles; import com.diffplug.spotless.extra.P2Provisioner; /** @@ -136,6 +138,15 @@ public void close() throws Exception { } // + // + private final Map gitIgnoredFiles = new ConcurrentHashMap<>(); + + /** The git-ignored files below the given project, shared by every format of that project for the duration of the build. */ + GitIgnoredFiles gitIgnoredFilesFor(File projectDir) { + return gitIgnoredFiles.computeIfAbsent(projectDir, GitIgnoredFiles::under); + } + // + static String INDEPENDENT_HELPER = "Helper"; static void usesServiceTolerateTestFailure(DefaultTask task, Provider serviceProvider) { diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/TargetExcludeUsingGitignoreTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/TargetExcludeUsingGitignoreTest.java new file mode 100644 index 0000000000..4675601227 --- /dev/null +++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/TargetExcludeUsingGitignoreTest.java @@ -0,0 +1,171 @@ +/* + * 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 targetExcludeUsingGitignore()}, see https://github.com/diffplug/spotless/issues/2443 */ +@ClearGitConfig +class TargetExcludeUsingGitignoreTest extends GradleIntegrationHarness { + private static final String IGNORED = "src/generated/Ignored.md"; + private static final String TRACKED = "src/main/Tracked.md"; + private static final String OPT_IN = "targetExcludeUsingGitignore()"; + + private void givenBuild(boolean configCache, String... miscFormatLines) throws IOException { + givenBuildWithFormats(configCache, format("misc", miscFormatLines)); + } + + private void givenBuildWithFormats(boolean configCache, String... formats) throws IOException { + if (configCache) { + setFile("gradle.properties").toContent("org.gradle.configuration-cache=true"); + } + setFile("build.gradle").toLines( + "plugins {", + " id 'com.diffplug.spotless'", + "}", + "spotless {", + String.join("\n", formats), + "}"); + } + + private static String format(String name, String... extraLines) { + return String.join("\n", + " format '" + name + "', {", + " target 'src/**/*.md'", + " " + String.join("\n ", extraLines), + " 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; + } + + private static String nativePath(String unixPath) { + // the violation message lists paths with native separators + return unixPath.replace('/', File.separatorChar); + } + + @ParameterizedTest(name = "configCache={0}") + @ValueSource(booleans = {false, true}) + @DisplayName("given targetExcludeUsingGitignore 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); + BuildResult result = gradleRunner().withArguments("spotlessCheck").buildAndFail(); + assertThat(result.getOutput()).contains(nativePath(IGNORED)); + } + } + + @ParameterizedTest(name = "configCache={0}") + @ValueSource(booleans = {false, true}) + @DisplayName("given targetExcludeUsingGitignore(), when checking and applying, then ignored files are skipped but others are fixed") + void skipsIgnoredFiles(boolean configCache) throws Exception { + try (Git git = givenRepoWithDirtyIgnoredFile()) { + givenBuild(configCache, OPT_IN); + 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 two formats and only one opts in, when checking, then only the other format still sees ignored files") + void onlyAffectsTheFormatWhichOptsIn(boolean configCache) throws Exception { + try (Git git = givenRepoWithDirtyIgnoredFile()) { + givenBuildWithFormats(configCache, format("optedIn", OPT_IN), format("notOptedIn")); + gradleRunner().withArguments("spotlessOptedInCheck").build(); + BuildResult result = gradleRunner().withArguments("spotlessNotOptedInCheck").buildAndFail(); + assertThat(result.getOutput()).contains(nativePath(IGNORED)); + } + } + + @ParameterizedTest(name = "configCache={0}") + @ValueSource(booleans = {false, true}) + @DisplayName("given targetExcludeUsingGitignore() together with targetExclude, when checking, then both exclusions apply") + void combinesWithTargetExclude(boolean configCache) throws Exception { + try (Git git = givenRepoWithDirtyIgnoredFile()) { + givenBuild(configCache, OPT_IN, "targetExclude 'src/vendor/**'"); + setFile("src/vendor/Vendored.md").toContent("HELLO"); + gradleRunner().withArguments("spotlessCheck").build(); + } + } + + @ParameterizedTest(name = "configCache={0}") + @ValueSource(booleans = {false, true}) + @DisplayName("given targetExcludeUsingGitignore(), 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, OPT_IN); + 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 targetExcludeUsingGitignore() outside of a git repository, when checking, then the build fails with a clear message") + void notAGitRepositoryFails() throws Exception { + givenBuild(false, OPT_IN); + setFile(TRACKED).toContent("hello"); + BuildResult result = gradleRunner().withArguments("spotlessCheck").buildAndFail(); + assertThat(result.getOutput()).contains("requires a git repository"); + } + + @Test + @DisplayName("given targetExcludeUsingGitignore() at the top level of the spotless block, when configuring, then the build fails because it is format-level only") + void isNotAvailableGlobally() throws Exception { + setFile("build.gradle").toLines( + "plugins {", + " id 'com.diffplug.spotless'", + "}", + "spotless {", + " " + OPT_IN, + "}"); + BuildResult result = gradleRunner().withArguments("spotlessCheck").buildAndFail(); + assertThat(result.getOutput()).contains("targetExcludeUsingGitignore"); + } +} diff --git a/plugin-maven/CHANGES.md b/plugin-maven/CHANGES.md index 7120684723..cad621559e 100644 --- a/plugin-maven/CHANGES.md +++ b/plugin-maven/CHANGES.md @@ -7,6 +7,7 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format ( - Add a `` parameter to ``. ([#3179](https://github.com/diffplug/spotless/pull/3179)) - Add a `spotless:report` goal that puts a Spotless page in the Maven site. ([#3175](https://github.com/diffplug/spotless/pull/3175)) List `spotless-maven-plugin` under `` and run `mvn site`: the page shows which files need formatting, with a colored diff for each, and their lint problems. It works with `maven-site-plugin` 3.12.1 and 3.20.0 or later. +- Add `true` to exclude the files of a format which git ignores. ([#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)) - DBeaver SQL formatter no longer prints an `IndexOutOfBoundsException` when a file starts with a keyword like `SET`. ([#1552](https://github.com/diffplug/spotless/issues/1552)) diff --git a/plugin-maven/README.md b/plugin-maven/README.md index cb94daa3de..fd01a0880d 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?](#exclude-using-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) @@ -2131,6 +2132,26 @@ 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? + +The fastest way to keep Spotless away from generated sources or `node_modules` is a tight ``, e.g. `src/**/*.md` rather than `**/*.md`, followed by ``. Only if the ignored files cannot be described with a few globs, you can ask git instead: + +```xml + + + + **/*.md + + true + ... +``` + +A file is excluded 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. + +This is the slowest of the three options, which is why it has to be set per format: the ignored files are still found by `` first, and Spotless then has to list the working tree of the module once per build to evaluate git's ignore rules. + ## `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 e7401be073..7ab4a7c650 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; @@ -146,6 +147,9 @@ public abstract class AbstractSpotlessMojo extends AbstractMojo { @Parameter private String ratchetFrom; + /** Computed at most once per module, shared by every format which sets {@code }. */ + private GitIgnoredFiles gitIgnoredFiles; + @Parameter private LicenseHeader licenseHeader; @@ -332,12 +336,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.excludeUsingGitignore()) { + files = withoutGitIgnored(files); + } if (isNullOrEmpty(filePatterns)) { return files; } @@ -358,6 +365,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..56b5f79b47 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,13 @@ public abstract class FormatterFactory { @Parameter private String ratchetFrom = RATCHETFROM_NOT_SET_AT_FORMAT_LEVEL; + /** + * If {@code true}, files which git ignores (via {@code .gitignore}, {@code .git/info/exclude} or + * {@code core.excludesFile}) and which are not tracked are excluded, in addition to {@code }. + */ + @Parameter + private boolean excludeUsingGitignore; + @Parameter private String[] includes; @@ -181,6 +188,10 @@ Optional ratchetFrom(FormatterConfig config) { .filter(ratchet -> !RATCHETFROM_NONE.equals(ratchet)); } + boolean excludeUsingGitignore() { + return excludeUsingGitignore; + } + 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/ExcludeUsingGitignoreTest.java b/plugin-maven/src/test/java/com/diffplug/spotless/maven/ExcludeUsingGitignoreTest.java new file mode 100644 index 0000000000..efa7adf879 --- /dev/null +++ b/plugin-maven/src/test/java/com/diffplug/spotless/maven/ExcludeUsingGitignoreTest.java @@ -0,0 +1,143 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; + +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 ExcludeUsingGitignoreTest extends MavenIntegrationHarness { + private static final String IGNORED = "src/generated/Ignored.md"; + private static final String TRACKED = "src/main/Tracked.md"; + private static final String OPT_IN = "true"; + + private static String withFormats(String... formats) { + return StringPrinter.buildStringFromLines( + "", + String.join("\n", formats), + ""); + } + + /** A format which lowercases HELLO in the given files, with extra configuration lines. */ + private static String format(String include, String... extraLines) { + return StringPrinter.buildStringFromLines( + " ", + String.join("\n", extraLines), + " ", + " " + include + "", + " ", + " ", + " 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 excludeUsingGitignore is not configured, when checking, then ignored files are still formatted (unchanged default)") + void defaultStillFormatsIgnoredFiles() throws Exception { + try (Git git = givenRepoWithDirtyIgnoredFile()) { + writePom(withFormats(format("src/**/*.md"))); + mavenRunner().withArguments("spotless:check").runHasError(); + } + } + + @Test + @DisplayName("given excludeUsingGitignore on the format, when checking and applying, then ignored files are skipped") + void skipsIgnoredFiles() throws Exception { + try (Git git = givenRepoWithDirtyIgnoredFile()) { + writePom(withFormats(format("src/**/*.md", OPT_IN))); + mavenRunner().withArguments("spotless:check").runNoError(); + mavenRunner().withArguments("spotless:apply").runNoError(); + assertFile(IGNORED).hasContent("HELLO"); + } + } + + @Test + @DisplayName("given excludeUsingGitignore on the format, when a non-ignored file is dirty, then it is still reported and fixed") + void stillFormatsNonIgnoredFiles() throws Exception { + try (Git git = givenRepoWithDirtyIgnoredFile()) { + writePom(withFormats(format("src/**/*.md", OPT_IN))); + 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 two formats and only one opts in, when applying, then only the other format still formats ignored files") + void onlyAffectsTheFormatWhichOptsIn() throws Exception { + try (Git git = givenRepoWithDirtyIgnoredFile()) { + setFile("src/generated/Ignored.txt").toContent("HELLO"); + writePom(withFormats(format("src/**/*.md", OPT_IN), format("src/**/*.txt"))); + mavenRunner().withArguments("spotless:apply").runNoError(); + assertFile(IGNORED).hasContent("HELLO"); + assertFile("src/generated/Ignored.txt").hasContent("hello"); + } + } + + @Test + @DisplayName("given excludeUsingGitignore together with excludes, when checking, then both exclusions apply") + void combinesWithExcludes() throws Exception { + try (Git git = givenRepoWithDirtyIgnoredFile()) { + setFile("src/vendor/Vendored.md").toContent("HELLO"); + writePom(withFormats(format("src/**/*.md", OPT_IN, "src/vendor/**"))); + mavenRunner().withArguments("spotless:check").runNoError(); + } + } + + @Test + @DisplayName("given excludeUsingGitignore with ratchetFrom, when an ignored file is new, then it is skipped") + void combinesWithRatchetFrom() throws Exception { + try (Git git = givenRepoWithDirtyIgnoredFile()) { + writePom("baseline\n" + withFormats(format("src/**/*.md", OPT_IN))); + mavenRunner().withArguments("spotless:check").runNoError(); + } + } + + @Test + @DisplayName("given excludeUsingGitignore outside of a git repository, when checking, then the build fails with a clear message") + void notAGitRepositoryFails() throws Exception { + setFile(TRACKED).toContent("hello"); + writePom(withFormats(format("src/**/*.md", OPT_IN))); + assertThat(mavenRunner().withArguments("spotless:check").runHasError().stdOutUtf8()).contains("requires a git repository"); + } +}