Git Hooks: Cấu hình tự động hóa workflow để không còn commit code lỗi

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

Chuyện xảy ra khi không có Git hooks

Mình từng làm trong team 8 người, và cứ vài ngày lại có ai đó push code lên rồi CI pipeline đỏ lè — hoặc vì quên chạy linter, hoặc commit message kiểu fix, test, aaaaa. Mỗi sprint khoảng 3-4 lần như vậy. Review code mà phải comment “bạn ơi format lại đi” thì vừa mất thời gian, vừa tạo friction không đáng có.

Git hooks giải quyết đúng cái đó — tự động chặn những thứ sai trước khi chúng vào repo, không cần ai nhắc.

Git hooks là gì, nằm ở đâu?

Git hooks thực chất là script thông thường — bash, Python, Node.js, gì cũng được — mà Git gọi tự động vào những thời điểm nhất định. Trước khi commit, trước khi push, sau khi merge… mỗi sự kiện đều có hook tương ứng.

Mỗi Git repo đều có sẵn thư mục .git/hooks/, trong đó chứa các file mẫu .sample. Để kích hoạt hook, tạo file đúng tên (bỏ đuôi .sample) và cấp quyền thực thi:

ls .git/hooks/
# applypatch-msg.sample  commit-msg.sample  pre-commit.sample  pre-push.sample  ...

# Tạo hook mới
touch .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit

So sánh client-side vs server-side hooks

Hai nhóm này hoạt động ở tầng hoàn toàn khác nhau — nhầm chỗ thì setup xong vẫn không có tác dụng gì.

Client-side hooks — chạy trên máy dev

Chạy trên máy của từng developer, trước hoặc sau các thao tác local:

  • pre-commit: chạy trước khi tạo commit — dùng để lint, format, chạy unit test nhanh
  • commit-msg: chạy sau khi nhập commit message — dùng để validate format (Conventional Commits, Jira ticket ID, v.v.)
  • pre-push: chạy trước khi push — dùng để chạy test suite đầy đủ hơn
  • post-commit: chạy sau khi commit thành công — thường dùng để notify hoặc log

Nhược điểm cần biết trước: .git/hooks/ không được Git track, nên mỗi developer phải tự cài sau khi clone. Nếu không có script setup, người mới vào team sẽ bỏ qua bước này mà không hay.

Server-side hooks — chạy trên remote repo

Chạy trên server (GitHub Actions không phải là hook, nhưng GitLab/Gitea self-hosted hỗ trợ):

  • pre-receive: chạy trước khi server nhận push — có thể reject toàn bộ push
  • post-receive: chạy sau khi push thành công — thường dùng để deploy, notify
  • update: tương tự pre-receive nhưng chạy per-branch

Server-side hooks không ai bypass được — developer không dùng --no-verify để qua được. Đổi lại, cần quyền truy cập server. GitHub/GitLab.com không cho cài server hooks, nên phải dùng CI/CD thay thế.

Manual hooks vs Husky — Cái nào phù hợp hơn?

Câu hỏi mình hay nhận nhất từ junior dev khi setup lần đầu: “dùng cái nào?” Câu trả lời thực ra phụ thuộc vào tech stack hơn là sở thích cá nhân.

Manual hooks: đơn giản, không phụ thuộc

Viết thẳng script vào .git/hooks/, hoặc tạo thư mục hooks/ trong repo rồi có script setup.sh để symlink:

# hooks/setup.sh
#!/bin/bash
cp hooks/pre-commit .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
echo "Git hooks installed!"

Ưu điểm: không cần Node.js, không phụ thuộc thêm package nào, chạy được với mọi ngôn ngữ (Python, Bash, Ruby…).
Nhược điểm: phải nhớ chạy setup.sh sau khi clone, dễ bị quên.

Husky: tự động cài khi npm install

Husky là npm package, tích hợp hooks vào package.json lifecycle. Khi dev chạy npm install, hooks được cài tự động — không cần thêm bước nào.

npm install --save-dev husky
npx husky init

Ưu điểm: zero-config cho team, hooks được commit vào repo (.husky/), không ai bị thiếu.
Nhược điểm: cần Node.js và npm, thêm dependency vào project non-JS thì hơi overkill.

