Git rebase, amend i cherry-pick – praca z commitami

by Patryk

W poprzedniej lekcji przygotowaliśmy Pull Request na GitHubie. Utworzyliśmy osobną gałąź, zapisaliśmy zmiany w commitach, poprawiliśmy skrypt po code review i połączyliśmy wszystko z master. Taka sytuacja jest banalnie prosta, w życiu często jest inaczej, bardzo często może pojawić się dodatkowy problem.

Przykładowo ktoś inny zdążył już dodać nowe commity do głównej gałęzi, a my nadal pracujemy na starszej wersji projektu. Innym razem zapisujemy commit i chwilę później zauważamy, że zapomnieliśmy o jednym pliku. Zdarza się też, że z kilku zmian przygotowanych na osobnej gałęzi potrzebujemy przenieść tylko jedną. Git ma do tego odpowiednie narzędzia.

W tej lekcji poznamy git rebase, git commit --amend i git cherry-pick. Wszystko przećwiczymy w naszym repozytorium devops-lab, wykorzystując skrypty i pliki konfiguracyjne podobne do tych, nad którymi pracowaliśmy wcześniej.

Git merge a git rebase – czym się różnią?


Zanim zaczniemy wykonywać polecenia, wróćmy na moment do pracy z gałęziami. Załóżmy, że utworzyliśmy feature branch z commita C. Dodaliśmy na nim dwa commity, D i E.

A---B---C  master
         \
          D---E  feature

W tym czasie ktoś inny wprowadził zmiany na master. Pojawiły się commity F i G.

Historia wygląda teraz tak:

A---B---C---F---G  master
         \
          D---E    feature

Nasz feature branch nadal zawiera potrzebne zmiany, ale powstał na starszej wersji projektu. Chcemy uwzględnić commity F i G, zanim dokończymy pracę.

Aktualizacja gałęzi przez merge

Jedną z metod poznaliśmy już w poprzednich artykułach. Będąc na feature branchu, możemy wykonać:

git merge master

Git połączy historie obu gałęzi. Jeśli nie będzie konfliktów, w tej sytuacji utworzy dodatkowy merge commit.

          D---E-------M  feature
/ /
A---B---C---F-------G master

Commit M ma dwóch rodziców: wcześniejszy commit naszej gałęzi oraz ostatni commit z master.

Historia zachowuje informację o tym, że rozwijaliśmy feature branch niezależnie, a później włączyliśmy do niego nowsze zmiany.

Aktualizacja gałęzi przez rebase

Możemy również wykonać:

git rebase master

Tym razem Git odtworzy nasze commity na nowszej podstawie. Zamiast poprzedniego układu otrzymamy:

A---B---C---F---G---D'---E'  feature
                ↑
              master

Zmiany z commitów D i E pojawiły się po commitach F i G. Zwróć uwagę na apostrofy przy nazwach D' i E'.

To nowe commity. Mogą zawierać takie same zmiany jak poprzednie, ale mają innych rodziców i nowe identyfikatory. Właśnie dlatego mówi się, że rebase przepisuje historię.

Przygotowanie laboratorium


Wracamy do repozytorium na naszej maszynie Debian:

cd ~/devops-lab-pr

Sprawdź stan projektu:

git status
git branch --show-current

Jeżeli jesteś na gałęzi z poprzedniego ćwiczenia, wróć na master:

git switch master

Możesz pobrać informacje z GitHuba:

git fetch origin

Jeśli lokalny master jest zgodny z historią zdalną albo pozostaje jedynie w tyle za nią, zaktualizuj go:

git pull

W tym artykule nie będziemy modyfikować master. Wystarczy nam czysty stan repozytorium, od którego utworzymy nowe gałęzie.

Laboratorium 1. Git rebase w praktyce


Żeby nie dodawać testowych commitów bezpośrednio do obecnej historii, przygotujemy dwie lokalne gałęzie:

  • lab/rebase-base – będzie udawać główną gałąź, na której ktoś inny dodał nowy commit,
  • lab/rebase – posłuży do przygotowania skryptu i wykonania rebase.

Działanie będzie dokładnie takie samo jak przy aktualizowaniu własnego feature brancha względem master.

Tworzenie gałęzi laboratoryjnych

