В этой статье я расскажу об одном полезном, но малоизвестном приеме работы с git — как можно легко создать коммит, используя дерево из другого коммита. Проще говоря, как получить нужное состояние проекта на какой-либо ветке, если это состояние уже когда-то и где-то было в репозитории раньше. Будет приведено несколько примеров того, как это позволяет элегантно решать некоторые практические задачи. И в частности я расскажу о найденном мной методе, который позволяет значительно упростить исправление множественных конфликтов при rebase. Кроме того, эта статья — отличный способ понять на практике, что из себя представляет коммит в git-е. Теоретическая часть. Про коммиты и деревья
git commit-tree
Практическая часть
1. Синхронизация с другой веткой
2. Сравнение двух веток
3. Реверт ветки
4. Частичный реверт
5. Искуственный merge
6a. Метод rebase через merge — описание
6b. Метод rebase через merge — скрипт
7. Алиас
Заключение Коммит, наверное, самое основное понятие в git-е, давайте посмотрим из чего он состоит. Каждый коммит имеет свой уникальный идентификатор в виде хэша, например 5e45ecb. И с помощью следующей команды, зная хэш, мы можем посмотреть его содержимое. Эти несколько строчек и есть всё содержимое коммита: Напомню, что коммит в git-е (в отличие от других VCS) не описывает сделанные изменения. Тут все наоборот: каждый коммит описывает конкретное состояние проекта целиком, а то что мы видим как вносимые им изменения — это на самом деле динамически вычисляемая разница в сравнении с предыдущим состоянием. Также стоит отметить, что все коммиты иммутабельны (неизменяемы), то есть, например, при rebase/cherry-pick/amend создаются абсолютно новые коммиты. tree (дерево) — по сути это просто папка с конкретным иммутабельным содержимым. Объект типа tree содержит поименный список файлов с конкретным содержимым (blobs) и подпапок (trees). А дерево, на которое указыват каждый коммит — это корневая папка проекта, точнее ее определенное состояние. Сделать это можно точно так же и для любого другого объекта (commit / tree / blob), при этом достаточно взять первые несколько уникальных симоволов хэша: 8640790949c12690fc71f9abadd7b57ec0539376 -> 8640790. На самом деле это важнейшая особенность того, как работает git, идентификатор коммита — это действительно хэш от его содержимого. Точно так же как и хэши вложенных в него объектов (trees, blobs).  Теперь посмотрим что происходит когда мы делаем Эта команда создает новый коммит который фиксирует в себе следующее: Это все сохраяется, хэшируется, и получается новый объект commit. И команда автоматически поднимает на него указатель текущей ветки. Как мы уже знаем, tree — это объект, который содержит состояние проекта целиком на какой-то определенный момент в прошлом — когда был создан коммит с этим деревом. Рабочую папку называют working tree / working copy / working directory, что вполне логично. Также у нас есть — staging area / index — область подготовленных изменений. Но логически это тоже дерево, точнее то состояние, которое сохраняется при коммите как дерево. Поэтому, мне кажется, более логичным это было бы называть staged tree. И наконец мы можем перейти к описанию нужной нам команды git commit-tree. Формально это одна из низкоуровневых команд, поэтому она редко упоминается и используется. Мы не будем рассматривать связанные с ней прочие низкоуровневые команды (такие как git write-tree, git-update-index, они также известны как plumbing commands). Нам интересно только одно частное следствие: с помощью этой команды мы можем легко скопировать (переиспользовать) дерево состояния проекта из любого другого коммита. Посмотрим на ее вызов Команда git commit-tree тоже создает коммит, но низкоуровневым способом. Здесь необходимо явно указать уже существующее дерево (tree) 4c835c2 и ссылку на родительский коммит a8fc5e3. И она возвращает хэш нового коммита d9aded7, а положение ветки не меняет (поэтому этот коммит как бы зависает в воздухе). Примеры использования этой команды демонстрируются на следующем простом репозитории. Он содержит три ветки:  master — основная ветка
alpha — ветка, на которой мы работаем и находимся
beta — ветка, которая уже была ранее смерджена в master Все действия легко повторить локально, для этого достаточно клонировать репозиторий, встать на ветку alpha и далее исполнять команды из примеров. Это исходное состояние — общее для всех примеров. Все команды, включая скрипт, работают и под windows. Только нужно открыть bash терминал в папке проекта, например, так Задача:
Синхронизировать состояние проекта на ветке alpha с веткой beta. То есть нужно создать на ветке alpha такой новый коммит, чтобы состояние проекта стало точно таким же, как на ветке beta. Конкретно такая задача, наверное, вряд ли когда-нибудь возникнет, но это самый подходящий кейс, чтобы продемонстрировать подход. Самое простое решение заключается в том, чтобы взять существующее дерево, на которое указывает ветка beta, и просто указать на него из нового коммита для ветки alpha. Так как это первый пример, вся его логика рассмотрена достаточно подробно. Для начала найдем хэш коммита, на который указывает ветка beta: 280c30f — достаточно взять несколько первых символов Теперь найдем хэш его дерева, отобразив содержимое коммита через git cat-file: 3c1afe7 — это и есть нужное нам дерево И теперь создадим коммит, указывающий на это дерево, а родительским коммитом укажем текущий коммит: Всё, коммит создан, команда вывела его хэш. Причем это значение будет всегда уникально, ведь оно вычисляется не только от дерева, но и от автора и времени. Сам коммит завис в воздухе, так как пока он не входит ни в одну из веток. Нам достаточно взять несколько первых символов: eb804d4, это уникальное для каждого случая значение я обозначу как xxxxxxx. Давайте посмотрим на его содержимое: Отлично, у него такое же дерево, как у коммита на ветке origin/beta. И так как этот коммит является прямым потомком текущей ветки, то чтобы включить его в ветку, достаточно просто сделать fast-forward merge Готово. Теперь состояние проекта на ветке alpha в точности такое же, как на ветке beta.  А если посмотреть на то, что же все-таки изменил этот коммит, мы увидим: он ревертнул все собственные коммиты (изменения) ветки alpha и добавил все уникальные коммиты (изменения) ветки beta, относительно их общего коммита-предка. Задача:
Сравнить ветку alpha с веткой beta. Из первого примера видно, что созданный коммит показывает все те изменения, которые являются фактической разницей между этими двумя ветками. Это его свойство позволяет легко сравнить одну ветку с другой. Достаточно создать третью временную ветку и в ней сделать подобный коммит. Итак, сначала вернем ветку alpha к исходному состоянию Создадим на текущем коммите ветку temp и встанем на нее И нам осталость сделать то же, что и в предыдущем примере. Но в этот раз мы уложимся в одну строчку. Для этого мы воспользуемся специальным синтаксисом для обращения к дереву коммита origin/beta^{tree} или что то же самое 280c30f^{tree}.  Готово, по сути мы материализовали в виде коммита дифф между двумя ветками Разумеется, такой "сравнительный" коммит мы можем создать вообще для любых двух коммитов (состояний) в репозитории. Задача:
Откатить несколько последних коммитов. Вернемся на ветку alpha и удалим ветку temp Предположим, что нам нужно откатить два последних коммита на ветке alpha. Есть два классических способа это сделать: Но можно это сделать и третим способом: Это добавит один новый коммит, который как бы откатывает изменения двух предыдущих коммитов. В отличие от первого способа, создается всего один коммит, даже если надо откатить десять последних коммитов. А отличие способа с git reset заключается в том, что мы не выкидываем эти коммиты из самой ветки. Далее, если потом нужно вернуть исходное состояние ветки, то это можно сделать аналогично При этом в истории ветки останутся эти два коммита, по которым будет видно, что ее откатывали и возвращали назад. Задача:
Откатить изменения в некоторых файлах за несколько последних коммитов. Снова вернем ветку alpha к исходному состоянию  Ветка alpha содержит 3 коммита, в каждом из которых вносятся изменения в файле Bill.txt, в последнем коммите также добавляется файл Azure.txt. Предположим, что нам нужно откатить изменения в файле Bill.txt за последние 2 коммита, при этом не трогая какие-либо другие файлы. Для начала откатим все файлы на 2 коммита назад Далее вернем ветку на предыдущий коммит, но не трогая состояние проекта на диске И теперь достаточно застейджить нужные файлы и сделать коммит, а прочие изменения можно отбросить. Задача:
Смерджить одну ветку в другую, получив заранее предопределенный результат. Представим такую ситуацию. В продакшене обнаружен критический баг и его как обычно нужно срочно починить. Однако при этом не ясно сколько времени уйдет на то, чтобы его изучить и сделать корректный хотфикс, поэтому был быстро сделан временный хотфикс, который изолировал взаимодействие с проблемным модулем. Итак, в ветке master появляется коммит с этой временной заплаткой, а спустя какое-то время вместо нее в master нужно смерджить уже полноценный хотфикс. Таким образом, нам нужно сделать merge ветки alpha в master, но это должен быть не традиционный мердж, когда все уникальные изменения из ветки alpha добавляются в master сверху и возникает конфликт, а мы должны полностью перезаписать master веткой alpha. Для начала вспомним вообще, что такое merge commit — на самом деле это тот же самый обычный коммит, но просто у него два родительских коммита, это хорошо видно, если посмотреть на его содержимое (то, как формируется его дерево — это уже отдельный вопрос). А кто в кого смерджен определяется просто — первый parent коммит считается главным. Сбросим к исходному состоянию текущую ветку alpha и переключимся на master И теперь та же команда, но уже с двумя родительскими коммитами Готово, мы смерджили ветку alpha в master, и нам не пришлось удалять временный код и решать конфликты, так как в данном случае нужно было просто перезаписать последние изменения. На самом деле это не так уж и странно — создавать merge с деревом, которое копируется из другого коммита. Такие ситуации могут возникнуть, потому что git это очень гибкий инструмент, который позволяет реализовать самые разные подходы для работы с ветками и репозиториями. Но все же самый банальный пример был в нашем репозитории с самого начала — попробуйте его разобрать самостоятельно или откройте спойлер, чтобы прочитать объяснение. Обратим внимание на то, как ветка beta была смерджена обратно в master. За то время, когда в ней появилось два коммита, в самой ветке master — новых коммитов не было. Это значит, что при мердже beta в master исключены какие-либо конфликты из-за одновременных изменений.  Если бы мы сделали git merge beta — то произошел бы fast-forward merge (поведение по умолчанию), то есть ветка master просто встала бы на том же коммите, где находится ветка beta, и никакого мердж коммита не было бы. Было бы вот так: Но тут был сделан no-fast-forward merge с помощью команды git merge beta --no-ff. То есть мы форсировали создание мердж коммита, хотя он был и не обязателен. И так как нужное конечное состояние проекта для будущего мерджа было известно — это дерево ветки beta, то git просто скопировал ссылку на это дерево в новый коммит: Задача:
Нужно сделать "тяжелый" rebase ветки (множество конфликтов на разных коммитах). Есть такая классическая холиварная тема в git — rebase vs merge. Но холиварить я не буду. Наоборот, я расскажу о том, как их можно подружить в контексте указанной задачи. Вообще, git был специально спроектирован так, чтобы мы могли эффективно делать merge. И когда я пришел на проект, где воркфлоу основан на rebase, мне первое время было неудобно и непривычно, пока я не разработал техники, которые упростили мою каждодневную работу с git. Одна из них это мой оригинальный метод для осуществления тяжелого rebase-а.  Итак, нам нужно сделать rebase ветки alpha на develop, чтобы потом смерджить ее так же красиво, как была смерджена ветка beta. Если мы как обычно начнем делать rebase, первый и последний коммит породят два разных конфликта в двух разных местах. Но если бы мы вместо rebase просто делали merge, то был бы всего один конфликт в одном месте в одном мердже коммите.  Если хочется убедиться в этом, предлагаю готовые команды под спойлером. Возвращаем ветки в исходное состояние Создадим и встанем на ветку alpha-rebase-conflicts и сделаем ребейз на master Будут конфликты на разных коммитах, включая "фантомный" конфликт. А теперь попробуем мердж, вернемся на ветку alpha и удалим ветку для ребейза. Переключимся на master и сделаем мердж Будет всего один простой конфликт, исправляем его и делаем Мердж успешно завершен. git-конфликты это естественная часть нашей жизни, и этот простой пример показывает, что merge в этом отношении однозначно удобнее rebase. В реальном проекте эта разница измеряется гораздо большим объемом потраченного времени и нервов. Поэтому, например, существует неоднозначная рекомендация сквошить перед мерджем все коммиты фича-ветки в один коммит. Идея этого метода заключается в том, чтобы сделать временный скрытый merge, в котором мы за один раз разрешим все конфликты. Запомнить результат (tree). Далее запустить обычный rebase, но с опцией "автоматически разрешать конфликты, выбирая наши изменения". И в конце добавить на ветку один дополнительный коммит, который восстановит корректное дерево. Приступим. Снова вернем обе ветки в исходное состояние. Cоздадим временную ветку temp, в которой сделаем merge. Конфликт. Резолвим один простой конфликт в файле Bill.txt как обычно (в любом редакторе).
Обратим внимание, что тут всего один конфликт, а не два, как при rebase. Возвращаемся на ветку alpha, делаем rebase с автоматическим разрешением всех конфликтов в нашу пользу, и приводим к состоянию ветки temp, а саму ветку temp удаляем. И наконец красиво мерджим alpha в master. Отметим, что master, alpha и удаленная ветка temp — все три указывают на одно и то же дерево, хотя это три разных коммита. Минусы данного метода: Плюсы: Скрипт опубликован здесь: https://github.com/capslocky/git-rebase-via-merge Проверим его работу на нашем примере. Снова вернем обе ветки в исходное состояние Скачиваем скрипт и делаем его исполняемым Файл появится тут: C:\Users\user-name\git-rebase-via-merge.sh Меняем ветку по умолчанию, на которую нужно делать ребейз, в нашем случае нужен origin/master Давайте также создадим и встанем на временную ветку, чтобы не трогать саму ветку alpha И теперь можно запустить скрипт (вместо традиционного git rebase origin/master) Базовую ветку также можно указать явно Также стоит упомянуть, что ours / theirs в конфликтах становятся более интуитивными: ours — это наши изменения на текущей ветке (HEAD)
theirs — это изменения из другой ветки (например, origin/develop) В то время как при rebase — у них противоположное значение. С помощью следующего git алиаса копирование дерева становится очень простой командой. Создаем алиас в конфиге текущего репозитория. И теперь достаточно выполнить git copy xxx, где xxx — любая ссылка на коммит. Это будет то же самое что и Примеры А исправить стандартный текст созданного коммита на произвольный можно через git amend. Или запустив редактор: По правде говоря, из описанного я сам регулярно пользуюсь только данным методом для ребейза, очень хорошо помогает на текущем проекте. Иногда делаю сравнение веток, тегов и коммитов. Остальное — единичные случаи. Для большинства приведенные примеры, наверное, не будут актуальны. Тем не менее знать про коммиты и деревья будет всегда полезно. А приемчики "как украсть дерево" или "сделать ребейз без боли" могут однажды пригодится. Если есть идеи зачем еще можно красть деревья (в частности, в практиках devops), пишите, будет интересно обсудить. Software Engineer