Git: проблема длинного прыжка

от автора

На протяжении многих лет использования git я выявил для себя одну серьезную проблему, которую этот инструмент не дает возможности разрешить удобным способом. Я назвал эту проблему “проблемой длинного прыжка”. Суть такова: у вас есть старая залежавшаяся ветка, которая отстает от main-ветки на сотни и тысячи коммитов. Задача — обновить эту старую ветку до свежего main. Все усугубляется тем, что у вас тяжеловесный репозиторий: гигабайты, десятки или сотни гигабайт.

Обычный алгоритм для обновления такой ветки:

  • прыгаем на старую ветку

  • мержим в нее свежий origin/main

  • пушим

Если вы в начале выполнения этих действий находитесь на какой-нибудь свежей ветке, например на main, то для вас выполнение этих операций означает два прыжка: один прыжок из актуальной версии репозитория в устаревшую версию, а потом, после того как вы совершите мерж, — это обратный прыжок от старого кода к новому. Если разрыв между старой версией репозитория и новой составляет много гигабайт и вы не можете похвастаться большой скоростью скачивания с git-сервера, то вы ощутите три волны боли:

  • боль предвкушения

  • боль прыжка в протухший репозиторий

  • боль обратного прыжка

Если я вас не убедил

Мой нынешний проект возводит неприятные последствия от таких прыжков в куб из-за своей специфики.

Во-первых, мы говорим об объемах репозитория, который перевалил за терабайт. Папка .git/lfs занимает 90% репозитория.

Во-вторых, периодически случаются эпизоды, когда все наши актуальные ветки в одночасье протухают на тысячи коммитов из-за огромного стороннего мержа, который обновляет половину проекта. Ситуация не то чтобы частая, но она случается. И когда она случается, нам всем неизбежно приходится сталкиваться с проблемой, о которой я пишу. То есть недостаточно быть хорошим мальчиком и держать все свои ветки в актуальном состоянии — даже это в какой-то момент не спасет тебя.

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

Git**b Update

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

Счастливчики те, кто работает в GitHub, поскольку у них есть все возможности совершить мерж руками платформы, не выходя из Web-интерфейса. В частности на странице PR’а есть опция Update branch, которая по умолчанию совершает git merge. Для желающих ребейзнуться есть возможность сделать это через Update with rebase.

В GitLab прямой аналог кнопки Update branch мне неизвестен. Точнее опция обновления ветки есть, но она безальтернативно делает ребейз — Rebase source branch. И это ребейз без права выбрать не-ребейз.

В обоих платформах перед обновлением ветки нужно разрешить конфликты. Причем это можно сделать тут же, в Web-интерфейсе. Однако, не во всех случаях платформа готова дать вам решить конфликты через Web-IDE — в тяжелых случаях сервис откажет вам в такой возможности и попросит сделать все локально. И это будет тупик.

Недостатки подхода:

  • Подходит для несложных случаев

  • Если вы не на GitHub, есть вероятность, что вы останетесь один на один с опцией сделать ребейз. А возможно, ваша платформа и вовсе не предоставляет таких возможностей, как удаленный мерж или ребейз

  • Ограниченные возможности комфортно решить конфликты — нельзя скомпилировать и запустить результат; а в сложных случаях — большие файлы, бинарные файлы, нетривиальные переименования, большое количество файлов — платформа может вам отказать и предложить решить все на вашей собственной машине

  • Я знаю один МР, который Web UI в принципе не мог адекватно переварить из-за обилия изменений, поэтому он не мог показать ничего — ни diff, ни кнопку Merge, ни опции Update branch / Rebase source branch — просто часами крутит спиннер и обещает, что скоро все покажет. Это тоже тупик. Возможно, можно было сделать что-то через API, но я не спец

Обратный PR/MR

Если площадка не дает прямой возможности обновить ветку так, как вы хотите, вы можете попытаться сымитировать это через фиктивный PR/MR, который “вывернут наизнанку” — он будет таргетить не feature -> main, а main -> feature. Далее, если у вас есть права и на работе в целом не против таких грязных мувов, вы на месте можете порешать конфликты (если они решаемы, если нет — тупик) и замержить main в вашу ветку.

