Przejdź do treści
Automatyzacja i DevOps·~15 min czytania

Rozjazd lokalnego main z origin/main: dlaczego przewinięcie odmawia i jak to naprawić

Dwie linie zmiany, jedna gałąź założona ze starej kopii i scalenie, które nigdy nie opuściło laptopa. Historia jednej awarii w repozytorium konfiguracji.

Autor: Zespół LinuxLab

W LinuxLab konfigurację serwerów trzymamy w repozytoriach Git. Zanim cokolwiek wdrożymy, sprawdzamy nie tylko treść zmiany, ale też to, czy trafiła tam, gdzie sięga automatyzacja. Opisany przypadek pochodzi z naszej pracy i został pozbawiony danych klienta.

Zmiana obejmowała dwie linie w pliku konfiguracyjnym jednego serwera. Została zapisana, zatwierdzona i wysłana na serwer Git, a mimo to gałąź main jej nie zawierała. Próba scalenia zakończyła się komunikatem fatal: Nie da się przewinąć, przerywanie. Poniżej rozkładamy na części, czym jest rozjazd gałęzi, dlaczego Git odmawia jego automatycznego rozwiązania, jak wyglądała naprawa i jaka kolejność komend zapobiega powtórce.

Spis treści

  1. Punkt wyjścia
  2. Co się wydarzyło
  3. Objaw
  4. Nazwy i pojęcia
  5. Dlaczego Git odmówił
  6. Dlaczego to było groźne
  7. Diagnoza
  8. Naprawa krok po kroku
  9. Weryfikacja
  10. Poprawna ścieżka
  11. Pułapka: brak śledzenia
  12. Checklista

Punkt wyjścia: repozytorium, z którego czyta automat

Konfiguracja kilkunastu serwerów opisana jest plikami w jednym repozytorium Git. Gałąź main pełni w nim rolę źródła prawdy. Narzędzie automatyzacji bierze pliki właśnie z tej gałęzi i doprowadza serwery do stanu, który jest w nich opisany.

Ma to jedną konsekwencję, o której łatwo zapomnieć. Jeżeli ustawienie istnieje na serwerze, ale nie istnieje w gałęzi main, to następne uruchomienie automatu je usunie. Nie dlatego, że coś zawiodło, tylko dlatego, że automat robi dokładnie to, o co go prosimy: przywraca stan opisany w repozytorium.

Zespół pracuje w prostym schemacie. Nowa gałąź, jeden lub kilka commitów, wysłanie gałęzi na serwer, scalenie do main, wysłanie main. Zmiana, o której mowa, obejmowała dwie linie w jednym pliku.

Co się wydarzyło

Kolejność zdarzeń wyglądała tak:

  1. Gałąź robocza została utworzona z lokalnej kopii main. Ta kopia nie była odświeżana od dłuższego czasu i brakowało w niej czterech commitów, które w międzyczasie trafiły na serwer.
  2. Zmiana została zatwierdzona jako commit i wysłana na serwer razem z gałęzią. Ten krok zadziałał poprawnie - gałąź pojawiła się w zdalnym repozytorium.
  3. Scalenie gałęzi do main zostało wykonane, ale wyłącznie w lokalnym katalogu. Nikt go nie wysłał na serwer.
  4. Równolegle reszta zespołu wysłała na serwer cztery inne commity, niezwiązane z tą zmianą.

Powstały dwie wersje gałęzi main, obie wyrastające z tego samego wspólnego punktu:

                       9d3e70b   ← nasza zmiana, tylko na laptopie
                      /
    4f1c9ab ─────────┤   wspólny punkt (baza scalenia)
                      \
                       1a2b3c4 ─ 5e6f7a8 ─ b9c0d1e ─ e58c2d1   ← origin/main

Taki układ nazywamy rozjazdem gałęzi. Obie strony mają commity, których nie ma druga strona. Żadna nie jest po prostu „starszą wersją" drugiej.

