Git и командная работа Хуки и автоматизация: pre-commit, CI-интеграция, подпись коммитов
0%

Хуки и автоматизация: pre-commit, CI-интеграция, подпись коммитов

Хуки и автоматизация: 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, ломаются на всех трёх случаях.

Четыре свойства, из которых следует всё остальное:

  1. Хуки не версионируются. .git/hooks — часть служебного каталога, а не рабочего дерева: их нельзя закоммитить и раздать через git clone. Это не недоработка, а осознанное решение безопасности — иначе клонирование любого репозитория означало бы выполнение чужого кода на вашей машине.
  2. Язык любой. Git смотрит только на бит +x и shebang: shell, Python, Go-бинарник — что угодно.
  3. Код возврата решает. У «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% вопросов о подписях:

  1. Подпись покрывает tree, parent, author, committer и сообщение — подделать содержимое, авторство или место коммита в DAG незаметно нельзя.
  2. Хэш считается по объекту вместе с gpgsig — подписанный и неподписанный коммит с одинаковым содержимым суть разные объекты с разными oid.
  3. Любая операция, пересоздающая коммит, теряет подпись. 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 — автоматизация, которая работает у всех, потому что версионируется.

Источники

Что дальше

Мы научились ставить проверки в точках перехода и доводить их до сервера. Следующее ограничение любой растущей кодовой базы — размер: когда репозиторий весит десятки гигабайт, git status думает секунды, а половина команды вообще не должна видеть чужие каталоги. Разберём инструменты, которыми Git масштабируется: submodules и subtree для чужого кода, sparse-checkout и partial clone для выборочной работы, LFS для бинарников — и честно посмотрим, за что каждый из них берёт плату.

Большие репозитории и монорепо: submodules, subtree, sparse-checkout, LFS

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов