W poprzednim wpisie poruszyłem problem konfigurowania hooków w gitcie. Dzisiaj przyjrzymy się jak zapanować nad hookami, w których chcemy wykonać wiele zadań. Będzie trochę basha, będzie trochę „magii” i w końcu będzie trochę porad jak sobie ułatwić życie.

Hooki – podstawy

Git, podobnie jak wiele innych narzędzi, pozwala na uruchomienie dodatkowych skryptów lub programów w wyniku zaistnienia pewnych zdarzeń. Jest to model reaktywny, to znaczy, że w systemie są zdefiniowane „punkty obserwacyjne”. Po osiągnięciu takiego punktu uruchamiane są wszystkie skrypty „obserwujące” dany punkt.

Jakie to są punkty?

W gitcie jest wiele punktów zaczepienia dla hooków, ale można je podzielić na te po stronie klienta i te po stronie serwera (repozytorium). Te po stronie serwera służą przede wszystkim do weryfikacji poprawności samych commitów. Czy nie ma gdzieś nadużywanego fast-forward, czy nie commitujemy do „chronionej” gałęzi (czyli takiej, do które powinny być dopisywane tylko MR/PR), czy w końcu do rozgłaszania zmian w repozytorium. Ten ostatni hook jest często wykorzystywany, do powiadamiania systemów CI o zmianach. Podsumowując po stronie serwera mamy:

  • pre-recive – odpalany raz gdy przychodzi kod (robisz git push).
  • update – odpalany raz dla każdej gałęzi zmienianej w przychodzącym kodzie.
  • post-recive – odpalany raz po zakończeniu procesowania zmian.

Co i jak można tutaj wykorzystać, to temat na osobny artykuł.

Po stronie klienta jest jednak znacznie więcej opcji. Przede wszystkim dlatego, że po stronie klienta dzieje się znacznie więcej rzeczy. Commitujemy, łączymy gałęzie, rebaseujemy, wypychamy, ciągniemy… a do tego niektóre z tych operacji są wieloetapowe. Dlatego też mamy tutaj większe pole do popisu. Oznacza to też, że wraz z rozwojem naszej bazy hooków zaczną pojawiać się pewne problemy. Zanim jednak o nich opowiem i pokażę, jak je rozwiązać zobaczmy, w jakiej kolejności wykonywane są skrypty. Po prostu w katalogu .git/hooks (lub tam, gdzie wskazuje core.hooksPath) umieśćmy pliki takie jak:

  • applypatch-msg
  • commit-msg
  • fsmonitor-watchman
  • post-checkout
  • post-commit
  • post-merge
  • post-update
  • post-receive
  • pre-applypatch
  • pre-commit
  • prepare-commit-msg
  • pre-push
  • pre-rebase
  • pre-receive
  • update

A w każdym z nich umieśćmy poniższy kod:

Listing 1. gdzie ja jestem

#!/usr/bin/env bash