Będąc na master, wykonaj:

git switch -c lab/rebase-base
git switch -c lab/rebase

Teraz pracujemy na lab/rebase, możesz to sprawdzić poleceniem:

git branch --show-current

Pierwszy commit: skrypt sprawdzający usługi

W poprzednich lekcjach tworzyliśmy już skrypty monitorujące dysk i podstawowe parametry systemu. Dodajmy kolejny, który wyświetli informacje o usługach.

nano scripts/service-check.sh

Wklej do niego poniższą zawartość:

#!/usr/bin/env bash

echo "Service check"
systemctl --failed

Polecenie systemctl --failed pokazuje jednostki systemd znajdujące się w stanie błędu. Na poprawnie działającej maszynie wynik może informować, że nie ma żadnych jednostek w stanie failed. Jest to tylko przykładowy skrypt.

Zapisz plik, nadaj mu uprawnienia do wykonania i go uruchom:

chmod +x scripts/service-check.sh
./scripts/service-check.sh

Zapisz zmiany:

git add scripts/service-check.sh
git commit -m "Add service check script"

Mamy pierwszy commit na gałęzi lab/rebase.

Drugi commit: nazwa hosta

Rozbudujmy skrypt o informację, na jakiej maszynie został uruchomiony. Otwórz ponownie plik:

nano scripts/service-check.sh

Na końcu dopisz:

echo "Hostname: $(hostname)"

Następnie zapisz zmianę w osobnym commicie:

git add scripts/service-check.sh
git commit -m "Add hostname to service check"

Wyświetl historię:

git log --oneline --decorate --graph -5

Na gałęzi lab/rebase powinniśmy mieć dwa nowe commity. Zapisz sobie ich skrócone hashe. Za chwilę sprawdzimy, czy zmienią się po rebase.

Niezależny commit na drugiej gałęzi

Teraz zasymulujemy sytuację, w której ktoś inny kontynuuje pracę nad projektem. Przełącz się na gałąź przygotowaną wcześniej:

git switch lab/rebase-base

Otwórz README.md:

nano README.md

Na końcu dopisz:

## Service diagnostics

Repository contains Linux service diagnostic scripts.

Zapisz zmianę:

git add README.md
git commit -m "Document service diagnostics"

Mamy teraz dwie różne historie.

Na lab/rebase powstał skrypt i jego rozbudowana wersja. Na lab/rebase-base pojawił się niezależny commit dotyczący README.

Sprawdź graf:

git log --oneline --decorate --graph --all -12

Interesujący nas fragment można przedstawić tak:

          D---E  lab/rebase
/
A---B---C
\
F lab/rebase-base

Commit F nie jest częścią historii lab/rebase, ale za chwilę to zmienimy.

Wykonanie git rebase

Wróć na gałąź ze skryptem:

git switch lab/rebase

Następnie wykonaj:

git rebase lab/rebase-base

Git sprawdzi, które commity należą do naszej gałęzi, ale nie występują w historii wskazanej jako nowa podstawa.

W tym przypadku są to:

D = Add service check script
E = Add hostname to service check

Następnie odtworzy ich zmiany po commicie F. Jeżeli wszystko przebiegnie poprawnie, zobaczysz komunikat o pomyślnym zakończeniu rebase. Sprawdź historie:

git log --oneline --decorate --graph --all -12

Schemat powinien przypominać:

A---B---C---F---D'---E'
            ↑       ↑
     lab/rebase-base lab/rebase

Wcześniejsze commity D i E zostały zastąpione nowymi commitami D' i E'. Porównaj ich hashe z tymi, które zapisałeś przed rebase, zobaczysz, że się zmieniły. Wiadomości commitów nadal brzmią tak samo, zmieniły się natomiast identyfikatory.

Co właściwie zrobił rebase?

Możemy rozłożyć całą operację na kilka prostych czynności. Git znalazł dwa commity należące do lab/rebase. Następnie odtworzył ich zmiany po najnowszym commicie gałęzi lab/rebase-base.

Dlatego po rebase mamy jedną linię historii:

F---D'---E'

Bez dodatkowego merge commita. Nie oznacza to jednak, że wcześniejsze commity zostały fizycznie przesunięte. Git utworzył ich nowe odpowiedniki. Ta różnica ma znaczenie, zwłaszcza gdy pracujemy z gałęzią opublikowaną już na GitHubie.

