Дарья Меленцова

Разработчица в команде инфраструктуры Яндекса, действующий автор курса «DevOps для эксплуатации и разработки».

Прошлая статья «Работаем с Git: первые шаги в GitHub» была посвящена установке, настройке Git и классическим операциям из набора для новичков GitHub. А теперь перейдём к практике и рассмотрим «горячие» сценарии, которые делают трудовые будни куда веселее. Или не очень.

Что будет в этой статье

  • поговорим о моделях ветвления, подходах к созданию веток и работе с ними;

  • помёржим две ветки разными способами (rebase и merge);

  • столкнёмся с конфликтами при мёрже и решим их;

  • научимся забирать определённый коммит из другой ветки (cherry-pick);

  • поговорим, как схлопывать коммиты, чтобы история коммитов выглядела более красиво (squash);

  • разберёмся, как откатывать изменения в случае необходимости (reset, revert).

Начнём разминку с моделей ветвления — чётких сценариев, по которым должны работать команды. Как и техника безопасности, они прописаны болью IT-специалистов, поэтому стоит внимательно разобрать этот вопрос.

Модели ветвления

В реальной жизни Git должен хранить код с историей изменений, а также позволять любому количеству пользователей работать с этим кодом: изменять, клонировать и препарировать другими способами.

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

Эти соглашения получили название «Модели ветвления». Рассмотрим тройку самых распространённых: Trunk-Based Development, Feature/Issue Branch Workflow и Gitflow.

На самом деле моделей намного больше, и вы можете настроить работу с Git под свой проект.

Trunk-Based Development

Trunk-Based Development

Trunk-Based Development — довольно удобная модель: разработчики трудятся над одной веткой (trunk, main или master) и не создают отдельные, а клонируют мастер-ветку к себе на компьютер, вносят изменения и потом вливают («мёржат») их обратно в master.

Обычно в течение одного рабочего дня происходит много мёржей в master — это и плюс, и минус одновременно. Кстати, о плюсах и минусах.

За что любят Trunk-Based Development:

  • Быстрая обратная связь от коллег. Например, вы запушили в master, а потом пришёл тестировщик и сказал, что всё сломалось. Изменения были недавно, поэтому легко понять причину «выключения» master и откатиться.

  • «Своя ветка». Если все разработчики работают над одной веткой, в идеале они относятся к ней как к своему общему коду и следят, чтобы она была в рабочем состоянии перед мёржем.

  • Разбивка на модули. Пуш всегда происходит в master, и большие задачи разбиваются на много задач. Такое деление кода на модули делает работу более комфортной.

  • Нет сложных мёржей. Бывают ситуации, когда вы работаете над веткой две недели, пытаетесь мёржить в master и получаете миллион конфликтов. В случае с Trunk-Based Development если и будут конфликты, то небольшие и легко решаемые.

За что ругают Trunk-Based Development:

  • Раз — и готово. Можно всё сломать одним коммитом.

  • Сложный revert. В день делают много коммитов, и если проблему заметили только на двадцатом, от HEAD будет не самый простой revert.

  • Регулярный пулл. В процессе работы нужно постоянно подтягивать к себе изменения из master, чтобы избежать конфликтов мёржа.

Feature/Issue Branch Workflow

Feature/Issue Branch Workflow

Feature/Issue Branch Workflow — логичное развитие модели Trunk-Based Development.

Обычно под каждую фичу или задачу создаётся своя ветка, в которой ведётся разработка, и по окончании ветка вливается в master.

За что любят Feature/Issue Branch Workflow:

  • Независимость. Работаете в своей ветке и ни от кого не ждёте сюрпризов, ничего не надо пуллить из master.

  • Всегда рабочий master. Изменения, которые пуллятся в master, обычно проходят тесты, и мастер всегда горит «зелёным».

За что ругают Feature/Issue Branch Workflow:

  • Впереди паровоза. Если коллега решил вмёржить что-то в master до того, как делаете мёрж вы, нужно забрать себе master и решить все возникающие конфликты, проблемы со сломанными тестами и только потом вливать ветку в master.

  • Переполнение. Количество веток может быстро расти, что требует большего код-ревью и тестирования для стабильности и целостности мастер-ветки.

