Re[3]: Git: rebase vs merge или линейная история против спагетти
От: halo Украина  
Дата: 23.02.22 14:27
Оценка: 15 (1)
Здравствуйте, Sharov, Вы писали:

S>Я не могу с topic-ветки на родительскую сделать rebase? git checkout parent, git pull; git checkout topic; git rebase parent

S>(или как-то так).
S>Что значит
S>

S>после предварительного git rebase topic-ветки.

S>?

Почему не можете? Именно так я и делаю, и именно об этом говорил в первом пункте, после чего -- git push --force, конечно. У нас с topic-ветками разрешены любые операции как локально (впрочем, кто запретит-то?), так и удалённо (потому что никто не заморачивается над их защитой). Но запрещено делать слияние в master-ветку без предварительного перебазирования topic-ветки на master (для упрощения будем считать, что единственной родительской веткой является master). Сама механика такой защиты реализуется в git серверными хуками, следуя определёнными правилами, принимающими или отклоняющими измения в ветках (точнее ссылках на фиксации) (правил множество: локально или удалённо пытаются подвинуть ветку; был ли это force-push; пытается ли кто-то откатить её назад; что там с топологией сливаемых изменений и т.д. и т.п., в чём может помочь git merge-base)).

Допустим, у меня локально было так (визуализацию ветки cool-feature для упрощения "согнул" вправо, хотя git log --graph выровняет её и прижмёт к левому краю, но, снова же, это не имеет значения):

  @ "Cool feature change 2" (<- refs/heads/cool-feature; refs/remotes/origin/cool-feature)
  * "Cool feature change 1"
  * "Cool feature change 0"
 /
* (<- refs/heads/master; refs/remotes/origin/master)
|\
| * "Basic feature change"
|/
*


Потом я узнаю, что на master-ветке произошли изменения и они были приняты на master (написали лично, пришло письмо, пропала кнопка "Merge" на том же GitLab). С этого момента, поскольку refs/remotes/origin/cool-feature больше не содержит фиксации, на которую в данный момент указывает refs/heads/master (с точки зрения удалённого репозитория), удалённый репозиторий не позволит принять изменения с ветки cool-feature на master. Локально синхронизируем новые изменения:

$ git checkout master
$ git pull # предпочитаю git fetch --prune && ​git merge --ff-only @{U}


@ "Merge basic-feature-fix into master" (<- refs/heads/master; refs/remotes/origin/master)
|\
| * "Basic feature fix"
|/
| ​@ "Cool feature change 2" (<- refs/heads/cool-feature; refs/remotes/origin/cool-feature)
| ​* "Cool feature change 1"
​| ​* "Cool feature change 0"
​|/
* "Merge basic-feature into master"
|\
| * "Basic feature"
|/
*


Делаем git rebase:

$ git checkout cool-feature
$ git rebase master


  ​@ "Cool feature change 2" (<- refs/heads/cool-feature)
  ​* "Cool feature change 1"
​  ​* "Cool feature change 0"
​ /
@ "Merge basic-feature-fix into master" (<- refs/heads/master; refs/remotes/origin/master)
|\
| * "Basic feature fix"
|/
| @ "Cool feature change 2" (<- refs/remotes/origin/cool-feature)
| ​* "Cool feature change 1"
​| ​* "Cool feature change 0"
​|/
* "Merge basic-feature into master"
|\
| * "Basic feature"
|/
*


Уведомляем удалённый репозиторий о своих изменениях:

$ git push -f


  ​@ "Cool feature change 2" (<- refs/heads/cool-feature; refs/remotes/origin/cool-feature)
  ​* "Cool feature change 1"
​  ​* "Cool feature change 0"
​ /
@ "Merge basic-feature-fix into master" (<- refs/heads/master; refs/remotes/origin/master)
|\
| * "Basic feature fix"
|/
* "Merge basic-feature into master"
|\
| * "Basic feature"
|/
*


