Mert Özen Her Satırda Daha İleri
DevOps Git & GitHub — #15

Git ile Ekip Çalışması: Branch Stratejileri, Rulesets ve Conventional Commits

Mert Özen 6 Kas 2026 10 dk 2 görüntülenme
Git ile Ekip Çalışması: Branch Stratejileri, Rulesets ve Conventional Commits

Bir force push'un ekip arkadaşının commit'ini nasıl sildiğinden yola çıkarak GitHub flow, Git flow ve trunk-based development'ı karşılaştırıyoruz; branch protection ile rulesets farkı, receive.denyNonFastForwards ile sunucu tarafı koruma, CODEOWNERS ve Conventional Commits.

Sürüm notu: Bu yazıdaki komutlar Git 2.54 ile denenmiştir.

Önce Tahmin Et

Ada kitap aramasını ekleyip main'e push ediyor. Alan ise sabahtan beri fetch yapmamış; kendi makinesinde sepete vergi ekleyip o da main'e push etmeye çalışıyor:

$ git commit -am "Add tax to cart total"
[main 8588f45] Add tax to cart total
 1 file changed, 2 insertions(+), 1 deletion(-)
$ git push
To ../bookstore.git
 ! [rejected]        main -> main (fetch first)
error: failed to push some refs to '../bookstore.git'
hint: Updates were rejected because the remote contains work that you do not
hint: have locally. This is usually caused by another repository pushing to
hint: the same ref. If you want to integrate the remote changes, use
hint: 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.

Alan aceleyle git push --force yazıyor. Ada'nın az önce push ettiği arama commit'ine ne olur?

$ git push --force
To ../bookstore.git
 + 44764e7...8588f45 main -> main (forced update)

Ada bir sonraki fetch'te durumu görüyor:

$ git fetch
From ../bookstore
 + 44764e7...8588f45 main       -> origin/main  (forced update)
$ git log --oneline origin/main
8588f45 Add tax to cart total
7f7eeec Add cart total
61419c1 Add book list
5102cb8 Add README and .gitignore

44764e7 Add book search remote'taki main'den silindi. Commit Ada'nın makinesinde hâlâ duruyor, yani kurtarılabilir, ama Git hiçbir uyarı vermeden ekip arkadaşının işini ortak branch'ten attı. İki kişilik bir ekipte bile ortak bir kurala ve o kuralı uygulayan bir korumaya ihtiyaç var. Bu yazı ikisini birden anlatıyor: branch stratejileri ve main'i koruyan GitHub ayarları, yanında CODEOWNERS ve Conventional Commits.

GitHub Flow: Tek Uzun Ömürlü Branch

Önceki yazıda adım adım uyguladığımız model bu. Uzun ömürlü tek branch main; geri kalan her şey kısa ömürlü bir branch'te yapılır, pull request ile review'dan geçer, merge edilir ve silinir. main her an deploy edilebilir kabul edilir. Modeli bu kadar sade tutan şey, sürüm kavramının ayrı bir branch'e ihtiyaç duymaması: merge edilen her PR bir sonraki deploy'un parçası olur.

Gücü sadeliği. Yeni gelen biri modeli on dakikada öğrenir, geçmiş okunabilir kalır, CI tek bir branch'i izler. Zayıf noktası aynı anda birden çok sürümü desteklemen gereken durumlar: kurulu bir masaüstü uygulamasının 1.x ve 2.x hattını aynı anda yaşatıyorsan, tek bir main bunu kendi başına taşımaz.

Git Flow: develop, release ve hotfix

