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

Git Interactive Rebase ve Squash: PR Öncesi Geçmişi Toparlamak

Mert Özen 6 Kas 2026 10 dk 2 görüntülenme
Git Interactive Rebase ve Squash: PR Öncesi Geçmişi Toparlamak

Dağınık bir feature branch'ini git rebase -i ile üç temiz commit'e indiriyoruz: todo listesi, pick, reword, edit, squash, fixup, drop ve sıra değiştirme, commit --fixup ile --autosquash akışı, GitHub'ın squash merge'ü ile farkı ve Git 2.54'teki deneysel git history reword.

Sürüm notu: Bu yazıdaki komutlar Git 2.54 ile denenmiştir; git history Git 2.54 ile gelen deneysel bir komuttur.

Önce Tahmin Et

Ada indirim özelliğini bitirdi ve PR açmadan önce branch'ine baktı:

$ git log --oneline main..feature/discount
b1010e0 (HEAD -> feature/discount) Document discounts in README
e43df45 add tests
69c0579 Add console.log for debugging
8a70245 Fix typo in discount
b03b5bd wip
7dd1804 Add discount function

Bu geçmişi hepimiz üretmişizdir. wip, bir önceki commit'teki yazım hatasını düzelten bir commit, kazara commit'lenmiş bir console.log. Reviewer'ın altı commit'i tek tek okuması gerekiyor ve ilk commit tek başına bozuk bile: export satırında applyDiscont yazıyor. Soru şu: bu altı commit'i, hiçbir kod satırını elle değiştirmeden, üç temiz commit'e indirebilir misin? Bunu yaparken console.log commit'ini sildiğinde kod ne olur?

Todo Listesini Okumak

Interactive rebase, bir önceki yazıdaki rebase'in aynısıdır; tek farkı, commit'leri uygulamadan önce sana bir liste vermesidir. Listeyi düzenler, kaydedip kapatırsın, Git de listeyi yukarıdan aşağı uygular.

$ git rebase -i main

Taban olarak main verdik, yani "main'den sonra bu branch'e eklenen commit'ler". Aynı şeyi git rebase -i HEAD~6 ile de yazabilirdin. Editörde şu açılır:

pick 7dd1804 # Add discount function
pick b03b5bd # wip
pick 8a70245 # Fix typo in discount
pick 69c0579 # Add console.log for debugging
pick e43df45 # add tests
pick b1010e0 # Document discounts in README

# Rebase 84281c4..b1010e0 onto 84281c4 (6 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup [-C | -c] <commit> = like "squash" but keep only the previous
#                    commit's log message, unless -C is used, in which case
#                    keep only this commit's message; -c is same as -C but
#                    opens the editor
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
...
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.

İlk fark edeceğin şey sıranın git log'un tersi olması. git log en yeniyi üstte gösterir; todo listesi ise en eskiden başlar, çünkü Git commit'leri bu sırayla uygulayacak. Liste bir senaryo gibidir: her satır bir komut, Git satırları sırayla oynar.

Komutlar: pick, reword, edit, squash, fixup, drop

Ada listeyi şöyle düzenledi:

pick 7dd1804 # Add discount function
fixup 8a70245 # Fix typo in discount
squash b03b5bd # wip
drop 69c0579 # Add console.log for debugging
edit b1010e0 # Document discounts in README
reword e43df45 # add tests

Satır satır okuyalım. pick commit'i olduğu gibi kullanır. fixup commit'i bir üstündeki commit'e eritir ve mesajını atar; yazım hatası düzeltmesi tam bunun için. Ama düzeltme listede ilk commit'ten iki satır aşağıdaydı. Ada satırı yukarı taşıdı, çünkü fixup ve squash her zaman bir üstteki satırla birleşir. squash da birleştirir ama iki mesajı da sana sunar, ortak bir mesaj yazmanı ister. drop commit'i tamamen çıkarır; satırı silmek de aynı işi görür, ama drop yazmak niyetini görünür kılar. edit commit'i uygular ve orada durur, böylece içeriğini değiştirebilirsin. reword içeriğe dokunmaz, yalnızca mesaj için editör açar.

Ada son iki satırın yerini de değiştirdi: README commit'i artık testlerden önce geliyor. Satırları taşımak commit'lerin sırasını değiştirir. Birbirinden bağımsız dosyalara dokunan commit'lerde bu sorunsuzdur; aynı satırlara dokunan commit'lerin yerini değiştirirsen conflict'e hazırlıklı ol.

Rebase Çalışırken: Squash, Edit, Reword

Kaydedip kapattığında Git ilk durağa gelir. squash satırı için ortak mesaj editörü açılır:

# This is a combination of 3 commits.
# This is the 1st commit message:

Add discount function

# The commit message #2 will be skipped:

# Fix typo in discount

# This is the commit message #3:

wip

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
...

fixup'ın mesajının neden "skipped" olduğunu burada görüyorsun. Ada wip satırını silip yerine bir gövde yazdı:

Add discount function

Rates outside 0..1 throw a RangeError.

Ardından edit durağı geldi:

[detached HEAD d1877a1] Add discount function
 Date: Mon Nov 9 10:05:00 2026 +0300
 1 file changed, 8 insertions(+)
 create mode 100644 src/discount.js
Stopped at b1010e0...  # Document discounts in README
You can amend the commit now, with

  git commit --amend 

Once you are satisfied with your changes, run

  git rebase --continue
$ git log --oneline -3
5107617 (HEAD) Document discounts in README
d1877a1 Add discount function
84281c4 (main) Add .gitignore

Git README commit'ini uyguladı ve elini çekti. Bu noktada repo senin; dosyayı değiştirip commit'i amend'leyebilir, hatta araya yeni commit'ler ekleyebilirsin. Ada README'ye bir satır ekledi:

$ git add README.md
$ git commit --amend --no-edit
[detached HEAD 6572a90] Document discounts in README
 Date: Mon Nov 9 10:10:00 2026 +0300
 1 file changed, 5 insertions(+)
$ git rebase --continue
[detached HEAD 12d9fc9] Add tests for applyDiscount
 Date: Mon Nov 9 10:09:00 2026 +0300
 1 file changed, 5 insertions(+)
 create mode 100644 test/discount.test.js
Successfully rebased and updated refs/heads/feature/discount.

--continue sonrasında reword için editör açıldı, Ada mesajı Add tests for applyDiscount yaptı ve rebase bitti. Sonuç:

$ git log --oneline main..feature/discount
12d9fc9 (HEAD -> feature/discount) Add tests for applyDiscount
6572a90 Document discounts in README
d1877a1 Add discount function
$ git show --stat HEAD~2
commit d1877a194f5e2c55ef71b8f665ed04e314d225c2
Author: Ada Lovelace <ada@example.com>
Date:   Mon Nov 9 10:05:00 2026 +0300

    Add discount function
    
    Rates outside 0..1 throw a RangeError.

 src/discount.js | 8 ++++++++
 1 file changed, 8 insertions(+)

Tahmin sorusunun ikinci yarısı: console.log commit'ini düşürünce o satır koddan da çıktı, çünkü o commit'in getirdiği değişiklik hiç uygulanmadı. drop geri alma değil, hiç olmamış sayma demektir. Üç commit kaldı ve her biri tek başına anlamlı. İlki artık bozuk değil: yazım hatası düzeltmesi içine eridi.

commit --fixup ve --autosquash

Review'da Alan indirimli tutarın iki basamağa yuvarlanmasını istedi. Bu değişiklik mantıken ilk commit'e ait. Yeni bir "Address review" commit'i atmak yerine Git'e bunun hangi commit'in düzeltmesi olduğunu söyleyebilirsin:

$ git diff
diff --git a/src/discount.js b/src/discount.js
index ed645ba..e2fd69e 100644
--- a/src/discount.js
+++ b/src/discount.js
@@ -2,7 +2,7 @@ function applyDiscount(total, rate) {
   if (rate < 0 || rate > 1) {
     throw new RangeError("rate must be between 0 and 1");
   }
-  return total - total * rate;
+  return Math.round((total - total * rate) * 100) / 100;
 }
 
 module.exports = { applyDiscount };
$ git add src/discount.js
$ git commit --fixup=d1877a1
[feature/discount d5ad5ce] fixup! Add discount function
 1 file changed, 1 insertion(+), 1 deletion(-)

Git mesajı kendisi yazdı: fixup! ön eki ve hedef commit'in özet satırı. Hazır olduğunda --autosquash bu commit'leri listede doğru yere kendisi yerleştirir:

$ git rebase -i --autosquash main
pick d1877a1 # Add discount function
fixup d5ad5ce # fixup! Add discount function
pick 6572a90 # Document discounts in README
pick 12d9fc9 # Add tests for applyDiscount
...

Liste zaten hazır geldi; kaydedip kapatman yeterli:

Successfully rebased and updated refs/heads/feature/discount.
$ git log --oneline main..feature/discount
e1032a5 (HEAD -> feature/discount) Add tests for applyDiscount
89f2e5a Document discounts in README
a92c61e Add discount function

Bu akışın güzelliği şu: gün boyunca --fixup commit'lerini birikirsin, PR'ın son hâlini vermeden önce tek bir --autosquash ile hepsini yerine eritirsin. Her seferinde yazmak istemiyorsan git config set rebase.autoSquash true ile interactive rebase'lerde varsayılan yapabilirsin. Branch'i daha önce push'ladıysan bir önceki yazıdaki kural geçerli: --force-with-lease --force-if-includes.

GitHub'ın Squash Merge'ü ile Karşılaştırma

GitHub bir PR'ı birleştirirken üç yöntem sunar: merge commit, squash ve rebase. "Squash and merge" PR'daki bütün commit'leri main'de tek bir commit'e indirir. O zaman neden interactive rebase ile uğraşalım?

Çünkü ikisi farklı soruları cevaplar. Squash merge "bu PR main'de kaç commit olarak dursun?" sorusunun cevabıdır ve cevap her zaman birdir. Interactive rebase ise "bu PR hangi mantıklı adımlardan oluşuyor?" sorusunun. Büyük bir PR'da "önce refactor, sonra özellik, sonra testler" ayrımını korumak, git bisect ve git revert için altın değerindedir. Squash bunu tek bir commit'e gömer. Küçük, tek amaçlı PR'larda squash merge pratiktir; çok adımlı PR'larda geçmişi rebase ile toparlayıp merge commit ya da rebase yöntemiyle birleştirmek daha çok bilgi bırakır.

Squash'ın yerelde bir yan etkisi de var. Aynı şeyi yerelde git merge --squash ile denersen bunu görürsün:

$ git switch main
Switched to branch 'main'
$ git merge --squash feature/discount
Updating 84281c4..223b19e
Fast-forward
Squash commit -- not updating HEAD
...
$ git commit -m "Add discounts (#12)"
[main c44843c] Add discounts (#12)
 3 files changed, 18 insertions(+)
 create mode 100644 src/discount.js
 create mode 100644 test/discount.test.js
$ 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"

main'deki yeni commit branch'teki commit'lerin hiçbiri değil, onların toplamının bir kopyası. Git bu yüzden branch'in merge edildiğini bilemez. GitHub'da squash merge edilen bir PR'ın yerel branch'ini silerken de aynı uyarıyı alırsın; içeriğin main'de olduğundan eminsen -D kullanırsın.

Git 2.54'te Yeni: git history

Interactive rebase güçlüdür ama yalnızca bir mesajı düzeltmek için bütün bir todo listesi açmak ağır gelebilir. Git 2.54 bunun için deneysel git history komutunu getirdi. Şimdilik iki alt komutu var: reword ve split.

$ git history reword HEAD~1
$ git log --oneline main..feature/discount
223b19e (HEAD -> feature/discount) Add tests for applyDiscount
ed0e65c Document discount rules in README
a92c61e Add discount function

Komut editörde yalnızca o commit'in mesajını açtı. Kaydettikten sonra commit ve ondan sonra gelenler yeniden yazıldı, branch yeni uca taşındı. Rebase'den farkı, working tree'ye ve index'e hiç dokunmaması; yarım kalmış değişikliklerin varken bile çalışır. split ise bir commit'in hunk'larını seçerek onu iki commit'e böler. Komut deneysel olduğu için davranışı değişebilir. Merge içeren geçmişte ve conflict çıkarabilecek işlemlerde çalışmaz. Sonraki sürümlerde yeni alt komutlar ekleniyor; bunları sürümler bölümünde ele alacağız.

Sık Düşülen Tuzaklar

Todo listesini git log sırasıyla okumak. Liste en eskiden en yeniye gider. fixup ve squash bir üstteki satırla birleşir; en üst satıra fixup yazarsan Git birleşecek bir commit bulamaz ve hata verir.

Satır silerek commit kaybetmek. Listeden silinen satır drop demektir. Yanlışlıkla sildiysen rebase bitmeden git rebase --abort ile çık; bittiyse reflog yazısında göreceğimiz yolla geri getirirsin.

Paylaşılmış branch'i toparlamak. Interactive rebase de bir rebase'dir; golden rule aynen geçerli. Ekip arkadaşın branch'in üzerine iş kurduysa, onunla konuşmadan geçmişi yeniden yazma.

edit durağında --continue'yu unutmak. Commit'i amend'ledikten sonra rebase hâlâ sürüyor. git status sana nerede olduğunu söyler.

Her şeyi tek commit'e ezmek. Temiz geçmiş az commit demek değildir; her commit'in tek ve anlaşılır bir işi olması demektir.

Küçük Bir Deneme

★☆☆ Bir branch'te üç commit at, ikincisinin mesajında bilerek bir yazım hatası bırak. git rebase -i ile önce reword, sonra aynı işi git history reword ile yap ve iki deneyimi karşılaştır.

★★☆ Dört commit'lik bir branch'te git commit --fixup ile iki düzeltme commit'i üret: biri ilk, biri üçüncü commit için. git rebase -i --autosquash listesinin nasıl geldiğine bak ve sonucu git log -p ile doğrula.

★★★ Todo listesinde aynı dosyanın aynı satırına dokunan iki commit'in yerini değiştir. Çıkan conflict'te HEAD tarafında hangi kod var? Çözdükten sonra exec node test/discount.test.js satırını her pick'ten sonra ekleyip testlerin her commit'te geçtiğini doğrula.

Interactive rebase'de bir satırı yanlışlıkla sildin, ya da reset --hard ile bir commit'i kaybettin. Paniğe gerek yok: Git, HEAD'in gittiği her yeri bir yerde not ediyor. Bir sonraki yazıda git reflog ile kaybolan commit'leri ve silinen branch'leri geri getireceğiz.