Хочу поделиться рецептами решения пары задач, которые иногда возникают при работе с 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

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


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


Преамбула: в файл кодили-кодили, и наконец накодили так, что даже вижуал-студия стала подтормаживать, его переваривая (не говоря уже о 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 гилт покажет ту же историю строчек, что и у оригинального файла. Главное — не сливайте эти два коммита вместе (а то вся магия исчезнет; пробовал!). Но при этом никто не мешает редактировать файлы прямо в процессе разделения; необязательно это делать позже отдельными коммитами. И да, можно выделять сразу много файлов!


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

Комментарии (16)


  1. maisvendoo
    21.09.2018 22:24
    +1

    Git неисчерпаем. Утащил себе в копилку рецептов


  1. aamonster
    21.09.2018 22:27

    1 — так делать не надо. Вместо всех этих танцев с бубном — мержите из master в feature (конечно, это надо было делать регулярно, но раз уж забивали на это — надо навёрстывать), спокойно разрешаете конфликты, тестируете, а потом уже ветку, «догнавшую» master без труда вливаете в него (тривиальный merge).

    Кстати, из мастера в фичу можно мёржить и в несколько этапов.

    2 — да, стандартный костыль. Увы, git, в отличие от mercurial, не хранит информацию о переименованиях, пытаясь искать их «на ходу» (криво и косо, но если ему помочь — справляется).


    1. mikhailian
      21.09.2018 23:29
      +1

      Где-то как-то Линус упоминал, что хранить метаданные об операциях он в принципе не хотел. Только состояния.


    1. klirichek Автор
      22.09.2018 20:02

      Про 1 — это именно один из способов мержа.
      Один накодил. А как мержить, сказал — не, "у меня лапки", давайте сами.
      Второй попробовал — "нахрапом" не срослось, и не нашёл ничего лучше, как начать откатывать изменения мастера (типа, раз тут не соображаю, как мержить, значит и в оригинале им не быть).
      И это всё как раз вокруг той самой задачи — "смержить из мастера в feature".
      Т.е. задача доросла до размеров, когда "тупо в лоб" решаться перестала, и назрел букет творческих подходов. (я в итоге небольшими шагами всё сделал, и это заняло пол-дня неспешной работы, при этом 90% этого времени гонялись тесты после каждого шага, а я сидел в левых чатиках/на хабре).
      И основная мысль (надо было иначе в рецепте её сформулировать) — в том, что commit --amend переписывает ЛЮБОЙ коммит, независимо от количества родителей. А не только "обычный".


      1. Lissov
        22.09.2018 22:05

        А почему не делать просто мерж отдельно по одному комитету поверх? В чем глобальное преимущество переписывать предыдущие коммиты?


      1. aamonster
        23.09.2018 08:12

        Да, я напутал — почему-то решил, что мержим в мастер (видимо, потому, что мне в голову бы не пришло в feature-ветке биться за идеальную историю: важно лишь, что она вливается в мастер одним коммитом, и я решил, что речь о нём; "букет творческих подходов" у меня, скорее всего, прошёл бы серией коммитов, amend бы использовал только для совсем ерунды).
        amend на merge — ну да, какая разница… Просто полдня работы — по мне слишком много для него.


  1. gdt
    22.09.2018 05:11

    1 — я бы лучше сделал ребейз, раз уж ветка отстала от мастера


    1. klirichek Автор
      22.09.2018 06:10

      Если просто отстала — то да, можно и ребейзом. Финальный момент — подмена мерж-коммита — останется; а ребейз — это лишь один из возможных вариантов получить конечный коммит для этой подмены (ветка опубликована; пушить ребезнутую ветку у нас, хм, не принято).


      Ещё иногда бывает проще вообще не мержить, а вручную отредактировать. Например, если в пачке коммитов есть одно глобальное переименование какой-нибудь функции, которая вызывается постоянно из разных мест. Тут проще в ветке сделать ровно то же переименование (всё равно IDE поможет; найдёт все контексты и сделает это правильно), чем мержить результат и потом разрешать логические конфликты (типа, всё слилось ок, но вот в новом коде в ветке по-прежнему используется прежнее имя, а гит об этом в принципе знать не может). А полученное при этом правильное состояние можно вот так же вкоммитать как результат "мержа" с переименующим коммитом (чтобы на будущее была новая точка мержа).


    1. iig
      22.09.2018 12:15

      В megcurial есть transplant и graft; утащить из старой ветки нужные комитеты порой проще, чем решать конфликты после merge.


      1. aamonster
        22.09.2018 13:11

        git cherri-pick, если нужна не вся ветка. Но я так понял, речь о другом — что в ветке переменная переименована в 5 местах, а в мастере она за это время расползлась ещё на 10, git это автоматом не отработает (емнип такое darcs умеет, но, во первых, он экзотичен, а во вторых — переименовывать надо его командой).


    1. Lissov
      22.09.2018 22:43

      Проблема ребейз в том, что конфликты будут неотличимы от намеренных изменений. А так как мерж/ребейз иногда делают в самом конце без повторного тестирования, то отдельный мерж очевиднее.


  1. Prototik
    22.09.2018 15:51

    По первому пункту: git merge --no-commit, правите что хотите, git commit. Только лишние сложности создали.


    1. mapron
      22.09.2018 16:45

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

      Ну и еще мне идея «только одного мерджа» не нравится тем, что это сложно отдавать на ревью.
      Надо либо как выше советовали, мастер туда вмерджить, либо все же создать вспомогательную ветку, в которой всё решено — а ее уже выкатывать на ревью.


      1. klirichek Автор
        22.09.2018 20:47

        Это да, но когда есть чатик, всегда можно и лично спросить.
        "Я мержу твой коммит, получилось 12 промежуточных слияний, и они все элементарны. Ты собрался туда же присаживать свою локальную ветку (поверх). Тебе оставить эту всю историю, или можно засквашить в единственный мерж-коммит"?
        И в ответ — "да не, у меня чисто локальные изменения, давай в единственный". Ок.
        Это конструктив, так захотелось. Задача решена. Но...




        Однако надо соображать. Пройдёт несколько лет. (хорошо, если мы будем в то время в том же коде шариться тем же коллективом. Это упростит хоть некоторые моменты. Но останется один, который станет серьёзной попоболью:
        Вот невнятный код (ну, чисто гипотетически — вдруг вот тут сортируется "пузырьком" гигабайтный массив байтов). Откуда он взялся? Зачем? Кто написал вот эти 10 строчек и в каком коммите? (ну т.е. либо там решена серьёзная проблема, почему стало именно так, или я сейчас всё поменяю, чтоб стало шустрее, и вдруг это разбудит древнего монстра… Как последняя "громыхнувшая" бага в ssl). И вот роешь, и тут гилт говорит, ага, "merged branch 'master' into feature". И всё! Тут как раз и начнётся всё самое интересное…
        Так что тут даже не совсем "ревью", а сам же через несколько лет вляпаешься, и будешь ревьювить....


        1. Lissov
          22.09.2018 23:12

          «merged branch 'master' into feature». И всё!

          В таком случае как раз можно посмотреть историю самого master. Если там пузырёк есть — то возможны монстры и т.д., а если появились только в «merged ...» то наверняка ошибка мержа и можно спокойно править.
          Обычно если squash то описание будет нормальное (не «merge»), и вот тогда сложно понять это был мерж такой или разработчик так и хотел.
          Или я неправильно понял что Вы имеете в виду?


  1. Lissov
    22.09.2018 23:14
    +1

    Из востребованных хитростей которые недавно пришлось применять — разделение (весьма тривиально) и слияние (это уже посложнее) репозиториев с сохранением истории. Если интересно могу описать.