Git – jak cofać wprowadzone zmiany?

W dzisiejszym wpisie postaram się wyjaśnić na czym polega przywracanie zmian w systemie kontroli wersji Git. Wbrew pozorom nie jest to takie trudne jakby się mogło wydawać. Jest to bardzo przydatna umiejętność, którą naprawdę warto opanować, wierz mi, będziesz z tego korzystał codziennie. Nie owijajmy w bawełnę, przejdźmy do tematu artykułu!

Przywracanie zmian w Git

W każdym momencie może zajść potrzeba modyfikacji wprowadzonej zmiany, bądź powrotu do jeszcze działającej wersji kodu. Taką operację możemy zdecydowanie porównać do cofania się w czasie. Trzeba być jednak bardzo ostrożnym, ponieważ niektórych operacji już nie można cofnąć! Może to nas kosztować bezpowrotnie utraconą pracą. Dlatego zachęcam, żebyś przemyślał / przemyślała dziesięć razy, czy na pewno wiesz co zrobisz.

Do cofania wprowadzonych zmian możemy wykorzystać trzy różne polecenia:

  • git checkout – operacja, która przesuwa wskaźnik HEAD na wskazany commit, na chłopski rozum przywraca projekt do wersji z danego commit’a. Nie trzeba jednak używać jej dosłownie na cały projekt, można przywrócić zmiany w pojedynczym pliku.
  • git revert – polecenie wywołujące nowy commit z wersją projektu z danego commit’u. Dzięki tej komendzie możemy bezpiecznie przywrócić zmiany, które zdążyliśmy wrzucić na serwer. Bezpieczeństwo wynika z tego, że nie następuje modyfikacja historii zmian w repozytorium.
  • git reset – przywraca zmiany w repozytorium do wskazanego punktu w historii zmian. Uwaga! Skorzystanie z tego polecenia oznacza modyfikację historii zmian w lokalnym repozytorium. Gdy commit’y znajdują się na serwerze może to spowodować wiele komplikacji.

Struktura repozytorium

Repozytorium na którym pokażę przywracanie zmian posiada trzy pliki napisane w języku Python o nazwach first.py, second.py i three.py, zrobiłem trzy commit’y, jeden dla inicjalizacji repo, drugi z dodanymi liniami do plików first.py oraz third.py, trzeci natomiast odpowiada za dodanie komentarzy.

Repo Example
########### first.py
a = 1
print('one = {}'.format(a))

a = 5
print('changed value to {}'.format(a))
########### second.py
b = 2
print('two = {}'.format(b))
########### third.py
c = 3
print('three = {}'.format(c))

a = 10
print('create new variable equal to {}'.format(a))

git checkout

Żeby przywrócić zmiany należy skorzystać z unikatowego identyfikatora, który możemy odczytać z logów. Do tego korzystamy z komendy git log –oneline, zwróci nam skrócony identyfikator, który git i tak rozpozna.

git log --oneline
fe25981 (HEAD -> master) added comments
6a53f06 added new lines
b014e90 first commit

Żeby wrócić do poprzedniego commit’a, przyjmijmy pierwszego, należy skorzystać z polecenia git checkout i jako parametr podać skróconą wersję identyfikatora.

git checkout <commit>

Gratulację, udało się poprawnie powrócić do pierwszego commit’a! Zobacz w jakim stanie teraz znajdują się pliki (kolejno z góry first.py, second.py i third.py):

a = 1
print('one = {}'.format(a))
b = 2
print('two = {}'.format(b))
c = 3
print('three = {}'.format(c))

Zwróć uwagę, że znajdujemy się w stanie „odłączonej głowy”. Wskaźnik HEAD wskazuje na commit, który przekazaliśmy jako argument.

Możemy wykonywać różne eksperymentalne zmiany, jednak one nie zostaną zapisane. Żeby nie utracić wprowadzonych zmian należy utworzyć nową gałąź, git od razu podpowiada jak to można zrobić.

Żeby zrozumieć dlaczego podpowiedź mówi o poleceniu git checkout -b <nowa gałąź> należy poznać definicję git checkout. Otóż ta komenda nie tylko odpowiada za przywracanie zmian, jeśli jako argument zamiast identyfikatora commit’u podamy nazwę jakiejś gałęzi polecenie wywoła zmianę położenia wskaźnika HEAD. Od tej chwili będziemy znajdować się na innej gałęzi. Natomiast git checkout -b <nazwa nowej gałęzi> jednocześnie utworzy nową gałąź oraz spowoduje zmianę obecnej na nowo utworzoną. Istniejące branch’e możemy sprawdzić przy pomocy git branch.

Domyślam się, że lepiej będzie to zobaczyć na przykładzie, dlatego najpierw nie będę tworzył nowej gałęzi, zobaczmy jak zachowa się git w przypadku stanu „detached HEAD”.

git commit in detached head

Wskaźnik HEAD nie wskazuje na żadną gałąź, a na commit. W przypadku gdybyśmy nie utworzyli żadnego branch’a i przełączylibyśmy się na powiedzmy master, utracilibyśmy wprowadzone zmiany. Dlatego zapobiegam temu i tworzę nową gałąź przy pomocy zaproponowanej przez gita komendzie. Nowy branch będzie nazywał się „from detached”.

git branch from detached

