Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
212 changes: 212 additions & 0 deletions lib/src/main/java/com/diffplug/spotless/GitPrePushHookInstaller.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
/*
* 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_HEADLINE = "##### 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.
*
* <p>This method checks for the following:
* <ul>
* <li>Ensures Git is installed and the `.git/config` file exists.</li>
* <li>Checks if an executor required by the hook is available.</li>
* <li>Creates and writes the pre-push hook file if it does not exist.</li>
* <li>Skips installation if the hook is already installed.</li>
* </ul>
* 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.
*
* <p>This method:
* <ul>
* <li>Reads the entire content of the pre-push hook file</li>
* <li>Identifies the Spotless hook section using predefined markers</li>
* <li>Removes the Spotless hook content while preserving other hook content</li>
* <li>Writes the modified content back to the hook file</li>
* </ul>
*
* @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 var hookStart = hook.indexOf(HOOK_HEADLINE);
final var hookEnd = hook.indexOf(HOOK_FOOTER) + HOOK_FOOTER.length();

final var hookScript = hook.substring(hookStart, hookEnd);

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 = "\n";
spotlessHook += "\n" + HOOK_HEADLINE;
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_HEADLINE);
}

/**
* 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);
}
}
Original file line number Diff line number Diff line change
@@ -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(executor(), "spotlessCheck", "spotlessApply");
}

private String executor() {
if (gradlew.exists()) {
return gradlew.getAbsolutePath();
}

logger.info("Gradle wrapper is not installed, using global gradle");
return "gradle";
}
}
Original file line number Diff line number Diff line change
@@ -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(executor(), "spotless:check", "spotless:apply");
}

private String executor() {
if (mvnw.exists()) {
return mvnw.getAbsolutePath();
}

logger.info("Maven wrapper is not installed, using global maven");
return "mvn";
}
}
3 changes: 3 additions & 0 deletions plugin-gradle/CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
15 changes: 15 additions & 0 deletions plugin-gradle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading