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

GitHub ile Pull Request: GitHub Flow, Review ve Merge Yöntemleri

Mert Özen 6 Kas 2026 10 dk 1 görüntülenme
GitHub ile Pull Request: GitHub Flow, Review ve Merge Yöntemleri

Branch, commit, push ve pull request: GitHub flow'un altı adımı, draft PR, review'a yeni push'larla cevap vermek, merge commit, squash ve rebase yöntemlerinin geçmişe etkisi, squash sonrası branch silmenin tuzağı ve fork ile upstream'i senkron tutmak.

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

Önce Tahmin Et

Ada, sepete indirim desteği eklemek için bir branch açıyor, değişikliği commit'leyip push ediyor:

$ git switch -c feature/discount
Switched to a new branch 'feature/discount'
$ git commit -am "Add discount to cart total"
[feature/discount 5631353] Add discount to cart total
 1 file changed, 3 insertions(+), 2 deletions(-)
$ git push -u origin feature/discount
To ../bookstore.git
 * [new branch]      feature/discount -> feature/discount
branch 'feature/discount' set up to track 'origin/feature/discount'.

Sonra GitHub'da bu branch'ten main'e bir pull request açıyor. Alan review'da bir sorun buluyor: indirim sepet tutarından büyükse toplam eksiye düşüyor. Ada düzeltmeyi yapıp bir commit daha atıyor. Bu commit'in Alan'ın önüne gelmesi için ne yapması gerekir? Yeni bir pull request mi açar, eskisini kapatıp yeniden mi açar?

Hiçbiri. Aynı branch'e push etmesi yeter. Pull request bir commit listesine değil bir branch'e bağlıdır; branch ilerledikçe PR da kendiliğinden güncellenir. Bu yazı bu fikrin etrafında dönüyor: GitHub flow, pull request'in yaşam döngüsü, üç merge yöntemi ve fork ile çalışmak. Örneklerde remote yine GitHub'ın yerine geçen yerel bare repo ../bookstore.git.

GitHub Flow'un Altı Adımı

GitHub'ın kendi dokümantasyonunda anlattığı GitHub flow altı adımdan oluşur. Bir branch açarsın. Değişikliklerini commit'leyip push edersin. Bir pull request açarsın; istersen draft olarak. Review'dan gelen yorumlara yeni commit'ler push ederek cevap verirsin. Onay ve korumalar tamamlanınca PR'ı merge edersin. Son olarak branch'i silersin.

Kuralın özü şu: main her zaman deploy edilebilir durumda kalır ve oraya giden her değişiklik bir PR'dan geçer. Branch kısa ömürlüdür, tek bir işi taşır ve iş bitince ortadan kalkar. Bir mahalle fırını gibi düşün: hamuru tezgâhta yoğurursun, ama vitrine yalnızca pişmiş ve tadına bakılmış ekmek çıkar. Tezgâh branch, vitrin main, tadına bakan da review.

Pull Request Açmak

Pull request, bir head branch'teki değişikliklerin bir base branch'e merge edilmesi için yapılan öneridir. Bizim örnekte head feature/discount, base main. Branch'i push ettikten sonra GitHub'da repo sayfasına girdiğinde yeni push edilmiş branch için PR açmanı öneren bir uyarı görürsün; Pull requests sekmesinden de açabilirsin. GitHub'a gerçekten push ettiğinde terminalde, remote: ile başlayan satırlarda PR açma bağlantısı da çıkar. Bunlar sunucunun mesajları, bare repo'da görünmezler.

PR formunda base ve head'i seçer, bir başlık ve açıklama yazarsın. Açıklama review yapan kişi içindir: ne değişti, neden değişti, nasıl test edilir. İş henüz bitmediyse PR'ı draft olarak açarsın. Draft bir PR herkese görünür, yorum alabilir, CI'da kontrolleri çalışır ama merge edilemez. Hazır olduğunda draft durumundan çıkarıp review'a açarsın.

Aynı işi GitHub CLI ile terminalden de yapabilirsin. gh yerelde kurulu olmadığı için çıktı göstermiyorum:

# commit mesajlarından başlık ve açıklama doldurarak draft PR açar
$ gh pr create --draft --fill
# PR'ı tarayıcıda açar
$ gh pr view --web
# başkasının PR'ını yerelde bir branch olarak indirir
$ gh pr checkout 1

Review ve Yeni Push'lar

Review yapan kişi PR'ın değişiklik sekmesinde satır satır yorum bırakır ve review'ı üç şekilde bitirir: yalnızca yorum, onay ya da değişiklik isteği. Alan'ın değişiklik isteğine Ada şöyle cevap veriyor:

$ git diff
diff --git a/src/cart.js b/src/cart.js
index 3f2d943..8da0c9c 100644
--- a/src/cart.js
+++ b/src/cart.js
@@ -1,6 +1,6 @@
 function total(items, discount = 0) {
   const sum = items.reduce((acc, item) => acc + item.price, 0);
-  return sum - discount;
+  return Math.max(sum - discount, 0);
 }

 module.exports = { total };
$ git commit -am "Keep cart total from going below zero"
[feature/discount 5c13196] Keep cart total from going below zero
 1 file changed, 1 insertion(+), 1 deletion(-)
$ git push
To ../bookstore.git
   5631353..5c13196  feature/discount -> feature/discount
$ git log --oneline main..feature/discount
5c13196 Keep cart total from going below zero
5631353 Add discount to cart total

Bu kez -u gerekmedi, çünkü branch'in upstream'i ilk push'ta ayarlandı. Push çıktısındaki 5631353..5c13196 aralığı, branch'in remote'ta nereden nereye ilerlediğini söylüyor. GitHub tarafında PR'ın commit listesine yeni commit eklenir ve kontroller yeniden çalışır; Alan sadece yeni değişikliğe bakıp review'ı tamamlar. main..feature/discount ise PR'ın içeriğinin ta kendisi: main'de olmayan, branch'te olan commit'ler.

Üç Merge Yöntemi

PR onaylandı. Bu arada Alan, main'e README'yi güncelleyen bir commit attı. GitHub'daki merge butonunun yanındaki menüde üç yöntem var. Ne yaptıklarını görmek için GitHub'ın yaptığını yerelde yeniden ürettim; her yöntemi aynı başlangıçtan denemek için bare repo'yu üç ayrı klasöre klonladım.

Birincisi merge commit. Varsayılan budur ve GitHub bunu --no-ff ile yapar, yani fast-forward mümkün olsa bile bir merge commit oluşturur:

$ git clone -q bookstore.git try-merge && cd try-merge
$ git merge --no-ff origin/feature/discount -m "Merge branch 'feature/discount'"
Merge made by the 'ort' strategy.
 src/cart.js | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)
$ git log --oneline --graph
*   87678ca Merge branch 'feature/discount'
|\
| * 5c13196 Keep cart total from going below zero
| * 5631353 Add discount to cart total
* | 39474ac Document how to run the cart
|/
* 7f7eeec Add cart total
* 61419c1 Add book list
* 5102cb8 Add README and .gitignore

İkincisi squash. Branch'teki bütün commit'ler tek bir commit'e sıkıştırılır ve main'e o tek commit eklenir:

$ git clone -q bookstore.git try-squash && cd try-squash
$ git merge --squash origin/feature/discount
Automatic merge went well; stopped before committing as requested
Squash commit -- not updating HEAD
$ git commit -m "Add discount to cart total"
[main cc5fa59] Add discount to cart total
 1 file changed, 3 insertions(+), 2 deletions(-)
$ git log --oneline --graph
* cc5fa59 Add discount to cart total
* 39474ac Document how to run the cart
* 7f7eeec Add cart total
* 61419c1 Add book list
* 5102cb8 Add README and .gitignore

Üçüncüsü rebase. Branch'teki her commit tek tek main'in ucuna yeniden uygulanır ve geçmiş düz bir çizgi olarak kalır:

$ git clone -q bookstore.git try-rebase && cd try-rebase
$ git switch feature/discount
Switched to a new branch 'feature/discount'
branch 'feature/discount' set up to track 'origin/feature/discount'.
$ git rebase main
Successfully rebased and updated refs/heads/feature/discount.
$ git switch main
Switched to branch 'main'
Your branch is up to date with 'origin/main'.
$ git merge --ff-only feature/discount
Updating 39474ac..c04d57c
Fast-forward
 src/cart.js | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)