Gitflow

Gitflow

Gitflow — чуть ли не самая популярная модель ветвления.

Основная идея Gitflow в том, что в проекте используют две основные ветки: master и develop. В ветке master хранится стабильная версия программного продукта, которая готова для выпуска в production (прод). В ветке develop хранится актуальная версия кода с последними изменениями, которые ещё не были выпущены в прод.

Как работает Gitflow: в начале спринта создают ветку develop, и все трудятся в ней. От ветки develop разработчики отводят другие ветки под конкретные задачи — по сути Feature/Issue Branch Workflow, только от develop, а не master. Разработчики кодят в своих ветках, а по завершении работы вливают изменения в develop. В конце спринта от ветки develop создаётся release-ветка, на которой прогоняются уже более серьёзные тесты, приближенные к продовой среде.

Hotfix-ветки вливаются в release, при этом новые feature-ветки в релиз уже не попадают.

Когда всё починили, влили фичи и готовы выпускать релиз, ветка release вливается в master и обратно в develop, чтобы дальнейшее исправление багов и работа велись от актуальной версии кода.

За что любят Gitflow:

  • Большие команды. Модель хорошо подходит для работы больших, распределённых команд.

  • «Выбор джуна». Gitflow эффективна при работе с junior-разработчиками, которым свойственно большое количество итераций до отправки кода в релиз.

За что ругают Gitflow:

  • Скорость. Модель медленная, поэтому получение MVP, коммуникация сотрудников и организация процессов будут происходить неэффективно.


Теперь вы знаете, что грамотные команды работают в GitHub по правилам, а не желанию «сделать мёрж в мастер перед выходными». Самое время посмотреть, как модели ведут себя в реальных условиях и помогают (или нет) разбираться с проблемами разработки.


Merge и rebase

Представим, что мы отвели свою ветку от main. Вносим в неё изменения, коммитим и уже хотим сделать мёрж в main, как… другие разработчики вносят свои изменения раньше нас.

Схематичное изображение коллапса

Забрать последнее main-состояние в свою ветку можно с помощью команды git merge или git rebase:

  • git merge — помёржит изменения из другой ветки, создав отдельный merge-коммит.

  • git rebase — заново наложит наши коммиты поверх той ветки, которую подтягиваем в свою.

Разберём различие между merge и rebase на примере.

У нас есть ветки main и merge и вот такой лог.

$ git log --all --graph --oneline:

Видно, что ветка merge отведена от main, дальше разработка разделилась, появились новые коммиты как в main, так и в merge.

Мы хотим забрать изменения из main в ветку merge.

Merge

Переключаемся на ветку merge:

$ git checkout merge

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

$ git fetch origin main                                                                            ✔  4 ⚙  6421  11:08:28
From github.com:ifireice/git
 * branch            main       -> FETCH_HEAD

Мёржим изменения из main в текущую ветку.

$ git merge main
Merge branch 'main' into merge
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.

Merge made by the 'recursive' strategy.
 README.md | 9 ---------
 1 file changed, 9 deletions(-)

Смотрим лог $ git log --all --graph --oneline:

Видим по истории, что две ветки смёржены в merge через дополнительный merge-коммит, который их объединил.

Rebase

Подтягиваем актуальное состояние main:

$ git fetch origin main                                                                            ✔  4 ⚙  6421  11:08:28
From github.com:ifireice/git
 * branch            main       -> FETCH_HEAD

Делаем rebase:

$ git rebase main
Successfully rebased and updated refs/heads/merge.

Смотрим лог $ git log --all --graph --oneline:

Видим, что наши изменения были применены поверх ветки main заново — будто мы отвели ветку merge от main только что.

Отдельного merge-коммита нет.

Стоит помнить, что git rebase переписывает историю коммитов и придётся делать git push force. Поэтому не нужно использовать rebase на ветках, с которыми работают несколько разработчиков.

