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

Git Submodule ve Subtree: Başka Bir Repo'yu Projeye Bağlamak ve Monorepo mu Polyrepo mu?

Mert Özen 15 Kas 2026 11 dk 8 görüntülenme
Git Submodule ve Subtree: Başka Bir Repo'yu Projeye Bağlamak ve Monorepo mu Polyrepo mu?

Submodule, üst repo'da dosyaları değil yalnız bir commit hash'ini (gitlink) saklar; bu yüzden clone'da --recurse-submodules, güncellemede pointer'ı commit'lemek gerekir. Bookstore'un ortak sözleşme repo'sunu submodule ve subtree ile bağlıyor, CI ayarını, paketle paylaşmayı ve monorepo kararını karşılaştırıyoruz.

Sürüm notu: Bu yazıdaki komutlar Git 2.56.1 ile denenmiştir. git subtree Git'in contrib klasöründen gelir; Git for Windows'ta kurulu gelir, bazı Linux dağıtımlarında ayrı paket olarak kurulur.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) üç repo'dan oluşuyor: API için bookstore-api, Angular istemcisi için bookstore-web ve ikisinin de kullandığı OpenAPI sözleşmesini tutan bookstore-contracts. Sözleşmeyi iki repo'ya kopyalamak yerine bookstore-api'ye submodule olarak bağlıyorsun. Örneklerde remote olarak 127.0.0.1 üzerinde çalışan yerel bir Git HTTP sunucusu kullanıyoruz; GitHub'daki repo'ların yerine geçiyor.

$ git clone -q http://127.0.0.1:52030/git/bookstore-api.git
$ cd bookstore-api
$ git submodule add http://127.0.0.1:52030/git/bookstore-contracts.git contracts
Cloning into '/home/user/src/bookstore-api/contracts'...

contracts klasöründe iki dosya var: README.md ve openapi/bookstore.yaml. Şimdi git status çalıştırınca staging area'da kaç dosya görürsün? İki mi, üç mü, dört mü?

$ git status
On branch main
Your branch is up to date with 'origin/main'.

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
	new file:   .gitmodules
	new file:   contracts

İki. Biri .gitmodules, diğeri contracts'ın kendisi; içindeki dosyalar hiç görünmüyor. Bu yazının geri kalanı bu iki satırın neden böyle olduğunu anlamaya dayanıyor.

Submodule Aslında Ne Saklar?

.gitmodules sıradan bir metin dosyası; submodule'ün yolunu ve nereden clone'lanacağını yazar:

$ cat .gitmodules
[submodule "contracts"]
	path = contracts
	url = http://127.0.0.1:52030/git/bookstore-contracts.git
$ git diff --cached contracts
diff --git a/contracts b/contracts
new file mode 160000
index 0000000..7a81215
--- /dev/null
+++ b/contracts
@@ -0,0 +1 @@
+Subproject commit 7a81215602efdc607e75b0ff8719d2878f89ef69

contracts için gelen diff tek satır: bir commit hash'i. Commit'ledikten sonra tree'ye bakınca aynı şeyi görürsün:

$ git commit -q -m 'Add bookstore-contracts as a submodule'
$ git ls-tree HEAD
100644 blob 0581a423c99009afd16f2c189bdf39612b515f88	.gitmodules
100644 blob 276f89a62bfe39e9f41041d496592592cc9156ea	README.md
160000 commit 7a81215602efdc607e75b0ff8719d2878f89ef69	contracts
$ git -C contracts log --oneline -1
7a81215 Add books endpoint contract

30-submodule-gitlink-tr.png

Commit'in içi yazısında gördüğümüz tree'lerde her satır bir blob ya da başka bir tree gösteriyordu. Burada üçüncü bir tür var: mode'u 160000 olan ve tipi commit olan bir girdi. Buna gitlink deniyor. Üst repo (superproject) bookstore-contracts'ın dosyalarını saklamaz; yalnızca "bu commit'te contracts klasörü şu commit'te olmalı" bilgisini saklar. Dosyalar contracts/.git'in gösterdiği ayrı bir repo'da durur (Git onu .git/modules/contracts altına koyar).