Co zrobić, gdy podczas rebase wystąpi konflikt?

W naszym ćwiczeniu zmienialiśmy różne pliki. lab/rebase zawierał skrypt, a lab/rebase-base modyfikował README. Dlatego Git mógł odtworzyć commity bez konfliktu.

Przy pracy z tym samym fragmentem pliku sytuacja wygląda inaczej. Załóżmy, że na jednej gałęzi zmieniliśmy:

port=8080

na:

port=8081

A na drugiej:

port=9090

Podczas rebase Git może zatrzymać się na commicie, którego zmian nie potrafi automatycznie zastosować. Zobaczymy komunikat CONFLICT. Dalsza praca przypomina rozwiązywanie konfliktów z wcześniejszej lekcji. Najpierw sprawdzamy:

git status

Git wskaże plik, którego nie udało się połączyć. Otwieramy go, analizujemy obie wersje i przygotowujemy poprawną zawartość. Usuwamy oznaczenia konfliktu:

<<<<<<<
=======
>>>>>>>

Następnie dodajemy poprawiony plik:

git add NAZWA_PLIKU

I kontynuujemy:

git rebase --continue

Git spróbuje odtworzyć kolejne commity. Jeśli pojawi się następny konflikt, trzeba go rozwiązać w ten sam sposób.

Przerwanie rebase

Jeśli podczas operacji coś pójdzie nie tak albo nie chcesz jeszcze rozwiązywać konfliktów, możesz przerwać rebase:

git rebase --abort

Git wróci do stanu gałęzi sprzed rozpoczęcia tej operacji. Zapamiętaj różnicę:

git rebase --continue

kontynuuje rebase po rozwiązaniu problemu.

git rebase --abort

anuluje trwającą operację.

To podobny mechanizm do git merge --continue i git merge --abort, które poznaliśmy podczas ćwiczenia konfliktów.

Dlaczego nie wykonujemy rebase na współdzielonej historii?


Wyobraź sobie, że pracujesz na gałęzi, z której korzystają również inne osoby. Jej historia wygląda tak:

A---B---C---D

Ktoś z zespołu pobrał już commity C i D.

Jeśli przebudujesz tę historię przez rebase, możesz otrzymać nowe commity:

A---B---C'---D'

Dla Gita C i C' to różne commity. Podobnie D i D'. Osoby pracujące na starej historii nadal mają wcześniejsze identyfikatory. Kiedy zaczniecie ponownie synchronizować zmiany, może pojawić się niepotrzebne zamieszanie.

Dlatego rebase najlepiej ćwiczyć na własnych, lokalnych gałęziach. W pracy zespołowej trzeba dodatkowo wiedzieć, czy dana gałąź jest już opublikowana i czy ktoś inny opiera na niej swoją pracę.

Laboratorium 2. Git commit –amend

Przejdźmy do innego przypadku.

Załóżmy, że przygotowujesz konfigurację usługi. Tworzysz plik, zapisujesz commit i dopiero po chwili zauważasz, że zapomniałeś o jednym parametrze. Możesz oczywiście utworzyć kolejny commit z wiadomością:

Fix previous commit

Ale jeśli poprzedni commit nie został jeszcze opublikowany, możesz poprawić go bez dokładania następnego zapisu do historii. Do tego służy git commit --amend.

Osobna gałąź do ćwiczenia

Wróć na master:

git switch master

Utwórz nową gałąź:

git switch -c lab/amend

Przygotuj katalog na konfigurację:

mkdir -p configs

Następnie utwórz plik:

nano configs/cache.conf

Wklej do niego poniższą zawartość:

enabled=true
ttl=300

To uproszczona konfiguracja mechanizmu cache. Zapisz plik i wykonaj commit:

git add configs/cache.conf
git commit -m "Add cache config"

Sprawdź jego hash i zapisz sobie jego identyfikator. Za chwilę będziemy go porównywać.

git log -1 --oneline

Poprawienie ostatniego commita

Po sprawdzeniu konfiguracji zauważamy, że zabrakło parametru określającego maksymalną liczbę przechowywanych elementów. Dopiszmy go do pliku:

