"Зміст цієї статті народився з цієї хабр статті та роздумів про найпростіший шлях синхронізації субмодулів у деяких моїх проєктах.

Що то є за звір такий - GIT-дерево

Усім добре відомо, що кожен git-комміт має хеш. Наприклад 3eda1bf626755efa6f683af88b91337daf304862. Маючи хеш ми можемо побачити як будується коміт:

$ git cat-file -p 5e45ecb
tree 8640790949c12690fc71f9abadd7b57ec0539376
parent 930741d1f5fd2a78258aa1999bb4be897ba3d015
author Mark Tareshawty <tareby...@github.com> 1542718283 -0500
committer Mark Tareshawty <tareby...@github.com> 1542718283 -0500
gpgsig -----BEGIN PGP SIGNATURE-----
...
 -----END PGP SIGNATURE-----

Fix scoping so that we don't break the naming convention

Це і є весь коміт

  • tree - посилання на конкретний стан проєкту
  • parent - посилання на батьківський коміт (можливий варіант з декільками батьками. Наприклад при мержі. Тоді головним вважається перший)
  • author - автор + дата
  • committer - той, хто робив коміт + дата
  • gpgsig - пидпис, якщо є
  • message - текст комментара

Кожен коміт описує конкретний стан проєкту цілком, а те, що ми бачимо як зміни, що вносяться ним, - це насправді динамічно обчислювальна різниця, як порівняти з попереднім станом. Також варто зазначити, що всі коміти імутабельні (незмінні), тобто, наприклад, під час rebase/cherry-pick/amend створюються абсолютно нові коміти.

tree (дерево) - по суті це просто папка з конкретним імутабельним вмістом. Об'єкт типу tree містить поіменний список файлів із конкретним вмістом (blobs) і підпапок (trees). А дерево, на яке вказує кожен коміт - це коренева папка проєкту, точніше її певний стан.

Побачити як будується дерево можна за допомогою тієї ж cat-file, але з хешем дерава.

$ git cat-file 8640790

------
100644 blob 7ab08294a46f158c51460be3e7df6a190e15023b    .env.example
100644 blob 0a1a4d1ad9ff3f35b67678ca893811e91b423af5    .gemset
040000 tree 033aa38ce0eab11fe229067c14ccce95e2b8b601    .github
100644 blob ca49bb7ffa6273b0be4ce7ba1accba456032fb11    .gitignore
100644 blob c99d2e7396e14ac072c63ec8419d9b8fede28d86    .rspec
100644 blob 65e77a2f59f635a8f24eb4714e8e43745c5c0eb9    .rubocop.yml
....

Коміт - це...