Kinh nghiệm của mình: project JavaScript/TypeScript thì dùng Husky, còn Python/Go/đa ngôn ngữ thì dùng manual hooks với script setup đặt trong Makefile hoặc README.

Triển khai thực tế: 3 hooks quan trọng nhất

1. pre-commit: chặn code lỗi trước khi commit

#!/bin/bash
# .git/hooks/pre-commit

echo "Running pre-commit checks..."

# Lấy danh sách file Python đang staged
STAGED_PY=$(git diff --cached --name-only --diff-filter=ACM | grep '\.py$')

if [ -n "$STAGED_PY" ]; then
  # Chạy flake8 lint
  echo "Linting Python files..."
  flake8 $STAGED_PY
  if [ $? -ne 0 ]; then
    echo "Linting failed. Fix errors before committing."
    exit 1
  fi

  # Chạy black format check
  black --check $STAGED_PY
  if [ $? -ne 0 ]; then
    echo "Format check failed. Run 'black .' to fix."
    exit 1
  fi
fi

echo "All checks passed!"
exit 0

Hook này chỉ kiểm tra file đang staged, không phải toàn bộ repo — nên thường xong trong 1-2 giây, không ảnh hưởng nhiều đến flow commit.

2. commit-msg: enforce format commit message

Conventional Commits (feat:, fix:, docs:…) giúp auto-generate changelog và dễ đọc history. Hook này validate pattern:

#!/bin/bash
# .git/hooks/commit-msg

MSG_FILE=$1
MSG=$(cat $MSG_FILE)

# Pattern: type(scope): description
# Ví dụ: feat(auth): add OAuth2 login
PATTERN='^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .{1,72}$'

if ! echo "$MSG" | grep -qE "$PATTERN"; then
  echo "ERROR: Commit message không đúng format!"
  echo "Expected: feat|fix|docs|style|refactor|test|chore(scope): description"
  echo "Example:  feat(auth): add OAuth2 login"
  echo ""
  echo "Your message: $MSG"
  exit 1
fi

exit 0

3. pre-push: chạy test trước khi push

#!/bin/bash
# .git/hooks/pre-push

BRANCH=$(git symbolic-ref HEAD | sed -e 's,.*/\(.*\),\1,')

# Chỉ chạy full test khi push lên main hoặc develop
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "develop" ]; then
  echo "Running test suite before push to $BRANCH..."
  python -m pytest tests/ -q --tb=short
  if [ $? -ne 0 ]; then
    echo "Tests failed. Push aborted."
    exit 1
  fi
fi

exit 0

Setup với Husky + lint-staged (cho project JS)

npm install --save-dev husky lint-staged
npx husky init

Thêm vào package.json:

{
  "lint-staged": {
    "*.{js,ts,tsx}": ["eslint --fix", "prettier --write"],
    "*.{css,md}": "prettier --write"
  },
  "scripts": {
    "prepare": "husky"
  }
}

File .husky/pre-commit:

#!/bin/sh
npx lint-staged

Tips để hooks không trở thành gánh nặng

  • Chỉ check file staged, không check toàn repo — dùng git diff --cached --name-only. Hook chạy quá 5 giây thì dev sẽ tìm cách bypass.
  • Để lối thoát khi cần gấp: git commit --no-verify -m "hotfix: emergency". Đôi khi cần push nhanh rồi fix sau — khóa cứng 100% thường phản tác dụng hơn là giúp ích.
  • Thông báo rõ ràng khi fail: đừng chỉ exit 1, hãy in ra lỗi cụ thể và câu lệnh để fix.
  • Commit hooks/ vào repo cùng script install-hooks.sh — để onboarding thành viên mới không phải hỏi thêm bước nào.

Sau khi mình áp dụng pre-commit lint và commit-msg validation cho team đó, số lượng comment “fix format/lint” trong PR giảm từ khoảng 4-5 comments/PR xuống gần bằng 0. Review tập trung hẳn vào logic, architecture. Merge conflict cũng ít hơn vì code đã được format nhất quán từ trước khi merge.

Git hooks không thay thế được CI/CD — integration test hay end-to-end vẫn cần chạy trên pipeline. Nhưng với những check đơn giản chạy xong trong vài giây, đặt ở client-side hook tiết kiệm thời gian hơn nhiều so với đợi pipeline 5-10 phút rồi mới biết lỗi.

Share: