Работаем с Git: трудовые будни
Дарья Меленцова
Разработчица в команде инфраструктуры Яндекса, действующий автор курса «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
, 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 — логичное развитие модели Trunk-Based Development.
Обычно под каждую фичу или задачу создаётся своя ветка, в которой ведётся разработка, и по окончании ветка вливается в master
.
За что любят Feature/Issue Branch Workflow:
Независимость. Работаете в своей ветке и ни от кого не ждёте сюрпризов, ничего не надо пуллить из
master
.Всегда рабочий
master
. Изменения, которые пуллятся вmaster
, обычно проходят тесты, и мастер всегда горит «зелёным».
За что ругают Feature/Issue Branch Workflow:
Впереди паровоза. Если коллега решил вмёржить что-то в
master
до того, как делаете мёрж вы, нужно забрать себеmaster
и решить все возникающие конфликты, проблемы со сломанными тестами и только потом вливать ветку вmaster
.Переполнение. Количество веток может быстро расти, что требует большего код-ревью и тестирования для стабильности и целостности мастер-ветки.
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 сообщит о конфликте:
Рассмотрим более подробно, что такое конфликт.
Классический пример конфликта
Разработчик A, выполняя задание из первой части статьи, поправил опечатку:
В это же самое время разработчик 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
:
Выполнили команду
$ git rebase -i <BASE>
, где<BASE>
— это точка начала ветки, с которой начинаем переписывать историю коммитов.Открылся текстовый файл, содержащий список всех коммитов в ветке.
Поработали с коммитами: изменили их порядок, объединили несколько коммитов в один или удалили ненужные коммиты.
После необходимых изменений сохранили файл и закрыли его.
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 (можно по-русски, вас сразу поймут).
Склонируем репозиторий.
В
$ git log
найдём идентификатор коммита, который надо помёржить. В нашем случае это4215d16f17f52e5279f84df6b89dd3d7b423cac4
.Переключимся в ветку
stable
:$ git checkout stable
.Черри-пикнем наш коммит:
$ 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. Вот краткий список достижений:
познакомились с самыми популярными моделями ветвления;
разобрали и решили конфликты при мёрже;
освоили «черри-пикинг» — забрали коммит из другой ветки;
благодаря squash попробовали схлопнуть коммиты, чтобы история коммитов выглядела более красиво;
научились откатывать изменения в случае необходимости, используя reset и revert.