Тепер зрозуміло, що насправді відбувається, коли виконується коміт

  • стан проєкту staging (зберігається як новий об'єкт tree і береться його хеш)
  • посилання на поточний (батьківський) коміт
  • автора + комітера + дві дати
  • текст коміту

Це все зберігається, хешується, і виходить новий об'єкт commit. І команда автоматично піднімає на нього покажчик поточної гілки.

commit-tree

І нарешті ми можемо перейти до опису потрібної нам команди git commit-tree. Формально це одна з низькорівневих команд, тому вона рідко згадується і використовується. Ми не будемо розглядати пов'язані з нею інші низькорівневі команди (такі як git write-tree, git-update-index, вони також відомі як plumbing commands). Нам цікавий тільки один окремий наслідок: за допомогою цієї команди ми можемо легко скопіювати (перевикористати) дерево стану проєкту з будь-якого іншого коміту.

Подивимося на її виклик

$ git commit-tree 4c835c2 -m ""Fixed bug"" -p a8fc5e3

d9aded78bf57ca906322e26883644f5f36cfdca5

Команда git commit-tree теж створює коміт, але низькорівневим способом. Тут необхідно явно вказати вже наявне дерево (tree) 4c835c2 і посилання на батьківський коміт a8fc5e3. І вона повертає хеш нового коміту d9aded7, а положення гілки не змінює (тому цей коміт ніби зависає в повітрі).

По суті - це вся теорія.

Приклади використання

Синхронізація з іншою гілкою

Синхронізувати стан проєкту на гілці alpha з гілкою beta. Тобто потрібно створити на гілці alpha такий новий коміт, щоб стан проєкту став точно таким же, як на гілці beta.

Знаходимо хеш дерева

$ git cat-file -p 280c30f

tree 3c1afe75f54518dbd82ea7a4e3c4ff50389a573a           <---
parent 560b449675513bc8f8f4d6cda56a922d4e36917a
author Baur <atanov...@gmail.com> 1540619512 +0600
committer Baur <atanov...@gmail.com> 1540619512 +0600

Тепер будуємо коміт, що вказує на це дерево

$ git commit-tree 3c1afe7 -m ""Synced with branch 'beta'"" -p HEAD

eb804d403d4ec0dbeee36aa09da706052a7cc687

Kоміт створено, команда вивела його хеш. У нього таке ж дерево, як у коміту на гілці origin/beta. І оскільки цей коміт є прямим нащадком поточної гілки. Причому це значення буде завжди унікальним, адже воно обчислюється не тільки від дерева, а й від автора і часу. Сам коміт завис у повітрі, оскільки поки що він не входить до жодної з гілок. Щоб включити його до гілки, досить просто зробити fast-forward merge

$ git merge --ff xxxxxxx

Updating 64fafc7..xxxxxxx
Fast-forward
 Azure.txt |  3 ---
 Bill.txt  |  6 +-----
 Linus.txt | 15 +++++++++++++++
 3 files changed, 16 insertions(+), 8 deletions(-)

Тепер стан проєкту на гілці alpha точно такий самий, як на гілці beta. А якщо подивитись на те, що ж усе-таки змінив цей коміт, ми побачимо: він ревертнув усі власні коміти (зміни) гілки alpha та додав усі унікальні коміти (зміни) гілки beta, відносно їхнього спільного коміту-попередника.

Порівняння двох гілок

З першого прикладу видно, що створений коміт показує всі ті зміни, які є фактичною різницею між цими двома гілками. Ця його властивість дозволяє легко порівняти одну гілку з іншою. Достатньо створити третю тимчасову гілку і в ній зробити подібний коміт.

Створимо на поточному коміті гілку temp і встанемо на неї

$ git checkout -b temp

І нам залишилося зробити те саме, що і в попередньому прикладі. Але цього разу ми вкладемося в один рядок. Для цього ми скористаємося спеціальним синтаксисом для звернення до дерева коміту origin/beta^{tree} або що те ж саме 280c30f^{tree}.

$ merge --ff $(git commit-tree origin/beta^{tree} -m ""Diff with branch 'beta'"" -p HEAD)

По суті ми матеріалізували у вигляді комміта дифф між двома гілками! Зрозуміло, такий ""порівняльний"" коміт ми можемо створити взагалі для будь-яких двох комітів (станів) у репозиторії.

Revert декількох комітів

Треба відкотити кілька останніх комітів.

Припустимо, що нам потрібно відкотити два останні коміти на гілці alpha. Є два класичні способи це зробити:

  • Два рази виконати git revert - на кожен коміт
  • git reset, тобто скидання положення гілки

Але можна це зробити і третім способом:

$ git merge --ff $(git commit-tree 7a714bf^{tree} -m ""Reverted to commit 7a714bf"" -p HEAD)

Це додасть один новий коміт, який ніби відкочує зміни двох попередніх комітів. На відміну від першого способу, створюється всього один коміт, навіть якщо треба відкотити десять останніх комітів. А відмінність способу з git reset полягає в тому, що ми не викидаємо ці коміти з самої гілки.

Далі, якщо потім потрібно повернути початковий стан гілки, то це можна зробити аналогічно

$ git merge --ff $(git commit-tree 64fafc7^{tree} -m ""Reverted back to commit 64fafc7"" -p HEAD)

При цьому в історії гілки залишаться ці два коміти, за якими буде видно, що її відкочували і повертали назад.

Цікавий варіант, якщо потрібно зробити реверт лише частково. Тоді алгоритм буде такий самий, але з додаванням ще 2-х кроків.

  • git reset HEAD~1 тобто після повного реверту ми повертаємося на крок назад.

  • І тепер достатньо застейджити потрібні файли і зробити коміт, а інші зміни можна відкинути.

      $ git add Bill.txt
    $ git commit -m ""Reverted file Bill.txt to 7a714bf""
    $ git reset --hard HEAD

Штучний merge

І тепер та сама команда, але вже з двома батьківськими комітами

$ git merge --ff $(git commit-tree alpha^{tree} -m ""Merge 'alpha' into 'master', but take 'alpha' tree"" -p HEAD -p alpha)

ALIAS

За допомогою наступного git аліасу копіювання дерева стає дуже простою командою. Створюємо аліас у конфігурації поточного репозиторію.

$ git config alias.copy '!git merge --ff $(git commit-tree ${1}^{tree} -p HEAD -m ""Tree copy from ${1}"")''

І тепер достатньо виконати git copy xxx, де xxx - будь-яке посилання на коміт.

$ git copy xxx

Це буде те ж саме що і

$ git merge --ff $(git commit-tree xxx^{tree} -p HEAD -m ""Tree copy from xxx"")

Приклади

$ git copy a8fc5e3
$ git copy origin/beta
$ git copy HEAD~3

виправити стандартний текст створеного коміту на довільний можна через git amend.

$ git commit --amend -m ""Just for test""

Або запустивши редактор:

$ git commit --amend

Висновки

Це справді неперевешений інструмент для тонкої роботи з git. Я дякую авторові з хабра, що розповів про такі команди. Дякую всім, хто дочитав цю статтю до завершення. Користуйтесь з насолодою.