echo "max_items=1000" >> configs/cache.conf

Dodaj zmianę do staging area:

git add configs/cache.conf

Tym razem zamiast zwykłego git commit wykonaj:

git commit --amend --no-edit

Opcja --amend zastępuje ostatni commit nowym, zawierającym przygotowane zmiany. --no-edit oznacza, że chcemy zachować jego dotychczasową wiadomość:

Add cache config

Sprawdź ponownie:

git log -1 --oneline

Wiadomość commita będzie taka sama jak wcześniej, ale hash powinien być inny.

Dlaczego amend zmienia hash?

git commit --amend nie dopisuje zawartości do istniejącego commita , tylko tworzy nowy commit, który zastępuje poprzedni na końcu bieżącej gałęzi.

Przed operacją:

A---B---C  lab/amend

Po operacji:

A---B---C'  lab/amend

Commit C' zawiera już poprawioną konfigurację. To kolejna forma przepisywania historii, dlatego najlepiej korzystać z niej przed wysłaniem commita na GitHuba.

Poprawianie wiadomości commita

--amend przydaje się również wtedy, gdy popełnisz literówkę w wiadomości ostatniego commita.

Załóżmy, że zapisałeś:

git commit -m "Add ngnix config"

A miało być:

Add nginx config

Jeżeli to ostatni, nieopublikowany commit, możesz zmienić jego wiadomość:

git commit --amend -m "Add nginx config"

Git utworzy nowy commit z poprawioną wiadomością. Nie musisz dodawać osobnego commita tylko po to, żeby poprawić literówkę. Pamiętaj jednak, że --amend uwzględnia również aktualną zawartość staging area. Przed jego wykonaniem warto sprawdzić status, dzięki temu nie dołączysz przypadkiem innych zmian.

git status
git diff --staged

Laboratorium 3. Git cherry-pick

Zostawmy na chwilę poprawianie historii i zajmijmy się przenoszeniem pojedynczych zmian. Wyobraź sobie, że na osobnej gałęzi przygotowujesz trzy poprawki:

D = nowa konfiguracja aplikacji A
E = nowa konfiguracja aplikacji B
F = nowa konfiguracja aplikacji C

Wszystkie znajdują się na jednym branchu. Okazuje się jednak, że do innej gałęzi potrzebujesz wyłącznie zmiany z commita E. Nie chcesz przenosić całej historii zawierającej D, E i F.

Możesz wtedy użyć:

git cherry-pick HASH_COMMITA

Git odtworzy zmianę z wybranego commita na aktualnej gałęzi i zapisze ją jako nowy commit.

Przygotowanie gałęzi źródłowej

Wróć na master:

git switch master

Utwórz gałąź:

git switch -c lab/cherry-source

Przygotuj katalog:

mkdir -p configs

Dodaj pierwszą konfigurację:

echo "app=A" > configs/app-a.conf

git add configs/app-a.conf
git commit -m "Add application A configuration"

Teraz drugą:

echo "app=B" > configs/app-b.conf

git add configs/app-b.conf
git commit -m "Add application B configuration"

I trzecią:

echo "app=C" > configs/app-c.conf

git add configs/app-c.conf
git commit -m "Add application C configuration"

Sprawdź historię:

git log --oneline -4

Na początku powinny znajdować się trzy nowe commity:

Add application C configuration
Add application B configuration
Add application A configuration

Zapisz hash commita B, ten commit za chwilę przeniesiemy.

Add application B configuration

Przeniesienie jednego commita

Wróć do master:

git switch master

Utwórz gałąź docelową:

git switch -c lab/cherry-target

Nie ma tu jeszcze plików app-a.conf, app-b.conf ani app-c.conf z naszej gałęzi źródłowej. Teraz wykonaj:

git cherry-pick HASH_B

Zastąp HASH_B identyfikatorem commita Add application B configuration, który zapisałeś wcześniej.

Jeśli wszystko przebiegnie poprawnie, Git utworzy commit na aktualnej gałęzi lab/cherry-target.

git log -1 --oneline

Powinieneś zobaczyć wiadomość:

Add application B configuration

Otwórz plik:

cat configs/app-b.conf

Wynik:

app=B

