Commit Message AI dan Otomatisasi Changelog: Why di Atas What

Foto oleh Raimond Spekking via Wikimedia Commons (CC BY-SA 4.0)
Bisa menulis yang akurat, tetapi jarang yang berguna. Diff hanya memuat apa yang berubah, dan git show sudah memberikan itu kepada pembaca dengan lebih presisi daripada ringkasan mana pun. Alasannya — perbaikan mana yang dipilih, apa yang digugurkan, apa yang rusak dan bagi siapa — tidak ada di dalam diff, jadi generator yang hanya membaca diff tidak punya cara menyediakannya.
Nama branch, issue yang membuka pekerjaan, deskripsi pull request, dan review thread. Keempatnya menyimpan penalaran yang tidak pernah sampai ke diff, terutama pendekatan yang ditolak. Rakit semuanya menjadi satu context file sebelum melakukan prompting, supaya Anda juga bisa membaca persis apa yang diterima model setiap kali hasilnya salah.
Karena tanda kutip ganda tidak melindunginya. POSIX menjaga dollar sign, backquote, dan backslash tetap khusus di dalam tanda kutip ganda, sehingga kata yang diapit backtick menjadi command substitution dan diganti oleh standard output perintah itu. Git tetap keluar dengan status 0, jadi message yang lebih pendek di-commit tanpa peringatan. Tulis message ke sebuah file memakai quoted heredoc lalu pakai git commit -F.
Hanya header-nya. Conventional Commits v1.0.0 mendefinisikan type, scope opsional di dalam tanda kurung, tanda seru penanda breaking change, dan format token footer, dan sebuah commit-msg hook bisa memeriksa semuanya dengan satu regular expression. Semua yang ada setelah titik dua tidak punya tata bahasa, jadi tidak ada validator yang bisa memberi tahu Anda apakah isinya benar.
Hanya sejauh kualitas commit subject Anda. Tool seperti git-cliff membaca git history dan mengelompokkan commit berdasarkan type conventional commit, tetapi tidak ada tahap di pipeline itu yang menulis ulang subject line. Baca rentang commit antara tag terakhir dan HEAD sebelum Anda menandai tag, karena kumpulan subject itulah yang akan diterbitkan.

