Posted on ::

Tak się zdarzyło, że w ciągu ostatnich miesięcy pracowałem nad wieloma projektami programistycznymi. Repozytoria leżały u różnych dostawców, musiałem więc użyć osobnych kont i metod autoryzacji. W moim przypadku były to jedno konto na Codebergu oraz dwa konta na Githubie - prywatne oraz pracowe. Problemem do rozwiązania stała się kwestia zarządzania tymi tożsamościami, kluczami, itp. Oto co chciałem osiągnąć: niezależnie od tego, skąd oraz dokąd sklonuję repozytorium, wszystkie zmiany powinny być opisane odpowiednim do projektu imieniem oraz adresem email, a pobieranie i wrzucanie zmian na serwer autoryzowane odpowiednim kluczem.

Do przechowywania konfiguracji repozytoriów Git używa się... Konfiguracji Gita. Jest to seria prostych plików tekstowych + narzędzie linii poleceń. Seria, ponieważ pliki te znajdują się w kilku miejscach i mają różny zakres działania. Mnie interesują najbardziej:

  • ~/.gitconfig - wersja globalna konfiguracji, znajduje się w folderze domowym. Tutaj znajdują się globalne ustawienia, takie jak strategie scalania czy aliasy (o których będzie kilka zdań na końcu), które są automatycznie aplikowane do każdego repozytorium.
  • <PROJEKT>/.git/config - wersja lokalna konfiguracji, znajduje się wewnątrz każdego repozytorium. Przechowuje przede wszystkim informacje dotyczące konkretnego repozytorium, takie jak adres zdalnego repozytorium, aktywne gałęzie, czy właśnie wspomniane wcześniej imię i adres email, używane do opisywania zmian. Można tu też nadpisać wartości z konfiguracji globalnej - lokalna wartość ma zawsze wyższy priorytet.

Przykładowy plik wygląda następująco:

# <PROJEKT>/.git/config

[core]
  repositoryformatversion = 0
  filemode = true
  bare = false
