Logo for e-Solutions

Jak cofnąć błędy za pomocą GIT

Czasami zdarza się podczas pracy z kodem, że commitujemy, commitujemy i nagle dochodzimy do wniosku, że popełniliśmy błąd. W zależności od tego jakiego rodzaju błąd chcemy naprawić postąpimy inaczej. W niniejszym artykule przedstawię kilka sposobów na cofnięcie błędów powstałych podczas pracy z repozytorium GIT. Można je w bardzo łatwy sposób poprawić. Należy jednak pamiętać, by nie poprawiać w ten sposób jeśli wypchcniemy nasze zmiany do zdalnego współdzielonego repozytorium. Mogłoby się okazać, że zaburzymy pracę innych koderów.

Porzucenie lokalnych niezatwierdzonych zmian

Załóżmy, że poczyniliśmy jakieś zmiany w plikach, ale jeszcze nie zatwierdziliśmy ich. W trakcie kodowania stwierdzamy, że to jednak nienajlepszy kierunek. Chcielbyśmy przywrócić zawartość pliku do ostatniego commit’a. Służy do tego komenda restore.

$ git status
Na gałęzi develop
Zmiany nie przygotowane do złożenia:
  (użyj „git add <plik>...”, żeby zmienić, co zostanie złożone)
  (użyj „git restore <plik>...”, aby odrzucić zmiany w katalogu roboczym)
        zmieniono:       hello.js

brak zmian dodanych do zapisu (użyj „git add” i/lub „git commit -a”)
$ git restore hello.js

Powyższe polecenie przywróci plik hello.js do stanu sprzed zmian, tj. do ostatnio zatwierdzonej ( skomitowanej ) wersji. Należy być bardzo ostrożnym, bo jeśli jednak po przywróceniu dojdziemy do wniosku, że jednak nie wszystko chcielibyśmy przywracać, to już będzie za późno na anulowanie tej operacji.

Przywrócenie usuniętego pliku

Załóżmy, że usunęliśmy jakiś plik, ale jeszcze nie wykonaliśmy commit. Przywrócenie pliku jest tak samo proste jak w poprzednim przypadku. Wystarczy użyć komendy restore.

$ git status
Na gałęzi develop
Zmiany nie przygotowane do złożenia:
  (użyj „git add/rm <plik>...”, żeby zmienić, co zostanie złożone)
  (użyj „git restore <plik>...”, aby odrzucić zmiany w katalogu roboczym)
        usunięto:        error.js

brak zmian dodanych do zapisu (użyj „git add” i/lub „git commit -a”)
$ git restore error.js

Przywrócenie pojedyńczej zmiany w pliku

Załóżmy, że wprowadziliśmy w pliku różne zmiany, ale jeszcze nie wykonaliśmy commit. Chcielibyśmy przywrócić jeden kawałek (ang.chunk). Komenda git diff pokaże name wszystkie zmiany jakie dokonaliśmy w pliku.

$ git diff
diff --git a/index.html b/index.html
index a6f3653..fd136fe 100644
--- a/index.html
+++ b/index.html
@@ -18,8 +18,9 @@
   <nav>
     <ul>
       <li class="nav-item"><a href="index.php" class="nav-link" id="nav-link">Home</a></li>
-      <li class="nav-item"><a href="products.html" class="nav-link" id="nav-link">Product</a></li>
+      <li class="nav-item"><a href="product.html" class="nav-link" id="nav-link">Product</a></li>
       <li class="nav-item"><a href="about.html" class="nav-link" id="nav-link">About</a></li>
+      <li class="nav-item"><a href="support.html" class="nav-link" id="nav-link">Support</a></li>
     </ul>
   </nav>

Aby zejść do poziomu kawałków (patch’y) podczas przywracania użyjemy opcji –patch (w skrócie -p). Po wykonaniu tej komendy git zaprezentuje jakie zmiany można cofnąć. Na dole pojawią się dostępne opcje.

Discard this hunk from worktree [y,n,q,a,d,s,e,?]?