Для GitHub этот метод, вероятно, неактуален, поскольку Update branch делает все то же самое, но более прямым путем. А в GitLab’е он вполне имеет смысл, если вы сильно против ребейза.

Недостатки подхода:

  • Недостатки предыдущего подхода

  • Замусоривание платформы фиктивными MRами, которые пачками будут отлегаться во вкладке Merged

  • Если у вас недостаточно прав на нажатие кнопки Merge, вам придется попросить старшего

Обратный локальный мерж

Аналог предыдущего подхода, но локальный. Мы снова мержим feature в main вместо мержа main в feature — ведь если результат одинаковый, то какая разница? Разница, конечно, как мы увидим, есть, но результат самого мержа и правда обычно одинаковый. Этот подход, внезапно, сильно дешевле прямого подхода, когда мы прыгаем на feature и обратно — как раз по той причине, что мы в этом случае не совершаем ни один из этих прыжков, а просто занимаемся локальным мержем. Мерж тоже может занимать время и ресурсы, но обычно они несравнимы с полноценными прыжками туда/обратно, поскольку прыжок зачастую подразумевает выкачку “лишнего шума”, порой много-гигабайтного.

# уходим в detached stategit switch --detach origin/main# мержим ветку в detached HEADgit merge -n feature# решаем конфликты# НЕ КОММИТИМ# ретаргетим ветку содержимым detached HEADgit switch -C feature# вот теперь коммитимgit commit# форсим обновление ветки тем, что мы наделалиgit push --force-with-lease origin feature# возвращаемся из detached state куда-нибудьgit switch main

Недостатки подхода:

  • Форс ветки. Несмотря на то, что мы начинали c нормального мержа, закончили мы на форсировании ветки и все испортили. В каких-то ситуациях форс может быть неприменим в реалиях вашей работы.

В остальном я считаю этот подход вполне себе адекватным и полноценным. Именно из-за практически полного отсутствия ограничений.

Feature-patch

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

  • Если у вас GitHub или GitLab, то к URL с PR или MR можно добавить .patch, и вы попадете на страницу с текстом патча, который можно скопировать

  • Набрать в Git Bash команду

      git fetch origin feature  git format-patch --stdout origin/main..origin/feature > feature.patch
  • You name it

Я хочу сразу предостеречь: не перепутайте .patch и .diff — нам нужен .patch! В теории .diff тоже может подойти, но он не умеет в нормальное разрешение конфликтов и стирает информацию о коммитах и их метаданных вроде сообщений, дат и т.д.

После того, как достали патч:

# уходим в detached stategit switch --detach origin/main# применяем патчgit am feature.patch# форсим обновление ветки тем, что мы наделалиgit branch -f feature HEADgit push --force-with-lease origin feature# возвращаемся из detached state куда-нибудьgit switch main

Если на стадии применения патча, у вас появились конфликты, решаете их как при обычном мерже, затем запускаете git am --continue.

Фактически эту ручной rebase, что-то в духе git rebase --onto origin/main <old-main> feature. Вот только настоящие локальные rebase и merge на тяжелом репозитории могут частично восстанавливать состояние старой ветки на вашей рабочей машине, что может стриггерить нелегкие возвраты в прошлое. В готовом патче эта работа уже проделана, так что теоретически этот способ быстрее предыдущего.

Недостатки подхода:

  • Может быть ситуация, когда вам не так просто достать патч

  • Все еще форс

Sparse-checkout

Наверное, самый правильный с точки зрения сохранения истории и потребления ресурсов вариант. Но насколько он правильный, настолько же он и муторный. Во первых, мы вступаем на территорию advanced git и будем использовать sparse-checkout. Во-вторых, мы будем клонировать новый репо. Но вы не бойтесь — мы склонируем пустышку.

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

Наглядный пример:

.├── assets├── src│   ├── a│   ├── b│   │   ├── ba│   │   │   └── touched-I.cpp│   │   ├── bb│   │   ├── bc│   │   └── bd│   │       └── touched-II.cpp│   ├── c│   ├── d│   │   └── touched-II.cpp│   └── e└── third-party

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

  • src/b/ba/

  • src/b/bd/

  • src/d/

Но в целом можно и широкими мазками: если вы знаете, что вся ваша работа была проведена в папке src, и неважно, что там тысячи файлов в подпапках, которые вы не трогали — они все весят считанные мегабайты, и вы готовы их скачать — то пожалуйста.

То есть и так сгодится:

  • src/b/

  • src/d/

Да и просто src/ сойдет при условии, что вы не боитесь скачивать всю src/.

После взятия списка папок создаем временную папку и выполняем следующее:

# клонируем временный репо не забирая ничего и входя в spapse-checkout режимgit clone --filter=blob:none --sparse <YOUR_REPO>.git# идем на свою веткуgit checkout feature# самое муторное: через пробел указываем все пути, которые мы потрогали в веткеgit sparse-checkout set src/b/ba/ src/b/bd/ src/d/# запускаем мержgit merge origin/main# правим конфликты. в git status будут огромные полотна в индексе - с этим ничего не сделать# правим, коммитим, пушим# удаляем временный репо

Как видите, мы сделали честный мерж ветки, обойдя стороной выкачивание всего репозитория. Фактически мы обошлись выкачиванием только того, что нам действительно необходимо для мержа — на стадии git sparse-checkout set гит выкачает все по указанным путям.

При мержах бывает один хитрый случай, когда в main-ветке переименована папка, родительская для файлов, которые мы меняли в нашей ветке. Например, в main папка b переименована в bigB. А в вашей старой ветке она все еще b. Решение есть — в git sparse-checkout set нужно перечислить обе версии путей:

git sparse-checkout set src/b/ba/ src/b/bd/ src/bigB/ba/ src/bigB/bd/ src/d/

Это сработает.

Если вы адепт worktrees и хотите использовать worktree вместо клонирования репозитория, то набор команд будет немного иной:

# создаем worktreegit worktree add --no-checkout ../feature-update feature# заходим в негоcd ../feature-update# инициируем sparse-checkoutgit sparse-checkout init --conegit sparse-checkout set src/b/ba/ src/b/bd/ src/d/# дальше обычный мерж

Но будет один неприятный нюанс — так как git worktree add нельзя скомбинировать с созданием sparse-режима, ваш индекс нещадно заполонится тьмой файлов, и вам придется чистить это самостоятельно. git clone обходит эту проблему стороной за счет флага --sparse, аналога которому у git worktree add нет.

Недостатки подхода:

Очевидны. Свистопляска со списком папок может оказаться непосильно мучительной, а порой и вовсе невозможной, если изменений много. Я на такой случай навайбодил python-скрипт, который читает diff к ветке и выводит мне готовую строку для git sparse-checkout set.

Скрипт
import sysfrom pathlib import PurePosixPathfrom collections import defaultdictdef parse_diff(lines):paths = set()for line in lines:    line = line.rstrip("\n")    if line.startswith("+++ ") or line.startswith("--- "):        path = line[4:]        if path.startswith("a/") or path.startswith("b/"):            path = path[2:]        if path != "/dev/null":            paths.add(path)return pathsdef build_dir_stats(paths):stats = defaultdict(int)for path in paths:    parts = PurePosixPath(path).parts    for i in range(1, len(parts)):        stats["/".join(parts[:i])] += 1return statsdef make_sparse_paths(paths):dirs = set()for path in paths:    parts = PurePosixPath(path).parts    dirs.add("/".join(parts[:-1]))result = []for d in sorted(dirs):    # remove parents if a child directory already covers them    if not any(        child.startswith(d + "/")        for child in dirs        if child != d    ):        result.append(d)return resultif name == “main”:with open(sys.argv[1], encoding=“utf-8”) as f:paths = parse_diff(f)sparse = make_sparse_paths(paths)print(‘git sparse-checkout set’, end=" “)    for path in sparse:        print(path, end=” ")

Послесловие

Спасибо моим коллегам за вклад в эту нелегкую тему ценой собственных нервных клеток

ссылка на оригинал статьи https://habr.com/ru/articles/1063614/