← назад к разделу

Коммит — единица истории. Через полгода именно по коммитам вы (или коллега) будете разбираться, что и зачем менялось. Разница между историей-помощником и историей-помойкой — в ремесле: что класть в коммит, как его подписывать и что в репозиторий не пускать вообще.

рабочая папка pricing.sql · сумма pricing.sql · отступы .env build/app .gitignoreэтих файлов Git не видит индекс pricing.sql · сумма pricing.sql · отступы git add кладёт сюда история вчерашний коммит git commitfix: сумма корзины style: отступы две правки в одном файле — два коммита

.gitignore вычёркивает .env и build/ ещё в рабочей папке — Git их просто не показывает. Из оставшегося git add набирает в индекс ровно тот кусок, который войдёт в коммит, а git commit превращает индекс в новый узел истории. Две правки, случайно оказавшиеся в одном файле, дают два узла — и каждый откатывается отдельно.

Обязательно

Атомарный коммит: одно изменение — один снимокспросят на собеседовании

Хороший коммит — атомарный: одно логическое изменение целиком. Починили баг — коммит; отформатировали код — отдельный коммит; добавили функцию — третий. Плохие крайности: «коммит-свалка» (баг + рефакторинг + новая функция в одном) и коммит «сохранился на всякий случай» с половиной недописанной мысли.

Зачем это: атомарные коммиты можно по одному откатывать, переносить между ветками и понимать при ревью. Свалку — только целиком.

Точность набора обеспечивает индекс:

git add db/migrations/0042_order_total.sql   # только нужные файлы
git add -p                          # интерактивно: по кусочкам внутри файла!
git commit -m "fix: пересчёт суммы при удалении последней позиции"

git add -p — недооценённая команда: она показывает изменения кусками и спрашивает, какие включить. Так из одного файла с двумя правками собираются два чистых коммита.

Сообщение коммита: ответ на вопрос «зачем»

Первая строка — короткое резюме, без точки в конце: в ленте git log --oneline видно только её. Планку называет сама документация Git: не больше 50 символов. Длиннее — и строку начнёт обрезать и лента в терминале, и карточка коммита на сервере. Форма бывает разная — повеление (починить), отглагольное существительное (пересчёт суммы), описание симптома (сумма не обнулялась); важно не какая, а что вся команда пишет одинаково. Что именно изменилось — видно из кода; сообщение отвечает на то, чего в коде нет: зачем.

  • Плохо: fix, правки, wip, изменил сервис заказов.
  • Хорошо: fix: сумма корзины не обнулялась при удалении последней позиции.

Многие команды используют префиксы conventional commits (feat:, fix:, refactor:, docs:) — по ним удобно фильтровать историю и собирать changelog автоматически. Если нужно больше контекста — пустая строка после резюме и абзац подробностей.

свалка fix правки wip история fix: сумма корзины feat: экспорт в CSV refactor: вынести расчёт

Две ленты git log --oneline: по верхней не понять ни что менялось, ни зачем, по нижней видно и то и другое.

Читать историю: log, diff, blame

git log --oneline -15        # последние 15 коммитов кратко
git log -p file.txt          # история конкретного файла с изменениями
git diff                     # что изменено, но ещё не в индексе
git diff --staged            # что уже в индексе (войдёт в коммит)
git show abc123              # что сделал конкретный коммит
git blame db/schema.sql      # кто и в каком коммите менял каждую строку

git blame звучит обвиняюще, но это инструмент археологии, а не поиска виноватых: нашли странную строку → blame → коммит → его сообщение объясняет «зачем» → а если нет, известно, кого спросить. Связка «diff перед коммитом» — привычка, отсекающая случайные изменения: секунда просмотра спасает от закоммиченного отладочного вывода.

Одной строкой --oneline история нужна редко: при отладке из неё ищут. Ключи, которые окупаются в первую же неделю:

git log --oneline --graph --all -20          # лента с ветками и слияниями
git log -S "discount_rate" --oneline         # коммиты, где эта строка появилась или исчезла
git log --grep "PROJ-142"                    # по тексту сообщения: вся история задачи
git log --author=ivan --since="2 weeks ago"  # кто и когда
git log --oneline -- src/billing/            # только коммиты, трогавшие этот путь
git log -L 40,60:db/schema.sql               # история конкретных строк файла

-S (его зовут «кирка») отвечает на вопрос «когда эту функцию удалили», на который blame ответить не может: удалённой строки в файле нет. А --graph --all показывает, как ветки расходились и сходились, и это первое, что открывают, когда история выглядит непонятно.

