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

Büyük Repo'larla Çalışmak: Git LFS, Partial Clone, Sparse Checkout, Shallow Clone ve Geçmişten Büyük Dosya Silmek

Mert Özen 15 Kas 2026 13 dk 7 görüntülenme
Büyük Repo'larla Çalışmak: Git LFS, Partial Clone, Sparse Checkout, Shallow Clone ve Geçmişten Büyük Dosya Silmek

Silinmiş bir dosya bile geçmişte durduğu için her clone'u şişirir; çözüm clone'da partial clone, sparse checkout ve shallow clone ile yükü atlamak, kalıcı olarak da git filter-repo ile geçmişi temizlemektir. Bookstore'un kitap kapaklarını Git LFS'e taşıyor, GitHub LFS kotalarını, CI'da fetch-depth'i ve scalar'ı Git 2.56 ile deniyoruz.

Sürüm notu: Bu yazıdaki komutlar Git 2.56.1, Git LFS (Large File Storage; büyük dosyaları Git dışında saklayan eklenti) 3.8.1 ve git-filter-repo 2.47.0 ile denenmiştir; git backfill Git 2.49'dan, cone modunun varsayılan olması Git 2.37'den beri vardır. GitHub kota ve limitleri 15 Kasım 2026 itibarıyla GitHub Docs'tan alınmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) Angular istemcisi bookstore-web, birkaç TypeScript dosyası, dört kitap kapağı ve bir README'den ibaret. Kapaklar üç kez güncellenmiş. Remote olarak yine 127.0.0.1 üzerindeki yerel Git HTTP sunucusunu kullanıyoruz.

$ git clone -q http://127.0.0.1:52030/git/bookstore-web.git
$ cd bookstore-web
$ git log --oneline
7bef651 Remove local database backup
4cd32eb Add checkout page
aa30bd7 Add local database backup
ba3856e Add cart page
70fecea Update book covers (round 3)
85e6fb9 Update book covers (round 2)
736fe5f Update book covers (round 1)
806dd26 Add Angular skeleton

Çalışma klasöründeki dosyaların toplamı 2,4 MB civarında. Sence .git klasörü ne kadar?

$ du -sh .git
 67M	.git
$ git count-objects -vH
count: 0
size: 0 bytes
in-pack: 49
packs: 1
size-pack: 67.06 MiB
prune-packable: 0
garbage: 0
size-garbage: 0 bytes

67 MB. Suçlu log'da saklı: biri aa30bd7'de 60 MB'lık bir veritabanı yedeğini commit'lemiş, iki commit sonra fark edip silmiş. Silmek dosyayı yalnız son snapshot'tan çıkardı; geçmişteki commit hâlâ onu gösteriyor ve her clone o 60 MB'ı indiriyor. Kapakların eski sürümleri de aynı şekilde geçmişte duruyor. Bu yazıda önce bu yükü clone sırasında nasıl atlayacağına, sonra kalıcı olarak nasıl kurtulacağına bakıyoruz.

Partial Clone: Önce Geçmiş, Dosyalar Sonra

$ git clone -q --filter=blob:none http://127.0.0.1:52030/git/bookstore-web.git web-blobless
$ cd web-blobless
$ du -sh .git
2.5M	.git
$ git rev-list --objects --all --missing=print | grep -c '^?'
9

--filter=blob:none ile clone'a "bütün commit'leri ve tree'leri getir, blob'ları yalnızca checkout'ta lazım olanlarla sınırla" demiş oldun. 67 MB 2,5 MB'a indi. Eksik dokuz object; kapakların ilk iki sürümü ve veritabanı yedeği. log çalışmaya devam ediyor, çünkü commit'ler ve tree'ler tam:

$ git log --oneline -1 -- covers/dune.jpg
70fecea Update book covers (round 3)
$ git show --stat HEAD~5 | tail -3
 covers/middlemarch.jpg | Bin 614400 -> 614400 bytes
 covers/the-hobbit.jpg  | Bin 614400 -> 614400 bytes
 4 files changed, 0 insertions(+), 0 deletions(-)
$ git rev-list --objects --all --missing=print | grep -c '^?'
1

show --stat eski kapakların boyutunu bilmek zorundaydı; Git eksik blob'ları remote'tan sessizce indirdi ve eksik sayısı bire düştü. Partial clone'da remote "promisor remote" olarak kaydedilir ve ihtiyaç anında ondan object çekilir. Bunun iki sonucu var: remote'a erişimin yoksa (uçakta ya da şirket ağının dışındayken) eski bir dosyanın içeriğine bakan komut hata verir; git blame gibi çok sayıda eski blob okuyan komutlar ilk çalışmada yavaştır. Eksikleri toplu indirmek istersen Git 2.49'dan beri git backfill var:

$ git backfill
$ git rev-list --objects --all --missing=print | grep -c '^?'
0
$ du -sh .git
 67M	.git

Geçmişteki o 60 MB'ı da indirdi. Yani partial clone sorunu ertelemiş; çözmemiş.

İkinci filtre --filter=tree:0; tree'leri de ihtiyaç anına bırakır:

$ cd ..
$ git clone -q --filter=tree:0 http://127.0.0.1:52030/git/bookstore-web.git web-treeless
$ du -sh web-treeless/.git
2.5M	web-treeless/.git

Bu küçük repo'da fark görünmüyor, çünkü tree sayısı az. Binlerce klasörlü büyük repo'larda fark büyür, ama geçmişte dolaşan her komut bu sefer tree indirmek zorunda kalır. GitHub'ın partial clone yazısındaki öneriyle aynı yerde duruyoruz: geliştirici makinesinde blobless, işi bitince silinen tek seferlik CI build'lerinde treeless.

31-clone-filters-tr.png

Sparse Checkout: Repo'nun Yalnız Bir Kısmı

Partial clone neyin indirileceğini, sparse checkout ise çalışma klasörüne neyin açılacağını belirler. Kapaklarla işi olmayan bir backend geliştiricisi yalnızca kodu ve dokümanı istiyor:

$ git clone -q --filter=blob:none --sparse http://127.0.0.1:52030/git/bookstore-web.git web-sparse
$ cd web-sparse
$ ls
README.md
$ git sparse-checkout set src docs
$ git sparse-checkout list
docs
src
$ ls
README.md
docs
src
$ du -sh .git
156K	.git

--sparse ile clone yalnızca kökteki dosyaları açtı, set iki klasörü ekledi. Cone modu (Git 2.37'den beri varsayılan) kalıp yerine klasör adlarıyla çalışır ve büyük repo'larda hızlıdır. İkisini birlikte kullanınca covers/ ne indirildi ne açıldı; .git 156 KB. git sparse-checkout disable her şeyi geri açar. Sparse checkout'un en çok işe yaradığı yer, birçok uygulamanın bir arada durduğu büyük monorepo'lar.

Shallow Clone ve CI'da fetch-depth

Üçüncü yol geçmişi kesmek:

$ git clone -q --depth 1 http://127.0.0.1:52030/git/bookstore-web.git web-shallow
$ cd web-shallow
$ git rev-parse --is-shallow-repository
true
$ git log --oneline
7bef651 Remove local database backup
$ git describe --tags
fatal: No names found, cannot describe anything.
$ git fetch -q --unshallow --tags
$ git describe --tags
v1.1.0-3-g7bef651

--depth 1 yalnız son commit'i getirir. Tek seferlik bir build için en hızlı yol bu ve actions/checkout da varsayılan olarak fetch-depth: 1 ile çalışır. Ama geçmiş isteyen her şey kırılır: git describe hiçbir tag göremediği için hata verdi; tag'den sürüm üreten MinVer gibi araçlar da aynı sebeple yanlış sürüm hesaplar. Versioning ve Release Management serisinin MinVer yazısında bunun çözümünü konuştuk: checkout adımında fetch-depth: 0. Shallow clone'u geliştirici makinesinde kullanma; üzerinde fetch, merge ve push çalışır ama sunucuya ek yük bindirir ve log ile blame geçmişin bittiği yerde durur.

Git LFS: Kapakları Repo'nun Dışına Almak

Kapak gibi sık değişen binary dosyalar için asıl çözüm, onları Git'in object'lerinden çıkarmak. Git LFS dosyanın yerine repo'ya küçük bir pointer yazar, içeriği ayrı bir LFS sunucusuna koyar. Eklentiyi makinene bir kez kurarsın; install global config'e LFS'in clean ve smudge filtrelerini yazar, LFS komutları da repo'ya pre-push başta olmak üzere birkaç hook ekler. Yeni bir repo'da sıradaki adım git lfs track 'covers/*.jpg' olurdu. Ama Bookstore'da kapaklar geçmişte zaten normal blob olarak duruyor ve track yalnız bundan sonraki commit'leri etkiler. Eskileri de taşımak için git lfs migrate geçmişi yeniden yazar. Önce ölç, sonra taşı:

$ git lfs install
Git LFS initialized.
$ git clone -q http://127.0.0.1:52030/git/bookstore-web.git web-lfs
$ cd web-lfs
$ git lfs migrate info --everything --include='covers/**'
Sorting commits: ..., done.
Examining commits: 100% (6/6), done.
*.jpg	7.4 MB	12/12 files	100%
$ git lfs migrate import --everything --include='covers/*.jpg'
Sorting commits: ..., done.
Rewriting commits: 100% (6/6), done.
Updating refs: ..., done.
Checkout: ..., done.
$ cat .gitattributes
covers/*.jpg filter=lfs diff=lfs merge=lfs -text
$ git log --oneline -4
503165a Add checkout page
32a0435 Add cart page
54f7722 Update book covers (round 3)
eb05092 Update book covers (round 2)

(Bu clone'da veritabanı yedeğini bir sonraki bölümdeki yöntemle önceden silmiştik, o yüzden altı commit var.) --everything bütün branch ve tag'leri dolaşır; on iki kapak sürümünün hepsi LFS'e geçti ve her commit'e .gitattributes eklendi. filter=lfs, Git'e bu dosyaları stage'lerken içeriği LFS'e verip yerine pointer koymasını söyleyen satır. Repo'da Git'in sakladığı şey artık bu:

$ git show HEAD:covers/dune.jpg
version https://git-lfs.github.com/spec/v1
oid sha256:9375ab4baf3bfdde9cc495ae4e8b798ae10c66f1063eca83a241e39632f204c8
size 614400
$ git lfs ls-files
9375ab4baf - covers/dune.jpg
52a705f2cb - covers/emma.jpg
44be4f39ad - covers/middlemarch.jpg
723c922e55 - covers/the-hobbit.jpg
$ git lfs checkout
Checking out LFS objects: 100% (4/4), 2.5 MB | 0 B/s, done.
$ wc -c covers/dune.jpg
  614400 covers/dune.jpg

Üç satırlık, 130 baytlık bir metin. ls-files'taki - çalışma klasöründe yalnız pointer olduğunu söylüyor: migrate dosyaları LFS'e taşıdı ama çalışma klasörüne pointer'ları bıraktı. git lfs checkout içerikleri yerel LFS deposundan yerleştirdi. Bundan sonrası sıradan Git:

$ git add covers/frankenstein.jpg
$ git commit -q -m 'Add Frankenstein cover'
$ git lfs ls-files
9375ab4baf * covers/dune.jpg
52a705f2cb * covers/emma.jpg
b3082edffa * covers/frankenstein.jpg
44be4f39ad * covers/middlemarch.jpg
723c922e55 * covers/the-hobbit.jpg
$ git lfs track 'covers/*.png'
Tracking "covers/*.png"
$ git diff .gitattributes
diff --git a/.gitattributes b/.gitattributes
index 186ef86..2a1fe0a 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1 +1,2 @@
 covers/*.jpg filter=lfs diff=lfs merge=lfs -text
+covers/*.png filter=lfs diff=lfs merge=lfs -text

* içeriğin yerelde olduğunu gösteriyor. track yalnızca .gitattributes'a bir satır ekler; bu dosyayı mutlaka commit'le. Push sırasında pre-push hook yeni LFS object'lerini önce LFS sunucusuna gönderir, sonra Git push'u devam eder.

Migrate bütün hash'leri değiştirdi; rebase yazısındaki kural burada da geçerli: yeniden yazılmış geçmiş ancak force push ile remote'a gider ve ekipteki herkes yeniden clone'lamalıdır. Bu laboratuvarda push çalıştırmadık; GitHub'da adımlar şöyle olur:

# çalıştırılmadı: ekip push'u durdurduktan sonra, bir kez
$ git push --force --all origin
$ git push --force --tags origin

LFS'li Repo'yu Clone'lamak ve GitHub Kotaları

Clone tarafında da seçim yapabilirsin. Aşağıdaki örnekte remote'u file:// URL'iyle verdik; git-lfs bu durumda LFS object'lerini kaynak klasörden kendi yerel aktarım yoluyla okuyor. GitHub'da aynı komutlar GitHub'ın LFS sunucusuyla konuşur:

$ GIT_LFS_SKIP_SMUDGE=1 git clone -q file:///home/user/src/web-lfs web-pointers
$ cd web-pointers
$ cat covers/emma.jpg
version https://git-lfs.github.com/spec/v1
oid sha256:52a705f2cb15bdc098fcf856ba1fbf8dad2e67708b9e952dd8ca4fc1e942cc35
size 614400
$ git lfs pull --include='covers/emma.jpg'
$ wc -c covers/emma.jpg covers/dune.jpg
  614400 covers/emma.jpg
     131 covers/dune.jpg
  614531 total

GIT_LFS_SKIP_SMUDGE=1 clone'da LFS içeriğini indirmez; git lfs pull --include ile yalnızca ihtiyacın olanı çekersin. actions/checkout'ta LFS varsayılan olarak kapalıdır (lfs: false); build gerçekten kapaklara ihtiyaç duymuyorsa böyle kalması kotanı korur.

GitHub'ın LFS limitlerini bilmek gerekir. Ücretsiz planda ve Pro'da 10 GiB depolama ve 10 GiB aylık bant genişliği, Team ve Enterprise Cloud'da 250 GiB dahil. Aşım, bir ödeme yöntemi ve bütçe tanımlıysa GiB başına ücretlendirilir; ödeme yöntemi yoksa ya da bütçe 0 ise kota dolduğunda LFS o ay için engellenir ve clone'larda yalnız pointer dosyaları gelir. Tek bir LFS dosyası Free ve Pro'da 2 GB, Team'de 4 GB, Enterprise Cloud'da 5 GB olabilir. LFS'siz normal Git'te ise GitHub 50 MiB üstündeki dosyalar için uyarır, 100 MiB üstündekileri reddeder. CI'da her build'in LFS çekmesi bant genişliğini hızla tüketir; ölçmeden lfs: true açma.

Büyük Dosyayı Geçmişten Silmek: git filter-repo

Geriye 60 MB'lık yedek kaldı. Bunu kalıcı olarak silmenin önerilen aracı git filter-repo; Git projesinin filter-branch belgesi de onu öneriyor. Tek bir Python dosyası, Git 2.22 ve Python 3.6 ve üstü istiyor. Önce bir analiz:

$ git clone -q http://127.0.0.1:52030/git/bookstore-web.git web-clean
$ cd web-clean
$ git filter-repo --analyze
Processed 19 blob sizes
Processed 8 commits
Writing reports to .git/filter-repo/analysis...done.
$ head -n 12 .git/filter-repo/analysis/path-all-sizes.txt
=== All paths by reverse accumulated size ===
Format: unpacked size, packed size, date deleted, path name
    62914560   62933766 2026-11-16 db/BookstoreDb.bak
     1843200    1843800 <present>  covers/the-hobbit.jpg
     1843200    1843800 <present>  covers/middlemarch.jpg
     1843200    1843800 <present>  covers/emma.jpg
     1843200    1843800 <present>  covers/dune.jpg
          47         51 <present>  README.md
          29         39 <present>  src/app/checkout.page.ts
          28         38 <present>  src/app/catalog/catalog.page.ts
          26         36 <present>  src/app/app.routes.ts
          25         35 <present>  src/app/cart.page.ts

Rapor silinmiş dosyayı silindiği tarihle birlikte en üste koydu. Şimdi onu geçmişin tamamından çıkaralım:

$ git filter-repo --invert-paths --path db/BookstoreDb.bak
NOTICE: Removing 'origin' remote; see 'Why is my origin removed?'
        in the manual if you want to push back there.
        (was http://127.0.0.1:52030/git/bookstore-web.git)
Parsed 8 commits
HEAD is now at 4170685 Add checkout page

New history written in 0.07 seconds; now repacking/cleaning...
Repacking your repo and cleaning out old unneeded objects
Completely finished after 0.17 seconds.
$ git log --oneline
4170685 Add checkout page
ba3856e Add cart page
70fecea Update book covers (round 3)
85e6fb9 Update book covers (round 2)
736fe5f Update book covers (round 1)
806dd26 Add Angular skeleton
$ git remote -v
$ git count-objects -vH
count: 0
size: 0 bytes
in-pack: 43
packs: 1
size-pack: 7.04 MiB
prune-packable: 0
garbage: 0
size-garbage: 0 bytes

Üç şeye dikkat et. Yedeği ekleyen ve silen iki commit boşaldığı için tamamen kayboldu. Yedekten önceki commit'lerin hash'leri (ba3856e ve öncesi) değişmedi; değişen yalnızca dosyanın geçtiği yerden sonrası. Son olarak filter-repo origin'i sildi (git remote -v boş): yanlışlıkla eski remote'a push'lamanı engellemek için, araç taze bir clone üzerinde çalışmanı ve remote'u bilerek yeniden eklemeni istiyor.

Sonrası bir ekip işi. Remote'u yeniden ekleyip force push'larsın, herkes eski clone'unu silip yeniden clone'lar, açık pull request'ler eski commit'leri göstermeye devam edeceği için yeniden açılır. Kimse eski clone'dan push'larsa dosya geri gelir. Silinen şey bir secret ise bu adımlar yetmez: GitHub'da eski commit'ler bir süre erişilebilir kalabilir ve secret zaten başkasının eline geçmiş olabilir. Önce secret'ı iptal edip yenisini üret; DevSecOps serisinin secret scanning yazısı sızan secret'a ilk müdahaleyi anlatıyor.

Bakım: git maintenance ve scalar

Repo'ları zamanla hızlı tutan iş git maintenance. git maintenance run --task=commit-graph gibi tek bir görevi elle çalıştırabilir, ya da git maintenance start ile işletim sisteminin zamanlayıcısına (Windows'ta Task Scheduler, macOS'ta launchd, Linux'ta systemd timer ya da cron) saatlik prefetch, commit-graph ve gece repack görevleri kaydedebilirsin. start kullanıcı seviyesinde zamanlanmış görev oluşturduğu için bu laboratuvarda çalıştırmadık.

scalar, Git 2.38'den beri Git'in içinde gelen ve büyük repo'lar için önerilen ayarları tek komutta yapan bir araç:

$ scalar clone --no-maintenance http://127.0.0.1:52030/git/bookstore-web.git web-scalar 2>&1 | tail -3
Already on 'main'
branch 'main' set up to track 'origin/main'.
Your branch is up to date with 'origin/main'.
$ ls web-scalar web-scalar/src
web-scalar:
src

web-scalar/src:
README.md
$ git -C web-scalar/src config list --local | grep -E '^(core.fsmonitor|core.untrackedcache|index.version|gc.auto|fetch.writecommitgraph|remote.origin.partialclonefilter|remote.origin.promisor)='
core.untrackedcache=true
core.fsmonitor=true
remote.origin.promisor=true
remote.origin.partialclonefilter=blob:none
fetch.writecommitgraph=false
gc.auto=0
index.version=4

Yaptığı şey bu yazının özeti: blobless partial clone, yalnızca kök dosyalarla başlayan cone modunda sparse checkout, dosya değişikliklerini izleyen fsmonitor, otomatik gc yerine zamanlanmış maintenance. Kodu src/ altına koyar ki build çıktıları repo'nun yanında ayrı durabilsin. --no-maintenance vermeseydik zamanlanmış görevleri de kuracaktı. Bookstore boyutundaki bir repo'da gerekmez; yüz binlerce dosyalı bir monorepo'da ilk denenecek şeydir.

Sık Düşülen Tuzaklar

İlki LFS kurulu olmayan bir makinede çalışmak. Ayşe yeni makinesinde git-lfs'i kurmayı unutmuş (bu örnekte git-lfs'i PATH'ten çıkararak taklit ettik):

$ git clone -q file:///home/user/src/web-lfs web-nolfs
$ cd web-nolfs
$ cat covers/emma.jpg
version https://git-lfs.github.com/spec/v1
oid sha256:52a705f2cb15bdc098fcf856ba1fbf8dad2e67708b9e952dd8ca4fc1e942cc35
size 614400
$ git add covers/jane-eyre.jpg
$ git commit -q -m 'Add Jane Eyre cover'

Kapaklar resim değil metin; uygulama bozuk görselle açılır. Daha kötüsü, yeni kapak LFS'e değil, normal blob olarak commit'lendi; hiçbir uyarı yok. Aynı repo'yu LFS kurulu bir makinede kontrol edince sorun görünüyor:

$ git lfs fsck --pointers
pointer: unexpectedGitObject: "covers/jane-eyre.jpg" (treeish 7cbc443c45be30ab49cf38f347173cefb747f23b) should have been a pointer but was not

Bunu CI'da bir adım olarak çalıştırmak, sorunu pull request aşamasında yakalar. Tersi de olur: biri pointer metnini dosyanın kendisi sanıp düzenler ve commit'ler.

İkincisi shallow clone'da sürüm hesaplamak. git describe tag'i göremeyince hata verir; MinVer ise Versioning serisindeki yazıda gördüğümüz gibi hiçbir uyarı vermeden 0.0.0-alpha.0 sürümüne düşer ve build yeşil geçer. Sürüm üreten her workflow'da fetch-depth: 0 olmalı.

Üçüncüsü dosyayı silmenin geçmişi temizlediğini sanmak. Açılıştaki 67 MB tam olarak bu. Büyük bir dosyayı fark ettiğinde ilk iş ne kadar yayıldığını öğrenmek: henüz push'lanmadıysa commit --amend ya da interactive rebase yeter; push'landıysa filter-repo ve ekipçe yeniden clone.

Küçük Bir Deneme

★☆☆ Kendi bir repo'nu --filter=blob:none ile clone'la ve du -sh .git değerini normal clone ile karşılaştır. Sonra git log -p çalıştır ve git rev-list --objects --all --missing=print | grep -c '^?' sayısının nasıl değiştiğine bak.

★★☆ Bir deneme repo'suna birkaç büyük binary dosya commit'le, sonra git lfs migrate info --everything ve git lfs migrate import ile LFS'e taşı. Taşımadan önceki ve sonraki commit hash'lerini karşılaştır.

★★★ Bir repo'ya 50 MB'lık bir dosya ekleyip sonraki commit'te sil. Taze bir clone'da git filter-repo --analyze raporundan dosyayı bul, geçmişten sil ve size-pack değerinin değiştiğini doğrula. Ardından eski clone'dan bir fetch yapıp eski commit'lerin orada nasıl durmaya devam ettiğini gör.

Bu yazıda repo'nun boyutuyla uğraştık. Bir sonraki yazıda Git'in nasıl davrandığını belirleyen ayarlara dönüyoruz: includeIf ile iş ve kişisel kimliği klasöre göre ayırmak, iki GitHub hesabını aynı makinede kullanmak, url.insteadOf ve ekip için önerilen ayarlar.