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:

1
2
3
      A---B---C  feature
     /
D---E---F  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:

1
2
3
      A---B---C
     /         \
D---E---F-------M  feature (commit mergujący)

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:

1
2
3
D---E---F  main
         \
          A'---B'---C'  feature

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:

1
2
3
4
5
6
Przed:   D---E---F  main
                  \
                   A---B---C  feature

Po (fast-forward merge feature do main):
D---E---F---A---B---C  main

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:

1
git merge --no-ff feature/checkout

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:

1
git rebase -i HEAD~3
1
2
3
pick a1b2c3d Add checkout validation
pick e4f5a6b fix typo
pick c7d8e9f WIP: still debugging

Zmień słowo na początku linii, a Git zastosuje odpowiednią akcję, gdy rebase ruszy:

PolecenieEfekt
pickZostawia commit bez zmian
rewordZostawia 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
dropUsuwa commit całkowicie
editZatrzymuje 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:

1
git pull --rebase origin main

Niezacommitowane zmiany sprawiają, że rebase odmawia startu. Dodaj --autostash, a Git schowa je na bok, zrobi rebase, a potem je z powrotem zaaplikuje:

1
git pull --rebase --autostash origin main

Ustaw to raz, zamiast wpisywać flagę za każdym razem, dla konkretnego brancha albo globalnie:

1
2
git config branch.feature/checkout.rebase true
git config --global pull.rebase true

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ę:

1
git push --force-with-lease origin feature/checkout

--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:

1
git reflog
1
2
3
4
a1b2c3d HEAD@{0}: rebase (finish): returning to refs/heads/feature/checkout
9f0e1d2 HEAD@{1}: rebase (pick): Add checkout validation
7c8b9a0 HEAD@{2}: rebase (start): checkout main
c4d5e6f HEAD@{3}: commit: WIP: still debugging

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:

1
git reset --hard HEAD@{3}

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ć

SytuacjaWybierz
Zaktualizowanie lokalnego, jeszcze niewypchniętego brancha o zmiany z mainrebase (albo pull --rebase)
Uporządkowanie własnych commitów przed otwarciem pull requestarebase -i
Zintegrowanie skończonego brancha z feature do mainmerge (z --no-ff lub bez, zależnie od konwencji zespołu)
Branch jest wspólny, wypchnięty, albo jest samym mainmerge — nigdy rebase
Musisz dokładnie wiedzieć, kiedy feature trafił do mainmerge --no-ff — commit mergujący to zaznacza
Chcesz liniową historię bez commitów mergujących w logurebase 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.

Powiązane artykuły