company_banner

Руководство по Git. Часть №2: золотое правило и другие основы rebase

Автор оригинала: Pierre de Wulf
  • Перевод
Посмотрим, что происходит, когда вы выполняете git rebase и почему нужно быть внимательным. 

Это вторая и третья части гайда по Git из блога Pierre de Wulf в переводе команды Mail.ru Cloud Solutions. Первую часть можно почитать тут.

Суть rebase


Как именно происходит rebase:


Можно сказать, что rebase — это открепить ветку (branch), которую вы хотите переместить, и подключить ее к другой ветке. Такое определение соответствует действительности, но попробуем заглянуть чуть глубже. Если вы посмотрите документацию, вот что там написано относительно rebase: «Применить коммиты к другой ветке (Reapply commits on top of another base tip)».

Главное слово здесь — применить, потому что rebase — это не просто копипаст ветки в другую ветку. Rebase последовательно берет все коммиты из выбранной ветки и заново применяет их к новой ветке.

Такое поведение приводит к двум моментам:

  1. Переприменяя коммиты, Git создает новые коммиты. Даже если они содержат те же изменения, то рассматриваются Git как новые и независимые коммиты.
  2. Git rebase переприменяет коммиты и не удаляет старые. Это значит, что после выполнения rebase ваши старые коммиты продолжат храниться в подпапке  /оbjects папки .git. Если вы не до конца понимаете, как Git хранит и учитывает коммиты, почитайте первую часть этой статьи.

Вот более правильная интерпретация того, что происходит при rebase:


Как видите, ветка feature содержит абсолютно новые коммиты. Как было сказано ранее, тот же самый набор изменений, но абсолютно новые объекты с точки зрения Git. 

Это также означает, что старые коммиты не уничтожаются. Они становятся просто недоступными напрямую. Если вы помните, ветка — всего лишь ссылка на коммит. Таким образом, если ни ветка, ни тег не ссылаются на коммит, к нему невозможно получить доступ средствами Git, хотя на диске он продолжает присутствовать.

Теперь давайте обсудим «Золотое правило».

Золотое правило rebase


Золотое правило rebase звучит так — «НИКОГДА не выполняйте rebase расшаренной ветки!». Под расшаренной веткой понимается ветка, которая существует в сетевом репозитории и с которой могут работать другие люди, кроме вас.

Часто это правило применяют без должного понимания, поэтому разберем, почему оно появилось, тем более что это поможет лучше понять работу Git.

Давайте рассмотрим ситуацию, когда разработчик нарушает золотое правило, и что происходит в этом случае.

Предположим, Боб и Анна вместе работают над проектом. Ниже представлено, как выглядят репозитории Боба и Анны и исходный репозиторий на GitHub:


У всех пользователей репозитории синхронизируются с GitHub.

Теперь Боб, нарушая золотое правило, выполняет rebase, и в это же время Анна, работая в ветке feature, создает новый коммит:


Вы видите, что произойдет?

Боб пытается выполнить пуш коммита, ему приходит отказ примерно такого содержания:


Выполнение Git не было успешным, потому что Git не знает, как объединить feature ветку Боба с feature веткой GitHub.

Единственным решением, позволяющим Бобу выполнить push, станет использование ключа force, который говорит GitHub-репозиторию удалить у себя ветку feature и принять за эту ветку ту, которая пушится Бобом. После этого мы получим следующую ситуацию:


Теперь Анна хочет запушить свои изменения, и вот что будет:


Это нормально, Git сказал Анне, что у нее нет синхронизированной версии ветки feature, то есть ее версия ветки и версия ветки в GitHub — разные. Анна должна выполнить pull. Точно таким же образом, как Git сливает локальную ветку с веткой в репозитории, когда вы выполняете push, Git пытается слить ветку в репозитории с локальной веткой, когда вы выполняете pull.

Перед выполнением pull коммиты в локальной и GitHub-ветках выглядят так:

A--B--C--D'   origin/feature // GitHub
A--B--D--E    feature        // Anna

Когда вы выполняете pull, Git выполняет слияние для устранения разности репозиториев. И вот, к чему это приводит:


Коммит M — это коммит слияния (merge commit). Наконец, ветки feature Анны и GitHub полностью объединены. Анна вздохнула с облегчением, все конфликты устранены, она может выполнить push. 

Боб выполняет pull, теперь все синхронизированы:


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

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

Также учтите, что появляются дубликаты коммитов в сетевом репозитории. В нашем случае — D и D’, содержащие одни и те же данные. По сути, количество дублированных коммитов может быть таким же большим, как и количество коммитов в вашей rebased ветке.

Если вы все еще не убеждены, давайте представим Эмму — третью разработчицу. Она работает в ветке feature перед тем, как Боб совершает свою ошибку, и в настоящий момент хочет выполнить push. Предположим, что к моменту ее push наш маленький предыдущий сценарий уже завершился. Вот что выйдет:


Ох уж этот Боб!!!!

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

Красота pull rebase


Как вы видели выше, проблем Анны можно было избежать, если бы она использовала pull rebase. Рассмотрим этот вопрос подробнее.

Допустим, Боб работает в ветке, отходящей от мастера, тогда его история может выглядеть вот так:




Боб решает, что настало время выполнить pull, что, как вы уже поняли, приведет к некоторым неясностям. Поскольку репозиторий Боба отходил от GitHub, Git спросит делать ли объединение, и результат будет таким:


Это решение подходит и работает нормально, однако, вам может быть полезно знать, что есть другие варианты решения проблемы. Одним из них является pull-rebase.

Когда вы делаете pull-rebase, Git пытается выяснить, какие коммиты есть только в вашей ветке, а какие — в сетевом репозитории. Затем Git объединяет коммиты из сетевого репозитория с самым свежим коммитом, присутствующим и в локальном, и в сетевом репозитории. После чего выполняет rebase ваших локальных коммитов в конец ветки. 

Звучит сложно, поэтому проиллюстрируем:

  1. Git обращает внимание только на коммиты, которые есть и в вашем, и в сетевом репозитории:

    Это выглядит как локальный клон репозитория GitHub.
  2. Git выполняет rebase локальных коммитов:


Как вы помните, при rebase Git применяет коммиты один за одним, то есть в данном случаем применяет в конец ветки master коммит E, потом F. Получился rebase сам в себя. Выглядит неплохо, но возникает вопрос — зачем так делать?

По моему мнению, самая большая проблема с объединением веток в том, что загрязняется история коммитов. Поэтому pull-rebase — более элегантное решение. Я бы даже пошел дальше и сказал, что когда нужно скачать последние изменения в вашу ветку, вы всегда должны использовать pull-rebase. Но нужно помнить: поскольку rebase применяет все коммиты по очереди, то когда вы делаете rebase 20 коммитов, вам, возможно, придется решать один за другим 20 конфликтов. 

Как правило, можно использовать следующий подход: одно большое изменение, сделанное давно — merge, два маленьких изменения, сделанных недавно — pull-rebase.

Сила rebase onto


Предположим, история ваших коммитов выглядит так:




Итак, вы хотите выполнить rebase ветки feature 2 в ветку master. Если вы выполните обычный rebase в ветку master, получите это:


Нелогично выглядит то, что коммит D существует в обоих ветках: в feature 1 и feature 2. Если вы переместите ветку feature 1 в конец ветки мастер, получится, что коммит D будет применен два раза.

Предположим, что вам нужно получить другой результат:


Для реализации подобного сценария как раз и предназначен git rebase onto.

Сначала прочтем документацию:

SYNOPSIS
       git rebase [-i | --interactive] [<options>] [--exec <cmd>]
               [--onto <newbase> | --keep-base] [<upstream> [<branch>]]
       git rebase [-i | --interactive] [<options>] [--exec <cmd>] 
[--onto <newbase>]
               --root [<branch>]
       git rebase (--continue | --skip | --abort | --quit | --edit-todo 
| --show-current-patch)


