479 lines
18 KiB
Markdown
479 lines
18 KiB
Markdown
# CONVENTION.md — Cardverse API
|
|
|
|
Dokumen ini adalah panduan resmi penulisan kode di project ini. Tujuannya supaya
|
|
semua kontributor (termasuk diri sendiri 6 bulan dari sekarang) nulis kode dengan
|
|
gaya yang konsisten, gampang dibaca, dan gampang di-maintain.
|
|
|
|
**Prinsip utama:** kode dibaca jauh lebih sering daripada ditulis. Optimasi untuk
|
|
yang baca, bukan cuma untuk yang nulis.
|
|
|
|
---
|
|
|
|
## 1. Struktur Folder
|
|
|
|
Project ini pakai konsep **per-module** (feature-based / modular monolith).
|
|
Struktur lengkap & alasan tiap folder ada di [`README.md`](./README.md#struktur-folder) —
|
|
dokumen ini tidak mengulang, cukup aturan tambahannya:
|
|
|
|
```
|
|
cardverse/
|
|
├── cmd/ # entry point (main.go per binary: api, seed, dst)
|
|
├── config/ # load environment variable
|
|
├── internal/
|
|
│ ├── database/ # koneksi DB, migration, seeder
|
|
│ ├── middleware/ # auth, cors, logger, rate limiter
|
|
│ ├── modules/ # 1 folder = 1 domain/fitur (model, dto, repository, service, handler, routes)
|
|
│ ├── router/ # gabungkan semua route module
|
|
│ └── pkg/ # helper generik lintas module (response, logger, utils, validator)
|
|
├── public/ # data assets statis (lihat aturan di bawah)
|
|
│ ├── images/
|
|
│ └── uploads/
|
|
└── tests/ # HANYA untuk integration/e2e test, BUKAN unit test (lihat bagian 7)
|
|
```
|
|
|
|
### Aturan wajib
|
|
|
|
- **Module baru = folder baru** di `internal/modules/`. Jangan taruh logic satu fitur
|
|
nyebar di banyak tempat di luar folder module-nya sendiri.
|
|
- **Jangan bikin folder generik** kayak `utils/`, `helpers/`, `common/` di level module.
|
|
Kalau kode itu generik lintas module, taruh di `internal/pkg/`. Kalau spesifik ke 1
|
|
domain, dia bukan "helper" — dia bagian dari `service.go` module itu.
|
|
- **`public/`** khusus untuk data assets statis yang bisa diakses langsung (gambar
|
|
hasil upload, file yang di-generate, dsb) — **bukan** untuk kode maupun file konfigurasi.
|
|
- `public/images/` — gambar statis (logo, avatar default, dsb)
|
|
- `public/uploads/` — file hasil upload user (foto profil, dokumen, dsb)
|
|
- Isi folder ini **jangan pernah di-commit** kalau berupa file hasil upload runtime —
|
|
pastikan sudah ada di `.gitignore` (cukup commit `.gitkeep` biar foldernya tetap ada di git)
|
|
- Kalau nanti butuh serve folder ini lewat HTTP, daftarkan lewat `r.Static("/public", "./public")`
|
|
di `internal/router/router.go`, jangan bikin route manual per file
|
|
|
|
---
|
|
|
|
## 2. Penamaan (Naming)
|
|
|
|
**Aturan paling penting di seluruh dokumen ini: nama harus mendeskripsikan APA
|
|
isinya / APA yang dilakukan, bukan singkatan yang cuma dipahami penulisnya sendiri dan pakai bahasa inggris penamaannya**
|
|
|
|
### 2.1 Variable
|
|
|
|
```go
|
|
// ❌ HINDARI — nama tidak jelas, harus baca konteks buat ngerti
|
|
d := time.Now().Sub(start)
|
|
u, _ := repo.FindByID(id)
|
|
n := len(users)
|
|
|
|
// ✅ BENAR — jelas dari nama variabelnya sendiri
|
|
duration := time.Now().Sub(start)
|
|
existingUser, _ := repo.FindByID(id)
|
|
totalUsers := len(users)
|
|
```
|
|
|
|
- Variable **boolean** harus dimulai kata tanya: `isActive`, `hasPermission`, `canDelete`, `shouldRetry`
|
|
- Variable **jamak/slice** pakai bentuk jamak: `users` (bukan `userList` atau `userArr`)
|
|
- Variable **short-lived** di scope kecil (misal index loop `for i := range x`) boleh singkat (`i`, `err`), tapi begitu scope-nya lebih dari beberapa baris, kasih nama jelas
|
|
- **Jangan** pakai singkatan yang ambigu: `usr`, `pwd`, `req` OK kalau konvensi umum (`req`/`resp` untuk HTTP dipahami luas), tapi `tmp`, `data`, `val`, `obj` generik itu **hindari** — ganti dengan nama yang jelasin isinya apa
|
|
|
|
### 2.2 Function & Method
|
|
|
|
Pola: **KataKerja + Objek**, jelasin APA yang dilakukan dan KE APA.
|
|
|
|
```go
|
|
// ❌ HINDARI
|
|
func Process(u User) error
|
|
func Handle(c *gin.Context)
|
|
func Check(email string) bool
|
|
|
|
// ✅ BENAR
|
|
func HashPassword(plain string) (string, error)
|
|
func ValidateEmailFormat(email string) bool
|
|
func (h *Handler) CreateUser(c *gin.Context)
|
|
func (s *service) DeleteUserByID(id uint) error
|
|
```
|
|
|
|
- Function yang balikin `bool` namanya harus mulai kata tanya: `IsValid()`, `HasAccess()`
|
|
- Function private/internal (huruf kecil di awal) boleh lebih pendek karena scope-nya
|
|
udah jelas dari package-nya, tapi tetap harus jelas apa fungsinya
|
|
- Satu function idealnya **cuma ngerjain 1 hal** (Single Responsibility). Kalau nama
|
|
function-nya butuh kata "And" (`CreateUserAndSendEmail`), itu tanda harus dipecah
|
|
jadi 2 function
|
|
|
|
### 2.3 Constant & Package-level Variable
|
|
|
|
```go
|
|
// ✅ constant pakai PascalCase kalau exported, camelCase kalau private
|
|
const MaxLoginAttempts = 5
|
|
const defaultPageSize = 10
|
|
|
|
// ✅ untuk grup constant terkait, pakai type + iota biar type-safe
|
|
type OrderStatus string
|
|
|
|
const (
|
|
OrderStatusPending OrderStatus = "pending"
|
|
OrderStatusPaid OrderStatus = "paid"
|
|
OrderStatusCancelled OrderStatus = "cancelled"
|
|
)
|
|
```
|
|
|
|
### 2.4 File
|
|
|
|
- Nama file **snake_case**, deskriptif: `rate_limiter.go`, `response_error_test.go`
|
|
- 1 file = 1 tanggung jawab. Kalau `service.go` sudah >300 baris dan ngerjain banyak
|
|
hal berbeda, pecah jadi beberapa file (`service.go`, `service_validation.go`, dst) —
|
|
tetap 1 package, cuma dipisah fisik biar gampang di-navigate
|
|
|
|
### 2.5 Package
|
|
|
|
- Nama package dan Folder **huruf kecil semua**, singular (bukan jamak): `user`, bukan `users`
|
|
- Hindari nama generik: `util`, `common`, `helper`, `base` sebagai nama package —
|
|
lihat aturan folder di bagian 1
|
|
|
|
---
|
|
|
|
## 3. Database & Query
|
|
|
|
### 3.1 JANGAN query di dalam loop (hindari N+1 problem)
|
|
|
|
Ini aturan yang paling sering dilanggar dan paling mahal dampaknya ke performa.
|
|
**Kumpulkan dulu data yang mau diproses, baru eksekusi 1 query bulk** — jangan
|
|
query satu-satu di dalam `for`.
|
|
|
|
```go
|
|
// ❌ SANGAT DIHINDARI — 1 query per iterasi, kalau ada 1000 data = 1000x round-trip ke DB
|
|
func (r *repository) CreateMany(users []User) error {
|
|
for _, u := range users {
|
|
if err := r.db.Create(&u).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ❌ SANGAT DIHINDARI — sama, tapi buat UPDATE
|
|
func (s *service) MarkAllAsRead(notifIDs []uint) error {
|
|
for _, id := range notifIDs {
|
|
s.db.Model(&Notification{}).Where("id = ?", id).Update("is_read", true)
|
|
}
|
|
return nil
|
|
}
|
|
```
|
|
|
|
```go
|
|
// ✅ BENAR — kumpulkan dulu ke slice, baru 1x bulk insert
|
|
func (r *repository) CreateMany(users []User) error {
|
|
if len(users) == 0 {
|
|
return nil
|
|
}
|
|
// GORM otomatis generate 1 statement INSERT dengan banyak VALUES sekaligus
|
|
return r.db.Create(&users).Error
|
|
}
|
|
|
|
// ✅ BENAR — kalau datanya sangat banyak (ribuan), pecah per batch biar
|
|
// nggak kena limit jumlah parameter di 1 query, TAPI tetap bukan query per-item
|
|
func (r *repository) CreateManyBatched(users []User) error {
|
|
if len(users) == 0 {
|
|
return nil
|
|
}
|
|
const batchSize = 100
|
|
return r.db.CreateInBatches(users, batchSize).Error
|
|
}
|
|
|
|
// ✅ BENAR — update banyak baris sekaligus pakai 1 query WHERE ... IN (...)
|
|
func (s *service) MarkAllAsRead(notifIDs []uint) error {
|
|
if len(notifIDs) == 0 {
|
|
return nil
|
|
}
|
|
return s.db.Model(&Notification{}).
|
|
Where("id IN ?", notifIDs).
|
|
Update("is_read", true).Error
|
|
}
|
|
```
|
|
|
|
**Pola umumnya:**
|
|
|
|
1. Kumpulkan semua data yang mau diproses ke **slice** dulu (di memory, bukan hit DB)
|
|
2. Baru eksekusi **1 query** (atau beberapa batch kalau datanya sangat besar) buat semua data itu sekaligus
|
|
3. Kalau butuh data referensi dari tabel lain buat banyak baris (misal ambil detail
|
|
product buat 50 order item), jangan `SELECT` 1 per item di loop — kumpulkan
|
|
semua ID-nya dulu, `SELECT ... WHERE id IN (...)` sekali, baru mapping di memory
|
|
|
|
```go
|
|
// ❌ N+1 query problem: 1 query ambil orders + N query ambil product tiap order item
|
|
for _, item := range orderItems {
|
|
var product Product
|
|
db.First(&product, item.ProductID)
|
|
// ...
|
|
}
|
|
|
|
// ✅ kumpulkan semua productID dulu, 1x query ambil semuanya, baru mapping di memory
|
|
productIDs := make([]uint, 0, len(orderItems))
|
|
for _, item := range orderItems {
|
|
productIDs = append(productIDs, item.ProductID)
|
|
}
|
|
|
|
var products []Product
|
|
db.Where("id IN ?", productIDs).Find(&products)
|
|
|
|
productMap := make(map[uint]Product, len(products))
|
|
for _, p := range products {
|
|
productMap[p.ID] = p
|
|
}
|
|
// sekarang tinggal productMap[item.ProductID] buat akses tiap item, tanpa query tambahan
|
|
```
|
|
|
|
### 3.2 Repository tetap satu-satunya lapisan yang bicara ke database
|
|
|
|
Business logic (di `service.go`) tidak boleh langsung import `gorm.io/gorm` atau
|
|
nulis query — semua akses data lewat `Repository` interface, sesuai pola yang sudah
|
|
ada. Ini memudahkan mocking di unit test dan menjaga tanggung jawab tetap terpisah.
|
|
|
|
### 3.3 Transaction untuk operasi yang harus atomik
|
|
|
|
Kalau 1 aksi bisnis butuh beberapa perubahan tabel yang harus **semua berhasil atau
|
|
semua gagal** (misal: kurangi stok + buat order), bungkus dengan `db.Transaction()`
|
|
di level repository/service, jangan biarkan tiap query jalan sendiri-sendiri.
|
|
|
|
```go
|
|
func (r *repository) CreateOrderWithStockDeduction(order *Order, productID uint, qty int) error {
|
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Create(order).Error; err != nil {
|
|
return err // otomatis rollback semua perubahan di transaction ini
|
|
}
|
|
if err := tx.Model(&Product{}).Where("id = ?", productID).
|
|
Update("stock", gorm.Expr("stock - ?", qty)).Error; err != nil {
|
|
return err
|
|
}
|
|
return nil // otomatis commit kalau sampai sini tanpa error
|
|
})
|
|
}
|
|
```
|
|
|
|
### 3.4 Hindari `SELECT *` (Spesifikkan Kolom yang Dibutuhkan)
|
|
|
|
- **Hindari penggunaan `SELECT *`** dalam query database.
|
|
- Tuliskan kolom yang ingin diambil **satu per satu secara spesifik** (misal `db.Select("id", "name", "email")`) sesuai kebutuhan data yang akan digunakan.
|
|
- Memilih kolom secara spesifik membuat eksekusi query jauh lebih cepat dan efisien, serta menghemat memori dan I/O jaringan database terutama ketika mengambil data dalam jumlah banyak.
|
|
|
|
---
|
|
|
|
## 4. Error Handling & Logging
|
|
|
|
### 4.1 Error Handling
|
|
|
|
- Selalu **cek error langsung setelah pemanggilan function** yang mengembalikannya —
|
|
jangan tunda atau abaikan (`_ = err` cuma boleh kalau memang sengaja & ada alasan jelas)
|
|
- Error domain (business logic) didefinisikan sebagai **package-level `var`** pakai
|
|
`errors.New(...)`, dicek pakai `errors.Is()` — sudah dipakai konsisten di
|
|
`service.go` tiap module (`ErrUserNotFound`, `ErrEmailTaken`, dst). Ikuti pola ini
|
|
untuk error baru
|
|
- **Jangan** expose detail error asli (pesan driver database, stack trace) ke response
|
|
API — sudah ditangani otomatis oleh `response.Error()` (lihat `internal/pkg/response`),
|
|
tinggal pakai, jangan bikin cara custom kirim error langsung ke client
|
|
- Pesan error (yang dicatat ke log, bukan yang dikirim ke client) harus dalam Bahasa
|
|
Indonesia yang jelas, bukan singkatan teknis semata: `"email sudah terdaftar"`,
|
|
bukan `"dup key"`
|
|
|
|
### 4.2 Logging & Request Debugging
|
|
|
|
- **Log Request untuk Debugging**: Selalu catat log untuk request HTTP yang masuk (seperti method, URL, query params, dan payload/body request) untuk memudahkan pencarian masalah (debugging).
|
|
- **Sensor Data Kredensial & Sensitif**: **Dilarang keras** menampilkan data kredensial atau informasi sensitif di dalam log (seperti `password`, `token`, `secret`, `credit_card`, `pin`, `otp`, atau header `Authorization`).
|
|
- Pastikan field kredensial di-masking (misal menjadi `"***"`) atau dibersihkan sebelum payload request dicatat ke log.
|
|
|
|
---
|
|
|
|
## 5. Response API
|
|
|
|
- Semua response lewat `response.Success()` / `response.SuccessWithPagination()` /
|
|
`response.Error()` di `internal/pkg/response` — jangan panggil `c.JSON()` langsung
|
|
di handler, supaya format response konsisten di semua endpoint
|
|
- Field JSON pakai `snake_case`: `total_data`, `created_at` — bukan `totalData`/`camelCase`
|
|
- Untuk field opsional (misal `Meta`, `Error`), pastikan pakai `json:"...,omitempty"`
|
|
supaya tidak muncul di response kalau kosong
|
|
|
|
---
|
|
|
|
## 6. Validasi Input (DTO)
|
|
|
|
- Semua request body/query **wajib** lewat DTO struct dengan tag `binding` —
|
|
jangan validasi manual pakai banyak `if` di handler
|
|
- Nama field DTO pakai tag `json` yang jelas, dan pesan error otomatis mengikuti
|
|
nama itu (lihat `internal/pkg/validator`) — jangan sampai nama field DTO ambigu
|
|
(`Val`, `Data`, `Input`)
|
|
- Tag `binding` selalu eksplisit soal wajib/opsional: pakai `required` untuk field
|
|
wajib, `omitempty` untuk field opsional — jangan biarkan ambigu
|
|
|
|
```go
|
|
// ✅ jelas mana wajib, mana opsional, dan aturan validasinya apa
|
|
type CreateUserRequest struct {
|
|
Name string `json:"name" binding:"required,min=2,max=100"`
|
|
Email string `json:"email" binding:"required,email"`
|
|
Password string `json:"password" binding:"required,min=6"`
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Testing
|
|
|
|
- Unit test **wajib** ada di folder yang sama dengan kode yang di-test (keterbatasan
|
|
tooling Go, lihat `README.md`) — jangan coba pindahkan
|
|
- Nama function test deskriptif, format `Test<Subjek>_<Skenario>`:
|
|
```go
|
|
func TestService_Create_EmailSudahDipakai(t *testing.T)
|
|
func TestRateLimiter_MenolakSetelahBurstHabis(t *testing.T)
|
|
```
|
|
- Business logic (`service.go`) di-test pakai **mock repository**, bukan koneksi
|
|
database sungguhan — ikuti pola `repository_mock_test.go` yang sudah ada
|
|
- Folder `tests/` di root (kalau nanti dibuat) khusus untuk **integration/e2e test**
|
|
yang benar-benar hit endpoint HTTP + database sungguhan — ini kategori berbeda
|
|
dari unit test, dan memang boleh terpisah dari kode karena cuma manggil API
|
|
dari luar (black-box), bukan akses internal package
|
|
|
|
---
|
|
|
|
## 8. Function & Kompleksitas
|
|
|
|
- Idealnya 1 function **muat dalam 1 layar** tanpa scroll (~40-50 baris). Kalau lebih,
|
|
kemungkinan besar dia ngerjain lebih dari 1 tanggung jawab — pecah jadi beberapa function
|
|
- Hindari **nested if lebih dari 2-3 level**. Pakai **early return** (guard clause):
|
|
|
|
```go
|
|
// ❌ nested dalam-dalam, susah dibaca
|
|
func (s *service) Update(id uint, req UpdateUserRequest) (*User, error) {
|
|
u, err := s.repo.FindByID(id)
|
|
if err == nil {
|
|
if req.Name != "" {
|
|
u.Name = req.Name
|
|
if updateErr := s.repo.Update(u); updateErr == nil {
|
|
return u, nil
|
|
} else {
|
|
return nil, updateErr
|
|
}
|
|
}
|
|
return u, nil
|
|
} else {
|
|
return nil, ErrUserNotFound
|
|
}
|
|
}
|
|
|
|
// ✅ early return, alur baca dari atas ke bawah, tanpa nested
|
|
func (s *service) Update(id uint, req UpdateUserRequest) (*User, error) {
|
|
u, err := s.repo.FindByID(id)
|
|
if err != nil {
|
|
return nil, ErrUserNotFound
|
|
}
|
|
|
|
if req.Name != "" {
|
|
u.Name = req.Name
|
|
}
|
|
|
|
if err := s.repo.Update(u); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return u, nil
|
|
}
|
|
```
|
|
|
|
- **Magic number/string dihindari** — pakai constant yang dikasih nama:
|
|
|
|
```go
|
|
// ❌
|
|
if len(password) < 6 { ... }
|
|
|
|
// ✅
|
|
const minPasswordLength = 6
|
|
if len(password) < minPasswordLength { ... }
|
|
```
|
|
|
|
---
|
|
|
|
## 9. Comment & Dokumentasi
|
|
|
|
- **Jangan beri komentar di setiap baris atau fungsi internal** jika nama fungsi dan variabelnya sudah mendeskripsikan secara jelas (_self-descriptive_).
|
|
- Cukup berikan **overview fungsi secara keseluruhan** (doc comment di atas fungsi) untuk menjelaskan gambaran umum apa yang dikerjakan fungsi tersebut dan jangan berlebihan.
|
|
- Comment di dalam baris kode (_inline comment_) hanya dipakai untuk menjelaskan **KENAPA** (konteks bisnis/alasan keputusan teknis yang tidak terlihat langsung dari kode), bukan **APA** yang sedang dilakukan baris tersebut.
|
|
|
|
```go
|
|
// ❌ HINDARI — komentar di setiap baris yang kodenya sendiri sudah jelas
|
|
func (s *userService) CreateUser(req CreateUserRequest) error {
|
|
// hash password user
|
|
hashedPassword, _ := HashPassword(req.Password)
|
|
// simpan user ke database
|
|
return s.repo.Create(user)
|
|
}
|
|
|
|
// ✅ BENAR — cukup overview ringkas di atas fungsi, tanpa komentar per baris di dalam body
|
|
// CreateUser menangani proses registrasi dan penyimpanan data pengguna baru.
|
|
func (s *userService) CreateUser(req CreateUserRequest) error {
|
|
hashedPassword, err := HashPassword(req.Password)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.repo.Create(user)
|
|
}
|
|
```
|
|
|
|
- Semua **function/type exported** (huruf besar di awal) wajib punya doc comment ringkas (overview) yang diawali nama function/type itu sendiri (konvensi standar Go).
|
|
- Doc comment cukup Bahasa Indonesia, tidak perlu Bahasa Inggris kecuali project ini nantinya open source untuk audiens internasional.
|
|
|
|
---
|
|
|
|
## 10. Formatting & Linting
|
|
|
|
- Jalankan `gofmt` (atau `goimports`) sebelum commit — **wajib**, bukan opsional.
|
|
Kebanyakan editor (VS Code + ekstensi Go) sudah auto-format on save
|
|
- Disarankan pasang [`golangci-lint`](https://golangci-lint.run/) untuk nangkep
|
|
isu umum (unused variable, ineffective assignment, dst) sebelum push:
|
|
```bash
|
|
golangci-lint run ./...
|
|
```
|
|
- Import selalu dikelompokkan: stdlib dulu, baris kosong, lalu internal (`cardverse/...`),
|
|
baris kosong, lalu third-party — `goimports` otomatis ngerjain ini
|
|
|
|
---
|
|
|
|
## 11. Checklist Sebelum Pull Request
|
|
|
|
- [ ] `gofmt` sudah dijalankan, tidak ada perbedaan format
|
|
- [ ] Nama variable/function sudah deskriptif, tidak ada singkatan ambigu
|
|
- [ ] Tidak ada query database di dalam loop — sudah dikumpulkan & di-bulk
|
|
- [ ] Semua error dicek, tidak ada yang diabaikan diam-diam
|
|
- [ ] Tidak ada data sensitif (password, token) yang ikut ter-log atau ter-expose ke response
|
|
- [ ] Unit test ditambahkan/diupdate untuk logic baru, ditaruh di folder yang sama
|
|
- [ ] `go test ./...` lolos semua sebelum push
|
|
- [ ] Struktur folder module diikuti (model, dto, repository, service, handler, routes)
|
|
- [ ] Format pesan commit mengikuti konvensi `git commit -m "[commit-type]([feature]-[code]) : [message in english]"`
|
|
|
|
---
|
|
|
|
## 12. Konvensi Git (Commit & Push)
|
|
|
|
### 12.1 Commit Convention
|
|
|
|
Struktur Commit:
|
|
|
|
```bash
|
|
git commit -m "[commit-type]([feature]-[code]) : [message in english]"
|
|
```
|
|
|
|
**Contoh:**
|
|
|
|
```bash
|
|
git commit -m "feat(login-001) : add new form login"
|
|
```
|
|
|
|
Daftar `commit-type`:
|
|
|
|
- **`feat`**: Digunakan saat menambahkan fitur baru.
|
|
- **`fix`**: Digunakan saat memperbaiki bug.
|
|
- **`refactor`**: Digunakan saat mengatur ulang atau merestrukturisasi kode yang ada.
|
|
- **`docs`**: Digunakan saat membuat perubahan terkait dokumentasi atau komentar.
|
|
- **`style`**: Digunakan untuk perubahan dalam format kode, spasi, tanda baca, dll.
|
|
- **`test`**: Digunakan saat menambahkan atau memperbarui kode pengujian atau skenario pengujian.
|
|
- **`chore`**: Digunakan untuk perubahan yang terkait dengan alat bantu, berkas konfigurasi, atau organisasi proyek.
|
|
|
|
### 12.2 Push Convention & Aturan Izin
|
|
|
|
- **Wajib Izin Sebelum Push**: Aksi `git push` **harus selalu meminta izin terlebih dahulu** kepada pengguna / pemilik repositori sebelum dieksekusi.
|
|
- **Dilarang Direct Push Tanpa Konfirmasi**: Jangan pernah melakukan `git push` secara otomatis atau tanpa konfirmasi eksplisit.
|