Перейти к содержимому

Продвинутый Git

Базовый цикл add–commit–push покрывает 95% рабочего времени. Оставшиеся 5% — это моменты, когда всё пошло не по плану: в прод уехал баг и нужно найти коммит-виновник за десять минут, а не за день; на полпути рефакторинга прилетает срочный хотфикс; в репозиторий попал .env с паролями; рабочая директория грязная, а переключаться нужно сейчас. Эта глава — арсенал для таких ситуаций.

Здесь разберём инструменты, которые отличают опытного инженера: stash со своим стеком, worktree для параллельной работы, bisect как автоматический бинарный поиск, rebase --onto как хирургический инструмент, субмодули и LFS, хуки и подпись коммитов, защиту от утечки секретов и гигиену .git.

git stash (см. git-stash(1)) прячет незакоммиченные изменения (modified + staged) и возвращает рабочую директорию в чистое состояние. Изменения не теряются — они складываются в стек:

СТЕК СТЭШЕЙ (новые сверху):
stash@{0} ← последний stash "WIP on feature-login: подсветка полей"
stash@{1} "WIP on main: эксперимент с конфигом"
stash@{2} ← самый старый "WIP on main: черновик документации"
Окно терминала
# что-то начал править, но прилетел хотфикс
git stash # спрятать (по умолчанию без untracked!)
git stash -u # спрятать вместе с untracked-файлами
git stash list # посмотреть стек
# вернуться к правкам
git stash pop # применить stash@{0} и удалить его из стека
git stash apply # применить stash@{0}, оставив в стеке
# навигация по стеку
git stash show -p stash@{2} # показать диф конкретного
git stash drop stash@{1} # удалить конкретный
git stash clear # выкинуть весь стек

Сценарий «прыгнул на хотфикс и вернулся»:

1. на feature-login, правки незакоммичены
рабочая папка: [грязная]
2. git stash
рабочая папка: чистая, на feature-login
3. git switch main → правка → commit → push
4. git switch feature-login
5. git stash pop
рабочая папка: [грязная снова, правки вернулись]

Важная деталь: stash — не бэкап. Он привязан к конкретному коммиту, на котором создавался, и если история этой ветки ушла вперёд, pop может дать конфликт. Правки дольше пары часов — коммить (хоть WIP-коммит), а не stash.

git worktree: несколько рабочих директорий на один репозиторий

Заголовок раздела «git worktree: несколько рабочих директорий на один репозиторий»

Проблема: ты на полпути в большом рефакторинге (грязная рабочая папка), а нужно срочно посмотреть прод. Переключаться нельзя, stash неудобен, клонировать репозиторий заново — дорого (гигабайты .git).

git worktree (см. git-worktree(1)) даёт несколько рабочих директорий, ссылающихся на один .git:

Окно терминала
git worktree add ../project-hotfix main # вторая папка на ветке main
cd ../project-hotfix
# работаешь в «отдельном клоне», но объекты общие
git worktree list
# /home/me/project abc1234 [feature-big-refactor]
# /home/me/project-hotfix def5678 [main]
git worktree remove ../project-hotfix # убрать, когда не нужно
ОДИН .git, ДВЕ ПАПКИ:
~/project/ ~/project-hotfix/
├── .git/ ◄──────────────────┤ (файл .git-файл-указатель,
│ ├── objects/ ◄── ОБЩИЕ а не каталог)
│ └── refs/ ◄── ОБЩИЕ ├── .git → файл:
├── src/ (refactor) │ "gitdir: /home/me/project/.git/worktrees/..."
└── (грязная папка) ├── src/ (main, чистая)
└── (готово к хотфиксу)

Следствия: «клон» мгновенный (объекты общие), fetch делается один раз на все папки. Ограничение: одна ветка — одна рабочая папка.

Окно терминала
git worktree add ../project-v1.2 v1.2.0 # и откат к тегу для отладки рядом с текущей работой

Классические применения: срочный хотфикс во время рефакторинга, «чистая» папка на main для быстрых проверок, сборка старого релиза из тега.

История: вчера работало, сегодня — нет. Тысяча коммитов между «хорошим» и «плохим». Вместо линейного перебора — бинарный поиск (см. git-bisect(1)): Git сам вычисляет средний коммит, ты говоришь «хорошо/плохо», и за ~10 итераций (для тысячи коммитов!) виновник найден.

Окно терминала
git bisect start
git bisect bad # текущий коммит — плохой
git bisect good v1.8.0 # тег v1.8.0 — хороший (где точно работало)
# Git переключается на средний коммит:
# Bisecting: 512 revisions left to test after this (roughly 9 steps)

Дальше — итерации:

ШАГ 1 (из ~9): коммит C512 ── проверяешь ──► ПЛОХО → виновник левее
ШАГ 2: коммит C256 ── проверяешь ──► ХОРОШО → виновник правее
ШАГ 3: коммит C384 ── проверяешь ──► ПЛОХО → левее
ШАГ 4: коммит C320 ── проверяешь ──► ХОРОШО → правее
ШАГ 5: коммит C352 ── проверяешь ──► ПЛОХО → левее
ШАГ 6: коммит C336 ── проверяешь ──► ХОРОШО → правее
ШАГ 7: коммит C344 ── проверяешь ──► ПЛОХО → левее
ШАГ 8: коммит C340 ── проверяешь ──► ХОРОШО → правее
ШАГ 9: коммит C342 ── ВИНОВНИК НАЙДЕН
git bisect reset # вернуться туда, где был

Проверку можно автоматизировать скриптом — bisect сам прогоняет его на каждом шаге (exit code 0 = хорошо, 1–124 = плохо, 125 = пропустить):

Окно терминала
git bisect start HEAD v1.8.0
git bisect run npm test -- --grep "checkout flow"
# ... через 9 прогонов:
# <коммит-хэш> is the first bad commit

Если виновник — чужой коммит, git bisect visualize показывает контекст, и ты смотришь конкретный диф, а не тысячу.

Обычный rebase переносит коммиты на новое основание. --onto нужен, когда переносится не вся ветка, а кусок: «возьми коммиты, которые есть в feature-B, но нет в feature-A, и поставь их на main».

Классическая задача: сделал ветку от неправильного места (от другой фичи вместо main):

БЫЛО (feature-search ошибочно выросла из feature-auth):
main: C1─C2
\
feature-auth: A1─A2
\
feature-search: S1─S2 ← надо перенести на main
Окно терминала
git rebase --onto main feature-auth feature-search
# ▲ ▲ ▲
# │ │ └── какую ветку двигать
# │ └── от какой точки отсчитывать «что переносить»
# └── куда ставить
СТАЛО:
main: C1─C2─────────●
\ \
feature-auth: A1─A2 \
\
feature-search: S1'─S2' ← как будто всегда росла от main

Другой частый сценарий — «отрезать хвост»: коммиты попали не в ту ветку. Тогда --onto в обратную сторону + cherry-pick:

Окно терминала
# три коммита случайно сделали в release-1.2 вместо main
git switch main
git cherry-pick release-1.2~3..release-1.2 # перенести их в main
git switch release-1.2
git reset --hard HEAD~3 # отрезать лишнее (ветка ещё не запушена!)

Субмодуль — это фиксированная ссылка на конкретный коммит другого репозитория (подробно — Pro Git, раздел «Подмодули»):

my-app/
├── .git/
├── src/
└── shared-lib/ ← НЕ файлы, а вложенный клон
├── .git → указатель на ../.git/modules/shared-lib
└── (checkout коммита abc1234)
.gitmodules ← файл с URL и путями субмодулей
Окно терминала
git submodule add git@github.com:octo/shared-lib.git libs/shared-lib
git commit -m "chore: add shared-lib as submodule"
# при клонировании репозитория с субмодулями
git clone --recurse-submodules git@github.com:you/my-app.git
# или позже:
git submodule update --init --recursive

Ключевое свойство: родитель хранит только хэш коммита субмодуля. Новые коммиты в субмодуле родитель не видит, пока ты явно не обновишь запись. Это и сила (пинимание версий), и боль (забыл обновить запись — CI собирает старое).

Git плохо хранит бинарники: каждая версия 100-МБ ассета — это новый объект на 100 МБ, и клонируешь ты все версии сразу. LFS (Large File Storage) выносит содержимое больших файлов в отдельное хранилище, а в репозитории оставляет лёгкий указатель:

БЕЗ LFS: С LFS:
.git/objects/ .git/lfs/objects/
├── 100MB (v1 ассета) ├── 100MB (v1)
├── 100MB (v2 ассета) └── 100MB (v2)
├── 100MB (v3 ассета) clone=300MB
ветки видят только указатели;
clone качает текущие версии
Окно терминала
git lfs install # один раз на машину
git lfs track "*.psd" "*.zip" "*.mp4" # правило → .gitattributes
git add .gitattributes
git commit -m "chore: track binaries with LFS"

Правило: всё, что не диффится текстом (картинки, видео, архивы, модели ML) — в LFS или в артефакт-хранилище (S3/MinIO), но не в обычные объекты Git.

В .git/hooks/ лежат исполняемые скрипты, которые Git запускает на событиях жизненного цикла (полный список хуков — githooks(5)). Самые полезные для разработчика:

Хук Когда Типичное использование
pre-commit перед созданием коммита линтер, форматтер, проверка секретов
commit-msg после ввода сообщения проверка формата conventional commits
pre-push перед push прогон тестов, запрет push в main
post-merge после merge npm install, если поменялись зависимости

Пример pre-commit без сторонних инструментов:

#!/bin/sh
# .git/hooks/pre-commit (chmod +x обязателен!)
# не дать закоммитить отладочные маркеры
if git diff --cached | grep -E '^\+.*(console\.log|debugger|TODO:HACK)'; then
echo "❌ Найден console.log/debugger в staged-изменениях" >&2
exit 1 # ненулевой код = отмена коммита
fi

Руками хуки не масштабируются (папка .git локальна и не коммитится). Стандарт де-факто — husky + lint-staged: конфиг хуков хранится в репозитории и ставится всем через npm install:

Окно терминала
npm install --save-dev husky lint-staged
npx husky init
package.json
{
"lint-staged": {
"*.{js,ts}": ["eslint --fix", "prettier --write"]
}
}
.husky/pre-commit
npx lint-staged

Теперь линтер гоняется только по staged-файлам за секунды. Но хуки — дверь «на совести»: их можно обойти (--no-verify), поэтому серверная защита (CI, branch protection) остаётся обязательной.

По умолчанию коммит подписывается именем и email — которые поставить может кто угодно. Подпись (GPG или SSH-ключом) доказывает, что коммит сделал именно владелец ключа:

Окно терминала
# GPG
git config --global user.signingkey AAAA1111BBBB2222
git config --global commit.gpgsign true
# или SSH-подпись (проще: ключ уже есть для GitHub)
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
обычный коммит: подписанный коммит:
author: Alice <a@corp.io> author: Alice <a@corp.io>
gpg: Signature made ...
«кто угодно может написать хэш коммита зашифрован
Alice в поле author» приватным ключом Alice;
сервер/GitHub проверяет
публичным → ✓ Verified

GitHub показывает зелёный «Verified» и позволяет требовать только подписанные коммиты в main — cheap win для цепочки поставки (supply chain).

Правило номер один: секрет никогда не попадает в Git. Даже если удалить коммит следующим — он остался в истории, в reflog, у всех, кто успел запулить, и у ботов, сканирующих публичные пуши в реальном времени (AWS-ключи воруют за минуты после пуша).

Профилактика на уровне хука:

Окно терминала
# pre-commit: сканер секретов до того, как коммит создан
gitleaks protect --staged --verbose
# exit 1, если найден похожий на секрет паттерн (AKIA..., -----BEGIN PRIVATE KEY-----, ...)
Окно терминала
# раз в неделю — полный аудит истории
gitleaks detect --source . --report-path gitleaks-report.json
# trufflehog — альтернатива с проверкой секретов «живые ли» через API
trufflehog git file://. --only-verified
git add .env ──► pre-commit (gitleaks) ──► СЕКРЕТ? ──► ДА ──► коммит ОТМЕНЁН
════════════════════════ └──► НЕТ ──► коммит создан

