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

Git rebase: Geçmişi Yeniden Yazmak ve Güvenle Push'lamak

Mert Özen 6 Kas 2026 10 dk 1 görüntülenme
Git rebase: Geçmişi Yeniden Yazmak ve Güvenle Push'lamak

Merge ile rebase'i aynı senaryoda karşılaştırıyoruz: rebase'in commit'leri neden yeni hash'lerle yeniden yazdığı, rebase sırasında conflict ve ters dönen HEAD, pull --rebase, golden rule, rebase edilmiş branch'i --force-with-lease ve --force-if-includes ile push'lamak ve üst üste branch'ler için --update-refs.

Sürüm notu: Bu yazıdaki komutlar Git 2.54 ile denenmiştir; --force-if-includes Git 2.30'dan, --update-refs Git 2.38'den beri vardır.

Önce Tahmin Et

Ada feature/search branch'inde iki commit attı. O sırada Alan main'e bir commit push'ladı. Örneklerde remote olarak yerel bir bare repo (../bookstore.git) kullanıyoruz; GitHub'daki repo'nun yerine geçiyor.

$ git fetch
From ../bookstore
   0fd0ee3..4bd3174  main       -> origin/main
$ git log --oneline --graph --all
* 4bd3174 (origin/main, origin/HEAD) Add price formatter
| * 0ad0a2b (HEAD -> feature/search) Document search in README
| * 48cdec9 Add search by title
|/  
* 0fd0ee3 (main) Add .gitignore
* eaa08db Add cart total
* e8b2239 Add book list
* e928043 Add README

Ada birazdan git rebase origin/main çalıştıracak. Komut bittiğinde 48cdec9 commit'i nerede olacak? feature/search'ün içinde mi, başka bir yerde mi, yoksa hiçbir yerde mi?

Merge mi, Rebase mi?

İki branch ayrıldığında onları birleştirmenin iki yolu var. Aynı durumda merge yapsaydın şunu görürdün:

$ git merge origin/main
Merge made by the 'ort' strategy.
 src/format.js | 5 +++++
 1 file changed, 5 insertions(+)
 create mode 100644 src/format.js
$ git log --oneline --graph
*   157568a (HEAD -> feature/search) Merge remote-tracking branch 'origin/main' into feature/search
|\  
| * 4bd3174 (origin/main, origin/HEAD) Add price formatter
* | 0ad0a2b Document search in README
* | 48cdec9 Add search by title
|/  
* 0fd0ee3 (main) Add .gitignore
...

Merge geçmişi olduğu gibi korur ve iki hattı bir merge commit'iyle bağlar. Hiçbir commit değişmez. Bedeli ise grafiğin her senkronda biraz daha örülmesi. Feature branch'ini her gün main ile güncelliyorsan, iki haftalık bir işin geçmişinde on tane "Merge remote-tracking branch" commit'i birikir.

Rebase ise şunu yapar:

$ git rebase origin/main
Successfully rebased and updated refs/heads/feature/search.
$ git log --oneline --graph --all
* d2f2883 (HEAD -> feature/search) Document search in README
* 1f5995b Add search by title
* 4bd3174 (origin/main, origin/HEAD) Add price formatter
* 0fd0ee3 (main) Add .gitignore
* eaa08db Add cart total
* e8b2239 Add book list
* e928043 Add README

Geçmiş düz bir çizgi. Sanki Ada işe Alan'ın commit'inden sonra başlamış gibi.

Rebase Commit'leri Yeniden Yazar

Tahmin sorusunun cevabı grafikte saklı: 48cdec9 artık yok, onun yerinde 1f5995b var. Rebase commit'leri taşımaz; her birini yeni tabanın üzerine tek tek cherry-pick'ler. Bir önceki yazıda gördüğün gibi, yeni parent yeni hash demektir. Mesajlar, içerik ve author aynı kalır, ama Git'in gözünde bunlar yepyeni commit'ler.

Eski commit'ler silinmedi; hiçbir branch onları göstermediği için grafikte görünmüyorlar ve bir süre reflog'da duruyorlar. Onları nasıl geri bulacağını reflog yazısında göreceğiz. Şimdilik akılda tutman gereken tek cümle şu: rebase geçmişi yeniden yazar.

Bir analoji: merge, iki yolun kesiştiği yere bir kavşak inşa etmektir. Rebase ise kendi yolunu söküp ana yolun bittiği yerden yeniden döşemektir. Yol aynı yere gider ama asfalt yenidir.

Rebase Sırasında Conflict

Ertesi gün Alan main'e books.js dosyasını değiştiren bir commit daha push'ladı. Ada da aynı dosyaya, aynı yere kendi fonksiyonunu eklemişti.

$ git fetch
From ../bookstore
   4bd3174..2ae4532  main       -> origin/main
$ git rebase origin/main
Auto-merging src/books.js
CONFLICT (content): Merge conflict in src/books.js
error: could not apply 9e8660d... Add findById helper
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
hint: You can instead skip this commit: run "git rebase --skip".
hint: To abort and get back to the state before "git rebase", run "git rebase --abort".
hint: Disable this message with "git config set advice.mergeConflict false"
Could not apply 9e8660d... # Add findById helper
$ git status
interactive rebase in progress; onto 2ae4532
Last commands done (3 commands done):
   pick d2f2883 # Document search in README
   pick 9e8660d # Add findById helper
  (see more in file .git/rebase-merge/done)
No commands remaining.
You are currently rebasing branch 'feature/search' on '2ae4532'.
  (fix conflicts and then run "git rebase --continue")
  (use "git rebase --skip" to skip this patch)
  (use "git rebase --abort" to check out the original branch)

Unmerged paths:
  (use "git restore --staged <file>..." to unstage)
  (use "git add <file>..." to mark resolution)
	both modified:   src/books.js

no changes added to commit (use "git add" and/or "git commit -a")

-i vermediğin hâlde status "interactive rebase" diyor; düz rebase de interactive rebase ile aynı altyapıyı kullanıyor, kafanı karıştırmasın. Asıl önemli satırlar şunlar: üç commit'ten ilk ikisi sorunsuz uygulandı, üçüncüsünde durduk. Bir merge'de conflict'i bir kez çözersin; rebase'de ise conflict'ler commit commit gelir. Beş commit'lik bir branch'te aynı dosyayla beş kez karşılaşabilirsin.

Dosyaya bakınca bir tuzak daha görüyorsun:

$ cat src/books.js
const books = [
  { id: 1, title: "Clean Code", price: 30 },
  { id: 2, title: "The Pragmatic Programmer", price: 35 },
];

<<<<<<< HEAD
function count() {
  return books.length;
}

module.exports = { books, count };
=======
function findById(id) {
  return books.find((b) => b.id === id);
}

module.exports = { books, findById };
>>>>>>> 9e8660d (Add findById helper)

Merge'de HEAD senin branch'indi. Rebase'de roller yer değiştirir: HEAD yeni taban, yani main ve şimdiye kadar uygulanmış commit'lerin. Alt taraf ise uygulanmaya çalışılan kendi commit'in. Rebase sırasında "ours" ve "theirs" kelimeleri ters anlam taşır; iki tarafı adlarına göre değil içeriğine göre oku.

Burada ikisi de kalmalı: iki fonksiyonu da tut, export satırını { books, count, findById } yap. Sonra:

$ git add src/books.js
$ git rebase --continue
[detached HEAD da020ef] Add findById helper
 1 file changed, 5 insertions(+), 1 deletion(-)
Successfully rebased and updated refs/heads/feature/search.

--continue commit mesajı için editör açar, kaydedip kapatırsın. --skip o commit'i tamamen bırakır, --abort ise branch'i rebase öncesindeki hâline döndürür. Çıktıdaki detached HEAD ifadesi de normal: rebase sürerken Git commit'leri isimsiz bir HEAD üzerinde üretir ve branch'i en sonda yeni uca taşır.

pull --rebase

Aynı fikir günlük git pull'da da işe yarar. Ada main'de küçük bir commit attı, bu arada Alan da push'ladı. Düz pull burada bir merge commit üretir (ya da pull yazısında gördüğün "divergent branches" hatasını verir). --rebase ise Ada'nın yerel commit'ini yeni gelenlerin üstüne taşır:

$ git pull --rebase
From ../bookstore
   2ae4532..6d97756  main       -> origin/main
Successfully rebased and updated refs/heads/main.
$ git log --oneline --graph -5
* 70bbd7c (HEAD -> main) Ignore log files
* 6d97756 (origin/main, origin/HEAD) Comment cart total
* 2ae4532 Add book count helper
* 4bd3174 Add price formatter
* 0fd0ee3 Add .gitignore
$ git push
To ../bookstore.git
   6d97756..70bbd7c  main -> main

Bunu her seferinde yazmak istemiyorsan git config set pull.rebase true ile varsayılan yap (eski biçimi git config pull.rebase true). Burada yeniden yazılan tek commit, henüz kimsenin görmediği yerel commit'in olduğu için güvenlidir.

Golden Rule: Paylaşılmış Geçmişi Rebase'leme

Rebase'in tek ama kesin kuralı şu: başkalarının üzerine iş kurduğu commit'leri rebase'leme. main gibi herkesin çektiği bir branch'i rebase'leyip push'larsan, ekipteki herkesin elindeki commit'ler bir anda "artık var olmayan" commit'lere dönüşür. Bir sonraki pull'larında aynı değişiklikleri iki kez görürler ve kimin neyi kaybettiğini bulmak saatler alır.

Pratikteki karşılığı: kendi feature branch'ini, PR açılmadan önce ya da PR'da tek başına çalışırken rebase'leyebilirsin. Ama ekip arkadaşın da aynı branch'e push'luyorsa, rebase'den önce ona haber ver. Peki haber vermeyi unuttuysan?

Rebase Edilmiş Branch'i Push'lamak: --force-with-lease

Ada feature/search'ü daha önce push'lamıştı. Bu arada Alan, Ada'dan habersiz aynı branch'e bir test commit'i ekledi. Ada ise main'in güncel hâline rebase'ledi ve push'lamaya çalıştı:

$ git switch feature/search
Switched to branch 'feature/search'
Your branch is up to date with 'origin/feature/search'.
$ git rebase main
Successfully rebased and updated refs/heads/feature/search.
$ git push
To ../bookstore.git
 ! [rejected]        feature/search -> feature/search (fetch first)
error: failed to push some refs to '../bookstore.git'
...

"up to date" satırına dikkat: Git yalnızca son fetch'teki bilgiyi bilir. Rebase'den sonra push her zaman reddedilir, çünkü uzak branch artık senin branch'inin atası değildir. Burada akla gelen ilk şey git push --force'tur ve o, Alan'ın commit'ini sessizce silerdi. Onun yerine:

$ git push --force-with-lease
To ../bookstore.git
 ! [rejected]        feature/search -> feature/search (stale info)
error: failed to push some refs to '../bookstore.git'

--force-with-lease şunu der: "Uzak branch hâlâ benim son gördüğüm commit'teyse üzerine yaz, değilse yazma." Ada'nın origin/feature/search bilgisi eskiydi, uzakta yeni bir commit vardı, push durdu.

Ama bu korumanın bir açığı var. Editörün ya da IDE'n arka planda git fetch yapıyorsa, ya da sen alışkanlıkla fetch çalıştırdıysan, "son gördüğüm" bilgisi güncellenir:

$ git fetch
From ../bookstore
   da020ef..9800da7  feature/search -> origin/feature/search
$ git push --force-with-lease --dry-run
To ../bookstore.git
 + 9800da7...b2e47ca feature/search -> feature/search (forced update)

--dry-run hiçbir şey göndermez, ama ne olacağını söyler: Alan'ın commit'ini görmeden onu ezecektin. Git 2.30 ile gelen --force-if-includes tam bu açığı kapatır. Uzak branch'in ucunun gerçekten senin yerel geçmişine girmiş olmasını şart koşar:

$ git push --force-with-lease --force-if-includes
To ../bookstore.git
 ! [rejected]        feature/search -> feature/search (remote ref updated since checkout)
error: failed to push some refs to '../bookstore.git'
...

Şimdi Ada ne kaçırdığına bakabilir. Önceki yazıdaki eşdeğer commit fikri burada işe yarar; --cherry-pick aynı patch'e sahip commit'leri listeden çıkarır:

$ git log --oneline --cherry-pick --right-only HEAD...origin/feature/search
9800da7 (origin/feature/search) Add search tests
$ git reset --hard origin/feature/search
HEAD is now at 9800da7 Add search tests
$ git rebase main
Successfully rebased and updated refs/heads/feature/search.
$ git push --force-with-lease --force-if-includes
To ../bookstore.git
 + 9800da7...b95344d feature/search -> feature/search (forced update)