Foto oleh Raimond Spekking via Wikimedia Commons (CC BY-SA 4.0)
Ringkasan Utama
Commit message AI yang dibuat hanya dari diff sekadar mengulang apa yang berubah, dan git show sudah membuktikannya. Alasan sebenarnya tersimpan di nama branch, issue, pull request, dan review thread, jadi masukkan semuanya sebagai input. Otomatiskan header Conventional Commits yang bisa diverifikasi script, dan tulis sendiri catatan breaking change dan revert.
Sebuah commit message hasil generate masuk ke history saya dengan bunyi: refactor: update component and translations. Isinya benar. Isinya juga tidak berguna, karena git show sudah memberi tahu file mana yang berubah dan baris mana yang bergeser. Yang tidak bisa dijawab tool mana pun enam minggu kemudian adalah kenapa file translations itu ikut berubah — bahwa file tersebut dimiliki sebuah merge script, dan editan manual saya diam-diam dikembalikan oleh run berikutnya.
Tulisan ini soal celah tempat commit message tadi berada. Saya sudah memasang commit-message generation di dua repository, mempertahankan bagian yang bisa diverifikasi script, membuang bagian yang dikarang model, dan menabrak satu bug shell yang diam-diam menghapus satu kata dari message lalu tetap di-commit git tanpa keluhan. Berikut yang bertahan, plus pipeline changelog yang menempel di atasnya.
Katakan terus terang: model yang hanya diberi git diff cuma bisa menghasilkan ringkasan dari input-nya sendiri. Semua isi ringkasan itu bisa didapat ulang pembaca lewat git show, bahkan lebih akurat, karena diff adalah kebenarannya dan ringkasan adalah parafrase yang kehilangan detail. Membayar token untuk memampatkan bukti yang sudah dipegang pembaca bukan otomatisasi. Itu pengulangan yang datang dengan masalah kepercayaan.
Nilai sebuah commit message justru ada pada bagian yang sama sekali tidak tersimpan di repository. Kenapa perubahan ini yang dipilih, bukan yang lebih kecil. Dari tiga perbaikan yang masuk akal, mana yang dipakai dan apa yang menggugurkan dua sisanya. Apakah sebuah conditional yang jelek itu workaround sementara untuk bug vendor atau keputusan desain permanen. Pembaca yang nanti menjalankan git blame pada baris itu mencari persis kalimat tersebut, dan diff tidak menyediakannya, jadi generator yang hanya membaca diff juga tidak.
Conventional Commits v1.0.0 membelah sebuah message menjadi bagian yang punya tata bahasa dan bagian yang tidak. Spesifikasinya mewajibkan type, memperbolehkan scope opsional di dalam tanda kurung, serta menetapkan feat untuk commit yang menambah fitur dan fix untuk commit yang memperbaiki bug, dan memetakan keduanya ke rilis MINOR dan PATCH menurut SemVer. Breaking change wajib ditandai dengan tanda seru tepat sebelum titik dua, atau dengan footer BREAKING CHANGE huruf kapital, dan itu memicu rilis MAJOR apa pun type-nya. Footer berupa token kata dengan pemisah titik dua dan spasi yang memakai tanda hubung sebagai pengganti spasi, seperti Acked-by.
#!/usr/bin/env bash
# .git/hooks/commit-msg — git passes the message file path as $1.
# This validates the HEADER only. Everything after the colon has no
# grammar, so there is nothing here that can judge whether it is TRUE.
set -euo pipefail
header=$(head -n 1 "$1")
# type(scope)!: description — the scope and the "!" are both optional per
# Conventional Commits v1.0.0, and the "!" must sit immediately before the
# colon. The 72-character ceiling is mine, not part of the spec.
pattern='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9./-]+\))?!?: .{1,72}$'
if [[ ! $header =~ $pattern ]]; then
echo "commit-msg: header does not match Conventional Commits v1.0.0" >&2
echo " got: $header" >&2
exit 1
fi
# The spec allows "!" with no footer. I do not: the marker announces that
# something breaks, and the sentence naming WHAT breaks is its whole value.
if [[ $header == *'!:'* ]] && ! grep -qE '^BREAKING[ -]CHANGE: ' "$1"; then
echo "commit-msg: '!' is set but no BREAKING CHANGE footer explains it" >&2
exit 1
fiItu sebuah kontrak, artinya hook bisa menegakkannya dan generator bisa diukur terhadapnya. Semua yang ada setelah titik dua — deskripsinya — tidak punya tata bahasa sama sekali, dan validasi schema sebanyak apa pun tidak memberi tahu Anda apakah isinya benar. Jadi saya hanya mengecek header, dan mengeceknya secara lokal, karena commit-msg hook yang menolak message sebelum masuk history jauh lebih murah daripada job CI yang menolaknya setelah branch di-push.
Arahkan git commit -t, atau config variable commit.template, ke sebuah file berisi pertanyaan yang memang ingin Anda jawab — kenapa sekarang, apa yang digugurkan, apa yang rusak — lalu berikan file yang sama ke generator. Prompt dan template yang tidak sejalan menghasilkan message yang lolos hook tetapi tidak menjawab apa pun.
Alasannya tidak hilang. Alasannya tertulis, hanya saja tidak di dalam diff. Pada perubahan normal, alasan itu sudah ada di empat tempat sebelum commit-nya ada: nama branch, issue yang membuka pekerjaan, deskripsi pull request, dan percakapan review yang mengubah pendekatan di tengah jalan. Generator yang menerima semua itu bisa menulis message yang layak dibaca. Generator yang hanya menerima git diff tidak bisa, dan prompt engineering secanggih apa pun tidak memperbaiki input yang tidak memuat jawabannya.
#!/usr/bin/env bash
# Assemble the why BEFORE prompting. The diff goes in last and only as file
# names — it is the one input the reader can already reproduce with git show.
set -euo pipefail
branch=$(git rev-parse --abbrev-ref HEAD)
issue=$(echo "$branch" | grep -oE '[0-9]+' | head -n 1 || true)
ctx=$(mktemp -t commit-ctx)
exec > "$ctx"
echo "## Branch"
echo "$branch"
echo
# The issue is the only input here written by the person who HAD the
# problem. Everything else in this file is already somebody's solution.
if [ -n "$issue" ]; then
echo "## Issue $issue"
gh issue view "$issue"
echo
fi
# The review thread holds the approach that was REJECTED. That sentence
# exists nowhere else in the repository, and it is the one a bisect wants.
echo "## Review thread"
gh pr view --comments || true
echo
echo "## Files touched"
git diff --cached --name-statusJadi pekerjaan yang menarik ada pada perakitan input, bukan pada prompt. Saya mengumpulkan konteksnya lebih dulu, menulisnya ke sebuah file, lalu menyerahkan file itu ke model — yang juga berarti saya bisa membaca persis apa yang diberikan setiap kali hasilnya salah. git-cliff sampai pada kesimpulan yang sama dari arah sebaliknya: ia mem-parsing remote metadata seperti judul pull request, nomor, dan author, karena commit subject saja ternyata tidak cukup untuk membangun changelog.