hook_type=${BASH_SOURCE##*/}

echo "Hello from $hook_type"
echo Params
echo $@
echo -------------

Następnie wystarczy coś zmienić w repozytorium i scommitować zmiany. Naszym oczom ukaże się coś w stylu:

Listing 2. Kolejność hooków przy commicie

$ git commit -am "chore: Some example changes"
pre-commit
Params

-------------
prepare-commit-msg
Params
.git/COMMIT_EDITMSG message
-------------
commit-msg
Params
.git/COMMIT_EDITMSG
-------------
Commit message meets Conventional Commit standards...
post-commit
Params

-------------
[master a1cc644] chore: Some example changes
 1 file changed, 1 insertion(+), 1 deletion(-)

Następnie wypchnijmy te zmiany do repozytorium:

Listing 3. Kolejność hooków przy push

$ git push
pre-push
Params
origin git@github.com:Koziolek/multihooks.git
-------------
Enumerating objects: 23, done.
Counting objects: 100% (23/23), done.
Delta compression using up to 8 threads
Compressing objects: 100% (15/15), done.
Writing objects: 100% (20/20), 2.24 KiB | 327.00 KiB/s, done.
Total 20 (delta 7), reused 0 (delta 0), pack-reused 0
remote: Resolving deltas: 100% (7/7), completed with 2 local objects.
To github.com:Koziolek/multihooks.git
   1452343..4bbecdc  master -> master

Jak widać w normalnej codziennej pracy tylko niektóre z hooków będą nam potrzebne. Oczywiście nie oznacza to, że inne nie będą nam potrzebne. W pewnych sytuacjach na pewno. Jednak nie o tym chciałem dzisiaj mówić. Skupmy się na najpopularniejszym, jak sądzę, hooku, czyli commit-msg.

Multihooki

Jak wspomniałem na początku hooki działają w modelu reaktywnym. Przy czym jest on ograniczony. Dla jednego rodzaju hooków możemy mieć tylko jeden skrypt. Co to oznacza w praktyce? Chcą wykonać wiele operacji w reakcji na jedno zdarzenie, musimy umieścić wszystko w jednym pliku. Oczywiście istnieją funkcje, można użyć polecenia source, by zmodularyzować nasz skrypt, ale chyba nie o to chodzi.

Typową i powszechnie stosowaną techniką w systemach *-xiowych jest umieszczenie wszystkich skryptów, które chcemy wykonać, w katalogu. Nazwa katalogu odpowiada „głównej” nazwie skryptu zakończonej na .d. Tak samo też możemy podejść do naszego problemu. Poniższy skrypt jest rozwiązaniem problemu:

Listing 4. Multihook – uruchamia wszystkie pliki w katalogu NAZWA\_HOOKA.d

#!/usr/bin/env bash

# Allow multiple hooks.
#
# To use it copy file to ./git/hooks directory or to directory pointed in git config core.hooksPath.
# Next create symbolic links from this file to hook files. This script should have execution rights.
# Put your scripts in >.d folders in your hook directory. Scripts will be executed in
# alphabetical order.
#
# Original code https://gist.github.com/damienrg/411f63a5120206bb887929f4830ad0d0
#

hook_type=${BASH_SOURCE##*/}
hook_dir="${BASH_SOURCE[0]}.d"

case "$hook_type" in
  applypatch-msg | \
  commit-msg | \
  fsmonitor-watchman | \
  post-checkout | \
  post-commit | \
  post-merge | \
  post-update | \
  post-receive | \
  pre-applypatch | \
  pre-commit | \
  prepare-commit-msg | \
  pre-push | \
  pre-rebase | \
  pre-receive | \
  update)
  IFS= read -rd '' stdin
  if [[ -d $hook_dir && "$(ls -I .gitkeep -A ${hook_dir})" ]]; then
      for file in "${hook_dir}"/*; do
        "./$file" "$@" <<<"$stdin" || exit 2
      done
  fi
  exit 0
  ;;
*)
  echo "unknown hook type: $hook_type"
  exit 2
  ;;
esac        

Jest tu trochę magii. Niedużo 🙂 Najpierw w zmiennej hook_type zapisujemy nazwę hooka, a tak naprawdę jest to nazwa pliku lub linku, który został uruchomiony. Szczegóły notacji i co oznaczają magiczne znaczki na końcu znajdziecie w manualu lub tutaj. Następnie tworzymy sobie nazwę katalogu ze skryptami dla danego hooka.

Później jest switch, który technicznie rzecz biorąc, jest średnio potrzebny. Jednak ma sens w przypadku, gdyby pojawiły się nowe hooki, a my byśmy na pałę podpięli ten skrypt. Zabezpiecza on przed „przykrymi” niespodziankami w przyszłości. Teraz czas na mięsko. Przedefiniujemy sobie IFS na pusty ciąg znaków, by sensowniej obsługiwać wywołanie naszego skryptu. If sprawdza, czy mamy co wywołać. Przy czym jeżeli commitujemy puste katalogi (zawierające jedynie .gitkeep), to ten .gitkeep jest ignorowany. W pętli wywołujemy wszystkie skrypty (w kolejności alfabetycznej), przekazując im wejście, jakie dostał nasz skrypt. To wejście to parametry, z jakimi klient gita wywołał nasz hook. Dodatkowo przekazujemy im całe wejście, jakie dostaliśmy. Jak coś się nie powiedzie, to całość się wywali.

Naszym zadaniem teraz jest utworzenie odpowiednich katalogów i wrzucenie do nich skryptów. Jeden skrypt, to jedna niezależna funkcjonalność.

Multihooki - kolejność wywołania

Na koniec jeszcze jedna uwaga. Jeżeli chcemy zapewnić kolejność wywołania skryptów, to warto użyć konwencji nazewniczej dla skryptów na zasadzie 000-nazwa_skryptu.sh. Bash będzie wywoływać skrypty w kolejności alfabetycznej, a dzięki nazwom zaczynającym się od cyfr, mamy możliwość sterowania kolejnością. Nie warto jednak numerować skryptów po kolei. Co dziesięć jest ok, ponieważ mamy dzięki temu miejsce na przyszłe „wtrącenia”.

Podsumowanie

Skryptologia jest bardzo przydatna przy pracy z gitem. Sam git potrafi dość dużo, ale ma też swoje ograniczenia. Wynikają one z jego natury. Skrypty pozwalają na eleganckie obudowanie skomplikowanych operacji. Hooki stanowią znowuż pierwszą linię obrony w przypadku prostych błędów, które mogą pojawić się w codziennej pracy.

Artykuły są dostępne na licencji CC-BY. Jeżeli spodobał ci się ten wpis, to podziel się nim z innymi lub wesprzyj autora. </div>