Подробнее про то, чем может быть опасен rebase, расскажем ниже.

Разрешение конфликтов

При мёрже может быть две ситуации.

Если наши изменения касаются разных частей проекта, то ничего страшного.

Но может быть так, что мы и другие разработчики затрагивали одну и ту же часть проекта и наши изменения будут конфликтовать. В таком случае GitHub сообщит о конфликте:

Негоже делать мёрж, пока бодрствуют конфликты 

Рассмотрим более подробно, что такое конфликт.

Классический пример конфликта

  1. Разработчик A, выполняя задание из первой части статьи, поправил опечатку:

  1. В это же самое время разработчик B в отдельной ветке внёс другое изменение и удалил строку с опечаткой:

При попытке влить эти изменения в main возникнет конфликт, который Git не может разрешить сам, — непонятно, что должно быть в результате. Этим должен заняться человек.

С помощью команды $ git log --all --graph мы можем посмотреть дерево коммитов:

Ищем свои коммиты

Чтобы поправить этот конфликт, нам нужно помёржить ветку main в нашу ветку feature-b, вручную поправить конфликт и обновить пулл-реквест.

Итак, мы склонировали локально репозиторий, и наша активная ветка — feature-b.

Выполним $ git checkout feature-b.

Если вы задаётесь вопросами, сделайте паузу и прочтите первую часть статьи. Иначе вопросов станет только больше, а ответов — нет.

Мёржим изменения из ветки main с помощью $ git merge main:

$ git merge main
Auto-merging README.md
CONFLICT (content): Merge conflict in README.md
Automatic merge failed; fix conflicts and then commit the result.

Git говорит, что просто так помёржить не получится и нужно вручную разрешить конфликт. Посмотрим, в чём у нас проблема:

$ git status
On branch feature-b
Your branch is up to date with 'origin/feature-b'.`

You have unmerged paths.
(fix conflicts and run "git commit")
(use "git merge --abort" to abort the merge)`

