Skip to content

Commit af40183

Browse files
authored
add git pre push hook (#2553)
2 parents cee7009 + 2dadeb6 commit af40183

File tree

21 files changed

+1274
-5
lines changed

21 files changed

+1274
-5
lines changed

CHANGES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (
1212
## [Unreleased]
1313
### Added
1414
* Allow specifying path to Biome JSON config file directly in `biome` step. Requires biome 2.x. ([#2548](https://github.com/diffplug/spotless/pull/2548))
15+
- `GitPrePushHookInstaller`, a reusable library component for installing a Git `pre-push` hook that runs formatter checks.
1516

1617
## Changed
1718
* Bump default `gson` version to latest `2.11.0` -> `2.13.1`. ([#2414](https://github.com/diffplug/spotless/pull/2414))
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
/*
2+
* Copyright 2025 DiffPlug
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.diffplug.spotless;
17+
18+
import static java.nio.charset.StandardCharsets.UTF_8;
19+
import static java.util.Objects.requireNonNull;
20+
21+
import java.io.File;
22+
import java.io.FileWriter;
23+
import java.io.IOException;
24+
import java.nio.file.Files;
25+
26+
/**
27+
* Abstract class responsible for installing a Git pre-push hook in a repository.
28+
* This class ensures that specific checks and logic are run before a push operation in Git.
29+
*
30+
* Subclasses should define specific behavior for hook installation by implementing the required abstract methods.
31+
*/
32+
public abstract class GitPrePushHookInstaller {
33+
34+
private static final String HOOK_HEADER = "##### SPOTLESS HOOK START #####";
35+
private static final String HOOK_FOOTER = "##### SPOTLESS HOOK END #####";
36+
37+
/**
38+
* Logger for recording informational and error messages during the installation process.
39+
*/
40+
protected final GitPreHookLogger logger;
41+
42+
/**
43+
* The root directory of the Git repository where the hook will be installed.
44+
*/
45+
protected final File root;
46+
47+
/**
48+
* Constructor to initialize the GitPrePushHookInstaller with a logger and repository root path.
49+
*
50+
* @param logger The logger for recording messages.
51+
* @param root The root directory of the Git repository.
52+
*/
53+
public GitPrePushHookInstaller(GitPreHookLogger logger, File root) {
54+
this.logger = requireNonNull(logger, "logger can not be null");
55+
this.root = requireNonNull(root, "root file can not be null");
56+
}
57+
58+
/**
59+
* Installs the Git pre-push hook into the repository.
60+
*
61+
* <p>This method checks for the following:
62+
* <ul>
63+
* <li>Ensures Git is installed and the `.git/config` file exists.</li>
64+
* <li>Checks if an executor required by the hook is available.</li>
65+
* <li>Creates and writes the pre-push hook file if it does not exist.</li>
66+
* <li>Skips installation if the hook is already installed.</li>
67+
* </ul>
68+
* If an issue occurs during installation, error messages are logged.
69+
*
70+
* @throws Exception if any error occurs during installation.
71+
*/
72+
public void install() throws Exception {
73+
logger.info("Installing git pre-push hook");
74+
75+
if (!isGitInstalled()) {
76+
logger.error("Git not found in root directory");
77+
return;
78+
}
79+
80+
var hookContent = "";
81+
final var gitHookFile = root.toPath().resolve(".git/hooks/pre-push").toFile();
82+
if (!gitHookFile.exists()) {
83+
logger.info("Git pre-push hook not found, creating it");
84+
if (!gitHookFile.getParentFile().exists() && !gitHookFile.getParentFile().mkdirs()) {
85+
logger.error("Failed to create pre-push hook directory");
86+
return;
87+
}
88+
89+
if (!gitHookFile.createNewFile()) {
90+
logger.error("Failed to create pre-push hook file");
91+
return;
92+
}
93+
94+
if (!gitHookFile.setExecutable(true, false)) {
95+
logger.error("Can not make file executable");
96+
return;
97+
}
98+
99+
hookContent += "#!/bin/sh\n";
100+
}
101+
102+
if (isGitHookInstalled(gitHookFile)) {
103+
logger.info("Git pre-push hook already installed, reinstalling it");
104+
uninstall(gitHookFile);
105+
}
106+
107+
hookContent += preHookContent();
108+
writeFile(gitHookFile, hookContent, true);
109+
110+
logger.info("Git pre-push hook installed successfully to the file %s", gitHookFile.getAbsolutePath());
111+
}
112+
113+
/**
114+
* Uninstalls the Spotless Git pre-push hook from the specified hook file by removing
115+
* the custom hook content between the defined hook markers.
116+
*
117+
* <p>This method:
118+
* <ul>
119+
* <li>Reads the entire content of the pre-push hook file</li>
120+
* <li>Identifies the Spotless hook section using predefined markers</li>
121+
* <li>Removes the Spotless hook content while preserving other hook content</li>
122+
* <li>Writes the modified content back to the hook file</li>
123+
* </ul>
124+
*
125+
* @param gitHookFile The Git pre-push hook file from which to remove the Spotless hook
126+
* @throws Exception if any error occurs during the uninstallation process,
127+
* such as file reading or writing errors
128+
*/
129+
private void uninstall(File gitHookFile) throws Exception {
130+
final var hook = Files.readString(gitHookFile.toPath(), UTF_8);
131+
final int hookStart = hook.indexOf(HOOK_HEADER);
132+
final int hookEnd = hook.indexOf(HOOK_FOOTER) + HOOK_FOOTER.length(); // hookEnd exclusive, so must be last symbol \n
133+
134+
/* Detailed explanation:
135+
* 1. hook.indexOf(HOOK_FOOTER) - finds the starting position of footer "##### SPOTLESS HOOK END #####"
136+
* 2. + HOOK_FOOTER.length() is needed because String.substring(startIndex, endIndex) treats endIndex as exclusive
137+
*
138+
* For example, if file content is:
139+
* #!/bin/sh
140+
* ##### SPOTLESS HOOK START #####
141+
* ... hook code ...
142+
* ##### SPOTLESS HOOK END #####
143+
* other content
144+
*
145+
* When we later use this in: hook.substring(hookStart, hookEnd)
146+
* - Since substring's endIndex is exclusive (it stops BEFORE that index)
147+
* - We need hookEnd to point to the position AFTER the last '#'
148+
* - This ensures the entire footer "##### SPOTLESS HOOK END #####" is included in the substring
149+
*
150+
* This exclusive behavior is why in the subsequent code:
151+
* if (hook.charAt(hookEnd) == '\n') {
152+
* hookScript += "\n";
153+
* }
154+
*
155+
* We can directly use hookEnd to check the next character after the footer
156+
* - Since hookEnd is already pointing to the position AFTER the footer
157+
* - No need for hookEnd + 1 in charAt()
158+
* - This makes the code more consistent with the substring's exclusive nature
159+
*/
160+
161+
var hookScript = hook.substring(hookStart, hookEnd);
162+
if (hookStart >= 1 && hook.charAt(hookStart - 1) == '\n') {
163+
hookScript = "\n" + hookScript;
164+
}
165+
166+
if (hookStart >= 2 && hook.charAt(hookStart - 2) == '\n') {
167+
hookScript = "\n" + hookScript;
168+
}
169+
170+
if (hook.charAt(hookEnd) == '\n') {
171+
hookScript += "\n";
172+
}
173+
174+
final var uninstalledHook = hook.replace(hookScript, "");
175+
176+
writeFile(gitHookFile, uninstalledHook, false);
177+
}
178+
179+
/**
180+
* Provides the content of the hook that should be inserted into the pre-push script.
181+
*
182+
* @return A string representing the content to include in the pre-push script.
183+
*/
184+
protected abstract String preHookContent();
185+
186+
/**
187+
* Generates a pre-push template script that defines the commands to check and apply changes
188+
* using an executor and Spotless.
189+
*
190+
* @param executor The tool to execute the check and apply commands.
191+
* @param commandCheck The command to check for issues.
192+
* @param commandApply The command to apply corrections.
193+
* @return A string template representing the Spotless Git pre-push hook content.
194+
*/
195+
protected String preHookTemplate(String executor, String commandCheck, String commandApply) {
196+
var spotlessHook = "";
197+
198+
spotlessHook += "\n";
199+
spotlessHook += "\n" + HOOK_HEADER;
200+
spotlessHook += "\nSPOTLESS_EXECUTOR=" + executor;
201+
spotlessHook += "\nif ! $SPOTLESS_EXECUTOR " + commandCheck + " ; then";
202+
spotlessHook += "\n echo 1>&2 \"spotless found problems, running " + commandApply + "; commit the result and re-push\"";
203+
spotlessHook += "\n $SPOTLESS_EXECUTOR " + commandApply;
204+
spotlessHook += "\n exit 1";
205+
spotlessHook += "\nfi";
206+
spotlessHook += "\n" + HOOK_FOOTER;
207+
spotlessHook += "\n";
208+
209+
return spotlessHook;
210+
}
211+
212+
/**
213+
* Checks if Git is installed by validating the existence of `.git/config` in the repository root.
214+
*
215+
* @return {@code true} if Git is installed, {@code false} otherwise.
216+
*/
217+
private boolean isGitInstalled() {
218+
return root.toPath().resolve(".git/config").toFile().exists();
219+
}
220+
221+
/**
222+
* Verifies if the pre-push hook file already contains the custom Spotless hook content.
223+
*
224+
* @param gitHookFile The file representing the Git hook.
225+
* @return {@code true} if the hook is already installed, {@code false} otherwise.
226+
* @throws Exception if an error occurs when reading the file.
227+
*/
228+
private boolean isGitHookInstalled(File gitHookFile) throws Exception {
229+
final var hook = Files.readString(gitHookFile.toPath(), UTF_8);
230+
return hook.contains(HOOK_HEADER) && hook.contains(HOOK_FOOTER);
231+
}
232+
233+
/**
234+
* Writes the specified content into a file.
235+
*
236+
* @param file The file to which the content should be written.
237+
* @param content The content to write into the file.
238+
* @throws IOException if an error occurs while writing to the file.
239+
*/
240+
private void writeFile(File file, String content, boolean append) throws IOException {
241+
try (final var writer = new FileWriter(file, UTF_8, append)) {
242+
writer.write(content);
243+
}
244+
}
245+
246+
public interface GitPreHookLogger {
247+
void info(String format, Object... arguments);
248+
249+
void warn(String format, Object... arguments);
250+
251+
void error(String format, Object... arguments);
252+
}
253+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
* Copyright 2025 DiffPlug
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.diffplug.spotless;
17+
18+
import java.io.File;
19+
20+
/**
21+
* Implementation of {@link GitPrePushHookInstaller} specifically for Gradle-based projects.
22+
* This class installs a Git pre-push hook that uses Gradle's `gradlew` executable to check and apply Spotless formatting.
23+
*/
24+
public class GitPrePushHookInstallerGradle extends GitPrePushHookInstaller {
25+
26+
/**
27+
* The Gradle wrapper file (`gradlew`) located in the root directory of the project.
28+
*/
29+
private final File gradlew;
30+
31+
public GitPrePushHookInstallerGradle(GitPreHookLogger logger, File root) {
32+
super(logger, root);
33+
this.gradlew = root.toPath().resolve("gradlew").toFile();
34+
}
35+
36+
/**
37+
* {@inheritDoc}
38+
*/
39+
@Override
40+
protected String preHookContent() {
41+
return preHookTemplate(executorPath(), "spotlessCheck", "spotlessApply");
42+
}
43+
44+
private String executorPath() {
45+
if (gradlew.exists()) {
46+
return gradlew.getAbsolutePath();
47+
}
48+
49+
logger.info("Gradle wrapper is not installed, using global gradle");
50+
return "gradle";
51+
}
52+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* Copyright 2025 DiffPlug
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.diffplug.spotless;
17+
18+
import java.io.File;
19+
20+
/**
21+
* Implementation of {@link GitPrePushHookInstaller} specifically for Maven-based projects.
22+
* This class installs a Git pre-push hook that uses Maven to check and apply Spotless formatting.
23+
*/
24+
public class GitPrePushHookInstallerMaven extends GitPrePushHookInstaller {
25+
26+
private final File mvnw;
27+
28+
public GitPrePushHookInstallerMaven(GitPreHookLogger logger, File root) {
29+
super(logger, root);
30+
this.mvnw = root.toPath().resolve("mvnw").toFile();
31+
}
32+
33+
/**
34+
* {@inheritDoc}
35+
*/
36+
@Override
37+
protected String preHookContent() {
38+
return preHookTemplate(executorPath(), "spotless:check", "spotless:apply");
39+
}
40+
41+
private String executorPath() {
42+
if (mvnw.exists()) {
43+
return mvnw.getAbsolutePath();
44+
}
45+
46+
logger.info("Maven wrapper is not installed, using global maven");
47+
return "mvn";
48+
}
49+
}

plugin-gradle/CHANGES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (
55
## [Unreleased]
66
### Added
77
* Allow specifying path to Biome JSON config file directly in `biome` step. Requires biome 2.x. ([#2548](https://github.com/diffplug/spotless/pull/2548))
8+
- `spotlessInstallGitPrePushHook` task, which installs a Git `pre-push` hook to run `spotlessCheck` and `spotlessApply`.
9+
Uses shared implementation from `GitPrePushHookInstaller`.
10+
[#2553](https://github.com/diffplug/spotless/pull/2553)
811

912
## Changed
1013
* Bump default `gson` version to latest `2.11.0` -> `2.13.1`. ([#2414](https://github.com/diffplug/spotless/pull/2414))

0 commit comments

Comments
 (0)