Skip to main content

Pre Commit

pre-commit

Documentación oficial

Github Repo

Pre-commit es una herramienta de control de calidad de código que permite ejecutar verificaciones automáticas antes de confirmar tus cambios en el sistema de control de versiones (como Git). La idea principal de pre-commit es que ejecuta una serie de "hooks" (ganchos) automáticamente cada vez que intentas hacer un commit, verificando si el código cumple con los estándares definidos. Si alguna verificación falla, el commit se bloquea hasta que se corrijan los problemas.

Principales características de pre-commit:

  • Se configura a través de un archivo YAML .pre-commit-config.yaml.
  • Permite definir múltiples hooks para diferentes tipos de verificación.
  • Puede usarse para verificar formato de código, linting, pruebas, vulnerabilidades y mucho más.
  • Funciona con diversos tipos de lenguajes de programación.
  • Es extensible con hooks personalizados.

Una vez configurado, pre-commit ejecuta automáticamente los hooks definidos siempre que intentas hacer un commit.

Es necesario que el archivo de configuración esté preparado para que pre-commit sepa qué hacer.

Instalación

Podemos instalar pre-commit de varias formas. En la documentación oficial se muestra cómo instalar con python pip, pero yo prefiero utilizar el gestor de paquetes del sistema operativo.

## linux
sudo apt-get install pre-commit
## mac
brew install pre-commit

pre-commit --version
pre-commit 4.2.0

Para habilitar pre-commit en el repositorio en el que estamos trabajando ejecutamos el comando install.

pre-commit install
pre-commit installed at .git/hooks/pre-commit

Este comando configura Git para ejecutar automáticamente los hooks de pre-commit siempre que intentes hacer un commit, pero ¿cómo sucede esto?

  1. Crea un hook Git: Crea (o sustituye) el archivo .git/hooks/pre-commit en tu repositorio local. Este es un script ejecutable que Git llamará automáticamente antes de finalizar cada commit.

  2. Configura el pipeline de verificación: El script instalado se configura para leer tu archivo .pre-commit-config.yaml y ejecutar todas las verificaciones definidas allí.

  3. Establece el flujo de trabajo: A partir de ese momento, siempre que ejecutes git commit, Git primero ejecutará el hook pre-commit, que a su vez ejecutará todas las verificaciones configuradas.

  4. Automatiza la verificación de calidad: Si alguna de las verificaciones falla, el commit será abortado, obligándote a corregir los problemas antes de poder commitear el código.

Este comando "conecta" la herramienta pre-commit al proceso de commit de Git, garantizando que tus verificaciones de calidad de código se ejecuten automáticamente, sin que tengas que acordarte de ejecutarlas manualmente en cada commit.

Para desinstalar solo ejecuta el comando pre-commit uninstall y el hook será removido del .git

Local vs Pipeline

Cuando ejecutas pre-commit install, la instalación es solo local y no se refleja en tu repositorio remoto. Esto ocurre porque:

  1. El comando pre-commit install modifica solo la carpeta .git/hooks/ de tu repositorio local.
  2. Por defecto, la carpeta .git/ y su contenido no se versionan - son ignorados por Git.
  3. Cada desarrollador necesita ejecutar pre-commit install en su propia copia local del repositorio.

Podemos versionar el archivo .pre-commit-config.yaml en tu repositorio, pero será necesario documentar en el README del proyecto que los desarrolladores necesitan ejecutar pre-commit install después de clonar el repositorio.

Puedes tener el archivo .pre-commit-config.yaml versionado en el repositorio y no utilizarlo en los pipelines, solamente para uso local lo que te posibilita hacer tus propias pruebas.

El gran problema de esto es que el colaborador PUEDE o NO seguir la documentación. Para evitar esto, coloca pre-commit en tus pipelines y muestra al colaborador que si no lo instala para garantizar el éxito del commit localmente tampoco pasará en el pipeline.

