Hướng dẫn cài đặt Pre-commit Framework: Tự động kiểm tra code style và lint trước mỗi commit

Git tutorial - IT technology blog
Git tutorial - IT technology blog

Bối cảnh: Tại sao team mình phải dùng Pre-commit

Hồi team mình còn 4–5 người, code review khá thoải mái — ai cũng quen nhau, biết nhau code kiểu gì. Nhưng khi lên 8 người với 3 senior, 2 mid-level và 3 junior mới vào, mọi thứ bắt đầu lộn xộn. PR nào cũng có comment kiểu “thiếu newline cuối file”, “indent tab thay vì space”, “import chưa sắp xếp”. Reviewer mất thời gian, người được review thì frustrated vì bị comment những thứ lẽ ra tool phải bắt tự động.

Mình quyết định áp dụng pre-commit framework sau khi thấy một repo open source dùng nó. Kết quả: số lượng comment style/format trong PR giảm xuống gần như về 0, reviewer tập trung được vào logic thay vì dấu phẩy thừa. Trong team 8 người, mình áp dụng pre-commit song song với Git flow đã dùng — và số merge conflict cũng giảm hẳn vì code nhất quán hơn, ít xung đột format hơn.

Pre-commit là framework quản lý Git hooks — cụ thể là hook pre-commit chạy ngay trước khi lệnh git commit tạo commit. Nếu có hook nào fail, commit bị chặn lại. Điểm khác biệt so với viết hook thủ công là pre-commit quản lý dependencies của từng hook riêng biệt, không đụng vào môi trường Python hay Node của project — mỗi hook chạy trong virtualenv cách ly của nó.

Cài đặt Pre-commit

Yêu cầu

Pre-commit yêu cầu Python 3.8+ và pip. Kiểm tra trước:

python --version   # Python 3.8.x trở lên
pip --version

Cài đặt global hoặc trong virtualenv

# Cài global
pip install pre-commit

# Hoặc với pipx (isolated, clean hơn — mình dùng cái này)
pipx install pre-commit

# Kiểm tra version
pre-commit --version
# pre-commit 3.7.x

Kích hoạt hooks cho repo

Sau khi cài xong, vào thư mục repo và chạy một lệnh duy nhất:

cd /path/to/your/repo
pre-commit install

Lệnh này tạo file .git/hooks/pre-commit — từ giờ mỗi lần git commit, pre-commit tự chạy. Nếu muốn chặn cả git push, thêm:

pre-commit install --hook-type pre-push

Cấu hình chi tiết với .pre-commit-config.yaml

Toàn bộ cấu hình nằm trong file .pre-commit-config.yaml ở root repo. File này commit vào git, nên mọi người trong team tự động dùng chung config — không cần mỗi người tự setup.

Cấu hình cơ bản cho project Python

# .pre-commit-config.yaml
repos:
  # Các hook cơ bản từ pre-commit-hooks
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: trailing-whitespace      # Xóa khoảng trắng thừa cuối dòng
      - id: end-of-file-fixer        # Đảm bảo file kết thúc bằng newline
      - id: check-yaml               # Kiểm tra cú pháp YAML
      - id: check-json               # Kiểm tra cú pháp JSON
      - id: check-merge-conflict     # Phát hiện conflict markers còn sót
      - id: check-added-large-files  # Chặn file quá lớn (tránh commit nhầm binary)
        args: ['--maxkb=500']
      - id: debug-statements         # Phát hiện print(), breakpoint() còn sót

  # Black — code formatter cho Python
  - repo: https://github.com/psf/black
    rev: 24.4.2
    hooks:
      - id: black
        language_version: python3

  # isort — sắp xếp import statements
  - repo: https://github.com/PyCQA/isort
    rev: 5.13.2
    hooks:
      - id: isort
        args: ["--profile", "black"]  # Compatible với Black

  # Flake8 — linter phát hiện lỗi logic và style
  - repo: https://github.com/PyCQA/flake8
    rev: 7.0.0
    hooks:
      - id: flake8
        args: ['--max-line-length=88']  # Match với Black default

Thêm hooks cho JavaScript/TypeScript

Project mix Python + JS (API backend + frontend) thì thêm vào phần repos:

  # Prettier — format JS/TS/CSS/JSON
  - repo: https://github.com/pre-commit/mirrors-prettier
    rev: v4.0.0-alpha.8
    hooks:
      - id: prettier
        types_or: [javascript, typescript, css, json]

  # ESLint
  - repo: https://github.com/pre-commit/mirrors-eslint
    rev: v9.4.0
    hooks:
      - id: eslint
        files: \.(js|ts|jsx|tsx)$
        types: [file]

