diff --git a/CHANGES.md b/CHANGES.md
index e98e32f712..a57d1c1e2c 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
* Allow specifying path to Biome JSON config file directly in `biome` step. Requires biome 2.x. ([#2548](https://github.com/diffplug/spotless/pull/2548))
+- `GitPrePushHookInstaller`, a reusable library component for installing a Git `pre-push` hook that runs formatter checks.
## Changed
* Bump default `gson` version to latest `2.11.0` -> `2.13.1`. ([#2414](https://github.com/diffplug/spotless/pull/2414))
diff --git a/lib/src/main/java/com/diffplug/spotless/GitPrePushHookInstaller.java b/lib/src/main/java/com/diffplug/spotless/GitPrePushHookInstaller.java
new file mode 100644
index 0000000000..167e089b7d
--- /dev/null
+++ b/lib/src/main/java/com/diffplug/spotless/GitPrePushHookInstaller.java
@@ -0,0 +1,253 @@
+/*
+ * Copyright 2025 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;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.nio.file.Files;
+
+/**
+ * Abstract class responsible for installing a Git pre-push hook in a repository.
+ * This class ensures that specific checks and logic are run before a push operation in Git.
+ *
+ * Subclasses should define specific behavior for hook installation by implementing the required abstract methods.
+ */
+public abstract class GitPrePushHookInstaller {
+
+ private static final String HOOK_HEADER = "##### SPOTLESS HOOK START #####";
+ private static final String HOOK_FOOTER = "##### SPOTLESS HOOK END #####";
+
+ /**
+ * Logger for recording informational and error messages during the installation process.
+ */
+ protected final GitPreHookLogger logger;
+
+ /**
+ * The root directory of the Git repository where the hook will be installed.
+ */
+ protected final File root;
+
+ /**
+ * Constructor to initialize the GitPrePushHookInstaller with a logger and repository root path.
+ *
+ * @param logger The logger for recording messages.
+ * @param root The root directory of the Git repository.
+ */
+ public GitPrePushHookInstaller(GitPreHookLogger logger, File root) {
+ this.logger = requireNonNull(logger, "logger can not be null");
+ this.root = requireNonNull(root, "root file can not be null");
+ }
+
+ /**
+ * Installs the Git pre-push hook into the repository.
+ *
+ *
This method checks for the following:
+ *
+ * - Ensures Git is installed and the `.git/config` file exists.
+ * - Checks if an executor required by the hook is available.
+ * - Creates and writes the pre-push hook file if it does not exist.
+ * - Skips installation if the hook is already installed.
+ *
+ * If an issue occurs during installation, error messages are logged.
+ *
+ * @throws Exception if any error occurs during installation.
+ */
+ public void install() throws Exception {
+ logger.info("Installing git pre-push hook");
+
+ if (!isGitInstalled()) {
+ logger.error("Git not found in root directory");
+ return;
+ }
+
+ var hookContent = "";
+ final var gitHookFile = root.toPath().resolve(".git/hooks/pre-push").toFile();
+ if (!gitHookFile.exists()) {
+ logger.info("Git pre-push hook not found, creating it");
+ if (!gitHookFile.getParentFile().exists() && !gitHookFile.getParentFile().mkdirs()) {
+ logger.error("Failed to create pre-push hook directory");
+ return;
+ }
+
+ if (!gitHookFile.createNewFile()) {
+ logger.error("Failed to create pre-push hook file");
+ return;
+ }
+
+ if (!gitHookFile.setExecutable(true, false)) {
+ logger.error("Can not make file executable");
+ return;
+ }
+
+ hookContent += "#!/bin/sh\n";
+ }
+
+ if (isGitHookInstalled(gitHookFile)) {
+ logger.info("Git pre-push hook already installed, reinstalling it");
+ uninstall(gitHookFile);
+ }
+
+ hookContent += preHookContent();
+ writeFile(gitHookFile, hookContent, true);
+
+ logger.info("Git pre-push hook installed successfully to the file %s", gitHookFile.getAbsolutePath());
+ }
+
+ /**
+ * Uninstalls the Spotless Git pre-push hook from the specified hook file by removing
+ * the custom hook content between the defined hook markers.
+ *
+ * This method:
+ *
+ * - Reads the entire content of the pre-push hook file
+ * - Identifies the Spotless hook section using predefined markers
+ * - Removes the Spotless hook content while preserving other hook content
+ * - Writes the modified content back to the hook file
+ *
+ *
+ * @param gitHookFile The Git pre-push hook file from which to remove the Spotless hook
+ * @throws Exception if any error occurs during the uninstallation process,
+ * such as file reading or writing errors
+ */
+ private void uninstall(File gitHookFile) throws Exception {
+ final var hook = Files.readString(gitHookFile.toPath(), UTF_8);
+ final int hookStart = hook.indexOf(HOOK_HEADER);
+ final int hookEnd = hook.indexOf(HOOK_FOOTER) + HOOK_FOOTER.length(); // hookEnd exclusive, so must be last symbol \n
+
+ /* Detailed explanation:
+ * 1. hook.indexOf(HOOK_FOOTER) - finds the starting position of footer "##### SPOTLESS HOOK END #####"
+ * 2. + HOOK_FOOTER.length() is needed because String.substring(startIndex, endIndex) treats endIndex as exclusive
+ *
+ * For example, if file content is:
+ * #!/bin/sh
+ * ##### SPOTLESS HOOK START #####
+ * ... hook code ...
+ * ##### SPOTLESS HOOK END #####
+ * other content
+ *
+ * When we later use this in: hook.substring(hookStart, hookEnd)
+ * - Since substring's endIndex is exclusive (it stops BEFORE that index)
+ * - We need hookEnd to point to the position AFTER the last '#'
+ * - This ensures the entire footer "##### SPOTLESS HOOK END #####" is included in the substring
+ *
+ * This exclusive behavior is why in the subsequent code:
+ * if (hook.charAt(hookEnd) == '\n') {
+ * hookScript += "\n";
+ * }
+ *
+ * We can directly use hookEnd to check the next character after the footer
+ * - Since hookEnd is already pointing to the position AFTER the footer
+ * - No need for hookEnd + 1 in charAt()
+ * - This makes the code more consistent with the substring's exclusive nature
+ */
+
+ var hookScript = hook.substring(hookStart, hookEnd);
+ if (hookStart >= 1 && hook.charAt(hookStart - 1) == '\n') {
+ hookScript = "\n" + hookScript;
+ }
+
+ if (hookStart >= 2 && hook.charAt(hookStart - 2) == '\n') {
+ hookScript = "\n" + hookScript;
+ }
+
+ if (hook.charAt(hookEnd) == '\n') {
+ hookScript += "\n";
+ }
+
+ final var uninstalledHook = hook.replace(hookScript, "");
+
+ writeFile(gitHookFile, uninstalledHook, false);
+ }
+
+ /**
+ * Provides the content of the hook that should be inserted into the pre-push script.
+ *
+ * @return A string representing the content to include in the pre-push script.
+ */
+ protected abstract String preHookContent();
+
+ /**
+ * Generates a pre-push template script that defines the commands to check and apply changes
+ * using an executor and Spotless.
+ *
+ * @param executor The tool to execute the check and apply commands.
+ * @param commandCheck The command to check for issues.
+ * @param commandApply The command to apply corrections.
+ * @return A string template representing the Spotless Git pre-push hook content.
+ */
+ protected String preHookTemplate(String executor, String commandCheck, String commandApply) {
+ var spotlessHook = "";
+
+ spotlessHook += "\n";
+ spotlessHook += "\n" + HOOK_HEADER;
+ spotlessHook += "\nSPOTLESS_EXECUTOR=" + executor;
+ spotlessHook += "\nif ! $SPOTLESS_EXECUTOR " + commandCheck + " ; then";
+ spotlessHook += "\n echo 1>&2 \"spotless found problems, running " + commandApply + "; commit the result and re-push\"";
+ spotlessHook += "\n $SPOTLESS_EXECUTOR " + commandApply;
+ spotlessHook += "\n exit 1";
+ spotlessHook += "\nfi";
+ spotlessHook += "\n" + HOOK_FOOTER;
+ spotlessHook += "\n";
+
+ return spotlessHook;
+ }
+
+ /**
+ * Checks if Git is installed by validating the existence of `.git/config` in the repository root.
+ *
+ * @return {@code true} if Git is installed, {@code false} otherwise.
+ */
+ private boolean isGitInstalled() {
+ return root.toPath().resolve(".git/config").toFile().exists();
+ }
+
+ /**
+ * Verifies if the pre-push hook file already contains the custom Spotless hook content.
+ *
+ * @param gitHookFile The file representing the Git hook.
+ * @return {@code true} if the hook is already installed, {@code false} otherwise.
+ * @throws Exception if an error occurs when reading the file.
+ */
+ private boolean isGitHookInstalled(File gitHookFile) throws Exception {
+ final var hook = Files.readString(gitHookFile.toPath(), UTF_8);
+ return hook.contains(HOOK_HEADER) && hook.contains(HOOK_FOOTER);
+ }
+
+ /**
+ * Writes the specified content into a file.
+ *
+ * @param file The file to which the content should be written.
+ * @param content The content to write into the file.
+ * @throws IOException if an error occurs while writing to the file.
+ */
+ private void writeFile(File file, String content, boolean append) throws IOException {
+ try (final var writer = new FileWriter(file, UTF_8, append)) {
+ writer.write(content);
+ }
+ }
+
+ public interface GitPreHookLogger {
+ void info(String format, Object... arguments);
+
+ void warn(String format, Object... arguments);
+
+ void error(String format, Object... arguments);
+ }
+}
diff --git a/lib/src/main/java/com/diffplug/spotless/GitPrePushHookInstallerGradle.java b/lib/src/main/java/com/diffplug/spotless/GitPrePushHookInstallerGradle.java
new file mode 100644
index 0000000000..0b11db5cab
--- /dev/null
+++ b/lib/src/main/java/com/diffplug/spotless/GitPrePushHookInstallerGradle.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2025 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;
+
+import java.io.File;
+
+/**
+ * Implementation of {@link GitPrePushHookInstaller} specifically for Gradle-based projects.
+ * This class installs a Git pre-push hook that uses Gradle's `gradlew` executable to check and apply Spotless formatting.
+ */
+public class GitPrePushHookInstallerGradle extends GitPrePushHookInstaller {
+
+ /**
+ * The Gradle wrapper file (`gradlew`) located in the root directory of the project.
+ */
+ private final File gradlew;
+
+ public GitPrePushHookInstallerGradle(GitPreHookLogger logger, File root) {
+ super(logger, root);
+ this.gradlew = root.toPath().resolve("gradlew").toFile();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ protected String preHookContent() {
+ return preHookTemplate(executorPath(), "spotlessCheck", "spotlessApply");
+ }
+
+ private String executorPath() {
+ if (gradlew.exists()) {
+ return gradlew.getAbsolutePath();
+ }
+
+ logger.info("Gradle wrapper is not installed, using global gradle");
+ return "gradle";
+ }
+}
diff --git a/lib/src/main/java/com/diffplug/spotless/GitPrePushHookInstallerMaven.java b/lib/src/main/java/com/diffplug/spotless/GitPrePushHookInstallerMaven.java
new file mode 100644
index 0000000000..138d27f9a3
--- /dev/null
+++ b/lib/src/main/java/com/diffplug/spotless/GitPrePushHookInstallerMaven.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2025 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;
+
+import java.io.File;
+
+/**
+ * Implementation of {@link GitPrePushHookInstaller} specifically for Maven-based projects.
+ * This class installs a Git pre-push hook that uses Maven to check and apply Spotless formatting.
+ */
+public class GitPrePushHookInstallerMaven extends GitPrePushHookInstaller {
+
+ private final File mvnw;
+
+ public GitPrePushHookInstallerMaven(GitPreHookLogger logger, File root) {
+ super(logger, root);
+ this.mvnw = root.toPath().resolve("mvnw").toFile();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ protected String preHookContent() {
+ return preHookTemplate(executorPath(), "spotless:check", "spotless:apply");
+ }
+
+ private String executorPath() {
+ if (mvnw.exists()) {
+ return mvnw.getAbsolutePath();
+ }
+
+ logger.info("Maven wrapper is not installed, using global maven");
+ return "mvn";
+ }
+}
diff --git a/plugin-gradle/CHANGES.md b/plugin-gradle/CHANGES.md
index dd1e9c425f..b318ae421f 100644
--- a/plugin-gradle/CHANGES.md
+++ b/plugin-gradle/CHANGES.md
@@ -5,6 +5,9 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (
## [Unreleased]
### Added
* Allow specifying path to Biome JSON config file directly in `biome` step. Requires biome 2.x. ([#2548](https://github.com/diffplug/spotless/pull/2548))
+- `spotlessInstallGitPrePushHook` task, which installs a Git `pre-push` hook to run `spotlessCheck` and `spotlessApply`.
+ Uses shared implementation from `GitPrePushHookInstaller`.
+ [#2553](https://github.com/diffplug/spotless/pull/2553)
## Changed
* Bump default `gson` version to latest `2.11.0` -> `2.13.1`. ([#2414](https://github.com/diffplug/spotless/pull/2414))
diff --git a/plugin-gradle/README.md b/plugin-gradle/README.md
index 1fa7519a8c..05a99350fd 100644
--- a/plugin-gradle/README.md
+++ b/plugin-gradle/README.md
@@ -53,6 +53,7 @@ Spotless supports all of Gradle's built-in performance features (incremental bui
- [**Quickstart**](#quickstart)
- [Requirements](#requirements)
+ - [Git hook (optional)](#git-hook)
- [Linting](#linting)
- **Languages**
- [Java](#java) ([google-java-format](#google-java-format), [eclipse jdt](#eclipse-jdt), [clang-format](#clang-format), [prettier](#prettier), [palantir-java-format](#palantir-java-format), [formatAnnotations](#formatAnnotations), [cleanthat](#cleanthat), [IntelliJ IDEA](#intellij-idea))
@@ -141,6 +142,20 @@ Spotless requires JRE 11+ and Gradle 6.1.1 or newer.
- If you're stuck on JRE 8, use [`id 'com.diffplug.spotless' version '6.13.0'` or older](https://github.com/diffplug/spotless/blob/main/plugin-gradle/CHANGES.md#6130---2023-01-14).
- If you're stuck on an older version of Gradle, [`id 'com.diffplug.gradle.spotless' version '4.5.1'` supports all the way back to Gradle 2.x](https://github.com/diffplug/spotless/blob/main/plugin-gradle/CHANGES.md#451---2020-07-04).
+### Git hook
+
+If you want, you can run `./gradlew spotlessInstallGitPrePushHook` and it will install a hook such that
+
+1. When you push, it runs `spotlessCheck`
+2. If formatting issues are found:
+ - It automatically runs `spotlessApply` to fix them
+ - Aborts the push with a message
+ - You can then commit the changes and push again
+
+This ensures your code is always clean before it leaves your machine.
+
+If you prefer instead to have a "pre-commit" hook so that every single commit is clean, see [#623](https://github.com/diffplug/spotless/issues/623) for a workaround or to contribute a permanent fix.
+
### Linting
Starting in version `7.0.0`, Spotless now supports linting in addition to formatting. To Spotless, all lints are errors which must be either fixed or suppressed. Lints show up like this:
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 e883953eaa..92079d67c4 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
@@ -1,5 +1,5 @@
/*
- * Copyright 2016-2024 DiffPlug
+ * Copyright 2016-2025 DiffPlug
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -38,14 +38,17 @@ public abstract class SpotlessExtension {
private final RegisterDependenciesTask registerDependenciesTask;
protected static final String TASK_GROUP = LifecycleBasePlugin.VERIFICATION_GROUP;
+ protected static final String BUILD_SETUP_TASK_GROUP = "build setup";
protected static final String CHECK_DESCRIPTION = "Checks that sourcecode satisfies formatting steps.";
protected static final String APPLY_DESCRIPTION = "Applies code formatting steps to sourcecode in-place.";
+ protected static final String INSTALL_GIT_PRE_PUSH_HOOK_DESCRIPTION = "Installs Spotless Git pre-push hook.";
static final String EXTENSION = "spotless";
static final String EXTENSION_PREDECLARE = "spotlessPredeclare";
static final String CHECK = "Check";
static final String APPLY = "Apply";
static final String DIAGNOSE = "Diagnose";
+ static final String INSTALL_GIT_PRE_PUSH_HOOK = "InstallGitPrePushHook";
protected SpotlessExtension(Project project) {
this.project = requireNonNull(project);
diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtensionImpl.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtensionImpl.java
index 75168f690a..53b7b011a1 100644
--- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtensionImpl.java
+++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtensionImpl.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2016-2024 DiffPlug
+ * Copyright 2016-2025 DiffPlug
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -23,7 +23,7 @@
import org.gradle.api.tasks.TaskProvider;
public class SpotlessExtensionImpl extends SpotlessExtension {
- final TaskProvider> rootCheckTask, rootApplyTask, rootDiagnoseTask;
+ final TaskProvider> rootCheckTask, rootApplyTask, rootDiagnoseTask, rootInstallPreHook;
public SpotlessExtensionImpl(Project project) {
super(project);
@@ -38,6 +38,10 @@ public SpotlessExtensionImpl(Project project) {
rootDiagnoseTask = project.getTasks().register(EXTENSION + DIAGNOSE, task -> {
task.setGroup(TASK_GROUP); // no description on purpose
});
+ rootInstallPreHook = project.getTasks().register(EXTENSION + INSTALL_GIT_PRE_PUSH_HOOK, SpotlessInstallPrePushHookTask.class, task -> {
+ task.setGroup(BUILD_SETUP_TASK_GROUP);
+ task.setDescription(INSTALL_GIT_PRE_PUSH_HOOK_DESCRIPTION);
+ });
project.afterEvaluate(unused -> {
if (enforceCheck) {
diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessInstallPrePushHookTask.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessInstallPrePushHookTask.java
new file mode 100644
index 0000000000..3e59e94b0a
--- /dev/null
+++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessInstallPrePushHookTask.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2025 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 org.gradle.api.DefaultTask;
+import org.gradle.api.tasks.TaskAction;
+import org.gradle.work.DisableCachingByDefault;
+
+import com.diffplug.spotless.GitPrePushHookInstaller.GitPreHookLogger;
+import com.diffplug.spotless.GitPrePushHookInstallerGradle;
+
+/**
+ * A Gradle task responsible for installing a Git pre-push hook for the Spotless plugin.
+ * This hook ensures that Spotless formatting rules are automatically checked and applied
+ * before performing a Git push operation.
+ *
+ * The task leverages {@link GitPrePushHookInstallerGradle} to implement the installation process.
+ */
+@DisableCachingByDefault(because = "not worth caching")
+public class SpotlessInstallPrePushHookTask extends DefaultTask {
+
+ /**
+ * Executes the task to install the Git pre-push hook.
+ *
+ *
This method creates an instance of {@link GitPrePushHookInstallerGradle},
+ * providing a logger to record informational and error messages during the installation process.
+ * The installer then installs the hook in the root directory of the Gradle project.
+ *
+ * @throws Exception if an error occurs during the hook installation process.
+ */
+ @TaskAction
+ public void performAction() throws Exception {
+ final var logger = new GitPreHookLogger() {
+ @Override
+ public void info(String format, Object... arguments) {
+ getLogger().lifecycle(String.format(format, arguments));
+ }
+
+ @Override
+ public void warn(String format, Object... arguments) {
+ getLogger().warn(String.format(format, arguments));
+ }
+
+ @Override
+ public void error(String format, Object... arguments) {
+ getLogger().error(String.format(format, arguments));
+ }
+ };
+
+ final var installer = new GitPrePushHookInstallerGradle(logger, getProject().getRootDir());
+ installer.install();
+ }
+}
diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/SpotlessInstallPrePushHookTaskTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/SpotlessInstallPrePushHookTaskTest.java
new file mode 100644
index 0000000000..d5ff385b82
--- /dev/null
+++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/SpotlessInstallPrePushHookTaskTest.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2025 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 org.junit.jupiter.api.Test;
+
+class SpotlessInstallPrePushHookTaskTest extends GradleIntegrationHarness {
+
+ @Test
+ public void should_create_pre_hook_file_when_hook_file_does_not_exists() throws Exception {
+ // given
+ setFile(".git/config").toContent("");
+ newFile(".git/hooks").mkdirs();
+ setFile("build.gradle").toLines(
+ "plugins {",
+ " id 'java'",
+ " id 'com.diffplug.spotless'",
+ "}",
+ "repositories { mavenCentral() }");
+
+ // when
+ var output = gradleRunner()
+ .withArguments("spotlessInstallGitPrePushHook")
+ .build()
+ .getOutput();
+
+ // then
+ assertThat(output).contains("Installing git pre-push hook");
+ assertThat(output).contains("Git pre-push hook not found, creating it");
+ assertThat(output).contains("Git pre-push hook installed successfully to the file " + newFile(".git/hooks/pre-push"));
+
+ final var content = getTestResource("git_pre_hook/pre-push.created-tpl")
+ .replace("${executor}", "gradle")
+ .replace("${checkCommand}", "spotlessCheck")
+ .replace("${applyCommand}", "spotlessApply");
+ assertFile(".git/hooks/pre-push").hasContent(content);
+ }
+
+ @Test
+ public void should_append_to_existing_pre_hook_file_when_hook_file_exists() throws Exception {
+ // given
+ setFile(".git/config").toContent("");
+ setFile("build.gradle").toLines(
+ "plugins {",
+ " id 'java'",
+ " id 'com.diffplug.spotless'",
+ "}",
+ "repositories { mavenCentral() }");
+ setFile(".git/hooks/pre-push").toResource("git_pre_hook/pre-push.existing");
+
+ // when
+ final var output = gradleRunner()
+ .withArguments("spotlessInstallGitPrePushHook")
+ .build()
+ .getOutput();
+
+ // then
+ assertThat(output).contains("Installing git pre-push hook");
+ assertThat(output).contains("Git pre-push hook installed successfully to the file " + newFile(".git/hooks/pre-push"));
+
+ final var content = getTestResource("git_pre_hook/pre-push.existing-installed-end-tpl")
+ .replace("${executor}", "gradle")
+ .replace("${checkCommand}", "spotlessCheck")
+ .replace("${applyCommand}", "spotlessApply");
+ assertFile(".git/hooks/pre-push").hasContent(content);
+ }
+}
diff --git a/plugin-maven/CHANGES.md b/plugin-maven/CHANGES.md
index 6829753b2e..f44ad846d4 100644
--- a/plugin-maven/CHANGES.md
+++ b/plugin-maven/CHANGES.md
@@ -5,6 +5,9 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (
## [Unreleased]
### Added
* Allow specifying path to Biome JSON config file directly in `biome` step. Requires biome 2.x. ([#2548](https://github.com/diffplug/spotless/pull/2548))
+- `spotless:install-git-pre-push-hook` goal, which installs a Git `pre-push` hook to run `spotless:check` and `spotless:apply`.
+ Uses shared implementation from `GitPrePushHookInstaller`.
+ [#2553](https://github.com/diffplug/spotless/pull/2553)
## Changed
* Bump default `gson` version to latest `2.11.0` -> `2.13.1`. ([#2414](https://github.com/diffplug/spotless/pull/2414))
diff --git a/plugin-maven/README.md b/plugin-maven/README.md
index f633ca5635..50b90ada1f 100644
--- a/plugin-maven/README.md
+++ b/plugin-maven/README.md
@@ -37,6 +37,7 @@ user@machine repo % mvn spotless:check
- [**Quickstart**](#quickstart)
- [Requirements](#requirements)
+ - [Git hook (optional)](#git-hook)
- [Binding to maven phase](#binding-to-maven-phase)
- **Languages**
- [Java](#java) ([google-java-format](#google-java-format), [eclipse jdt](#eclipse-jdt), [prettier](#prettier), [palantir-java-format](#palantir-java-format), [formatAnnotations](#formatAnnotations), [cleanthat](#cleanthat), [IntelliJ IDEA](#intellij-idea))
@@ -145,7 +146,20 @@ Spotless consists of a list of formats (in the example above, `misc` and `java`)
Spotless requires Maven to be running on JRE 11+. To use JRE 8, go back to [`2.30.0` or older](https://github.com/diffplug/spotless/blob/main/plugin-maven/CHANGES.md#2300---2023-01-13).
-
+
+### Git hook
+
+If you want, you can run `mvn spotless:install-git-pre-push-hook` and it will install a hook such that
+
+1. When you push, it runs `spotless:check`
+2. If formatting issues are found:
+ - It automatically runs `spotless:apply` to fix them
+ - Aborts the push with a message
+ - You can then commit the changes and push again
+
+This ensures your code is always clean before it leaves your machine.
+
+If you prefer instead to have a "pre-commit" hook so that every single commit is clean, see [#623](https://github.com/diffplug/spotless/issues/623) for a workaround or to contribute a permanent fix.
### Binding to maven phase
@@ -176,6 +190,8 @@ any other maven phase (i.e. compile) then it can be configured as below;
```
+
+
## Java
[code](https://github.com/diffplug/spotless/blob/main/plugin-maven/src/main/java/com/diffplug/spotless/maven/java/Java.java). [available steps](https://github.com/diffplug/spotless/tree/main/plugin-maven/src/main/java/com/diffplug/spotless/maven/java).
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 d21a1b8113..e5a8004f97 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
@@ -1,5 +1,5 @@
/*
- * Copyright 2016-2024 DiffPlug
+ * Copyright 2016-2025 DiffPlug
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -91,6 +91,7 @@ public abstract class AbstractSpotlessMojo extends AbstractMojo {
static final String GOAL_CHECK = "check";
static final String GOAL_APPLY = "apply";
+ static final String GOAL_PRE_PUSH_HOOK = "install-git-pre-push-hook";
@Component
private RepositorySystem repositorySystem;
diff --git a/plugin-maven/src/main/java/com/diffplug/spotless/maven/SpotlessInstallPrePushHookMojo.java b/plugin-maven/src/main/java/com/diffplug/spotless/maven/SpotlessInstallPrePushHookMojo.java
new file mode 100644
index 0000000000..984724a688
--- /dev/null
+++ b/plugin-maven/src/main/java/com/diffplug/spotless/maven/SpotlessInstallPrePushHookMojo.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2025 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.File;
+
+import org.apache.maven.plugin.AbstractMojo;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.MojoFailureException;
+import org.apache.maven.plugins.annotations.Mojo;
+import org.apache.maven.plugins.annotations.Parameter;
+
+import com.diffplug.spotless.GitPrePushHookInstaller.GitPreHookLogger;
+import com.diffplug.spotless.GitPrePushHookInstallerMaven;
+
+/**
+ * A Maven Mojo responsible for installing a Git pre-push hook for the Spotless plugin.
+ * This hook ensures that Spotless formatting rules are automatically checked and applied
+ * before performing a Git push operation.
+ *
+ *
The class leverages {@link GitPrePushHookInstallerMaven} to perform the installation process
+ * and uses a Maven logger to log installation events and errors to the console.
+ */
+@Mojo(name = AbstractSpotlessMojo.GOAL_PRE_PUSH_HOOK, threadSafe = true)
+public class SpotlessInstallPrePushHookMojo extends AbstractMojo {
+
+ /**
+ * The base directory of the Maven project where the Git pre-push hook will be installed.
+ * This parameter is automatically set to the root directory of the current project.
+ */
+ @Parameter(defaultValue = "${project.basedir}", readonly = true, required = true)
+ private File baseDir;
+
+ /**
+ * Executes the Mojo, installing the Git pre-push hook for the Spotless plugin.
+ *
+ *
This method creates an instance of {@link GitPrePushHookInstallerMaven},
+ * providing a logger for logging the process of hook installation and any potential errors.
+ * The installation process runs in the root directory of the current Maven project.
+ *
+ * @throws MojoExecutionException if an error occurs during the installation process.
+ * @throws MojoFailureException if the hook fails to install for any reason.
+ */
+ @Override
+ public void execute() throws MojoExecutionException, MojoFailureException {
+ final var logger = new GitPreHookLogger() {
+ @Override
+ public void info(String format, Object... arguments) {
+ getLog().info(String.format(format, arguments));
+ }
+
+ @Override
+ public void warn(String format, Object... arguments) {
+ getLog().warn(String.format(format, arguments));
+ }
+
+ @Override
+ public void error(String format, Object... arguments) {
+ getLog().error(String.format(format, arguments));
+ }
+ };
+
+ try {
+ final var installer = new GitPrePushHookInstallerMaven(logger, baseDir);
+ installer.install();
+ } catch (Exception e) {
+ throw new MojoExecutionException("Unable to install pre-push hook", e);
+ }
+ }
+}
diff --git a/plugin-maven/src/test/java/com/diffplug/spotless/maven/SpotlessInstallPrePushHookMojoTest.java b/plugin-maven/src/test/java/com/diffplug/spotless/maven/SpotlessInstallPrePushHookMojoTest.java
new file mode 100644
index 0000000000..ebfc13d982
--- /dev/null
+++ b/plugin-maven/src/test/java/com/diffplug/spotless/maven/SpotlessInstallPrePushHookMojoTest.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2025 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.junit.jupiter.api.Test;
+
+class SpotlessInstallPrePushHookMojoTest extends MavenIntegrationHarness {
+
+ @Test
+ public void should_create_pre_hook_file_when_hook_file_does_not_exists() throws Exception {
+ // given
+ setFile(".git/config").toContent("");
+ setFile("license.txt").toResource("license/TestLicense");
+ writePomWithJavaLicenseHeaderStep();
+
+ // when
+ final var output = mavenRunner()
+ .withArguments("spotless:install-git-pre-push-hook")
+ .runNoError()
+ .stdOutUtf8();
+
+ // then
+ assertThat(output).contains("Installing git pre-push hook");
+ assertThat(output).contains("Git pre-push hook not found, creating it");
+ assertThat(output).contains("Git pre-push hook installed successfully to the file " + newFile(".git/hooks/pre-push"));
+
+ final var content = getTestResource("git_pre_hook/pre-push.created-tpl")
+ .replace("${executor}", newFile("mvnw").getAbsolutePath())
+ .replace("${checkCommand}", "spotless:check")
+ .replace("${applyCommand}", "spotless:apply");
+ assertFile(".git/hooks/pre-push").hasContent(content);
+ }
+
+ @Test
+ public void should_append_to_existing_pre_hook_file_when_hook_file_exists() throws Exception {
+ // given
+ setFile(".git/config").toContent("");
+ setFile("license.txt").toResource("license/TestLicense");
+ setFile(".git/hooks/pre-push").toResource("git_pre_hook/pre-push.existing");
+
+ writePomWithJavaLicenseHeaderStep();
+
+ // when
+ final var output = mavenRunner()
+ .withArguments("spotless:install-git-pre-push-hook")
+ .runNoError()
+ .stdOutUtf8();
+
+ // then
+ assertThat(output).contains("Installing git pre-push hook");
+ assertThat(output).contains("Git pre-push hook installed successfully to the file " + newFile(".git/hooks/pre-push"));
+
+ final var content = getTestResource("git_pre_hook/pre-push.existing-installed-end-tpl")
+ .replace("${executor}", newFile("mvnw").getAbsolutePath())
+ .replace("${checkCommand}", "spotless:check")
+ .replace("${applyCommand}", "spotless:apply");
+ assertFile(".git/hooks/pre-push").hasContent(content);
+ }
+
+ private void writePomWithJavaLicenseHeaderStep() throws IOException {
+ writePomWithJavaSteps(
+ "",
+ " ${basedir}/license.txt",
+ "");
+ }
+}
diff --git a/testlib/src/main/resources/git_pre_hook/pre-push.created-tpl b/testlib/src/main/resources/git_pre_hook/pre-push.created-tpl
new file mode 100644
index 0000000000..8edbebebf6
--- /dev/null
+++ b/testlib/src/main/resources/git_pre_hook/pre-push.created-tpl
@@ -0,0 +1,11 @@
+#!/bin/sh
+
+
+##### SPOTLESS HOOK START #####
+SPOTLESS_EXECUTOR=${executor}
+if ! $SPOTLESS_EXECUTOR ${checkCommand} ; then
+ echo 1>&2 "spotless found problems, running ${applyCommand}; commit the result and re-push"
+ $SPOTLESS_EXECUTOR ${applyCommand}
+ exit 1
+fi
+##### SPOTLESS HOOK END #####
diff --git a/testlib/src/main/resources/git_pre_hook/pre-push.existing b/testlib/src/main/resources/git_pre_hook/pre-push.existing
new file mode 100644
index 0000000000..a46210c6c1
--- /dev/null
+++ b/testlib/src/main/resources/git_pre_hook/pre-push.existing
@@ -0,0 +1,51 @@
+#!/bin/sh
+
+# An example hook script to verify what is about to be pushed. Called by "git
+# push" after it has checked the remote status, but before anything has been
+# pushed. If this script exits with a non-zero status nothing will be pushed.
+#
+# This hook is called with the following parameters:
+#
+# $1 -- Name of the remote to which the push is being done
+# $2 -- URL to which the push is being done
+#
+# If pushing without using a named remote those arguments will be equal.
+#
+# Information about the commits which are being pushed is supplied as lines to
+# the standard input in the form:
+#
+#
+#
+# This sample shows how to prevent push of commits where the log message starts
+# with "WIP" (work in progress).
+
+remote="$1"
+url="$2"
+
+zero=$(git hash-object --stdin &2 "Found WIP commit in $local_ref, not pushing"
+ exit 1
+ fi
+ fi
+done
diff --git a/testlib/src/main/resources/git_pre_hook/pre-push.existing-installed-end-tpl b/testlib/src/main/resources/git_pre_hook/pre-push.existing-installed-end-tpl
new file mode 100644
index 0000000000..48330e5842
--- /dev/null
+++ b/testlib/src/main/resources/git_pre_hook/pre-push.existing-installed-end-tpl
@@ -0,0 +1,61 @@
+#!/bin/sh
+
+# An example hook script to verify what is about to be pushed. Called by "git
+# push" after it has checked the remote status, but before anything has been
+# pushed. If this script exits with a non-zero status nothing will be pushed.
+#
+# This hook is called with the following parameters:
+#
+# $1 -- Name of the remote to which the push is being done
+# $2 -- URL to which the push is being done
+#
+# If pushing without using a named remote those arguments will be equal.
+#
+# Information about the commits which are being pushed is supplied as lines to
+# the standard input in the form:
+#
+#
+#
+# This sample shows how to prevent push of commits where the log message starts
+# with "WIP" (work in progress).
+
+remote="$1"
+url="$2"
+
+zero=$(git hash-object --stdin &2 "Found WIP commit in $local_ref, not pushing"
+ exit 1
+ fi
+ fi
+done
+
+
+##### SPOTLESS HOOK START #####
+SPOTLESS_EXECUTOR=${executor}
+if ! $SPOTLESS_EXECUTOR ${checkCommand} ; then
+ echo 1>&2 "spotless found problems, running ${applyCommand}; commit the result and re-push"
+ $SPOTLESS_EXECUTOR ${applyCommand}
+ exit 1
+fi
+##### SPOTLESS HOOK END #####
diff --git a/testlib/src/main/resources/git_pre_hook/pre-push.existing-installed-middle-tpl b/testlib/src/main/resources/git_pre_hook/pre-push.existing-installed-middle-tpl
new file mode 100644
index 0000000000..63f7da0a0b
--- /dev/null
+++ b/testlib/src/main/resources/git_pre_hook/pre-push.existing-installed-middle-tpl
@@ -0,0 +1,94 @@
+#!/bin/sh
+
+# An example hook script to verify what is about to be pushed. Called by "git
+# push" after it has checked the remote status, but before anything has been
+# pushed. If this script exits with a non-zero status nothing will be pushed.
+#
+# This hook is called with the following parameters:
+#
+# $1 -- Name of the remote to which the push is being done
+# $2 -- URL to which the push is being done
+#
+# If pushing without using a named remote those arguments will be equal.
+#
+# Information about the commits which are being pushed is supplied as lines to
+# the standard input in the form:
+#
+#
+#
+# This sample shows how to prevent push of commits where the log message starts
+# with "WIP" (work in progress).
+
+remote="$1"
+url="$2"
+
+zero=$(git hash-object --stdin &2 "Found WIP commit in $local_ref, not pushing"
+ exit 1
+ fi
+ fi
+done
+
+
+##### SPOTLESS HOOK START #####
+SPOTLESS_EXECUTOR=${executor}
+if ! $SPOTLESS_EXECUTOR ${checkCommand} ; then
+ echo 1>&2 "spotless found problems, running ${applyCommand}; commit the result and re-push"
+ $SPOTLESS_EXECUTOR ${applyCommand}
+ exit 1
+fi
+##### SPOTLESS HOOK END #####
+
+
+# some additional pre-push code
+remote="$1"
+url="$2"
+
+zero=$(git hash-object --stdin &2 "Found WIP commit in $local_ref, not pushing"
+ exit 1
+ fi
+ fi
+done
diff --git a/testlib/src/main/resources/git_pre_hook/pre-push.existing-reinstalled-middle-tpl b/testlib/src/main/resources/git_pre_hook/pre-push.existing-reinstalled-middle-tpl
new file mode 100644
index 0000000000..7265927c13
--- /dev/null
+++ b/testlib/src/main/resources/git_pre_hook/pre-push.existing-reinstalled-middle-tpl
@@ -0,0 +1,94 @@
+#!/bin/sh
+
+# An example hook script to verify what is about to be pushed. Called by "git
+# push" after it has checked the remote status, but before anything has been
+# pushed. If this script exits with a non-zero status nothing will be pushed.
+#
+# This hook is called with the following parameters:
+#
+# $1 -- Name of the remote to which the push is being done
+# $2 -- URL to which the push is being done
+#
+# If pushing without using a named remote those arguments will be equal.
+#
+# Information about the commits which are being pushed is supplied as lines to
+# the standard input in the form:
+#
+#
+#
+# This sample shows how to prevent push of commits where the log message starts
+# with "WIP" (work in progress).
+
+remote="$1"
+url="$2"
+
+zero=$(git hash-object --stdin &2 "Found WIP commit in $local_ref, not pushing"
+ exit 1
+ fi
+ fi
+done
+
+
+# some additional pre-push code
+remote="$1"
+url="$2"
+
+zero=$(git hash-object --stdin &2 "Found WIP commit in $local_ref, not pushing"
+ exit 1
+ fi
+ fi
+done
+
+
+##### SPOTLESS HOOK START #####
+SPOTLESS_EXECUTOR=${executor}
+if ! $SPOTLESS_EXECUTOR ${checkCommand} ; then
+ echo 1>&2 "spotless found problems, running ${applyCommand}; commit the result and re-push"
+ $SPOTLESS_EXECUTOR ${applyCommand}
+ exit 1
+fi
+##### SPOTLESS HOOK END #####
diff --git a/testlib/src/test/java/com/diffplug/spotless/GitPrePushHookInstallerTest.java b/testlib/src/test/java/com/diffplug/spotless/GitPrePushHookInstallerTest.java
new file mode 100644
index 0000000000..6167e26b8c
--- /dev/null
+++ b/testlib/src/test/java/com/diffplug/spotless/GitPrePushHookInstallerTest.java
@@ -0,0 +1,244 @@
+/*
+ * Copyright 2025 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;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import com.diffplug.spotless.GitPrePushHookInstaller.GitPreHookLogger;
+
+class GitPrePushHookInstallerTest extends ResourceHarness {
+ private final List logs = new ArrayList<>();
+ private final GitPreHookLogger logger = new GitPreHookLogger() {
+ @Override
+ public void info(String format, Object... arguments) {
+ logs.add(String.format(format, arguments));
+ }
+
+ @Override
+ public void warn(String format, Object... arguments) {
+ logs.add(String.format(format, arguments));
+ }
+
+ @Override
+ public void error(String format, Object... arguments) {
+ logs.add(String.format(format, arguments));
+ }
+ };
+
+ @Test
+ public void should_not_create_pre_hook_file_when_git_is_not_installed() throws Exception {
+ // given
+ final var gradle = new GitPrePushHookInstallerGradle(logger, rootFolder());
+
+ // when
+ gradle.install();
+
+ // then
+ assertThat(logs).hasSize(2);
+ assertThat(logs).element(0).isEqualTo("Installing git pre-push hook");
+ assertThat(logs).element(1).isEqualTo("Git not found in root directory");
+ assertThat(newFile(".git/hooks/pre-push")).doesNotExist();
+ }
+
+ @Test
+ public void should_use_global_gradle_when_gradlew_is_not_installed() throws Exception {
+ // given
+ final var gradle = new GitPrePushHookInstallerGradle(logger, rootFolder());
+ setFile(".git/config").toContent("");
+
+ // when
+ gradle.install();
+
+ // then
+ assertThat(logs).hasSize(4);
+ assertThat(logs).element(0).isEqualTo("Installing git pre-push hook");
+ assertThat(logs).element(1).isEqualTo("Git pre-push hook not found, creating it");
+ assertThat(logs).element(2).isEqualTo("Gradle wrapper is not installed, using global gradle");
+ assertThat(logs).element(3).isEqualTo("Git pre-push hook installed successfully to the file " + newFile(".git/hooks/pre-push").getAbsolutePath());
+
+ final var content = gradleHookContent("git_pre_hook/pre-push.created-tpl", ExecutorType.GLOBAL);
+ assertFile(".git/hooks/pre-push").hasContent(content);
+ }
+
+ @Test
+ public void should_reinstall_pre_hook_file_when_hook_already_installed() throws Exception {
+ // given
+ final var gradle = new GitPrePushHookInstallerGradle(logger, rootFolder());
+ final var installedGlobally = gradleHookContent("git_pre_hook/pre-push.existing-installed-end-tpl", ExecutorType.GLOBAL);
+ final var hookFile = setFile(".git/hooks/pre-push").toContent(installedGlobally);
+
+ setFile("gradlew").toContent("");
+ setFile(".git/config").toContent("");
+
+ // when
+ gradle.install();
+
+ // then
+ assertThat(logs).hasSize(3);
+ assertThat(logs).element(0).isEqualTo("Installing git pre-push hook");
+ assertThat(logs).element(1).isEqualTo("Git pre-push hook already installed, reinstalling it");
+ assertThat(logs).element(2).isEqualTo("Git pre-push hook installed successfully to the file " + hookFile.getAbsolutePath());
+
+ final var content = gradleHookContent("git_pre_hook/pre-push.existing-installed-end-tpl", ExecutorType.WRAPPER);
+ assertFile(".git/hooks/pre-push").hasContent(content);
+ }
+
+ @Test
+ public void should_reinstall_pre_hook_file_when_hook_already_installed_in_the_middle_of_file() throws Exception {
+ // given
+ final var gradle = new GitPrePushHookInstallerGradle(logger, rootFolder());
+ final var installedGlobally = gradleHookContent("git_pre_hook/pre-push.existing-installed-middle-tpl", ExecutorType.GLOBAL);
+ final var hookFile = setFile(".git/hooks/pre-push").toContent(installedGlobally);
+
+ setFile("gradlew").toContent("");
+ setFile(".git/config").toContent("");
+
+ // when
+ gradle.install();
+
+ // then
+ assertThat(logs).hasSize(3);
+ assertThat(logs).element(0).isEqualTo("Installing git pre-push hook");
+ assertThat(logs).element(1).isEqualTo("Git pre-push hook already installed, reinstalling it");
+ assertThat(logs).element(2).isEqualTo("Git pre-push hook installed successfully to the file " + hookFile.getAbsolutePath());
+
+ final var content = gradleHookContent("git_pre_hook/pre-push.existing-reinstalled-middle-tpl", ExecutorType.WRAPPER);
+ assertFile(".git/hooks/pre-push").hasContent(content);
+ }
+
+ @Test
+ public void should_reinstall_a_few_times_pre_hook_file_when_hook_already_installed_in_the_middle_of_file() throws Exception {
+ // given
+ final var gradle = new GitPrePushHookInstallerGradle(logger, rootFolder());
+ final var installedGlobally = gradleHookContent("git_pre_hook/pre-push.existing-installed-middle-tpl", ExecutorType.GLOBAL);
+ setFile(".git/hooks/pre-push").toContent(installedGlobally);
+
+ setFile("gradlew").toContent("");
+ setFile(".git/config").toContent("");
+
+ // when
+ gradle.install();
+ gradle.install();
+ gradle.install();
+
+ // then
+ final var content = gradleHookContent("git_pre_hook/pre-push.existing-reinstalled-middle-tpl", ExecutorType.WRAPPER);
+ assertFile(".git/hooks/pre-push").hasContent(content);
+ }
+
+ @Test
+ public void should_create_pre_hook_file_when_hook_file_does_not_exists() throws Exception {
+ // given
+ final var gradle = new GitPrePushHookInstallerGradle(logger, rootFolder());
+ setFile("gradlew").toContent("");
+ setFile(".git/config").toContent("");
+
+ // when
+ gradle.install();
+
+ // then
+ assertThat(logs).hasSize(3);
+ assertThat(logs).element(0).isEqualTo("Installing git pre-push hook");
+ assertThat(logs).element(1).isEqualTo("Git pre-push hook not found, creating it");
+ assertThat(logs).element(2).isEqualTo("Git pre-push hook installed successfully to the file " + newFile(".git/hooks/pre-push").getAbsolutePath());
+
+ final var content = gradleHookContent("git_pre_hook/pre-push.created-tpl", ExecutorType.WRAPPER);
+ assertFile(".git/hooks/pre-push").hasContent(content);
+ }
+
+ @Test
+ public void should_append_to_existing_pre_hook_file_when_hook_file_exists() throws Exception {
+ // given
+ final var gradle = new GitPrePushHookInstallerGradle(logger, rootFolder());
+ setFile("gradlew").toContent("");
+ setFile(".git/config").toContent("");
+ setFile(".git/hooks/pre-push").toResource("git_pre_hook/pre-push.existing");
+
+ // when
+ gradle.install();
+
+ // then
+ assertThat(logs).hasSize(2);
+ assertThat(logs).element(0).isEqualTo("Installing git pre-push hook");
+ assertThat(logs).element(1).isEqualTo("Git pre-push hook installed successfully to the file " + newFile(".git/hooks/pre-push").getAbsolutePath());
+
+ final var content = gradleHookContent("git_pre_hook/pre-push.existing-installed-end-tpl", ExecutorType.WRAPPER);
+ assertFile(".git/hooks/pre-push").hasContent(content);
+ }
+
+ @Test
+ public void should_create_pre_hook_file_for_maven_when_hook_file_does_not_exists() throws Exception {
+ // given
+ final var gradle = new GitPrePushHookInstallerMaven(logger, rootFolder());
+ setFile("mvnw").toContent("");
+ setFile(".git/config").toContent("");
+
+ // when
+ gradle.install();
+
+ // then
+ assertThat(logs).hasSize(3);
+ assertThat(logs).element(0).isEqualTo("Installing git pre-push hook");
+ assertThat(logs).element(1).isEqualTo("Git pre-push hook not found, creating it");
+ assertThat(logs).element(2).isEqualTo("Git pre-push hook installed successfully to the file " + newFile(".git/hooks/pre-push").getAbsolutePath());
+
+ final var content = mavenHookContent("git_pre_hook/pre-push.created-tpl", ExecutorType.WRAPPER);
+ assertFile(".git/hooks/pre-push").hasContent(content);
+ }
+
+ @Test
+ public void should_use_global_maven_when_maven_wrapper_is_not_installed() throws Exception {
+ // given
+ final var gradle = new GitPrePushHookInstallerMaven(logger, rootFolder());
+ setFile(".git/config").toContent("");
+
+ // when
+ gradle.install();
+
+ // then
+ assertThat(logs).hasSize(4);
+ assertThat(logs).element(0).isEqualTo("Installing git pre-push hook");
+ assertThat(logs).element(1).isEqualTo("Git pre-push hook not found, creating it");
+ assertThat(logs).element(2).isEqualTo("Maven wrapper is not installed, using global maven");
+ assertThat(logs).element(3).isEqualTo("Git pre-push hook installed successfully to the file " + newFile(".git/hooks/pre-push").getAbsolutePath());
+
+ final var content = mavenHookContent("git_pre_hook/pre-push.created-tpl", ExecutorType.GLOBAL);
+ assertFile(".git/hooks/pre-push").hasContent(content);
+ }
+
+ private String gradleHookContent(String resourcePath, ExecutorType executorType) {
+ return getTestResource(resourcePath)
+ .replace("${executor}", executorType == ExecutorType.WRAPPER ? newFile("gradlew").getAbsolutePath() : "gradle")
+ .replace("${checkCommand}", "spotlessCheck")
+ .replace("${applyCommand}", "spotlessApply");
+ }
+
+ private String mavenHookContent(String resourcePath, ExecutorType executorType) {
+ return getTestResource(resourcePath)
+ .replace("${executor}", executorType == ExecutorType.WRAPPER ? newFile("mvnw").getAbsolutePath() : "mvn")
+ .replace("${checkCommand}", "spotless:check")
+ .replace("${applyCommand}", "spotless:apply");
+ }
+
+ private enum ExecutorType {
+ WRAPPER, GLOBAL
+ }
+}