Viết commit message tốt trong Git
1. Tổng quan
Commit message là thứ bạn đọc lại nhiều nhất khi debug, review code hay viết changelog — nhiều hơn cả code bạn vừa viết. Bài này giúp bạn nắm cấu trúc commit message chuẩn, áp dụng Conventional Commits, và tự động kiểm tra message bằng commitlint + husky. Đọc xong, bạn viết commit rõ ràng, nhất quán, và team có thể generate changelog tự động từ lịch sử git.
2. Vì sao commit message quan trọng
Một commit message tốt trả lời được 3 câu hỏi: thay đổi gì, tại sao thay đổi, và ảnh hưởng gì. Thiếu nó, bạn phải git show từng commit để đoán ý đồ tác giả — tốn thời gian gấp nhiều lần so với việc viết message tử tế ngay từ đầu.
So sánh hai message cho cùng một thay đổi:
# Tệ
fix bug
# Tốt
fix(payment): xử lý null pointer khi PaymentService.charge() nhận Order chưa có Customer
Message tệ không cho biết bug gì, ở đâu. Message tốt cho biết module (payment), hành động (fix), và nguyên nhân cụ thể — đọc 5 giây là hiểu.
3. Cấu trúc commit message chuẩn
Git khuyến nghị cấu trúc 3 phần: subject (tiêu đề), body (nội dung), footer (chân trang).
<type>(<scope>): <subject>
<body>
<footer>
subject: tóm tắt ngắn, ≤ 50 ký tự, viết ở thì hiện tại (“add” không phải “added”).body: giải thích tại sao thay đổi, không phải cái gì (diff đã cho biết cái gì rồi).footer: metadata — breaking change, issue liên quan, co-author.
Ví dụ đầy đủ:
fix(order): tránh double-charge khi retry thanh toán thất bại
PaymentService.charge() không có idempotency key nên khi
network timeout rồi client retry, hệ thống gọi API gateway
hai lần cho cùng một Order.
Thêm idempotencyKey = order.getId() + "-" + attemptCount
vào request header để gateway tự dedupe.
Closes #482
3.1. Dòng subject
Subject là dòng quan trọng nhất — nó hiện trên git log --oneline, trên PR title, trên changelog. Quy tắc:
# Sai - viết hoa đầu, có dấu chấm cuối, dùng quá khứ
Fixed the bug in order service.
# Đúng - viết thường, không dấu chấm, động từ hiện tại
fix: order không trừ tồn kho khi thanh toán qua ví điện tử
Lưu ý: Giới hạn 50 ký tự không phải luật cứng, nhưng vượt 72 ký tự sẽ bị cắt trên nhiều terminal và GitHub UI. Cố gắng súc tích.
3.2. Phần body
Body dùng để giải thích ngữ cảnh — thứ mà code diff không nói được: vì sao chọn giải pháp này, giải pháp khác bị loại vì sao, có side effect gì không.
refactor(cart): chuyển CartService từ đồng bộ sang async
Trước đây CartService.checkout() gọi InventoryService và
ShippingService tuần tự, tổng latency ~800ms. Chuyển sang
CompletableFuture chạy song song hai call này, giảm latency
xuống ~450ms.
Không đổi public API, chỉ đổi implementation bên trong.
Ngăn cách subject và body bằng một dòng trống — git và các tool parse commit đều dựa vào quy ước này.
4. Conventional Commits
Conventional Commits là chuẩn phổ biến nhất hiện nay, định nghĩa type cố định để tool có thể tự động phân loại và sinh changelog.
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
Các type thường dùng:
| Type | Ý nghĩa |
|---|---|
feat |
thêm tính năng mới |
fix |
sửa bug |
docs |
chỉ thay đổi tài liệu |
style |
format code, không đổi logic |
refactor |
đổi cấu trúc code, không đổi hành vi |
test |
thêm/sửa test |
chore |
việc lặt vặt (build, dependency, config) |
perf |
cải thiện performance |
Ví dụ với scope là tên module:
feat(auth): hỗ trợ đăng nhập bằng OAuth2 Google
Thêm GoogleOAuth2Provider implement OAuth2LoginProvider.
Config thêm client-id, client-secret trong
application.properties.
4.1. Breaking change
Khi thay đổi phá vỡ backward compatibility, đánh dấu bằng ! sau type/scope hoặc footer BREAKING CHANGE:.
feat(api)!: đổi response của GET /orders/{id} thành OrderDto
BREAKING CHANGE: trường "customerName" đổi thành object
"customer" chứa id, name, email. Client cần cập nhật parser.
Semantic-release và các tool CI/CD dựa vào dòng BREAKING CHANGE: để tự động bump major version.
5. Tự động kiểm tra commit message
Quy ước tốt chỉ có ích nếu team tuân theo nhất quán. Dùng commitlint để chặn commit sai format ngay tại local, trước khi push.
<!-- Không áp dụng cho project Node, chỉ ví dụ dependency nếu bạn dùng Maven wrapper cho husky-git-hooks -->
Thực tế commitlint chạy trên Node, cài qua npm:
npm install --save-dev @commitlint/cli @commitlint/config-conventional husky
Tạo file config:
// commitlint.config.js
module.exports = { extends: ['@commitlint/config-conventional'] };
Gắn hook commit-msg qua husky:
npx husky init
echo 'npx --no -- commitlint --edit "$1"' > .husky/commit-msg
Giờ thử commit sai format:
git commit -m "fixed bug"
⧗ input: fixed bug
✖ subject may not be empty [subject-empty]
✖ type must be one of [feat, fix, docs, style, refactor, test, chore, perf]
✖ found 2 problems, 0 warnings
husky - commit-msg script failed (code 1)
Commit bị chặn ngay, buộc bạn sửa lại đúng format trước khi tạo commit.
5.1. Dùng với dự án Java/Spring Boot
Nếu project là Maven, bạn vẫn có thể dùng husky + commitlint song song (chúng chỉ cần Node runtime, không đụng build Java). Hoặc nếu muốn thuần Java, viết một script kiểm tra đơn giản và gọi qua core.hooksPath:
#!/bin/bash
# .githooks/commit-msg
PATTERN="^(feat|fix|docs|style|refactor|test|chore|perf)(\([a-z-]+\))?: .{1,50}"
MSG=$(cat "$1")
if ! [[ "$MSG" =~ $PATTERN ]]; then
echo "Commit message không đúng format Conventional Commits" >&2
exit 1
fi
git config core.hooksPath .githooks
chmod +x .githooks/commit-msg
5.2. Viết test cho script kiểm tra format
Nếu bạn maintain script kiểm tra riêng (không dùng commitlint), nên có test cho regex đó. Giả sử bạn đóng gói logic parse vào class Java để tái dùng trong CI:
public class CommitMessageValidator {
private static final Pattern PATTERN =
Pattern.compile("^(feat|fix|docs|style|refactor|test|chore|perf)" +
"(\\([a-z0-9-]+\\))?: .{1,50}");
public boolean isValid(String message) {
String subject = message.lines().findFirst().orElse("");
return PATTERN.matcher(subject).find();
}
}
class CommitMessageValidatorTest {
private final CommitMessageValidator validator = new CommitMessageValidator();
@Test
void shouldAcceptConventionalFormat() {
boolean result = validator.isValid("fix(order): tránh double-charge khi retry");
assertThat(result).isTrue();
}
@Test
void shouldRejectMessageWithoutType() {
boolean result = validator.isValid("fixed the bug");
assertThat(result).isFalse();
}
@Test
void shouldRejectUnknownType() {
boolean result = validator.isValid("update: thêm cái gì đó");
assertThat(result).isFalse();
}
}
Test này đảm bảo logic validate không bị regression khi bạn sửa regex sau này — ví dụ thêm type mới mà quên update test.
6. Lỗi thường gặp
Commit quá lớn, message chung chung. “update code” cho một commit sửa 15 file khác nhau là dấu hiệu bạn nên tách thành nhiều commit nhỏ, mỗi commit một mục đích rõ ràng (atomic commit).
Lẫn nhiều loại thay đổi trong một commit. Vừa fix bug vừa refactor không liên quan trong cùng commit khiến git revert và git bisect khó dùng — revert một fix có thể kéo theo mất cả phần refactor.
Viết body lặp lại subject. Body nên bổ sung ngữ cảnh, không phải diễn giải lại dòng đầu bằng câu dài hơn.
# Lặp lại, vô nghĩa
fix(order): sửa lỗi tính tổng tiền
Sửa lỗi tính tổng tiền trong OrderService.
Dùng emoji/ký tự trang trí thay cho nội dung. Emoji không sai nhưng nếu bạn dùng để thay thế mô tả thực sự, changelog tự động sẽ trống rỗng thông tin.
Lưu ý: Conventional Commits phù hợp với team có CI/CD tự động sinh changelog hoặc semantic versioning. Nếu team nhỏ, ít commit, áp quy tắc quá cứng có thể gây ma sát không cần thiết — ưu tiên sự rõ ràng hơn là tuân thủ format máy móc.
7. Kết luận
Commit message tốt không cần dài, chỉ cần đúng cấu trúc: subject ngắn gọn ở hiện tại, body giải thích lý do, footer ghi metadata cần thiết. Áp dụng Conventional Commits giúp team đọc lịch sử git nhanh hơn và cho phép tool tự động hóa changelog, versioning. Thiết lập commitlint hoặc git hook tùy chỉnh để biến quy ước thành ràng buộc tự động, tránh phụ thuộc vào ý thức từng người trong team.