Objaw: komenda, która odmówiła

Próba doprowadzenia lokalnej gałęzi main do stanu z serwera zakończyła się błędem:

$ git merge --ff-only origin/main
fatal: Nie da się przewinąć, przerywanie.

W wersji angielskiej ten sam komunikat brzmi fatal: Not possible to fast-forward, aborting.

Stan gałęzi potwierdzała komenda git status uruchomiona ze skróconym opisem gałęzi:

$ git status -sb
## main...origin/main [do przodu 1, wstecz 4]

Zapis „do przodu 1, wstecz 4" (po angielsku ahead 1, behind 4) jest dosłowną definicją rozjazdu. Lokalna gałąź ma jeden commit, którego nie ma serwer, i jednocześnie brakuje jej czterech commitów, które serwer już ma.

Nazewnictwo i cztery pojęcia potrzebne do zrozumienia błędu

Komunikat o niemożliwym przewinięciu bywa mylnie odczytywany jako awaria. Nie jest awarią. Żeby to zobaczyć, warto najpierw uporządkować samą nazwę gałęzi, a potem rozdzielić cztery rzeczy, które w codziennej pracy zlewają się w jedno.

main a master

Obie nazwy oznaczają to samo: gałąź, którą repozytorium traktuje jako domyślną. Git nie ma wbudowanego pojęcia „gałęzi głównej" - dla samego narzędzia jest to zwykła gałąź, pozbawiona jakichkolwiek szczególnych właściwości. Znaczenie nadaje jej umowa w zespole oraz ustawienie po stronie serwera, wskazujące, na którą gałąź trafia osoba wchodząca do repozytorium.

Przez wiele lat komenda git init tworzyła gałąź o nazwie master i tak wyglądała większość poradników. W 2020 roku Git w wersji 2.28 dodał ustawienie init.defaultBranch, pozwalające wybrać nazwę gałęzi zakładanej w nowym repozytorium. W ślad za tym serwisy hostujące repozytoria zmieniły swoją domyślną nazwę na main. Powodem była rezygnacja z terminologii master i slave, obecnej wcześniej w wielu narzędziach technicznych.

Dla treści tego artykułu nazwa nie ma żadnego znaczenia. Rozjazd gałęzi, przewinięcie, commit scalający i wszystkie opisane komendy działają identycznie - wystarczy w miejsce main podstawić master. Znaczenie ma wyłącznie to, żeby nie mieszać obu nazw w obrębie jednego repozytorium, bo wtedy łatwo wysłać zmiany na gałąź, z której nikt nie czyta.

Sprawdzenie, którą gałąź serwer uznaje za domyślną:

$ git symbolic-ref refs/remotes/origin/HEAD --short
origin/main

Jeżeli komenda zgłosi brak takiego odwołania, można je odtworzyć, pytając wprost serwer:

git remote set-head origin --auto

Ustawienie nazwy dla nowo tworzonych repozytoriów:

git config --global init.defaultBranch main

Zmiana nazwy w repozytorium, które już istnieje, jest możliwa komendą git branch -m master main, ale jej skutki nie kończą się na naszym katalogu. Trzeba jeszcze wysłać nową gałąź, przestawić gałąź domyślną w panelu serwisu hostującego, usunąć starą oraz dopilnować, żeby wszystkie osoby w zespole odświeżyły swoje kopie. Jest to zmiana do przeprowadzenia w uzgodnionym momencie, a nie przy okazji innej pracy.

origin

origin to nazwa nadana zdalnemu repozytorium, czyli kopii leżącej na serwerze Git. Sama nazwa nie ma znaczenia technicznego - to domyślna etykieta nadawana przy klonowaniu.

main a origin/main

To dwie różne rzeczy o podobnej nazwie. main to gałąź w naszym katalogu, na której pracujemy. origin/main to lokalna notatka mówiąca, jak gałąź main wyglądała na serwerze przy ostatnim kontakcie z nim.

