Pull to refresh

Пара иногда востребованных хитростей при работе с git

Reading time 6 min
Views 25K

Хочу поделиться рецептами решения пары задач, которые иногда возникают при работе с git, и которые при этом не "прямо совсем очевидны".


Сперва я думал накопить подобных рецептов побольше, однако всему своё время. Думаю, если есть польза, то можно и понемногу...


Итак...


Мержим застарелые ветки с минимальной болью


Преамбула. Есть основная ветка (master), в которую активно коммитаются новые фичи и фиксы; есть параллельная ветка feature, у которой разработчики уплыли на какое-то время в собственную нирвану, и потом внезапно обнаружили, что уже месяц не мержились с мастером, и мерж "в лоб" (голова с головой) стал уже нетривиален.


(Да, речь не об идеальном мире, где всё правильно, преступность отсутствует, дети всегда послушны и даже через дорогу переходят строго за руку с мамой, внимательно посмотрев по сторонам).


Цель: смержиться. При этом, чтобы это был "чистый" мерж, без особенностей. Т.е. чтобы в публичном репозитории в графе веток две нитки соединялись в единственной точке с сообщением "merged branch 'master' into feature". А всю "вот эту вот" головную боль о том, сколько времени и сил это заняло, сколько было конфликтов было решено и сколько волос при этом поседело хранить незачем.


Фабула. То, что в гите можно редактировать последний коммит ключиком --amend знают все. Фишка в том, что этот "последний коммит" при этом может находиться где угодно и содержать что угодно. Например, это может быть не просто "последний коммит в линейную ветку", где забыли поправить опечатку, но и мерж-коммит от обычного или "осьминожного" слияния. --amend ровно так же накатит предложенные изменения и "встроит" изменённый коммит в дерево, как будто он в самом деле появился в результате честного слияния и разрешения конфликтов. По сути git merge и git commit --amend позволяет полностью разделить"застолбление места" ("этот коммит в дереве будет находиться ЗДЕСЬ") и содержание самого коммита.


Основная идея сложного мерж-коммита с чистой историей проста: сперва "столбим место", создавая чистый мерж-коммит (невзирая на содержимое), затем переписываем его с помощью --amend, делая содержимое "правильным".


  1. "Столбим место". Это легко сделать, назначив при мерже стратегию, которая не будет задавать лишних вопросов о разрешении конфликтов.


    git checkout feature
    git merge master -s ours

  2. Ах, да. Надо было перед мержем создать "резервную" ветку из головы feature. Ведь ничего же на самом деле не слито… Но пусть это будет 2-м пунктом, а не 0-м. В общем, переходим на не-слитую feature, и теперь честно сливаем. Любым доступным способом, невзирая ни на какие "грязные хаки". Мой личный способ — просматриваем мастер-ветку от момента последнего слияния и оцениваем возможные проблемные коммиты (например: поправили в одном месте опечатку — не проблемный. Массово (на много файлов) переименовали какую-либо сущность — проблемный. И т.д.). От проблемных коммитов создаём новые ветки (я делаю бесхитростно — master1, master2, master3 и т.д.). И потом сливаем ветку за веткой, двигаясь от старых к свежим и исправляя конфликты (которые при таком подходе обычно самоочевидны). Другие методы предлагайте (я не волшебник; я только учусь; буду рад конструктивным замечаниям!). В конечном итоге, потратив (может быть) несколько часов на чисто рутинные операции (которые можно доверить юниору, ибо сложных конфликтов при таком подходе просто нет), получаем финальное состояние кода: все нововведения/фиксы мастера успешно портированы в ветку feature, все релевантные тесты на этом коде прошлись и т.д. Успешный код должен быть закоммитан.


  3. Переписываем "историю успеха". Находясь на коммите, где "всё сделано", запускаем следующее:



git tag mp
git checkout mp
git reset feature
git checkout feature
git tag -d mp

(расшифровываю: с помощью тэга (mp — merge point) переходим в detached HEAD состояние, оттуда reset на голову нашей ветки, где в самом начале "застолблено место" обманным мерж-коммитом. Тэг больше не нужен, поэтому его удаляем). Теперь мы стоим на первоначальном "чистом" мерж-коммите; при этом в рабочей копии у нас "правильные" файлы, где всё нужное смержено. Теперь нужно добавить все изменённые файлы в индекс, и особенно тщательно просмотреть на non-staged (там будут все новые файлы, возникшие в основной ветке). Все нужные оттуда добавляем тоже.


Наконец, когда всё готово — вписываем в зарезервированное место свой правильный коммит:


git commit --amend

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


Upd: Более лаконичный способ


Спустя три месяца после этой публикации вышла статья "Как и зачем красть деревья в git" от capslocky


По её мотивам можно добиться ровной той же цели более кратким путём и без вспомогательных механизмов: не нужно "столбить место", рассматривать unstaged-файлы после reset и делать amend; можно в один шаг создать прямой мерж-коммит с нужным содержимым.


Начинаем сразу со слияния любыми доступными методами (как в п. 2 выше). Развесистая промежуточная история и хаки при этом по-прежнему не имеют значения. А далее вместо п.3 с подменой мерж-коммита делаем искусственный merge, как в статье:


git tag mp
git checkout feature
git merge --ff $(git commit-tree mp^{tree} -m "merged branch 'master' into 'feature'" -p feature -p master)
git tag -d mp

Всю магию здесь делает в один шаг третья команда (git commit-tree).


Выделяем часть файла, сохраняя историю


Преамбула: в файл кодили-кодили, и наконец накодили так, что даже вижуал-студия стала подтормаживать, его переваривая (не говоря уже о JetBrains). (Да, мы снова в "неидеальном" мире. Как всегда).


Умные мозги подумали-подумали, и выделили несколько сущностей, которые можно отпочковать в отдельный файл. Но! Если просто взять, скопипастить кусок файла и вставить в другой — это будет с точки зрения git совершенно новый файл. В случае любых проблем поиск по истории однозначно укажет лишь "где этот инвалид?", который разделил файл. А найти оригинальный источник бывает нужно вовсе не "для репрессий", а сугубо конструктивно — чтобы узнать, ЗАЧЕМ была изменена вот эта строчка; какую багу это фиксило (или не фиксило никакую). Хочется, чтобы файл был новый, но при этом вся история изменений всё же осталась!


Фабула. С некоторыми слегка досадными краевыми эффектами это можно сделать. Для определённости — есть файл file.txt, из которого хочется выделить часть в file2.txt. (и при этом сохранить историю, да). Запускаем вот такой сниппет:


f=file.txt; f1=file1.txt; f2=file2.txt
cp $f $f2
git add $f2
git mv $f $f1
git commit -m"split $f step 1, converted to $f1 and $f2"

В результате получаем файлы file1.txt и file2.txt. У них у обоих совершенно одинаковая история (настоящая; как у исходного файла). Да, оригинальный file.txt пришлось при этом переименовать; в этом и состоит "слегка досадный" краевой эффект. К сожалению, найти способ сохранить историю, но чтобы при этом НЕ переименовывать исходный файл, я не смог (если кто смог — расскажите!). Однако гит всё стерпит; никто не мешает теперь отдельным коммитом переименовать файл обратно:


git mv $f1 $f
git commit -m"split finish, rename $f1 to $f"

Теперь у file2.txt гилт покажет ту же историю строчек, что и у оригинального файла. Главное — не сливайте эти два коммита вместе (а то вся магия исчезнет; пробовал!). Но при этом никто не мешает редактировать файлы прямо в процессе разделения; необязательно это делать позже отдельными коммитами. И да, можно выделять сразу много файлов!


Ключевой момент рецепта: переименовать исходный файл в другой в том же коммите, где из него делается (и, возможно, редактируется) копия (копии). И пусть этот коммит в будущем живёт (никогда не сквашится с обратным переименованием).


Upd: пара рецептов от Lissov


Отделяем часть репозитория с историей


Вы находитесь на последней версии начального репозитория. Задача — отделить одну папку. (Я видел варианты на несколько папок, но проще и понятнее либо сначала сложить всё в одну, либо повторить ниженаписанное несколько раз.)


Важно! Все перемещения делать командой git mv, иначе гит может потерять историю.


Выполняем:


git filter-branch --prune-empty --subdirectory-filter "{directory}" [branch]

{directory} — та папка, которую надо отделить. В итоге получаем папку вместе с полной историей коммитов только в неё, то есть в каждом коммите отображаются файлы только из этой папки. Естественно, часть коммитов получатся пустыми, их убирает --prune-empty.
Теперь меняем origin:


git remote set-url origin {another_repository_url}`
git checkout move_from_Repo_1

Если второй репозиторий чистый, можно сразу в master. Ну и push:


git push -u move_from_Repo_1

Весь сниппет целиком (для лёгкого копи-паста):


directory="directory_to_extract"; newurl="another_repository_url"
git filter-branch --prune-empty --subdirectory-filter "$directory"
git remote set-url origin "$newurl"
git checkout move_from_Repo_1
git push -u move_from_Repo_1

Сливаем вместе два репозитория


Допустим, вы проделали то что выше 2 раза и получили бранчи move_from_Repo_1 и move_from_Repo_2, и в каждом перенесли файлы с помощью git mv туда, где они должны оказаться после слияния. Теперь осталось смержить:


br1="move_from_Repo_1"; br2="move_from_Repo_2"
git checkout master
git merge origin/$br1 --allow-unrelated-histories
git merge origin/$br2 --allow-unrelated-histories
git push

Весь фокус в "--allow-unrelated-histories". В итоге получаем один репозиторий с полной историей всех изменений.

Tags:
Hubs:
+30
Comments 20
Comments Comments 20

Articles