Yang satu ini membuat saya kehilangan satu message sungguhan. Saya menempelkan usulan commit ke git commit -m lengkap dengan tanda kutip ganda di sekelilingnya, lalu identifier yang dibungkus backtick lenyap. POSIX menjelaskan alasannya dengan gamblang: di dalam tanda kutip ganda, dollar sign, backquote, dan backslash tetap memegang makna khususnya, sehingga kata yang diapit backtick dianggap command substitution dan diganti oleh standard output perintah tersebut. Perintahnya tidak ada, output-nya kosong, dan katanya berubah menjadi tidak ada sama sekali.
Yang membuatnya berbahaya adalah git keluar dengan status 0. Shell menulis command not found ke stderr, git menerima string yang satu kata lebih pendek dari yang Anda setujui, dan commit-nya tetap masuk. Saya baru sadar karena subject yang tersimpan punya dua spasi berturut-turut di tempat identifier tadi. Ini kondisi sebelum dan sesudahnya persis dari terminal saya, beserta perbaikan yang sekarang saya pakai untuk setiap message lebih dari satu baris:
# Wrong: double quotes leave the backquoted word as command substitution.
$ git commit -m "fix(parser): handle empty `input` arrays"
zsh: command not found: input
$ git log -1 --pretty=%s
fix(parser): handle empty arrays
# Two spaces where the word used to be. git exited 0. Nothing warned me.
# Right: a quoted heredoc. Quoting the delimiter switches off every
# expansion inside it, so backticks, $VERSION and ${braces} all survive.
$ cat > /tmp/msg.txt <<'MSG'
fix(parser): keep `input` when the array is empty
parseArgs dropped the key entirely, so callers saw undefined instead of an
empty array. Rejected the ${DEFAULT} fallback because it hides a bad payload
rather than surfacing it.
Refs: #412
MSG
$ git commit -F /tmp/msg.txt
# -F is mutually exclusive with -m, which is exactly the point: the message
# is now a file that the shell never re-parsed.git commit -m tidak gagal ketika shell merusak message Anda. Git menerima string yang lebih pendek itu lalu menuliskannya, sehingga kehilangannya hanya terlihat di git log, biasanya berbulan-bulan kemudian, dan setelah di-push Anda tidak bisa memperbaikinya tanpa menulis ulang clone semua orang. Tanda kutip tunggal adalah perbaikan cepat, tetapi string berkutip tunggal tidak bisa memuat apostrof, dan kebanyakan kalimat Inggris memuatnya.
Changelog generator membaca tag dan commit subject. git-cliff membangun file changelog dengan menganalisis git history dan mengelompokkan commit berdasarkan type conventional commit — feat, fix, docs — dan pengelompokan itulah yang membuat hasilnya enak dibaca. Tidak ada satu tahap pun di pipeline yang memperbaiki subject line. Kalau separuh commit di antara dua tag berbunyi update component and translations, release notes-nya juga berbunyi begitu, hanya dengan font yang lebih rapi.
# The exact set of subjects that will become release notes. Read this
# BEFORE the tag — afterwards it is a published artefact.
git log "$(git describe --tags --abbrev=0)"..HEAD --pretty='%h %s'
# The subjects that will NOT group, because they carry no Conventional
# Commits type. Every line here lands in an "Other" bucket nobody opens.
git log "$(git describe --tags --abbrev=0)"..HEAD --pretty='%s' |
grep -vE '^(feat|fix|docs|perf|refactor|revert)(\(.+\))?!?: '
# Breaking changes read from the FOOTER rather than the subject: the "!"
# marker and the footer are separate signals, and only the footer explains.
git log "$(git describe --tags --abbrev=0)"..HEAD \
--grep='^BREAKING[ -]CHANGE:' --pretty='%h %s%n%b'Karena itu saya membaca input-nya sebelum menandai tag, bukan output-nya setelah itu. Rentang commit antara tag terakhir dan HEAD adalah kumpulan subject persis yang akan diterbitkan, dan memeriksanya butuh kurang dari satu menit — termasuk memeriksa yang tidak akan terkelompok, yang berakhir di bagian Other yang tidak dibuka siapa pun.
git-cliff adalah generator yang akhirnya saya pakai: ia membangun changelog dari git history, mengelompokkan berdasarkan type conventional commit tanpa konfigurasi, dan bisa menarik judul pull request, nomor, serta author dari hosting provider. Bagian terakhir itu satu-satunya tahap di pipeline yang menambahkan konteks yang tidak dimiliki commit subject.
Tiga jenis kalimat tetap ditulis manusia, dan kebetulan tiga itu pula yang paling mungkin diandalkan pembaca.
Pembagiannya bukan soal kemampuan, melainkan soal pertanggungjawaban. Header yang bisa diverifikasi hook aman untuk di-generate, karena yang salah tertangkap secara mekanis dalam hitungan detik. Klaim tentang niat tidak aman, karena satu-satunya pemeriksa yang tersedia adalah manusia yang sudah tahu niatnya — dan manusia yang membaca kalimat itu cukup teliti untuk menangkap yang salah sebenarnya bisa menulis yang benar dalam waktu yang sama.

