Konvensi pesan commit paling rapi pun hanya sekuat kebiasaan pengetiknya. Format Conventional Commits menuntut subjek berpola type(scope): deskripsi, tetapi Git sendiri tidak peduli — “update aja” tetap sah sebagai pesan. Hook commit-msg menutup celah itu: hook ini berjalan tepat setelah pesan ditulis dan sebelum commit lahir, membaca pesannya, dan menolak yang melanggar pola. Kita bangun validasinya lengkap — pola Conventional Commits plus batas panjang subjek — lalu mengujinya dengan pesan yang gagal dan pesan yang lolos.

Membaca Pesan dari Berkas $1 #

Mekanisme masukannya sudah diperkenalkan di Client-Side Hooks : Git menyimpan draf pesan ke berkas sementara — biasanya .git/COMMIT_EDITMSG — lalu mengoper jalurnya sebagai $1. Satu jebakan layak dibuktikan lebih dulu: saat commit ditulis lewat editor, berkas itu masih memuat baris komentar berawalan # (petunjuk template dan status), dan baris-baris itu baru dibuang Git setelah hook selesai. Hook yang membaca baris pertama mentah akan salah mengira komentar sebagai subjek. Karena itu skrip kita menyaring komentar lebih dulu.

Skrip Validasi Conventional Commits #

bash
mkdir latihan-commitmsg && cd latihan-commitmsg
git init -b main
git config user.name "Budi Santoso"
git config user.email "[email protected]"
cat > .git/hooks/commit-msg <<'HOOK'
#!/bin/sh
# commit-msg — validasi pesan Conventional Commits.
# $1 = berkas sementara (biasanya .git/COMMIT_EDITMSG) berisi
#      pesan calon commit; baris komentar berawalan # masih ada.

berkas_pesan="$1"
subjek="$(grep -v '^#' "$berkas_pesan" | head -n 1)"

pola='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9-]+\))?: .+'

if ! printf '%s\n' "$subjek" | grep -qE "$pola"; then
  {
    echo "commit-msg: subjek tidak mengikuti Conventional Commits:"
    echo "  '$subjek'"
    echo "format yang diterima: type(scope): deskripsi"
    echo "type salah satu: feat fix docs style refactor perf test build ci chore revert"
  } >&2
  exit 1
fi

if [ "${#subjek}" -gt 72 ]; then
  echo "commit-msg: subjek ${#subjek} karakter (maksimal 72): '$subjek'" >&2
  exit 1
fi
HOOK
chmod +x .git/hooks/commit-msg

Bedah baris-per-barisnya:

  1. berkas_pesan="$1" menyimpan jalur berkas pesan — satu-satunya argumen yang Git kirim.
  2. grep -v '^#' ... | head -n 1 mengambil baris pertama yang bukan komentar; itulah subjek pesan.
  3. pola='^(feat|fix|...)...' adalah regex Conventional Commits: prefiks type dari daftar resmi, scope opsional dalam kurung (huruf kecil, angka, tanda hubung), titik dua, spasi, lalu deskripsi. Daftar type ini persis tabel dari chapter Conventional Commits .
  4. printf '%s\n' "$subjek" | grep -qE "$pola" menguji subjek terhadap pola; -q membuat grep bisu karena kita hanya butuh exit code-nya.
  5. Blok { ... } >&2 mencetak penolakan beserta petunjuk perbaikan ke stderr — pesan kesalahan yang hanya berkata “salah” tanpa berkata “bagaimana benarnya” hanya melahirkan frustrasi.
  6. [ "${#subjek}" -gt 72 ] menghitung panjang subjek dan menjaga batas 72 karakter — angka yang sama dipakai alat commitlint di CI.

Uji: Pesan Ditolak, Pesan Lolos #

Pertama, pesan favorit semua orang yang sedang buru-buru:

bash
printf 'pencarian v1\n' > cari.js
git add cari.js
git commit -m "update aja"

Output yang Dihasilkan (perintah terakhir):

text
commit-msg: subjek tidak mengikuti Conventional Commits:
  'update aja'
format yang diterima: type(scope): deskripsi
type salah satu: feat fix docs style refactor perf test build ci chore revert

Commit batal, lengkap dengan alasan dan format yang benar — penulisnya tinggal mengetik ulang. Pesan yang benar lolos:

bash
git commit -m "feat: tambah pencarian"

Output yang Dihasilkan:

text
[main (root-commit) 26de84d] feat: tambah pencarian
 1 file changed, 1 insertion(+)
 create mode 100644 cari.js

Hash 26de84d berbeda di mesin pembaca. Varian dengan scope juga sah — dan sekaligus menguji regex bagian kurungnya:

bash
printf 'filter v1\n' > filter.js
git add filter.js
git commit -m "feat(filter): saring hasil"

Output yang Dihasilkan:

text
[main 8f1a950] feat(filter): saring hasil
 1 file changed, 1 insertion(+)
 create mode 100644 filter.js

Uji: Panjang Subjek #

Pemeriksaan kedua menjaga subjek tetap nyaman dibaca di git log --oneline. Subjek uji berikut 97 karakter:

bash
git commit -m "feat(pencarian): tambah filter kategori hasil pencarian berdasarkan nama produk dan harga terkini"

Output yang Dihasilkan:

text
commit-msg: subjek 97 karakter (maksimal 72): 'feat(pencarian): tambah filter kategori hasil pencarian berdasarkan nama produk dan harga terkini'

Hook melaporkan jumlah pastinya — memotong subjek jadi pekerjaan penulis, bukan tebak-tebakan.

Berdampingan dengan Kerangka Tim #

Hook ini paling kuat saat berpasangan dengan alat bantu dari chapter Commit Conventions : commit.template mengisi editor dengan kerangka type(scope): saat commit ditulis tanpa -m, dan hook menolak bila kerangka itu tidak diisi benar. Uji cepatnya memakai editor non-interaktif yang menyalin draf berisi komentar:

bash
printf '# <type>(<scope>): <deskripsi>\n# type wajib: feat fix docs ...\n' > pesan-commit.txt
git config commit.template pesan-commit.txt
printf 'draf\n' > draf.md
git add draf.md
printf 'feat(pencarian): saring draf\n\n# template terisi\n' > /tmp/pesan-editor.txt
GIT_EDITOR="cp /tmp/pesan-editor.txt" git commit

Output yang Dihasilkan (perintah terakhir):

text
[main e9e2699] feat(pencarian): saring draf
 1 file changed, 1 insertion(+)
 create mode 100644 draf.md

Subjek di baris pertama lolos validasi; baris # template terisi diabaikan skrip persis seperti Git mengabaikannya saat menyimpan pesan.

Seperti semua hook klien, validasi ini hanya berdiri di mesin yang memasangnya — lapisan penegakan terakhir untuk pesan commit adalah CI, yang memeriksa pesan pada pull request di server. Sebelum sampai ke sana, satu hook pasca masih tersisa untuk dibangun: yang berjalan setelah merge, saat repository berubah tanpa kita mengetik apa pun.