Git
Chapters ▾ 2nd Edition

7.7 Інструменти Git - Усвідомлення скидання (reset)

Усвідомлення скидання (reset)

До того, як переходити до більш спеціалізованих інструментів, поговорімо про команди Git reset (скинути) та checkout (отримати). Ці дві команди найбільше збивають з пантелику, особливо, коли ви вперше ними користуєтесь. Вони роблять так багато всього, що спроба дійсно зрозуміти їх та використовувати правильно здається безнадійною. Щоб усе ж таки це зробити, ми пропонуємо просту метафору.

Три дерева

Набагато легше зрозуміти reset та checkout, якщо уявити, що Git керує вмістом трьох різних дерев. “Деревом” ми тут називатимемо “колекцію файлів”, а не саме структуру даних. (Є декілька випадків, в яких індекс насправді не поводиться як дерево, проте легше поки що уявляти його так.)

Git як система керує та маніпулює трьома деревами під час нормальної роботи:

Дерево Роль

HEAD

Знімок останнього коміту, наступний батько

Індекс

Пропонований знімок наступного коміту

Робоча Директорія

Пісочниця

HEAD

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

Насправді, доволі легко побачити, як цей знімок виглядає. Ось приклад отримання списку файлів директорії та SHA-1 суми кожного файла в знімку HEAD:

$ git cat-file -p HEAD
tree cfda3bf379e4f8dba8717dee55aab78aef7f4daf
author Scott Chacon  1301511835 -0700
committer Scott Chacon  1301511835 -0700

initial commit

$ git ls-tree -r HEAD
100644 blob a906cb2a4a904a152...   README
100644 blob 8f94139338f9404f2...   Rakefile
040000 tree 99f1a6d12cb4b6f19...   lib

Команди Git cat-file та ls-tree є командами низького рівня, якими не дуже користуються в повсякденній роботі, проте вони корисні при зʼясуванні того, що насправді коїться.

Індекс

Індекс — це пропозиція наступного коміту. Ця концепція Git також має назву “Область Додавання”, адже саме сюди дивиться Git при виконанні git commit.

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

$ git ls-files -s
100644 a906cb2a4a904a152e80877d4088654daad0c859 0	README
100644 8f94139338f9404f26296befa88755fc2598c289 0	Rakefile
100644 47c6340d6459e05787f644c2447d2595f5d3a54b 0	lib/simplegit.rb

Ми знову скористалися git ls-files, яка зазвичай виконується за лаштунками та показує як наразі виглядає індекс.

Технічно, індекс не є деревом — насправді його реалізовано як сплощений маніфест, проте для нас це достатньо близько.

Робоча директорія

Нарешті, є ваша робоча директорія. Інші два дерева зберігають свій вміст у ефективний проте незручний спосіб: усередині теки .git. Робоча Директорія розпаковує їх до реальних файлів, що дозволяє їх редагувати набагато легше. Вважайте Робочу Директорію пісочницею, де ви можете спробувати зміни до того, як додати їх до індексу, а потім до історії.

$ tree
.
├── README
├── Rakefile
└── lib
    └── simplegit.rb

1 directory, 3 files

Робочий процес

Головне призначення Git - записувати знімки вашого проекту в послідовно кращих станах за допомогою маніпулювання цими трьома деревами.

reset workflow

Уявімо собі цей процес: припустімо, ви переходите до нової директорії з єдиним файлом у ній. Ми назвемо цю версію файла v1, та будемо позначати її синім. Тепер виконаємо git init, що створить сховище Git з посиланням HEAD, що вказує на ненароджену гілку (master ще не існує).

reset ex1

Наразі тільки в дереві Робочої Директорії є якийсь вміст.

Тепер ми бажаємо зробити коміт з цим файлом, отже ми використовуємо git add щоб взяти вміст з Робочої Директорії та скопіювати його до Індексу.

reset ex2

Потім виконуємо git commit, що бере вміст Індексу та зберігає його в незмінному знімку, створює обʼєкт коміту, що вказує на цей знімок, та оновлює master, щоб той вказував на цей коміт.

reset ex3

Якщо ми виконаємо git status, то не побачимо ніяких змін, адже всі три дерева однакові.

Тепер ми хочемо зробити зміну в цьому файлі та зберегти їх у коміті. Ми пройдемо той самий процес. Спочатку змінимо файл у робочій директорії. Назвемо цю версію файла v2, та позначимо її червоним.

reset ex4

Якщо ми зараз виконаємо git status, то побачимо файл червоним у “Changes not staged for commit”, адже в цього елемента є різниця між Індексом та Робочою Директорією. Далі виконуємо git add на ньому, щоб додати його до Індекса.

reset ex5

Тепер, якщо ми виконаємо git status, то побачимо файл зеленим під “Changes to be commited”, адже Індекс та HEAD різняться — тобто, наш пропонований наступний коміт зараз відрізняється від останнього коміту. Нарешті, виконуємо git commit щоб завершити коміт.

reset ex6

Тепер git status нічого не виведе, адже всі три дерева знову однакові.