Na gałęzi docelowej pojawiła się konfiguracja aplikacji B. Nie przenieśliśmy natomiast konfiguracji A ani C, ponieważ należą do innych commitów.

Kiedy cherry-pick przydaje się w pracy DevOps?


Dobrym przykładem jest poprawka bezpieczeństwa albo naprawa błędu, która musi trafić do kilku wersji aplikacji. Załóżmy, że na master mamy nowy commit poprawiający konfigurację usługi. Jednocześnie utrzymujemy starszą wersję aplikacji na gałęzi release/1.5.

Nie chcemy przenosić do niej wszystkich zmian z master. Potrzebujemy tylko konkretnej poprawki. Możemy wtedy wykonać:

git switch release/1.5
git cherry-pick HASH_POPRAWKI

Git spróbuje zastosować wskazaną zmianę w historii gałęzi release/1.5. Oczywiście trzeba sprawdzić, czy poprawka nadal ma sens w starszej wersji projektu. Sam fakt, że Git potrafi zastosować commit, nie gwarantuje poprawnego działania aplikacji.

Rebase, cherry-pick i amend – kiedy którego użyć?


Mamy już kilka poleceń, które wpływają na historię repozytorium. Warto uporządkować ich zastosowania.

PolecenieKiedy się przydaje
git mergeGdy chcemy połączyć historie gałęzi
git rebaseGdy chcemy odtworzyć własne commity na nowszej podstawie
git commit --amendGdy chcemy poprawić ostatni commit
git cherry-pickGdy potrzebujemy zmiany z konkretnego commita na innej gałęzi

Zadanie samodzielne


Po wykonaniu trzech laboratoriów spróbuj jeszcze raz wykorzystać poznane polecenia na własnym przykładzie.

Zadanie 1. Aktualizacja feature brancha

Utwórz gałąź feature/log-analysis i przygotuj na niej dwa commity:

Add log analysis script
Document log analysis script

Następnie utwórz niezależną zmianę na osobnej gałęzi bazowej i wykonaj rebase feature/log-analysis na tę nowszą podstawę. Nie dodawaj ćwiczebnego commita bezpośrednio na master.

Sprawdź historię przed operacją i po niej. Porównaj hashe commitów.

Zadanie 2. Poprawienie commita

Na gałęzi feature/log-analysis dodaj zmienną przechowującą nazwę pliku z logami.

Zapisz zmianę w commicie:

Add log file variable

Następnie dopisz komentarz wyjaśniający przeznaczenie tej zmiennej. Tym razem nie twórz kolejnego commita Fix comment. Popraw ostatni commit przez --amend. Sprawdź, czy jego hash się zmienił.

Zadanie 3. Przeniesienie wybranej zmiany

Utwórz gałąź feature/utility-scripts i zapisz na niej trzy niezależne commity:

Add utility script A
Add utility script B
Add utility script C

Następnie utwórz z master gałąź release/test i przenieś na nią wyłącznie commit Add utility script B. Wykorzystaj git cherry-pick.

Sprawdź, czy na gałęzi docelowej znajdują się tylko pliki i zmiany pochodzące z wybranego commita.

Sprawdzenie repozytorium po ćwiczeniach

Wróć na główną gałąź:

git switch master

Sprawdź stan projektu:

git status
git branch -a

Następnie wyświetl graf historii:

git log --oneline --decorate --graph --all -25

Powinieneś zobaczyć osobne gałęzie laboratoryjne oraz główną gałąź master, której historia nie została zmieniona przez nasze eksperymenty.


Następny krok: Kończymy część kursu poświęconą Gitowi. Potrafisz już pracować na gałęziach, rozwiązywać konflikty, przygotowywać Pull Requesty i zarządzać historią commitów. Teraz przejdziemy do Dockera. Zobaczymy, czym są kontenery, jak działają i dlaczego pomagają uniknąć sytuacji, w której aplikacja działa na jednej maszynie, a na drugiej już nie.

Przejdź do następnej lekcji: Docker dla początkujących – jak rozwiązuje problem „u mnie działa”? →

← Poprzednia lekcja: Pull Request w GitHub – feature branch i code review · Wróć do spisu kursu DevOps

Może Cię również zainteresować

Dodaj komentarz