Robisz pull najnowszego main, a Git zatrzymuje się z CONFLICT (content): Merge conflict in checkout.js. Rozwiązujesz konflikt, a w logu pojawia się commit o nazwie Merge branch 'main' into feature/checkout, leżący na trzech commitach, które faktycznie napisałeś. Cztery commity, jedna prawdziwa zmiana. Kolega, który za miesiąc przeczyta tę historię, nie ma szans rozpoznać, który jest który.
git merge i git rebase odpowiadają na to samo pytanie: jak zaktualizować swój branch o zmiany zrobione gdzieś indziej. Zostawiają za sobą bardzo różne ślady, a wybranie złego na wspólnym branchu robi realną szkodę.
Jak rebase i merge inaczej zmieniają graf commitów
Załóżmy, że main dostał commity D, E, F, podczas gdy ty pracowałeś na feature, który ma twoje własne commity A, B, C, odgałęzione od starego końca main:
| |
git merge main, uruchomiony z feature, tworzy nowy commit z dwoma rodzicami: końcem feature i końcem main. W A, B ani C nic się nie zmienia. Te same hashe, ci sami rodzice, to samo miejsce w grafie:
| |
git rebase main, uruchomiony również z feature, robi coś odwrotnego. Zostawia main nietknięty i odtwarza A, B i C jeden po drugim na F: dla każdego powstaje nowy commit, A’, B’ i C’, z nowym hashem i nowym rodzicem:
| |
Wynik wygląda dokładnie tak, jakbyś odgałęził się od F od samego początku i nigdy się nie rozjechał z main. W tym cały urok: liniowa historia, żadnych commitów mergujących w logu. Koszt jest taki, że A, B i C przestają istnieć z punktu widzenia Gita. Zastępują je A’, B’ i C’, a każdy, kto wcześniej pobrał A, B lub C, ma teraz commity, które już nie pasują do twoich.
Czym jest fast-forward merge i kiedy Git go używa zamiast commitu mergującego
Merge potrzebuje commitu mergującego tylko wtedy, gdy oba branche się przesunęły. Jeśli feature odgałęził się od F, a main od tamtej pory się nie ruszył, połączenie feature z main nie ma nic do pogodzenia: Git po prostu przesuwa wskaźnik main do C i się zatrzymuje. To jest fast-forward i zostawia taką samą płaską historię jak rebase:
| |
Git tworzy commit mergujący tylko wtedy, gdy obie strony dodały commity, których druga nie ma. Niektóre zespoły i tak wymuszają jeden, żeby każdy feature branch zostawiał widoczny ślad w main w miejscu, gdzie został zintegrowany:
| |
Jak uporządkować commity za pomocą rebase interaktywnego
Rebase może też edytować twoją własną historię, bez udziału żadnego innego brancha. git rebase -i otwiera ostatnie N commitów w edytorze, jedna linia na commit:
| |
| |
Zmień słowo na początku linii, a Git zastosuje odpowiednią akcję, gdy rebase ruszy:
| Polecenie | Efekt |
|---|---|
pick | Zostawia commit bez zmian |
reword | Zostawia commit, pozwala zmienić jego opis |
squash | Łączy go z poprzednim commitem, scalając oba opisy |
fixup | Łączy go z poprzednim commitem, odrzucając ten opis |
drop | Usuwa commit całkowicie |
edit | Zatrzymuje rebase w tym miejscu, żebyś mógł ręcznie poprawić commit |
Zamień tę listę na squash e4f5a6b fix typo i fixup c7d8e9f WIP: still debugging, a zamiast trzech commitów, które mają sens tylko w kolejności, w jakiej je napisałeś, dostajesz jeden: Add checkout validation. Zrób to przed otwarciem pull requesta, nie po tym, jak ktoś już pobrał twój branch — squash przepisuje hash każdego kolejnego commitu, tak samo jak każdy inny rebase.
Kiedy poprawiasz wcześniejsze commity na bieżąco, git commit --fixup <hash>, a potem git rebase -i --autosquash <base>: to polecenie samo przenosi każdy fixup obok jego celu i oznacza go, więc nigdy nie musisz ręcznie przestawiać listy.
Jak git pull --rebase utrzymuje twój branch płaskim
Ten sam wybór wraca przy każdym pull. Zwykły git pull na branchu, na którym masz lokalne commity, podczas gdy zdalny branch poszedł do przodu, robi najpierw fetch, a potem merge. Tak w historii ląduje commit Merge branch 'main' into feature/checkout, wyłącznie z powodu złego timingu. git pull --rebase pobiera te same commity i odtwarza twoje na wierzchu:
| |
Niezacommitowane zmiany sprawiają, że rebase odmawia startu. Dodaj --autostash, a Git schowa je na bok, zrobi rebase, a potem je z powrotem zaaplikuje:
| |
Ustaw to raz, zamiast wpisywać flagę za każdym razem, dla konkretnego brancha albo globalnie:
| |
To jest bezpieczne, bo przepisuje tylko commity, które istnieją wyłącznie na twoim lokalnym branchu. W momencie, gdy przestaje to być prawdą, rebase przestaje być bezpieczny.
Złota zasada: nigdy nie rób rebase commitów, które ktoś inny już pobrał
Rebase przepisuje hashe commitów. Jeśli zrobisz rebase brancha, którego stare commity ktoś inny już ma — pobrał twój branch, to jest main, albo dowolny branch, przy którym pracuje więcej niż jedna osoba — jego historia i twoja przestają się zgadzać co do tego, czym jest ten branch. Git nie ma sposobu, żeby wiedzieć, że twój A i jego A’ to ta sama logiczna zmiana. Widzi dwa niepowiązane commity, a kolejny merge między obiema kopiami duplikuje wszystko, co się rozjechało.
Zasada, która tego unika: rób rebase tylko branchy, na których nikt inny jeszcze nic nie zbudował. Twój własny branch z feature, zanim dałeś komuś hash, na którym mógłby budować, to wolny teren. Branch, który już wypchnąłeś i na który wskazałeś kolegom, main, każdy długo żyjący wspólny branch: te się merguje, nigdy nie rebasuje.
Rebase brancha, który już wypchnąłeś, wymaga wymuszonego pusha, żeby zdalne repozytorium zaakceptowało przepisaną historię:
| |
--force-with-lease odrzuca push, jeśli zdalne repozytorium ma commity, których jeszcze nie pobrałeś — dokładnie sytuacja, w której ktoś zbudował coś na starej historii, a gołe --force po cichu wyrzuciłoby jego pracę. Nigdy gołego --force, i tylko na branchu, co do którego masz pewność, że jest wyłącznie twój.
Jak odzyskać dane po nieudanym rebase za pomocą git reflog
Rebase na złej bazie albo rebase interaktywny, w którym odrzuciłeś niewłaściwy commit, wygląda na sytuację bez wyjścia: commity, które miałeś minutę temu, już nie pojawiają się w git log. Nie zniknęły. git reflog zapisuje każdą pozycję, na którą HEAD wskazywał na twojej maszynie, łącznie z rebase’ami:
| |
| |
HEAD@{3} to miejsce, na które twój branch wskazywał tuż przed rozpoczęciem rebase. Zrób reset do tego punktu, a wrócisz dokładnie do wcześniejszego stanu, łącznie z odrzuconymi commitami:
| |
Wpisy reflog domyślnie wygasają po 90 dniach, albo po 30 dniach dla commitów, na które nie wskazuje już żadna inna referencja. To siatka bezpieczeństwa na ostatnią nieudaną godzinę, nie stałe przechowywanie. Do tego działa tylko lokalnie: nigdy nie jest wypychany i nie potrafi cofnąć przepisania historii, które ktoś inny już pobrał.
Ten sam odruch — powrót do znanego, działającego stanu po nieudanej zmianie — sprawdza się też poza historią Gita. Deployment w Kubernetesie z tego samego powodu trzyma własną historię rolloutów, a kubectl rollout undo robi dla nieudanego deploya to, co git reset --hard na wpisie reflog robi dla nieudanego rebase.
Git rebase vs merge: co i kiedy wybrać
| Sytuacja | Wybierz |
|---|---|
Zaktualizowanie lokalnego, jeszcze niewypchniętego brancha o zmiany z main | rebase (albo pull --rebase) |
| Uporządkowanie własnych commitów przed otwarciem pull requesta | rebase -i |
Zintegrowanie skończonego brancha z feature do main | merge (z --no-ff lub bez, zależnie od konwencji zespołu) |
Branch jest wspólny, wypchnięty, albo jest samym main | merge — nigdy rebase |
Musisz dokładnie wiedzieć, kiedy feature trafił do main | merge --no-ff — commit mergujący to zaznacza |
| Chcesz liniową historię bez commitów mergujących w logu | rebase po swojej stronie, przed otwarciem pull requesta |
Decyzja tak naprawdę dotyczy tego, kto jeszcze widział te commity. Tylko ty? Rebase, i trzymaj czystą historię. Co najmniej jedna inna osoba? Merge. Trochę bardziej bałaganiarski log nic cię nie kosztuje; przepisana wspólna historia kosztuje każdego, kto ją pobrał, całe popołudnie.
Jeśli twój zespół nie ma jeszcze żadnej konwencji, zacznij tutaj: pull.rebase = true lokalnie, rebase -i, żeby uporządkować commity przed otwarciem pull requesta, a potem zwykły merge, żeby wprowadzić je do main. Dostajesz czystą historię dla każdego brancha, nigdy nie przepisując commitu, od którego ktoś inny zależy, łącznie z CI. Workflow taki jak Budować i Publikować Obraz Docker z GitHub Actions taguje każdy build SHA commitu, a te SHA muszą dalej istnieć po tym, jak pipeline ich użył. Ustaw git config --global pull.rebase true już dziś: samo to usuwa większość przypadkowych commitów mergujących z twojego logu.