Переключення гілок та клонування проходить за схожим процесом. Коли ви отримуєте (checkout) гілку, Git перенаправляє HEAD до нового посилання гілки, заповнює Індекс знімком того коміту, далі копіює вміст Індексу до Робочої Директорії.

Роль скидання (reset)

Команду reset легше зрозуміти в цьому контексті.

Задля наступних прикладів, скажімо ми змінили файл file.txt знову та зробили третій коміт. Отже тепер наша історія виглядає так:

reset start

Розгляньмо ґрунтовно що саме робить команда reset при виклику. Вона безпосередньо змінює ці три дерева в простий та передбачуваний спосіб. Вона здійснює три базові операції.

Крок 1: перемістити HEAD

Спершу reset перемістить те, на що вказує HEAD. Це не те саме, що змінити сам HEAD (це робить checkout). reset пересуває гілку, на яку вказує HEAD. Це означає, що якщо HEAD вказує на гілку master (тобто гілка master є поточною), то виконання git reset 9e5e6a4 почнеться зі зміни вказівника master так, що він буде вказувати на 9e5e6a4.

reset soft

Байдуже яку форму reset викликано, все одно це перше, що команда спробує зробити. Виклик reset --soft просто зупиниться на цьому.

Тепер подивіться хвильку на зображення та усвідомте, що сталося: суттєво остання команда git commit була скасована. При виконанні git commit, Git створює новий коміт та пересуває до нього гілку, на яку вказує HEAD. Коли ви робите reset назад до HEAD~ (батько HEAD), ви повертаєтесь туди, де були, без зміни Індексу чи Робочої Директорії. Тепер можна оновити Індекс та знову виконати git commit, щоб здійснити те, що можна зробити за допомогою git commit --amend (дивіться Зміна останнього коміту).

Крок 2: оновлення індексу (--mixed)

Зауважте, що при виконанні git status ви побачити зеленим різницю між Індексом та новим HEAD.

Наступне, що зробить reset — оновить Index вмістом знімку, на який тепер вказує HEAD.

reset mixed

Якщо ви використаєте опцію --mixed, то reset на цьому зупиниться. Те саме буде і без опції, отже якщо ви не будете надавати ніяких опцій (просто git reset HEAD~ у даному випадку), тут команда і зупиниться.

Тепер подивіться ще хвильку на це зображення, щоб зрозуміти що сталося: ви все одно скасували останню commit, проте також деіндексували все. Ви відкотили до стану до виконання git add та git commit.

Крок 3: оновлення робочої теки (--hard)

Третє, що робить reset — змушує Робочу Директорію виглядати як Індекс. Якщо ви використаєте опцію --hard, Git виконає і цей крок.

reset hard

Поміркуймо що щойно сталося. Ви скасували останній коміт, команди git add і git commit, і всю працю, яку ви робили у вашій робочій директорії.

Важливо зазначити, що ця опція (--hard) єдиний шлях зробити reset небезпечним, і один з дуже нечисленних випадків, в яких Git може дійсно знищити дані. Будь-який інший виклик reset можна доволі легко скасувати, проте опцію --hard не можна, адже вона насилу переписує файли в Робочій Директорії. У даному окремому випадку, ми досі маємо версію v3 нашого файлу в коміті в нашій базі даних Git, і могли б відновити її за допомогою reflog, проте якби ми не зробили були коміт, Git все одно переписав би файли та ми ніяк не змогли б відновити нашу працю.

Короткий підсумок

Команда reset переписує ці три дерева у заданому порядку, та зупиняється де ви їй скажете:

  1. Пересунути гілку, на яку вказує HEAD (зупинитися тут, якщо --soft)

  2. Зробити так, щоб Індекс виглядав як HEAD (зупинитися тут, якщо не --hard)

  3. Зробити так, щоб Робоча Директорія виглядала як Індекс

Скидання зі шляхом

Ми розглянули поведінку reset у базовому випадку, проте їй також можна передати шлях, що його треба обробити. Якщо задати шлях, reset пропустить крок 1, та обмежить решту своїх дій файлом або декількома файлами. Це доволі розумно — HEAD є просто вказівником та не може вказувати на частину одного коміту та частину іншого. Проте Індекс та Робоча директорія можуть бути частково оновленими, отже скидання йде далі до кроків 2 та 3.

Отже, припустіть, що ми виконали git reset file.txt. Цей формат (адже ви не вказали SHA-1 коміту або гілку, та не задали --soft або --hard) є скороченням git reset --mixed HEAD file.txt, що:

  1. Пересунути гілку, на яку вказує HEAD (пропущено)

  2. Зробити так, щоб Індекс виглядав як HEAD (зупинитися тут)

Отже суттєво просто копіює file.txt з HEAD до Індексу.

reset path1

Це має ефект деіндексації файла. Якщо подивитися на зображення, та згадати, що робить git add, то зрозуміло що вони роблять цілковито протилежні речі.

reset path2

Ось чому вивід команди git status пропонує виконувати цю команду для деіндексації файла. (Докладніше в Вилучання файла з індексу.)