$ git log --oneline --graph
* c04d57c Keep cart total from going below zero
* 7740367 Add discount to cart total
* 39474ac Document how to run the cart
* 7f7eeec Add cart total
* 61419c1 Add book list
* 5102cb8 Add README and .gitignore

Hash'lere dikkat et. Merge commit yönteminde branch'teki 5631353 ve 5c13196 aynen main'in geçmişine girdi. Squash'ta ikisi de yok, yerlerinde tek bir cc5fa59 var. Rebase'de mesajlar aynı ama hash'ler yeni: 7740367 ve c04d57c. GitHub'ın rebase yöntemi de böyle çalışır: committer bilgisini her zaman günceller, yeni SHA'lar üretir ve bu commit'leri senin adına imzalayamaz.

Hangisini Seçmeli?

İki yaklaşım ve kısa tradeoff. Merge commit geçmişi olduğu gibi korur: hangi commit'in hangi PR ile geldiği grafikte görünür ve PR'ı tek bir git revert -m 1 ile geri alabilirsin. Bedeli dallı budaklı bir grafik ve main'de "typo düzelt" gibi ara commit'ler. Squash ise main'i her PR'ın tek commit olduğu temiz bir listeye çevirir; geri almak ve okumak kolaydır, ama branch içindeki ara adımlar main'in geçmişinden kaybolur ve büyük bir PR tek dev bir commit olur. Rebase ikisinin ortasında: düz bir geçmiş ve korunmuş ara commit'ler verir, karşılığında her commit'in kendi başına anlamlı ve derlenebilir olmasını ister.

Küçük ve odaklı PR'lar açan bir ekip için squash çoğu zaman en az kafa yoran seçimdir. Commit geçmişine özen gösteren, her commit'i bilinçli düzenleyen bir ekip için merge commit ya da rebase daha fazla bilgi saklar. Repo yöneticisi bu üç yöntemden hangilerinin açık olacağını repo ayarlarından seçebilir; ekip olarak birinde anlaşıp diğerlerini kapatmak tartışmayı baştan bitirir.

Merge'den Sonra: Branch'i Silmek

PR merge edilince GitHub sana branch'i silme butonunu gösterir. Diyelim ki PR squash ile merge edildi ve branch GitHub'da silindi. Ada'nın yerel repo'sunda durum şöyle:

$ git switch main
Switched to branch 'main'
Your branch is up to date with 'origin/main'.
$ git pull --ff-only --prune
From ../bookstore
 - [deleted]         (none)     -> origin/feature/discount
   7f7eeec..cc5fa59  main       -> origin/main
Updating 7f7eeec..cc5fa59
Fast-forward
 README.md   | 2 ++
 src/cart.js | 5 +++--
 2 files changed, 5 insertions(+), 2 deletions(-)
$ git branch -d feature/discount
error: the branch 'feature/discount' is not fully merged
hint: If you are sure you want to delete it, run 'git branch -D feature/discount'
hint: Disable this message with "git config set advice.forceDeleteBranch false"
$ git branch -D feature/discount
Deleted branch feature/discount (was 5c13196).

İki ayrıntı var. İlki git switch main'in "up to date" demesi: Git bunu son fetch'teki bilgiye göre söylüyor, remote'un o anki hâline bakmıyor. Gerçek durumu pull sonrası görüyoruz. İkincisi -d'nin reddetmesi. Squash sonrasında main'de 5c13196 yok, onun yerine içeriği aynı olan cc5fa59 var. Git içeriğe değil commit'lere baktığı için branch'i merge edilmemiş sayar. Değişikliğin main'e girdiğini PR'dan bildiğin için -D burada güvenli. Rebase yönteminde de aynısı olur. Branch GitHub'da silinmeden önce kendin silmek istersen git push origin --delete feature/discount yeterli. --prune ise remote'ta artık olmayan remote-tracking branch'leri temizler; her seferinde yazmamak için fetch.prune ayarını açabilirsin.

Fork ve Upstream

