git log main..develop melaporkan sepuluh commit yang belum masuk main, padahal isi kesepuluh commit itu sudah ada di main byte demi byte. GitHub terus menawarkan Compare & pull request, dan setiap merge sesudahnya memunculkan konflik di berkas yang tidak pernah disentuh dua kali oleh siapa pun.
Repo itu milik klien, dan git-nya saya yang pegang. Penyebabnya tercatat di berkas konvensi cabang untuk repo itu, dalam bagian bertanggal 31 Agustus 2026 yang judulnya lebih mirip pengakuan daripada aturan: --no-ff yang ditetapkan berkas itu diabaikan selama enam rilis, dan itu memakan waktu nyata. Sepanjang rentang itu, setiap merge ke main memakai git merge --squash. Isi produksinya tidak pernah salah. Yang rusak hanya ancestry, yaitu hubungan parent antar-commit yang memberi tahu git dari mana main diturunkan.
Dua cabang, dua tujuan deploy
Pembagian cabangnya berlaku sejak 5 Mei 2026. Sebelum itu, staging dan produksi sama-sama auto-deploy dari develop, dan sekitar pukul 16.00 hari itu sebuah push pemulihan berisi lima commit memicu kedua deploy sekaligus sampai CPU di platform hosting aplikasinya habis. Menurut penalaran kontak DevOps di tim klien, yang memasang sisi hosting-nya, deploy staging dan produksi bersamaan dari satu cabang menggandakan beban CPU sesaat dan ikut menyebabkan kegagalan health check hari itu, saat aplikasinya tidak bisa dimuat. Menurut penalaran yang sama, memisah cabang membuat waktu deploy berselang. Staging deploy ulang saat develop di-push, sedangkan produksi baru deploy ulang belakangan, hanya saat main diperbarui. Pukul 18.23 hari itu juga dia mengonfirmasi bahwa cabang produksi sudah dipindah ke main, dan merge ke main akan memicu deploy otomatis.
Sejak itu konvensinya sederhana. develop auto-deploy ke staging dan menjadi tempat kerja harian. main auto-deploy ke produksi dan hanya diperbarui lewat merge dari develop saat rilis siap, jadi tidak ada commit yang boleh dibuat langsung di main. Commit di-push ke dua remote, client dan origin, yang kedua sebagai mirror pribadi. Alur rilis ke produksi tertulis sebagai lima baris:
git checkout main
git merge develop --no-ff -m "merge: <release description>"
git push client main
git push origin main
git checkout develop--no-ff di baris kedua dipakai karena mempertahankan merge commit eksplisit di riwayat main, sehingga belakangan mudah mengenali kapan sebuah rilis tayang. Merge commit seperti itu punya dua parent, ujung main sebelumnya dan ujung develop yang di-merge. Parent kedua itulah yang memberi tahu git bahwa commit-commit develop sudah menjadi bagian dari main.
Konvensi yang dicek dari riwayat yang sudah salah
Memakai --squash di enam rilis itu bukan pilihan yang ditimbang. Catatan di berkas yang sama menyebutnya kegagalan membaca. Konvensinya memang sempat dicek, tapi bukan dengan membuka berkasnya. Yang dijalankan adalah perintah ini:
git log --format="%H %p" -3 main%H mencetak hash commit dan %p mencetak hash parent-nya, jadi perintah ini memperlihatkan berapa parent yang dimiliki tiga commit teratas di main. Merge commit dari --no-ff tampil dengan dua hash parent. Commit hasil squash tampil dengan satu, sama persis seperti commit biasa:
# bentuk keluarannya, disederhanakan dengan placeholder
<hash> <parent> satu parent: commit biasa, termasuk hasil squash
<hash> <parent-main> <parent-develop> dua parent: merge commit dari --no-ffTiga commit yang dikembalikannya ternyata sudah berupa squash dari rangkaian kerja yang sama. Riwayat yang saya jadikan rujukan adalah keluaran dari kesalahan yang sedang saya ulangi. Catatan itu menyebutnya bukti melingkar, padahal berkas konvensinya sudah menuliskan jawabannya secara gamblang.
Satu parent, dan develop tidak pernah tercatat sudah di-merge
git merge --squash develop menyusun isi develop di working tree dan index seolah-olah merge sungguhan terjadi, tapi tidak mencatat informasi merge-nya. Commit yang menyusul adalah commit biasa dengan satu parent, yaitu ujung main sebelumnya. Catatan saya merumuskan akibatnya dalam satu kalimat: squash hanya mencatat satu parent, jadi git tidak lagi melihat develop sebagai sudah di-merge.
Notasi main..develop berarti commit yang bisa dicapai dari develop tetapi tidak dari main. Tanpa parent kedua, commit develop sejak merge sungguhan terakhir tidak pernah menjadi leluhur main, berapa pun rilis yang sudah membawa isinya. Begitulah git log main..develop sampai melaporkan sepuluh commit tertunda yang isinya sudah ada di main byte demi byte. Di GitHub, gejalanya berupa tawaran Compare & pull request yang terus muncul.
Gejala ketiga ada di merge. Setiap merge sesudahnya memunculkan konflik di berkas yang tidak pernah disentuh dua kali oleh siapa pun. Secara umum, merge tiga arah dihitung dari merge base, titik bersama terakhir kedua cabang, dan tanpa parent kedua titik itu tidak pernah maju. Isi yang dibawa squash ke main tercatat sebagai commit lain yang tidak berhubungan dengan commit develop, jadi git membandingkan develop dengan salinan isinya sendiri, dihitung dari titik bersama yang makin tua. Konflik itu saya selesaikan dengan mengambil develop secara utuh, empat kali, sambil menganggap konflik yang terus berulang sebagai hal biasa untuk repo ini. Padahal konflik itu saya timbulkan sendiri.
Perbaikannya hanya mencatat parent yang hilang
Bentuk perbaikannya ditentukan oleh apa yang tidak rusak. Tree main cocok persis dengan tree develop sepanjang waktu, jadi produksi tidak pernah salah dan tidak ada yang hilang. Yang perlu diperbaiki hanya ancestry-nya. Tapi kesalahan --squash itu sudah sampai ke kedua remote, dan membatalkannya butuh perbaikan ancestry.
Resep yang saya catat untuk kondisi tree yang sudah sama adalah git merge -s ours develop. Perintah ini membiarkan tree main tidak tersentuh dan hanya mencatat parent kedua yang hilang. Strategi ours selalu memakai tree cabang yang sedang aktif dan mengabaikan semua perubahan dari cabang lain, jadi dia hanya aman kalau kedua tree memang sudah identik. Kalau masih ada perbedaan, strategi ini membuangnya tanpa konflik dan tanpa peringatan. Jangan tertukar juga dengan opsi -X ours, yang cuma memihak sisi sendiri di bagian yang konflik dan tetap menggabungkan sisanya.
Catatan yang sama memerintahkan verifikasinya: hash tree sebelum dan sesudah harus identik, dan main..develop harus menjadi 0. Dalam bentuk perintah, kira-kira seperti ini. Ini versi yang saya sederhanakan, bukan transkrip dari repo itu:
# disederhanakan, hanya untuk kondisi tree main dan develop yang sudah sama
git rev-parse 'main^{tree}' 'develop^{tree}' # dua hash ini harus identik
git checkout main
git merge -s ours develop
git rev-parse 'main^{tree}' # harus sama dengan hash main di atas
git rev-list --count main..develop # harus 0
git checkout developPerbaikan ini tidak menulis ulang riwayat. Dia menambah satu merge commit di atas main, jadi tidak ada commit yang sudah ada di remote yang perlu diganti.
Satu hal perlu saya tulis apa adanya. Catatan saya memuat resep dan langkah verifikasinya, tapi tidak memuat hasilnya. Tidak ada hash tree sebelum dan sesudah, tidak ada hitungan main..develop sesudah perbaikan, dan tidak ada hash commit perbaikannya. Yang bisa saya pastikan di sini hanya bahwa perbaikan ancestry itu dijalankan dan apa yang seharusnya diperiksa sesudahnya, bukan angka hasil pemeriksaan itu.
Baris kelima yang terlewat dua kali
Bagian berikutnya di berkas yang sama, juga bertanggal 31 Agustus, mencatat tiga pelanggaran lagi hari itu terhadap apa yang tertulis di atasnya. Semuanya terjadi di sesi kerja yang sama, sesudah koreksi --squash sudah tercatat di berkas itu. Dua butir pertamanya punya bentuk yang sama. Butir pertama adalah tiga commit yang dibuat langsung di main. Ketiganya ketahuan sebelum di-push, dipindah ke develop dengan git cherry-pick, lalu main di-reset dan di-merge dengan benar. Butir kedua adalah kejadian yang sama, terulang dua commit kemudian, dengan perbaikan dan penyebab yang sama.
Penyebab keduanya identik. Alur rilis tadi berakhir dengan git checkout develop, tapi yang dijalankan hanya empat baris pertama, dan merge meninggalkan posisi di main. Berkas konvensinya menegaskan hal ini dengan huruf kapital: baris kelima adalah bagian dari prosedur, bukan sekadar merapikan sesudah rilis. Baris itulah yang mencegah pekerjaan berikutnya dimulai di cabang yang salah, dan melewatkannya adalah jalan commit mendarat di main.
Aturan operasional yang saya tetapkan sesudahnya adalah menjalankan git branch --show-current sebelum git add pertama dari setiap pekerjaan. Bukan sebelum commit, tapi sebelum berkas pertama di-stage.
Prosedur yang tertulis itu dijalankan, bukan diingat
Aturan umum yang saya tarik dari hari itu: kalau sebuah catatan mendokumentasikan prosedur, jalankan prosedur yang disebutnya, dan jangan menurunkannya ulang dari state repositori. State repositori bisa merupakan keluaran dari kesalahan sebelumnya, dan itu persis yang terjadi di sini. Tiga squash di ujung main tadi adalah keluaran seperti itu.
Setiap kegagalan di atas berasal dari bekerja berdasarkan ingatan tentang prosedurnya, bukan dari membacanya. Membaca prosedurnya butuh sepuluh detik. Tiga pemulihannya butuh jauh lebih lama, dan salah satunya, squash tadi, sudah sampai ke kedua remote dan butuh perbaikan ancestry untuk membatalkannya.