Jeśli nie jesteśmy pewni znaczenia odpowiednich liter, zawsze możemy skorzystać z pomocy wciskając znak zapytania. Do wyboru mamy następujące opcje: y - (yes) tak, usuń ten kawałek n - (no) nie, nie usuwaj tego kawałka q - (quit) wyjdź; nie wyrzucaj tego kawałka ani żadnej z pozostałych a - (all) odrzuć ten kawałek i wszystkie kolejne d - (don’t) nie odrzucaj tego kawałka ani żadnego kolejnego s - (split) podziel ten kawałek na mniejsze części e - (edit) dokonaj ręcznej edycji ? - wyświetl pomoc W najprosztrzym użyciu wybieramy y lub n. Jeśli w wyświetlonym kawałku znajduje się więcej zmian, a my chcemy tylko część przywrócić opcja s podzieli nam kawałek na mniejsze części, a następnie dostaniemy możliwość wyboru czy przywracać kolejne części. Oto przykład

$ git restore -p index.html
diff --git a/index.html b/index.html
index a6f3653..fd136fe 100644
--- a/index.html
+++ b/index.html
@@ -18,8 +18,9 @@
   <nav>
     <ul>
       <li class="nav-item"><a href="index.php" class="nav-link" id="nav-link">Home</a></li>
-      <li class="nav-item"><a href="products.html" class="nav-link" id="nav-link">Product</a></li>
+      <li class="nav-item"><a href="product.html" class="nav-link" id="nav-link">Product</a></li>
       <li class="nav-item"><a href="about.html" class="nav-link" id="nav-link">About</a></li>
+      <li class="nav-item"><a href="support.html" class="nav-link" id="nav-link">Support</a></li>
     </ul>
   </nav>
 
(1/1) Discard this hunk from worktree [y,n,q,a,d,s,e,?]? ?
y - discard this hunk from worktree
n - do not discard this hunk from worktree
q - quit; do not discard this hunk or any of the remaining ones
a - discard this hunk and all later hunks in the file
d - do not discard this hunk or any of the later hunks in the file
s - split the current hunk into smaller hunks
e - manually edit the current hunk
? - print help
@@ -18,8 +18,9 @@
   <nav>
     <ul>
       <li class="nav-item"><a href="index.php" class="nav-link" id="nav-link">Home</a></li>
-      <li class="nav-item"><a href="products.html" class="nav-link" id="nav-link">Product</a></li>
+      <li class="nav-item"><a href="product.html" class="nav-link" id="nav-link">Product</a></li>
       <li class="nav-item"><a href="about.html" class="nav-link" id="nav-link">About</a></li>
+      <li class="nav-item"><a href="support.html" class="nav-link" id="nav-link">Support</a></li>
     </ul>
   </nav>
 
(1/1) Discard this hunk from worktree [y,n,q,a,d,s,e,?]? s
Split into 2 hunks.
@@ -18,5 +18,5 @@
   <nav>
     <ul>
       <li class="nav-item"><a href="index.php" class="nav-link" id="nav-link">Home</a></li>
-      <li class="nav-item"><a href="products.html" class="nav-link" id="nav-link">Product</a></li>
+      <li class="nav-item"><a href="product.html" class="nav-link" id="nav-link">Product</a></li>
       <li class="nav-item"><a href="about.html" class="nav-link" id="nav-link">About</a></li>
(1/2) Discard this hunk from worktree [y,n,q,a,d,j,J,g,/,e,?]?

Poprawianie ostatniego commita

Załóżmy, że wykonaliśmy commit, ale popełniliśmy błąd w treści wiadomości, lub chcemy uzupełnić o brakujący plik. Wówczas wystarczy użyć opcji –amend

$ git commit --amend

Zwróć uwagę, że w przypadku użycia opcji –amend, git podmienia commit, zmienia się jego ID, gdyż zmienia się skrót obiektu. Następuje nadpisanie historii. Dlatego też należy pamiętać, że jeśli wykonałeś już push do zdalnego repozytorium, to nie używaj tej opcji.

Przywrócenie commita ze środka historii

Załóżmy, że po wykonaniu commita, okazało się, że dwa commity wcześniej popełniliśmy błąd. Mamy możliwość utworzenia nowego commita, który jest “odwrotnością”, tego błędnego. Użyjemy do tego celu opcji revert wskazują ID commita, który chcemy usunąć.

$ git revert 682a9c1

Git otworzy nam edytor, abyśmy mogli uzupełnić komentarz. Domyślnie znajdzie się tam informacja który commit jest wycofywany. Po zapisaniu wiadomości, zostanie utworzony nowy commit. Jest to bardzo bezpieczna operacja, nie tracimy w ten sposób historii.

Usuwanie ostatnich commitów

