Kolejny z serii artykułów o GIT. Dzisiaj sporo wiedzy o liniowej historii zmian w GIT.
Często pracując nad projektem chcemy mieć czystą historię zmian, niezaśmieconą merge comitami i niepoprzeplataną mieszaniną commitów naszych i innych programistów. Możemy to zrealizować na kilka sposobów. Omówimy je w tym artykule.
Seria Artykułów o GIT – spis treści
- GIT HEAD – co to jest? Detached Head. Kiedy można stracić głowę?
- GIT fast-forward i Merge Commit. Co to właściwie jest?
- Git REBASE i Squash. Tworzymy ładną historię zmian.
- Przydatne narzędzia i oprogramowanie do pracy z GIT
- Przydatne Materiały o GIT plus BONUS – ściąga z poleceń GIT’a
- Podsumowanie.
Squash commit: git merge –squash branch
Merge
Klasyczny merge dołącza zmiany z gałęzi D
i E
do master
a za pomocą merge commita (F
).
W historii gałęzi master
wystąpią wszystkie commity od A
do F
. Taka historia ma kilka wad:
Wady klasycznego Merge
- Do mastera dołączane są nasze commity cząstkowe, nie zawsze chcemy aby inni deweloperzy wiedzieli nasze commity cząstkowe (na rysunku powyżej
D
iE
), zaciemnia to historię zmian i utrudnia proces przeglądu kodu w ramach Code Review Pull Reguestów. - Oprócz naszych commitów cząstkowych pojawia się dodatkowy merge commit (chyba, że jest możliwość przeprowadzenia Fast-Forward, o którym pisałem w poprzednim artykule).
- Commity są poprzeplatane – zgodnie z czasem ich wykonania – w gałęzi
master
pojawi się więc mętlik w postaci: mój commit, commit innego dewelopera, mój commit, itd. nie jest to czytelne ani zbyt logiczne, ponieważ – o ile nie zaciągaliśmy w międzyczasie mastera – to przecież commitów innego developera nie braliśmy pod uwagę podczas pracy – zajęliśmy się nimi dopiero podczas rozwiązywania ewentualnych konfliktów podczas złączania.
Zalety klasycznego Merge
- Klarowna historia powiązań między gałęziami. Dzięki Merge Commitom dokładnie wiemy, która gałąź weszła do której i w jakim czasie. Widać to wyraźnie na wykresie w narzędziach graficznych.
Squash
Najprostszą, a w praktyce najbezpieczniejsza opcją jest squashing commitów.
Załóżmy, że pracujemy na gałęzi naszej funkcjonalności o nazwie UC93_AddMoneyAPI
zrobiliśmy na niej kilkanaście commitów w ciągu ostatnich kilku dni i jesteśmy gotowi oddać naszą pracę i dowiązać ją do gałęzi master
(załóżmy, że tak nazywa się nasza główna gałąź, pomijam też fakt, że taki merge powinniśmy realizować poprzez Pull Request).
git checkout master
git merge --squash UC93_AddMoneyAPI
Wszystkie zmiany zrealizowane w gałęzi UC93_AddMoneyAPI
zostaną spakowane jako jeden commit i dołączone do gałęzi master
.
Dzięki temu otrzymamy liniowa historię commitów oraz unikniemy merge commita, tracimy jednak informację o pochodzeniu zmiany – nie mamy informacji, z której gałęzi ona pochodzi.
SQUASH – Tworzymy nowy commit zawierający wszystkie zmiany z naszej gałęzi.
Squash
Squash jest bezpiecznym sposobem przekazywania zmian z jednej gałęzi do drugiej – wykonując squash, podobnie jak merge – na pewno nie stracimy żadnych zmian.
Jak widać na rysunku powyżej zmiany z commitów D
i E
zostały spakowane w jeden commit F
, który został dołączony do gałęzi master. Jest to bardzo wygodny i czytelny sposób przenoszenia zmian. Git po prostu analizuje jakie zmiany wykonaliśmy w danej gałęzi (technicznie – weryfikuje różnice pomiędzy stanem obecnym, a wspólnym przodkiem) i pakuje je w jeden commit, commit ten jest następnie dołączany do gałęzi, do której wykonujemy merge.
Jeśli pojawią się konflikty, GIT najpierw poprosi o ich rozwiązanie i dopiero później wykona commit.
Można więc powiedzieć, że generujemy dedykowany, nowy commit, który zawiera kumulatywną kopię wszystkich zmian wprowadzone na gałęzi Feature
. Po wykonaniu squash commita – commity z gałęzi Feature
przestają być nam potrzebne ponieważ kopia zmian jest zawarta w gałęzi głównej (F
na rysunku powyżej) możemy więc spokojnie usunąć gałąź rozwojową (Feature
). Gałąź Feature jest osierocona.
git checkout Master
$ git merge --squash Feature
$ git commit -m "Squashed"
Jak widać na przykładzie powyżej po komendzie merge z parametrem squash wykonujemy commita dołączającego skumulowane zmiany. Mamy wtedy okazję napisac rozbudowany komentarz opisujący zmiany, komentarz ten będzie widoczny dla innych programistów.
Squash na piechotę
Czy możemy wykonać squash inaczej? Możemy! Czasami na rozmowach rekrutacyjnych zdarzało mi się zadawać pytanie „w jaki sposób wprowadzić zmiany zawarte w 3 ostatnich commitach do gałęzi głównej w taki sposób, aby gałąź główna zawierała tylko jeden commit zawierający wszystkie nasze zmiany„. Innymi słowy: „jak scalić 3 ostatnie commity w jeden?”. Znów można zrobić to na conajmniej 2 sposoby. Omówimy teraz najprostszy!
git checkout Feature
git reset --soft HEAD~3
git commit -m "Squashed 3 last commits"
git reset
Jak to zadziała. Przypominam jak działa git reset:
git reset –soft | Cofa nas do commitu podanego jako parametr oraz: – pozostawia na dysku zmienione pliki, – pliki są automatycznie zastageowane (zawarte w stage shapshot, czyli stage index nie jest modyfikowany) | Innymi słowy: następuje wycofanie polecenia commit , nie wycofujemy polecenia add . |
git reset –hard | Cofa nas do commitu podanego jako parametr oraz fizycznie usuwa wszystkie zmiany w plikach z dysku. | Innymi słowy: wycofuje commit i zmiany w plikach. |
git reset | Inaczej git reset –mixed (jest to domyślny tryb polecenia reset). Cofa nad do commitu podanego jako parametr oraz: – pozostawia zmienione pliki na dyskum, – pliki nie są zastageowane (nie są zawarte w stage shapshot, stage snapshot zostaje przywrócony tam gdzie przywracany jest HEAD). | Innymi słowy: następuje wycofanie polecenia commit oraz polecenia add . |
Nieco innymi słowy:
--soft
: uncommit changes, changes are left staged (index).--mixed
: (default): uncommit + unstage changes, changes are left in working tree.--hard
: uncommit + unstage + delete changes, nothing left.
Co więc wykona nasze polecenie?
Zresetuje stan historii GIT do stanu sprzed 3 commitów, a wszystkie zmiany pozostaną zastageowane w lokalnym repositorium, gotowe co zacommitowania. W ten sposób złączyliśmy zmiany z 3 commitów w jedną zmianę.
Na co należy uważać:
- Przed komendą reset upewnijmy się, że nasze zmiany są zaccomitowane lub znajdują się w stash.
- Reset wykonujemy tylko na naszej, lokalnej gałęzi, nie resetujemy głównej gałęzi (np. master), do której piszą także inni developerzy.
- Jeśli nasze zmiany wypchnęliśmy z lokalnej gałęzi na serwer – prawdopodobnie pojawi się konflikt podczas próby
push
’a, musimy wtedy nadpisać zdalną historię historią lokalną:poleceniem:
git push --force
Kiedy przydaje się użyć reset zamiast squash:
Jeśli z jakiś powodów nie chcemy używać squash merge
tylko „zwykły” merge
(np. chcemy aby do głównej gałęzi powędrowało kilka commitów, ponieważ łatwiej będzie się robiło Pull Request’y), ale nie chcemy przekazywać do głównego repozytorium wszystkie naszych commitów cząstkowych.
REBASE
Dochodzimy do komendy o dużych możliwościach, często trudnej do zrozumienia , mogącej sporo napsuć w projekcie, ulubionej przez rekruterów… 🙂
Jednak w praktyce jest ona przydatna i łatwa do zrozumienia!
Co to jest cherry-pick?
Cherry-pick to kolejne przydatne polecenie GITa służące do swobodnego kopiowanie commitów pomiędzy gałęziami, np.:
git checkout Feature
git git cherry-pick ba0510c
Przenosimy się na gałąź Feature
i kopiujemy commit o hashu ba0510c
do tej gałęzi. Należy zwrócić uwagę na słowo kopiujemy. Commit nie jest usuwany, dowiązywany symbolicznie czy jakkolwiek inaczej – tylko zwyczajnie kopiowany.
Otrzymuje nowy hash i nie jest w żaden sposób powiązany z gałęzią źródłową.
Dodatkowo, przydatną opcją jest parametr --no-commit
:
git cherry-pick ba0510c --no-commit
Opcja ta kopiuje zmiany ze wskazanego commitu do naszego lokalnego repozytorium i nie wykonuje commitu. Daje to możliwość wprowadzenia dodatkowych zmian lub połączenia ich z już istniejącymi.
Rebease
Rebease to w najprostszym wydaniu skopiowanie szeregu commitów z jednej gałęzi do drugiej!
Taki sam efekt moglibyśmy osiągnąć powielając procedurę chery-pick na serii commitów.
Kluczowe do zapamiętania: commity są kopiowane, mają nowe hashe, nie są powiązane z commitami źródłowymi. To całkowicie nowe commity. Ma to kilka następstw, o których powiemy dalej i należy o tym pamiętać!
Nieco mądrzej: Rebase to procedura zmiany bazy naszej gałęzi z jednego commita na inny, sprawiająca, że rodzic naszej gałęzi ulega modyfikacji, finalnie w GIT mamy stan jakbyśmy wyszli od innego commitu.
Krok po kroku można to sobie wyobrazić jako:
git checkout feature
(git pull master)
git rebase master
git checkout master
git merge feature
- Git ustala wspólnego przodka gałęzi
feature
imaster
. - GIT przenosi nasze zmiany wprowadzone na gałęzi
Feature
do tymczasowego schowka. - GIT pobiera zmiany z gałęzi
master
i dodaje je do gałęziFeature
(„ustawia wskaźnik Feature na ostatnim comicie z mastera”). - Git KOPIUJE nasze zmiany z tymczasowego schowka do gałęzi F
eature
. Kopiowanie odbywa się commit po commicie, na bieżąco rozwiązujemy konflikty.
Zmiany lądują „na końcu gałęzi.” - Wykonujemy MERGE
master
ifeature
. Co zrobi merge? Przeprowadzi zwykły Fast Forward!
Dzięki temu uzyskamy liniową historię i nie narazimy się na nadpisanie identyfikatorów commitów w gałęzimaster
co rozwścieczyłoby innych programistów!
Głównym celem rebase jest utrzymanie liniowej historii zmian. Zanim przejdziemy do szczegółów ważne ostrzeżenie:
Nie wykonuj rebease na głównej gałęzi, ani na gałęzi do której zapisują inni programiści, chyba, że masz pewność, że nikt jeszcze nie pobrał zmian!
Wynika to z tego o czym wspominałem już wielokrotnie – GIT kopiuje commity i nadaje im nowe hashe, jeśli zmienisz bazę gałęzi master
inni programiści będą mieć konflikty, gdyż ich lokalne repozytorium zawiera inne identyfikatory commitów. Bazę gałęzi, których używasz tylko Ty możesz zmieniać bezkarnie, ponieważ nie ma ryzyka wystąpienia konfliktów.
Scenariusz, w którym rekomenduje używanie Rebase:
Prowadzisz prace rozwojowe nad nowym przypadkiem użycia w aplikacji, działasz na branchu Feature
. Chcesz dociągnąć do tego brancha aktualne zmiany z gałęzi głównej master
(ponieważ inni programiści wprowadzili tam zmiany), nie chcesz jednak „psuć historii” zmian swojej gałęzi – nie chcesz aby została ona przepleciona zmianami z gałęzi master
. Poza tym – przeniesienie zmian z mastera
wyeliminuje konflikty na etapie scalania twojej gałęzi z master (wystarczy Fast-Forward).
Jeśli działamy w gałęzi Feature
od dawna i zmiany pobieramy często – szczególnie warto używać rebase, ponieważ zwykły merge za każdym razem wytworzy nieeleganckiego nam Merge-Commita.
Konflikty
Podczas procesu rebase commity są przenoszone pojedynczo: „jeden po drugim”, jeśli pojawią się konflikty musimy je rozwiązywać na bieżąco, co oznacza, że jeśli mamy konflikty w plikach rozsianych pomiędzy różnymi commitami – konflikty będzie trzeba rozwiązywać wielokrotnie.
Podczas klasycznego merge tworzony jest pojedynczy merge commit, w którym rozwiązujemy konflikty, w rebase robimy to na bieżąco.
Technicznie – podczas rozwiązywania konfliktów w procedurze Rebase znajdziemy się w stanie z odłączoną głowa (detached HEAD), po rozwiązaniu konfliktu kontynuujemy rebase komendą git rebase --continue
.
W najprostszym ujęciu to tyle.! 🙂
Interaktywny Rebase
Jak już wiemy Rebase kopiuje commity daj nam więc „czysta kartę” i możemy w trakcie tego kopiowanie troche poszaleń, np.:
- pominąć commit,
- zmienić kolejność commitów,
- zmienić komentarz (message) dla danego commitu,
- wykonać squash kilku commitow(!),
- wykonać edycje zawartości commitu
Jest to okazja nieco posprzątam na naszej lokalnej gałęzi zanim wykonamy merge.
git rebase --interactive master
Wykonuje interaktywny rebase bieżącej gałęzi z master.
GIT otwiera (zdefiniowany) edytor tekstowy i umożliwia nam zdecydowanie co robimy:
Przy każdym comicie wpisujemy polecenie, które ma zostać wykonane i zapisujemy plik.
Następnie wykonujemy commit zmian. Jeśli zrozumieliście idee rebase i squash bardziej rozległy komentarz jest zbędny. 🙂
Opcja -e (edit)
Szczególnie użyteczna jest opcja EDIT (-e)
powoduje ona zatrzymanie rebase – podobnie jak w przypadku napotkania konfliktów – znajdziemy się w stanie z odłączonym HEAD i możemy, np.:
- edytować zawartość danego commita poprzez
git commit --amend
- dodać kolejne commity pomiędzy (zwyczajnie wykonujemy
git add * && git commit -m
) - kontynuujemy rebase:
rebase --continue
Czyszczenie lokalnej historii
Interaktywny rebase możemy również wykorzystać z powodzeniem do „wyprostowania” lokalnej historii zmian, np:
git rebase -i HEAD~3
Spowoduje interaktywny rebase dla 3 ostatnich commitów licząc od aktualnego położenia HEAD
. Jest to okazja aby poprawić historie 3 ostatnich commitów.
Wyszedłem ze złego brancha. Ratujcie mnie!
Git Rebase –onto
Kto z nas nie słyszał w zespole siarczystych pozdrowień i stwierdzenia, że „wyszedłem ze złego brancha”. Case study:
Programista pracował nad funkcjonalnością Feature1 w branchu o tej samej nazwie, przyszedł do niego szef z prośbą aby dorobić mało funkcję w głównej gałęzi rozwojowej. Programista nie myśląc wiele wpisał git checkout -b "SimpleFeature2"
i wykonał 3 commity załatwiające sprawę. Zadowolony chce domergować swoją pracę do mastera i co się okazuje? Wyszedł z brancha Feature1,
a nie z mastera
! Programista nie chce aby już teraz zmiany z Feature1
weszły do mastera
, chce natomiast wprowadzić do niego zmiany z SimpleFeature2
. Co robić, jak żyć..?
Opcje są dwie (nie licząc partyzanckich pomysłów z wykorzystaniem notatnika :)):
- Cherry pick commitów do mastera, lub – bardziej poprawnie – utworzenie nowej gałęzi z mastera, cherry-pick commitów z
SimpleFeature1
do tej nowej gałęzi i merge nowej gałęzi do mastera. To rozwiązanie jest OK, jednak cherry-pick commitów może zająć trochę czasu w przypadku gdy mamy ich więcej.
Wspominałem jednak, że rebase to własnie taki zwielokrotniony chery pick, coś w tym jest! Użyjmy go! - Rebase, ale bardziej inteligentny – z wstawieniem informacji odkąd dokąd kopiować.
Rebase –onto – selektywne kopiowanie
Składnia jest następująca:
git rebase -- onto nowa_baza stara_baza punkt_odniesienia (domyślnie HEAD)
W omawianym przypadku wystarczy więc:
git rease --onto master Feature SimpleFeature1
W efekcie commity z SimpleFeature1
zostaną wstawione za masterem.
master
– nowa baza
Feature
– stara baza
SimpleFeature1
– na jakiej gałęzi działamy „gdzie ma wskazywać HEAD nowej bazy”. Argument jest opcjonalny jeśli bylibyśmy aktualnie na SimpleFeature
1.
Ważne uwagi
Rebase kopiuje commity nadając im nowe identyfikatory dlatego nigdy nie robimy REBASE na gałęziach, które pobrał już ktoś inny.
Jeśli ktoś zrobi rebase na masterze rebaseowane commity otrzymają nowe identyfikatory. Programiści, którzy będą pobierać zmiany będą mieć konflikty, ponieważ dla GITa to całkiem nowa partia zmian! Tak wiem, pisze o tym 4 raz, ale uwierz mi – wolisz uniknąć tej pomyłki!
Ratunku! Ktoś Rebaseował mastera, mam same konflikty!
Publikacja produkcji idzie za chwile. Co robić?!
Cóż… Jesteś w …. pewnym kłopocie… Jedyny ratunek jaki widzę to reflog
, a konkretnie: cofnąć się za pomocą refloga zdalnej gałęzi do miejsca przed rebase a następnie rebase mastera --onto
commit przed felernym rebase. 🙂
Da się to zrobić i działa, ale trzeba wiedzieć jak to działa, próba rebaseowania z refloga w szaleńczym amoku po popsuciu czegoś nie jest dobrym pomysłem. 🙂
Dzisiaj omówiliśmy Rebase w różnych ujęciach, nie jest to (niestety) wyczerpanie tematu. Stay Tuned! 🙂
Świetny artykuł, chyba najbardziej wyczerpująco opisałeś temat w całej polskiej blogosferze! 🙂
Leci sub bloga, Pisza dalej!
Czy w praktyce częściej robimy squash czy rebase. Co jest lepsze w projekcie?
To zależy! 🙂 Jak do końca nie wiesz co jest lepsze to lepiej korzystać ze squasha 0 jest mniej inwazyjny.
Generalnie – ja lubię dość często robić rebase mojej gałęzi rozwojowej z masterem (wiążę świeżego mastera jako rodzica mojej gałęzi).