После этого снова можем сливать свои изменения на удалённом репозитории, где условный GitLab под капотом у меня делает что-то типа такого:

$ git checkout "$TARGET_REF"
$ git merge --no-ff --no-squash "$SOURCE_REF"
... исполнение проверок в ./git/hooks/pre-merge-commit ...
$ test ! -z "$DELETE_SOURCE_REF" && git branch -d "$SOURCE_REF"


Тогда локально:

$ git checkout master
$ git pull
$ git branch -d cool-feature


@ "Merge cool-feature into master" (<- refs/heads/master; refs/remotes/origin/master)
|\
| ​* "Cool feature change 2" (<- refs/heads/cool-feature) (если ветка на удалённом репозитории не удалена и не было git fetch --prune)
| ​* "Cool feature change 1"
​| ​* "Cool feature change 0"
​|/
* "Merge basic-feature-fix into master"
|\
| * "Basic feature fix"
|/
* "Merge basic-feature into master"
|\
| * "Basic feature"
|/
*


По такой схеме легко увидеть некоторую закономерность. Поэтому:

* --no-ff -- создание отдельной фиксации для слияния ("Merge ... into master"), иначе git на удалённой машине просто переместит refs/heads/master на последнее изменение в cool-feature
__* мотивация 1: упрощение отслеживания времени сливания ветки, иначе имело бы тупо линейный вид как trunk в Subversion;
__* мотивация 2: отчёт лога изменений от версии к версии прямиком из merge-фиксаций с помощью тупого git log --format --merges (но это, к сожалению, если при git merge на удалённом репозитории можно указать свои сообщения)
* контраргумент: "нравится линейность, merge-фиксации засоряют граф" -- можно визуализировать историю с помощью git log --no-merges
* --no-squash -- запретить сжимать все фиксации между master и cool-feature в одно изменение перед слиянием
__* мотивация 1: отслеживание изменений на ветке по отдельности;
__* мотивация 2: возможность git revert отдельного изменения в случае необходимости, если каждая фиксация содержит логически атомарное и завершённое изменение
__* контраргумент: "мне нравится squash, так как он убирает весь промежуточный мусор" -- никто не запрещает регулярно пользоваться commit --amend или интерактивным rebase у себя на ветке по мере необходимости во время рабочего процесса, или позже самому сделать squashed-фиксацию с помощью git rebase -i и команд pick/fixup/squash.

Из минусов такого подхода могу назвать следующее:
* если есть множество открытых PR, каждый из них приходится перебазировать на только что обновлённый master вручную;
* автоматическая генерация сообщений вида "Merge ... into master" на удалённой системе, мягко говоря, малоинформативна, даже если включает в себя ссылки на тикеты в баг-трекерах и на pull-request-ы;
* на удалённом репозитории, если есть CI, это может занимать слишком продолжительное время;
* поскольку rebase это, в общем случае, о применении наборов патчей, со временем неизбежны конфликты с родительских веток в промежуточных изменениях, в то время, как merge просто будет пытаться слить только последние изменения на этих ветках (если не ошиюбаюсь, делая в некоторых случаях исключения) -- это общая проблема rebase, когда его сравнивают с merge; + ещё одна проблема rebase: если при git merge отрезолвили конфликт, при постоянном git rebase изменений, который ещё не слили в родительскую ветку, есть ненулевая возможность неправильно поправить конфликт и даже не заметить этого, что потом можно даже и не вспомнить (но, снова же, по-моему, это решается git rerere);
* иногда на master может попасть что-то нежелательное, что можно удалить только имея права прямой записи в такой master с локальной машины (например, на topic-ветке кто-то сделал commit, а тогда revert (вместо commit --amend, reset, rebase или любого другого способа, убирающего ненужное из истории), таким образом создав zero-diff из двух фиксаций, которые наверняка при review не заметят), при этом предупредив всех, что master придётся подправвить, что может повлиять на уже существующие изменения (как локально, так и удалённо, причём не важно в каком масштабе).

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