Bu tasarımın sonucu şu: bookstore-api'nin her commit'i, bookstore-contracts'ın tam olarak hangi sürümüyle çalıştığını kayıt altına alır. Altı ay önceki bir commit'e döndüğünde, sözleşme de o günkü hâline döner. Bedeli ise o pointer'ı senin elle yönetmen.

Clone'larken: --recurse-submodules

Ada değişikliği push'ladı, Emre repo'yu her zamanki gibi clone'luyor:

$ git clone -q http://127.0.0.1:52030/git/bookstore-api.git api-emre
$ cd api-emre
$ ls contracts
$ git submodule status
-7a81215602efdc607e75b0ff8719d2878f89ef69 contracts
$ git submodule update --init --recursive
Submodule 'contracts' (http://127.0.0.1:52030/git/bookstore-contracts.git) registered for path 'contracts'
Cloning into '/home/user/src/api-emre/contracts'...
Submodule path 'contracts': checked out '7a81215602efdc607e75b0ff8719d2878f89ef69'
$ git submodule status
 7a81215602efdc607e75b0ff8719d2878f89ef69 contracts (v1.0.0)

Düz clone'dan sonra contracts boş bir klasör. submodule status satırının başındaki - "bu submodule henüz başlatılmadı" demek. update --init önce .gitmodules'taki URL'i .git/config'e kaydeder, sonra repo'yu clone'layıp gitlink'teki commit'e geçer. --recursive, submodule'ün de kendi submodule'leri varsa onları da getirir. İşin başında hatırlarsan tek adımda da olur:

$ git clone --recurse-submodules http://127.0.0.1:52030/git/bookstore-api.git api-ayse
Cloning into 'api-ayse'...
Submodule 'contracts' (http://127.0.0.1:52030/git/bookstore-contracts.git) registered for path 'contracts'
Cloning into '/home/user/src/api-ayse/contracts'...
Submodule path 'contracts': checked out '7a81215602efdc607e75b0ff8719d2878f89ef69'

Submodule Neden Detached HEAD'de Durur?

Emre'nin clone'unda submodule'ün içine gir:

$ cd contracts
$ git status
HEAD detached at 7a81215
nothing to commit, working tree clean
$ git branch -a
* (HEAD detached at 7a81215)
  main
  remotes/origin/HEAD -> origin/main
  remotes/origin/main

Branch yazısında detached HEAD'i bir uyarı işareti olarak görmüştük. Burada ise normal ve bilerek böyle: üst repo bir branch'i değil, bir commit'i gösteriyor. bookstore-contracts'ın main'i yarın ilerlese bile, bookstore-api kendi gitlink'i ne diyorsa onu ister. Git de submodule'ü o commit'e koyar ve hiçbir branch'e bağlamaz.

İlginç bir ayrıntı: Ada'nın repo'sunda, yani submodule add'in yeni clone'ladığı submodule'de, HEAD hâlâ main'deydi. Detached HEAD, ilk submodule update'ten sonra başlar. Bu fark, tuzaklar bölümündeki "kaybolan commit"in de nedeni.

Submodule'ü Güncellemek ve Pointer'ı Commit'lemek

Sözleşme ekibi kitap detay endpoint'ini ekleyip v1.1.0 tag'ini push'ladı. Ada API'yi bu sürüme taşıyor:

$ git submodule update --remote contracts
From http://127.0.0.1:52030/git/bookstore-contracts
   7a81215..b5f1da9  main       -> origin/main
 * [new tag]         v1.1.0     -> v1.1.0
Submodule path 'contracts': checked out 'b5f1da9975344069cc14b2aaf4fab41144869dce'
$ git status
On branch main
Your branch is up to date with 'origin/main'.

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
	modified:   contracts (new commits)

no changes added to commit (use "git add" and/or "git commit -a")
$ git diff --submodule=log
Submodule contracts 7a81215..b5f1da9:
  > Add book detail endpoint contract
$ git add contracts
$ git commit -q -m 'Bump contracts to v1.1.0'

--remote, gitlink'teki commit yerine submodule'ün remote'undaki branch'in ucunu alır. Branch varsayılan olarak remote'un HEAD'idir; .gitmodules'a branch = release gibi bir satır yazarak değiştirebilirsin. Önemli olan şu: update --remote yalnızca senin çalışma klasöründeki submodule'ü taşır. Ekibin görmesi için git add contracts ve commit şart; git status'taki modified: contracts (new commits) satırı bunu hatırlatıyor. --submodule=log ise diff'i bir hash çiftinden okunur bir commit listesine çeviriyor; her seferinde yazmak istemezsen diff.submodule log ayarı var.

Emre ertesi sabah pull'luyor:

$ git pull -q
$ git status
On branch main
Your branch is up to date with 'origin/main'.

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
	modified:   contracts (new commits)

no changes added to commit (use "git add" and/or "git commit -a")
$ git submodule update
Submodule path 'contracts': checked out 'b5f1da9975344069cc14b2aaf4fab41144869dce'
$ git config set submodule.recurse true

Aynı satır, bu kez ters anlamla. Pull gitlink'i güncelledi ama submodule'ün çalışma klasörü eski commit'te kaldı. Emre burada git add contracts derse, Ada'nın güncellemesini geri almış olur. Doğrusu git submodule update. submodule.recurse true ayarı pull, switch ve checkout gibi komutlara --recurse-submodules'ü varsayılan olarak ekler; submodule'lü bir repo'da bu ayarı açmak bu hatayı büyük ölçüde ortadan kaldırır.

Push tarafında da bir emniyet var. Submodule'de yeni bir commit atıp onu göstermeden üst repo'yu push'larsan, ekip arkadaşların var olmayan bir commit'i gösteren bir gitlink alır ve submodule update hata verir. git push --recurse-submodules=check, push'lanacak commit'lerin gösterdiği submodule commit'lerinin submodule'ün en az bir remote'unda bulunduğunu kontrol eder, yoksa push'u durdurur. push.recurseSubmodules check ile varsayılan yapılabilir. Bu laboratuvarda push çalıştırmadığımız için davranışı git-push belgesinden aktarıyoruz.

CI'da Submodule

GitHub Actions'ta actions/checkout submodule'leri varsayılan olarak getirmez. Build bir sözleşme dosyasını okuyorsa, checkout adımına tek satır eklemek gerekir:

steps:
  - uses: actions/checkout@v7
    with:
      # recursive: submodule'lerin submodule'lerini de getirir
      submodules: recursive
  - run: git submodule status

Burada sık atlanan nokta yetki. Workflow'un varsayılan token'ı yalnızca bulunduğu repo'ya erişir. bookstore-contracts private bir repo ise checkout onu clone'layamaz; o repo'yu okuyabilen bir token'ı token: input'una ya da bir deploy key'i ssh-key: input'una vermen gerekir. Bu dosyayı actionlint ile kontrol ettik; tam hâli yazının snapshot'ında duruyor.

Alternatif: git subtree

Submodule'ün bütün zorluğu "iki ayrı repo, bir pointer" modelinden geliyor. git subtree başka bir yol izler: dış repo'nun dosyalarını doğrudan kendi geçmişine alır. Angular istemcisinde aynı sözleşmeyi bu yolla bağlayalım:

$ git clone -q http://127.0.0.1:52030/git/bookstore-web.git
$ cd bookstore-web
$ git subtree add --prefix=contracts http://127.0.0.1:52030/git/bookstore-contracts.git v1.0.0 --squash
git fetch http://127.0.0.1:52030/git/bookstore-contracts.git v1.0.0
From http://127.0.0.1:52030/git/bookstore-contracts
 * tag               v1.0.0     -> FETCH_HEAD
Added dir 'contracts'
$ git log --oneline --graph
*   5352fa9 Merge commit 'd1da7ffb66198d630898602fa6facdba01b0daea' as 'contracts'
|\
| * d1da7ff Squashed 'contracts/' content from commit 7a81215
* d437aa4 Add README
$ git log -1 --format=%B HEAD^2
Squashed 'contracts/' content from commit 7a81215

git-subtree-dir: contracts
git-subtree-split: 7a81215602efdc607e75b0ff8719d2878f89ef69

contracts artık sıradan bir klasör; clone'layan kişi ekstra hiçbir komut çalıştırmaz, CI'da ayar gerekmez. --squash, dış repo'nun bütün geçmişi yerine tek bir "squashed" commit ekler. O commit'in mesajındaki git-subtree-split satırı, hangi upstream commit'ten geldiğini kaydeder; sonraki güncellemeler bunu kullanır:

$ git subtree pull --prefix=contracts http://127.0.0.1:52030/git/bookstore-contracts.git v1.1.0 --squash -m 'Update contracts to v1.1.0'
From http://127.0.0.1:52030/git/bookstore-contracts
 * tag               v1.1.0     -> FETCH_HEAD
Merge made by the 'ort' strategy.
 contracts/openapi/bookstore.yaml | 3 +++
 1 file changed, 3 insertions(+)

Ters yön de mümkün. Ayşe istemci repo'sunda çalışırken sözleşmeye bir endpoint ekledi ve bunu bookstore-contracts'a geri vermek istiyor:

$ git commit -q -am 'Add create book contract'
$ git subtree split --prefix=contracts -b contracts-upstream
...
Created branch 'contracts-upstream'
4106b4438d81e64af14c878bae677eb7baa1483c
$ git log --oneline contracts-upstream -3
4106b44 Add create book contract
b5f1da9 Add book detail endpoint contract
7a81215 Add books endpoint contract

split, contracts/ altına dokunan commit'leri ayıklayıp klasörü kök kabul eden yeni bir geçmiş üretti. Dikkat edersen alttaki iki hash, bookstore-contracts'taki gerçek commit'lerle aynı: squash commit'lerindeki git-subtree-split satırları sayesinde yeni commit doğrudan upstream'in ucuna oturdu. Bu branch'i bookstore-contracts'a push'layıp oradan bir pull request açabilirsin. Bedel: split geçmişin tamamını dolaşır, büyük repo'larda yavaşlar ve subtree komutlarının hepsi prefix'i ve URL'i her seferinde ister.

Submodule, Subtree, Paket: Hangisi?

Ortak kodu paylaşmanın üçüncü yolu, onu hiç repo'ya sokmamak: sözleşmeden üretilen C# client'ını bir NuGet paketi, TypeScript tiplerini bir npm paketi olarak yayınlamak. Versioning ve Release Management serisindeki NuGet ve npm paketi yayınlama yazıları bu yolu ayrıntılı anlatıyor.

SubmoduleSubtreeNuGet/npm paketi
Üst repo'da ne durur?Commit hash'i (gitlink)Dosyaların kendisiSürüm numarası
Clone'layan ne yapar?--recurse-submodules ya da update --initHiçbir şeydotnet restore / npm ci
Sürüm sabitlemeCommit'eSquash commit'ineSemver'e
Geri katkıSubmodule'de branch, push, pull requestsubtree split ve pushKaynak repo'da pull request, yeni sürüm
CI ayarısubmodules: recursive ve erişim token'ıGerekmezPaket feed'ine erişim
En çok işe yaradığı yerAyrı ekiplerin, ayrı hızda değişen koduNadiren değişen, küçük ortak kodBirden fazla tüketicisi olan, sürümlenen kütüphane

Bookstore İçin Karar: Monorepo mu Polyrepo mu?

Submodule ve subtree, aslında daha büyük bir sorunun belirtisi: kod neden üç repo'da? Bookstore'u tek repo'da (monorepo) tutmak bu yazıdaki bütün komutları gereksiz kılar. Sözleşme, API ve istemci aynı commit'te değişir; bir endpoint'i değiştiren pull request aynı anda istemciyi de günceller ve "API'nin hangi sürümü sözleşmenin hangi sürümüyle çalışıyor?" sorusu kalmaz. Polyrepo'nun (her parçanın ayrı repo'da durması) avantajı ise bağımsızlık: farklı ekipler, farklı yetkiler, farklı release takvimi, küçük ve hızlı clone'lar.

Bookstore ekibi için karar şöyle çıktı: API, Angular istemcisi ve sözleşme aynı ekipte ve çoğu özellik üçüne birden dokunuyor, o yüzden tek repo. Monorepo'da her değişiklikte her şeyi build etmemek için path filtreleri gerekiyor; bu konu GitHub Actions serisinin .NET + Angular monorepo yazısında. Sözleşme ileride kurumsal müşterilere ayrı bir SDK olarak açılırsa, o noktada bir paket olarak yayınlanır. Submodule ise iki durumda mantıklı kalır: kontrol etmediğin bir upstream'i (örneğin bir fork'u) belirli bir commit'e sabitlemek istediğinde, ve repo'yu birleştirmenin organizasyon olarak mümkün olmadığı durumlarda.

Sık Düşülen Tuzaklar

İlki pointer'ı commit'lemeyi unutmak. update --remote sonrası git status'taki modified: contracts (new commits) satırını görmezden gelip yalnız kendi kodunu commit'lersen, CI ve ekip eski sözleşmeyle build eder. Senin makinende çalışan kod, başka yerde derlenmez.

İkincisi submodule içinde branch'siz commit atmak. Ada sözleşmede küçük bir düzeltme yapıyor ve submodule'ün detached HEAD'de olduğunu fark etmiyor:

$ cd contracts
$ git status -sb
## HEAD (no branch)
$ git commit -q -am 'Add changelog section'
$ git log --oneline -1
a0235d4 Add changelog section
$ cd ..
$ git submodule update
Submodule path 'contracts': checked out 'b5f1da9975344069cc14b2aaf4fab41144869dce'
$ git -C contracts log --oneline -1
b5f1da9 Add book detail endpoint contract

submodule update submodule'ü gitlink'in gösterdiği commit'e geri koydu ve a0235d4'ü hiçbir branch göstermiyor. Commit kaybolmadı, reflog'da duruyor; reflog yazısındaki yöntem burada da işe yarıyor:

$ git -C contracts reflog -3
b5f1da9 HEAD@{0}: checkout: moving from a0235d4b32a51f82cd20763acba3aeeaf32238ad to b5f1da9975344069cc14b2aaf4fab41144869dce
a0235d4 HEAD@{1}: commit: Add changelog section
b5f1da9 HEAD@{2}: checkout: moving from main to b5f1da9975344069cc14b2aaf4fab41144869dce
$ git -C contracts switch -c docs/changelog HEAD@{1}
Previous HEAD position was b5f1da9 Add book detail endpoint contract
Switched to a new branch 'docs/changelog'

Alışkanlık olarak, submodule'de değişiklik yapmadan önce git switch -c <branch> ile bir branch aç.

Üçüncüsü private submodule'ün CI'da sessizce boş kalması ya da clone hatası vermesi. Checkout adımı yalnızca kendi repo'suna yetkili bir token'la çalışır; erişimi baştan planla.

Dördüncüsü .gitmodules'a SSH (Secure Shell; şifreli uzak bağlantı protokolü) URL'i yazmak. git@github.com:... biçimindeki bir URL, SSH anahtarı olmayan her makinede, CI dahil, clone'u kırar. HTTPS URL'i yazıp SSH kullanmak isteyenlerin bunu kendi makinelerinde url.insteadOf ile çevirmesi daha esnek; bu ayarı git config yazısında göreceğiz.

Küçük Bir Deneme

★☆☆ İki yerel repo oluştur, birini diğerine submodule olarak ekle ve git cat-file -p HEAD^{tree} çıktısında 160000 satırını bul. Sonra submodule'de bir commit at ve üst repo'da git diff ile git diff --submodule=log çıktılarını karşılaştır.

★★☆ Submodule'lü repo'yu düz git clone ile clone'la, submodule status çıktısındaki - işaretini gör. Ardından submodule.recurse true ayarıyla bir pull yap ve submodule'ün kendiliğinden güncellendiğini doğrula.

★★★ Aynı ortak klasörü bir repo'ya submodule, diğerine subtree add --squash ile ekle. Ortak repo'da üç commit at, iki tarafı da güncelle, sonra subtree tarafında bir değişiklik yapıp subtree split ile geri ver. Hangi yöntemde kaç komut çalıştırdığını ve kaç yeni commit oluştuğunu not et.

Bu yazıda bir repo'yu başka bir repo'ya bağladık. Repo'nun kendisi büyüdüğünde ne olur? Bir sonraki yazıda kitap kapaklarını Git LFS'e (Large File Storage; büyük dosyaları Git dışında saklayan eklenti) taşıyacak, partial clone, sparse checkout ve shallow clone ile yalnız ihtiyacımız olanı indirecek ve yanlışlıkla commit'lenmiş büyük bir dosyayı geçmişten sileceğiz.