Yeni olan tek commit Alan'ınki. Ada uzak hâli temel aldı, rebase'i onun üzerinde tekrarladı ve bu kez push geçti. Kendi eski rebase sonucunu bıraktı, çünkü aynı commit'ler yeni rebase'de yeniden üretildi. Bu ikisini her seferinde yazmak yerine git config set push.useForceIfIncludes true ile --force-if-includes'u varsayılan yapabilirsin; o zaman --force-with-lease yazman yeterli olur.

Üst Üste Branch'ler: --update-refs

Büyük bir işi parçalara böldüğünde branch'ler üst üste biner: feature/search-ui, feature/search'ün üzerinde durur. main ilerlediğinde üstteki branch'i rebase'lersen alttaki eski commit'lerde kalır. Git 2.38'deki --update-refs aradaki branch'leri de birlikte taşır:

$ git switch feature/search-ui
Switched to branch 'feature/search-ui'
$ git rebase --update-refs main
Successfully rebased and updated refs/heads/feature/search-ui.
Updated the following refs with --update-refs:
	refs/heads/feature/search
$ git log --oneline --graph main~1..feature/search-ui
* 8c4e525 (HEAD -> feature/search-ui) Add search box markup
* 132def6 (feature/search) Add search tests
* 137f667 Add findById helper
* 4495cc6 Document search in README
* dc775e3 Add search by title
* 6440e17 (origin/main, origin/HEAD, main) Rename project in README

feature/search da yeni commit'lerin üzerine taşındı. Bunu sürekli kullanıyorsan git config set rebase.updateRefs true ile açık bırakabilirsin. İki branch'i de sonrasında ayrı ayrı push'lamayı unutma.

Sık Düşülen Tuzaklar

main'i rebase'lemek. Rebase'lenen branch senin feature branch'in olmalı; komut "bulunduğun branch'i verdiğin tabanın üzerine taşı" der. main'deyken git rebase feature/search yazarsan tersini yaparsın.

Conflict'te HEAD'i kendi kodun sanmak. Rebase'de HEAD tabandır. Yanlış tarafı seçersen kendi değişikliğini sessizce kaybedersin.

Rebase'i yarıda bırakmak. Conflict'i çözdükten sonra git commit yazıp bırakırsan rebase hâlâ sürer. git status "rebase in progress" diyorsa işin bitmemiştir: --continue ya da --abort.

Alışkanlıkla --force kullanmak. Rebase'den sonraki push için --force-with-lease --force-if-includes kullan. CI'da bile, örneğin otomatik güncellenen bir branch'te, düz --force başkasının işini ezer.

Uzun süre rebase'lememek. İki hafta main'den uzak kalan bir branch'i rebase'lemek, her commit'te conflict demektir. Sık ve küçük rebase'ler, seyrek ve büyük olanlardan çok daha az acıtır.

Küçük Bir Deneme

★☆☆ bookstore repo'nda main'den bir branch aç, iki commit at, main'e de başka bir dosyaya dokunan bir commit at. Rebase'den önce ve sonra git log --oneline ile hash'leri karşılaştır. Hangileri değişti, hangileri aynı kaldı?

★★☆ Aynı satırı iki branch'te farklı değiştir ve önce merge, sonra (reset ile geri dönüp) rebase ile birleştir. İki durumda HEAD tarafında hangi kodun durduğunu not et.

★★★ İki klon ve bir bare repo kur. Birinde branch'i push'la, diğerinde aynı branch'e commit ekleyip push'la. İlkinde rebase yap ve sırasıyla --force-with-lease, git fetch, --force-with-lease --dry-run ve --force-with-lease --force-if-includes dene. Her adımda neden o sonucu aldığını tek cümleyle yaz.

Bu yazıda rebase'i bir branch'i olduğu gibi taşımak için kullandık. Ama rebase taşırken commit'leri değiştirmene de izin verir: sırasını değiştirmek, birleştirmek, mesajını düzeltmek, aradan birini atmak. Bir sonraki yazıda git rebase -i, fixup commit'leri ve squash ile geçmişi PR'dan önce toparlayacağız.