Blog technologiczny od 2014 · 1 115 wpisów
RSS

git squash – squash commitów bez rebase

Git squash to połączenie kilku commitów w jeden. Bez rebase zrobisz to na dwa sposoby: git reset --soft $(git merge-base HEAD development) i nowy commit albo git merge --squash na tymczasowym branchu. Oba działają także wtedy, gdy w branchu są merge’e z innych branchy — tam, gdzie git rebase -i kończy się konfliktami.

W życiu programisty są takie momenty, kiedy na feature branchu pojawia się mnóstwo commitów. Same commity to jeszcze pół biedy — poprzeczka podnosi się, gdy dochodzą merdże z innych branchy. Przez jakiś czas byłem fanem robienia squasha komendą git rebase -i HEAD~X, gdzie X to liczba commitów do połączenia (w praktyce git rebase -i HEAD~5). Wszystko działało fajnie, dopóki commity były moje. Gdy na liście pojawiały się merge, konflikty stawały się bardzo prawdopodobne. Dziś ten problem rozwiązuję prościej — i to pokazuję niżej, razem z klasycznym squashem przez rebase.

W skrócie
  • Wszystkie commity w branchu w jeden, bez rebase: git reset --soft $(git merge-base HEAD development), potem git commit.
  • Ostatnie N commitów: git reset --soft HEAD~N albo git rebase -i HEAD~N i squash/fixup przy wszystkich poza pierwszym.
  • Branch z merge’ami: reset --soft albo merge --squash — interaktywny rebase każe od nowa rozwiązywać te same konflikty.
  • Po squashu: git push --force-with-lease, nigdy na branchu, na którym pracuje ktoś jeszcze.
  • Cofnięcie: git reset --hard ORIG_HEAD albo wpis z git reflog.

Co robi git squash?

Squash commitów (ang. squash — zgnieść) to zastąpienie kilku commitów jednym, który zawiera sumę ich zmian. Kod się nie zmienia, zmienia się tylko historia: zamiast „wip 1”, „wip 2”, „poprawka literówki” zostaje jeden squash commit z porządnym commit message, np. feat: my awesome feature.

W Gicie nie ma osobnej komendy git squash:

zsh
$ git squash
git: 'squash' is not a git command. See 'git --help'.

Squash robi się jedną z kilku komend opisanych niżej. Przykłady przetestowałem na Gicie 2.54 z takim układem:

  • new_feature — branch, na którym mam bałagan z commitami (w tym merge z development),
  • development — branch, do którego będzie wystawiony pull request, czyli new_feature -> development.

Squash commitów bez rebase

Sposób 1: git reset --soft do miejsca rozgałęzienia

Najszybszy sposób na połączenie wszystkich commitów w branchu w jeden. git merge-base znajduje ostatni wspólny commit obu branchy, a git reset --soft przesuwa tam branch, zostawiając wszystkie zmiany w indeksie (staged):

zsh
git checkout new_feature
git reset --soft $(git merge-base HEAD development)
git commit -m "feat: my awesome feature"

Ważne, żeby resetować do git merge-base, a nie do samego development. Jeśli w development pojawiły się nowe commity, których nie masz u siebie, reset do jego czubka zrobiłby commit, który po cichu cofa te zmiany. Merge-base uwzględnia też merdże: gdy wcześniej wciągnąłeś development do swojego brancha, merge-base wskazuje ostatni wciągnięty commit, więc w squashu zostają tylko Twoje zmiany. W moim teście branch z trzema commitami „wip”, merdżem z development i czwartym „wip” zamienił się w jeden commit z samym feature.txt.

Sposób 2: git merge --squash na tymczasowym branchu

To metoda, której używam od lat. Wygląda na dłuższą, ale daje dokładnie to, co zobaczy reviewer: jeden commit na czubku brancha docelowego.

  1. Tworzymy tymczasowy branch z brancha, do którego będziemy merdżować (u nas development):
    zsh
    git checkout -b new_feature_temp development
  2. Merdżujemy do niego new_feature z opcją --squash:
    zsh
    git merge --squash new_feature

    Git przeniesie wszystkie zmiany, ale nie zrobi commita:

    zsh
    Squash commit -- not updating HEAD
     feature.txt | 4 ++++
     1 file changed, 4 insertions(+)

    Efekt jest podobny do git stash pop, z tą różnicą, że zmiany są już dodane do indeksu. Teraz wpisujemy commit message:

    zsh
    git commit -m "feat: my awesome feature"
  3. Przechodzimy na oryginalny branch i resetujemy go do tymczasowego:
    zsh
    git checkout new_feature
    git reset --hard new_feature_temp
  4. Wypychamy zmiany i usuwamy tymczasowy branch:
    zsh
    git push --force-with-lease
    git branch -D new_feature_temp

Może się to wydawać skomplikowane, ale uwierzcie, że tak nie jest. Ten sposób sprawdza się szczególnie na feature branchach, które były wyrównywane z branchem docelowym, bo trzeba było rozwiązać konflikty — w takim przypadku interaktywny rebase się nie sprawdzi (dlaczego, pokazuję niżej). Jeśli git merge --squash zgłosi konflikt, to znaczy, że development zmienił się od ostatniego wyrównania — rozwiązujesz go raz i commitujesz.

Squash ostatnich N commitów

Gdy chcesz połączyć tylko kilka ostatnich commitów (np. git squash last 2 commits), wystarczy reset o N commitów wstecz:

zsh
git reset --soft HEAD~3
git commit -m "feat: one commit"

Trzy ostatnie commity zamieniają się w jeden, a starsze zostają bez zmian. Jeśli chcesz zachować i połączyć ich opisy, użyj zamiast tego interaktywnego rebase z następnej sekcji.

Squash z rebase: git rebase -i

Klasyczny sposób, wygodny, gdy w branchu nie ma merge’y:

zsh
git rebase -i HEAD~3

Git otwiera edytor z listą commitów — od najstarszego:

zsh
pick b2c9d72 # commit 2
pick b908472 # commit 3
pick 6af8565 # commit 4

Pierwszy zostawiasz jako pick, a w pozostałych zmieniasz pick na:

  • squash (s) — commit łączy się z poprzednim, a Git otwiera edytor z połączonymi opisami wszystkich commitów do zredagowania,
  • fixup (f) — commit łączy się z poprzednim, a jego opis jest odrzucany (zostaje opis pierwszego).

Po zapisaniu pliku dostajesz Successfully rebased and updated refs/heads/main. Gdy oznaczysz jako squash także pierwszy commit, rebase zatrzyma się z błędem error: cannot 'squash' without a previous commit — popraw listę przez git rebase --edit-todo albo przerwij przez git rebase --abort.

Dlaczego git rebase -i nie lubi merge’y

Rebase przepisuje commity jeden po drugim na nową bazę, a commity merge pomija. Konflikty, które rozwiązałeś w merdżu, wracają więc przy pierwszym commicie, który dotyka tego samego miejsca. W teście branch z rozwiązanym wcześniej konfliktem w app.txt po git rebase -i development zatrzymał się na pierwszym commicie:

zsh
CONFLICT (content): Merge conflict in app.txt
error: could not apply 1e4a234... wip 1

Ten sam branch przez git merge --squash przeszedł bez żadnego konfliktu. Dokładnie to skłoniło mnie do porzucenia rebase przy squashu.

git commit --fixup i --autosquash

Jeśli wiesz z góry, że poprawka należy do konkretnego wcześniejszego commita, oznacz ją od razu:

zsh
git commit --fixup <hash-commita>

Git tworzy commit z opisem fixup! feat: add l5. Przy rebase z opcją --autosquash sam przesuwa go pod właściwy commit i oznacza jako fixup:

zsh
git rebase -i --autosquash HEAD~3
zsh
pick 0c561d7 # feat: add l5
fixup b84701b # fixup! feat: add l5
pick 23c05c7 # docs: other

Wystarczy zapisać listę bez zmian — poprawka znika w commicie feat: add l5.

Squash przy merge’u pull requesta (GitHub, GitLab)

Często nie trzeba squashować lokalnie, bo zrobi to serwer przy merdżu:

  • GitHub — przycisk Squash and merge w pull requeście łączy wszystkie commity PR w jeden commit na branchu docelowym. Musi być włączona opcja Allow squash merging (Settings → General → Pull Requests), tam też wybierasz domyślny opis commita (np. tytuł PR i lista commitów albo tytuł i opis PR). Dokumentacja GitHub
  • GitLab — w merge requeście pole Squash commits przed kliknięciem Merge. W Settings → Merge requests → Squash commits when merging maintainer wybiera: Do not allow, Allow, Encourage albo Require. Dokumentacja GitLab

Squash przy merdżu nie przepisuje Twojego brancha, więc nie wymaga force pusha. Minus, o którym ostrzega dokumentacja GitHuba: jeśli dalej pracujesz na tym samym branchu i otworzysz kolejny PR, zesquashowane commity mogą pojawić się znowu, a te same konflikty trzeba rozwiązywać ponownie. Po merdżu najlepiej zacząć nowy branch.

Push po squashu: --force-with-lease zamiast -f