[remote "origin"]
  url = git@github.com:osobiste/projekt.git
  fetch = +refs/heads/*:refs/remotes/origin/*
[branch "main"]
  remote = origin
  merge = refs/heads/main
[user]
  name = "Andrzej"
  email = "andrzej@buziaczek.pl"

Drugą częścią układanki są klucze SSH. Pozwalają one na automatyczną autoryzację dostępu do różnych zasobów, również do repozytoriów Git. Pary kluczy - jeden prywatny i jeden publiczny - najlepiej stworzyć per tożsamość+dostawca; w moim przypadku są to trzy pary. To, które klucze używane są do którego repozytorium, można ustawić w kolejnym pliku konfiguracyjnym: ~/.ssh/config.

Przykładowy plik wygląda tak:

# ~/.ssh/config

Host codeberg.org
  HostName codeberg.org
  User git
  IdentityFile ~/.ssh/id_rsa_codeberg_osobiste

Host github.com
  HostName github.com
  User git
  IdentityFile ~/.ssh/id_rsa_github_osobiste

Host github.com
  HostName github.com
  User git
  IdentityFile ~/.ssh/id_rsa_github_pracodawca

Co uważniejsi mogą już tutaj zauważyć pewną niedogodność: pole Host, które jest identyfikatorem danej konfiguracji, nie rozróżnia pomiędzy różnymi kontami u danego dostawcy - mamy dwa wpisy oznaczone github.com. Na szczęście da się to łatwo obejść (spoiler: to pole to jedynie alias).

Rozwiązanie, które pozwoliło mi osiągnąć efekt, polega na użyciu dwóch intrygujących dyrektyw Konfiguracji Gita:

  • [includeIf hasconfig:remote.*.url]
    • która pozwala zastosować osobną konfigurację w zależności od adresu repozytorium - ma sens
  • [url.<base>.insteadOf]
    • która pozwala na całkowitą zmianę adresu danego repozytorium - ⁉️🙋‍♀️🤯

Jak to wszystko działa? Bardzo prosto. Zacznijmy od globalnego ~/.gitconfig:

# ~/.gitconfig

[includeIf "hasconfig:remote.*.url:git@codeberg.org:osobiste/**"]
  path = ~/.gitconfig_osobiste.codeberg.org.gitconfig

[includeIf "hasconfig:remote.*.url:git@github.com:osobiste/**"]
  path = ~/.gitconfig_osobiste.github.com.gitconfig

[includeIf "hasconfig:remote.*.url:git@github.com:pracodawca/**"]
  path = ~/.gitconfig_pracodawca.github.com.gitconfig

[alias]
  nuke = ...
  check = ...

Mamy tutaj trzy identyczne wpisy includeIf, które robią dokładnie to samo, czyli: dla dowolnego repozytorium, jeśli adres tego repozytorium pasuje do wzorca, to załącz do końcowej konfiguracji dla tego repozytorium zawartość pliku wskazanego w path. Nazwa i lokalizacja tych plików może być dowolna - w moim przypadku wyglądają one jak wyglądają, ponieważ:

  1. chciałem, żeby wszystkie pliki .gitconfig w katalogu domowym były "koło siebie" np. w przeglądarce plików VSCodium
  2. chciałem od razu widzieć, który plik odpowiada jakiej wersji tożsamość+dostawca
  3. VSCodium rozpoznaje plik o nazwie .gitconfig jako Konfigurację Gita - i ładnie koloruje składnię i oznacza ikonkę - ale pliku o nazwie .gitconfig_osobiste.codeberg.org już nie ogarnia, i dopiero dodanie "rozszerzenia" załatwiało sprawę

Poniżej przykład jednego z "inkludowanych" plików - w tym przypadku jest to wersja dla Githuba:

# ~/.gitconfig_osobiste.github.com.gitconfig

[user]
  name = "Andrzej"
  email = "andrzej@buziaczek.pl"
[url "git@osobiste.github.com:"]
  insteadOf = git@github.com:

Sekcja [user] jest oczywista. Natomiast nikt może zapytać, o co chodzi z tą podmianą adresu? To jest rozwiązanie wspomnianej niedogodności z konfiguracją SSH! Żeby to miało pełny sens, trzeba wprowadzić do niej małą poprawkę:

# ~/.ssh/config

Host osobiste.github.com # poprawiony Host, a.k.a `alias`
  HostName github.com
  User git
  IdentityFile ~/.ssh/id_rsa_github_osobiste
...

I teraz wszystko działa automagicznie w ten sposób:

  • klonujemy przykładowe repozytorium: git clone git@github.com:osobiste/projekt.git
  • adres repozytorium pasuje do jednej z dyrektyw includeIf w globalnej konfiguracji Git
  • imię oraz email są odczytane z dedykowanej konfiguracji - każda zmiana jest odpowiednio podpisana
  • adres repozytorium zostaje podmieniony na osobiste.github.com - odpowiednia para kluczy może być zidentyfikowana w konfiguracji SSH, a finalnie i tak zostanie użyty poprawny adres pobrany z pola HostName

Od teraz każde nowe repozytorium powinno konfigurować się samodzielnie, a obsłużenie nowego dostawcy czy tożsamości jest całkiem proste.


Aliasy. Konfiguracja Gita pozwala na tworzenie własnych komend Gita. Stworzenie aliasu jest bardzo proste - polega na dodaniu do konfiguracji w sekcji [alias] pary alias = polecenie. Dla poleceń Git wystarczy podać samo polecenie (co), ale można też używać komend powłoki (ojesu) lub nawet odpalać skrypty (nuke, check):

# ~/.gitconfig

[alias]
  co = checkout
  ojesu = !find . -type f -name "*.js" | xargs wc -l | sort -nr | head -10
  nuke = !~/.gitconfig_aliases.sh nuke
  check = !~/.gitconfig_aliases.sh check

Rezultat:

% git ojesu
  5506 total
  3201 ./static/js/searchElasticlunr.js
  1760 ./static/js/mermaid.js
   270 ./static/js/count.js
   103 ./static/js/codeblock.js
    82 ./static/js/themetoggle.js
    49 ./static/js/toc.js
    25 ./static/js/main.js
    14 ./static/js/note.js
     1 ./static/js/searchElasticlunr.min.js

W gratisie dorzucam plik .gitconfig_aliases.sh, w którym napisałem dwie funkcje użyte w przykładowych aliasach. Pierwsza z nich przegląda wszystkie katalogi w miejscu uruchomienia, sprawdza, czy są tam repozytoria Git i wyświetla informacje z konfiguracji. Tego aliasu używałem bardzo często podczas pracy nad przedmiotem tego wpisu. Drugi alias to tak ugrzeczniony git clean --dfx, który automatycznie działa w obrębie danego folderu i zawsze pyta o pozwolenie. Ułatwia kasowanie plików z .gitignore czy pustych folderów, których nie widać w git diff. Plik skryptu trzeba potraktować chmod +x, żeby aliasy mogły go użyć.

#!/usr/bin/env zsh

check() {
  for dir in */; do
    [[ -d "$dir/.git" ]] || continue
    {
      echo "$dir"
      git -C "$dir" config --get remote.origin.url
      git -C "$dir" config --get user.email
      git -C "$dir" config --get user.name
    } | paste -sd $'\t' -
  done | column -t
}

nuke() {
  [[ -n $GIT_PREFIX ]] && echo GIT_PREFIX=$GIT_PREFIX
  files=$(git clean -ndfx -- $GIT_PREFIX | tee /dev/tty)
  [[ -z $files ]] && echo "Nothing to nuke" && exit
  read -n 1 -p "Proceed? (yY) " proceed
  echo
  [[ $proceed == [yY] ]] && git clean -dfx -- $GIT_PREFIX
}

"$@"