На протяжении многих лет использования 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.patch You 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=” ")
Послесловие
Спасибо моим коллегам за вклад в эту нелегкую тему ценой собственных нервных клеток
Комментарии (14)

Tony-Sol
27.07.2026 18:44Во-первых, мы говорим об объемах репозитория, который перевалил за терабайт.
Что же там такое, что аж на терабайт и почему в таком случае это все еще git?

AskePit Автор
27.07.2026 18:44геймдев :) больше всего весят ассеты. только не спрашивайте “А почему не X?”. ответ будет где-то между “NDA” и “обстоятельства”, т.е. по факту с чем приходится работать, с тем приходится

Tony-Sol
27.07.2026 18:44геймдев :) больше всего весят ассеты.
Так и подумал, но решил что лучше уточнить)
только не спрашивайте “А почему не X?”.
Ну на самом деле да, следующим хотел про perforce helix спросить "а чего не он, он как раз под геймдев позиционируется"
ответ будет где-то между “NDA” и “обстоятельства”
Как говорится - "так исторически сложилось"

indeed174
27.07.2026 18:44Поддерживаю комментаторов, стало интересно. Что за проекты хранят терабайт в гит.

itstranger
27.07.2026 18:44Касаемо террабайт не знаю, но в геймдеве ассеты спокойно могут весить несколько гигабайт, а то и десятков. Так же подобное встречается в вебе. Правда для подобного обычно используют git lfs, а тысячи коммитов относительно текста, даже с самой раздутой архитектурой больше гига вряд ли весить будут) Я бы даже сказал в реальности несколько сотен мегабайт с учётом хранения всех дифов.
Да и проблем с мерждем тех же ассетов обычно нет, если грамотно выстроена работа в команде)

indeed174
27.07.2026 18:44Для геймдев и ассетов бинарных есть более удобные решения. Тот же Lore эпики сделали. Я вот и думаю, кому в куче веток нужны именно терабайты, чтобы версии контролировать через мерджи.

capslocky
27.07.2026 18:44Я попробовал "Обратный локальный мерж" на моем небольшом демо репозитории.
Во-первых, тут происходит схлопывание всей истории ветки в один коммит поверх ветки main. То есть по сути rebase + squash.
Во-вторых, мне пришлось явно выйти из мерджа:
% git switch -C feature fatal: cannot switch branch while merging Consider "git merge --quit" or "git worktree add". % git merge --quitИ я также согласен, что форс-пуш лучше избегать. Можно изменить этот подход без потери истории, не создавая rebase, и не делая форс-пуш.
# уходим в detached state git switch --detach origin/main # мержим ветку в detached HEAD git merge -n feature # выходим из мерджа, решаем конфликты, добавляем файлы git merge --quit git add -A # создаем временный коммит чтобы зафиксировать результат как HEAD git commit -m "Temp commit" # самый главный трюк: создаем merge commit используя HEAD с двумя парентами, полчаем его хэш и # сразу переносим ветку на этот новый коммит (по сути fast-forward) git switch -C feature $(git commit-tree HEAD^{tree} -p feature -p origin/main -m "Merged branch 'main' into 'feature'") # обычный пуш без форса git push origin featureВ общем я использовал тот же прием, про который писал на Хабре тут и тут.

AskePit Автор
27.07.2026 18:44вы знаете - это гениальный трюк! и, пожалуй, именно то, что я хотел: совершить
feature -> main, а потом притвориться, что это былmain -> feature. и без форса.в комментариях к вашим статьям люди писали, что не понимают, зачем нужно спускаться к низкоуровневым гит-командам, но вот живой пример, где commit-tree решает плохо решаемую задачу.
так же спасибо, что указали на упущенный мной
git merge --quit- я поправлю статью
capslocky
27.07.2026 18:44Я надеялся, что он вам пригодится, поэтому написал такой подробный комментарий. Теперь вы можно сделать алиас для этой хитрой команды.

minamoto
27.07.2026 18:44Если в ветке понятное небольшое количество коммитов, я бы сделал просто новую ветку, черри-пиком забрал в неё нужные коммиты, удалил бы старую ветку и запушил новую на её место. Правда тут нужно действовать крайне осторожно, чтобы ничего не потерять случайно.
funca
Что если отключать синхронизацию lfs на время ваших операций? `export GIT_LFS_SKIP_SMUDGE=1`
AskePit Автор
хмм, стоит попробовать. спасибо за совет