Git
Chapters ▾ 2nd Edition

7.6 Інструменти Git - Переписування історії

Переписування історії

Часто, працюючи з Git, хочеться переробити локальну історію комітів. Одна з чудових рис Git — він дозволяє робити рішення якнайпізніше. Ви можете вирішити, які файли до якого коміту потраплять, доки не зробите коміт з області індексування, можете вирішити, що ви не збираєтесь поки що працювати над чимось за допомогою git stash, та можете переписувати вже збережені коміти, щоб вони виглядали так, нібито вони були збережені іншим чином. Це може включати зміну порядку комітів, зміну повідомлень чи редагування файлів в коміті, зварювання (squash) комітів разом або розбивання комітів на частини, або повне вилучення комітів — все доки ви надасте доступ до вашої роботи іншим.

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

Note

Оскільки так багато всього відбувається у вашому локальному клоні, у вас є широка свобода переписувати історію локально — це один з принципів Git. Втім, щойно ви надіслали кудись свою працю, усе різко змінюється: вам варто розглядати надіслану працю як остаточну, хіба що у вас є серйозні причини щось змінювати. Тобто варто не надсилати свої коміти, доки вам у них щось нt до вподоби, і ви не готові показати їх решті світу.

Зміна останнього коміту

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

Якщо ви бажаєте лише виправити повідомлення останнього коміту, то це зробити дуже просто:

$ git commit --amend

Ця команда запустить текстовий редактор, в якому вже буде повідомлення останнього коміту. Ви можете його змінити, зберегти та вийти. Коли ви збережете файл та закриєте редактор, Git створить новий коміт з цим оновленим повідомленням та зробить його вашим новим останнім комітом.

Якщо ж ви хочете змінити _вміст останнього коміту, процес майже не відрізняється: спочатку зробіть потрібні зміни, а тоді команда git commit --amend замінює останній коміт на новий і поліпшений коміт.

Треба бути обережним з цим засобом, адже ці покращення змінюють SHA-1 коміту. Це як дуже маленьке перебазування (rebase) — не змінюйте свій останній коміт, якщо ви його вже кудись виклали.

Tip
Коли ви виправляєте коміт, можливо, варто оновити повідомлення коміту

Коли ви виправляєте (amend) коміт, у вас є можливість змінити і повідомлення, і вміст коміту. Якщо ви сильно змінюєте вміст коміту, то майже безсумнівно варто оновити й повідомлення коміту, щоб воно відповідало виправленому вмісту.

З іншого боку, якщо виправлення достатньо незначні (наприклад, виправлення дурного одруку, чи додавання забутого файлу), і з попереднім повідомленням коміту все гаразд, ви можете просто зробити потрібні зміни, проіндексувати їх, і уникнути зайвої сесії в текстовому редакторі за допомогою:

$ git commit --amend --no-edit

Зміна декількох повідомлень комітів

Щоб змінити коміт, що записаний раніше в історії, необхідно використати складніші інструменти. Git не має окремого інструменту зміни-історію, проте можна використати утиліту перебазування (rebase) щоб перебазувати послідовність комітів на HEAD, на якому вони і так базувались, замість переміщення на іншу базу. За допомогою інтерактивного перебазування можна зупинитись після коміту, який ви бажаєте змінити, та змінити повідомлення, додати файли - що забажаєте. Інтерактивне перебазування можна запустити за допомогою опції -i команди git rebase. Необхідно зазначити як далеко назад ви бажаєте переписувати коміти, для чого треба сказати команді, на який коміт перебазуватися.

Наприклад, якщо ви бажаєте змінити останні три коміти, або будь-які повідомлення комітів у цій групі, то треба задати як аргумент git rebase -i батька останнього коміту, який ви бажаєте редагувати, тобто HEAD~2^ або HEAD~3. Можливо легше запам’ятати ~3, адже це дозволяє редагувати три останні коміти. Проте пам’ятайте, що насправді цей запис означає чотири коміти тому, батька останнього коміту, якого ви бажаєте редагувати:

$ git rebase -i HEAD~3

