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

Git Hook'ları ve Commit İmzalama: pre-commit'ten SSH İmzasına

Mert Özen 6 Kas 2026 11 dk 1 görüntülenme
Git Hook'ları ve Commit İmzalama: pre-commit'ten SSH İmzasına

pre-commit ve commit-msg hook'larını gerçek çıktılarla yazıyor, core.hooksPath ile ekiple paylaşıyor, Git 2.54'ün config tabanlı hook'larını ve git hook list'i deniyoruz. Ardından commit'leri SSH anahtarıyla imzalıyor, allowed signers ile doğruluyor ve GitHub'daki Verified rozetinin şartlarını görüyoruz.

Sürüm notu: Bu yazıdaki komutlar Git 2.54 ile denenmiştir; SSH ile imzalama Git 2.34'ten, config tabanlı hook'lar Git 2.54'ten beri vardır.

Önce Tahmin Et

Ekipte biri yine console.log bırakıp commit atmış. Sen de buna bir son vermek için bookstore repo'sunda küçük bir pre-commit hook'u yazıyorsun. Staging area'daki JavaScript değişikliklerinde console.log görürse commit'i durduracak. Sonra src/cart.js'e bilerek bir console.log(items); satırı ekleyip deniyorsun:

$ cat .git/hooks/pre-commit
#!/bin/sh
if git diff --cached -U0 -- 'src/*.js' | grep -q '^+.*console\.log'; then
  echo "pre-commit: remove console.log from staged files" >&2
  exit 1
fi
$ git add src/cart.js
$ git commit -m "Log cart items"

Script doğru, grep doğru, çıkış kodu doğru. Sence commit oluşur mu? Cevabı vermeden önce bir kez daha bak: eksik olan şey kodda değil.

$ git commit -m "Log cart items"
hint: The '.git/hooks/pre-commit' hook was ignored because it's not set as executable.
hint: You can disable this warning with `git config set advice.ignoredHook false`.
[main b68ba03] Log cart items
 1 file changed, 1 insertion(+)

Commit oluştu. Git dosyayı buldu ama çalıştırılabilir olmadığı için yok saydı ve sadece bir hint bıraktı. Hook'lar sıradan programlardır; Git onları çalıştırabildiği sürece dilleri önemli değildir. Bu yazıda önce hook'ların nasıl çalıştığını, sonra ekiple nasıl paylaşıldığını, Git 2.54 ile gelen config tabanlı hook'ları ve son olarak commit'leri SSH anahtarıyla imzalamayı göreceğiz.

Hook Nedir, Nerede Durur?

Hook, Git'in belirli anlarda kendiliğinden çağırdığı bir script'tir: commit oluşmadan hemen önce, commit mesajı yazıldıktan sonra, push'tan önce, merge'den sonra. Her repo'da örnekleri hazır gelir:

$ ls .git/hooks
applypatch-msg.sample
commit-msg.sample
fsmonitor-watchman.sample
post-update.sample
pre-applypatch.sample
pre-commit.sample
pre-merge-commit.sample
pre-push.sample
pre-rebase.sample
pre-receive.sample
prepare-commit-msg.sample
push-to-checkout.sample
sendemail-validate.sample
update.sample

Bir hook'u etkinleştirmek için dosyayı uzantısız adıyla (pre-commit gibi) bu klasöre koyar ve çalıştırılabilir yaparsın. Kural basit: pre- ile başlayan hook'lar sıfırdan farklı bir kodla çıkarsa Git o işlemi iptal eder. post- hook'ları ise iş bittikten sonra çalışır, sonucu değiştiremez; bildirim göndermek, log yazmak gibi işlere yarar.

Bu yazıdakiler client-side hook'lar, yani senin makinende çalışanlar. pre-receive ve update gibi server-side hook'lar ise push'u kabul eden sunucuda çalışır. GitHub.com kendi sunucularında senin yazdığın server-side hook'u çalıştırmaz; orada aynı işi branch protection, rulesets ve CI yapar.

pre-commit: Commit'ten Önce Son Kontrol

Önceki denemeyi geri alıp dosyayı çalıştırılabilir yapalım:

$ git reset -q --soft HEAD~1
$ chmod +x .git/hooks/pre-commit
$ git commit -m "Log cart items"
pre-commit: remove console.log from staged files
$ echo $?
1

Bu kez Git hook'u çalıştırdı, hook 1 ile çıktı ve commit hiç oluşmadı. Değişikliğin hâlâ staging area'da duruyor, hiçbir şey kaybolmadı. Script'in git diff --cached'e baktığına dikkat et: working tree'deki hâline değil, commit'e girecek hâline bakmak gerekir. Yoksa dosyayı düzeltip stage etmeyi unutan biri yine yanlış içeriği commit'leyebilir.

Bir de kaçış kapısı var:

$ git commit --no-verify -m "Log cart items"
[main 15c4139] Log cart items
 1 file changed, 1 insertion(+)
$ git reset -q --hard HEAD~1

--no-verify (kısaca -n) pre-commit ve commit-msg hook'larını atlar. Bu bir hata değil, bilinçli bir tasarım. Hook'lar geliştiriciye yardımcı olan bir emniyet kemeridir, güvenlik duvarı değildir. Kimsenin atlayamaması gereken kurallar CI'da ve sunucu tarafında uygulanır.

commit-msg: Mesajı Denetlemek

Commit mesajı yazısında özet satırını 50 karakter civarında tutmaktan bahsetmiştik. commit-msg hook'u mesajın yazıldığı geçici dosyanın yolunu ilk argüman olarak alır, yani mesajı okuyup karar verebilir:

$ cat .git/hooks/commit-msg
#!/bin/sh
subject=$(head -n 1 "$1")
if [ ${#subject} -gt 50 ]; then
  echo "commit-msg: subject is ${#subject} characters, keep it at 50 or less" >&2
  exit 1
fi
$ chmod +x .git/hooks/commit-msg
$ git commit -m "Change the cart total so it multiplies price by quantity"
commit-msg: subject is 56 characters, keep it at 50 or less
$ git commit -m "Multiply price by quantity in cart total"
[main 53f5fc8] Multiply price by quantity in cart total
 1 file changed, 1 insertion(+), 1 deletion(-)

Conventional Commits kullanan ekipler aynı hook'ta mesajın feat:, fix: gibi bir önekle başlayıp başlamadığını da kontrol eder. Mesajı değiştirmek istersen bunun yeri prepare-commit-msg hook'udur; örneğin branch adındaki ticket numarasını mesajın sonuna eklemek için.

Hook'ları Ekiple Paylaşmak: core.hooksPath

Buraya kadar her şey güzel ama bir sorun var: .git klasörü repo'nun içeriğine dahil değil. Hook'ların ne commit'lenir ne de clone ile karşı tarafa gider. Çözüm, hook'ları repo'da sıradan bir klasöre koyup Git'e "hook'ları buradan oku" demek:

$ mkdir .githooks
$ mv .git/hooks/pre-commit .git/hooks/commit-msg .githooks/
$ git add .githooks
$ git commit -m "Share pre-commit and commit-msg hooks"
[main 6c6ba8f] Share pre-commit and commit-msg hooks
 2 files changed, 11 insertions(+)
 create mode 100755 .githooks/commit-msg
 create mode 100755 .githooks/pre-commit
$ git config set core.hooksPath .githooks
$ git ls-files -s .githooks
100755 92db1613ed706fae0d0d6276cb52cca556e16ca1 0	.githooks/commit-msg
100755 c032c98e27a8f13e05a4c9243b339d44659fb159 0	.githooks/pre-commit

100755, Git'in çalıştırılabilir bit'ini de sakladığını gösteriyor. Windows'ta bu bit dosya sisteminden gelmediği için git update-index --chmod=+x .githooks/pre-commit ile elle işaretlemen gerekebilir.

Şimdi Alan repo'yu clone'lasın:

$ git clone -q bookstore alan
$ cd alan
$ git config get core.hooksPath; echo $?
1
$ ls .git/hooks | head -3
applypatch-msg.sample
commit-msg.sample
fsmonitor-watchman.sample

Dosyalar geldi ama ayar gelmedi. Bu da bilinçli: clone sırasında karşı taraftan gelen bir ayarın senin makinende kendiliğinden script çalıştırması, kötü niyetli bir repo için açık davetiye olurdu. Her geliştirici git config set core.hooksPath .githooks komutunu bir kez kendisi çalıştırır. README'ye bir satır yazmak ya da projenin kurulum script'ine eklemek yeterli. Husky gibi araçlar da arka planda aynı core.hooksPath ayarını kullanır.

Config Tabanlı Hook'lar (Git 2.54)

Klasik modelde her olay için tek bir dosya vardır: bir pre-commit. İki farklı kontrol istiyorsan ikisini aynı script'e sıkıştırırsın. Git 2.54 hook'ları config üzerinden tanımlamayı getirdi: her hook'a bir ad verirsin, hangi komutu hangi olayda çalıştıracağını ayrı ayrı yazarsın. Bir olaya birden fazla hook bağlanabilir ve bunlar farklı config seviyelerinden gelebilir.

Alan'ın makinesinde, bütün repo'larında çalışacak global bir gizli anahtar kontrolü ve bu repo'ya özel iki hook tanımlayalım:

$ git config set --global hook.no-secrets.command '! git diff --cached | grep -q "PRIVATE KEY"'
$ git config set --global --append hook.no-secrets.event pre-commit
$ git config set hook.no-console.command .githooks/pre-commit
$ git config set --append hook.no-console.event pre-commit
$ git config set hook.subject-length.command .githooks/commit-msg
$ git config set --append hook.subject-length.event commit-msg
$ git hook list pre-commit
no-secrets
no-console
$ git hook list --show-scope pre-commit
global	no-secrets
local	no-console

event çok değerli bir ayar, bu yüzden --append kullanıyoruz; aynı hook'u hem pre-commit hem pre-push'ta çalıştırmak için ikinci bir satır eklemen yeterli. command bir dosya yolu da olabilir, tek satırlık bir shell komutu da. Hook'lar config'de karşılaşılan sırayla çalışır; global dosya local'den önce okunduğu için no-secrets önde.

$ git commit -m "Debug cart"
pre-commit: remove console.log from staged files
$ git hook run pre-commit; echo $?
pre-commit: remove console.log from staged files
1
$ git hook list pre-push; echo $?
warning: no hooks found for event 'pre-push'
1

git hook run, bir olayı commit atmadan tetikleyip hook'larını denemenin en hızlı yolu. Global bir hook'u tek bir repo'da kapatmak istersen config'ini silmen gerekmez:

$ git config set hook.no-secrets.enabled false
$ git hook list --show-scope pre-commit
global	disabled	no-secrets
local	no-console

Klasik .git/hooks dosyaları ve core.hooksPath çalışmaya devam ediyor; config'teki hook'lar önce, hook klasöründeki dosya en son çalışır. Git 2.55 bunlara paralel çalıştırmayı ekledi (hook.<name>.parallel, hook.jobs); 2.54'te hook'lar sırayla çalışır.

SSH ile Commit İmzalama

Commit'teki Author satırı sadece bir metin. user.email'e istediğin adresi yazıp istediğin kişi adına commit atabilirsin. İmza, commit'i gerçekten o anahtarın sahibinin oluşturduğunu kanıtlar. Eskiden bunun tek yolu GPG'ydi ve kurulumu birçok kişiyi caydırırdı. Git 2.34'ten beri zaten elinde olan SSH anahtarıyla imzalayabilirsin (OpenSSH 8.2p1 ve üstü yeterli; ancak 8.7'deki ssh-keygen hatalı olduğu için Git 8.8 ve üstünü öneriyor).

Bu örnekte ayrı bir imzalama anahtarı üretiyoruz. Gerçek kullanımda passphrase ver ve anahtarı ssh-agent'a ekle; buradaki -N "" yalnızca denemeyi hızlandırmak için:

$ ssh-keygen -q -t ed25519 -C "ada@example.com" -N "" -f ~/.ssh/ada_signing
$ git config set gpg.format ssh
$ git config set user.signingkey ~/.ssh/ada_signing.pub
$ git commit -S -m "Require qty in cart total"
[main fee0a05] Require qty in cart total
 1 file changed, 1 insertion(+), 1 deletion(-)
$ git log --show-signature -1
error: gpg.ssh.allowedSignersFile needs to be configured and exist for ssh signature verification
commit fee0a053860a9b5245ca0d075df920be75ed36fd
No signature
Author: Ada Lovelace <ada@example.com>
Date:   Sat Nov 7 10:08:00 2026 +0300

    Require qty in cart total

İmzalama çalıştı, doğrulama çalışmadı. "No signature" yanıltıcı görünse de commit imzalı; Git sadece bu imzayı kimin anahtarıyla karşılaştıracağını bilmiyor. GPG'de bu iş keyring'deydi, SSH'ta ise güvendiğin anahtarları listeleyen bir allowed signers dosyası var:

$ echo "ada@example.com namespaces=\"git\" $(cat ~/.ssh/ada_signing.pub)" > ~/.ssh/allowed_signers
$ git config set gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers
$ git log --show-signature -1
commit fee0a053860a9b5245ca0d075df920be75ed36fd
Good "git" signature for ada@example.com with ED25519 key SHA256:XPYcjUZkeiPeLZANCSRHUZF5s7P4OJx7rdI47P8grdk
Author: Ada Lovelace <ada@example.com>
Date:   Sat Nov 7 10:08:00 2026 +0300

    Require qty in cart total

Her commit'te -S yazmak yerine imzalamayı varsayılan yap; aynı ayar annotated tag'ler için de var:

$ git config set commit.gpgSign true
$ git config set tag.gpgSign true
$ git commit -m "Mark books module v1"
[main 28ab277] Mark books module v1
 1 file changed, 1 insertion(+)
$ git tag -m "First release" v1.0.0
$ git log --format="%h %G? %GS %s"
28ab277 G ada@example.com Mark books module v1
fee0a05 G ada@example.com Require qty in cart total
53f5fc8 N  Multiply price by quantity in cart total
4734313 N  Add cart total
14693d0 N  Add bookstore skeleton
$ git verify-commit HEAD
Good "git" signature for ada@example.com with ED25519 key SHA256:XPYcjUZkeiPeLZANCSRHUZF5s7P4OJx7rdI47P8grdk

%G? imza durumunu tek harfle verir: G geçerli, N imzasız. Ayarları burada repo seviyesinde yaptık; kendi makinende bunları bir kez --global ile yapmak daha pratik. Ayarların adı hâlâ gpg.*, çünkü SSH desteği sonradan aynı altyapının üstüne eklendi.

GitHub'da "Verified" Rozeti

Yerelde doğrulamak için allowed signers dosyası gerekiyordu; GitHub'da bunun karşılığı hesabına eklediğin anahtarlar. Settings → SSH and GPG keys → New SSH key ekranında Key type olarak "Signing Key" seçip public key'i yapıştırırsın. Kimlik doğrulamada kullandığın anahtarın aynısını imza için de kullanacaksan, onu bu ekrandan bir kez de Signing Key olarak eklemen gerekir. Bundan sonra imzalı commit'lerini push ettiğinde GitHub, imza geçerliyse ve commit'teki e-posta hesabında doğrulanmış bir adresse commit'in yanına "Verified" rozetini koyar.

İki ayrıntı daha. GitHub'ın web arayüzünde yaptığın commit'leri ve squash merge'leri GitHub kendi anahtarıyla imzalar, onlar da "Verified" görünür. Hesap ayarlarındaki vigilant mode ise imzasız commit'lerini "Unverified" olarak işaretler; birisi senin e-postanla commit atarsa bu fark hemen göze çarpar. Ekip seviyesinde rulesets ile bir branch'e yalnız imzalı commit kabul ettirmek de mümkün.

Sık Düşülen Tuzaklar

İlki çalıştırılabilir bit'i unutmak. Açılıştaki örnekte olduğu gibi Git yalnızca bir hint basar ve devam eder. Hook'un çalışıp çalışmadığından emin değilsen git hook run pre-commit ile doğrudan dene.

İkincisi working tree'ye bakan hook yazmak. git diff ile git diff --cached farklı şeyler gösterir. Commit'e giren içerik index'tedir; kontrolün de oraya bakmalı.

Üçüncüsü hook'un neden durdurduğunu söylememesi. Yukarıdaki no-secrets tek satırlık bir komut ve başarısız olduğunda hiçbir şey yazmıyor. Commit'i durdurup sebebini söylemeyen bir hook, ekibin ilk hafta --no-verify alışkanlığı edinmesine yol açar. Mesaj yaz ve hook'u hızlı tut; her commit'te otuz saniye süren bir test seti pre-commit'te değil CI'da çalışmalı.

Dördüncüsü imza e-postası. allowed signers dosyasındaki adres, commit'teki committer e-postası ve GitHub hesabında doğrulanmış adres aynı değilse imza geçerli olsa bile "Good signature" ya da "Verified" görmezsin. Birden fazla e-posta kullanıyorsan önce git config get user.email'e bak.

Beşincisi rebase ve amend. commit.gpgSign açıkken rebase ettiğin her commit yeniden imzalanır, bu kez senin anahtarınla. Başkasının imzalı commit'lerini rebase edersen onların imzası gider, yerine seninki gelir.

Küçük Bir Deneme

★☆☆ Kendi deneme repo'nda .git/hooks/pre-commit.sample dosyasını pre-commit adıyla kopyala ve satır sonunda boşluk bırakılmış bir dosya ekleyip commit atmayı dene. Hook ne dedi? Script'i okuyup bu kontrolü hangi Git komutunun yaptığını bul.

★★☆ no-secrets hook'unu, başarısız olduğunda no-secrets: private key in staged changes yazacak şekilde yeniden tanımla. Sonra aynı hook'u git config set --append ile pre-push olayına da bağla ve git hook list pre-push çıktısına bak.

★★★ Ayrı bir anahtarla ikinci bir kişi (Alan) için commit imzala ama allowed signers dosyasına yalnız Ada'yı yaz. git log --format="%h %G? %GS %s" Alan'ın commit'i için hangi harfi gösteriyor? G, U ve N arasındaki farkı git help log içinden bul.

Bu yazıyla Blok A, yani kemik yapı bitti. Bir sonraki yazıda sürümlere geçiyoruz: Git 2.23'ten 2.39'a kadar gelen değişiklikler, checkout'tan switch ve restore'a geçiş, pull uyarısı, ort ve burada kullandığımız SSH imzalamanın hangi sürümle geldiği.