У blame две слабости, и обе лечатся ключами. Массовое переформатирование или перенос файла записывают на автора того коммита все строки разом, и настоящая история пропадает за «Reformat code»; git blame -w игнорирует изменения пробелов, -C (можно повторить дважды) отслеживает строки, переехавшие из других файлов, а --ignore-rev пропускает названный коммит, и его номер команды кладут в файл .git-blame-ignore-revs, чтобы все пропускали его автоматически. Вторая слабость в том, что сообщение коммита не всегда объясняет «зачем»; тогда от коммита идут к обсуждению: по номеру задачи в сообщении или по pull request, куда коммит попал, GitHub и GitLab показывают его прямо на странице коммита.

.gitignore: чему в истории не место

Git предлагает коммитить всё, что видит, но не всё должно попадать в репозиторий: артефакты сборки воспроизводятся из кода, зависимости из lock-файла, а настройки редактора у каждого свои. Файл .gitignore в корне проекта перечисляет пути, которые Git перестаёт замечать. Правило одно: игнорируется генерируемое и личное, коммитится исходное и общее.

# артефакты сборки — воспроизводятся из кода
build/
# зависимости — восстанавливаются из lock-файла
node_modules/
# логи
*.log
# настройки IDE
.idea/
# СЕКРЕТЫ — никогда в git!
.env

Зависимости и их lock-файл живут по разные стороны этого правила: node_modules/, .venv/, target/ и build/ восстанавливаются и в историю не идут, а lock-файл (package-lock.json, go.sum, poetry.lock, gradle.lockfile) коммитят обязательно, иначе у коллеги соберётся другой набор версий.

Строка про .env важнее остальных. Секреты (пароли, ключи API) не должны попадать в историю никогда — история распределённая и вечная. Вычистить секрет из неё технически можно, но это не спасает: на публичном хостинге старый коммит ещё какое-то время открывается прямо по своему идентификатору, а у коллег, успевших забрать историю, он уже лежит в локальной копии. Работает ровно одно действие — сменить сам ключ. Чтобы секрет не доехал до сервера вовсе, в конвейер ставят проверку истории на секреты (Gitleaks); где такие проверки стоят в конвейере, разбирает статья про его принципы. Попавший в коммит секрет считается скомпрометированным с этой секунды, и вопрос только в том, как быстро его отзовут.

Две ловушки .gitignore

Комментарии здесь стоят отдельными строками, и это не вкусовщина. В .gitignore символ # начинает комментарий только в начале строки; если написать build/ # артефакты, Git примет за шаблон всю строку целиком и пойдёт искать внутри папки build что-то с именем # артефакты. Такого пути в проекте нет, правило молча не срабатывает, а build/ продолжает попадать в коммиты. Проверить, что именно игнорируется, помогает git status --ignored.

Вторая ловушка того же файла обиднее. .gitignore действует только на то, за чем Git ещё не следит. Если build/ хоть раз попал в коммит, Git уже считает папку своей, и добавленная строка ничего не изменит: build/ так и будет всплывать в git status после каждой сборки. Папку надо сначала убрать из индекса, оставив файлы на диске:

git rm -r --cached build/   # Git перестаёт следить, с диска ничего не пропадает
git commit -m "chore: убрать build из репозитория"

Вот после этого правило из .gitignore начинает работать.

Что показывает git status: untracked и уборка

Новичок кладёт в папку файл, запускает git status и не находит его среди «изменённых»: Git показывает его отдельно, в блоке Untracked files, а в коротком виде (git status -s) помечает двумя знаками вопроса ??. Это правильно: Git ничего не знает о файле, пока его хотя бы раз не добавили в индекс. Изменённый это тот, чей прошлый снимок уже есть в истории. Отсюда три состояния в выводе: untracked (Git не следит), modified (следит, файл изменён, но в индекс не положен), staged (лежит в индексе и войдёт в следующий коммит).

Удалять и переименовывать отслеживаемые файлы удобнее через Git, чем через файловый менеджер: тогда индекс обновится сам.

git rm docs/old-runbook.md          # удалить с диска и из индекса
git rm --cached secrets.env         # перестать отслеживать, но оставить на диске
git mv docs/setup.md docs/install.md  # переименовать, Git запишет это как перемещение
git clean -n                        # показать, какие untracked-файлы будут удалены
git clean -fd                       # удалить их вместе с пустыми папками

git clean без -n необратим: untracked-файлы не попадали ни в один снимок, и вернуть их будет неоткуда. Поэтому сначала всегда сухой прогон.

Поправить последний коммитспросят на собеседовании

Забыли файл или опечатались в сообщении — не создавайте коммит «fix предыдущего»:

git add забытый-файл.txt
git commit --amend           # заменит последний коммит новым

Осторожность одна: --amend переписывает коммит: старый снимок заменяется новым с другим идентификатором, а не правится на месте. Пока коммит только у вас, это никого не касается; если он уже на сервере, обычный push откажет, и --amend попадает под золотое правило переписывания истории. Безопасно, пока он не отправлен на сервер. Правило «не переписывать отправленное» подробно разобрано в статье про rebase.