Teraz wskaźnik poprawnie określa gałąź w jakiej się znajdujemy oraz mamy pewność, że nie utracimy wprowadzonych zmian. Możemy się przełączyć do master’a, teraz zaprezentuję cofanie zmian w pojedynczym pliku.

git checkout master

Zdecydowałem się na potrzeby tej części skorzystać z transparentnej powłoki, żebyś zobaczył / zobaczyła jak kod zmieni się po poleceniu. Skorzystamy z poprzedniej komendy, jednak dodamy teraz nazwę pliku.

git checkout 6a53f06 third.py

Został usunięty komentarz o którym mówi commit „added comments”. Zmiana dodanego komentarza została poprawnie usunięta, więc cel został osiągnięty.

Gdy wkleimy / dopiszemy utracony kod oraz dodamy plik do staging area zobaczymy, że nie ma potrzeby ponownie robić commit’a, system zobaczył powrót pliku do stanu w którym się znajdował.

git revert

Ponownie znajdujemy się w najnowszej wersji projektu oraz wrócimy do pierwszej wersji. Skorzystamy z polecenia git revert, którego celem będzie utworzenie nowego commit’a i cofnięcie zmian wprowadzonych podczas przekazanego commit’a. Jako argument znowu podamy skrócony identyfikator oraz będziemy musieli nadać nazwę nowej wersji.

Żeby to pokazać zrobiłem nowego commit’a, gdzie dopisałem do pliku second.py kilka nowych linijek kodu. first.py i third.py pozostają bez zmian.

########### second.py
b = 2
print('two = {}'.format(b))

c = 25
print('new line in second file with var = {}'.format(c))
c = 25 / 5
c = c * 2

Teraz cofnijmy się drugiego commit’a:

git revert

Na załączonym obrazku widać, że pojawiła się w logach modyfikacja pliku second.py. Przywracamy zmiany korzystając z skróconego identyfikatora, który wskazuje na commit o nazwie „added new lines”. Po zatwierdzeniu tego polecenia wyświetla się:

git revert msg

W tej chwili tworzony jest nowy commit do którego musimy dodać komentarz. Zmieniłem nazwę, żeby było wiadomo, że została przywrócona poprzednia wersja projektu. Zapisujemy zmianę i zatwierdzamy.

git revert msg changed

Poznaliśmy nową metodę przywracania starszych wersji projektu, której poprawne działanie prezentuje poniższa ilustracja:

git revert completed

Jak widzimy historia modyfikacji nie uległa zmianie, dodaliśmy nowego commit’a, który przywrócił stan plików z zadanego momentu. Możemy teraz skorzystać z innych metod cofania zmian.

git reset

Zaczniemy od wyprowadzenia stanów działania git reset. Otóż to polecenie posiada trzy możliwe stany:

  • –mixed – jest to domyślny stan, który resetuje wszystkie zmiany wprowadzone do momentu zadanego commit’u i zapisuje je w katalogu roboczym. Czyli wszystko co znajduje się w historii modyfikacji powyżej podanego jako argument identyfikatora pojawią się w katalogu roboczym.
  • –soft – działa dosłownie tak samo jak –mixed, jednak różni się tym, że zmiany nie pojawią się w katalogu roboczym, a w staging area. Dzięki temu możemy łatwo zdecydować, które zmiany zachować, a które utracić.
  • –hard – zmiany zostaną całkowicie usunięte, bezpowrotnie! Dlatego należy uważać podczas korzystania z tego polecenia.

Pomocna może przydać się wizualizacja wyżej wymienionych argumentów:

git visualization

Zaprezentuję teraz po kolei wspomniane trzy metody przywracania zmian. Zacznijmy od –hard. Będziemy cofać się o jeden commit.

git reset hard

Zauważmy, że na zawsze utraciliśmy wykonanego wcześniej revert’a! Należy być ostrożnym podczas tej operacji!

Teraz skorzystamy z polecenia –soft. Zwróć uwagę, że zmiany rzeczywiście pojawiły się w staging area.

git reset soft

Nie robiłem nowego commit’a, usunąłem wprowadzone zmiany. Wykonałem dwa polecenia:

git reset HEAD second.py # dodalem plik do strefy "zmodyfikowany"
git checkout -- second.py # usunalem wprowadzone zmiany

I na sam koniec cofniemy zmiany przy pomocy polecenia –mixed:

git reset mixed

Wprowadzone zmiany znajdują się w plikach w katalogu roboczym, jednak nie są zapisane. Następnie usunę modyfikację i pozostawię pliki w postaci w jakich były podczas drugiego commit’a.

Zawartość pliku first.py:

a = 1
print('one = {}'.format(a))

a = 5
print('changed value to {}'.format(a))

Zawartość pliku second.py:

b = 2
print('two = {}'.format(b))

Zawartość pliku third.py:

c = 3
print('three = {}'.format(c))

a = 10
print('create new variable equal to {}'.format(a))

Podsumowanie

Poznaliśmy dzisiaj trzy metody cofania zmian w tym niezwykłym systemie kontroli wersji jakim jest git. Bardzo polecam samodzielnie przećwiczyć przedstawiony materiał i postarać się go opanować, ponieważ w pracy będziesz z niego korzystać praktycznie codziennie.

Jeśli chciałbyś poznać fundamenty gita, polecam poprzedni wpis!

Linki referencyjne:

Cofanie zmian

checkout

revert

reset

What’s the difference between git reset –mixed, –soft, and –hard?