Не забувайте також, що це команда перебазування — кожен коміт в інтервалі HEAD~3..HEAD буде переписаний, зміните ви його повідомлення чи ні. Не включайте жодного коміту, який ви вже відправили до центрального серверу — інакше ви заплутаєте інших розробників тим, що надасте альтернативну версію тих самих змін.

Виконання цієї команди відкриває ваш текстовий редактор зі списком комітів, що виглядає приблизно так:

pick f7f3f6d changed my name a bit
pick 310154e updated README formatting and added blame
pick a5f4a0d added cat-file

# Rebase 710f0f8..a5f4a0d onto 710f0f8
#
# Commands:
#  p, pick = use commit (використати коміт)
#  r, reword = use commit, but edit the commit message (використати коміт, проте редагувати його повідомлення)
#  e, edit = use commit, but stop for amending (використати коміт, але зупинитись для покращення)
#  s, squash = use commit, but meld into previous commita (використати коміт, проте злити його з попереднім)
#  f, fixup = like "squash", but discard this commit's log message (як "squash", проте ігнорувати повідомлення цього коміту)
#  x, exec = run command (the rest of the line) using shell (виконати команду (решта рядка))
#
# These lines can be re-ordered; they are executed from top to bottom. (Ці рядки можна змінювати місцями, вони будуть виконані згори донизу)
#
# If you remove a line here THAT COMMIT WILL BE LOST. (Якщо вилучити рядок, ЦЕЙ КОМІТ БУДЕ ВТРАЧЕНО)
#
# However, if you remove everything, the rebase will be aborted. (Втім, якщо видалити все, перебазування не відбудеться)
#
# Note that empty commits are commented out (Зауважте, що пусті коміти закоментовані)

Важливо зазначити, що коміти йдуть у зворотному порядку, а не так, як ви звикли їх бачити при використанні команди log. Якщо виконаймо log, то побачимо щось таке:

$ git log --pretty=format:"%h %s" HEAD~3..HEAD
a5f4a0d added cat-file
310154e updated README formatting and added blame
f7f3f6d changed my name a bit

Зауважте зворотний порядок. Інтерактивне перебазування дає вам скрипт, який збирається виконувати. Воно почне з коміту, який ви зазначите в командному рядку (HEAD~3) та відтворить зміни внесені кожним з комітів зміни згори донизу. Воно видає найстаріший нагорі, а не найновіший, адже він є першим, що буде відтворений.

Вам треба відредагувати скрипт щоб він зупинився на коміті, який ви бажаєте редагувати. Для цього змініть слово ‘pick’ (підібрати) на ‘edit’ (редагувати) для кожного з комітів, після яких треба зупинитися. Наприклад, щоб змінити тільки повідомлення третього коміту, можна змінити файл наступним чином:

edit f7f3f6d changed my name a bit
pick 310154e updated README formatting and added blame
pick a5f4a0d added cat-file

Коли ви збережете файл та вийдете з редактора, Git поверне вас до останнього коміту в цьому списку та надасть вам командний рядок з наступним повідомленням:

$ git rebase -i HEAD~3
Stopped at f7f3f6d... changed my name a bit (Зупинився після f7f3f6d...)
You can amend the commit now, with (Тепер ви можете змінити коміт за допомогою)

       git commit --amend

Once you’re satisfied with your changes, run (Щойно ви задоволені своїми змінами, виконайте)

       git rebase --continue

Ця інструкція докладно розповідає, що робити. Наберіть

$ git commit --amend

Змініть повідомлення коміту та вийдіть з редактору. Потім виконайте

$ git rebase --continue

Ця команда застосує решту два коміти автоматично — і все готово. Якщо ви зміните pick на edit ще в якомусь рядку, то можете повторити ці кроки для кожного коміту, для якого набрали edit. Щоразу Git зупинятиметься, дозволить вам змінити коміт, та продовжить, доки ви не завершите перебазування.

Зміна послідовності комітів

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

pick f7f3f6d changed my name a bit
pick 310154e updated README formatting and added blame
pick a5f4a0d added cat-file

на такий:

pick 310154e updated README formatting and added blame
pick f7f3f6d changed my name a bit

