Blog technologiczny od 2014 · 1 115 wpisów
RSS

Conventional Commits – format, typy i przykłady epickich commitów

Conventional Commits to standard formatu wiadomości commita w git (wersja 1.0.0): typ(zakres): opis, np. feat(cli): add --dry-run option. Typ mówi, co się zmieniło (feat – nowa funkcja, fix – poprawka), a ! albo stopka BREAKING CHANGE oznacza zmianę łamiącą. Taką historię czyta i człowiek, i narzędzia, które same podbiją wersję i zrobią CHANGELOG.

Mógłbym rzec, że git commit to jedna z najbardziej niepozornych komend w git. A przecież z tej krótkiej wiadomości można się dużo dowiedzieć – między innymi tego, co autor miał na myśli.

Niestety realia są, jakie są, i w większości projektów ta wiadomość nic nie mówi. Czasami odnoszę wrażenie, że ludzie muszą tam wpisać cokolwiek, bo to jedyny wymóg, żeby opublikować kawałek swojej pracy. W niektórych projektach jedynym wymogiem jest numer ticketa. Commity o treści refactor, added XXX czy cr fixes to smutna i nudna rzeczywistość.

A gdyby dało się czytać historię zmian w kodzie z samej historii commitów, bez zaglądania w szczegóły? Gdyby trzeba było zrobić revert na produkcji – czy wiedziałbyś od razu, który commit wycofać? Na to jest prosty standard. Cytując znaną scenę z polskiego kultowego filmu: i teraz wchodzę ja, cały na biało.

