Git: utrzymanie czystej historii commitów

Pracując z repozytorium Gita, większe zespoły nieraz mają kłopot z utrzymaniem zwięzłej i czytelnej historii commitów. Wiele gałęzi łączonych między sobą daje w efekcie trudny do ogarnięcia bałagan. W niniejszym wpisie prezentuję rozwiązanie tego problemu.

Pracowałem już w niejednym zespole i bardzo często okazuje się, że większość osób korzystających z repozytorium Gita nie jest zaznajomiona z co ciekawszymi trikami. Użyjmy umownie imion dla programistów: Alicji i Bartka. Typowy flow wygląda następująco:

  1. Alicja tworzy gałąź z mastera i umieszcza na niej pewną liczbę commitów.
  2. W tym samym czasie do mastera dołączony jest PR Bartka, tworząc konflikt.
  3. Alicja w celu zażegnania konfliktów dołącza mastera do swojej gałęzi, tworząc tym sposobem nowy commit.
  4. Następnie następuje merge gałęzi Alicji do mastera.

Poniżej przedstawiam przykładowy wykres podobnej sytuacji, biorąc pod uwagę tylko mastera i gałęzie Alicji i Bartka.

Typowy schemat pracy
1. Typowy schemat pracy

Jak widać na wykresie, gałęzie są utworzone z różnych commitów bazowych. Gałąź Bartka jest domerdżowana do mastera wcześniej. Zhgodnie z opisanym wyżej flow, Alicja merdżuje mastera do swojej gałęzi, a dopiero później z powrotem merdżuje ją do mastera.

Nietrudno sobie wyobrazić, że w większych zespołach jest to znacznie bardziej złożone: wiele jednocześnie istniejących gałęzi może zmieniać ten sam kod, tworząc wiele potencjalnych konfliktów. Liczba commitów na każdej gałęzi również może być o wiele większa.

Usprawnienie 1: rebase

Zacznę może od tego, czym jest rebase. W najprostszym przypadku polega na zmianie commitu bazowego gałęzi. Gałąź Alicji ma commit bazowy (czyli ten, z którego jest tworzona) sprzed utworzenia i merdża gałęzi Bartka. Aby uniknąć merdżowania mastera do gałęzi Alicji, należałoby utworzyć jej gałąź po merdżu gałęzi Bartka, czyli zmienić jej commit bazowy. Stąd rebase: zmiana bazy gałęzi.

W historii commitów Gita wygląda to następująco:

Commity uporządkowane za pomocą rebase
2. Commity uporządkowane za pomocą rebase

Co zyskujemy w ten sposób? Na masterze nie ma żadnych commitów między utworzeniem gałęzi a jej zmerdżowaniem, co daje pożądane rezultaty:

  • między rozgałęzieniem a merdżem nie zmienia się stan mastera, więc nie ma konfliktów,
  • w historii commitów łatwo znaleźć serię commitów danego użytkownika w obrębie danego zadania,
  • commitów jest mniej, unikamy bowiem merdżowania mastera do gałęzi.

Sam rebase może oczywiście stworzyć konflikty. Pamiętajmy, że każdy commit to nic innego niż tzw. changeset, czyli zestaw instrukcji przekształcenia stanu początkowy każdej zmienionej linii kodu w wyjściowy. Jeśli nagle zmiany te zaaplikujemy do innego stanu gałęzi bazowej, może się okazać, że natrafimy na konflikty: stan bazowy zakładany przez zestaw zmian jest inny od faktycznego, więc Git nie wie, który ze stanów jest prawidłowy. Rebase jednak daje nam możliwość usunięcia konfliktów (co wiąże się z modyfikacją już istniejących commitów), nie jest więc konieczne merdżowanie gałęzi między sobą.

Alicja wpisuje zatem następujące polecenia:

git checkout master
git pull
git checkout alicja
git rebase master

Ważne jest wstępne zaktualizowanie lokalnej gałęzi master. Dysponując aktualnym jej stanem, Alicja może zmienić bazowy commit swojej gałęzi na aktualny ostatni commit mastera.

Samo polecenie rebase przyjmuje obowiązkowy argument: nowy commit bazowy. Może to być hash konkretnego commita, nazwa tagu, nazwa gałęzi (w tym przypadku Git zakłada, że chodzi o tzw. wierzchołek, czyli tip — ostatni znany commit tej gałęzi) lub wyrażenie w stylu „n commitów przed danym commitem”. Ten ostatni przypadek przedstawię później. W tym przypadku Alicja przesuwa swoją gałąź na wierzchołek mastera.

Zakładając, że nie wystąpią konflikty, to będzie koniec zabawy. Może się jednak zdarzyć, że zmiany na gałęzi Alicji konfliktują z aktualnym stanem mastera. W takiej sytuacji Git poinformuje Alicję o konflikcie i zaczeka, aż ta go rozwiąże:

First, rewinding head to replay your work on top of it…
Applying: alicja change 1
Using index info to reconstruct a base tree…
M       hello.txt
Falling back to patching base and 3-way merge…
Auto-merging hello.txt
CONFLICT (content): Merge conflict in hello.txt
error: Failed to merge in the changes.
Patch failed at 0001 blue change 1
The copy of the patch that failed is found in: .git/rebase-apply/patch

When you have resolved this problem, run "git rebase --continue".
If you prefer to skip this patch, run "git rebase --skip" instead.
To check out the original branch and stop rebasing, run "git rebase --abort".

Polecenie git status wyświetli wszystkie skonfliktowane pliki oznaczone jako „both modified”. Wewnątrz skonfliktowanego pliku znajdzie się dokładne oznaczenie konfliktu z wyszczególnionym stanem z gałęzi bazowej oraz oczekiwanym stanem końcowym gałęzi merdżowanej/rebasowanej:

<<<<<<< 7839e8313759ec8113c93f124d4f9a703c4fe701
hello Bartek
=======
hello Alicja
>>>>>>> Alicja change 1

Zadaniem Alicji jest określenie, który stan jest pożądany i doprowadzenie pliku do niego. Po zażegnaniu konfliktu, pozostaje poinformować Gita, że plik już nie zawiera konfliktów i że można kontynuować operację:

git add hello.txt
git rebase --continue

Usprawnienie 2: squash

Czyszczenie historii można posunąć o krok dalej za pomocą squashowania. Squashowanie polega na połączeniu kilku commitów w jeden. Rezultat wygląda następująco:

Commity połączone za pomocą squash
3. Commity połączone za pomocą squash

Jak widać, gałąź Alicji zawiera teraz tylko jeden commit. Wszystkie zmiany, jakie zostały wprowadzone są teraz częścią tego pojedynczego commita. Jak zatem to osiągnąć?

Z technicznego punktu widzenia to nadal rebase, ale należy tu skorzystać z dodatkowych możliwości tego narzędzia. Gałąź Alicji zawiera dokładnie dwa commity, zatem Alicja wpisuje polecenie:

git rebase -i HEAD~2

Przypomnijmy: poprzednim razem jako argument użyty był master, czyli rebase do aktualnego stanu gałęzi master. Tym razem jest to HEAD~2, czyli „2 commity wstecz od najnowszego commitu”. Alicja podaje liczbę 2 znając liczbę commitów swojej gałęzi. Łatwo mogła ją sprawdzić, choćby poleceniem git log. Mogła też podać większą liczbę n, co poskutkowałoby włączeniem również ostatnich n-2 commitów z gałęzi bazowej. W połączeniu z przełącznikiem -i nie ma to specjalnego znaczenia, gdyż dodatkowe commity można zwyczajnie zostawić w spokoju.

Przełącznik -i (lub --interactive) umożliwia użycie trybu interaktywnego. W tym trybie można dokładnie określić, które commity należy zostawić w spokoju, a które połączyć. Dzieje się to poprzez otwarcie pliku tekstowego w edytorze. Po otwarciu go, Git wyświetla coś podobnego:

pick d66b00e Alicja change 1
pick 1e53128 Alicja change 2

# Rebase 7839e83..1e53128 onto 7839e83 (2 command(s))
#
# Commands:
# p, pick = use commit
# r, reword = use commit, but edit the commit message
# e, edit = use commit, but stop for amending
# s, squash = use commit, but meld into previous commit
# f, fixup = like "squash", but discard this commit's log message
# x, exec = run command (the rest of the line) using shell
# d, drop = remove commit
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.
#
# Note that empty commits are commented out

Zgodnie z zawartymi instrukcjami, gałęzie oznaczone jako pick będą użyte (czyli zostawione w spokoju, ewentualnie zastosowane jako commit bazowy, jeśli po nich nastąpi squash). Te oznaczone jako squash zostaną dołączone do poprzedzającego je commita oznaczonego jako pick.

Alicja chce połączyć oba commity w jeden, pierwszy zostawia zatem oznaczony jako pick, a drugi oznacza jako squash:

pick d66b00e Alicja change 1
squash 1e53128 Alicja change 2

Po zapisaniu pliku, Git połączy wybrane commity i pozwoli edytować komentarz do powstałego w ten sposób nowego commita. Dzieje się to również interaktywnie, a więc poprzez otwarcie pliku tekstowego w edytorze:

# This is a combination of 2 commits.
# The first commit's message is:

Alicja change 1

# This is the 2nd commit message:

Alicja change 2

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
# Date:      Mon Jun 12 12:09:46 2017 +0200
#
# interactive rebase in progress; onto 7839e83
# Last commands done (2 commands done):
#    pick d66b00e Alicja change 1
#    squash 1e53128 Alicja change 2
# No commands remaining.
# You are currently editing a commit while rebasing branch 'blue' on '7839e83'.
#
# Changes to be committed:
#   modified:   hello.txt
#

