Cuando me pongo a jugar con un lenguaje… ya has visto que juego de verdad 😄.
Y una de las cosas que más me gusta hacer es traer ideas de otros ecosistemas e intentar adaptarlas para ver cómo encajan en el que estoy usando en ese momento.
Después de haber estado afinando mis dev containers para Spring Boot y Quarkus, esta vez me dio por investigar otra pieza importante del workflow de desarrollo: los pre-commit hooks.
En muchos proyectos es bastante habitual usar como herramienta pre-commit, el framework escrito en Python que permite ejecutar checks automáticos antes de hacer un commit. Funciona muy bien y es extremadamente popular… pero tiene un pequeño inconveniente en el contexto Java: introduce una dependencia externa que realmente no necesitamos. Y cuando estamos trabajando con Dev Containers, y por extensión con contenedores, cuanto más pequeña sea la imagen mejor que mejor. Si no sabes de qué va esto de los Dev Containers te dejo este vídeo que grabé hace tiempo para mi canal de YouTube:
Por lo que en este caso mi objetivo era claro 🎯: conseguir pre-commit hooks en proyectos Java sin obligar a nadie a instalar Python, o tener que incluirlo en la configuración del Dev Container, solo para ejecutar unas validaciones básicas de formato o estilo de código.
Pero no se trata solo de validar en local. Para mí es igual de importante que estas mismas reglas se ejecuten en el CI, de forma que todo el código que llegue al repositorio siga exactamente el mismo estilo y formato. Si el lint pasa en tu máquina pero no se valida en el pipeline, al final cada uno acaba formateando como quiere y las reglas se convierten en papel mojado.
Así que en este artículo te cuento cómo he configurado pre-commit hooks usando Spotless, Checkstyle y Gradle, de forma que podamos validar automáticamente el código antes de cada commit manteniéndonos completamente dentro del ecosistema Java.
📝 Modificar el archivo build.gradle
Lo primero que he necesitado hacer a sido modificar el archivo build.gradle que ya conoces, para poder instalar un par de plugins: checkstyle y com.diffplug.spotless:
plugins {
id 'java'
id 'checkstyle' // Plugin nativo de Gradle
id 'com.diffplug.spotless' version '7.0.2' // Formateo automático
}Después en este mismo archivo, necesitas añadir el siguiente bloque:
spotless {
java {
target 'src/*/java/**/*.java'
googleJavaFormat('1.25.2') // Formateador de Google
removeUnusedImports()
importOrder('java', 'javax', 'jakarta', '', 'com.tuempresa')
trimTrailingWhitespace()
endWithNewline()
}
format 'misc', {
target '*.md', '*.yml', '*.yaml', '*.properties', '*.gradle', '.gitignore'
trimTrailingWhitespace()
endWithNewline()
}
}Con este conseguimos:
- Un formato consistente usando Google Java Format
- Eliminación automática de imports no usados
- Un orden homogéneo de imports
- Archivos sin espacios sobrantes
- Aplicar las mismas reglas a ficheros no Java
Y ahora el bloque de Checkstyle:
checkstyle {
toolVersion = '10.21.4'
configFile = file("${rootDir}/config/checkstyle/checkstyle.xml")
ignoreFailures = false
maxWarnings = 0
}Esto fuerza a que:
- Las reglas vivan dentro del repositorio
- Se use una versión concreta de Checkstyle
- El build falle ante cualquier warning
💄 Configuración de Checkstyle (config/checkstyle)
Para Checkstyle he creado un directorio específico dentro del proyecto:
config/
└── checkstyle/
├── checkstyle.xml
└── suppressions.xmlAquí es donde definimos las reglas principales:
<?xml version="1.0"?>
<!DOCTYPE module PUBLIC
"-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
"https://checkstyle.org/dtds/configuration_1_3.dtd">
<!--
Checkstyle configuration based on Google Java Style Guide (simplified).
Full guide: https://google.github.io/styleguide/javaguide.html
Note: Formatting is handled by Spotless (google-java-format).
This config focuses on naming conventions, best practices, and code quality.
-->
<module name="Checker">
<property name="charset" value="UTF-8"/>
<property name="severity" value="error"/>
<property name="fileExtensions" value="java"/>
<!-- Suppressions for test files (allow snake_case method names) -->
<module name="SuppressionFilter">
<property name="file" value="${config_loc}/suppressions.xml"/>
</module>
<!-- Suppress checks on generated sources -->
<module name="BeforeExecutionExclusionFileFilter">
<property name="fileNamePattern" value=".*[\\/]build[\\/].*"/>
</module>
<!-- File-level checks -->
<module name="FileTabCharacter">
<property name="eachLine" value="true"/>
</module>
<module name="LineLength">
<property name="max" value="120"/>
<property name="ignorePattern" value="^package.*|^import.*|a]* href|href|http://|https://|ftp://"/>
</module>
<module name="TreeWalker">
<!-- ========== Naming Conventions ========== -->
<module name="PackageName">
<property name="format" value="^[a-z]+(\.[a-z][a-z0-9]*)*$"/>
</module>
<module name="TypeName">
<property name="format" value="^[A-Z][a-zA-Z0-9]*$"/>
</module>
<module name="MethodName">
<property name="format" value="^[a-z][a-zA-Z0-9]*$"/>
</module>
<module name="MemberName">
<property name="format" value="^[a-z][a-zA-Z0-9]*$"/>
</module>
<module name="ParameterName">
<property name="format" value="^[a-z][a-zA-Z0-9]*$"/>
</module>
<module name="LocalVariableName">
<property name="format" value="^[a-z][a-zA-Z0-9]*$"/>
</module>
<module name="ConstantName">
<property name="format" value="^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$"/>
</module>
<!-- ========== Imports ========== -->
<module name="AvoidStarImport"/>
<module name="RedundantImport"/>
<module name="UnusedImports"/>
<!-- ========== Code Quality ========== -->
<module name="EqualsHashCode"/>
<module name="SimplifyBooleanExpression"/>
<module name="SimplifyBooleanReturn"/>
<module name="StringLiteralEquality"/>
<module name="NestedTryDepth">
<property name="max" value="2"/>
</module>
<!-- ========== Best Practices ========== -->
<module name="OneStatementPerLine"/>
<module name="MultipleVariableDeclarations"/>
<module name="MissingSwitchDefault"/>
<module name="DefaultComesLast"/>
<module name="FallThrough"/>
<!-- ========== Modifiers ========== -->
<module name="ModifierOrder"/>
<module name="RedundantModifier"/>
<!-- ========== Annotations ========== -->
<module name="MissingOverride"/>
<!-- ========== Whitespace (complementary to Spotless) ========== -->
<module name="NoWhitespaceAfter">
<property name="tokens" value="AT,INC,DEC,UNARY_MINUS,UNARY_PLUS,BNOT,LNOT,DOT,ARRAY_DECLARATOR,INDEX_OP"/>
</module>
<module name="NoWhitespaceBefore"/>
</module>
</module>Está basado en la Google Java Style Guide, pero simplificado y con una idea clara:
Checkstyle valida, Spotless formatea. Y sí, es XML y parece sacado de los 90… pero es lo que hay 😄
El fichero incluye:
- Convenciones estrictas de nombres
- Control de imports
- Reglas de calidad de código
- Buenas prácticas
- Exclusión de código generado
Luego por otro lado tengo otro archivo llamado supressions.xml por si hay de alguna cosa de la que no quiero que se queje:
<?xml version="1.0"?>
<!DOCTYPE suppressions PUBLIC
"-//Checkstyle//DTD SuppressionFilter Configuration 1.2//EN"
"https://checkstyle.org/dtds/suppressions_1_2.dtd">
<!--
Checkstyle suppressions for test files.
Allows snake_case method names in tests (common pattern: methodName_shouldDoSomething).
-->
<suppressions>
<!-- Allow underscores in test method names (e.g., givenX_whenY_thenZ pattern) -->
<suppress checks="MethodName" files=".*Test\.java$"/>
<suppress checks="MethodName" files=".*IT\.java$"/>
</suppressions>
🎣 Script del pre-commit hook
Aquí es donde echo un poco de menos herramientas como pre-commit o Husky 🥲, porque en este caso toca crear el hook a mano.
El script vive en gradle/hooks/pre-commit:
#!/bin/bash
# =============================================================================
# Pre-commit hook for Sports Events Service
# Installed automatically by: ./gradlew installGitHooks
# =============================================================================
set -e
echo "🔍 Running pre-commit checks..."
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Get only staged Java files
STAGED_JAVA_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.java$' || true)
if [ -z "$STAGED_JAVA_FILES" ]; then
echo -e "${YELLOW}No Java files staged for commit. Skipping Java checks.${NC}"
else
# Check formatting with Spotless
echo "📝 Checking code formatting (Spotless)..."
if ! ./gradlew spotlessCheck --quiet 2>/dev/null; then
echo -e "${RED}❌ Code formatting issues found!${NC}"
echo -e "${YELLOW}Run './gradlew spotlessApply' to fix formatting automatically.${NC}"
exit 1
fi
echo -e "${GREEN}✓ Code formatting OK${NC}"
# Run Checkstyle
echo "🔍 Checking code style (Checkstyle)..."
if ! ./gradlew checkstyleMain checkstyleTest --quiet 2>/dev/null; then
echo -e "${RED}❌ Checkstyle violations found!${NC}"
echo "Check the report at: build/reports/checkstyle/"
exit 1
fi
echo -e "${GREEN}✓ Checkstyle OK${NC}"
fi
# =============================================================================
# Agentic Workflows Validation
# Compile .md workflows in .github/workflows/ to ensure they are valid
# =============================================================================
# Get staged workflow markdown files
STAGED_WORKFLOW_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '^\.github/workflows/.*\.md$' || true)
if [ -z "$STAGED_WORKFLOW_FILES" ]; then
echo -e "${YELLOW}No agentic workflow files staged. Skipping workflow validation.${NC}"
else
echo "🤖 Validating agentic workflows..."
# Check if gh aw is available
if ! command -v gh &> /dev/null || ! gh aw --help &> /dev/null; then
echo -e "${RED}❌ GitHub CLI with Agentic Workflows extension not found!${NC}"
echo -e "${YELLOW}Install it with: gh extension install github/gh-aw${NC}"
exit 1
fi
# Compile each staged workflow file
for workflow in $STAGED_WORKFLOW_FILES; do
echo " Compiling: $workflow"
if ! gh aw compile "$workflow" > /dev/null 2>&1; then
echo -e "${RED}❌ Workflow compilation failed: $workflow${NC}"
echo -e "${YELLOW}Run 'gh aw compile $workflow' to see errors.${NC}"
exit 1
fi
done
echo -e "${GREEN}✓ Agentic workflows OK${NC}"
fi
echo -e "${GREEN}✅ All pre-commit checks passed!${NC}"
exit 0
Y hace lo siguiente:
- Detecta si hay archivos Java en staging
- Ejecuta
spotlessCheck - Ejecuta
checkstyleMainycheckstyleTest - Bloquea el commit si algo falla
Además de todo esto estoy lanzando la herramienta gh aw para forzar la compilación de mis flujos agenticos de GitHub, que era uno de los motivos principales de querer todo esto 😇
🔄 Instalación automática del pre-commit hook
El último paso es instalar este script automáticamente usando Gradle:
// ============================================================================
// Git Hooks Installation
// ============================================================================
tasks.register('installGitHooks', Copy) {
description = 'Installs pre-commit hooks into .git/hooks'
group = 'git hooks'
from("${rootDir}/gradle/hooks/") {
include 'pre-commit'
}
into "${rootDir}/.git/hooks"
filePermissions {
user {
read = true
write = true
execute = true
}
group {
read = true
execute = true
}
other {
read = true
execute = true
}
}
}
// Auto-install hooks on first build
tasks.named('compileJava') {
dependsOn 'installGitHooks'
}¿Qué conseguimos con esto?
- El hook se copia automáticamente a
.git/hooks - Tiene permisos de ejecución (755)
- Se instala en cualquier build
- Los desarrolladores no tienen que hacer nada manual
- Al clonar y compilar, los hooks ya están activos
Instalación manual si hiciera falta:
./gradlew installGitHooks # Instalar/reinstalar hooks manualmente
y si quieres ver qué tareas tienes disponibles:
./gradlew tasks --group "git hooks" # Ver tasks de hooks¡Nos vemos 👋🏻!