Случайно запушил — порядок действий: отозвать секрет (перевыпустить ключ/пароль — это первым делом!), потом уже чистить историю (git filter-repo, force-push), потом проверить логи использования. Чистка истории не спасает от кражи — секрет уже могли взять.

git blame показывает, в каком коммите и кем была изменена каждая строка файла:

Окно терминала
git blame -L 40,60 src/payments.js
# ^abc1234 (Влад Б. 2026-03-14 13:22:11 +0300 40) const commission = 0.02;
# abc12345 (Аня К. 2026-01-02 09:10:45 +0300 41) ...
файл сейчас: blame показывает:
строка 40: 0.02 ← коммит abc1234, Влад, 14 марта
строка 41: ... ← коммит abc12345, Аня, 2 января
строка 42: ... ← коммит def9999, Влад, 30 декабря

Полезные флаги: -L 40,60 (диапазон строк), -w (игнорировать переносы строк), -C (ловить перемещения кода между файлами), --ignore-rev (не винить за переформатирование). В расследовании бага: нашёл строку-подозреваемую → git show <коммит> → видишь диф и контекст задачи.

Монорепо — много проектов в одном репозитории. Проблема: git clone качает всё. Решения:

Sparse checkout (см. git-sparse-checkout(1)) — чекаутишь только нужные папки:

Окно терминала
git clone --filter=blob:none --sparse git@github.com:corp/monorepo.git
cd monorepo
git sparse-checkout set apps/web libs/ui # в рабочей папке только они
# --cone (режим по умолчанию в новых Git): только папки верхнего уровня,
# быстрее и предсказуемее, чем паттерны-гитигноры

С partial clone (--filter=blob:none) объекты качаются лениво по мере обращения: git log и git blame работают сразу, а содержимое файлов приезжает только для тех путей, что реально чекаутятся. Для монорепо на сотни гигабайт это разница между клоном за минуту и за час.

полный clone монорепо: sparse-checkout set apps/web:
monorepo/ monorepo/
├── apps/ ├── apps/
│ ├── web/ ◄ нужно │ └── web/
│ ├── mobile/ ✗ не нужно ├── libs/ui/
├── libs/ └── (остальное НЕ скачано/не развёрнуто)
│ ├── ui/ ◄ нужно
│ └── core/ ✗ не нужно
└── 5 ГБ объектов └── объекты качаются лениво (blob:none)

Submodules (разобраны выше) — альтернатива, когда проекты всё же живут в разных репозиториях, но нужна согласованная версия. Worktree — третий вариант: по папке на проект с общими объектами. Выбор зависит от культуры команды; технически Git поддерживает все три.

Git сам подчищает за собой, но знать рычаги полезно:

Окно терминала
git gc # сборка мусора: упаковка объектов, ускорение
git gc --aggressive # тщательнее и дольше; почти никогда не нужно
git prune # удалить недостижимые объекты (делает gc и так)
git reflog expire --expire=30.days --all # подрезать reflog
ЧТО ГДЕ КОПИТСЯ:
объекты: все версии файлов ──► git gc упаковывает в pack-файлы
reflog: история движений веток (90 дней) ──► expire подрезает
stash: стек припрятанного ──► drop/clear
untracked: неотслеживаемый мусор ──► clean (ОПАСНО!)

И отдельно — git clean -fd, самая опасная команда из бытовых:

Окно терминала
git clean -n # ПРОБНЫЙ ПРОГОН: покажет, ЧТО будет удалено (обязательно сначала!)
git clean -fd # удалить untracked-файлы и папки — БЕЗ корзины, навсегда
git clean -fdx # и то, что в .gitignore (node_modules, .env — тоже!)
git status говорит "nothing to commit" + untracked файлы.
git clean -fd удаляет untracked НАВСЕГДА:
до: после clean -fd:
src/ ✓ src/ ✓
notes.md ✗ ──► notes.md УДАЛЁН
draft/ ✗ draft/ УДАЛЁН
.env ✓(игнор).env ✓ (без -x не тронет)
  1. Stash как долговременное хранилище. Через неделю не вспомнишь, что там лежало, а pop после ребейза ветки может дать конфликт в полусделанной работе. Правки дольше пары часов — коммить, даже WIP.

  2. git clean -fd без -n. Одна опечатка — и черновики/генерированный код/локальные фикстуры удалены без корзины. Мышечная память: всегда clean -n перед clean -fd.

  3. Забыть --recurse-submodules при clone. Репозиторий выглядит целым, но папки субмодулей пустые, сборка падает с загадочными ошибками. Диагноз: git submodule status показывает - (не инициализирован).

  4. Обновить субмодуль, но не зафиксировать указатель. В субмодуле сделал git pull, всё работает локально, запушил только родителя — CI собирает старую версию, потому что запись в родителе не изменилась. После правок в субмодуле всегда: коммит в субмодуле + коммит указателя в родителе.

  5. Хуки как единственная защита. --no-verify обходит любой хук за секунду, а хуки не настроены у того, кто клонирует впервые. Хуки — для скорости фидбека, branch protection и CI — для гарантий.

  6. Bisect без автоматизации на проекте, где проверка занимает 10 минут. Ручной bisect с долгой проверкой — мучение. Сначала напиши скрипт/тест, который однозначно говорит «хорошо/плохо», и прогоняй git bisect run.

  1. Что делает git stash и чем pop отличается от apply? Прячет незакоммиченные изменения в стек, очищая рабочую директорию. pop применяет последний стэш и удаляет его из стека; apply применяет, оставляя в стеке.

  2. Зачем нужен git worktree? Несколько рабочих директорий с общим .git: параллельная работа над разными ветками без stash и без полного повторного клона. Ограничение: ветка не может быть открыта в двух worktree одновременно.

  3. Как найти коммит, сломавший фичу, если их тысяча? git bisect: помечаю плохой (текущий) и хороший (тег/коммит, где работало), Git бинарным поиском переключает на средние коммиты; лучше — git bisect run <скрипт-тест> для автоматизации. ~log₂(N) шагов.

  4. Объясни git rebase --onto main A B. Переносит коммиты ветки B, начиная с точки, где она разошлась с A, на новое основание main. Используется, когда ветка выросла не оттуда или нужно перенести кусок ветки.

  5. Чем субмодуль отличается от простого копирования кода? Субмодуль — фиксированная ссылка на коммит внешнего репозитория с собственной историей; обновления явные и пинятся. Копия — размножённый код, который расходится с оригиналом.

  6. Запушил .env с паролем. Твои действия? Сначала отозвать/перевыпустить секрет — историю чистить поздно, ключ уже могли украсть. Потом удалить из истории (git filter-repo), force-push, уведомить команду переклонироваться, проверить логи доступа.

  7. Зачем husky, если есть CI? Husky даёт фидбек за секунды до создания коммита (линтер/форматтер по staged-файлам), CI — гарантию на сервере, которую нельзя обойти. Это два разных уровня защиты, а не замена друг друга.

  1. Начни правку в ветке, спрять её через git stash -u, переключись на main, сделай хотфикс-коммит, вернись и примени стэш. Критерий: git stash list пуст, правки на месте, хотфикс — отдельный коммит в main.
  2. Создай worktree на ветке main рядом с текущей папкой, убедись, что объекты общие (изменение из одной папки видно в git log другой), удали worktree. Критерий: git worktree list показывает только исходную папку.
  3. В тестовом репозитории с 20+ коммитами сломай поведение в последнем, найди виновника через git bisect run со скриптом-проверкой. Критерий: bisect заканчивается строкой «first bad commit» с правильным хэшом.
  4. Создай ветку feature-X от другой фича-ветки вместо main и перенеси её коммиты на main через git rebase --onto. Критерий: git log --graph показывает feature-X, растущую напрямую от main.
  5. Подключи husky + lint-staged: pre-commit прогоняет форматтер только по staged-файлам, commit-msg проверяет формат сообщения (feat:, fix:). Критерий: коммит с сообщением «wip» отклонён, с «feat: add x» проходит.
  6. Настрой gitleaks как pre-commit хук и попробуй закоммитить файл с фейковым ключом «redacted:AKIA…». Критерий: коммит отклонён, в выводе виден файл и тип секрета.
  7. Прогони git clean -n в своём реальном проекте и проанализируй список: что бы ты потерял при clean -fdx? Вынеси ценное из untracked-зоны.