Saya tetap men-generate header-nya. Itu kemenangan nyata meski membosankan — type yang konsisten, scope yang konsisten, subject yang muat, dan changelog yang terkelompok tanpa disunting tangan. Stack Overflow Developer Survey 2025 merangkum risikonya dengan tepat: 66 persen responden melaporkan solusi AI yang hampir benar tetapi tidak benar-benar tepat, dan 45 persen mengatakan debugging kode hasil AI justru makan waktu lebih lama. Commit message punya pola kegagalan yang sama tanpa umpan balik apa pun, karena tidak ada yang crash ketika message-nya salah.
Ketimpangan itulah yang layak jadi penutup. Kode yang salah gagal di test. Commit message yang salah lolos semua pemeriksaan yang Anda punya, mengendap di history, lalu dibaca bertahun-tahun kemudian oleh orang yang tidak punya cara tahu bahwa itu hasil generate. git log adalah bukti, dan bukti yang tidak bisa dipercaya bukanlah bukti — jadi subject yang singkat tapi jujur lebih berharga daripada yang lancar tapi karangan.
Aturan yang akhirnya saya pegang muat dalam satu kalimat: otomatiskan bagian commit message yang punya tata bahasa, dan tulis sendiri bagian yang punya alasan. Berikan nama branch, issue, dan review thread ke generator supaya alasannya punya sumber. Baca subject-nya sebelum Anda menandai tag, bukan changelog-nya setelah itu. Dan tulis sendiri catatan breaking change, penjelasan revert, dan catatan serah terima jam dua pagi, karena itulah kalimat yang akan diandalkan orang saat keadaan genting.
Sumber dan bacaan lanjutan