Python

Python Pre-commit Hooks: Otomatiskan Kode Berkualitas

Tutorial lengkap pre-commit hooks — linting, formatting, security checks, custom hooks, dan integrasi CI/CD

1. Pengenalan Pre-commit Hooks

Pre-commit hooks adalah script yang otomatis dijalankan sebelum kamu melakukan git commit. Tujuannya: mendeteksi dan memperbaiki masalah kode secara otomatis sebelum masuk ke repository. Ini menyelamatkan kamu dari code review yang memalukan karena typo, format yang berantakan, atau kode yang tidak aman.

Bayangkan kamu punya asisten pribadi yang selalu memeriksa setiap baris kode sebelum kamu commit — memastikan style konsisten, tidak ada bug sederhana, dan tidak ada secret yang bocor. Itulah pre-commit hooks!

Mengapa Pre-commit Hooks Penting?

Manfaat Penjelasan
Konsistensi kodeSeluruh tim menggunakan style yang sama
Detect bugs lebih awalError ditangkap sebelum masuk repository
KeamananMencegah commit password, API keys, atau secret
Code review lebih cepatTidak perlu review masalah formatting/style
OtomatisTidak perlu diingatkan, hook berjalan sendiri
Standar timSemua aturan terdefinisi dalam satu file
Diagram: Pre-commit Workflow
┌──────────┐    ┌──────────────────────────┐    ┌──────────────┐
│ Developer │    │   Pre-commit Hooks       │    │   Git        │
│ Tulis     │───→│                          │───→│   Repository │
│ Kode      │    │  ✅ Lint check           │    │              │
│           │    │  ✅ Format check         │    │   (hanya     │
│           │    │  ✅ Type check           │    │    kode      │
│           │    │  ✅ Security scan        │    │    bersih)   │
│           │    │  ✅ Test runners         │    │              │
└──────────┘    └──────────────────────────┘    └──────────────┘
                         │
                    ❌ Gagal → Commit diblokir, fix dulu
                    ✅ Bersih → Commit berhasil

2. Setup & Instalasi

Menggunakan library pre-commit yang populer dan well-maintained. Setup sangat cepat.

# Instalasi pre-commit
pip install pre-commit

# Verifikasi instalasi
pre-commit --version

# Setup di project kamu
cd my-python-project
git init

# Buat konfigurasi (akan dibahas di section berikut)
# Atau langsung install hooks
pre-commit install
# Struktur project setelah setup
my-python-project/
├── .git/
├── .pre-commit-config.yaml    ← Konfigurasi hooks
├── .pre-commit-hooks.yaml     ← (opsional) Custom hook definitions
├── pyproject.toml             ← Konfigurasi linter/formatter
├── src/
│   └── myproject/
│       └── __init__.py
└── tests/

Perintah Dasar pre-commit

Perintah Fungsi
pre-commit installInstall git hook di repo lokal
pre-commit run --all-filesJalankan hooks di semua file
pre-commit run <hook>Jalankan hook spesifik saja
pre-commit autoupdateUpdate versi hook ke terbaru
pre-commit cleanHapus cache environments
pre-commit uninstallHapus git hook
# Jalankan hooks di semua file (pertama kali bisa lambat karena install environment)
pre-commit run --all-files

# Output contoh:
# black....................................................Passed
# isort....................................................Passed
# flake8...................................................Failed
# - myproject/utils.py:42:89: E501 line too long (120 > 88)

# Fix semua file otomatis
pre-commit run --all-files  # hook yang auto-fix (black, isort) akan memperbaiki

# Jalankan hanya hook tertentu
pre-commit run black --all-files
pre-commit run flake8 --all-files

3. Konfigurasi .pre-commit-config.yaml

File .pre-commit-config.yaml adalah jantung dari setup pre-commit kamu. Di sini kamu mendefinisikan hooks mana yang akan dijalankan, versi apa, dan opsi apa yang digunakan.

# .pre-commit-config.yaml - Template Lengkap
repos:
  # ========== FORMATTING ==========
  - repo: https://github.com/psf/black
    rev: 24.4.2
    hooks:
      - id: black
        language_version: python3.11
        args: [--line-length=88]

  - repo: https://github.com/pycqa/isort
    rev: 5.13.2
    hooks:
      - id: isort
        args: [--profile=black]

  # ========== LINTING ==========
  - repo: https://github.com/pycqa/flake8
    rev: 7.0.0
    hooks:
      - id: flake8
        args: [--max-line-length=88, --extend-ignore=E203,W503]

  # ========== TYPE CHECKING ==========
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.10.0
    hooks:
      - id: mypy
        additional_dependencies: [types-requests]

  # ========== SECURITY ==========
  - repo: https://github.com/PyCQA/bandit
    rev: 1.7.8
    hooks:
      - id: bandit
        args: [-c, pyproject.toml]

  # ========== MISC ==========
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.5.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-json
      - id: check-added-large-files
        args: [--maxkb=500]
      - id: check-merge-conflict
      - id: debug-statements
      - id: check-docstring-first

Konfigurasi Per-File & Exclude

repos:
  - repo: https://github.com/psf/black
    rev: 24.4.2
    hooks:
      - id: black
        # Exclude file tertentu
        exclude: |
          (?x)(
            ^migrations/|
            ^generated/|
            \.pyi$
          )
        # Hanya jalankan di file Python tertentu
        types: [python]

  - repo: https://github.com/pycqa/flake8
    rev: 7.0.0
    hooks:
      - id: flake8
        # File yang di-exclude
        exclude: ^tests/fixtures/
        args: [--config=.flake8]
        # Tambahan dependency
        additional_dependencies: [flake8-bugbear, flake8-comprehensions]

4. Linting: flake8, ruff, pylint

Linting adalah proses analisis kode untuk menemukan error, style violations, dan code smells. Berikut tiga linter populer untuk Python.

Flake8: Classic Python Linter

