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

Git

Как Git хранит историю и синхронизирует её с сервером, чем merge отличается от rebase, какую модель ветвления выбрать команде и как спасать «потерянные» коммиты. Git спрашивают на любом собеседовании: по ответам видно, работал ли кандидат в команде с pull request или только нажимал кнопки в IDE.

Junior+

Как устроен Git и какие команды нужны каждый день?

Заголовок раздела «Как устроен Git и какие команды нужны каждый день?»
Ответ

Что это

Коммит — снимок всех файлов с автором, сообщением и ссылкой на предыдущий коммит. Изменение проходит три области:

  1. Рабочая копия — файлы на диске.
  2. Индекс — то, что войдёт в следующий коммит.
  3. Репозиторий — папка .git с историей.

Как пользоваться

Команда Что делает
git clone <url> копирует репозиторий
git status что изменено и что в индексе
git add <файл> кладёт правки в индекс
git commit -m "..." создаёт коммит из индекса
git switch -c <ветка> создаёт ветку и переходит в неё
git fetch скачивает чужие коммиты
git pull fetch и слияние в текущую ветку
git push отправляет коммиты на сервер

Частая ошибка

Считать, как в SVN, что коммит уже у коллег. До git push он только у вас.

Как устроено

Признак SVN Git
История на центральном сервере полная копия в каждом клоне
Коммит сразу на сервере локально, на сервер — через push
Ветка копия каталога указатель на коммит
Без сети почти ничего всё, кроме обмена с сервером