Załóżmy, że doszliśmy do wniosku, że jeden czy więcej ostatnich commitów, jednak nie mają sensu. Chcielibyśmy je usunąć z historii. Wykorzystamy do tego komendę git reset z opcją –hard wskazując ID commita, do którego chcielibyśmy się cofnąć. Wskazany przez nas commit będzie tym który pozostanie, a wszystkie kolejne zostaną usunięte.

$ git reset --hard 682a9c1

Opcja –hard oznacza, że nic nie powinno pozostać w lokalnej kopii. Jeśli jednak chcielibyśmy pozostawić zmiany w lokalnej kopii, użyjemy opcji –mixed.

$ git reset --mixed HEAD^

W powyższym przykładzie użyłem HEAD^, co oznacza przedostatni commit. Czyli powyższe przywróci do przedostatnie commita, zostawiając to co było w ostatnim w lokalnej kopii.

Jeśli wiemy o ile commitów chcemy się cofnąć, możemy zamiast podawania ID, wkazywać w następujący sposób: HEAD^ = HEAD~1 - poprzedni HEAD~2 - 2 do tyłu HEAD~3 - 3 do tyłu itd.

Przywracanie pojedyńczego pliku do starej wersji

Załóżmy, że chcemy przywrócić konkretny plik do wersji sprzed jakiś zmian. Aby przejrzeć zmiany na wskazanym pliku możemy wykonać:

$ git log -p index.html

Opcja -p spowoduje pokazanie nam różnic w kolejnych commitach. Najważniejsze, że będziemy widzieli identyfikatory commitów, które przydadzą się podczas wskazania do jakiego momentu chcemy przywrócić ów plik.

$ git restore --source 682a9c1 index.html

Powyższe przywróci plik do kopii lokalnej. Teraz możemy go wyedytować w żądanej wersji i dodać do kolejnego commita.

Odzyskiwanie usuniętych commitów

Załóżmy, że doszliśmy do wniosku, że chcemy usunąć jeden lub więcej ostatnich commitów. Usuwamy go i nagle dochodzimy do wniosku, że to był jednak błąd. W pierwszej kolejności zobaczmy co jest zapisane w reflogu. Reflog to taki dziennik, w którym zapisywane są zmiany położenia HEAD.

$ git reflog
1fda704 (HEAD -> feature/test) HEAD@{0}: reset: moving to 1fda704
0f64c53 HEAD@{1}: commit: sth changed

Widzimy, tutaj, że commit 0f64c53 to ten przed usunięcem, natomiast pod 1fda704 jest zapisana operacja przeniesienia HEAD, czyli usunięcie commita, które okazało się błędnym. Precyzyjnie to HEAD został przeniesiony do commit 1fda704. Aby to usunięce wycofać użyjmy reset. Przeniesiemy HEAD ponownie do 0f64c53.

$  git reset --hard 0f64c53
HEAD wskazuje teraz 0f64c53 sth changed

Odzyskiwanie usuniętej gałęzi

Załóżmy, że doszliśmy do wniosku, że nie potrzebujemy już gałęzie typu feature. Usuwamy ją i po pewnym czasie okazuje się, że jednak potrzebujemy ją. Podobnie jak w poprzednim przypadku, z pomocą przychodzi nam reflog. Zajrzyjmy więc do niego.

$ git reflog
e324894 (HEAD -> develop) HEAD@{0}: checkout: moving from feature/test to develop
0f64c53 HEAD@{1}: commit: sth changed

Utworzymy ponownie usuniętą gałąź

$ git branch feature/test 0f64c53

W ten sposób można utworzyć gałąż z dowolnej pozycji w reflogu. W poprzednim przykładzie użyliśmy reset, ale nic nie stoi na przeszkodzie by utworzyć branch, a potem np. przenosić commity pomiędzy gałęziami.

Przenoszenie commitu do nowej gałęzi

Załóżmy, że po wykonaniu commit, zorientowaliśmy się, że byliśmy na złej gałezi. Przd wykonaniem commit powinniśmy utworzyć nową gałąź. Możemy to w bardzo łatwy sposób naprawić. Utworzymy z tego miejsca nową gałąź, a następnie usuniemy zbędny commit.

$ git branch feature/new-field
$ git reset --hard HARD~1

Przenoszenie commitu do innej gałęzi

