W tym artykule zgłębimy różnorodne metody manipulowania historią zmian w systemie Git.
Jako programista, z pewnością nieraz staniesz przed sytuacją, w której konieczne będzie cofnięcie się do wcześniejszej wersji projektu, lecz nie będziesz pewien, jak tego dokonać. Nawet jeśli znasz podstawowe komendy Gita, takie jak `reset`, `revert` czy `rebase`, możesz nie być w pełni świadomy różnic między nimi. Rozpocznijmy zatem od wyjaśnienia, czym charakteryzują się te trzy polecenia.
Zastosowanie `git reset`
Polecenie `git reset` to zaawansowane narzędzie służące do wycofywania zmian w projekcie.
Można je traktować jako funkcję umożliwiającą cofanie zmian. Za jego pomocą można swobodnie przemieszczać się pomiędzy różnymi etapami zatwierdzeń (commitami). `Git reset` posiada trzy tryby działania: `–soft`, `–mixed` i `–hard`. Domyślnie używany jest tryb mieszany (`–mixed`). W procesie resetowania zmian w Git wykorzystywane są trzy elementy: `HEAD`, obszar przygotowania zmian (indeks) oraz katalog roboczy.
Katalog roboczy to przestrzeń, w której aktualnie pracujesz, zawierająca Twoje pliki. Za pomocą komendy `git status` można sprawdzić zawartość tego katalogu.
Obszar przygotowania zmian (indeks) to miejsce, w którym Git przechowuje informacje o wprowadzonych modyfikacjach w plikach. Zapisane zmiany są odzwierciedlane w katalogu `.git`. Użyj `git add „nazwa pliku”`, aby umieścić plik w obszarze przygotowania. Ponownie, używając `git status`, możesz sprawdzić, jakie pliki znajdują się w tym obszarze.
Bieżąca gałąź w Git jest oznaczana jako `HEAD`. Wskazuje ona na ostatni commit w obecnej gałęzi. Jest traktowana jako wskaźnik dla każdego odniesienia. Gdy przełączysz się na inną gałąź, `HEAD` również zostanie przeniesiony do nowej gałęzi.
Przejdźmy do wyjaśnienia, jak działa `git reset` w trzech trybach: `hard`, `soft` i `mixed`. Tryb `hard` służy do powrotu do konkretnego zatwierdzenia. Katalog roboczy jest aktualizowany do stanu tego zatwierdzenia, a obszar przygotowania zostaje zresetowany. W przypadku resetu `soft`, zmieniany jest tylko wskaźnik `HEAD` na wskazany commit, a pliki wszystkich commitów przed resetem pozostają w katalogu roboczym i obszarze przygotowania. Tryb mieszany (domyślny) resetuje zarówno wskaźnik, jak i obszar przygotowania.
`Git reset` w trybie twardym (hard)
Celem twardego resetu jest przesunięcie wskaźnika `HEAD` do wskazanego zatwierdzenia. Powoduje on usunięcie wszystkich commitów dokonanych po danym commitcie. Ta komenda zmienia historię zatwierdzeń, kierując wskaźnik na konkretny commit.
W poniższym przykładzie dodam trzy nowe pliki, zatwierdzę je, a następnie wykonam twardy reset.
Jak widać, poniższa komenda wskazuje, że aktualnie nie ma żadnych zmian do zatwierdzenia.
$ git status On branch master Your branch is ahead of 'origin/master' by 2 commits. (use "git push" to publish your local commits) nothing to commit, working tree clean
Teraz utworzę trzy pliki i dodam do nich treść.
$ vi file1.txt $ vi file2.txt $ vi file3.txt
Dodaję te pliki do istniejącego repozytorium.
$ git add file*
Po ponownym uruchomieniu komendy `status` zobaczymy nowe pliki, które zostały dodane.
$ git status On branch master Your branch is ahead of 'origin/master' by 2 commits. (use "git push" to publish your local commits) Changes to be committed: (use "git restore --staged <file>..." to unstage) new file: file1.txt new file: file2.txt new file: file3.txt
Zanim zatwierdzę zmiany, pokażę, że aktualnie w Git mam historię trzech commitów.
$ git log --oneline 0db602e (HEAD -> master) one more commit 59c86c9 new commit e2f44fc (origin/master, origin/HEAD) test
Teraz zatwierdzam zmiany w repozytorium.
$ git commit -m 'added 3 files' [master d69950b] added 3 files 3 files changed, 3 insertions(+) create mode 100644 file1.txt create mode 100644 file2.txt create mode 100644 file3.txt
Uruchamiając `ls-files`, można zauważyć, że nowe pliki zostały dodane.
$ git ls-files demo dummyfile newfile file1.txt file2.txt file3.txt
Kiedy uruchamiam komendę `log` w Git, mam 4 commity, a `HEAD` wskazuje na ostatni z nich.
$ git log --oneline d69950b (HEAD -> master) added 3 files 0db602e one more commit 59c86c9 new commit e2f44fc (origin/master, origin/HEAD) test
Jeśli ręcznie usunę plik `file1.txt` i wykonam `git status`, zobaczę informację o tym, że zmiana nie jest przygotowana do zatwierdzenia.
$ git status On branch master Your branch is ahead of 'origin/master' by 3 commits. (use "git push" to publish your local commits) Changes not staged for commit: (use "git add/rm <file>..." to update what will be committed) (use "git restore <file>..." to discard changes in working directory) deleted: file1.txt no changes added to commit (use "git add" and/or "git commit -a")
Teraz uruchomię twardy reset.
$ git reset --hard HEAD is now at d69950b added 3 files
Po ponownym sprawdzeniu statusu, okazuje się, że nie ma zmian do zatwierdzenia, a usunięty plik został przywrócony do repozytorium. Cofnięcie nastąpiło, ponieważ po usunięciu pliku nie dokonałem zatwierdzenia, więc twardy reset przywrócił poprzedni stan.
$ git status On branch master Your branch is ahead of 'origin/master' by 3 commits. (use "git push" to publish your local commits) nothing to commit, working tree clean
Tak prezentuje się log Git po wykonaniu resetu.
$ git log commit d69950b7ea406a97499e07f9b28082db9db0b387 (HEAD -> master) Author: mrgeek <[email protected]> Date: Mon May 17 19:53:31 2020 +0530 added 3 files commit 0db602e085a4d59cfa9393abac41ff5fd7afcb14 Author: mrgeek <[email protected]> Date: Mon May 17 01:04:13 2020 +0530 one more commit commit 59c86c96a82589bad5ecba7668ad38aa684ab323 Author: mrgeek <[email protected]> Date: Mon May 17 00:54:53 2020 +0530 new commit commit e2f44fca2f8afad8e4d73df6b72111f2f2fd71ad (origin/master, origin/HEAD) Author: mrgeek <[email protected]> Date: Mon May 17 00:16:33 2020 +0530 test
Celem twardego resetu jest wskazanie konkretnego zatwierdzenia i aktualizacja katalogu roboczego oraz obszaru przygotowania. Pokażę jeszcze jeden przykład. Aktualnie moje commity wizualnie wyglądają następująco:
Teraz uruchomię komendę z `HEAD^`, co oznacza, że chcę zresetować do poprzedniego commita (jeden commit wstecz).
$ git reset --hard HEAD^ HEAD is now at 0db602e one more commit
Widać, że wskaźnik `HEAD` zmienił się teraz na `0db602e` z `d69950b`.
$ git log --oneline 0db602e (HEAD -> master) one more commit 59c86c9 new commit e2f44fc (origin/master, origin/HEAD) test
Jeśli sprawdzisz log, commit `d69950b` zniknął, a `HEAD` wskazuje teraz na `0db602e` SHA.
$ git log commit 0db602e085a4d59cfa9393abac41ff5fd7afcb14 (HEAD -> master) Author: mrgeek <[email protected]> Date: Mon May 17 01:04:13 2020 +0530 one more commit commit 59c86c96a82589bad5ecba7668ad38aa684ab323 Author: mrgeek <[email protected]> Date: Mon May 17 00:54:53 2020 +0530 new commit commit e2f44fca2f8afad8e4d73df6b72111f2f2fd71ad (origin/master, origin/HEAD) Author: mrgeek <[email protected]> Date: Mon May 17 00:16:33 2020 +0530 Test
Uruchamiając `ls-files`, zobaczymy, że pliki `file1.txt`, `file2.txt` oraz `file3.txt` nie znajdują się już w repozytorium, ponieważ ten commit oraz jego pliki zostały usunięte po wykonaniu twardego resetu.
$ git ls-files demo dummyfile newfile
`Git reset` w trybie miękkim (soft)
Teraz pokażę przykład miękkiego resetu. Załóżmy, że ponownie dodałem 3 pliki, jak opisano wcześniej i zatwierdziłem zmiany. Log Git prezentuje się jak poniżej. Ostatni commit to „soft reset”, a `HEAD` na niego wskazuje.
$ git log --oneline aa40085 (HEAD -> master) soft reset 0db602e one more commit 59c86c9 new commit e2f44fc (origin/master, origin/HEAD) test
Szczegóły commita w logu można zobaczyć za pomocą poniższej komendy.
$ git log commit aa400858aab3927e79116941c715749780a59fc9 (HEAD -> master) Author: mrgeek <[email protected]> Date: Mon May 17 21:01:36 2020 +0530 soft reset commit 0db602e085a4d59cfa9393abac41ff5fd7afcb14 Author: mrgeek <[email protected]> Date: Mon May 17 01:04:13 2020 +0530 one more commit commit 59c86c96a82589bad5ecba7668ad38aa684ab323 Author: mrgeek <[email protected]> Date: Mon May 17 00:54:53 2020 +0530 new commit commit e2f44fca2f8afad8e4d73df6b72111f2f2fd71ad (origin/master, origin/HEAD) Author: mrgeek <[email protected]> Date: Mon May 17 00:16:33 2020 +0530 test
Teraz, za pomocą miękkiego resetu, chcę powrócić do starszego commita o SHA `0db602e085a4d59cfa9393abac41ff5fd7afcb14`.
W tym celu uruchamiam poniższą komendę. Wystarczy podać więcej niż 6 początkowych znaków SHA; nie jest wymagane pełne SHA.
$ git reset --soft 0db602e085a4
Uruchamiając `git log`, widzę, że `HEAD` został zresetowany do wskazanego commita.
$ git log commit 0db602e085a4d59cfa9393abac41ff5fd7afcb14 (HEAD -> master) Author: mrgeek <[email protected]> Date: Mon May 17 01:04:13 2020 +0530 one more commit commit 59c86c96a82589bad5ecba7668ad38aa684ab323 Author: mrgeek <[email protected]> Date: Mon May 17 00:54:53 2020 +0530 new commit commit e2f44fca2f8afad8e4d73df6b72111f2f2fd71ad (origin/master, origin/HEAD) Author: mrgeek <[email protected]> Date: Mon May 17 00:16:33 2020 +0530 test
Różnica polega jednak na tym, że pliki z commita `aa400858aab3927e79116941c715749780a59fc9`, w którym dodałem 3 pliki, nadal są obecne w katalogu roboczym. Nie zostały usunięte. Dlatego w sytuacji, gdy nie chcesz utracić danych, warto zastosować miękki reset zamiast twardego. W trybie miękkim nie ma ryzyka utraty plików.
$ git ls-files demo dummyfile file1.txt file2.txt file3.txt newfile
`Git revert` – Przywracanie zmian
W systemie Git polecenie `revert` służy do wykonywania operacji przywracania, czyli cofania wprowadzonych zmian. Działa podobnie do polecenia `reset`, z tą różnicą, że tworzy nowy commit, aby cofnąć zmiany do konkretnego stanu. Krótko mówiąc, można powiedzieć, że `git revert` jest rodzajem commitu.
Polecenie `git revert` nie usuwa żadnych danych podczas operacji przywracania.
Dodajmy dla przykładu 3 pliki i zatwierdźmy je za pomocą `git commit`.
$ git commit -m 'add 3 files again' [master 812335d] add 3 files again 3 files changed, 3 insertions(+) create mode 100644 file1.txt create mode 100644 file2.txt create mode 100644 file3.txt
Log pokaże nowy commit.
$ git log --oneline 812335d (HEAD -> master) add 3 files again 0db602e one more commit 59c86c9 new commit e2f44fc (origin/master, origin/HEAD) test
Chcąc powrócić do wcześniejszego commita, na przykład – `59c86c9 new commit`, uruchamiam poniższą komendę.
$ git revert 59c86c9
Spowoduje to otwarcie pliku, gdzie zobaczysz szczegóły commita, do którego próbujesz powrócić. Możesz nadać nowemu commitowi nazwę, a następnie zapisać i zamknąć plik.
Revert "new commit" This reverts commit 59c86c96a82589bad5ecba7668ad38aa684ab323. # Please enter the commit message for your changes. Lines starting # with '#' will be ignored, and an empty message aborts the commit. # # On branch master # Your branch is ahead of 'origin/master' by 4 commits. # (use "git push" to publish your local commits) # # Changes to be committed: # modified: dummyfile
Po zapisaniu i zamknięciu pliku, otrzymamy następujący rezultat.
$ git revert 59c86c9 [master af72b7a] Revert "new commit" 1 file changed, 1 insertion(+), 1 deletion(-)
W przeciwieństwie do resetu, revert utworzył nowy commit. Sprawdzenie logu pokaże nowy commit będący wynikiem operacji przywracania.
$ git log --oneline af72b7a (HEAD -> master) Revert "new commit" 812335d add 3 files again 0db602e one more commit 59c86c9 new commit e2f44fc (origin/master, origin/HEAD) test
Log Git będzie zawierał pełną historię commitów. Jeśli chcesz usunąć commity z historii, `revert` nie będzie dobrym wyborem. Jeśli jednak zależy Ci na zachowaniu zmian w historii, `revert` jest lepszym rozwiązaniem niż `reset`.
`Git rebase`
W Git `rebase` to metoda przenoszenia lub scalania commitów jednej gałęzi nad inną. W praktyce programiści rzadko tworzą funkcje w gałęzi głównej. Zamiast tego pracują w osobnej gałęzi („gałąź funkcji”), a po dodaniu nowych funkcjonalności, przenoszą je do gałęzi głównej.
Rebase bywa mylący, ponieważ jest bardzo podobny do scalania (`merge`). Celem zarówno `merge`, jak i `rebase` jest przeniesienie commitów z jednej gałęzi do innej. Załóżmy, że mamy wykres commitów, który wygląda następująco:
Wyobraźmy sobie, że pracujesz w zespole z innymi programistami. W takiej sytuacji śledzenie zmian może stać się bardzo skomplikowane, gdy wiele osób pracuje nad różnymi gałęziami i integruje zmiany. Staje się to niejasne.
W takim przypadku z pomocą przychodzi rebase. Zamiast używać `git merge`, wykorzystamy `rebase`, aby przenieść dwa commity z gałęzi funkcji do gałęzi głównej. `Rebase` przenosi wszystkie commity z gałęzi funkcji na szczyt gałęzi głównej. W efekcie Git duplikuje commity z gałęzi funkcji w gałęzi głównej.
Dzięki takiemu podejściu otrzymujemy czysty, liniowy wykres commitów.
Ułatwia to śledzenie zmian. Wszystkie commity znajdują się w jednej linii, co ułatwia orientację w historii zmian, nawet gdy nad projektem pracuje wielu programistów.
Pokażę to w praktyce.
Tak aktualnie wygląda moja gałąź główna. Ma 4 commity.
$ git log --oneline 812335d (HEAD -> master) add 3 files again 0db602e one more commit 59c86c9 new commit e2f44fc (origin/master, origin/HEAD) test
Uruchomię poniższą komendę, aby utworzyć i przejść do nowej gałęzi o nazwie „feature”. Gałąź zostanie utworzona na podstawie drugiego commita `59c86c9`.
(master) $ git checkout -b feature 59c86c9 Switched to a new branch 'feature'
Sprawdzając log w gałęzi funkcji, widzę tylko 2 commity pochodzące z gałęzi głównej (master).
(feature) $ git log --oneline 59c86c9 (HEAD -> feature) new commit e2f44fc (origin/master, origin/HEAD) test
Utworzę funkcję 1 i zatwierdzę ją w gałęzi funkcji.
(feature) $ vi feature1.txt (feature) $ git add . The file will have its original line endings in your working directory (feature) $ git commit -m 'feature 1' [feature c639e1b] feature 1 1 file changed, 1 insertion(+) create mode 100644 feature1.txt
Dodam jeszcze jedną funkcję, tj. funkcję 2, w gałęzi funkcji i zatwierdzę ją.
(feature) $ vi feature2.txt (feature) $ git add . The file will have its original line endings in your working directory (feature) $ git commit -m 'feature 2' [feature 0f4db49] feature 2 1 file changed, 1 insertion(+) create mode 100644 feature2.txt
Log gałęzi funkcji pokazuje, że ma dwa nowe commity, które właśnie wykonałem.
(feature) $ git log --oneline 0f4db49 (HEAD -> feature) feature 2 c639e1b feature 1 59c86c9 new commit e2f44fc (origin/master, origin/HEAD) test
Teraz chcę dodać te dwie nowe funkcje do gałęzi master. W tym celu użyję komendy rebase. Z gałęzi funkcji wykonam rebase w stosunku do gałęzi głównej. Spowoduje to ponowne zakotwiczenie mojej gałęzi funkcji w stosunku do najnowszych zmian.
(feature) $ git rebase master Successfully rebased and updated refs/heads/feature.
Teraz przejdę i sprawdzę gałąź główną.
(feature) $ git checkout master Switched to branch 'master' Your branch is ahead of 'origin/master' by 3 commits. (use "git push" to publish your local commits)
Na koniec wykonam rebase gałęzi master na moją gałąź funkcji. Spowoduje to pobranie dwóch nowych commitów z gałęzi funkcji i odtworzenie ich na szczycie mojej gałęzi głównej.
(master) $ git rebase feature Successfully rebased and updated refs/heads/master.
Sprawdzając log w gałęzi głównej, widzimy, że dwa commity z gałęzi funkcji zostały pomyślnie dodane do mojej gałęzi głównej.
(master) $ git log --oneline 766c996 (HEAD -> master, feature) feature 2 c036a11 feature 1 812335d add 3 files again 0db602e one more commit 59c86c9 new commit e2f44fc (origin/master, origin/HEAD) test
To wszystko, jeśli chodzi o polecenia `reset`, `revert` i `rebase` w systemie Git.
Podsumowanie
W niniejszym artykule omówiliśmy polecenia `reset`, `revert` i `rebase` w Git. Mam nadzieję, że ten przewodnik krok po kroku był pomocny. Teraz wiesz, jak modyfikować historię zatwierdzeń, wykorzystując omówione polecenia.
newsblog.pl
Maciej – redaktor, pasjonat technologii i samozwańczy pogromca błędów w systemie Windows. Zna Linuxa lepiej niż własną lodówkę, a kawa to jego główne źródło zasilania. Pisze, testuje, naprawia – i czasem nawet wyłącza i włącza ponownie. W wolnych chwilach udaje, że odpoczywa, ale i tak kończy z laptopem na kolanach.