Git flow, Vincent Driessen'in 2010'daki yazısıyla yayılan bir model. İki uzun ömürlü branch var: main yalnızca yayınlanmış sürümleri tutar, develop bir sonraki sürümün biriktiği yerdir. Özellikler develop'tan açılan feature/* branch'lerinde geliştirilir. Sürüm zamanı gelince develop'tan bir release/* branch'i açılır, son düzeltmeler orada yapılır, sonra hem main'e hem develop'a merge edilir ve main tag'lenir. Canlıdaki acil hatalar için main'den hotfix/* açılır. Tek bir özellik ve bir sürümle bile grafiğin nasıl görüneceğine bakalım:

$ git switch -q -c develop
$ git switch -q -c feature/search
$ git add src/search.js && git commit -q -m "Add book search"
$ git switch -q develop && git merge -q --no-ff feature/search -m "Merge feature/search into develop"
$ git switch -q -c release/1.1.0
$ echo "1.1.0" > VERSION && git add VERSION && git commit -q -m "Bump version to 1.1.0"
$ git switch -q main && git merge -q --no-ff release/1.1.0 -m "Release 1.1.0"
$ git tag -a v1.1.0 -m "Version 1.1.0"
$ git switch -q develop && git merge -q --no-ff release/1.1.0 -m "Merge release/1.1.0 into develop"
$ git log --oneline --graph --all --decorate
*   ab295b3 (HEAD -> develop) Merge release/1.1.0 into develop
|\
| | *   d5612ed (tag: v1.1.0, main) Release 1.1.0
| | |\
| | |/
| |/|
| * | 8b07302 (release/1.1.0) Bump version to 1.1.0
|/ /
* |   394a5ce Merge feature/search into develop
|\ \
| |/
|/|
| * 46155cb (feature/search) Add book search
|/
* 7f7eeec Add cart total
* 61419c1 Add book list
* 5102cb8 Add README and .gitignore

İki gerçek commit için üç merge commit ve dört branch. Git flow'un güçlü yanı tam da bu yapı: planlı sürümler çıkaran, aynı anda birden fazla sürümü destekleyen, sürümden önce bir dondurma ve test dönemi olan ürünlerde kimin nereye ne merge edeceği nettir. Zayıf yanı da bu yapı: release ve hotfix'leri iki yere merge etmeyi unutmak kolaydır, develop ile main zamanla birbirinden uzaklaşır. Driessen de 2020'de yazısının başına eklediği notta, sürekli deploy edilen web uygulamaları için GitHub flow gibi daha basit bir modeli önerdi.

Trunk-Based Development

Trunk-based development'ta herkes değişikliklerini sık sık, en az günde bir kez, tek bir ana branch'e (trunk, bizde main) entegre eder. Branch açılıyorsa bir iki günden uzun yaşamaz. Bitmemiş bir iş de main'e girebilir, ama kullanıcıya görünmez; bunu feature flag ile yaparsın:

// indirim özelliği bitene kadar kapalı kalır
const discountsEnabled = process.env.DISCOUNTS_ENABLED === "true";

function total(items, discount = 0) {
  const sum = items.reduce((acc, item) => acc + item.price, 0);
  return discountsEnabled ? Math.max(sum - discount, 0) : sum;
}

Kazancı entegrasyonun hiç birikmemesi: uzun yaşayan branch olmadığı için büyük merge'ler ve haftalık conflict seansları da olmaz. Bedeli disiplin. Her push'u yakalayan hızlı ve güvenilir bir test hattı şart; feature flag'ler de temizlenmezse kodda ölü dallar olarak kalır. Sürüm gerekiyorsa main'den release branch'i kesilir ve yalnızca düzeltmeler oraya cherry-pick edilir.

Hangisi Ne Zaman?

Doğru model, ekibin nasıl teslim ettiğine bağlı. Sürekli deploy edilen bir web uygulaması ya da servis geliştiriyorsan iki seçenek var: GitHub flow ya da trunk-based. GitHub flow review'u PR'a bağlar ve öğrenmesi kolaydır; trunk-based daha sık entegrasyon ve daha küçük değişiklikler ister, karşılığında güçlü bir CI ve feature flag altyapısı bekler. Küçük PR'lar ve hızlı review ile çalışan bir GitHub flow ekibi pratikte trunk-based'e çok yaklaşır. Kurulumu yapılan, mağazadan dağıtılan ya da aynı anda birkaç sürümü desteklenen bir ürünse Git flow ya da onun sadeleştirilmiş bir hâli (main ve sürüm başına release branch'leri) mantıklı olabilir. Hangisini seçersen seç, kuralı yazılı hâle getir ve aşağıdaki korumalarla uygula; yazılı olmayan kural ilk acil durumda delinir.

main'i Korumak: Branch Protection ve Rulesets

Önce Tahmin Et'teki kazayı önlemenin yolu sunucu tarafında bir kural. Git'in kendisinde bunun basit bir karşılığı var: bare repo'da receive.denyNonFastForwards ve receive.denyDeletes ayarları. Aynı senaryoyu bu ayarlar açıkken tekrar oynattım:

$ git -C bookstore.git config set receive.denyNonFastForwards true
$ git -C bookstore.git config set receive.denyDeletes true
$ git push --force
remote: error: denying non-fast-forward refs/heads/main (you should pull first)
To ../bookstore.git
 ! [remote rejected] main -> main (non-fast-forward)
error: failed to push some refs to '../bookstore.git'
$ git push origin --delete main
remote: error: denying ref deletion for refs/heads/main
To ../bookstore.git
 ! [remote rejected] main (deletion prohibited)
error: failed to push some refs to '../bookstore.git'

Bu kez reddi istemci değil sunucu verdi: [remote rejected]. Alan'ın elinde --force olsa da kural sunucuda. GitHub'da sunucu ayarına erişimin yok; aynı işi repo ayarlarından yaparsın ve çok daha fazlasını yapabilirsin: merge'den önce PR zorunluluğu, belirli sayıda onay, geçmesi gereken status check'ler, force push ve silme yasağı.

Bunun iki yolu var. Eski yol branch protection kuralları: bir branch'e ya da pattern'e bağlanır ve bir branch'e aynı anda yalnızca bir koruma kuralı uygulanır. Yeni yol rulesets: 17 Nisan 2023'te public beta olarak geldi, 24 Temmuz 2023'te genel kullanıma açıldı. Ruleset'lerin farkları şunlar. Bir branch'e aynı anda birden çok ruleset uygulanabilir ve katmanlanır; çakışan kurallarda en kısıtlayıcı olan kazanır. Bir ruleset'i silmeden aktif ya da pasif yapabilirsin. Repo'ya okuma erişimi olan herkes ruleset'leri görebilir, yani "push'um neden reddedildi" sorusunun cevabı gizli bir yönetici ayarında kalmaz. Ruleset'ler yalnızca branch'leri değil tag'leri de hedefleyebilir. Yeni bir repo'da ruleset ile başla. Status check'leri üretecek CI tarafını, yani GitHub Actions'ı, ileride ayrı bir seride ele alacağız.

CODEOWNERS

Korumalar "onay şart" der ama kimin onayı olduğunu söylemez. CODEOWNERS bunu yapar. Repo'nun kökünde, docs/ içinde ya da .github/ klasöründe duran bir CODEOWNERS dosyasıdır ve hangi dosyalardan kimin sorumlu olduğunu yazar:

# .github/CODEOWNERS
# varsayılan sahip: her dosya
*              @ada
# sepet kodunun sahibi Alan
/src/cart.js   @alan
# dokümantasyon bir ekibe ait
/docs/         @bookstore/docs-team

Pattern'ler .gitignore'a benzer ve dosyada en son eşleşen satır geçerlidir; bu yüzden genel kural en üste, özel kurallar aşağıya yazılır. Bir PR src/cart.js'e dokunduğunda Alan review için otomatik olarak eklenir. Branch koruması ya da ruleset'te code owner onayını zorunlu kılarsan, o dosyaya dokunan bir PR Alan onaylamadan merge edilemez. Bu, ekip büyüdükçe "bunu kim review etmeli" sorusunu ortadan kaldırır.

Conventional Commits

Commit mesajları için ekipçe bir biçimde anlaşmak, geçmişi hem insan hem araç için okunur yapar. En yaygın biçim Conventional Commits: mesaj bir tiple başlar, isteğe bağlı olarak parantez içinde bir scope gelir, sonra iki nokta ve kısa bir açıklama. bookstore ekibi bu biçime sonradan geçti:

$ git log --oneline
aee166d feat(books)!: store prices in cents
f94e27d docs: explain how to run the cart
c280b0c fix(cart): keep total from going below zero
bc647f2 feat(cart): add discount support
7f7eeec Add cart total
61419c1 Add book list
5102cb8 Add README and .gitignore
$ git log --oneline --grep="^feat"
aee166d feat(books)!: store prices in cents
bc647f2 feat(cart): add discount support
$ git log -1 --format=%B
feat(books)!: store prices in cents

BREAKING CHANGE: books now have priceInCents instead of price.

feat yeni bir özellik, fix bir hata düzeltmesidir; docs, refactor, test, ci, chore gibi tipler de yaygın. İki noktanın hemen önündeki (tipten ya da scope'tan sonraki) ! ya da mesajın sonundaki BREAKING CHANGE: satırı geriye dönük uyumu bozan bir değişikliği işaretler. Biçim semantic versioning ile doğrudan eşleşir: fix patch, feat minor, breaking change major sürüm artışı demektir. --grep örneğinde gördüğün gibi düz Git ile bile süzülebilir; changelog ve sürüm numarası üreten araçlar da buna dayanır. Biçimi bir commit-msg hook'u ile otomatik kontrol etmeyi hook'lar yazısında göreceğiz.

Sık Düşülen Tuzaklar

İlk tuzak modeli ekipten değil bir blog yazısından seçmek. Günde birkaç kez deploy eden üç kişilik bir ekibe Git flow kurmak, her değişikliği beş branch'ten geçirmek demektir. Önce nasıl teslim ettiğinize bakın, modeli ona göre seçin.

İkincisi korumayı yöneticilere uygulamamak. Branch protection kurallarında "Do not allow bypassing the above settings" açılmadıkça yöneticiler kuralları varsayılan olarak atlayabilir; ruleset'lerde de bypass listesine eklediğin herkes atlayabilir. "Acil durumda gerekir" diye açık bırakılan kapı, Önce Tahmin Et'teki kazanın ta kendisidir. İstisna gerekiyorsa açık ve kayıtlı bir yol tanımla.

Üçüncüsü CODEOWNERS'ta sıralamayı ters yazmak. Son eşleşen satır kazandığı için * satırını en alta koyarsan bütün özel kuralları ezer ve her PR yalnızca varsayılan sahibe gider.

Dördüncüsü Conventional Commits'i squash merge ile birlikte düşünmemek. Squash yöntemi kullanıyorsanız main'e giren mesaj çoğu zaman PR başlığından gelir; biçimi yalnızca commit'lerde değil PR başlıklarında da uygulamanız gerekir.

Küçük Bir Deneme

★☆☆ Bir bare repo ve iki klon kur. Birinden push et, diğerinden fetch etmeden --force ile push et ve ilk klonun fetch çıktısındaki (forced update) satırını gör. Sonra git push --force-with-lease ile aynı şeyi dene; fark ne?

★★☆ Aynı bare repo'da receive.denyNonFastForwards ayarını aç ve senaryoyu tekrarla. Ardından reddedilen değişikliği nasıl push edebileceğini bul: önce fetch, sonra merge ya da rebase.

★★★ Git flow grafiğine bir hotfix ekle: main'den hotfix/1.1.1 aç, bir düzeltme commit'le, hem main'e hem develop'a --no-ff ile merge et ve v1.1.1 tag'ini at. git log --oneline --graph --all --decorate çıktısını trunk-based bir geçmişle karşılaştır.

Ekip kurallarını ve korumaları kurduk. Bir sonraki yazıda tek başına ama sık yaşanan bir duruma dönüyoruz: yanlış bir değişikliği geri almak. git restore ile dosyaları, git reset'in --soft, --mixed ve --hard modlarıyla commit'leri geri alacağız ve her birinin HEAD'e, index'e ve working tree'ye ne yaptığını tek tek göreceğiz.