Podobnie jak poprzednio, z tą różnicą, że gałąź w której commit powinien się znaleźć już istnieje. Nie chcemy zatem tworzyć nowej gałęzi. Aby to naprawić potrzebujemy wykonać cztery kroki: przejść do docelowej gałęzi, skopiować tam commit, wrócić do gałęzi w której pierwotnie ów commit został utworzony i usunąć go.

$ git checkout feature/new-field
Przełączono na gałąź „feature/new-field”
$ git cherry-pick 3e2e2b8
Auto-scalanie index.html
[feature/new-field 3e2e2b8] Change sth
 Date: Thu Jan 27 16:32:35 2022 +0100
 1 file changed, 1 insertion(+), 1 deletion(-)
$ git checkout feature/new-label
Przełączono na gałąź „feature/new-label”
$ git reset HEAD^ --hard
HEAD wskazuje teraz 0f64c53 some message

Edycja opisu zmiany, ale starszej, nie ostaniej

Edycja ostatniej zmiany jest bardzo prosta. Jak wcześniej opisałem używa się do tego opcji –amend. W przypadku, gdy chcemy edytować zmianę która jest kilka commitów wstecz, użyjemy rebase. Załóżmy, że chcemy zmienić opis przedostatniego commita, czyli HEAD~1. Jednakże do polecenia przekażemy nadrzędny commit do niego, czyli HEAD~2. Dodatkowo wykonamy to z opcją -i, czyli interaktywnie.

$ git rebase -i HEAD~2

Uruchomi się edytor, w którym będziemy mogli określać zmiany. Będziemy widzieli listę, kolejnych zatwierdzeń w odwrotnej kolejności

pick 4eaf12f tę wiadomość będziemy zmieniać
pick 0f64c53 ostatni commit

Dodatkowo pierwsza kolumna zawiera polecenie. Lista poleceń jest nam również wyświetlana. Druga kolumna, to hash commita, a trzecia kolumna zawiera jego opis. W tym miejscu nie zmieniamy opisów. Edytujemy tylko i wyłącznie pierwszą kolumnę. Będzie nas interesowała opcja

# r, reword <zapis> = użyj zapisu, ale przeredaguj jego komunikat

Wobec czego zmieniamy pick na reword w odpowiednim wierszu.

reword 4eaf12f tę wiadomość będziemy zmieniać
pick 0f64c53 ostatni commit

Gdy zapiszemy, ponowie otworzy się edytor, ale tym razem z tą jedną konkretną zmianą i teraz możemy zmodyfikować nasz opis.

Usuwanie zmiany, ale starszej, nie ostatniej

W tym przypadku podobnie jak poprzednio użyjemy rebase. Pamiętając tak samo, aby wskazać zmianę o jedną wcześniej, niż tę którą chcemy usuwać. Gdy otworzy się edytor z listą zmian następujących po wskazanej w poleceniu, tym razem pack zamieniamy na drop zgodnie z podpowiedzią

# d, drop <zapis> = usuń zapis

Uzupełnienie starszej zmiany

Załóżmy, że zorientowaliśmy się, że do starszego commita czegoś nie dodaliśmy, albo czegoś nie usunęliśmy. Moglibyśmy oczywiście utworzyć kolejny commit, który byłby poprawką, ale dla zachowania spójności chcielibyśmy poprawić we właściwym miejscu. W tym celu przy tworzeniu nowego commita dodajemy opcję –fixup ze wskazaniem który commit poprawiamy

$ git commit --fixup 1fda704
[feature/test cc70040] fixup! Add css
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 style.css

Został teraz utworzony nowy commit z komentarzem “fixup! " i dalej komentarz poprawianego commita, więc to jeszcze nie koniec. To nie jest efekt finalny, który chcielibyśmy osiągnąć. Teraz użyjemy rebase aby połączyć dwie zmiany w jedną. Opcję -i już znamy - interaktywnie. Opcja –autosqash spowoduje takie ustawienie listy commitów, aby możliwe było połączenie odpowiednich. Tak to wygląda:

$ git rebase -i HEAD~4 --autosquash

Następnie uruchomi się edytor, gdzie już będziemy mieli podpowiedziane łączenie commitów, więc nic nie trzeba zmieniać. Wystarczy potwierdzić poprzez zapisanie.

pick 1fda704 Add css
fixup cc70040 fixup! Add css
pick 5ed3e06 Change sth
pick 669ebf2 Other change

Na powyższej liście opcja fixup oznacz, połączenie z poprzednim, ale bez komentarza. A o to właśnie nam chodzi.