Zgodnie z opisem, wszystkie zakomentowane znakiem # linie nie będą częścią wiadomości commita. Pozostałe utworzą nową wiadomość. Najlepiej zatem zakomentować nadmiarowe linie i wpisać nowy komentarz w pojedynczej linii:

# This is a combination of 2 commits.
# The first commit's message is:

Alicja changes

# This is the 2nd commit message:

# Alicja change 2

Po zapisaniu pliku, Git wyświetli nam informację o udanym rebase:

[detached HEAD 03050b1] Alicja changes
 Date: Mon Jun 12 12:09:46 2017 +0200
 1 file changed, 1 insertion(+), 1 deletion(-)
Successfully rebased and updated refs/heads/blue.

Dodatkowe porady

  1. Należy zawsze pamiętać, że rebase to destruktywna (nieodwracalna) operacja. Usuwa ona trwale commity i tworzy nowe, z nowymi hashami SHA1. Trzeba zatem zachować szczególną ostrożność podczas korzystania z rebase. A najlepiej…
  2. …A najlepiej pracować nie na głównym repozytorium, a na własnym jego forku. Nawet jeśli coś się zepsuje, nie zrobimy krzywdy głównemu repozytorium.
  3. Nie wolno używać rebase na gałęzi master. Przenigdy i pod żadnym pozorem. Rebase mastera oznaczałby, że wszyscy programiści będą nagle mieli nieaktualny stan mastera (co jest upierdliwe, choć da się z tego wyjść obronną ręką), a dodatkowo możemy poważnie zaszkodzić wszystkim forkom repozytorium. Commity na masterze nie mogą być usuwane, mogą być tylko dodawane (poprzez merge z gałęzi lub, w razie potrzeby, tzw. revert commits, czyli commity odwracające zmiany z poprzednich commitów — to jednak materiał na oddzielny wpis).
  4. Możemy użyć polecenia łączącego obie opisane operacje: git rebase -i master. Pozwoli ono na przeniesienie gałęzi na koniec mastera w trybie interaktywnym, umożliwiającym m.in. łączenie commitów. Może to być użyteczne, jednak pamiętać trzeba, że zmiana commita bazowego zawsze wiąże się z możliwością wystąpienia konfliktów.
  5. Jeśli gałąź, na której pracujemy, jest już wypchnięta na Stasha (albo GitHuba, czy czego tam jeszcze używamy), rebase sprawi, że Git nie pozwoli nam wypchnąć zmian. Trzeba go zmusić odpowiednim przełącznikiem: git push --force. Przełącznik tem umożliwi wypchnięcie zmian i zmodyfikowanie historii commitów w zdalnym repozytorium.
  6. Rebase i squash wykonujemy przed poproszeniem o code review. Dodatkowe zmiany, powstałe przez znalezienie błędów przez recenzującego kod, zostawiamy jako oddzielne commity aż do czasu zatwierdzenia kodu. W ten sposób recenzent zobaczy, że commity odpowiadają na zostawione komentarze, a oprogramowanie używane do recenzowania kodu (Stash, Crucible…) wyświetli poprawnie zmiany w kodzie, np. oznaczy tylko zmienione pliki jako niezrecenzowane. Po uzyskaniu aprobaty można ponownie wykonać squash i rebase, jednakże…
  7. …Jednakże rozwiązywanie konfliktów jest procesem narażonym na błędy. Istnieje możliwość, że zmiany w kodzie zostaną przeoczone lub wprowadzony zostanie błąd. Po każdym rozwiązaniu konfliktów należy ponownie uruchomić wszystkie testy oprogramowania i upewnić się, że projekt się kompiluje i wszystkie testy przechodzą. Dopiero później włączamy gałąź do mastera.
  8. Jeśli rebase gałęzi z wieloma commitami w rezultacie zmusza nas do zażegnywania konfliktów w każdym kolejnym commicie, znacznie łatwiej jest najpierw zrobić squash, a dopiero potem rebase. Konflikty będą wtedy tylko w jednym commicie.
  9. Jeśli sprawy pójdą bardzo źle podczas rebasowania, zawsze można operację przerwać poleceniem git rebase --abort. Przywrócony zostanie stan repozytorium sprzed rebase, dając nam możliwość podejścia do problemu w inny sposób (np. squash wybranych commitów gałęzi).
  10. Zdarza się, że podczas squashowania nie chcemy łączyć wiadomości z kilku commitów, a jedynie zastosować pierwszą, usuwając pozostałe (np. możemy chcieć pozbyć się wiadomości w stylu „literówka” albo „poprawki po code review”). W takiej sytuacji zamiast oznaczać commity jako squash, możemy oznaczyć je jako fixup. Poza trybem ustalania wiadomości commita, fixup nie różni się niczym od squasha.

Napisz komentarz


Szukaj wpisów


Chmura tagów