W skrócie
  • Format: typ(zakres)!: opis, potem opcjonalnie pusta linia i treść, a na końcu stopki (np. Refs: #123).
  • Specyfikacja definiuje tylko feat i fix; pozostałe typy (docs, chore, refactor…) pochodzą z konwencji Angulara i @commitlint/config-conventional.
  • fix = wersja PATCH, feat = MINOR, ! lub BREAKING CHANGE = MAJOR (SemVer).
  • Pilnowanie formatu: commitlint + husky (hook commit-msg), wygodne pisanie: commitizen, automatyczne wydania: semantic-release albo release-please.
  • Przy „Squash and merge” na GitHubie liczy się tytuł pull requesta – to on zostaje commitem na głównej gałęzi.

Conventional Commits – format wiadomości commita

Pełny format według specyfikacji z conventionalcommits.org:

zsh
typ(zakres)!: opis

[opcjonalna treść]

[opcjonalne stopki]

Obowiązkowe są tylko typ, dwukropek ze spacją i opis. Reszta jest opcjonalna. Przykład z każdym elementem:

zsh
feat(api)!: remove v1 endpoints

Endpoints /v1/* were deprecated for a year. Clients should
switch to /v2/*, which returns the same data in a paginated form.

BREAKING CHANGE: /v1/* now returns 410 Gone
Refs: #482

type – rodzaj zmiany

Typ, jak sama nazwa wskazuje, określa rodzaj zmiany w kodzie: czy to nowa funkcjonalność, a może poprawka. Sama specyfikacja wymaga tylko dwóch typów: feat (nowa funkcja) i fix (poprawka błędu). Inne są dozwolone, a najpopularniejszy zestaw to ten z konwencji Angulara, używany przez @commitlint/config-conventional – lista jest niżej.

scope – zakres zmiany

Zakres podaje się w nawiasie zaraz po typie i jest opcjonalny. Specyfikacja mówi, że ma to być rzeczownik określający miejsce w kodzie, np. feat(parser):, fix(form):.

Z doświadczenia wiem (liczba i złożoność projektów), że nie zawsze jest to idealne rozwiązanie. Ta „sugestia” sprawdza się bardzo dobrze w małych projektach, gdzie funkcjonalność nie jest skomplikowana. Problem pojawia się wtedy, gdy nad projektem pracuje dużo osób – wtedy określenie zakresu robi się problematyczne.

Dlatego w zespołach, w których pracuję, używamy numeru ticketa jako zakresu, np. fix(ED-439): handle empty cart. Dodatkowa zaleta: jeśli korzystasz z systemu takiego jak Jira, taki numer jest automatycznie linkowany, co bardzo się przydaje przy generowaniu CHANGELOG-u. Domyślna konfiguracja commitlinta taki zakres przepuszcza.

description – opis

Najistotniejsza część. Trzeba się naprawdę postarać, żeby napisać dobry opis – na szczęście tylko na początku. Im lepiej (epicko) opiszesz swoje zmiany, tym lepiej dla wszystkich.

Najlepszy przykład, z jakim sam się spotkasz, to commity o wdzięcznej treści cr fixes. Ile razy to widzisz? Wiesz, co kryje się za tym magicznym zwrotem? A gdyby napisać, że zmieniłeś sposób wywołania funkcji, poprawiłeś literówkę w nazwie zmiennej albo usunąłeś wywołania razem z definicją jakiejś metody? Czy takie wiadomości nie są czytelniejsze?

Dobry opis:

  • zwięźle mówi, co się zmieniło (dlaczego – to miejsce na treść commita),
  • jest w trybie rozkazującym: nie added polish translations ani adding polish translations, tylko add polish translations – to zwyczaj z git i Angulara, sama specyfikacja tego nie wymaga,
  • zaczyna się małą literą i nie kończy kropką – tego pilnuje już commitlint w domyślnej konfiguracji (podobnie jak długości nagłówka do 100 znaków).

Treść (body) zaczyna się po jednej pustej linii pod nagłówkiem i jest dowolnym tekstem – tu wyjaśniasz kontekst i powód zmiany. Stopki (footer) idą po kolejnej pustej linii, w formacie podobnym do git trailers: Token: wartość albo Token #wartość, np. Refs: #123, Reviewed-by: Anna. W nazwie tokena zamiast spacji używa się myślnika – z jednym wyjątkiem, którym jest BREAKING CHANGE.

BREAKING CHANGE i wykrzyknik

Niektóre zmiany są łamiące, np. zmiana w API, po której stary kod klienta przestaje działać. Można je oznaczyć na dwa sposoby (albo oba naraz):

  • ! tuż przed dwukropkiem: feat!: drop support for Node 18 albo feat(api)!: …,
  • stopką BREAKING CHANGE: opis – pisaną zawsze wielkimi literami (dopuszczalny jest też zapis BREAKING-CHANGE).
zsh
feat: allow provided config object to extend other configs

BREAKING CHANGE: `extends` key in config file is now used for extending other config files

Poza BREAKING CHANGE wielkość liter w elementach formatu nie ma według specyfikacji znaczenia – w praktyce jednak wszyscy piszą typy małymi literami, a commitlint tego wymaga.

Typy commitów – ściąga (feat, fix, chore i reszta)

Typy z @commitlint/config-conventional. Uwaga na liczbę pojedynczą: test i style, nie tests i styles – commitlint odrzuci wersje z „s”.

Typ Kiedy używać
feat nowa funkcjonalność
fix poprawka błędu
docs zmiany tylko w dokumentacji
style formatowanie, białe znaki, przecinki, brakujące średniki – bez zmiany działania kodu
refactor zmiana kodu, która nie jest ani poprawką, ani nową funkcją
perf poprawa wydajności
test dodanie brakujących albo poprawa istniejących testów
build system budowania i zależności zewnętrzne (np. npm, webpack)
ci konfiguracja i skrypty CI (np. GitHub Actions)
chore pozostałe prace porządkowe, które nie ruszają kodu źródłowego ani testów
revert wycofanie wcześniejszego commita

Co oznacza chore w git?

chore to „prace domowe” w repozytorium: aktualizacja narzędzi, porządki w konfiguracji, zmiany w .gitignore – wszystko, co nie zmienia działania aplikacji ani testów. Aktualizacje zależności według config-conventional pasują do build, ale w praktyce często widać chore(deps): (zależności deweloperskie) i fix(deps): (zależności produkcyjne) – tak oznaczają je boty aktualizujące paczki.

Revert w Conventional Commits

Specyfikacja nie narzuca formatu revertów, ale w FAQ poleca typ revert i stopkę z SHA wycofywanych commitów:

zsh
revert: let us never again speak of the noodle incident

Refs: 676104e, a215868

Domyślna wiadomość z git revert (Revert "feat: …") też przejdzie przez commitlint – jest na liście wiadomości, które pomija, razem z commitami typu Merge branch … oraz fixup! i squash!.

Przykłady commitów – dobre i złe

Prawdziwa historia zgodna z Conventional Commits wygląda tak:

zsh
docs: add short circuit to hook example (#665)
docs: fix typo in code inside README.md (#662)
fix(deps): update dependency lodash to v4.17.15 (#652)
chore: update mocha, other dev deps (#653)
fix(node): remove node 6 and 8 support (#649)
fix(cli): allow argv to be overridden in bootstrap (#621)
feat(cli): implement --hook option for git hooks integration (#615)
test(ED-439): add tests for Modal component
style: format code with new prettier config

A tak wyglądają typowe błędy (każdy z nich odrzuca commitlint z domyślną konfiguracją):

Źle Dlaczego Lepiej
cr fixes brak typu, opis nic nie mówi fix(cart): rename total to totalGross
Fix: Typo typ i opis wielką literą fix: correct typo in login error message
tests: add modal tests nie ma typu tests test: add modal tests
feat: add login page. kropka na końcu feat: add login page

Język opisu nie jest określony – feat: dodaj logowanie przez Google jest poprawne. W projektach open source i w międzynarodowych zespołach standardem jest jednak angielski.

Conventional Commits a SemVer

Typy przekładają się bezpośrednio na wersjonowanie semantyczne (MAJOR.MINOR.PATCH). Dla wersji 1.4.2:

  • same fix → 1.4.3 (PATCH),
  • co najmniej jeden feat → 1.5.0 (MINOR),
  • cokolwiek z ! albo BREAKING CHANGE → 2.0.0 (MAJOR), niezależnie od typu.

docs, chore, style i reszta same w sobie nowej wersji nie wymuszają. Właśnie dlatego narzędzia potrafią wyliczyć numer wersji, założyć tag w git i wygenerować listę zmian bez udziału człowieka.

commitlint i husky – automatyczne sprawdzanie commitów

commitlint sprawdza, czy wiadomość jest zgodna z konwencją, a husky podpina go pod hook commit-msg, więc zła wiadomość w ogóle nie trafi do repozytorium. Konfiguracja dla husky v9 (sprawdzona na commitlint 21 i Node 24):

zsh
npm install --save-dev @commitlint/cli @commitlint/config-conventional husky
echo "export default { extends: ['@commitlint/config-conventional'] };" > commitlint.config.mjs
npx husky init
echo "npx --no -- commitlint --edit \$1" > .husky/commit-msg

Dwie rzeczy, na które warto uważać:

  • Dokumentacja commitlinta podaje plik commitlint.config.js. Nowy npm zapisuje jednak w package.json "type": "commonjs" i wtedy export default kończy się błędem SyntaxError: Unexpected token 'export'. Rozszerzenie .mjs działa niezależnie od tego ustawienia.
  • npx husky init dodaje w package.json skrypt "prepare": "husky" i tworzy też hook .husky/pre-commit z npm test. Jeśli projekt nie ma testów, każdy commit się wywali – usuń ten plik albo wpisz w nim swoją komendę.

Od teraz commit git commit -m "cr fixes" zostanie odrzucony:

zsh
✖   subject may not be empty [subject-empty]
✖   type may not be empty [type-empty]
✖   found 2 problems, 0 warnings

Pojedynczą wiadomość możesz przetestować bez commitowania, a w CI – sprawdzić zakres commitów:

zsh
echo "fix(parser): handle empty input" | npx commitlint
npx commitlint --from HEAD~1 --to HEAD --verbose

U mnie commit jest częścią code review. W erze agentów AI, każdy projekt ma krótką instrukcję o w tym jak ma tworzyć commity. Przed push uruchamiane są hooki, które sprawdzają kod i wiadomości commit.

commitizen – commit z kreatora

commitizen zamiast pustego edytora zadaje pytania w konsoli: typ, zakres, opis, czy to zmiana łamiąca, które zgłoszenie zamyka. Z gotowych odpowiedzi składa wiadomość zgodną z konwencją. Instalacja w projekcie (ostatnia linia to już samo commitowanie, zamiast git commit):

zsh
npm install --save-dev commitizen
npx commitizen init cz-conventional-changelog --save-dev --save-exact
npx cz

Dobry wybór na start albo dla osób, które rzadko commitują i nie pamiętają typów.

semantic-release i release-please – wydania z commitów

Tu widać największy zysk z porządnych commitów:

  • semantic-release działa w CI po każdym merge’u do głównej gałęzi: analizuje commity od ostatniego wydania, wylicza wersję, tworzy tag, notatki do wydania i publikuje paczkę (np. w npm). Zero ręcznych kroków.
  • release-please (Google) zamiast publikować od razu, utrzymuje „release PR” z podbitą wersją i CHANGELOG-iem. Wydanie i tag powstają dopiero, gdy zmergujesz ten PR – masz więc kontrolę nad tym, kiedy wychodzi nowa wersja.

Do samego CHANGELOG-u, bez publikacji, wystarczy conventional-changelog.

Conventional Commits na GitHubie a „Squash and merge”

Jeśli zespół merguje pull requesty przez Squash and merge, wszystkie commity z gałęzi zamieniają się w jeden (więcej o tym, jak to działa, we wpisie o squashu commitów). Na głównej gałęzi – i w narzędziach od wydań – zostaje więc tylko wiadomość tego jednego commita. W praktyce:

  • Tytuł PR musi być w formacie Conventional Commits, np. feat(auth): add Google sign-in. GitHub dopisuje do niego numer PR, np. (#123).
  • W ustawieniach repozytorium (Settings → General → Pull Requests → Allow squash merging) warto wybrać domyślną wiadomość Pull request title. Przy ustawieniu Default PR z jednym commitem dostaje wiadomość tego commita, a dopiero PR z kilkoma commitami – tytuł PR, co łatwo przeoczyć.
  • Format tytułów PR można sprawdzać w GitHub Actions, np. akcją action-semantic-pull-request.

Specyfikacja zresztą wprost to dopuszcza: przy squashu osoby prowadzące projekt mogą poprawić wiadomości przy merge’u, więc pojedyncze commity na gałęzi nie muszą być idealne.

Po co to wszystko?

  1. Conventional Commits wprowadza porządek w historii commitów.
  2. Wbrew pozorom poprawia komunikację w projekcie (zespole).
  3. Pozwala automatycznie wygenerować CHANGELOG (historię zmian).
  4. Poprawność wiadomości da się sprawdzać automatycznie – w hooku commit-msg albo w CI.
  5. Łatwo określić, czy zmiany trafią do wersji major, minor czy patch.

W Conventional Commits jest jeden problem – Ty!

Oczywiście jest jeden problem w tym wszystkim: czynnik ludzki. Developerzy przede wszystkim muszą chcieć pisać lepsze opisy i rozumieć, jakiego rodzaju zmiany wprowadzili w kodzie. Przed każdym commitem trzeba się więc dodatkowo „wysilić”. Dobra wiadomość jest taka, że szybko to wchodzi w krew – a commitlint przypomni, gdy zapomnisz. Jeśli chcesz dopasować do tego także szablon wiadomości czy edytor, zajrzyj do wpisu o konfiguracji git config.

Najczęstsze pytania

01Co to jest Conventional Commits?+
Specyfikacja formatu wiadomości commitów w git, obecnie w wersji 1.0.0. Każda wiadomość zaczyna się od typu zmiany (np. feat, fix), opcjonalnego zakresu i krótkiego opisu, dzięki czemu historię da się czytać i przetwarzać automatycznie.
02Czym różnią się feat, fix i chore?+
feat to nowa funkcja (podbija wersję minor), fix to poprawka błędu (wersja patch), a chore to prace porządkowe, które nie zmieniają działania aplikacji ani testów i same nie tworzą nowej wersji.
03Jak oznaczyć zmianę łamiącą (breaking change)?+
Dodaj wykrzyknik przed dwukropkiem, np. feat!: albo feat(api)!:, lub stopkę BREAKING CHANGE: z opisem, zawsze wielkimi literami. Każdy z tych sposobów oznacza nową wersję major.
04Jak zrobić revert zgodnie z Conventional Commits?+
Specyfikacja poleca typ revert i stopkę Refs: z SHA wycofanych commitów. Domyślna wiadomość z git revert (Revert „…”) też przechodzi przez commitlint.
05Czy commity można pisać po polsku?+
Tak, specyfikacja nie określa języka, a commitlint akceptuje polski opis. Typy (feat, fix…) zostają po angielsku, bo na nich opierają się narzędzia.
06Jak przetestować wiadomość commita bez commitowania?+
Przekaż ją do commitlinta przez potok: echo „feat: add search” | npx commitlint. Przy wiadomości niezgodnej z konwencją commitlint wypisze złamane reguły i zakończy się błędem.

Przydatne linki

Z tymi „epickimi” commitami to był trochę taki clickbait, ale w słusznej sprawie :).
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 Szwecja: Pudełko do odkładania telefonów, to najpopularniejszy prezent pod choinkę w 2019 roku