Unmerged paths:
(use "git add <file>..." to mark resolution)
both modified:   [README.md](http://readme.md/)`

no changes added to commit (use "git add" and/or "git commit -a")`

Видно, что в обеих ветках есть изменение в файле README.md. Откроем его любым текстовым редактором и найдём специальную метку о конфликте:

Так конфликт выглядит в редакторе VS Code со специальной подсветкой, но можно пользоваться любым редактором.

Сейчас мы должны привести файл к итоговой версии и сохранить. Пусть у нас итоговый текст выглядит так:

Добавим файл в индекс и закоммитим изменения:

Git автоматически предложит сообщение о коммите, его можно не менять. Сохраняем и выходим:

$ git commit
[feature-b 151a787] Merge branch 'main' into feature-b

Посмотрим на результат с помощью команды $ git log --all --graph:

Обратите внимание, что появился специальный merge-коммит, который сливает две ветки. В нём как раз и содержится разрешение конфликта, которое мы сделали вручную.

Теперь осталось обновить наш pull-реквест. Для этого надо отправить на сервер изменения нашей ветки. Выполним: $ git push.

И… конфликтов больше нет, можем помёржить pull-реквест.

Squash — уборка коммитов

Squash коммитов помогает упростить и почистить историю изменений ветки и уменьшить общее количество коммитов в репозитории. Это делает историю изменений более читабельной и помогает лучше понять, какие изменения были внесены в проект на определённом этапе разработки.

Представим, что мы работали над одной задачей несколько дней и каждый день коммитили небольшой кусочек изменений. В итоге видим в истории несколько коммитов для одной задачи:

Хотим объединить коммиты 1, 2 и 3 в один-единственный.

Для этого выполним $ git rebase -i HEAD~3:

  • git rebase -i — это интерактивная команда Git, которая позволяет изменять порядок и применять изменения коммитов ветки в интерактивном режиме.

Помните, что использование команды git rebase -i может привести к потере данных. Нужно точно понимать, что вы хотите сделать. Поэтому всегда создавайте резервную копию текущей ветки перед использованием этой команды.

  • HEAD — ссылка на последний коммит.

  • ~n — от последнего коммита взять n коммитов.

  • HEAD~3 — потому что работать будем с тремя последними коммитами.

Открывается редактор. Видим, что захватили три последних коммита, которые и склеим:

git rebase -i — мощный инструмент (посмотрите, сколько опций), но сейчас нас интересует только squash.

Склеиваем коммиты:

Отменяем два последних коммита при помощи squash и s.

Оба коммита будут склеены с тем, который помечен pick.

Сохраняем изменения и выходим.

git rebase -i откроет следующее окно:

Можно отредактировать сообщение, сохранить изменения и выйти из редактора.

Смотрим на $ git log --graph:

Теперь у нас вместо трёх коммитов один. Как мы и хотели.

В двух словах разберём, как действовал squash:

  1. Выполнили команду $ git rebase -i <BASE>, где <BASE> — это точка начала ветки, с которой начинаем переписывать историю коммитов.

  2. Открылся текстовый файл, содержащий список всех коммитов в ветке.

  3. Поработали с коммитами: изменили их порядок, объединили несколько коммитов в один или удалили ненужные коммиты.

  4. После необходимых изменений сохранили файл и закрыли его.

  5. Git выполнил перезапись истории коммитов в соответствии с изменениями.

Ещё одно применение squash — схлопывание коммитов при мёрже

squash полезен, когда вы мёржите ветку в main. В отдельной ветке вы можете вести разработку как угодно, но для сохранения более понятной и чистой истории основной ветки при мёрже ветки можно схлопнуть все коммиты в один.

Как обычно, представим, что разработку вели в ветке feature-c и сделали два коммита:

Теперь хотим мёржить ветку feature-c в main. Для этого переключимся на ветку main и выполним $ git merge --squash target_branch_name:

$ git merge --squash origin/feature-c 
Updating bb0b109..e7989c7
Fast-forward
Squash commit -- not updating HEAD
 README.md | 7 +++++++
 1 file changed, 7 insertions(+)

Сохраним изменения с помощью $ git commit:

И смотрим историю $ git log --graph:

Видим, что помёржили нашу ветку feature-c с main благодаря --squash.

Сжатие коммитов в GitHub

GitHub позволяет вам сжимать коммиты при мёрже пулл-реквеста (если вдруг забыли сделать это перед созданием PR).

Здесь достаточно посмотреть на скриншот:

Сначала выполнится squash, а потом merge.

И в истории коммитов вместо двух будет всего один.

Cherry-pick — выборочный мёрж

git cherry-pick — команда Git, которая переносит коммит(ы) из одной ветки в другую.

Она берёт изменения, которые были сделаны в указанном коммите, и накладывает на текущую ветку.

Предположим, в нашей команде процесс разработки построен так: существует ветка stable со стабильной версией продукта, которая сейчас работает в проде, и ветка main, где находится самая свежая версия продукта.

Внезапно в продакшен-версии продукта нашли баг. Разработчик исправил баг в main, но баг оказался настолько критичным, что и на проде его тоже нужно чинить.

Если мы просто выкатим ветку main в прод, туда попадёт ещё и много новых изменений, которые сделали другие разработчики в main.

 К слову, срочно и в спешке выкатывать новую версию продукта — не самая лучшая идея (можно выкатить ещё несколько новых багов).

Поэтому нужно взять коммит с исправлением и помёржить в ветку stable только его (и потом уже выкатить ветку stable в продакшен).

Посмотрим на $ git log --all --graph:

Если мы просто помёржим main в stable, то и коммит с фичей, и коммит с лекарством попадут в прод. А нам нужен только коммит с лекарством.

Чтобы сделать «мёрж по выбору», у Git есть команда cherry-pick (можно по-русски, вас сразу поймут).

  1. Склонируем репозиторий.

  2. В $ git log найдём идентификатор коммита, который надо помёржить. В нашем случае это 4215d16f17f52e5279f84df6b89dd3d7b423cac4.

  3. Переключимся в ветку stable: $ git checkout stable.

  4. Черри-пикнем наш коммит:

$ git cherry-pick 4215d16f17f52e5279f84df6b89dd3d7b423cac4
Auto-merging README.md
[stable 2e07d5a] fix bug
 Date: Wed Mar 29 09:33:51 2023 +0300
 1 file changed, 2 deletions(-)

Что именно сделает Git: возьмёт изменения, которые были сделаны в коммите 4215d16f17f52e5279f84df6b89dd3d7b423cac4, и наложит их на самый верхний коммит в ветке stable. Поэтому если мы посмотрим $ git log --all --graph, то наш коммит будет выглядеть как новый, независимый коммит (а не как коммит-мёрж).

Чтобы изменения оказались на сервере, не забудьте сделать $ git push

Revert и reset

Предположим, что в основную ветку попали изменения, которые принесли баг, и нужно откатить изменения с багом.

git revert и git reset — это две команды для отмены изменений в Git.

Однако их действия и последствия различаются.

Если кратко, то различие между git revert и git reset в том, что git reset переносит вас на определённую точку в истории коммитов, a git revert создаёт новый коммит с отменой изменений.

Revert

$ git revert используется для добавления нового коммита, который отменяет изменения, сделанные в другом коммите. В отличие от git reset, не изменяет историю коммитов.

Прописываем git log --oneline:

Сделаем revert двух коммитов — 4215d16 и 3ce8c50:

$ git revert 4215d16 3ce8c50.

Git попросит ввести коммит-месседжи для каждого коммита:

$ git revert 4215d16 3ce8c50
[revert 98a0bfc] Revert "fix bug"
 1 file changed, 2 insertions(+)
[revert 7b330be] Revert "update ReadMe.md"
 1 file changed, 1 deletion(-)

Смотрим $ git log --oneline:

Видим, что у нас появилось два новых коммита, которые откатывают изменения заданных коммитов.

Чтобы не засорять историю и при необходимости быстро посмотреть, что изменилось, revert можно сделать в один коммит с помощью ключа -n. Нужно не забыть потом закоммитить изменения:

$ git revert -n 4215d16 3ce8c50

$ git commit
Revert "update ReadMe.md"

This reverts commit 3ce8c505f9651d548454c8856fdfee86e92a123f.

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
# On branch revert
# You are currently reverting commit 3ce8c50.
#
# Changes to be committed:
#       modified:   README.md
#

$ git log --oneline
8b6752c (HEAD -> revert) Revert "update ReadMe.md"
4215d16 (main) fix bug
3ce8c50 update ReadMe.md
80dcb32 Merge pull request #13 from ifireice/feature-b
338d0e9 (origin/feature-b) Merge branch 'main' into feature-b
1e2c3a0 fix typo
2db25fc drop line
9e8b1e5 Merge pull request #1 from ifireiceya/fix-misprint
d2fa945 Поправили опечатку
0f75a77 Init
cdb80a7 Initial commit

При revert также работает запись HEAD~<число коммитов>.

Если нужно удалить несколько коммитов, то ещё можно использовать вот такую запись:

$ git revert -n HEAD~5..HEAD~2 (первый коммит..последний коммит).

Reset

$ git reset используется для отмены изменений, применённых в коммите, и возвращения к предыдущему состоянию. По сути, перемещает HEAD на заданный коммит.

У команды есть различные опции, которые влияют на её поведение:

  • --soft — изменения не удаляются, а только помещаются в рабочий каталог. C помощью этой опции вы можете отменить коммит и оставить изменения в рабочем каталоге.

  • --mixed, в отличие от --soft, удаляет коммит и возвращает изменения в индекс. То есть нужно будет выполнить команду git add перед следующим коммитом. Используется по умолчанию, если не передать опцию revert.

  • --hard — крайний вариант. Он удаляет не только коммит, но и все изменения, внесённые в историю коммитов до него. Осторожно, восстановить данные после применения этой команды нельзя.

Что может быть не так с reset

Отведём отдельную ветку reset и поработаем в ней с помощью $ git checkout -b reset. Внесём какие-то изменения и запушим. Получим вот такой $ git log --oneline:

Мы хотим отменить два последних коммита: c314848 и 3391dc8.

Для этого выполняем $ git reset dddcea7.

Смотрим ещё раз $ git log --oneline:

Коммиты пропали, вроде всё ок.

Попробуем запушить изменения.

$ git push     
To github.com:ifireice/git.git
 ! [rejected]        reset -> reset (non-fast-forward)
error: failed to push some refs to 'github.com:ifireice/git.git'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. Integrate the remote changes (e.g.
hint: 'git pull ...') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.

Git просто так не даёт запушить и намекает на проблемы. Что может быть не так?

Допустим, наш коллега работает с той же веткой, что и мы.

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

Коллега внёс в неё изменения и пушит их:

$ git commit -am "added changes from a colleague"
[main 50bd1e1] added changes from a colleague
 1 file changed, 1 insertion(+)

$ git push                       
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Delta compression using up to 12 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 365 bytes | 365.00 KiB/s, done.
Total 3 (delta 1), reused 0 (delta 0), pack-reused 0
remote: Resolving deltas: 100% (1/1), completed with 1 local object.
To github.com:ifireice/git.git
   dddcea7..50bd1e1  main -> main

Посмотрим, что у нас в истории на удалённом сервере:

Видим в истории все три коммита (и наши, и коллеги).

Теперь мы пушим наши изменения с force:

$ git push -f 
Total 0 (delta 0), reused 0 (delta 0), pack-reused 0
To github.com:ifireice/git.git
 + f27c52e...dddcea7 reset -> reset (forced update)

Смотрим историю:

Пропали все ненужные нам коммиты + пропал коммит коллеги.

А у коллеги — всё хорошо:

$ git status                     
On branch reset
Your branch is up to date with 'origin/reset'.

nothing to commit, working tree clean

Он делает ещё один коммит и пушит изменения без force, так как для Git всё в порядке:

$ git commit -am "delete typo on ReadMe"
[reset 455e520] delete typo on ReadMe
 1 file changed, 3 deletions(-)

$ git push 
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Delta compression using up to 12 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 314 bytes | 314.00 KiB/s, done.
Total 3 (delta 1), reused 1 (delta 0), pack-reused 0
remote: Resolving deltas: 100% (1/1), completed with 1 local object.
To github.com:ifireice/git.git
   f27c52e..455e520  reset -> reset

Смотрим историю:

Те коммиты, которые мы откатывали, вернулись + появились коммиты коллеги.

Как от такого защититься? В GitHub есть возможность установить «правила защиты» на ветку. Заходим в репозиторий → Settings → Branches → Add branch protection rules:

Вводим имя ветки reset (но чаще так «защищают» основную ветку) в поле Branch name pattern, пролистываем вниз и выбираем настройку Allow force pushes. Указываем пользователей, которым разрешено делать force push (если не выберете пользователя, после сохранения проставится значение в Everyone):

Создаём правило и проверяем, что теперь пушить с force нельзя:

$ git push -f                   
Total 0 (delta 0), reused 0 (delta 0), pack-reused 0
remote: error: GH006: Protected branch update failed for refs/heads/reset.
remote: error: Cannot force-push to this branch
To github.com:ifireice/git.git
 ! [remote rejected] reset -> reset (protected branch hook declined)
error: failed to push some refs to 'github.com:ifireice/git.git

На этом будний день с Git подошёл к концу. Надеемся, он был продуктивным для вас!

Итоги

Спасибо всем, кто дочитал статью и проникся регулярной работой, которую проводят специалисты с Git. Вот краткий список достижений:

  • познакомились с самыми популярными моделями ветвления;

  • научились мёржить две ветки с помощью rebase и merge;

  • разобрали и решили конфликты при мёрже;

  • освоили «черри-пикинг» — забрали коммит из другой ветки;

  • благодаря squash попробовали схлопнуть коммиты, чтобы история коммитов выглядела более красиво;

  • научились откатывать изменения в случае необходимости, используя reset и revert.