Hook local — script tự viết

Đây là phần mình thấy mạnh nhất: viết hook riêng không cần publish lên đâu, chỉ cần để trong repo:

  # Hook local — chạy script trong repo
  - repo: local
    hooks:
      - id: no-leftover-todos
        name: Check for leftover TODO/FIXME in staged files
        entry: grep -n "TODO\|FIXME\|HACK"
        language: system
        types: [python]
        pass_filenames: true

      - id: run-unit-tests
        name: Run unit tests
        entry: python -m pytest tests/unit -q --tb=short
        language: system
        pass_filenames: false
        stages: [pre-push]  # Chỉ chạy khi push, không phải mỗi commit

Lưu ý stages: [pre-push] — test nặng để ở pre-push, lint nhẹ để ở pre-commit để không làm chậm workflow commit hằng ngày.

Tự động cập nhật version hooks

Sau khi viết config xong, chạy lệnh này để update rev của tất cả hooks lên bản mới nhất:

pre-commit autoupdate

Mình đặt lệnh này vào lịch chạy mỗi tháng một lần — tránh dùng hooks cũ có security issue.

Kiểm tra và Monitoring

Chạy thủ công trước khi commit

Muốn kiểm tra toàn bộ codebase mà không cần tạo commit giả:

# Chạy tất cả hooks trên tất cả file
pre-commit run --all-files

# Chạy chỉ một hook cụ thể
pre-commit run black --all-files
pre-commit run flake8 --all-files

# Chạy trên file cụ thể
pre-commit run --files src/main.py src/utils.py

Lần đầu chạy, pre-commit tải về và cài môi trường riêng cho từng hook — mất khoảng 1–2 phút. Từ lần sau, cache lại nên chạy ngay.

Đọc output khi hook fail

Đây là output thực tế khi commit bị chặn:

$ git commit -m "Add user authentication"
[INFO] Stashing unstaged changes to tracked files.
Trim Trailing Whitespace.................................................Passed
Fix End of Files.........................................................Passed
Check for merge conflicts................................................Passed
black....................................................................Failed
- hook id: black
- files were modified by this hook

reformatted src/auth.py

isort....................................................................Failed
- hook id: isort
- files were modified by this hook

Fixing src/auth.py

Black và isort tự động sửa file — chỉ cần git add src/auth.py rồi commit lại. Với flake8 thì khác: nó báo lỗi nhưng không tự sửa, phải sửa tay theo hướng dẫn.

Bỏ qua hooks khi thực sự cần

# Bỏ qua tất cả hooks (hotfix khẩn cấp)
git commit --no-verify -m "hotfix: production down"

# Bỏ qua hook cụ thể
SKIP=flake8 git commit -m "WIP: will fix lint later"

Trong team mình có quy định: ai dùng --no-verify phải ghi rõ lý do trong commit message và tạo ticket để fix trong vòng 1 ngày. Không phải rule nghiêm ngặt, nhưng đủ để mọi người có trách nhiệm.

Tích hợp với CI/CD để đảm bảo không ai bỏ qua

Local hooks chỉ chạy trên máy dev — người khác vẫn có thể push mà không cài pre-commit. Thêm vào GitHub Actions để CI chặn luôn:

# .github/workflows/lint.yml
name: Lint Check
on: [push, pull_request]

jobs:
  pre-commit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - uses: pre-commit/[email protected]  # Action chính thức từ pre-commit

Action này tái sử dụng cache của pre-commit, thường chỉ mất 30–60 giây trong CI — nhẹ hơn nhiều so với chạy full test suite.

Onboard member mới

Sau khi member mới clone repo, họ chỉ cần chạy 2 lệnh:

pip install pre-commit
pre-commit install

Mình có thêm 2 lệnh này vào Makefile target setup và README để không ai bị quên. Một tip nữa: chạy pre-commit run --all-files ngay khi onboard — để member mới thấy trạng thái hiện tại của codebase và fix hết legacy lint ngay từ đầu, tránh tích lũy thêm.

Sau tháng đầu áp dụng cho cả team 8 người, CI fail do style giảm từ ~40% xuống dưới 5%. Quan trọng hơn, code review bắt đầu tập trung vào architecture và logic — những thứ thực sự cần con người đọc, thay vì dấu phẩy thừa mà tool có thể xử lý trong vài giây.

Share: