Хуки и автоматизация: pre-commit, CI-интеграция, подпись коммитов
Разговор про хуки обычно начинается с «давайте повесим линтер на pre-commit», а через полгода заканчивается тем, что половина команды коммитит с --no-verify, а вторая ждёт сорок секунд на каждый коммит. Причина не в инструментах, а в непонимании того, что такое хук с точки зрения модели данных Git.
Всё, что делает Git, — переводит данные из одного состояния в другое: рабочий каталог → индекс → объект commit → локальная ссылка → ссылка на сервере. Хук — точка, где Git приостанавливает переход, вызывает вашу программу и смотрит на код возврата. Как только вы видите хуки именно так, становится понятно, почему pre-commit обязан смотреть на индекс, а не на рабочий каталог; почему post-commit ничего не может отменить; и почему серверные хуки — единственная настоящая гарантия, а всё клиентское — вежливая просьба.
Статья опирается на модель объектов и DAG из «Внутри Git» и на цикл add/commit из «Ежедневной работы». Базовое введение — в статье «Git», оформление изменений на ревью — в «Как создать хороший Pull Request».
Что такое хук физически
Никакой магии: хук — это исполняемый файл с определённым именем в каталоге, на который указывает core.hooksPath (по умолчанию $GIT_DIR/hooks). Суффикс .sample — единственное, что отделяет примеры от рабочего состояния: файлы с ним Git не читает, поэтому свежий клон никогда не выполняет ничего постороннего. Реальный путь всегда выдаёт git rev-parse --git-path hooks — он учитывает core.hooksPath, worktree и submodule, а скрипты установки, которые пишут напрямую в .git/hooks, ломаются на всех трёх случаях.
Четыре свойства, из которых следует всё остальное:
- Хуки не версионируются.
.git/hooks— часть служебного каталога, а не рабочего дерева: их нельзя закоммитить и раздать черезgit clone. Это не недоработка, а осознанное решение безопасности — иначе клонирование любого репозитория означало бы выполнение чужого кода на вашей машине. - Язык любой. Git смотрит только на бит
+xи shebang: shell, Python, Go-бинарник — что угодно. - Код возврата решает. У «pre-» хуков ненулевой выход отменяет операцию; у «post-» хуков код возврата игнорируется — они уже ничего не остановят. При этом клиентские хуки обходятся:
git commit --no-verify,git push --no-verify, а для самых упорных —core.hooksPath=/dev/null.
Карта хуков
Полный список — в githooks(5), его стоит открыть целиком хотя бы раз. Систематизация по фазам:
Контракт самых полезных — то, что надо держать в голове при написании скриптов:
| Хук | Аргументы | stdin | Ненулевой код | Обходится |
|---|---|---|---|---|
pre-commit |
нет | нет | отменяет коммит | --no-verify |
prepare-commit-msg |
файл, источник (message/template/merge/squash/commit), oid |
нет | отменяет коммит | нет |
commit-msg |
файл сообщения | нет | отменяет коммит | --no-verify |
post-commit, post-receive |
нет | у второго old-oid new-oid ref |
игнорируется | — |
pre-push |
имя remote, URL | local-ref local-oid remote-ref remote-oid |
отменяет push | --no-verify |
pre-receive |
нет | old-oid new-oid ref по всем ссылкам |
отклоняет весь push | — |
update |
ref, old-oid, new-oid | нет | отклоняет эту ссылку | — |
reference-transaction |
prepared/committed/aborted |
old-oid new-oid ref |
отменяет транзакцию | — |
Две тонкости, на которых спотыкаются все:
commit-msgвызывается и для слияний — документация прямо говорит, что хук запускается изgit commitиgit merge. Валидатор Conventional Commits, не умеющий пропускатьMerge branch 'main' into feature, ломает мержи всей команде.- Нулевой oid означает создание или удаление ссылки. В
pre-pushиpre-receiveстрока из нулей в полеremote-oid— это «ветки на сервере ещё нет». В репозиториях на SHA-256 нулей будет 64, поэтому сравнение с захардкоженной константой из 40 символов — распространённый баг.
Пишем правильный pre-commit руками
Самая частая ошибка — «обычный» хук, встречающийся в каждом втором проекте: npx eslint src/ || exit 1. Здесь три проблемы, и все три следуют из модели данных. Проблема первая: индекс ≠ рабочий каталог. Коммит собирается из индекса. Если вы сделали git add -p и застейджили половину правок файла, линтер увидит и вторую половину — либо пропустит поломку, либо отклонит корректный коммит. Правильный способ — материализовать ровно индекс во временный каталог:
#!/usr/bin/env bash
set -euo pipefail
git diff --cached --quiet && exit 0 # ничего не застейджено — нечего проверять
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
git checkout-index --prefix="$tmp/" -af # разворачивает СОДЕРЖИМОЕ ИНДЕКСА
# Файлы, попадающие в коммит: Added, Copied, Modified, Renamed.
# -z + read -d '' переживают пробелы и юникод в путях.
mapfile -d '' files < <(git diff --cached --name-only --diff-filter=ACMR -z)
[ ${#files[@]} -eq 0 ] && exit 0
cd "$tmp"
printf '%s\0' "${files[@]}" | xargs -0 ruff check --
printf '%s\0' "${files[@]}" | xargs -0 ruff format --check --
git checkout-index -af --prefix= — буквально «запиши дерево индекса вон туда»: никакого git stash, никакой гонки и никакого риска потерять незакоммиченные правки, если хук упадёт посередине. Фреймворки идут другим путём — сохраняют незастейдженные изменения в бинарный патч (git diff --binary), откатывают рабочий каталог и восстанавливают патч в trap; так автофиксы могут править файлы на месте, но нужна аккуратная обработка бинарников и подмодулей, и поэтому в проде дешевле взять готовый фреймворк.
Проблема вторая: скорость. Хук, проверяющий весь проект, растёт вместе с проектом. Практический порог — две секунды: всё, что дольше, люди начинают обходить, и это не вопрос дисциплины, а арифметика — разработчик коммитит десятки раз в день. time git commit --allow-empty -m wip, показывающий 11 секунд, — это не хук, а причина, по которой у вас скоро появится --no-verify.
Проблема третья: автоформат, который молча меняет коммит. Соблазнительно повесить prettier --write с автоматическим git add — тогда в коммит уезжает код, который вы не видели, и, хуже того, не тот, что ревьюили. Компромисс: в хуке только проверка (--check) с внятным «запусти make fmt», либо автофикс с явным выводом «я поправил вот эти файлы, посмотри git diff --cached».
Что происходит при git commit по шагам
Порядок делит зоны ответственности: prepare-commit-msg работает до редактора и заполняет шаблон, commit-msg — после и проверяет результат. Трейлеры (Refs:, Co-authored-by:, Signed-off-by:) — стандартизованный механизм: с ними работают git interpret-trailers и git log --format='%(trailers:key=Refs)'. Про соглашения о сообщениях — в «Совместной работе».
#!/usr/bin/env bash
# prepare-commit-msg: вытащить номер задачи из имени ветки
msg_file="$1"
case "${2:-}" in merge|squash|commit|message) exit 0 ;; esac # тут пользователь всё сказал сам
branch="$(git symbolic-ref --short HEAD 2>/dev/null || true)"
[[ "$branch" =~ ([A-Z]{2,10}-[0-9]+) ]] || exit 0
grep -q "${BASH_REMATCH[1]}" "$msg_file" && exit 0
printf '\nRefs: %s\n' "${BASH_REMATCH[1]}" >> "$msg_file" # трейлер, а не префикс
Парный к нему валидатор проверяет уже итоговый текст:
#!/usr/bin/env bash
# commit-msg: валидация Conventional Commits, не ломающая служебные сообщения
first="$(head -n1 "$1")"
case "$first" in "Merge "*|"Revert "*|"Reapply "*|"fixup!"*|"squash!"*|"amend!"*) exit 0 ;; esac
pattern='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9._/-]+\))?!?: .{1,72}$'
grep -Eq "$pattern" <<<"$first" && exit 0
echo "Ожидается: <тип>[(область)][!]: <описание>, например fix(auth): не логировать токен" >&2
echo "Черновик цел: git commit -e -F .git/COMMIT_EDITMSG" >&2
exit 1
Белый список для fixup!/squash! обязателен: без него вы запрещаете команде пользоваться git commit --fixup и autosquash-режимом интерактивного rebase из «Переписывания истории».
Фреймворки: почему не стоит писать это самому
Ручной хук живёт ровно до момента, когда его надо раздать команде, обновить, отключить на CI и заставить работать на Windows. Механизм у всех фреймворков один — core.hooksPath (Git 2.9+), переносящий каталог хуков в версионируемую часть репозитория. pre-commit (pre-commit.com) — самый зрелый вариант, язык-агностичный несмотря на питоновское происхождение; он создаёт изолированное окружение для каждого хука по указанной ревизии, поэтому у всех членов команды и в CI работает одна и та же версия линтера.
# .pre-commit-config.yaml
default_install_hook_types: [pre-commit, commit-msg, pre-push]
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: check-merge-conflict # ловит забытые <<<<<<< в коде
- id: check-added-large-files
args: [--maxkb=512]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix] # поправил файл и ВСЁ РАВНО упал
- id: ruff-format
- repo: https://github.com/gitleaks/gitleaks
rev: v8.21.2
hooks:
- id: gitleaks # секреты стоит держать локально любой ценой
- repo: local
hooks:
- id: unit-tests
entry: pytest -q -m "not slow"
name: быстрые юнит-тесты
language: system
pass_filenames: false
stages: [pre-push] # тесты — на push, не на каждый коммит
$ git commit -m "feat(api): пагинация в /orders"
check for merge conflicts................................................Passed
check for added large files..............................................Passed
ruff.....................................................................Failed
- hook id: ruff | exit code: 1 | files were modified by this hook
src/api/orders.py:14:1: F401 [*] `typing.Optional` imported but unused
Флаг --exit-non-zero-on-fix здесь принципиален: хук поправил файл и всё равно упал, чтобы вы посмотрели на правку и застейджили её осознанно. lefthook (evilmartians/lefthook) — один Go-бинарник без рантайма, с параллельным запуском и плейсхолдером {staged_files} (плюс stage_fixed: true, чтобы автофиксы сами попадали в индекс). husky + lint-staged — де-факто стандарт в JS: husky 9 просто ставит core.hooksPath=.husky/_, а вся логика описана в lint-staged как «маска файлов → список команд».
| pre-commit | lefthook | husky + lint-staged | |
|---|---|---|---|
| Установка | Python/pipx | один бинарник | npm |
| Пиннинг версий линтеров | да, изолированные env | нет, ваши | через package.json |
| Механизм | пишет в .git/hooks |
core.hooksPath |
core.hooksPath |
| Готовые проверки | сотни | нет | нет |
| Запуск в CI | pre-commit run --all-files |
lefthook run pre-commit --all-files |
npx lint-staged --diff=... |
Важный конфликт, из-за которого «локально всё зелёное, а в CI линтер падает»: pre-commit по умолчанию пишет в .git/hooks, а husky и lefthook переопределяют core.hooksPath. Если в репозитории оказались оба, побеждает core.hooksPath, и хуки pre-commit молча перестают запускаться. Диагностика: git config --get core.hooksPath.
Честно: хуки — не гарантия
Клиентский хук не является контролем. Он обходится флагом, не устанавливается автоматически, его нет у того, кто клонировал репозиторий вчера и забыл выполнить pre-commit install, и его нет у бота, который открывает PR. Отсюда рабочее правило: хук — ускоритель обратной связи, CI — граница. Одна и та же проверка живёт в двух местах, но роли у них разные.
Приём, снимающий большую часть боли, — тонкий хук и толстая цель make: единственный источник правды о том, что значит «проверить проект». Хук вызывает make lint по изменённым файлам, CI — make check целиком; ни у кого не возникает вопроса «а что там проверяется» и не появляется двух расходящихся конфигураций.
.PHONY: fmt lint test check
fmt: ; ruff format .
lint: ; ruff check . && mypy src
test: ; pytest -q
check: lint test
Серверные хуки: единственная настоящая граница
pre-receive и update выполняются на сервере и не обходятся клиентским флагом. Вот pre-receive, отклоняющий пуш с большими файлами, — он предотвращает проблему, которую иначе придётся решать через filter-repo из «Переписывания истории»:
#!/usr/bin/env bash
set -euo pipefail
max_bytes=$((5 * 1024 * 1024)); status=0
is_zero() { case "$1" in *[!0]*) return 1 ;; *) return 0 ;; esac; } # SHA-1 и SHA-256 разом
while read -r old new ref; do
is_zero "$new" && continue # удаление ветки — пропускаем
if is_zero "$old"; then range="$new --not --all" # новая ветка: только новые объекты
else range="$old..$new"; fi
# rev-list --objects даёт "oid путь", cat-file добавляет тип и размер за один процесс
while read -r type oid size path; do
[ "$type" = blob ] && [ "$size" -gt "$max_bytes" ] || continue
echo "ОТКЛОНЕНО: $path весит $size байт (лимит $max_bytes). Используйте Git LFS." >&2
status=1
done < <(git rev-list --objects $range |
git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)')
done
exit "$status"
Второй классический серверный хук — запрет force-push в защищённые ветки:
#!/usr/bin/env bash
ref="$1"; old="$2"; new="$3" # update вызывается на каждую ссылку
case "$ref" in refs/heads/main|refs/heads/release/*) ;; *) exit 0 ;; esac
git merge-base --is-ancestor "$old" "$new" && exit 0
echo "ОТКЛОНЕНО: $ref обновляется не fast-forward. История защищённой ветки неизменна." >&2
exit 1
Идиома rev-list --objects | cat-file --batch-check — стандартный способ считать вес репозитория (%(rest) здесь означает путь); тот же приём используется в аудитах монорепозиториев, см. «Большие репозитории и монорепо».
Оговорка про SaaS. На GitHub.com, GitLab.com и Bitbucket Cloud своих серверных хуков нет — вместо них декларативные механизмы: rulesets и branch protection в GitHub, push rules и server hooks в GitLab. Это те же pre-receive, настраиваемые галочками, и настраивать их обязательно: без запрета force-push и обязательных статус-чеков весь остальной пайплайн — украшение.
Интеграция с CI: чем это отличается от локального запуска
Первое, что ломает интуицию: в CI проверяется не ваш коммит. Для события pull_request GitHub Actions чекаутит эфемерный merge-коммит refs/pull/N/merge — результат слияния вашей ветки с текущим состоянием базовой. Это правильное поведение (тестировать надо то, что окажется в main), но объясняет два регулярных сюрприза: скрипты релиза, берущие HEAD как «версию», получают несуществующий коммит, а проверки подписей на HEAD видят неподписанный merge, сгенерированный платформой (github.event.pull_request.head.sha — вот ваш реальный коммит). Второе — глубина клона: actions/checkout по умолчанию делает fetch-depth: 1, то есть shallow-клон, где нет ни merge-base, ни git describe, ни истории для gitleaks; симптом — fatal: no merge base или «в CI ничего не нашлось, локально находит».
name: checks
on: [pull_request, push]
concurrency: # новый пуш отменяет прошлый прогон
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
hooks:
runs-on: ubuntu-latest
permissions: { contents: read }
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # нужна полная история: merge-base, describe, скан секретов
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- uses: actions/cache@v4 # pre-commit сам кэширует окружения хуков
with:
path: ~/.cache/pre-commit
key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
- run: pipx install pre-commit
- if: github.event_name == 'pull_request' # только изменённые файлы
run: |
pre-commit run --show-diff-on-failure --color=always \
--from-ref "origin/${{ github.base_ref }}" --to-ref HEAD
- if: github.event_name == 'push' # на main — всё целиком
run: pre-commit run --all-files
Ключевая деталь — --from-ref/--to-ref: pre-commit сам вычислит изменённые файлы между ссылками и прогонит хуки только по ним. Диапазон изменений считается всегда одинаково: git diff --name-only "origin/$BASE"...HEAD. Разница между A..B и A...B в git diff — источник постоянной путаницы: две точки сравнивают снимки напрямую, три сравнивают merge-base(A,B) с B. В CI почти всегда нужны три точки, иначе вы увидите как «свои» ещё и чужие изменения, приехавшие в базовую ветку. Механику диапазонов разбираем в «Merge и rebase», общее устройство пайплайнов — в «Основах CI» и «Современных CI-платформах», встраивание тестов — в «Тестах в CI».
Подпись коммитов: что подписывается на самом деле
Автор коммита в Git — это просто строка, которую вы вписали в конфиг: git -c user.name="Linus Torvalds" -c user.email="torvalds@linux-foundation.org" commit создаст абсолютно валидный коммит от имени Линуса. Никакой аутентификации здесь нет и быть не может: коммит создаётся локально, до всякого взаимодействия с сервером. Подпись — единственный механизм, привязывающий объект к владельцу ключа. Физически Git считает подпись по содержимому объекта commit и вставляет её обратно как заголовок gpgsig между committer и телом сообщения:
$ git cat-file -p HEAD
tree 9a3c1f0d7b25e8a4c60f1b3d97e2a5c8f4b0d216
parent 4f1bc8e290ad3b7f16c05d9a2e83f4b7c0d1a95e
author Анна <a@ex.io> 1755000000 +0300
committer Анна <a@ex.io> 1755000060 +0300
gpgsig -----BEGIN SSH SIGNATURE-----
U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgqVX3n2p8Kk1uYb0jJcRr7oTf9
-----END SSH SIGNATURE-----
fix(auth): не логировать refresh-токен
Три следствия, которые снимают 90% вопросов о подписях:
- Подпись покрывает
tree,parent,author,committerи сообщение — подделать содержимое, авторство или место коммита в DAG незаметно нельзя. - Хэш считается по объекту вместе с
gpgsig— подписанный и неподписанный коммит с одинаковым содержимым суть разные объекты с разными oid. - Любая операция, пересоздающая коммит, теряет подпись.
rebase,squash,cherry-pick,amendсоздают новый объект и подпишут его, только если попросить явно (-S) или включитьcommit.gpgsign.
Настройка: SSH вместо GPG
С Git 2.34 подписывать можно обычным SSH-ключом — тем же, которым вы уже пушите. Это на порядок проще GPG и снимает главный барьер внедрения.
$ git config --global gpg.format ssh
$ git config --global user.signingkey ~/.ssh/id_ed25519.pub
$ git config --global commit.gpgsign true && git config --global tag.gpgsign true
$ git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers
$ cat ~/.config/git/allowed_signers # кто чем имеет право подписывать
a@ex.io namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKq...
b@ex.io namespaces="git",valid-after="20240101" ssh-ed25519 AAAAC3NzaC...
$ git log --format='%h %G? %GS %s' -4
7f2e4b8 G a@ex.io fix(auth): не логировать refresh-токен
3b71fe2 U b@ex.io feat(api): пагинация в /orders
5c04ad9 N chore: bump deps
c1a9d3f E Merge pull request #412 from feature/auth
Плейсхолдер %G? — главный инструмент автоматизации, его значения стоит запомнить:
Именно поэтому наивная проверка [ "$(git log -1 --format=%G?)" = "G" ] в CI почти всегда падает: на чистом раннере нет ни allowed_signers, ни GPG-keyring, и вы получите E/U на валидных подписях. Правильно так — с файлом подписантов, который лежит в самом репозитории и потому ревьюится как код:
- name: Все коммиты PR подписаны доверенными ключами
run: |
git config gpg.format ssh
git config gpg.ssh.allowedSignersFile .github/allowed_signers
base="$(git merge-base "origin/${{ github.base_ref }}" HEAD)"
bad="$(git log --format='%H %G?' "$base..HEAD" | awk '$2 != "G" { print $1 }')"
[ -z "$bad" ] || { echo "Без доверенной подписи:"; echo "$bad"; exit 1; }
Честно: что подпись даёт и чего не даёт
Подпись доказывает ровно одно: объект с таким содержимым был создан обладателем приватного ключа. Из этого не следует:
- что человек одобрил это как релиз — ключ обычно лежит на рабочей машине или в агенте, и компрометация машины даёт злоумышленнику подпись;
- что «Verified» на GitHub означает вашу подпись — коммиты из веб-интерфейса, кнопки merge и squash-merge подписывает ключ самой платформы (
web-flow): формально проверка проходит, но подписал их не автор; - что история защищена — подпись не мешает обладателю прав force-push переписать ветку, подписав всё заново своим ключом. От этого защищает
pre-receive, а не подпись.
Что подпись действительно закрывает: подделку авторства, подмену содержимого при транспорте и на зеркалах и — самое ценное — проверяемую цепочку до тега релиза. Подписывайте как минимум теги, даже если не подписываете каждый коммит: git tag -s v2.4.0, git verify-tag v2.4.0, а на стороне потребителя — git config merge.verifySignatures true, после чего git merge откажется сливать неподписанное. Отдельная недооценённая возможность — подписанный push: git push --signed отправляет серверу сертификат, доказывающий, что именно этот человек попросил передвинуть именно эту ссылку с этого oid на тот. В pre-receive он приезжает в GIT_PUSH_CERT, GIT_PUSH_CERT_SIGNER, GIT_PUSH_CERT_STATUS. Это единственный механизм в Git, дающий аудируемый лог изменений ссылок, и для регулируемых сред он подходит идеально. Современная альтернатива долгоживущим ключам — gitsign из Sigstore: подпись коротко живущим сертификатом по OIDC-логину с записью в прозрачный журнал Rekor, без ключей на дисках. Про цепочку поставок в целом — «Безопасность в пайплайне» и «Безопасность цепочки поставок».
Практический совет, экономящий много времени: разные ключи и почты для работы и личных проектов раскладываются условными включениями — секция [includeIf "gitdir:~/work/"] path = ~/.gitconfig-work в ~/.gitconfig подтянет рабочие user.email, user.signingkey и commit.gpgsign во всех репозиториях внутри каталога, и вам больше не придётся вспоминать, каким ключом подписан коммит.
Автоматизация без хуков
Часть задач, которые пытаются решить хуками, решается декларативно и потому надёжнее: эти механизмы версионируются и работают у всех.
* text=auto eol=lf # нормализация переводов строк: в объектах всегда LF
*.png binary # не пытаться диффить и мержить
*.svg diff # а вот SVG — текст, диффить полезно
CHANGELOG.md merge=union # обе стороны склеиваются, а не конфликтуют
package-lock.json -diff linguist-generated=true
*.ipynb filter=nbstripout # чистить вывод ячеек на входе в индекс
docs/** export-ignore # не попадёт в git archive
merge=union для CHANGELOG экономит сотни микроконфликтов; про остальные драйверы слияния — в «Конфликтах слияния». Фильтры clean/smudge — самая мощная и самая опасная часть механизма: clean выполняется при попадании файла в индекс, smudge — при выгрузке в рабочий каталог (git config filter.nbstripout.clean "jupyter nbconvert --stdin --stdout ..."). Осторожно: они запускаются на каждый add и checkout соответствующих файлов, а их конфигурация лежит в .git/config, то есть у остальных её нет — .gitattributes без раздачи конфига даёт «filter not found». Тем же механизмом работает Git LFS.
$ git config --global pull.rebase true # без коммитов "Merge branch 'main'"
$ git config --global fetch.prune true # удалённые ветки исчезают локально
$ git config --global rerere.enabled true # запоминать разрешения конфликтов
$ git config --global diff.algorithm histogram # заметно лучше на реальном коде
$ git config --global rebase.autosquash true
$ git maintenance start # фоновая упаковка и commit-graph
# bisect run — автоматический поиск сломавшего коммита за log2(N) шагов
$ git bisect start HEAD v2.3.0
$ git bisect run pytest -q tests/test_orders.py::test_pagination
3b71fe2c8d0a15f6b284e7c0d19f3a5b6c82e419 is the first bad commit
Скрипт для bisect run возвращает 0 для «хорошо», 1–124 для «плохо» и 125 для «нельзя проверить» — на последнем bisect пропустит коммит, а не сочтёт его сломанным.
Типичные ошибки
- Хук не исполняемый — Git молча его не запускает:
chmod +x, а для файлов вcore.hooksPathещё иgit update-index --chmod=+x. Проверка рабочего каталога вместо индекса ловится тестом «застейджить половину файла и закоммитить». - Автоформат с молчаливым
git add— вы коммитите не то, что видели. - Медленный хук или хук с сетевым запросом. Проверка «есть ли такой тикет в Jira» на pre-commit означает таймаут на каждом коммите и невозможность работать в самолёте.
- Валидатор сообщений без белого списка — ломает
merge,revert,fixup!и autosquash. - Захардкоженный
.git/hooksв установщике — не работает приcore.hooksPath, в worktrees и подмодулях; два фреймворка сразу — второй, тот что ставитcore.hooksPath, молча выигрывает. - Хуки как единственный контроль —
--no-verifyсуществует; на сервере обязаны быть branch protection и обязательные чеки. - Наивная проверка подписей в CI — без
allowed_signersвы получитеU/Eна валидных подписях и заблокируете команду; и не ждите, что подпись переживёт rebase — oid считается по объекту вместе сgpgsig. - Секрет, найденный после пуша. Чистка истории через
filter-repoне отменяет утечку — коммит уже мог быть склонирован и остаётся доступен по прямой ссылке в кэше платформы. Первым действием всегда ротация ключа, вторым — чистка; подробно в «Управлении секретами».
Мини-итог
- Хук — исполняемый файл в
core.hooksPath, который Git вызывает на переходе между состояниями модели данных. Контракт: аргументы, stdin, код возврата. - «pre-» хуки отменяют операцию ненулевым кодом, «post-» не отменяют ничего.
--no-verifyвыключаетpre-commit,commit-msgиpre-push. Хуки не клонируются — и это правильно: иначеgit cloneбыл бы выполнением чужого кода. pre-commitобязан проверять индекс, а не рабочий каталог:git checkout-index --prefix=<tmp> -af. Держите его быстрым (< 2 с) и по изменённым файлам; всё медленное — наpre-pushили в CI.- Клиентский хук — ускоритель обратной связи, граница только серверная:
pre-receive/updateлибо branch protection с обязательными чеками. - В CI вы проверяете не свой коммит, а эфемерный merge; берите диапазон
merge-base..HEADи не забывайтеfetch-depth: 0. Конфигурация проверок для хука и для CI должна быть одна (pre-commit run --all-files,make check) — иначе они разъедутся за месяц. - Подпись живёт в заголовке
gpgsigвнутри объекта: покрываетtree,parent, авторов и сообщение, входит в хэш и потому не переживаетrebase/squash/amend. SSH-подпись (gpg.format=ssh+allowed_signers) на порядок проще GPG; подписывайте как минимум теги релизов. При этом «Verified» ≠ «автор одобрил»: web-flow и squash-merge подписывает платформа, а подпись защищает от подделки авторства и подмены содержимого, но не от компрометации машины. .gitattributes,merge=union,rerere,bisect run,includeIf— автоматизация, которая работает у всех, потому что версионируется.
Источники
- githooks(5) — исчерпывающий контракт всех хуков: аргументы, stdin, коды возврата. Читать целиком.
- Pro Git: Git Hooks, An Example Git-Enforced Policy, Signing Your Work.
- pre-commit.com — фреймворк и каталог готовых хуков; lefthook; husky и lint-staged.
- gitattributes(5) — фильтры, драйверы diff и merge,
export-ignore; git-checkout-index, git-cat-file, git-rev-list — инструменты серверных проверок; git-verify-commit. - ssh-keygen(1), раздел ALLOWED SIGNERS — формат файла подписантов; Caleb Hearth, Signing Git Commits with Your SSH Key; Sigstore gitsign.
- Conventional Commits; GitHub Rulesets; GitLab Push Rules; gitleaks и TruffleHog.
Что дальше
Мы научились ставить проверки в точках перехода и доводить их до сервера. Следующее ограничение любой растущей кодовой базы — размер: когда репозиторий весит десятки гигабайт, git status думает секунды, а половина команды вообще не должна видеть чужие каталоги. Разберём инструменты, которыми Git масштабируется: submodules и subtree для чужого кода, sparse-checkout и partial clone для выборочной работы, LFS для бинарников — и честно посмотрим, за что каждый из них берёт плату.
Большие репозитории и монорепо: submodules, subtree, sparse-checkout, LFS