La ventaja del uso de pre-commit en todos los procesos, local y pipeline es:

  • Consistencia: Mantiene el código consistente en todo el proyecto
  • Automatización: Reduce el trabajo manual de verificación.
  • Educación: Enseña buenas prácticas al equipo.
  • Productividad: Identifica errores antes de enviarlos al repositorio remoto

Sobre los ítems anteriores veo una importancia mayor en el aspecto EDUCACIÓN. Forzar el uso garantiza que tu equipo trabaje de forma correcta y aprenda el método correcto de trabajo.

Primeros Hooks

Como vimos, los hooks que serán ejecutados se definen dentro del archivo .pre-commit-config.yaml.

Tenemos un modelo inicial para empezar con nuestros hooks.

pre-commit sample-config
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v3.2.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files

# Guardando el sample
pre-commit sample-config > .pre-commit-config.yaml

Los hooks listados en esta configuración (trailing-whitespace, end-of-file-fixer, check-yaml, y check-added-large-files) son todos parte del repositorio estándar de hooks de pre-commit https://github.com/pre-commit/pre-commit-hooks, y pre-commit los descargará y configurará automáticamente en la primera ejecución.

Una lista de algunos más relevantes del repositorio de pre-commit-hooks.

  • check-added-large-files: Previene que archivos grandes sean commiteados

      -   id: check-added-large-files
    args: ['--maxkb=1024'] #(default=500kB)
  • check-executables-have-shebangs: Verifica que los ejecutables no binarios tengan un shebang apropiado.

  • check-json: Intenta cargar todos los archivos json para verificar la sintaxis.

  • check-yaml: Intenta cargar todos los archivos yaml para verificar la sintaxis.

  • check-merge-conflict: Verifica si hay archivos que contengan secuencias de caracteres de conflicto de merge. --assume-in-merge- Permite ejecutar el gancho cuando no hay ninguna operación de merge en curso

  • check-symlinks: Verifica si hay enlaces simbólicos que no apuntan a nada, más conocidos como atajos.

  • detect-aws-credentials: Examina archivos en busca de patrones que correspondan a credenciales de AWS y compara con tus propias credenciales configuradas en AWS CLI generando fallo si encuentra coincidencias.

  • detect-private-key: Detecta si hay archivos de claves privadas en el commit.

  • double-quote-string-fixer: Reemplaza strings entre comillas dobles por strings entre comillas simples.

  • end-of-file-fixer: Garantiza que los archivos terminen solo con una nueva línea.

  • trailing-whitespace: Elimina espacios en blanco a la derecha. Para preservar saltos de línea duros de Markdown...

      -   id: trailing-whitespace
    args: ['--markdown-linebreak-ext=md']
  • no-commit-to-branch: Este hook impide commits directos en branches específicas, generalmente las principales de tu proyecto (como main o master). Esto ayuda a:

    1. Forzar el uso de Pull/Merge Requests para todos los cambios en las branches principales.
    2. Prevenir cambios accidentales en ambientes de producción.
    3. Garantizar que todos los cambios pasen por el proceso de revisión de código.
    4. Mantener la calidad e integridad de las branches protegidas.
    - id: no-commit-to-branch
    args: ['--branch', 'main', '--branch', 'develop', '--branch', 'release']

    Podemos incluso configurar para permitir solo commit en branches específicas. Óptimo para uso de gitflow.

    - id: no-commit-to-branch
    args: ['--pattern', '^(feature|bugfix|hotfix)/']

¿Y cómo quedaría entonces nuestro config inicial? Observa que el sample no trajo la versión más reciente del repositorio y ya la sustituimos por la más nueva.