# .flake8 atau di setup.cfg
[flake8]
max-line-length = 88
extend-ignore = E203, W503
per-file-ignores =
    __init__.py:F401
    tests/*:E501
max-complexity = 10
exclude =
    .git,
    __pycache__,
    build,
    dist,
    .venv,
    migrations
# .pre-commit-config.yaml - flake8 dengan plugins
- repo: https://github.com/pycqa/flake8
  rev: 7.0.0
  hooks:
    - id: flake8
      additional_dependencies:
        - flake8-bugbear      # Additional bug detection
        - flake8-comprehensions  # List/dict comprehension checks
        - flake8-docstrings   # Docstring conventions
        - flake8-typing-imports  # Import sorting for type hints

Ruff: Linter Super Cepat (Rekomendasi 2026)

# pyproject.toml - Ruff configuration
[tool.ruff]
target-version = "py311"
line-length = 88
select = [
    "E",    # pycodestyle errors
    "W",    # pycodestyle warnings
    "F",    # pyflakes
    "I",    # isort
    "N",    # pep8-naming
    "UP",   # pyupgrade
    "B",    # flake8-bugbear
    "S",    # flake8-bandit (security)
    "C4",   # flake8-comprehensions
    "SIM",  # flake8-simplify
    "TCH",  # flake8-type-checking
    "RUF",  # Ruff-specific rules
]
ignore = ["E501"]

[tool.ruff.per-file-ignores]
"tests/**" = ["S101"]   # Allow assert in tests
"__init__.py" = ["F401"] # Allow unused imports
# .pre-commit-config.yaml - Ruff
- repo: https://github.com/astral-sh/ruff-pre-commit
  rev: v0.4.4
  hooks:
    - id: ruff
      args: [--fix, --exit-non-zero-on-fix]
    - id: ruff-format  # Ruff juga bisa format!
💡 Ruff vs Flake8

Ruff ditulis dalam Rust dan 10-100x lebih cepat dari flake8. Di tahun 2026, Ruff menjadi pilihan utama karena bisa menggantikan flake8 + isort + black sekaligus. Sangat direkomendasikan untuk project baru!

Pylint: Deep Analysis

# Pylint lebih berat tapi lebih mendalam
- repo: https://github.com/pylint-dev/pylint
  rev: v3.1.0
  hooks:
    - id: pylint
      args: [--disable=C0114,C0115,C0116]  # Disable missing docstring
      additional_dependencies: [pylint-flask, pylint-django]

5. Formatting: black, isort, ruff format

Formatter otomatis merapikan kode kamu sehingga seluruh project menggunakan style yang konsisten. Tidak perlu lagi debat tentang indentasi, panjang baris, atau quote style!

Black: The Uncompromising Formatter

# SEBELUM black:
def contoh_fungsi(  nama, umur, alamat,kota   ):
    if nama and umur:
        return {"nama": nama, "umur": umur,
                "alamat": alamat,
                    "kota": kota}
    return    None

# SETELAH black:
def contoh_fungsi(nama, umur, alamat, kota):
    if nama and umur:
        return {
            "nama": nama,
            "umur": umur,
            "alamat": alamat,
            "kota": kota,
        }
    return None
# pyproject.toml - Black configuration
[tool.black]
line-length = 88
target-version = ["py311"]
include = '\.pyi?$'
extend-exclude = '''
/(
    \.eggs
  | \.git
  | \.venv
  | build
  | dist
  | migrations
)/
'''

isort: Import Sorting

# SEBELUM isort:
from myproject.utils import helper
import os
from datetime import datetime
import sys
from django.shortcuts import render
import json
from django.http import JsonResponse

# SETELAH isort:
import json
import os
import sys
from datetime import datetime

from django.http import JsonResponse
from django.shortcuts import render

from myproject.utils import helper
# pyproject.toml - isort configuration
[tool.isort]
profile = "black"
known_first_party = ["myproject"]
line_length = 88
sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"]
multi_line_output = 3
include_trailing_comma = true
force_grid_wrap = 0
use_parentheses = true
ensure_newline_before_comments = true
# .pre-commit-config.yaml - Combo formatting
- repo: https://github.com/psf/black
  rev: 24.4.2
  hooks:
    - id: black

- repo: https://github.com/pycqa/isort
  rev: 5.13.2
  hooks:
    - id: isort
        args: [--profile=black]

6. Security: bandit, safety

Bandit memindai kode Python untuk menemukan masalah keamanan yang umum — seperti penggunaan eval(), hardcoded password, insecure random, dan banyak lagi.

# Bandit akan mendeteksi masalah ini:

# B101: assert digunakan (berbahaya di production)
assert user.is_admin

# B301: pickle digunakan (bisa eksekusi arbitrary code)
import pickle
data = pickle.loads(user_input)  # ⚠️ Bahaya!

# B303: MD5 digunakan (tidak aman)
import hashlib
hash = hashlib.md5(password.encode())  # ⚠️ Gunakan bcrypt!

# B311: random digunakan untuk keamanan
import random
token = random.randint(1000, 9999)  # ⚠️ Gunakan secrets!

# B602: subprocess dengan shell=True
import subprocess
subprocess.call("ls -la", shell=True)  # ⚠️ Gunakan list!

# B105: hardcoded password
password = "admin123"  # ⚠️ Gunakan env vars!
# pyproject.toml - Bandit configuration
[tool.bandit]
exclude_dirs = ["tests"]
skips = ["B101"]  # Skip assert check

# Atau dengan file .bandit
[bandit]
exclude = ./tests
skips = B101
# .pre-commit-config.yaml - Security hooks
- repo: https://github.com/PyCQA/bandit
  rev: 1.7.8
  hooks:
    - id: bandit
      args: [-c, pyproject.toml]
      exclude: ^tests/

# Deteksi secret/credential yang tidak sengaja di-commit
- repo: https://github.com/gitleaks/gitleaks
  rev: v8.18.2
  hooks:
    - id: gitleaks
⚠️ Jangan Commit Secret!

Pre-commit hooks seperti gitleaks atau detect-secrets bisa mendeteksi API keys, password, dan token yang tidak sengaja masuk ke kode. Selalu gunakan .env file dan .gitignore untuk credentials.

7. Type Checking: mypy

mypy adalah static type checker untuk Python yang memverifikasi type annotations. Ini membantu menangkap bug yang berkaitan dengan tipe data sebelum runtime.

# Contoh kode yang akan di-check mypy

def hitung_luas(panjang: float, lebar: float) -> float:
    return panjang * lebar

# ✅ Benar
hasil: float = hitung_luas(5.0, 3.0)

# ❌ Error: Incompatible types
hasil: str = hitung_luas(5.0, 3.0)  # Error: float bukan str

# ❌ Error: Argument types
hitung_luas("5", 3)  # Error: str bukan float

from typing import Optional

def cari_user(id: int) -> Optional[dict]:
    if id > 0:
        return {"id": id, "nama": "Budi"}
    return None

# ❌ Tanpa check: crash saat akses .get()
user = cari_user(-1)
print(user.get("nama"))  # AttributeError!

# ✅ Dengan check:
user = cari_user(-1)
if user is not None:
    print(user.get("nama"))
# pyproject.toml - mypy configuration
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
show_error_codes = true

[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
# .pre-commit-config.yaml - mypy hook
- repo: https://github.com/pre-commit/mirrors-mypy
  rev: v1.10.0
  hooks:
    - id: mypy
      args: [--strict]
      additional_dependencies:
        - types-requests
        - types-PyYAML
        - types-redis

8. Custom Hooks

Selain menggunakan hook yang sudah ada, kamu bisa membuat custom hooks untuk aturan spesifik project kamu. Misalnya: memastikan semua file punya header, tidak ada debug print, atau format commit message tertentu.

# .pre-commit-config.yaml - Custom local hooks
repos:
  - repo: local
    hooks:
      # Hook 1: Cek tidak ada debug print
      - id: no-debug-print
        name: Check no debug print statements
        entry: '^\s*print\('
        language: pygrep
        types: [python]
        exclude: ^tests/

      # Hook 2: Pastikan semua fungsi punya docstring
      - id: check-docstrings
        name: Check function docstrings
        entry: python scripts/check_docstrings.py
        language: system
        types: [python]
        pass_filenames: true

      # Hook 3: Cek tidak ada TODO/FIXME di production
      - id: no-todo
        name: Check no TODO/FIXME
        entry: 'TODO|FIXME|HACK|XXX'
        language: pygrep
        types: [python]
        exclude: ^tests/

      # Hook 4: Validasi commit message
      - id: commit-msg-check
        name: Validate commit message
        entry: python scripts/validate_commit_msg.py
        language: system
        stages: [commit-msg]

      # Hook 5: Jalankan pytest sebelum commit
      - id: pytest-check
        name: Run tests
        entry: pytest tests/ -x -q
        language: system
        pass_filenames: false
        always_run: true

Custom Hook Script: check_docstrings.py

#!/usr/bin/env python3
"""Pre-commit hook: Pastikan semua fungsi publik punya docstring."""
import ast
import sys

def check_file(filepath: str) -> list[str]:
    errors = []
    with open(filepath) as f:
        tree = ast.parse(f.read(), filename=filepath)

    for node in ast.walk(tree):
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            # Skip private functions
            if node.name.startswith('_'):
                continue
            # Check docstring
            if not (node.body and isinstance(node.body[0], ast.Expr)
                    and isinstance(node.body[0].value, ast.Constant)
                    and isinstance(node.body[0].value.value, str)):
                errors.append(
                    f"{filepath}:{node.lineno} - "
                    f"Fungsi '{node.name}' tidak punya docstring"
                )
    return errors

if __name__ == "__main__":
    all_errors = []
    for filepath in sys.argv[1:]:
        all_errors.extend(check_file(filepath))

    if all_errors:
        for error in all_errors:
            print(f"  ❌ {error}")
        sys.exit(1)
    sys.exit(0)

Validate Commit Message Hook

#!/usr/bin/env python3
"""Validasi format commit message (Conventional Commits)."""
import re
import sys

# Format: type(scope): description
PATTERN = r'^(feat|fix|docs|style|refactor|test|chore|perf|ci)(\(.+\))?: .{1,72}$'

def validate_message(msg_file: str) -> bool:
    with open(msg_file) as f:
        first_line = f.readline().strip()

    if not re.match(PATTERN, first_line):
        print(f"❌ Commit message tidak valid!")
        print(f"   Format: type(scope): description")
        print(f"   Contoh: feat(auth): tambah login OAuth")
        print(f"   Tipe: feat|fix|docs|style|refactor|test|chore|perf|ci")
        print(f"   Message: '{first_line}'")
        return False
    return True

if __name__ == "__main__":
    if not validate_message(sys.argv[1]):
        sys.exit(1)
    print("✅ Commit message valid!")
    sys.exit(0)

9. Integrasi CI/CD

Pre-commit hooks harus juga dijalankan di CI/CD pipeline untuk memastikan semua developer menjalankannya. Berikut integrasi dengan GitHub Actions.

# .github/workflows/pre-commit.yml
name: Pre-commit Checks

on:
  pull_request:
  push:
    branches: [main, develop]

jobs:
  pre-commit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'

      - name: Cache pre-commit
        uses: actions/cache@v4
        with:
          path: ~/.cache/pre-commit
          key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}

      - name: Install pre-commit
        run: pip install pre-commit

      - name: Run pre-commit
        run: pre-commit run --all-files --show-diff-on-failure

GitHub Actions dengan Matrix Testing

# .github/workflows/ci.yml
name: CI Pipeline

on: [push, pull_request]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      - run: pip install pre-commit
      - run: pre-commit run --all-files

  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ['3.10', '3.11', '3.12']
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
      - run: pip install -e ".[dev]"
      - run: pytest --cov=src --cov-report=xml
      - uses: codecov/codecov-action@v4
        with:
          file: coverage.xml

Pre-commit.ci: Auto-Fix PRs

# Tambahkan di .pre-commit-config.yaml untuk auto-fix PR
ci:
  autoupdate_schedule: weekly
  autofix_commit_msg: 'style: auto-fix by pre-commit.ci'
  autoupdate_commit_msg: 'chore: update pre-commit hooks'
💡 Pre-commit.ci

Layanan pre-commit.ci bisa otomatis menjalankan hooks dan membuat PR perbaikan. Setup sangat mudah — cukup install app di GitHub repo kamu. Gratis untuk open source!

10. Best Practices & Troubleshooting

Kombinasi Hook yang Direkomendasikan

Kategori Hook Alternatif
Formatblack + isortruff format
Lintruffflake8 + plugins
Type Checkmypypyright
Securitybandit + gitleaksdetect-secrets
Misctrailing-whitespace, end-of-file-fixer

Troubleshooting Umum

# Masalah: Hook lambat saat pertama kali
# Solusi: Gunakan --install-hooks untuk pre-install
pre-commit install-hooks

# Masalah: Ingin skip hook untuk commit darurat
# Solusi: --no-verify (gunakan dengan bijak!)
git commit -m "hotfix" --no-verify

# Masalah: Hook environment corrupt
# Solusi: Clean dan reinstall
pre-commit clean
pre-commit install-hooks

# Masalah: Update semua hook ke versi terbaru
pre-commit autoupdate
git add .pre-commit-config.yaml
git commit -m "chore: update pre-commit hooks"

# Masalah: Hook error karena Python version
# Solusi: Tentukan language_version di config
# hooks:
#   - id: black
#     language_version: python3.11

# Masalah: Cek apakah hook ter-install
pre-commit --version
ls .git/hooks/pre-commit
⚠️ Jangan Gunakan --no-verify Kebiasaan!

--no-verify melewati semua hooks. Ini berguna untuk emergency, tapi jangan jadikan kebiasaan. Jika hook sering gagal, perbaiki konfigurasi atau kodenya, jangan skip hooknya.

11. Quiz Pemahaman

1. Kapan pre-commit hooks dijalankan?

2. Perintah apa untuk menjalankan hooks di semua file yang sudah ada?

3. Tool mana yang bisa menggantikan black + isort + flake8 sekaligus?

4. Apa fungsi dari gitleaks dalam pre-commit hooks?

5. Bagaimana cara melewati hooks untuk commit darurat?

🔍 Zoom
100%
🎨 Tema