Squash przepisuje historię, więc zwykły push zostanie odrzucony (non-fast-forward) — branch na serwerze ma commity, których lokalnie już nie ma. Zamiast git push -f używaj:

zsh
git push --force-with-lease

Różnica: --force-with-lease nadpisze branch tylko wtedy, gdy na serwerze jest to, co ostatnio pobrałeś. Gdy ktoś w międzyczasie coś wypchnął, push się nie uda:

zsh
 ! [rejected]        new_feature -> new_feature (stale info)

Pułapka: jeśli coś zrobi git fetch w tle (np. IDE), lease przestaje chronić — w moim teście po samym git fetch push nadpisał cudzy commit. Przed tym chroni dodatkowa opcja --force-if-includes (git push --force-with-lease --force-if-includes), która w tej samej sytuacji odrzuciła push z komunikatem remote ref updated since checkout. Można ją włączyć na stałe: git config --global push.useForceIfIncludes true (więcej o ustawieniach w poradniku git config — konfiguracja).

Jak cofnąć squash

Squash nie usuwa starych commitów od razu — dalej są w repozytorium, tylko żaden branch na nie nie wskazuje. Zaraz po reset albo rebase Git zapamiętuje poprzedni czubek brancha w ORIG_HEAD:

zsh
git reset --hard ORIG_HEAD

Uwaga: ORIG_HEAD wskazuje stan sprzed ostatniej takiej operacji. Jeśli od tego czasu zrobiłeś kolejny reset albo rebase, szukaj w git reflog:

zsh
$ git reflog -3
02cfb39 HEAD@{0}: commit: feat: one commit
f0f51a4 HEAD@{1}: reset: moving to HEAD~3
6af8565 HEAD@{2}: reset: moving to ORIG_HEAD

Wracasz do wpisu sprzed resetu — tu git reset --hard HEAD@{2} (albo po hashu: git reset --hard 6af8565). Wpis HEAD@{1} to już stan po resecie, łatwo się pomylić. Jeśli zesquashowany branch był wypchnięty, po cofnięciu znowu potrzebny jest git push --force-with-lease.

Kiedy nie robić squasha

  • Na branchu współdzielonym (main, development, branch, na którym pracuje kilka osób). Przepisana historia rozjedzie się z ich lokalnymi kopiami, a force push może skasować cudze commity.
  • Gdy commity mają sens osobno — np. refaktor i nowa funkcja. Osobne commity łatwiej przejrzeć, cofnąć (git revert) i przeszukać git bisect. Wtedy lepiej połączyć tylko poprawki przez fixup.
  • Gdy historia jest potrzebna później — squash usuwa informację, kiedy i kto wprowadził konkretną zmianę. Jeśli wersje oznaczasz tagami, zrób squash przed otagowaniem, nie po (o tagach: git tag — jak pracować z tagami).

Najczęstsze pytania

01Co robi git squash?+
Łączy kilka commitów w jeden, który zawiera sumę ich zmian. Kod zostaje taki sam, zmienia się tylko historia brancha.
02Jak połączyć wszystkie commity w branchu w jeden?+
Najprościej git reset --soft $(git merge-base HEAD development) i jeden git commit, gdzie development to branch docelowy. Działa też przy merge’ach w branchu.
03Czym różni się squash od fixup?+
Oba łączą commit z poprzednim. squash pozwala zredagować połączony opis, fixup odrzuca opis łączonego commita i zostawia opis poprzedniego.
04Czy squash usuwa zmiany w kodzie?+
Nie, zmiany zostają w jednym commicie. Stare commity można odzyskać przez ORIG_HEAD albo git reflog, dopóki Git ich nie usunie przy sprzątaniu repozytorium.
05Czy można zrobić squash po git push?+
Tak, ale potem trzeba wypchnąć branch ponownie z --force-with-lease. Rób to tylko na własnym branchu, na którym nikt inny nie pracuje.
06Czy lepiej squash w PR, czy lokalnie?+
Jeśli zespół używa „Squash and merge” na GitHubie albo opcji Squash commits w GitLabie, lokalny squash nie jest potrzebny. Lokalnie warto go zrobić, gdy chcesz oddać do review czystą historię.
Opublikowano · Znalazłeś błąd? Napisz
Piotr Cichosz
Autor

Od ponad 10 lat jestem zaangażowany w świat elektroniki użytkowej, zdobywając szeroką wiedzę i doświadczenie w testowaniu oraz recenzowaniu najnowszych technologii. Moja kariera obejmuje pracę w wiodących firmach technologicznych, gdzie specjalizowałem się…

Czytaj dalej
← Poprzedni Recenzja realme C33 – budżetowiec, który robi dobre zdjęcia