Ta notatka nie aktualizuje się sama. Zmieniają ją tylko git fetch, git pull oraz git push. Bez pobrania danych z serwera origin/main może pokazywać stan sprzed tygodni, a Git nie zgłosi z tego powodu żadnego ostrzeżenia. Z tego wynika praktyczny wniosek: liczby „do przodu" i „wstecz" z git status opisują sytuację wobec ostatniego pobrania, a nie wobec tego, co jest na serwerze w tej chwili.

fetch a pull

git fetch pobiera z serwera nowe commity i odświeża notatkę origin/main. Nie dotyka przy tym gałęzi, na której pracujemy, ani plików w katalogu. Jest to operacja bezpieczna, którą można wykonać w każdej chwili.

git pull wykonuje git fetch, a zaraz po nim scalenie pobranych zmian do bieżącej gałęzi. Robi więc dwie rzeczy naraz i to właśnie ta druga potrafi zaskoczyć. Przy diagnozowaniu rozjazdu wygodniej rozdzielić te kroki i najpierw zobaczyć stan, a dopiero potem zdecydować, co z nim zrobić.

przewinięcie

Przewinięcie (angielskie fast-forward) to najprostszy możliwy sposób scalenia. Git stosuje je wtedy, gdy nasza gałąź jest wcześniejszym punktem na tej samej linii rozwoju co gałąź docelowa. Nie trzeba wtedy niczego łączyć ani rozstrzygać - wystarczy przesunąć wskaźnik gałęzi do przodu, na nowszy commit.

Warunek jest jeden i jest ostry: nasz commit musi być przodkiem commitu docelowego. Innymi słowy, wszystko, co mamy, musi już znajdować się w tej drugiej linii.

Dlaczego Git odmówił

W opisanej sytuacji lokalna gałąź main zawierała commit 9d3e70b, którego na serwerze nie było. Warunek przewinięcia przestał być spełniony. Nie istnieje sposób, by samym przesunięciem wskaźnika dojść z lokalnego stanu do stanu e58c2d1, bo po drodze trzeba by porzucić własny commit.

Przełącznik --ff-only znaczy dosłownie „wykonaj to tylko wtedy, gdy da się przewinąć". Jest to świadome zabezpieczenie: prosimy Gita, żeby zaktualizował gałąź, jeżeli jest to zwykłe przesunięcie, a w każdym innym przypadku niczego nie ruszał i powiedział o tym wprost.

Git zachował się więc dokładnie zgodnie z poleceniem. Zamiast po cichu wykonać operację o innych skutkach, przerwał pracę i zostawił repozytorium w niezmienionym stanie. Ta odmowa była pierwszym sygnałem, że sytuacja wygląda inaczej, niż zakładaliśmy - i dobrze, że padła przed jakąkolwiek zmianą historii.

Dlaczego rozjazd był groźny, a nie tylko niewygodny

Sam rozjazd gałęzi jest zjawiskiem normalnym i zdarza się w każdym zespole. Problemem było co innego: konfiguracja została już wcześniej wprowadzona bezpośrednio na serwerze, a gałąź main o niej nie wiedziała.

Powstał stan pozornie poprawny. Na serwerze ustawienie działało. W repozytorium go nie było. Automat czyta z main, więc jego kolejne uruchomienie cofnęłoby zmianę i przywróciło stan sprzed poprawki. Awaria wróciłaby bez żadnego widocznego powodu, w momencie niezwiązanym z pierwotną pracą, co należy do najtrudniejszych do zdiagnozowania sytuacji.

Druga konsekwencja jest organizacyjna. Reszta zespołu, patrząc na main, nie miała podstaw sądzić, że poprawka istnieje. Każdy, kto sprawdziłby historię gałęzi, uznałby temat za niezałatwiony.