Дополнительно: при первом чтении можно пропустить

Глубже: секрет попал в историюрасширенное

Ключ от базы или токен, закоммиченный по ошибке, «удалить трудно» не потому, что Git упрям, а потому, что снимок с ним останется в истории у всех, кто успел сделать pull. Из своей истории его вычищают инструментом, который переписывает все коммиты, где встречался файл или строка: git filter-repo (рекомендуемый, ставится отдельно) или BFG Repo-Cleaner.

git filter-repo --path config/prod.env --invert-paths   # выбросить файл из всех коммитов
git push --force --all && git push --force --tags

После этого хеши всех коммитов начиная с первого «заражённого» меняются, и каждому в команде придётся заново клонировать репозиторий. Но главное не это. На GitHub и GitLab старый коммит ещё некоторое время открывается по прямому адресу с его SHA, лежит в кешах форков и в логах CI. Поэтому единственное, что действительно закрывает утечку, это ротация: старый ключ отзывают, выпускают новый, и лишь потом чистят историю, чтобы секрет не всплывал в поиске.

Глубже: хуки до коммитарасширенное

Секрет в истории, забытый форматер, тест, который не запускали: всё это можно ловить до того, как коммит создан. Git перед каждым коммитом запускает скрипт .git/hooks/pre-commit, если он есть и исполняем; ненулевой код возврата отменяет коммит.

#!/bin/sh
# .git/hooks/pre-commit
gitleaks protect --staged --redact || exit 1
make lint || exit 1

Чем это отличается от проверки в PR: хук работает у вас на машине, до того как коммит уехал на сервер, и ловит секрет, который в PR уже стал бы утечкой. Зато хук легко обойти: git commit --no-verify пропускает его, а сам файл хука не коммитится, каждый ставит его себе сам (поэтому команды держат конфигурацию в репозитории и раскатывают её инструментами вроде pre-commit или husky). Правило одно: хук ускоряет обратную связь, но не заменяет проверку в CI. Что именно обязан ловить конвейер, разобрано в стандартах.

Глубже: git bisect: найти сломавший коммитрасширенноеспросят на собеседовании

История как инструмент отладки работает так: тест, который проходил неделю назад, сегодня падает, а между двумя точками сто коммитов. Проверять по одному значит сто прогонов; git bisect делает двоичный поиск и укладывается в семь.

git bisect start
git bisect bad                 # текущий коммит сломан
git bisect good v2.3.0         # здесь точно работало
# Git переключает на середину; проверяем и отвечаем:
git bisect good                # или git bisect bad
git bisect reset               # вернуться, когда виновник назван

Если проверка автоматизируется, отвечать руками не нужно: git bisect run make test сам гоняет команду на каждом шаге и находит первый плохой коммит. Здесь и окупается дисциплина атомарных коммитов: когда каждый снимок собирается и означает одно изменение, найденный коммит сразу говорит, что сломалось; в коммите «правки» на сорок файлов поиск заканчивается там же, где начался.

Глубже: подпись коммитоврасширенное

Имя и почта в коммите это просто строки из git config, их может написать кто угодно. Поэтому компании, где история кода служит юридическим следом, требуют подписанных коммитов: рядом с коммитом лежит подпись, сервер проверяет её и показывает отметкой verified.

Подписывать можно GPG-ключом или тем же SSH-ключом, которым вы ходите на сервер, второй путь проще:

git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true    # подписывать всегда, без -S

Открытый ключ добавляют в аккаунт как signing key (это отдельный пункт настроек, не тот, что для доступа). После этого git log --show-signature показывает, кем подписан коммит, а в правилах защиты ветки включают требование подписи. Вместе с подписью часто требуют и Signed-off-by (git commit -s): это не криптография, а формальное согласие с лицензией вклада.

Коротко

  • Атомарный коммит: одно логическое изменение. git add -p собирает коммит по кусочкам.
  • Сообщение отвечает «зачем»: короткое резюме, при нужде — подробности абзацем. Префиксы feat/fix помогают фильтровать.
  • История — инструмент: log для ленты, diff перед коммитом, blame для археологии.
  • .gitignore: сборка, зависимости, IDE-файлы — вон; секреты — никогда в git, попали — ротация.
  • --amend чинит последний коммит, пока он не отправлен.
  • git status делит файлы на untracked, modified и staged; удалять и переименовывать через git rm и git mv, git clean только после -n.
  • Секрет в истории лечит ротация ключа, а уже потом git filter-repo; сломавший коммит ищет git bisect, до коммита проверяет хук pre-commit.
  • Историю ищут ключами: -S по содержимому, --grep по сообщению, -- путь по файлам, --graph --all по веткам; blame -w -C и .git-blame-ignore-revs спасают от переформатирований; --amend создаёт новый коммит с другим идентификатором.

Что почитать дальше