fetch против pull

  • git fetch обновляет только ветки удалённого отслеживания origin/*. Ваши ветки и файлы не меняются.
  • git pull делает fetch, а затем вливает origin/<ветка> в текущую ветку слиянием или rebase.

Ловушки

  • Неожиданный merge-коммит. Если вы и коллега закоммитили в одну ветку, git pull со слиянием создаст коммит слияния. История обрастает «Merge branch ‘main’ of …».
  • Лишнее в коммите. git add . добавит всё подряд, включая bin/, obj/ и локальные настройки. Их исключают в .gitignore.
  • Отказ pull. Без настройки стратегии Git останавливается с «Need to specify how to reconcile divergent branches». Установщик Git for Windows обычно прописывает pull.rebase false, поэтому на Windows этой ошибки можно не встретить.

Правило

Сначала git fetch и git log main..origin/main, потом осознанно merge или rebase. Стратегию pull настройте явно.

Под капотом

  • git clone копирует все объекты и создаёт ветки удалённого отслеживания origin/*. Локальная main получает upstream — ветку на сервере, с которой сравнивается.
  • git status -sb показывает расхождение с upstream: [ahead 2, behind 1]. Это сравнение с последним fetch, а не с сервером прямо сейчас.
  • Удалённых репозиториев может быть несколько: origin для своего форка и upstream для исходного проекта.

Цена

  • Клон содержит всю историю. Для CI хватает git clone --depth 1 (shallow clone), для больших репозиториев — --filter=blob:none (partial clone): содержимое старых файлов скачивается по требованию.
  • Бинарные файлы не сливаются, а блокировок, как svn lock, в самом Git нет: конфликт в них решают только выбором одной версии.

Компромиссы

  • pull.rebase true держит локальную историю линейной, но конфликты решаются покоммитно.
  • pull.rebase false проще, но засоряет историю коммитами слияния.
  • pull.ff only безопаснее всего: при расхождении Git останавливается и ждёт решения человека.

Как проверить

  • git remote -v — куда смотрят удалённые репозитории.
  • git log --oneline --graph --all — граф локальных и удалённых веток.
  • git config --show-origin --get-regexp pull — откуда взялась стратегия pull.

Итог: в Git две истории — ваша и серверная, и между ними нет ничего автоматического, кроме того, что вы явно настроили.

Пример
Окно терминала
git clone https://github.com/acme/shop.git # копия репозитория со всей историей
cd shop
git switch -c feature/discount # новая ветка от текущего коммита
git status # что изменено и что уже в индексе
git add src/Discount.cs # положить файл в индекс
git commit -m "Add discount calculation" # локальный коммит: сервер о нём не знает
git fetch origin # обновить origin/*, свои ветки не трогать
git log --oneline main..origin/main # какие коммиты пришли на сервер в main
git push -u origin feature/discount # опубликовать ветку и запомнить upstream
Middle+

Чем merge отличается от rebase и когда что использовать?

Заголовок раздела «Чем merge отличается от rebase и когда что использовать?»
Ответ

Что это

Два способа перенести изменения одной ветки в другую.

  • Merge соединяет ветки. Если обе ушли вперёд, появляется коммит слияния с двумя родителями.
  • Rebase применяет ваши коммиты заново поверх другой ветки. Получаются новые коммиты с новыми хешами и прямая история.

Как пользоваться

  1. Обновить ветку: git fetch, затем git rebase origin/main или git merge origin/main.
  2. При конфликте Git останавливается и помечает спорные места: <<<<<<<, =======, >>>>>>>.
  3. Исправьте файл, git add <файл>, затем git rebase --continue (после merge — git commit).
  4. Отменить — git rebase --abort или git merge --abort.

Частая ошибка

Rebase ветки, которую уже забрали коллеги, и git push --force: их история разойдётся с сервером.

Как устроено

  1. Fast-forward. Если в целевой ветке нет новых коммитов, merge просто сдвигает её указатель вперёд. Нового коммита нет.
  2. Трёхстороннее слияние. Иначе Git сравнивает общего предка и две вершины и создаёт коммит слияния. --no-ff создаёт его даже там, где возможен fast-forward.
  3. Rebase. Git находит общего предка, по одному применяет ваши коммиты поверх новой базы и переносит на результат указатель ветки.

Когда что выбирать

Ситуация Что использовать
Обновить свою ветку от main rebase
Почистить свои коммиты перед PR rebase -i
Ветка общая для нескольких людей merge
Влить PR в main метод, заданный в репозитории

Ловушки

  • Rebase опубликованного. Золотое правило из Pro Git: не переписывать коммиты, которые есть вне вашего репозитория и на которых другие могли основать работу.
  • Force push. --force перетирает чужие коммиты. --force-with-lease откажет, если ветка на сервере изменилась с вашего последнего fetch. После fetch без интеграции защита пропадает — для этого есть --force-if-includes (Git 2.30+).
  • Squash merge. После него коммиты ветки не попадают в main. Продолжать ту же ветку нельзя — её удаляют и начинают новую.

Правило

Свою ветку — rebase и --force-with-lease, общую — только merge.

Под капотом

  • Rebase — серия cherry-pick: каждый коммит применяется заново как отдельный патч. Поэтому конфликт может всплыть на каждом коммите, а не один раз.
  • Старые коммиты не удаляются сразу: на них указывают ORIG_HEAD и reflog, пока их не уберёт сборка мусора.
  • С Git 2.34 стратегия слияния по умолчанию — ort, заметно более быстрая замена recursive.

Компромиссы

Метод слияния PR Плюс Минус
Merge commit полная история, слияние откатывается одним revert -m 1 граф с параллельными ветками
Squash один коммит на PR, читаемый main теряются промежуточные коммиты и их авторы
Rebase merge линейная история с отдельными коммитами новые хеши, подписи коммитов теряются

Чистка своих коммитов

  • git commit --fixup <hash> и git rebase -i --autosquash main вклеивают исправление в нужный коммит.
  • git config rerere.enabled true запоминает разрешённые конфликты и применяет их повторно при следующем rebase.

Как проверить в проде

  • git log --first-parent main показывает историю main по одному коммиту на PR, если сливали через merge commit.
  • git range-diff сравнивает ветку до и после rebase и показывает, что изменилось при разрешении конфликтов.

Итог: merge хранит правду о том, как шла работа, rebase — удобный рассказ о ней; переписывают только то, что ещё никто не забрал.

Пример
Окно терминала
git switch feature/discount
git fetch origin
git rebase origin/main # переносим свои коммиты поверх свежего main
# CONFLICT (content): Merge conflict in price.txt
git status --short # UU price.txt — файл с конфликтом
# правим price.txt и убираем маркеры <<<<<<< ======= >>>>>>>
git add price.txt
git rebase --continue # применяем оставшиеся коммиты
git push --force-with-lease # откажет, если ветку на сервере кто-то изменил
История: до, после merge и после rebase
# До: ветки разошлись
$ git log --oneline --graph --all
* d331889 Add tests
* 19aa729 Apply discount
| * 2767ee0 Update notes
|/
* 2ccb685 Add price
# Вариант 1: git merge feature/discount в main
$ git log --oneline --graph
* 6371798 Merge branch 'feature/discount'
|\
| * d331889 Add tests
| * 19aa729 Apply discount
* | 2767ee0 Update notes
|/
* 2ccb685 Add price
# Вариант 2: git rebase main в ветке, затем git merge feature/discount в main
$ git log --oneline --graph
* 37980f0 Add tests
* e677de9 Apply discount
* 2767ee0 Update notes
* 2ccb685 Add price

После rebase у коммитов ветки новые хеши (19aa729 → e677de9), а слияние в main прошло fast-forward.

Middle+

Какие бывают модели ветвления и как выбрать?

Заголовок раздела «Какие бывают модели ветвления и как выбрать?»
Ответ

Что это

Модель ветвления отвечает на вопросы: от какой ветки создавать свою, куда её вливать и что выкладывается в прод.

Как пользоваться: GitHub Flow

  1. Создайте ветку от свежего main: git switch -c feature/discount origin/main.
  2. Делайте небольшие коммиты и отправляйте ветку на сервер.
  3. Откройте pull request. CI проверяет сборку и тесты, коллеги делают ревью.
  4. После одобрения PR вливают в main, а ветку удаляют.

Частая ошибка

Держать ветку неделями. PR вырастает до сотен файлов, ревью превращается в формальность, а при слиянии копятся конфликты.

Как устроено

Модель Ветки Кому подходит
Git Flow main, develop, feature/*, release/*, hotfix/* продукт с версиями, редкие релизы
GitHub Flow main и короткие ветки задач веб-сервис с частыми выкладками
Trunk-based trunk (main), ветки на день-два, релизные по необходимости частая интеграция и CI/CD

Когда что выбирать

  • Выкладываете несколько раз в неделю и поддерживаете одну версию — GitHub Flow или trunk-based.
  • Поставляете коробочные версии и чините старые — релизные ветки: trunk-based с ними или Git Flow.
  • Незаконченную функцию не держат в ветке: её вливают выключенной за feature flag.

Ловушки

  • Hotfix в Git Flow. Исправление вливают и в main, и в develop. Забыли второе — ошибка вернётся в следующем релизе.
  • Флаги навсегда. Каждый флаг — две ветви кода и два набора тестов. После включения для всех флаг удаляют.
  • Защита без проверок. Правило «только через PR» без обязательных проверок и ревью ничего не защищает. Какие проверки делать обязательными — в теме CI/CD и качество кода.

Правило

Чем чаще вы выкладываете, тем короче должны жить ветки.

Цена долгих веток

  • Конфликты растут вместе с расхождением веток, а интеграция откладывается до слияния. Ошибки интеграции находят в конце, когда их дороже всего чинить.
  • Большой PR ревьюят поверхностно: внимание рецензента ограничено, а не количество строк.

Релизные ветки

  1. Ветку release/2.4 отрезают от main, когда версия готова, и помечают тегом.
  2. Новые функции в неё не попадают — только исправления.
  3. Исправление делают в main и переносят через cherry-pick -x: в сообщении остаётся ссылка на исходный коммит.

Обратный порядок — сначала в релиз — рискует потерять исправление в main, и ошибка вернётся в следующей версии.

Компромиссы

  • Feature flags отделяют выкладку от релиза, но добавляют ветвления в коде и комбинации для тестов.
  • Крупный рефакторинг без долгой ветки делают через branch by abstraction: новая реализация растёт рядом со старой за общей абстракцией.
  • Когда в main вливают десятки PR в день, проверки на устаревшей базе пропускают поломки. Merge queue в GitHub проверяет каждый PR поверх уже стоящих в очереди.

Как проверить в проде

Метрики модели: время жизни ветки, размер PR, время от коммита до прода (lead time for changes из DORA). Растут — модель не работает, как задумано.

Итог: модель ветвления — инструмент для ритма поставки; долгие ветки откладывают интеграцию, флаги и короткие PR приближают её.

Пример
Окно терминала
# Trunk-based: короткая ветка от свежего main, в main — только через PR
git switch -c fix/rounding origin/main
git commit -am "Fix rounding"
git push -u origin fix/rounding
# Релизная ветка отрезается от main и помечается тегом
git switch -c release/2.4 main
git tag -a v2.4.0 -m "Release 2.4.0"
# Исправление сначала попало в main (коммит f2096a1), переносим его в релиз
git switch release/2.4
git cherry-pick -x f2096a1 # -x добавит "(cherry picked from commit ...)"
git tag -a v2.4.1 -m "Hotfix 2.4.1"
git push origin release/2.4 v2.4.1
Граф после переноса исправления
$ git log --oneline --graph --all --decorate
* f2096a1 (main) Fix rounding
* b182e6f Feature B (behind flag)
| * bf02e76 (HEAD -> release/2.4, tag: v2.4.1) Fix rounding
|/
* 7f43956 (tag: v2.4.0) Feature A
Senior+

Как Git хранит данные и как спасать ситуации?

Заголовок раздела «Как Git хранит данные и как спасать ситуации?»
Ответ

Что это

Коммит не исчезает, когда вы откатили ветку или удалили её. Git помнит каждое перемещение HEAD и веток в reflog.

Как пользоваться

Ситуация Команда
Отменить уже отправленный коммит git revert <hash>
Убрать последний локальный коммит, сохранив правки git reset --soft HEAD~1
Вернуть коммиты после reset --hard git reflog, затем git reset --hard HEAD@{1}
Отложить незаконченную работу git stash, потом git stash pop
Перенести один коммит в другую ветку git cherry-pick <hash>

Частая ошибка

git reset --hard при незакоммиченных правках. Reflog хранит только коммиты, поэтому несохранённые изменения пропадут навсегда.

Как устроено

  • Ветка — файл в .git/refs/heads/ с хешем коммита. HEAD обычно ссылается на ветку: ref: refs/heads/main.
  • Если HEAD указывает прямо на коммит, это detached HEAD. Новые коммиты в этом состоянии потеряются, если не создать ветку.

reset против revert

Команда Что меняет
reset --soft <коммит> только ветку; изменения остаются в индексе
reset --mixed <коммит> ветку и индекс; правки остаются в файлах
reset --hard <коммит> ветку, индекс и рабочую копию
revert <коммит> ничего не переписывает, добавляет обратный коммит

Другие инструменты

  • Случайно удалённая ветка. git branch -D печатает хеш: (was f45de9d). Его же видно в git reflog. Восстановление: git branch <имя> <hash>.
  • Stash. git stash -u откладывает и неотслеживаемые файлы.
  • Bisect. git bisect start <плохой> <хороший>, затем good или bad на каждом шаге; git bisect run <команда> проверяет автоматически.

Ловушки

  • revert merge-коммита требует -m 1. Если ту же ветку потом влить снова, откатанные изменения не вернутся — сначала откатывают сам revert.
  • Сброшенный stash в reflog не попадает. Его ищут через git fsck --unreachable.

Правило

Общая история — revert, своя локальная — reset.

Под капотом

  • Объект адресуется хешем своего содержимого (SHA-1; поддерживается и SHA-256). Изменить коммит нельзя — только создать новый с другим хешем.
  • Новые объекты лежат отдельными сжатыми файлами, git gc упаковывает их в pack-файлы с дельта-сжатием.
  • Reflog локален и истекает: по умолчанию записи живут 90 дней, недостижимые — 30. После этого git gc удаляет коммиты без ссылок.

Секрет попал в историю

  1. Отозвать и сменить секрет. Это главный шаг: копии истории уже есть у всех, кто сделал fetch.
  2. Переписать историю: git filter-repo --sensitive-data-removal с --invert-paths --path <файл> или --replace-text.
  3. Отправить результат: git push --force --mirror origin.
  4. На GitHub ссылки PR так не перезаписать — их и кеши удаляет поддержка.
  5. Коллеги переклонируют репозиторий. Повтор предотвращают .gitignore, хранилище секретов и push protection.

Большие файлы

Git LFS хранит в репозитории текстовый указатель, а содержимое — на отдельном сервере. git lfs track пишет правило в .gitattributes, git lfs migrate import переносит уже закоммиченные файлы, переписывая историю. GitHub блокирует файлы больше 100 MiB.

Компромиссы

Переписывание истории меняет все хеши после первого изменённого коммита: открытые PR и чужие клоны ломаются, поэтому его делают один раз и по договорённости.

Итог: коммиты неизменяемы и долго восстановимы локально, а утёкший секрет спасает не чистка истории, а его смена.

Пример
Окно терминала
git reset --hard HEAD~3 # «потеряли» три коммита
git reflog -2
# 7bf4219 HEAD@{0}: reset: moving to HEAD~3
# e87e8fa HEAD@{1}: commit: Step 8
git reset --hard HEAD@{1} # ветка снова на e87e8fa
git branch -D feature/x # Deleted branch feature/x (was f45de9d).
git branch feature/x f45de9d # ветка восстановлена
# Ищем коммит, который сломал тест: v2.3.0 — хорошая версия, HEAD — плохая
git bisect start HEAD v2.3.0
git bisect run dotnet test --filter "FullyQualifiedName~DiscountTests"
# f45de9dde16e396ecacc40236ccfa2d41ffdb7e6 is the first bad commit
git bisect reset # вернуться туда, где были до поиска
Секрет в истории
Окно терминала
# 0. Секрет уже отозван и заменён — без этого чистка бесполезна
git clone --mirror https://github.com/acme/shop.git
cd shop.git
# В passwords.txt строки вида: P@ssw0rd==>***REMOVED***
git filter-repo --sensitive-data-removal --replace-text ../passwords.txt
git log -p --all | grep -c "P@ssw0rd" # 0 — в истории секрета больше нет
git push --force --mirror origin