Źródłem nieporozumienia było potraktowanie czterech różnych stanów jako jednego. Zmiana może znajdować się:

  1. w plikach w katalogu roboczym, jeszcze bez commitu,
  2. w commicie w lokalnym repozytorium,
  3. w gałęzi roboczej wysłanej na serwer,
  4. w gałęzi main na serwerze.

Zdanie „zatwierdzone i wysłane" opisuje stan trzeci. Automatyzacja czyta ze stanu czwartego. Odległość między nimi wynosi jedno scalenie i jedno wysłanie, a żadne z nich nie dzieje się samo.

Diagnoza: trzy komendy, które pokazują prawdziwy stan

Diagnozę zaczynamy od pobrania aktualnego stanu z serwera. Komenda niczego nie scala i nie zmienia plików, więc można ją uruchomić bez obaw:

git fetch origin

Następnie sprawdzamy położenie gałęzi względem serwera:

$ git status -sb
## main...origin/main [do przodu 1, wstecz 4]

Na koniec wypisujemy, które konkretnie commity są tylko po jednej stronie:

$ git log --oneline --graph --left-right main...origin/main
< 9d3e70b popraw ustawienie w pliku konfiguracyjnym
> e58c2d1 aktualizacja parametrów usługi
> b9c0d1e mapowanie kont
> 5e6f7a8 uzupełnienie dokumentacji
> 1a2b3c4 poprawka literówki

Trzy kropki w zapisie main...origin/main oznaczają różnicę symetryczną, czyli commity występujące tylko po jednej ze stron. Znak < oznacza commit obecny wyłącznie w gałęzi wymienionej po lewej, a znak > commit obecny wyłącznie po prawej. Ten jeden wydruk odpowiada na pytanie, co dokładnie zostanie połączone.

Naprawa krok po kroku

Skoro przewinięcie jest niemożliwe, potrzebna jest operacja, która potrafi połączyć dwie rozdzielone linie. Robi to zwykłe scalenie, tworzące tak zwany commit scalający - commit mający dwóch rodziców, po jednym z każdej łączonej linii.

git fetch origin
git switch main
git merge origin/main -m "Merge remote-tracking branch 'origin/main'"

Efekt w historii wygląda tak:

$ git log --oneline --graph -7
*   7b64f0a Merge remote-tracking branch 'origin/main'
|\
| * e58c2d1 aktualizacja parametrów usługi
| * b9c0d1e mapowanie kont
| * 5e6f7a8 uzupełnienie dokumentacji
| * 1a2b3c4 poprawka literówki
* | 9d3e70b popraw ustawienie w pliku konfiguracyjnym
|/
* 4f1c9ab wspólny punkt

Obie linie zostały zachowane i połączone. Nic nie zostało nadpisane ani porzucone.

Jeżeli obie strony zmieniałyby te same fragmenty tych samych plików, Git zatrzymałby scalenie i poprosił o rozstrzygnięcie konfliktu. W takiej sytuacji zawsze można wycofać się do stanu sprzed komendy:

git merge --abort

W opisywanym przypadku konfliktu nie było, ponieważ zmiany dotyczyły różnych plików. Warto jednak wiedzieć, że przerwanie scalenia jest w pełni bezpieczne i nie zostawia po sobie śladu.

A co z rebase?

Rozjazd można też rozwiązać komendą git rebase, która przenosi własne commity tak, by wyrastały z nowszego punktu, i daje historię bez rozgałęzień. Ma to jednak istotne ograniczenie: rebase tworzy nowe commity w miejsce dotychczasowych, czyli zmienia historię. Jest to bezpieczne dla commitów, których nikt jeszcze nie pobrał, i kłopotliwe dla tych, które są już na serwerze i mogą być w kopiach innych osób.

Wybór między scaleniem a rebase jest przede wszystkim ustaleniem zespołowym. W opisywanym repozytorium historia zawierała commity scalające dla każdej gałęzi, więc scalenie było zgodne z przyjętą konwencją.

Weryfikacja: dwa sprawdzenia zamiast założeń

