Waktu memindahkan situs portofolio saya sendiri dari Next.js ke Astro, syarat yang saya pasang sederhana: situs Next.js yang sedang jalan tidak boleh tertimpa atau berubah. Situs Astro-nya dibangun di folder baru yang bersebelahan dengan folder situs lama, dengan repo git baru yang privat, bukan branch dari repo lama dan bukan penulisan ulang di tempat. Konten blog yang berjumlah 238 pasang MDX, sampul blog, video, graf konten, dan nilai env direncanakan untuk disalin, bukan dipindahkan.
Menyalin terdengar seperti langkah paling aman di seluruh rencana itu. Pertanyaan yang sebenarnya bukan salin atau pindah, melainkan salin dari mana. Diukur pada 8 September 2026, hari pemindahannya, menyalin dari disk akan membuat 189 berkas berbeda byte dari yang benar-benar dilayani produksi.
Dua berkas yang baru saja lolos sha256
Tahap pertama mengurus sistem desain, konstanta situs, robots.txt, dan aset. Commit-nya mencatat aset disalin dengan verifikasi sha256 per berkas, 52 berkas dibandingkan dan nol berbeda. Commit perbaikan untuk tahap yang sama, tercatat kurang dari empat menit kemudian, menulis bahwa Git memperingatkan CRLF di favicon.svg dan site.webmanifest akan dinormalkan, dan itu mengubah byte yang baru saja diverifikasi. Di Git, peringatan semacam ini berbunyi CRLF will be replaced by LF.
Urutan kejadiannya sudah memberi petunjuk. Dua berkas ber-CRLF lolos perbandingan sha256 tanpa selisih, jadi pembandingnya juga ber-CRLF. Dari tiga tempat yang memegang kedua berkas itu, yaitu disk, git, dan produksi, hanya disk yang menyimpannya dengan CRLF. Dengan kata lain, verifikasi tadi membandingkan salinan dengan disk tempat salinan itu diambil, dan pemeriksaan seperti itu hampir pasti lolos.
Salinan lokal yang basi
Penelusurannya membalik dugaan awal. Working tree projek Next memang memegang kedua berkas dengan CRLF, tapi git menyimpannya sebagai LF, dan produksi melayani yang LF karena platform hosting-nya membangun dari git. Bentuk CRLF itu tidak pernah sampai ke pengunjung. Yang sampai adalah isi git.
Langkah yang nyaris saya ambil adalah mengunci public/** -text supaya byte disk terjaga. Atribut -text menyuruh git berhenti menormalkan akhir baris dan menyimpan berkas persis seperti di disk. Kalau itu yang dipasang, CR yang hanya hidup di disk lama akan masuk ke repo baru sebagai isinya yang sah. Langkah itu justru membekukan artefak yang basi.
Rencana migrasinya sendiri mengarah ke tempat yang sama. Instruksinya menyalin dengan rsync -a supaya byte dan cap waktu utuh, tidak menormalkan apa pun, dan menambahkan .gitattributes berisi * -text di repo baru untuk mencegah git menormalkan akhir baris. Rencana itu berpihak pada byte disk.
Dua hari sebelumnya, spec yang membedah situs lama sudah melihat CRLF tersebar di 85 MDX Indonesia, 102 MDX Inggris, dan 22 berkas src/. Kesimpulannya diambil dari git status yang bersih: repo dianggap menyimpan berkas-berkas itu apa adanya. Kesimpulan itu tidak sejalan dengan yang terukur di hari pemindahan, karena git menyimpan MDX dan kedua aset tadi sebagai LF.
git status memang bukan alat untuk menjawab pertanyaan itu. Git menilai perubahan lewat data stat yang tercatat di index dan lewat aturan konversi akhir barisnya sendiri, jadi berkas ber-CRLF di disk yang isinya setara dengan blob LF bisa saja dilaporkan bersih. Dengan core.autocrlf=input, nilai yang dicatat spec yang sama untuk repo itu, git mengubah CRLF jadi LF dulu sebelum membandingkan isi berkas dengan blob. Untuk melihat kedua sisinya terpisah, git ls-files --eol mencetak akhir baris di index dan di working tree untuk tiap berkas:
# contoh umum dengan nama berkas netral, bukan keluaran dari repo situs ini
git ls-files --eol contoh.svg
# i/lf w/crlf attr/ contoh.svgKolom i/ adalah index dan w/ adalah working tree. Berkas yang bentuknya seperti dua aset tadi muncul sebagai i/lf w/crlf.
Dari dua berkas jadi 189
Begitu git yang dijadikan pembanding, dua berkas tadi baru ujungnya saja. Hasil ukurnya, dalam tiga baris:
public/favicon.svg disk CRLF git LF produksi LF
public/site.webmanifest disk CRLF git LF produksi LF
content/blog/**/*.mdx 187 dari 476 disk CRLF, git seluruhnya LF, produksi LFDua aset ditambah 187 MDX sama dengan 189 berkas. Angka 187 itu juga cocok dengan spec dua hari sebelumnya, karena 85 MDX Indonesia ditambah 102 MDX Inggris adalah 187.
Catatan verifikasi lain yang bertanggal sama menutup hitungan itu dari sisi ukuran. Ukuran korpus artikel 3.168.943 byte rupanya angka disk, sedangkan total blob git-nya 3.149.927 byte. Selisihnya 19.016 byte, tepat sama dengan jumlah karakter CR di 187 berkas yang ber-CRLF. Seluruh selisih ukuran antara disk dan git terjelaskan oleh CR, tanpa sisa.
CR itu juga tidak sepenuhnya diam. Spec 6 September mencatat gray-matter menoleransi CRLF, sedangkan alat berbasis grep atau regex ^---$ tidak. Pemeriksaan di dalam spec itu sempat hanya menghitung 153 dari 238 cover: sebelum \r dibuang. Selisihnya 85, angka yang sama dengan jumlah MDX Indonesia ber-CRLF di catatan yang sama.
Tanpa .gitattributes, mesin yang memutuskan
Penyebab strukturalnya ada di repo asal. Repo itu tidak punya .gitattributes sama sekali, jadi bentuk akhir baris di disk bergantung pada core.autocrlf mesin yang kebetulan dipakai. Tanpa atribut, git menyerahkan urusan itu ke konfigurasi lokal: true menulis CRLF saat checkout dan mengembalikannya ke LF saat commit, input hanya menormalkan saat commit, dan false tidak mengubah apa pun. Isi git bisa identik di dua mesin sementara byte di disknya berbeda. Dari mana persisnya CR di disk saya datang tidak tercatat. Yang tercatat adalah bahwa tidak ada yang mengunci bentuknya.
Untuk dua aset tadi, perbaikannya dua langkah. Keduanya ditulis ulang dari byte yang benar-benar dilayani produksi, dan repo baru mendapat .gitattributes yang mengunci svg serta webmanifest ke LF sekaligus menandai aset biner supaya tidak pernah disentuh normalisasi. Bagian aturannya seperti ini:
*.svg text eol=lf
*.webmanifest text eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.webp binary
*.ico binary
*.woff2 binary
*.webm binary
*.mp4 binary
*.pdf binary
*.astro text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.css text eol=lf
*.md text eol=lf
*.mdx text eol=lf
*.json text eol=lfKomentar di berkas itu menulis alasannya: dua berkas teks di public/ dikunci ke LF supaya tidak bergantung pada core.autocrlf mesin siapa pun. text eol=lf membuat git menyimpan LF di repo dan menulis LF saat checkout, apa pun pengaturan mesinnya, sedangkan binary mematikan konversi sama sekali. Berkas sumber Astro, TypeScript, CSS, Markdown, MDX, dan JSON ikut dikunci ke LF. .gitattributes itu masuk lewat commit perbaikan yang sama pada 8 September dan belum diubah sejak itu.
Verifikasi akhirnya dilakukan terhadap blob git projek Next: 50 aset cocok, 1 sengaja berbeda (og-image.png), dan 0 tak terduga.
Blob sebagai pembanding
Aturan pertama yang tercatat dari pemindahan ini adalah bahwa acuan paritas berkas adalah blob git, bukan working tree. Cara memeriksanya per berkas:
git -C <repo> cat-file blob HEAD:<path> | shasum -a 256Hasilnya dibandingkan dengan sha256 salinan baru, bukan dengan berkas di working tree repo asal. git cat-file blob mengeluarkan isi blob persis seperti tersimpan, tanpa konversi akhir baris apa pun, jadi yang dibandingkan adalah isi git itu sendiri.
Di tahap artikel, aturan yang sama dipakai untuk konten. Catatan projek untuk tahap itu menulisnya dengan huruf kapital: konten wajib diambil dari blob git, bukan working tree. Catatan itu menambahkan bahwa ini aturan yang sama dengan aset dan sudah kejadian dua kali, jadi diperlakukan sebagai default. Commit kontennya menulis artikel dari blob git dan mencatat 714 berkas konten dan sampul diverifikasi cocok dengan blob git.
Di hari yang sama, 21 video webm seberat 83 MB yang tidak pernah ikut tersalin juga diambil dari blob git dengan hash dicek satu per satu, dan 48 dari 48 aset (21 webm ditambah 27 webp) punya content-length yang cocok dengan produksi.
Di tahap alur CV dan email, aturan itu menjadi cara kerja untuk berkas yang harus setia: berkas diturunkan dari blob git, lalu diubah lewat penggantian tertarget yang jumlahnya dihitung, bukan diketik ulang. Ceklis tahap pertama di rencana migrasi menuliskannya sebagai syarat: sha256 semua aset yang disalin dibandingkan terhadap blob git projek Next, bukan terhadap berkas di working tree-nya. Ceklis itu menambahkan satu kalimat pendek, ini bukan kerewelan.
Aturan ini menangkap kesalahan nyata lebih dari sekali dalam satu sesi, jadi saya memperlakukannya sebagai default, bukan sebagai kewaspadaan.
Batas verifikasinya
Ada satu batas yang perlu saya tulis apa adanya. Untuk 187 MDX itu, yang tercatat adalah verifikasi terhadap blob git. Catatan saya menyebut produksi melayani versi LF, tapi pembandingan byte per berkas MDX terhadap produksi tidak pernah tercatat. Untuk aset, pembandingnya dua: blob git untuk 50 aset di verifikasi akhir, dan content-length produksi untuk 48 berkas webm dan webp.
Hari itu juga domainnya dipindahkan, dan situs Astro mulai melayani pengunjung.
Yang saya pegang sejak itu sederhana. Kalau produksi dibangun dari git, working tree hanyalah salah satu salinan, dan bisa saja salinan yang basi. Acuan untuk menyalin dan memverifikasi adalah blob, dan repo tujuan mengunci akhir barisnya secara eksplisit supaya bentuk di disk tidak lagi bergantung pada mesin siapa yang kebetulan dipakai.