На протяжении многих лет использования 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 state git switch --detach origin/main # мержим ветку в detached HEAD git merge -n feature # решаем конфликты # НЕ КОММИТИМ # ретаргетим ветку содержимым detached HEAD git 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.patchYou name it
Я хочу сразу предостеречь: не перепутайте .patch и .diff - нам нужен .patch! В теории .diff тоже может подойти, но он не умеет в нормальное разрешение конфликтов и стирает информацию о коммитах и их метаданных вроде сообщений, дат и т.д.
После того, как достали патч:
# уходим в detached state git switch --detach origin/main # применяем патч git am feature.patch # форсим обновление ветки тем, что мы наделали git branch -f feature HEAD git 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 вместо клонирования репозитория, то набор команд будет немного иной:
# создаем worktree git worktree add --no-checkout ../feature-update feature # заходим в него cd ../feature-update # инициируем sparse-checkout git sparse-checkout init --cone git 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 sys from pathlib import PurePosixPath from collections import defaultdict def 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 paths def 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])] += 1 return stats def 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 result if 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=” ")
Послесловие
Спасибо моим коллегам за вклад в эту нелегкую тему ценой собственных нервных клеток