Przed wysłaniem czegokolwiek na serwer sprawdzamy, co właściwie powstało po scaleniu. Najprostszą i najmocniejszą kontrolą jest porównanie stanu po scaleniu z tym, co obecnie leży na serwerze:

$ git diff --stat origin/main HEAD
 konfiguracja/serwer-a.yml | 2 ++
 1 file changed, 2 insertions(+)

Wynik powinien zawierać dokładnie tę zmianę, którą dokładamy, i nic poza nią. Jedna linia i dwa dopisane wiersze oznaczają, że scalenie niczego nie zgubiło i niczego nie powieliło. Gdyby na liście pojawiły się pliki, których nie ruszaliśmy, byłby to sygnał, że coś poszło nie tak - i moment, w którym jeszcze łatwo się wycofać.

Dopiero po tym sprawdzeniu wysyłamy zmiany:

$ git push origin main
To ssh://serwer-git/projekt/konfiguracja.git
   e58c2d1..7b64f0a  main -> main

Ostatni krok to potwierdzenie, że commit rzeczywiście znalazł się w gałęzi main na serwerze. Nie chodzi o powtórzenie tego, co przed chwilą wypisał push, tylko o zadanie pytania wprost o stan zdalny:

git fetch origin
git merge-base --is-ancestor 9d3e70b origin/main && echo "jest w origin/main"

Komenda git merge-base --is-ancestor A B sprawdza, czy commit A zawiera się w historii commitu B. Kończy się powodzeniem, gdy tak jest, i niepowodzeniem w przeciwnym razie, dzięki czemu nadaje się do użycia w skryptach. To właśnie ta kontrola odróżnia „wysłałem" od „jest na miejscu".

Poprawna ścieżka od początku

Cała sytuacja bierze się z jednego pominiętego kroku na samym początku: gałąź powstała ze starej kopii main. Poniższa kolejność usuwa tę przyczynę i domyka pracę potwierdzeniem po stronie serwera.

Pobranie aktualnego stanu przed rozpoczęciem pracy:

git fetch origin

Utworzenie gałęzi wprost z zdalnego stanu, a nie z lokalnej kopii main. Dzięki wskazaniu origin/main punkt startowy jest aktualny niezależnie od tego, w jakim stanie jest lokalna gałąź:

git switch -c poprawka-konfiguracji origin/main

Zatwierdzenie zmiany i wysłanie gałęzi. Przełącznik -u zapamiętuje powiązanie gałęzi lokalnej ze zdalną:

git add konfiguracja/serwer-a.yml
git commit -m "popraw ustawienie w pliku konfiguracyjnym"
git push -u origin poprawka-konfiguracji

Scalenie do main. Najpierw aktualizujemy lokalną gałąź main przewinięciem - jeżeli nie ma na niej własnych commitów, przewinięcie jest możliwe i komenda przejdzie. Jeżeli zamiast tego pojawi się znany już komunikat o niemożliwym przewinięciu, znaczy to, że na main leży coś, o czym nie wiedzieliśmy, i trzeba się temu przyjrzeć przed dalszymi krokami:

git switch main
git merge --ff-only origin/main
git merge --no-ff poprawka-konfiguracji -m "Merge branch 'poprawka-konfiguracji'"

Przełącznik --no-ff wymusza utworzenie commitu scalającego nawet wtedy, gdy przewinięcie byłoby możliwe. Dzięki temu w historii widać, że dana grupa zmian stanowiła całość, a wycofanie jej sprowadza się do wycofania jednego commitu.

Sprawdzenie zawartości, wysłanie i potwierdzenie:

git diff --stat origin/main HEAD
git push origin main
git fetch origin
git merge-base --is-ancestor poprawka-konfiguracji origin/main && echo "jest w origin/main"

Usunięcie gałęzi, gdy nie jest już potrzebna:

git push origin --delete poprawka-konfiguracji
git branch -d poprawka-konfiguracji