Коли ви збережете файл та вийдете з редактора, Git поверне гілку до батька цих комітів, застосує 310154e, потім f7f3f6d та зупиниться. Таким чином ви змінили послідовність цих комітів та повністю вилучили коміт “added cat-file”.

Зварювання комітів

Також можливо взяти декілька комітів та зварити їх в один коміт за допомогою інтерактивного перебазування. Скрипт має корисні інструкції в повідомленні перебазування:

#
# Commands:
#  p, pick = use commit (використати коміт)
#  r, reword = use commit, but edit the commit message (використати коміт, проте редагувати його повідомлення)
#  e, edit = use commit, but stop for amending (використати коміт, але зупинитись для покращення)
#  s, squash = use commit, but meld into previous commita (використати коміт, проте злити його з попереднім)
#  f, fixup = like "squash", but discard this commit's log message (як "squash", проте ігнорувати повідомлення цього коміту)
#  x, exec = run command (the rest of the line) using shell (виконати команду (решта рядка))
#
# These lines can be re-ordered; they are executed from top to bottom. (Ці рядки можна змінювати місцями, вони будуть виконані згори донизу)
#
# If you remove a line here THAT COMMIT WILL BE LOST. (Якщо вилучити рядок, ЦЕЙ КОМІТ БУДЕ ВТРАЧЕНО)
#
# However, if you remove everything, the rebase will be aborted. (Втім, якщо видалити все, перебазування не відбудеться)
#
# Note that empty commits are commented out (Зауважте, що пусті коміти закоментовані)

Якщо замість “pick” або “edit” ви задасте “squash”, Git застосує і цю зміну, і зміну безпосередньо до неї, до того як дозволити вам злити повідомлення комітів разом. Отже, якщо ви бажаєте зробити один коміт з цих трьох комітів, то треба зробити скрипт таким:

pick f7f3f6d changed my name a bit
squash 310154e updated README formatting and added blame
squash a5f4a0d added cat-file

Коли ви збережете файл та вийдете з редактору, Git застосує всі три зміни та поверне вас у редактор для злиття трьох повідомлень комітів:

# This is a combination of 3 commits. (Це комбінація 3 комітів)
# The first commit's message is: (Повідомлення першого коміту)
changed my name a bit

# This is the 2nd commit message: (Повідомлення другого коміту)

updated README formatting and added blame

# This is the 3rd commit message: (Повідомлення третього коміту)

added cat-file

Коли ви збережете цей файл, отримаєте єдиний коміт, що вносить зміни всіх трьох попередніх комітів.

Розщеплення коміту

Розщеплення коміту скасовує коміт, а потім частково індексує та зберігає коміти стільки разів, скільки ви бажаєте створити комітів. Наприклад, припустімо, що ви бажаєте розщепити середній з трьох комітів. Замість “updated README formatting and added blame” треба зробити два коміти: перший — “updated README formatting”, та другий — “added blame”. Це можна зробити в скрипті rebase -i, якщо змінити інструкцію біля коміту, котрий треба розщепити, на “edit”:

pick f7f3f6d changed my name a bit
edit 310154e updated README formatting and added blame
pick a5f4a0d added cat-file

Потім, коли скрипт повернеться до командного рядка, треба скинути (reset) цей коміт, взяти скинуті зміни, та створити з них декілька комітів. Коли ви збережете файл та вийдете з редактору, Git повернеться до батька першого коміту зі списку, застосує перший коміт (f7f3f6d), застосує другий (310154e) та поверне вас до консолі. Там ви можете зробити скидання коміту за допомогою команди git reset HEAD^, яка фактично скасовує коміт та залишає змінені файли не індексованими. Тепер ви можете індексувати файли та робити коміти, доки не отримаєте декілька комітів, а потім виконати git rebase --continue щойно все готово:

$ git reset HEAD^
$ git add README
$ git commit -m 'updated README formatting'
$ git add lib/simplegit.rb
$ git commit -m 'added blame'
$ git rebase --continue

Git застосує останній коміт (a5f4a0d) зі скрипту, і історія виглядатиме так:

$ git log -4 --pretty=format:"%h %s"
1c002dd added cat-file
9b29157 added blame
35cfb2b updated README formatting
f3cc40e changed my name a bit