## Aquí tenemos los items globales de los que hablaremos más tarde, repos es uno de ellos
#...
##
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0 # Usa la versión más reciente disponible
hooks:
- id: check-added-large-files
args: ['--maxkb=1024'] # Permite archivos hasta 1MB (por defecto es 500kB)
- id: check-executables-have-shebangs
- id: check-json
- id: check-yaml
- id: check-merge-conflict
args: ['--assume-in-merge'] # Permite ejecutar cuando no hay operación de merge
- id: check-symlinks
- id: detect-aws-credentials
args: ['--credentials-file=~/.aws/credentials', '--allow-missing-credentials']
- id: detect-private-key
- id: double-quote-string-fixer
- id: end-of-file-fixer
- id: trailing-whitespace
args: ['--markdown-linebreak-ext=md'] # Preserva saltos de línea duros en archivos Markdown
- id: no-commit-to-branch
args: ['--branch', 'main', '--branch', 'develop', '--branch', 'release']

¿Y dónde están esos hooks? ¿Referenciamos directamente lo que tenemos en el repositorio? ¿Quién ejecuta esos hooks?

Cuando utilizamos el repositorio solo estamos apuntando el proyecto que vamos a utilizar, pero en realidad ese repositorio es clonado a tu máquina localmente e irá a algún lugar que veremos más adelante.

Para probar sigue el flujo normalmente para hacer un commit en tu repositorio.

git add -A
git commit -m "test pre-commit"

## Y lo verás ejecutando... pero probablemente fallará.

Si falla, observa que corrigió algunas cosas y algunos archivos sufrieron modificaciones. Por eso es necesario que rehagas los comandos git add y git commit nuevamente para agregar las modificaciones que fueron hechas.

Ambiente

Pre-commit es un orquestador de hooks y nada más. Cuando apuntamos un repositorio ya posee todo lo necesario para ejecutar el hook. En realidad estamos apuntando el repositorio que clonará.

Cuando ejecutas un commit, este orquestador lee tu archivo .pre-commit-config.yaml. En la primera ejecución, descarga automáticamente los hooks especificados de sus repositorios remotos y crea ambientes aislados (generalmente virtualenvs) para cada hook descargado. Cuando es necesario, instala las dependencias del hook en ese ambiente aislado.

Al pasar esto repo: https://github.com/pre-commit/pre-commit-hooks pre-commit clonará este repositorio dentro de una carpeta de tu sistema.

# mac y linux
~/.cache/pre-commit

# windows
C:\Users\<tu-usuario>\AppData\Local\pre-commit

Como ya utilizo pre-commit en otros proyectos tengo más repositorios que solo pre-commit-hook.

~/.cache/pre-commit
❯ tree -L 1 .
.
├── README
├── db.db
├── patch1742487237-94180
├── patch1742489491-15045
├── patch1742578081-54469
├── pre-commit.log
├── repo8nab35wd
├── repo_bvmclu5
├── repolgpjw5vx
├── repomto3fe10
├── repon25rfbaz
└── repouta6jw6j

Una de esas carpetas es el clon del repo https://github.com/pre-commit/pre-commit-hooks (repouta6jw6j).

Ya vemos que en realidad el proyecto usa python para ejecutar los scripts de cada uno de los hooks. Otros proyectos pueden usar otros métodos.

 ~/.cache/pre-commit/repouta6jw6j
ls
CHANGELOG.md build py_env-python3.12 setup.cfg tests
LICENSE pre_commit_hooks py_env-python3.13 setup.py tox.ini
README.md pre_commit_hooks.egg-info requirements-dev.txt testing

Y podemos ver que tenemos la carpeta pre_commit_hooks con el script python que se ejecuta cuando pasamos el id.

ls pre_commit_hooks
__init__.py check_toml.py forbid_new_submodules.py
check_added_large_files.py check_vcs_permalinks.py mixed_line_ending.py
check_ast.py check_xml.py no_commit_to_branch.py
check_builtin_literals.py check_yaml.py pretty_format_json.py
check_byte_order_marker.py debug_statement_hook.py removed.py
check_case_conflict.py destroyed_symlinks.py requirements_txt_fixer.py
check_docstring_first.py detect_aws_credentials.py sort_simple_yaml.py
check_executables_have_shebangs.py detect_private_key.py string_fixer.py
check_json.py end_of_file_fixer.py tests_should_end_in_test.py
check_merge_conflict.py file_contents_sorter.py trailing_whitespace_fixer.py
check_shebang_scripts_are_executable.py fix_byte_order_marker.py util.py
check_symlinks.py fix_encoding_pragma.py