Małe -d usuwa gałąź tylko wtedy, gdy jej zmiany są już scalone. Jest to celowe zabezpieczenie przed utratą pracy, dlatego nie warto zastępować go wariantem -D, który kasuje bezwarunkowo.

Pułapka poboczna: gałąź bez ustawionego śledzenia

Przy okazji naprawy wyszła druga usterka. Gałąź robocza istniała lokalnie i na serwerze, ale nie miała ustawionego powiązania między jedną a drugą:

$ git branch -vv
* poprawka-konfiguracji 9d3e70b popraw ustawienie w pliku konfiguracyjnym
  main                  4f1c9ab [origin/main: wstecz 4] wspólny punkt

Przy gałęzi main widnieje w nawiasie kwadratowym gałąź zdalna, a przy gałęzi roboczej nie ma nic. Oznacza to, że Git nie wie, z czym miałby ją zestawiać. Konsekwencje są dwie: git status nie pokaże dla niej informacji o wyprzedzeniu ani opóźnieniu, a git push i git pull bez podania argumentów nie będą miały celu.

Powiązanie ustawia się przy pierwszym wysłaniu przełącznikiem -u, a dla istniejącej już gałęzi osobną komendą:

git branch -u origin/poprawka-konfiguracji

Brak powiązania sam w sobie nie psuje danych, ale odbiera jedno z podstawowych źródeł informacji o stanie pracy. W sytuacji, w której właśnie rozstrzygamy, co jest na serwerze, a co tylko u nas, jest to strata odczuwalna.

Checklista

  1. Przed założeniem gałęzi wykonać git fetch origin. Gałąź założona ze starej kopii main to najczęstsza przyczyna późniejszego rozjazdu.
  2. Zakładać gałęzie wprost z origin/main, a nie z lokalnej gałęzi main.
  3. Traktować origin/main jako notatkę z ostatniego pobrania, a nie podgląd stanu serwera na żywo.
  4. Czytać komunikat Nie da się przewinąć jako informację o rozjeździe, a nie jako awarię. Repozytorium pozostaje wtedy nietknięte.
  5. Rozjazd rozpoznawać komendą git status -sb, a jego zawartość wypisywać przez git log --oneline --graph --left-right main...origin/main.
  6. Do połączenia rozjechanych linii używać scalenia. Rebase zostawiać dla commitów, których nie ma jeszcze na serwerze, ponieważ zmienia historię.
  7. Przed wysłaniem sprawdzać wynik scalenia komendą git diff --stat origin/main HEAD. Powinna pokazać dokładnie zamierzoną zmianę i nic więcej.
  8. Po wysłaniu potwierdzać stan po stronie serwera przez git merge-base --is-ancestor. „Wysłane" i „obecne w main" to dwa różne stany.
  9. Sprawdzać powiązanie gałęzi przez git branch -vv i ustawiać je przełącznikiem -u.
  10. Gdy zmiana została wprowadzona na serwerze ręcznie, domknąć ją w repozytorium tego samego dnia. Dopóki nie ma jej w gałęzi, z której czyta automatyzacja, jest tymczasowa i zostanie cofnięta.

Rozjazd gałęzi nie jest usterką Gita ani skutkiem błędnej komendy. Jest naturalnym efektem pracy kilku osób na wspólnym repozytorium i staje się kłopotliwy dopiero wtedy, gdy zostanie niezauważony. Koszt jego wykrycia to jedno git fetch i dwa spojrzenia na wydruk. Koszt przeoczenia bywa znacznie wyższy, bo zmiana wraca do stanu sprzed poprawki w momencie, którego nikt nie łączy z pierwotną pracą.

Konfiguracja serwerów rozjeżdża się z repozytorium?

Porządkujemy repozytoria konfiguracji, ustawiamy powtarzalny proces wdrożeń i domykamy zmiany wprowadzane ręcznie na serwerach, żeby nie znikały przy kolejnym uruchomieniu automatyzacji.