Açık kaynakta çoğu zaman repo'ya push yetkin yoktur. O zaman repo'yu fork edersin: GitHub, repo'nun kendi hesabında bir kopyasını oluşturur. Sen fork'una push edersin, PR'ı fork'undaki branch'ten orijinal repo'ya açarsın. Orijinal repo'ya geleneksel olarak upstream denir. Fork butonunun yaptığını yerelde git clone --bare ile canlandırdım:

$ git clone --bare bookstore.git ada-bookstore.git
Cloning into bare repository 'ada-bookstore.git'...
done.
$ git clone ada-bookstore.git ada-fork
Cloning into 'ada-fork'...
done.
$ cd ada-fork
$ git remote add upstream ../bookstore.git
$ git remote -v
origin	.../ada-bookstore.git (fetch)
origin	.../ada-bookstore.git (push)
upstream	../bookstore.git (fetch)
upstream	../bookstore.git (push)

Zamanla orijinal repo ilerler; Alan main'e yeni commit'ler atar. Fork'unu güncel tutmak için upstream'den fetch edip kendi main'ini ona taşır, sonra fork'una push edersin:

$ git fetch upstream
From ../bookstore
 * [new branch]      main       -> upstream/main
$ git merge --ff-only upstream/main
Updating cc5fa59..a0198f8
Fast-forward
 .gitignore | 1 +
 1 file changed, 1 insertion(+)
$ git push origin main
To .../ada-bookstore.git
   cc5fa59..a0198f8  main -> main

--ff-only bilinçli bir seçim: fork'undaki main'e hiç commit atmazsan bu merge her zaman fast-forward olur. Kendi işini her zaman ayrı branch'lerde yap, main'i upstream'in aynası olarak tut. GitHub arayüzünde fork sayfasındaki senkron butonu ve gh repo sync komutu aynı işi yapar.

Sık Düşülen Tuzaklar

İlk tuzak PR açıkken branch'i başka işe kullanmak. Branch'e push ettiğin her commit PR'a girer. İndirim PR'ı review beklerken aynı branch'e başka bir özelliğin commit'ini atarsan, Alan iki işi birden review etmek zorunda kalır. Her iş için yeni bir branch aç.

İkincisi devasa PR'lar. Bin satırlık bir PR'ı kimse gerçekten review etmez, sadece onaylar. Küçük PR daha hızlı review edilir, daha az conflict çıkarır ve sorun çıktığında daha kolay geri alınır.

Üçüncüsü squash ya da rebase ile merge edilmiş bir branch'te çalışmaya devam etmek. O branch'in commit'leri main'de artık başka hash'lerle duruyor; aynı branch'e yeni commit atıp yeni bir PR açarsan eski commit'ler de tekrar görünür ve conflict'ler çıkar. Merge edilen branch'i sil, yeni iş için güncel main'den yeni branch aç.

Dördüncüsü fork'un main'ine commit atmak. Bir kez atınca upstream ile fork'un main'i ayrışır ve --ff-only artık çalışmaz. Fork'ta main'e dokunma.

Küçük Bir Deneme

★☆☆ Bir bare repo ve bir klon kur. Bir branch açıp iki commit at, push et, sonra bir commit daha atıp tekrar push et. Push çıktısındaki eski..yeni aralığını git log --oneline ile karşılaştır.

★★☆ Üç merge yöntemini yerelde, aynı branch üzerinde dene. Her birinden sonra git branch -d ile feature branch'ini silmeye çalış. Hangisinde Git itiraz etmiyor, neden?

★★★ Fork senaryosunu kur, ama bu kez fork'un main'ine bilerek bir commit at ve upstream'e de başka bir commit ekle. git merge --ff-only upstream/main ne diyor? Fork'un main'ini, kendi commit'ini bir branch'e taşıyarak upstream'le yeniden aynı hâle getir.

Bir PR'ın yaşam döngüsünü baştan sona gördük. Bir sonraki yazıda bakışı ekibe çeviriyoruz: GitHub flow, Git flow ve trunk-based development arasında nasıl seçim yapılır, branch protection ve rulesets ile main nasıl korunur, CODEOWNERS ve Conventional Commits ekip içinde ne işe yarar.