Lo que se ejecutó fue prácticamente esto para cada uno de los hooks

python /ruta/hacia/.cache/pre-commit/repoXYZ/script_del_hook.py [argumentos]

Si no tienes python ¿cómo se ejecutó? En realidad el ejecutable vino junto con el repositorio.

~/.cache/pre-commit/repouta6jw6j
ls py_env-python3.13/bin/python
py_env-python3.13/bin/python

Local Vs Repo

El parámetro repo no necesita apuntar a una url de repositorio pudiendo ser definido como local. En este caso vamos a pasar los parámetros que queramos para ello.

- repo: local
hooks:
- id: custom-script
name: Custom local script
entry: ./scripts/validate.sh
language: script
- id: trivy
name: Run trivy
entry: trivy config . --exit-code 1 --severity HIGH,CRITICAL --skip-dirs "tests" --timeout 5m
language: system
pass_filenames: false
verbose: true

Parámetros Globales

top-level-config

No pasamos ningún parámetro global, pero es posible. Estos bloques de configuración global definen valores por defecto que se aplican a todos los hooks en tu archivo .pre-commit-config.yaml, a menos que sean sobrescritos en las configuraciones específicas de cada hook. Abajo están los valores default que no necesitamos definir pero vale el conocimiento en caso de necesitarlo.

### BLOQUE GLOBAL ####
# Define qué tipos de hooks serán instalados por defecto cuando ejecutas pre-commit install. En este caso, solo hooks del tipo pre-commit serán instalados.
default_install_hook_types:
- "pre-commit"
# Define las versiones de lenguaje por defecto para los diferentes hooks. El objeto vacío significa que se usarán las versiones por defecto del sistema.
default_language_version: {}
# Define en qué etapas del git cada hook será ejecutado por defecto. Esta configuración incluye todas las etapas posibles que es el default. No quiere decir que correrá en todas las etapas, apenas en la que instalamos en este caso pre-commit. Hablaremos de esto a continuación.
default_stages:
- "commit-msg"
- "post-checkout"
- "post-commit"
- "post-merge"
- "post-rewrite"
- "pre-commit"
- "pre-merge-commit"
- "pre-push"
- "pre-rebase"
- "prepare-commit-msg"
# Define un patrón global de archivos que serán verificados. String vacía significa que no hay filtro global adicional.
files: ''
# Define un patrón regex para excluir archivos. ^$ es una regex que no corresponde a ningún archivo (corresponde solo a strings vacías).
exclude: ^$
# Si se define como true, pre-commit se detendrá en el primer fallo de hook. Como está false, todos los hooks serán ejecutados incluso si alguno falla.
fail_fast: false
# Versión mínima de pre-commit necesaria para ejecutar esta configuración.
minimum_pre_commit_version: '0'
####################
repos:
#... Continúa...

Generalmente vemos más el uso de pre-commit en la etapa pre commit, pero podríamos instalar hooks para varias etapas. En realidad el nombre pre-commit confunde un poco pensando que solo está limitado a su etapa.

Estas son las etapas posibles.

  • pre-commit: Antes de finalizar un mensaje de commit
  • commit-msg: Para validar el mensaje del commit
  • post-checkout: Después de un checkout
  • post-commit: Después de completar un commit
  • post-merge: Después de completar un merge
  • post-rewrite: Después de comandos que reescriben commits (como rebase)
  • pre-merge-commit: Antes de un merge commit
  • pre-push: Antes de un push
  • pre-rebase: Antes de un rebase
  • prepare-commit-msg: Preparando el mensaje de commit por defecto