Нас интересует вот это:

OPTIONS
       --onto <newbase>
          Starting point at which to create the new commits. If the 
--onto option is not specified, the starting point is <upstream>. May be 
any valid commit, and not just an existing branch name.


С помощью этой опции указывается, в какой точке создавать новые коммиты.

Если эта опция не указана, то стартовой точкой станет upstream.

Для понимания приведу еще один рисунок:

A--B--C        master
    \
     D--E      feature1
         \
          F--G feature2

Here we want to rebase feature2 to master beginning from feature1
                           |                                |
                        newbase                         upstream


То есть ветка master — это newbase, а ветка feature 1 — upstream.

Таким образом, если вы хотите получить результат как на последнем рисунке, необходимо выполнить в ветке feature2 git rebase --onto master feature1.

Удачи!

Переведено при поддержке Mail.ru Cloud Solutions.

Что еще почитать по теме:

  1. Первая часть гайда про Git.
  2. Мой второй год в качестве независимого разработчика.
  3. Наш канал в Телеграме о цифровой трансформации

Mail.ru Group
Строим Интернет

Комментарии 26

    +8
    > Таким образом, если ни ветка, ни тег не ссылаются на коммит, к нему невозможно получить доступ средствами Git

    git checkout {commit}?
      +2
      Очень много обсуждения возможных проблем с rebase, и ни одного слова зачем он вообще может быть нужен. Я например не знаю. Считаю его злом и обхожусь без него. Потому что «нерасшареные ветки» как вы их называете – это зло.
        0
        ну это же не моя статья ).  Я в общем тоже rebase не использую. Но почитать как можно сделать мне показалось полезным. Тут каждый решает для себя.
          +7

          rebase, особенно интерактивный, — это способ переписать историю: перенести коммиты, убрать, добавить, переименовать. С историей можно сделать всё что угодно.


          Зачем переписывать историю — большой вопрос.


          Кто-то считает, что история коммитов должна оставаться неизменной, потому что она показывает как развивался проект.


          Кто-то (я, например) считает, что в публичный репозиторий должен попадать "причёсанный" код, где каждый коммит приносит какаю-то ценность проекту.


          Я делаю по несколько десятков коммитов ежедневно просто, чтобы не потерять прогресс. Большая часть из них — это черновая работа, проверка версий и гипотез. Я считаю, что подобные вещи не приносят ценности проекту, поэтому из множества черновых коммитов в итоге я рибейсом собираю 1-2 осмысленных чистовых. По ним можно адекватно проследить историю развития проекта, а самое главное — их можно легко откатить.

            0

            Публичную историю лучше держать неизменной (точнее, не так, НУЖНО держать неизменной. Ваш тезис про rebase в пошаренный код ).


            Личной историей можно управлять по своему разумению.
            Не все привыкли раскладывать код по полочкам, не всегда это возможно.
            Например, пишешь новую фичу, решил ради неё добавить функций в глобальную либу проекта. Сперва ради скорости вкоммитал в один коммит, а позже решил разделить изменения в либу от фичи.


            Или — в основной ветке решили переименовать глобальную сущность из foo в bar.
            Может в отдельном коммите, может в "комбайне" других изменений (вы на это не влияете), но суть в том, что вы у в своей фиче тоже много где заюзали foo.


            В первом случае — rebase -i, встаём на нужный коммит, делаем reset HEAD~ и делаем нужное количество коммитов. В одном изменения в глобальную либу, в другом — новые строчки от фичи и т.д. Делаем снова rebase -i, и коммиты с изменениями в глобал группируем и сливаем в один, если нужно. Остальные — по своему усмотрению. По итогу мухи и котлеты отделены, можно работать дальше!


            Во втором случае — делаем то же переименование поверх своей ветки (тем же способом, что было сделано в основной. Например, через. IDE). Коммитаем, ребейзим. В результате переписывания получаем коммит с переименованием ровно тех моментов, что вы добавили (а те, что приплыли из основной ветки автоматически отбросятся, как совпадающие).


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

          +4
          Я пользуюсь регулярно и в разных кейсах.
          Изредка даже форс пуш бывает.

          Инструмент, как обычно, не виноват.
            +5

            Один из примеров полезности rebase:


            Есть очень-очень крупный проект (master) и есть свой личный форк (fork) проекта с личными изменениями, которые, по каким-то причинам, не могут быть приняты (либо не хочется отправлять) в апстрим. Проект большой (десятки-сотни тысяч коммитов), личные изменения — максимум 10-20 коммитов.


            Пришло время подтянуть свежие обновления из крупного проекта. Есть два варианта:


            1) можно замержить masterfork
            2) можно сделать rebase


            В случае «1» наши фиксы со временем перемешиваются с коммитами из апстрима и через пару лет уже будет трудно разобраться, что происходит.


            В случае «2» у нас всегда в ветке fork сверху те 10-20 коммитов с нашими фиксами и ситуация всегда ясна, конфликты проще разрешить при значительных изменениях в апстриме.


            Для облегчения ситуации можно при каждом обновлении из апстрима создавать новую ветку fork-YYYYMMDD, чтобы после rebase хэши коммитов не менялись.

              –1
              ну т.е. из-за того, что diff нормальный не может посмотреть? гитхаб например замечательно все отображает.
                0
                Для облегчения ситуации можно при каждом обновлении из апстрима создавать новую ветку fork-YYYYMMDD, чтобы после rebase хэши коммитов не менялись.
                После rebase хэши, всё-равно, поменяются, потому что объект коммита содержит в себе указатель на предка, и если тот меняется (а при rebase это всегда происходит), то и хэш всего коммита меняется. И последующих, аналогично.
                  0

                  Я имел в виду, чтобы старую ветку не трогать (чтобы в ней хэши коммитов не менялись), а создать новую и очередной rebase уже там делать. Пример:


                  1) есть наш форк 2-недельной давности (our-20200414) и новые изменения в master, поверх которых надо наши изменения положить


                  * 8ce46bb (HEAD -> master) 5
                  * cda3070 4
                  | * 95f2d51 (our-20200414) 5'
                  | * 07a2aee 4'
                  |/
                  * d8695a4 3
                  * 82dc170 2
                  * 59a32e9 1

                  2) делаем rebase


                  git checkout -b our-20200428 master
                  git rebase --onto our-20200428 d8695a4 95f2d51
                  git branch -f our-20200428

                  3) получили:


                  * e2e5630 (HEAD, our-20200428) 5'
                  * f1ed7e7 4'
                  * 8ce46bb (master) 5
                  * cda3070 4
                  | * 95f2d51 (our-20200414) 5'
                  | * 07a2aee 4'
                  |/
                  * d8695a4 3
                  * 82dc170 2
                  * 59a32e9 1
                  * 87aa0fa “root”
                +6
                rebase помогает держать свою собственную feature-ветку актуальной, периодически перестраивая её поверх актуального master/develop (при этом без лишних мержей). всегда так делаю для веток, которые принадлежат только мне и от которых никто не зависит
                  –1
                  чем это лучше мержа в feature ветку? Он не создает дополнительных коммитов.
                    +1
                    тем, что история остается линейной.
                      –1

                      И какое в этом преимущество?

                        0

                        Например, проще искать изменения, которые внесли регрессию. Да и, вообще, Оккам был не дурак.

                      0

                      Я в гите относительно недавно. А что за мерж, которые не создает коммитов? Что вы имели ввиду?

                        +1
                        Возможно речь про fastforward, но он работает только когда ветка в которую вливает без изменений с момента ветвления.
                          0
                          Тогда и rebase не нужен. Да и вообще причём тут? Речь же шла о том, что есть фича-ветка и девелоп ушедший вперёд. Один говорит, что фичу надо ребейзить на голву девелопа периодически, а этот отвечат, что надо подмёрживать девелоп в фичу. При этом как-то делать это без коммита. Или чего-то не так понял, или он.
                            0
                            Согласен, похоже le1ic в чем то ошибся, ибо таких возможностей в гите без ребейза нет.
                          –1
                          с одним коммиттом, а не с N
                      +1
                      Опишу свой сценарий. Есть некий проект, туда имеют возможность коммитить несколько человек, включая меня. Я замечаю опечатку в GUI, правлю, делаю коммит и хочу отправить его на сервер:
                      error: failed to push some refs to 'https://github.com/miranda-ng/miranda-ng'
                      hint: Updates were rejected because the remote contains work that you do not have locally. This is usually caused by another repository pushing to the same ref. You may want to first integrate the remote changes (e.g., 'git pull ...') before pushing again. See the 'Note about fast-forwards' in 'git push --help' for details.

                      Если я не сделаю «rebase after merge», то мой коммит (когда он таки улетит на сервер) будет выглядеть как две записи в истории:
                      Merge branch 'master' of github.com/miranda-ng/miranda-ng
                      Fix spelling

                      Первая из двух записей — информационный мусор, который никакой пользы остальным участникам не несёт. Он прилетит всем, кто подписан, например, по RSS на историю коммитов (некоторые пользователи, совершенно точно, подписаны и следят за ходом разработки). Зачем им это?

                      P.S. Возможно, я использую git как-то неправильно и ужасно, но я не программист, сложные и большие коммиты с разработкой новых фич не делаю, все мои правки сводятся к таким вот тривиальным исправлениям мелких и очевидных ошибок.
                        –2
                        ну окей, для меня коммиты — это всего лишь коммиты. которые я делаю с удобной мне периодичностью десятки раз в день и сразу пушу на сервер, чтобы не потерять изменения и запустить CI. Для вас коммиты и красота истории же имеют значимость сами по себе. При этом вы теряете возможность совместной работы (зачем тогда вообще коммиты нужны, добавляйте всю фичу одним коммитом, и история будет красивой?), и использования сервера как бэкапа.

                        Что касается rss апдейтов, у нас что-то похожее сделано, но там только список замерженных feature branch (собственно никаких других там коммитов нет, тк напрямую коммитить в master запрещено). По-моему feature-branch это гораздо более осмысленный юнит для отслеживания.
                          0
                          Updates were rejected because the remote contains work that you do not have locally

                          1) git pull --rebase
                          2) проверить, что там подтянулось
                          3) git push

                            0
                            Выбор команд — дело абсолютно неважное в данной ситуации, потому что в реальности отработает оно одинаково, ваш вариант будет разве что быстрее =)
                          0
                          и ни одного слова зачем он вообще может быть нужен. Я например не знаю.

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


                          Помимо этого объединить промежуточные шаги в законченный коммит. Это работает немного наоборот. Когда вы не исправляете коммиты, вы держите все изменения незакоммиченными, и коммитите когда уверены что все сделано. Это создает дополнительную умственную нагрузку, особенно если надо проверить несколько вариантов реализации (и выбрать тот, который не последний) — ага, тут я поменял, надо еще в том файле поменять, в этом вот это убрать, а вот это не трогать, в этом было мало изменений, можно Ctrl+Z нажать несколько раз, все поменял, проверил, можно коммитить. С исправлением коммитов можно любой шаг коммитить, и при проверке очередной версии не держать все в голове, а просто вносить изменения, если что-то упусил, будет еще один коммит. Потом это все объединяется в 1 или несколько нормальных коммитов. "Наоборот" тут в том смысле, что не rebase дает что-то конкретное, а без rebase сложнее, но пока не попробуешь делать с rebase, это неочевидно.


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

                          +1
                          По сути, rebase — это последовательность cherry-pick'ов, поэтому на каждом коммите может вознинкуть merge conflict. Когда-то тут на хабре я описал способ как, делая rebase, свести всё к единственному merge conflict'у.

                          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                          Самое читаемое