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: + *

+ * 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: + *

+ * + * @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 + } +}