Si fuéramos a instalar para más etapas sería este ejemplo:

# instalando hooks para pre-push y commit-msg
pre-commit install --hook-type pre-commit --hook-type pre-push --hook-type commit-msg

Si tuviéramos esto en .pre-commit-config.yaml solo el comando pre-commit install haría todo el trabajo.

default_install_hook_types:
- "pre-commit"
- "pre-push"
- "commit-msg"

Parámetros Principales de los Hooks

repos-config

Aquí están los principales parámetros disponibles para configurar hooks en pre-commit:

  • id: Identificador único del hook dentro del repositorio (o del local).
  • name (opcional): Nombre personalizado para el hook, que será mostrado durante la ejecución.
  • entry: Punto de entrada para el hook. Puede ser un script, módulo o comando.
  • language Lenguaje utilizado por el hook. Algunos valores posibles: python, node, ruby, rust, dotnet, perl conda, system (utiliza comandos del sistema).
  • files: Expresión regular que define qué archivos deben ser analizados por el hook.
  • exclude: Expresión regular que define qué archivos deben ser ignorados por el hook.
  • types, types_or y types_and: Filtra los archivos por tipo.
  • exclude_types: Tipos de archivos a ser excluidos.
  • args: Lista de argumentos a ser pasados al hook.
  • stages Define en qué etapas el hook debe ser ejecutado: commit, merge-commit, push, prepare-commit-msg, commit-msg, post-checkout, post-commit, post-merge, post-rewrite, manual (solo cuando se llama manualmente).
  • additional_dependencies Lista de dependencias adicionales necesarias para el hook.
  • always_run: Define si el hook debe ejecutarse incluso cuando no hay archivos correspondientes.
  • pass_filenames: Define si los nombres de los archivos deben ser pasados al hook.
  • fail_fast: Si true, falla en la primera ocurrencia de error.
  • verbose: Si true, produce salida más detallada.
  • require_serial Si true, ejecuta el hook de forma serial (no paralela).
  • description Descripción de lo que hace el hook.
  • minimum_pre_commit_version Versión mínima de pre-commit necesaria para ejecutar el hook.

No existe un valor default para los parámetros de los hooks. Estos parámetros pueden variar de acuerdo con cada hook en el repositorio. Cuando utilices un hook es necesario consultar la documentación. Generalmente, los hooks de pre-commit preservan los siguientes valores como default, sin especificarlos explícitamente en sus configuraciones:

        ## Los hooks normalmente especifican explícitamente solo:
- id:
name:
entry:
language:
files:
types:
################################################

##### Valores normalmente mantenidos por defecto ######
# Sin patrón de exclusión específico
exclude: ^$
types_or: []
types_and: []
exclude_types: []
# Frecuentemente dejado vacío o con descripción mínima
description: ''
# Sin argumentos adicionales por defecto
args: []
# Solo ejecutado en la fase pre-commit por defecto
stages: [pre-commit]
# Sin dependencias adicionales
additional_dependencies: []
# Ejecutan solo cuando archivos relevantes son modificados
always_run: false
# Reciben los nombres de los archivos modificados como argumentos
pass_filenames: true
# La mayoría de los hooks no interrumpen la ejecución después de un fallo
fail_fast: false
# No muestran información detallada por defecto
verbose: false
# Pueden ser ejecutados en paralelo con otros hooks
require_serial: false
#################################################

Solo un detalle...

#### ESTO ES VÁLIDO, PERO ES REDUNDANTE.
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0 # Usa la versión más reciente disponible
hooks:
- id: check-json
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0 # Usa la versión más reciente disponible
hooks:
- id: check-yaml

Sería lo mismo que escribir esto.

repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0 # Usa la versión más reciente disponible
hooks:
- id: check-json
- id: check-yaml

Si quieres validar el archivo de configuración.