Нагадуємо, що це змінить SHA-1 кожного з комітів вашого списку, отже треба бути впевненим, що жоден з комітів списку ще не був відправлений до спільного сховища.

Ядерний варіант: filter-branch

Є ще одна опція переписування історії, яку можна використовувати, якщо треба переписати більшу кількість комітів у якийсь скриптований спосіб — наприклад, змінити вашу поштову адресу глобально, або вилучити файл з усіх комітів. Команда називається filter-branch, і вона може переписати величезні частини історії, отже напевно не варто його використовувати, якщо ваш проект вже є в загальному доступі чи хтось інший працює з комітами, які ви збираєтесь переписати. Втім, вона може бути дуже корисно. Ви дізнаєтесь про декілька найпоширеніших застосувань, щоб отримати загальну ідею про те, що може робити цей інструмент.

Вилучення файла з кожного коміту

Таке трапляється скрізь. Хтось випадково зберігає величезний двійковий файл бездумним git add ., та ви бажаєте видалити його скрізь. Чи може ви випадково зберегли файл, що містить пароль, та бажаєте зробити проект відкритим. filter-branch саме потрібний вам інструмент, якщо ви бажаєте відшкребти щось з усієї історії. Щоб вилучити файл passwords.txt з усієї історії, можна використати опцію --tree-filter команди filter-branch:

$ git filter-branch --tree-filter 'rm -f passwords.txt' HEAD
Rewrite 6b9b3cf04e7c5686a9cb838c3f36a8cb6a0fc2bd (21/21)
Ref 'refs/heads/master' was rewritten

Опція --tree-filter виконує задану команду після кожного коміту проекту, після чого знову робить коміт результатів. У даному випадку, ви вилучаєте файл під назвою passwords.txt з кожного знімку, існував він чи ні. Якщо ви бажаєте вилучити всі випадково збережені файли резервних копій, то можете виконати щось на кшталт git filter-branch --tree-filter 'rm -f *~' HEAD.

Ви зможете спостерігати, як Git переписує дерева та коміти, після чого пересуває вказівник гілки. Зазвичай розумно робити це в тестовій гілці, після чого робити жорстке скидання (hard-reset) вашої гілки master, коли ви переконаєтесь, що результат відповідає запланованому. Щоб виконати filter-branch на всіх гілках, передайте команді --all.

Робимо піддиректорію новим коренем

Припустімо, що ви зробити імпорт з іншої системи контролю версій та маєте піддиректорії, що не мають сенсу (trunk, tags тощо). Якщо ви бажаєте зробити піддиректорію trunk новим коренем проекту для кожного коміту, то вам теж потрібен filter-branch:

$ git filter-branch --subdirectory-filter trunk HEAD
Rewrite 856f0bf61e41a27326cdae8f09fe708d679f596f (12/12)
Ref 'refs/heads/master' was rewritten

Тепер коренем вашого проекту стало те, що було в піддиректорії trunk для кожного коміту. Git автоматично вилучить коміти, що не впливали на піддиректорію.

Глобальна зміна поштової адреси

Ще один поширений випадок: ви могли забути виконати git config щоб задати своє ім’я та поштову адресу до початку роботи, або можливо бажаєте відкрити проект з роботи та змінити вашу робочу поштову адресу на особисту адресу. У цьому випадку, можна змінити поштову адресу декількох комітів автоматично за допомогою filter-branch. Треба бути обережним, щоб змінити тільки вашу адресу, отже використовуймо --commit-filter:

$ git filter-branch --commit-filter '
        if [ "$GIT_AUTHOR_EMAIL" = "schacon@localhost" ];
        then
                GIT_AUTHOR_NAME="Scott Chacon";
                GIT_AUTHOR_EMAIL="schacon@example.com";
                git commit-tree "$@";
        else
                git commit-tree "$@";
        fi' HEAD

Це проходить та змінює кожен коміт, який має вашу адресу. Оскільки коміти містять значення SHA-1 своїх батьків, ця команда змінює SHA-1 кожного коміту вашої історії, а не лише тіх, що мають збіг поштової адреси.