Ми могли б так же легко не давати Git припускати, що ми маємо на увазі “візьми дані з HEAD”, якби б задали окремий коміт, з якого треба брати версію. Ми могли просто виконати щось на кшталт git reset eb43bf file.txt.

reset path3

Ця досягає такого ж ефекту, ніби ми повернули вміст файлу до v1 у Робочій Директорії, виконали на ньому git add, а потім повернули його вміст до v3 знов (тільки без проходження всіх цих кроків). Якщо тепер виконати git commit, буде записано зміну, що повертає файл до стану v1, хоча ми ніколи не мали його в такому стані в Робочій Дирикторії.

Також цікаво знати, що як і команда git add, reset приймає опцію --patch щоб деіндексувати вміст файлу по шматкам. Отже ви можете вибірково деіндексувати або скасовувати вміст.

Зварювання (squashing)

Подивімося на те, як можна зробити щось цікаве цією винайденою можливістю — зварювання комітів.

Припустімо у вас є послідовність комітів з повідомленнями “oops.”, “WIP” та “forgot this file” (забув цей файл). Ви можете використати reset щоб швидко та легко зварити їх в один коміт, що дозволяє вам виглядати дійсно витонченим. (У Зварювання комітів йдеться про інший спосіб це зробити, проте в даному випадку легше скористатися reset.)

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

reset squash r1

Ви можете виконати git reset --soft HEAD~2 щоб перемістити гілку HEAD назад до старшого коміту (найновішого коміту, який ви бажаєте залишити):

reset squash r2

Та просто знову виконати git commit:

reset squash r3

Тепер, як бачите, ваша досяжна історія, історія яку ви будете викладати, тепер виглядає ніби ви зробили були лише коміт з file-a.txt першої версії, потім другий коміт та коміт, що змінив file-a.txt до третьої версії та додав file-b.txt. Коміт з другою версією файла більше не існує в історії.

Отримання (checkout)

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

Без окремих файлів

Виконання git checkout [гілка] дуже схоже на виконання git reset --hard [гілка] в тому, що оновлює всі три дерева до [гілки], проте є дві важливих відмінності.

По-перше, на відміну від reset --hard, checkout безпечна команда щодо робочої директорії. Вона спершу перевіряє, що не зіпсує ніяких файлів, в яких є зміни. Насправді, вона навіть трохи кмітливіша — вона намагається зробити просте злиття в Робочій Директорії, отже всі файли, які ви не змінювали, будуть оновлені. reset --hard, з іншого боку, просто замінить все не розбираючи та не перевіряючи.

Друга важлива відмінність у тому, як checkout оновлює HEAD. reset переміщує гілку, на яку вказує HEAD, а checkout натомість переміщує сам HEAD, щоб той вказував на іншу гілку.

Наприклад, скажімо в нас є гілки master та develop, які вказують на різні коміти, та поточною гілкою є develop (отже HEAD на неї вказує). Якщо виконати git reset master, то сам develop почне вказувати на той самий коміт, що й master. Якщо ж виконати git checkout master, то пересувається не develop, а HEAD. HEAD почне вказувати на master.

Отже, в обох випадках ми переміщуємо HEAD до коміту A, проте як ми це робимо дуже різниться. reset переміщує гілку, на яку вказує HEAD, а checkout переміщує сам HEAD.

reset checkout

З файлами

Інший спосіб виконати команду checkout — це надати йому шляхи файлів, що, як і з reset, призведе до збереження HEAD. Вона поводиться так само як git reset [гілка] файл в тому, що оновлює індекс файлом з того коміту, проте також переписує файл у робочій директорії. Вона була б повністю рівнозначна git reset --hard [гілка] файл (якби б reset це дозволяв) — вона небезпечна для робочою директорії, та не переміщує HEAD.

Також, як і git reset та git add, checkout розуміє опцію --patch, що дозволяє вибірково повертати вміст файла по шматкам.

Підсумок

Сподіваємось, що тепер ви розумієте і почуваєтесь комфортніше з командою reset, проте ймовірно досі трохи спантеличені, чим саме вона відрізняється від checkout та навряд чи запам’ятали всі правила різноманітних форм викликів.

Ось шпаргалка щодо того, як команди впливають на які дерева. Колонка “HEAD” має значення “ПОС” (посилання), якщо переміщує посилання (гілку), на яке вказує HEAD, або “HEAD”, якщо переміщує сам HEAD. Приділить особливу увагу колонці РД у Безпеці? (РД - робоча директорія) — якщо в ній НІ, двічі поміркуйте перш ніж виконати команду.

HEAD Індекс Робоча Директорія РД у Безпеці?

Рівень Комітів

reset --soft [коміт]

ПОС

НІ

НІ

ТАК

reset [коміт]

ПОС

ТАК

НІ

ТАК

reset --hard [коміт]

ПОС

ТАК

ТАК

НІ

checkout <коміт>

HEAD

ТАК

ТАК

ТАК

Рівень Файлів

reset (коміт) <шляхи>

НІ

ТАК

НІ

ТАК

checkout (коміт) <шляхи>

НІ

ТАК

ТАК

НІ