# Forcé un error para verificar.
❯ pre-commit validate-config .pre-commit-config.yaml

==> File .pre-commit-config.yaml
==> At Config()
==> At key: repos
==> At Repository(repo='https://github.com/pre-commit/pre-commit-hooks')
=====> Missing required key: hooks

Evolucionando Más

Claro que los hooks que vamos a ejecutar dependen mucho del proyecto. Si estamos desarrollando un módulo terraform sería interesante ejecutar un terraform fmt, terraform validate, terraform test si tiene pruebas, checkov para verificación estática de seguridad, etc.

Vamos a hablar de esta configuración para ampliar nuestros horizontes. Esto podría ser una configuración para un proyecto terraform o módulo.

repos:
# Ya sabemos...
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: check-merge-conflict
- id: end-of-file-fixer
exclude: "README.md"
- id: trailing-whitespace
# Este repo posee varios hooks para terraform vamos a utilizar
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.96.1
hooks:
- id: terraform_fmt
files: ^[^/]+\.tf$
exclude: ^examples/
- id: terraform_tflint
exclude: ^examples/
args: # Varios argumentos pasando para el tflint que está en el repositorio
- '--args=--only=terraform_deprecated_interpolation'
- '--args=--only=terraform_deprecated_index'
- '--args=--only=terraform_unused_declarations'
- '--args=--only=terraform_comment_syntax'
- '--args=--only=terraform_documented_outputs'
- '--args=--only=terraform_documented_variables'
- '--args=--only=terraform_typed_variables'
- '--args=--only=terraform_module_pinned_source'
- '--args=--only=terraform_naming_convention'
- '--args=--only=terraform_required_version'
- '--args=--only=terraform_required_providers'
- '--args=--only=terraform_standard_module_structure'
- '--args=--only=terraform_workspace_remote'
- id: terraform_validate
exclude: ^examples/
# Pero el comando terraform test no fue implementado en este repositorio y vamos a resolverlo localmente
- repo: local
hooks:
- id: terraform-test
name: Run Terraform Test
entry: terraform test # El comando ejecutado será terraform test sin ningún argumento
language: system
pass_filenames: false
- id: checkov # Y también vamos a utilizar checkov para análisis estático del código
name: Run checkov
## el entry aquí fue pasado con los argumentos, podríamos haber separado <<<<<<
entry: checkov -d . --skip-path "examples" --skip-path "tests" --quiet
language: system
pass_filenames: false
verbose: true
# También vamos a generar la documentación de este proyecto terraform
- repo: https://github.com/terraform-docs/terraform-docs
rev: "v0.19.0"
hooks:
- id: terraform-docs-go
args: ["--output-mode", "replace", "--output-file", "README.md", "."]

El último paso fue innecesario pues el propio repo antonbabenko/pre-commit-terraform posee este hook.

Para que este pre-commit funcione necesitaremos tener checkov disponible en el path del sistema pues no existe el repositorio que está trayendo este binario para nosotros.

Otros Hooks

Si navegamos hasta https://pre-commit.com/hooks.html podemos encontrar más hooks que pueden formar parte de tu proyecto.

Varios comandos que podemos colocar en los hooks pueden ejecutarse localmente.

Hooks populares:

  • Formateadores de código: black, prettier, autopep8
  • Linters: flake8, eslint, pylint
  • Verificadores de seguridad: bandit, safety
  • Verificadores de estilo: isort, yapf
  • Verificadores de sintaxis: check-json, check-yaml

Comandos útiles

  • pre-commit run: Ejecuta los hooks manualmente
  • pre-commit run check-yaml --all-files: Ejecuta este hook para todos los archivos
  • pre-commit run --all-files: Ejecuta en todos los archivos
  • pre-commit autoupdate: Actualiza los hooks a las versiones más recientes

Pre-commit es una herramienta valiosa para equipos de desarrollo que desean mantener alta calidad de código y estandarización en sus proyectos.