Git
Chapters ▾ 2nd Edition

7.7 Git инструменти - Мистерията на командата Reset

Мистерията на командата Reset

Преди да преминем към по-специализираните инструменти на Git, нека поговорим още малко за reset и checkout командите. Тези команди са два от най-смущаващите аспекти в Git, когато за първи път се сблъскате с тях. Правят толкова много неща, че изглежда безнадеждно да бъдат разбрани и използвани ефективно. Ето защо, препоръчваме една проста метафора.

Трите дървета

Един по-лесен подход да мислите за reset и checkout е да гледате на Git като мениджър на съдържание за три различни дървета. Като казваме “дърво”, в действителност разбираме “колекция от файлове”, а не структурата от данни. (Има няколко ситуации, където индексът на практика не работи като дърво, но за нашите цели е по-лесно да го възприемаме като такова.)

Git като система управлява три дървета в нормалната си работа:

Дърво Роля

HEAD

Snapshot на последния къмит, родител на следващия

Index

Snapshot за следващия къмит

Работна директория

Работна област

Дървото HEAD

HEAD е указателят към референцията на текущия клон, която от своя страна сочи към последния къмит направен в този клон. Това означава, че HEAD ще бъде родител на следващия създаден къмит. Най-лесно е да гледаме на HEAD като на snapshot на последния ни къмит в този клон.

В действителност, лесно е да видим как изглежда този snapshot. Ето пример за извличане на реалния листинг на директория и SHA-1 чексумите за всеки файл в HEAD snapshot-а:

$ 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 са “plumbing” команди използвани за неща на по-ниско ниво и рядко се използват в ежедневната работа, но ни помагат да видим какво се случва тук.

Индексът

Индексът е очаквания следващ къмит. Наричаме го още “Staging Area” понеже това е мястото, от което 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 дърво, работната ви директория, известно още като “working tree”. Другите две съхраняват съдържанието си в ефективен, но неудобен за разглеждане вид, в директорията .git. Работната директория, от своя страна, разпакетира съдържанието в действителните файлове, с които работим. Можем да гледаме на нея като на опитно поле, в което пробваме промените си преди да ги изпратим в индексната област и след това в историята на проекта.

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

1 directory, 3 files

Работният процес

Основната работна последователност на Git е да записва snapshot-и на проекта ни в последователни серии, манипулирайки тези три дървета.

reset workflow

Нека онагледим процеса: да кажем, че отиваме в нова директория с един файл в нея. Ще наречем това v1 на файла и ще го маркираме в синьо. Сега изпълняваме git init, което ще създаде ново Git хранилище с HEAD референция, която сочи към все още несъществуващ клон (master все още не е създаден).

reset ex1

На този етап, единствено работната ни директория има някакво съдържание.

Сега ще искаме да индексираме този файл, така че използваме git add за да вземем съдържанието от работната област и да го копираме в индексната.

reset ex2

След това, изпълняваме git commit, което ще вземе съдържанието на индекса и ще го запише като перманентен snapshot, ще създаде къмит обект, който сочи към този snapshot и ще настрои нашия master клон да сочи към този къмит.

reset ex3

Ако сега изпълним git status няма да видим промени, защото трите ни дървета са идентични.

Сега правим промяна по файла и го къмитваме. Ще минем през същия процес, първо променяме файла в работната директория. Нека наречем това v2 на файла и да го маркираме в червено.

reset ex4

Когато изпълним git status в този момент, ще видим този файл в червено в секцията “Changes not staged for commit,” защото той сега се различава от копието си в индекса. След това изпълняваме git add и го индексираме.

reset ex5

В момента, git status, ще ни покаже файла в зелен цвят в секцията “Changes to be committed” защото индексът и HEAD се различават — тоест нашият очаквам следващ къмит е различен от последно съхранения. Последно, изпълняваме git commit за да финализираме новия къмит.

reset ex6

Сега git status няма да покаже разлики, защото трите дървета отново са еднакви.

Клонирането и превключването на клонове минават през подобен процес. Когато превключим към друг клон, HEAD се променя и сочи към референцията на този клон, индексът се попълва със snapshot-а на този къмит и след това съдържанието на индекса се копира в работната директория.

Ролята на Reset

Командата`reset` придобива по-ясно значение, когато се разглежда в такъв контекст.

За целта на тези примери, нека кажем, че сме променили file.txt отново и сме го къмитнали за трети път. Така историята ни сега ще изглежда по този начин:

reset start

Нека минем през това какво точно прави reset, когато я изпълним. Тя директно манипулира тези три дървета по прост и предвидим начин. Извършват се три основни операции.

Стъпка 1: Преместване на HEAD

Първото нещо, което reset прави е да смени мястото, където HEAD сочи. Това не означава, че самия HEAD се променя (което става с checkout), reset премества клона, към който сочи HEAD. Което ще рече, че ако HEAD е на master клона (тоест в момента сте в този клон), изпълнението на git reset 9e5e6a4 ще започне като направи master да сочи към 9e5e6a4.

reset soft

Без значение каква форма на reset с къмит сте изпълнили, това е първото нещо, което командата винаги ще опита да направи. С reset --soft, тя просто ще завърши тук.

Сега погледнете пак последната диаграма и ще видите какво се е случило: командата практически е отменила последно изпълнената git commit команда. Когато изпълните git commit, Git създава нов къмит и премества клона, към който сочи HEAD към този къмит. Когато ресетнете обратно към HEAD~ (тоест родителя на HEAD), вие премествате клона обратно където е бил без да променяте индекса или работната директория. Сега можете да обновите индекса и да изпълните git commit отново, така че да постигнете резултата, който бихте имали с git commit --amend (вижте Промяна на последния къмит).

Стъпка 2: Обновяване на индекса (--mixed)

Ако сега пуснете git status, ще видите в зелено разликата между индекса и новия HEAD.

Следващото нещо, което reset ще направи е да обнови индекса със съдържанието на snapshot-а, към който вече сочи HEAD.

reset mixed

Ако подадете аргумента --mixed, reset ще спре процеса в тази точка. Този аргумент се подразбира, така че ако не подадете никакви аргументи, а просто изпълните git reset HEAD~, това е точката в която командата ще спре процеса.

Поглеждайки отново диаграмата, осъзнаваме че командата пак е отменила последната commit, но в допълнение е деиндексирала всичко. По същество сега се върнахте обратно до момента преди изпълнението на командите git add и git commit.

Стъпка 3: Обновяване на работната директория (--hard)

Третото нещо, което командата reset може да стори, е да обнови съдържанието на работната директория така, че да я направи като индексната. Ако подадете параметъра --hard тя ще стигне чак до там.

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 в основната ѝ форма, но можете също така да ѝ посочите път, по който да работи. Ако укажете път, reset ще пропусне стъпка 1 и ще ограничи действието си до специфичен файл/файлове. В това всъщност има смисъл — HEAD е просто указател и вие не можете да сочите част от един къмит и част от друг. Но индексът и работната директория могат да бъдат частично обновени, така че reset преминава към стъпки 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 аргумент за да деиндексира съдържание в hunk-by-hunk стил. Така можете селективно да деиндексирате или връщате съдържание.

Обединяване

Нека видим как да направим нещо интересно с тази нова функционалност — да обединяваме къмити (squashing).

Да кажем, че имате серия къмити със съобщения като “oops.”, “WIP” и “forgot this file”. Можете да използвате reset за да ги обедините на бърза ръка в един общ къмит, което ще ви спечели уважение в очите на колегите. (Обединяване на къмити показва друг начин да направите това, но в този пример е по-лесно да използваме reset.)

Да приемем, че имате проект, в който първият къмит има един файл, вторият добавя нов файл и модифицира първия, а третият къмит модифицира първия файл още един път. Вторият къмит е бил work in progress и искате да го обедините с някой друг.

reset squash r1

Може да изпълните git reset --soft HEAD~2 за да преместите HEAD клона назад към по-стар къмит (най-скорошния, който искате да запазите):

reset squash r2

След това просто изпълнете git commit отново:

reset squash r3

Сега може да видите, че достъпната ви история, тази която ще публикувате, вече съдържа един къмит с file-a.txt v1 и след това втори, който е модифицирал file-a.txt до версия v3 и е добавил file-b.txt. Къмитът с версия v2 на файла вече е извън историята.

Check It Out

Накрая, може да се запитате каква е разликата между checkout и reset. Подобно на reset, checkout манипулира трите дървета и може да е различна в зависимост от това дали ѝ подавате път или не.

Без пътища

Изпълнението на git checkout [branch] е доста подобно по резултат от това на git reset --hard [branch] по отношение на това, че обновява всички три дървета, така че да изглеждат като [branch], но с две основни разлики.

Първо, за разлика от 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 да сочи към commit A, но начинът, по който го правим е много различен. reset ще премести клона, към който сочи HEAD, checkout ще премести самия HEAD.

reset checkout

С пътища

Друг начин да изпълним checkout е с път към файл, което както и reset, не премества HEAD. То е точно като git reset [branch] file по смисъла на това, че обновява индекса с този файл в този къмит, но в допълнение на това презаписва и файла в работната област. Резултатът ще е подобен на git reset --hard [branch] file (ако reset ви позволи изпълнението) — не е безопасен за работната директория и не премества HEAD.

В допълнение на това, както git reset и git add, checkout също приема аргумента --patch, за да ви позволи селективно извличане на част от файл в hunk-by-hunk маниер.

Обобщение

Надяваме се, че сега се чувствате по-удобно с командата reset, но въпреки това знаем, че тя предизвиква конфуз, когато я сравнявате с checkout и е твърде възможно да забравите всички правила и различни начини на изпълнението ѝ.

Ето кратка таблица с това коя команда кое от дърветата променя. Колоната “HEAD” съдържа “REF”, ако командата отляво премества референцията (клона), към който сочи HEAD и съдържа “HEAD” ако командата премества самия HEAD. Обърнете специално внимание на WD Safe? (безопасна за работната директория) колоната — ако тя съдържа NO, помислете добре преди да я изпълните.

HEAD Index Workdir WD Safe?

Commit Level

reset --soft [commit]

REF

NO

NO

YES

reset [commit]

REF

YES

NO

YES

reset --hard [commit]

REF

YES

YES

NO

checkout <commit>

HEAD

YES

YES

YES

File Level

reset [commit] <paths>

NO

YES

NO

YES

checkout [commit] <paths>

NO

YES

YES

NO