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), potemgit commit. - Ostatnie N commitów:
git reset --soft HEAD~Nalbogit rebase -i HEAD~Nisquash/fixupprzy wszystkich poza pierwszym. - Branch z merge’ami:
reset --softalbomerge --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_HEADalbo wpis zgit 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:
$ 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 zdevelopment),development— branch, do którego będzie wystawiony pull request, czylinew_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):
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.
- Tworzymy tymczasowy branch z brancha, do którego będziemy merdżować (u nas
development):git checkout -b new_feature_temp development - Merdżujemy do niego
new_featurez opcją--squash:git merge --squash new_featureGit przeniesie wszystkie zmiany, ale nie zrobi commita:
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:git commit -m "feat: my awesome feature" - Przechodzimy na oryginalny branch i resetujemy go do tymczasowego:
git checkout new_feature git reset --hard new_feature_temp - Wypychamy zmiany i usuwamy tymczasowy branch:
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:
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:
git rebase -i HEAD~3Git otwiera edytor z listą commitów — od najstarszego:
pick b2c9d72 # commit 2
pick b908472 # commit 3
pick 6af8565 # commit 4Pierwszy 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:
CONFLICT (content): Merge conflict in app.txt
error: could not apply 1e4a234... wip 1Ten 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:
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:
git rebase -i --autosquash HEAD~3pick 0c561d7 # feat: add l5
fixup b84701b # fixup! feat: add l5
pick 23c05c7 # docs: otherWystarczy 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:
git push --force-with-leaseRóż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:
! [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:
git reset --hard ORIG_HEADUwaga: ORIG_HEAD wskazuje stan sprzed ostatniej takiej operacji. Jeśli od tego czasu zrobiłeś kolejny reset albo rebase, szukaj w git reflog:
$ 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_HEADWracasz 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 przezfixup. - 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?+
02Jak połączyć wszystkie commity w branchu w jeden?+
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?+
squash pozwala zredagować połączony opis, fixup odrzuca opis łączonego commita i zostawia opis poprzedniego.04Czy squash usuwa zmiany w kodzie?+
ORIG_HEAD albo git reflog, dopóki Git ich nie usunie przy sprzątaniu repozytorium.05Czy można zrobić squash po git push?+
--force-with-lease. Rób to tylko na własnym branchu, na którym nikt inny nie pracuje.