Software Development

Współpracować z gitem

Styczeń 30, 2017 0
Podziel się:

Git jest systemem kontroli wersji. Zapisuje historie zmian w śledzonym katalogu.
W kilku etapach postaram się przybliżyć jak wygląda praca z gitem i jakimi mechanizmami się rządzi.

W artykule opisuję w jaki sposób git przechowuje dane o zmianach, jakie są tego implikacje i w jaki sposób działają poszczególne komendy.

Treść jest dedykowana dla osób, które mają podstawowe doświadczenie z gitem i chcą uzupełnić swoją wiedzę.

Zaczynamy!

W PIGUŁCE O STRUKTURZE BAZY DANYCH

Podstawowe założenia:

  • repozytorium to katalog (.git), w którym jest baza danych
  • git używa struktur niezmiennych (immutable structures)
  • w trakcie pracy możemy modyfikować tylko wskaźniki – TAGI i BRANCHE
  • pojedynczy commit(zapis) jest to zapis stanu śledzonego katalogu

Repozytorium

Po sklonowaniu git clone lub inicjacji nowego repozytorium git init tworzony jest katalog o nazwie .git (kropka git) w którym znajduje się baza danych gita. Repozytoria zdalne (te z którymi się synchronizują użytkownicy) są również tworzone poleceniem git init –bare. W takim przypadku nie występuje katalog roboczy, a w jego miejscu jest umieszczona baza danych – czyli to samo co jest lokalnie w .git .

Struktury niezmienne (immutable structures)

Git zapisuje w repozytorium kopie wszystkich śledzonych zasobów. Podstawową operacją jest git commit czyli zapis katalogu roboczego. Podczas tej operacji tworzona jest seria wpisów, w bazie gita, zawierająca m.in. kopie zmienionych plików, opis zmian. Każdy wpis w bazie danych jest identyfikowany swoim ‘skrótem’ (hashem). Skrót jest tworzony w oparciu o zawartość pojedynczego wpisu. W praktyce oznacza to, że nie występują dwa różne wpisy do bazy gita, które mają taki sam skrót.

Przykładowy skrót: aaa43c379d7b32606f8e8a4fb7248747821c2c27.

Historia zmian w gitcie jest zapisywana jako lista ‘commitów’.

Przykładowy commit z bazy danych gita ( git cat-file pokazuje treść wpisu z bazy danych):

$ git cat-file -p HEAD
tree 51c68a455640c831d2a0cf0103b8f76484df9bb9
parent aaa43c379d7b32606f8e8a4fb7248747821c2c27
author Maciej Aleksandrowicz <maleksandrowicz@pl.sii.eu> 1483435432 +0100
committer Maciej Aleksandrowicz <maleksandrowicz@pl.sii.eu> 1483435432 +0100
Nowy commit

Prawie każdy commit (z wyjątkiem pierwszego) zawiera pole ‘parent’, które wskazuje na poprzedzający wpis. W ten sposób jest tworzona lista zmian.

Efekt z tego taki, że nie można modyfikować już dodanych commitów (nie oznacza to, że nie da się cofać pomyłkowo dodanych zmian), ponieważ każda zmiana zmieniłaby jego skrót i ‘przerwała’ listę.

Możemy modifikować tylko wskaźniki – TAGI i BRANCHE

Skoro dodajemy tylko nowe commity, to pojawia się pytanie – gdzie jest zapisany skrót najnowszego commita?

Tu pojawiają się BRANCHE (gałęzie) i TAGI (znaczniki). Są to jedyne elementy, które są zmienne w bazie danych gita. Każdy tag i branch jest plikiem w bazie gita (.git/refs/heads i .git/refs/tags), którego treścią jest skrót commita.

Praca na gałęzi ‘master’, oznacza że każda kolejna zmiana będzie w odniesieniu do commita zapisanego w pliku .git/refs/heads/master.

$ cat .git/refs/heads/master
c29cc78766e9aa6c17b53f9a40f5427c93054956

TAGI od BRANCHE’y różnią się tylko tym, że nie są modyfikowane. Służą do oznaczenia ważnych commitów.
Można je po prostu utworzyć i w razie potrzeby usunąć.

Pojedynczy commit jest to zapis stanu śledzonego katalogu

Każdy commit ma w treści pole ‘tree’. Jest to wskaźnik na inny plik w bazie danych, który zawiera opis katalogu w repozytorium.

Przykładowa treść wpisu ‘tree’:

$ git cat-file -p 51c68a455640c831d2a0cf0103b8f76484df9bb9
100644 blob 5f44efa8e71bfca2d78f403f9d57898152a1effc    git.txt

Treść pliku ‘tree’ to lista plików i katalogów (innych plików tree) oraz ich odpowiednie skróty. Kiedy zmieniana jest treść pliku lub struktura katalogu (np. dodanie nowego pliku, zmiana nazwy podkatalogu), zmianie ulega również jego skrót. Wynika z tego, że jakakolwiek zmiana w repozytorium powoduje utworzenie nowego pliku ‘tree’ opisującego całą strukturę katalogów.
Następstwem tego podejścia jest brak możliwości zsynchronizowania tylko części repozytorium.

PODSTAWOWE OPERACJE

Większość programów oferujących interfejs graficzny (np. TortoiseGIT, SourceTree) utrzymuje podobne nazewnictwo jak nazwy komend. Interfejsy graficzne są nakładką na gita z linii poleceń.

W czasie pracy z gitem występują 2 obszary po których się poruszamy: repozytorium i katalog roboczy:

  • katalog roboczy to miejsce, w który znajduje się repozytorium (katalog .git) i pliki śledzone przez git
  • repozytorium jest to baza danych w której zapisywany jest stan katalogu roboczego w momencie commita, wartości wskaźników (branche i tagi), pliki konfiguracyjne, wskaźniki na zdalne repozytoria (remote)

Praca z gitem sprowadza się do wykonania zapis stanu katalogu roboczego (git commit) i synchronizacji lokalnego repozytorium ze zdalnym (git push/pull/fetch).
Pracując z gitem, każdy użytkownik zarządza lokalnym repozytorium, tworzonym poleceniem git clone. Takie repozytorium jest zsynchronizowane ze zdalnym repozytorium (remote).
Synchronizacja oznacza, że zmiany utworzone w lokalnej bazie danych są wysyłane do zdalnego repozytorium, a zmiany ze zdalnego repozytorium, nakładane na lokalne. Dzięki temu możliwa jest wspólna praca wielu użytkowników.

PRACA Z LINIĄ POLECEŃ

W skrócie:

  • git help – uruchamia stronę z dokumentacją każdej komendy w git. Np. git help add, uruchamia instrukcje komendy ‘git add’
  • git status – pokazuje obecny stan katalogu roboczego
  • git log – pokazuje listę zmian w repozytorium
  • git add – dodaje pliki do indeksu(etapu przygotownia commita) (flaga -A dodaje również usunięte pliki)
  • git commit – zapisuje zmiany z indeksu do bazy danych
  • git push – wysyła zmiany do zdalnego repozytorium
  • git reset – zmiana wskaźnika brancha
  • git clean – usuwanie nieśledzonych plików
  • git checkout – zmienia aktualny branch, zmienia stan katalogu roboczego np. zmienia wartość katalogu na wersję z innego brancha
  • git pull – pobiera zmiany ze zdalnego repozytorium i wykonuje merge lub rebase
  • git fetch – pobiera zmiany ze zdalnego repozytorium, ale nie nanosi ich na katalog roboczy
  • git stash – tworzy commit obecnych zmian i przywraca katalog roboczy do stanu z repozytorium (odłożenie zmian na półkę)
  • git rebase – wykonuje ponownie serię commitów, ale na innej gałęzi. Ma wiele zastosowań
  • git cherry-pick – tworzy commit ze zmianami z innego commita
  • git clone – tworzy repozytorium na podstawie innego
  • git init – tworzy nowe, puste repozytorium

Obecnie katalog roboczy jest zsynchronizowany z repozytorium – wskazuje to polecenie:

$ git status
On branch master
nothing to commit, working tree clean

working tree clean – czyli katalog roboczy nie ma oczekujących zmian

Po zmodyfikowaniu pliku git.txt:

$ git status
On branch master
Changes not staged for commit:
  (use "git add ..." to update what will be committed)
  (use "git checkout -- ..." to discard changes in working directory)

        modified:   git.txt

no changes added to commit (use "git add" and/or "git commit -a")

Przygotowanie commita składa się z 2 etapów:

  1. dodanie plików do indeksu (oznaczenie ich do zapisu)
  2. utworzenie commita

Dodanie plików do indeksu

Powyżej git wskazuje, że plik git.txt nie jest oznaczony do zapisu (Changes not staged for commit).
polecenie git add git.txt dopisuje go do indeksu. Teraz git status wskazuje:

$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD ..." to unstage)

        modified:   git.txt

Polecenie git add dodało plik git.txt do indeksu.
WAŻNE
polecenie ‘git add’ tworzy kopię pliku git.txt w momencie kiedy był uruchomiony.
Oznacza to, że kiedy po ‘git add’ zrobię zmianę w pliku git.txt, nie będzie ona widoczna w commitcie (chyba, że ponownie wpiszę ‘git add git.txt’), a ‘git status’ pokaże
:

$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD ..." to unstage)

		modified:   git.txt

Changes not staged for commit:
  (use "git add ..." to update what will be committed)
  (use "git checkout -- ..." to discard changes in working directory)

		modified:   git.txt

Utworzenie commita

polecenie git commit -m “przykladowy commit” utworzy commit o nazwie ‘przykladowy commit’.
Uruchomienie git commit bez dodatkowego parametru uruchomi domyślny edytor tekstowe, w którym należy wprowadzić nazwę.

$ git commit -m "przykladowy commit"
[master 8de15f5] przykladowy commit
 1 file changed, 72 insertions(+), 2 deletions(-)

Kolejnym krokiem jest synchronizacja ze zdalnym repozytorium.
Polecenie git pull pobiera zdalne zmiany.
Jeśli w międzyczasie zdalne repozytorium zostało zmodyfikowane automatycznie tworzy ‘merge’ czyli złączenie dwóch gałęzi. W takim przypadku uruchamia się edytor (jak w przypadku git commit bez dodatkowego parametru) z opisem zmiany. W tym momencie mogą występować konflikty (conflicts). Merge/Rebase i konflikty są opisane poniżej.

$ git pull
remote: Counting objects: 3, done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 3 (delta 1), reused 0 (delta 0)
Unpacking objects: 100% (3/3), done.
From C:/Users/maleksandrowicz/Desktop/blogersii/../blogersiiremote
   8de15f5..2634d92  master     -> origin/master
Updating 8de15f5..2634d92
Fast-forward
 git.txt | 19 ++++++++++++++++++-
 1 file changed, 18 insertions(+), 1 deletion(-)

Kiedy lokalne repozytorium jest zsynchronizowane, można ‘wypchnąć’ lokalne zmiany do zdalnego repozytorium poleceniem git push.

$ git push
Counting objects: 3, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (2/2), done.
Writing objects: 100% (3/3), 555 bytes | 0 bytes/s, done.
Total 3 (delta 1), reused 0 (delta 0)
To C:/Users/maleksandrowicz/Desktop/blogersii/../blogersiiremote
   2634d92..3b13d1b  master -> master

Branche i tagi

  • na katalog roboczy zawsze wskazuje branch HEAD
  • nowy branch tworzy się poleceniem git branch NAZWA. Nowy branch wskazuje commit wskazywany przez HEAD
  • git tag jest odpowiednikiem git branch dla tagów
  • git checkout NAZWA zmienia aktywny branch
  • git checkout -b NAZWA tworzy branch i ustawia go jako aktywny
  • git branch -d NAZWA usuwa branch o ile nie pozostawi ‘osieroconych’ commitów (czyli jest zmergowany)
  • git branch -D NAZWA (duże d) usuwa branch niezależnie czy pozostawi commity
  • git push -u origin NAZWA tworzy w zdalnym repozytorium ‘origin’ branch NAZWA i automatycznie kojarzy lokalną gałąź NAZWA z origin/NAZWA
  • git push origin :NAZWA (dwókropek przed nazwą) usuwa branch ze zdalnego repozytorium ‘origin’

git branch -a pokazuje listę wszystkich gałęzi w repozytorium.

$ git branch -a
* master
  remotes/origin/HEAD -> origin/master
  remotes/origin/master

Branch ‘master’ ma swoją reprezentację w zdalnym repozytorium (nazwane ‘origin’) jako ‘origin/master’.

origin/master wskazuje na stan zdalnego repozytorium.
W momencie kiedy uruchamiany jest git fetch lub git pull, pobierane są zmiany ze zdalnego repozytorium i przesuwana jest gałąź origin/master.

Lokalne zmiany są utworzone w gałęzi master (‘master’ i ‘origin/master’ są to 2 różne gałęzie). Żeby można było dodać lokalne zmiany do zdalnego repozytorium, operacja git pull wykonuje złączenie lokalnej i zdalnej gałęzi. Efektem złączenia (merge’a) jest nowy ‘specjalny’ commit, który ma 2 gałęzie nadrzędne.

$ git cat-file -p HEAD
tree b0e26f6ea15223f9bf51edaa5b61611a5bd046ef
parent 8eb8f89082f9024fed980055145b8fce3521fa61
parent 4fcbcc435882f92e807d85f3c9e59ec8ba3beeca
author Maciej Aleksandrowicz <maleksandrowicz@pl.sii.eu> 1483446754 +0100
committer Maciej Aleksandrowicz <maleksandrowicz@pl.sii.eu> 1483446754 +0100

Merge branch 'X'

Po złączeniu gałęzi można wypchnąć zmiany do zdalnego repozytorium.
Z punktu widzenia zdalnego repozytorium, jeżeli zmieniana jest np. master, to przyjmowane są jedynie takie zmiany, które dodadzą(można do niej dodać tylko nowe commity).
Gałęzie to wskaźniki do commitów.

Przykład:

Zdalne repozytorium składa się z commitów X, Y, Z. Master wskazuje na commit Z.

(zdalne)
|Z - [master]
|Y
|X

Użytkownik synchronizował się z tym repozytorium dawno temu, kiedy ostatnim commitem był Y.
Następnie dodał do niego lokalnie commit Y1:

(lokalne)
|Y1 - [master]
|Y - [origin/master]
|X

Kiedy użytkownik uruchamia git push zdalne repozytorium zwraca błąd, ponieważ master w zdalnym repozytorium zmieniłby się z ‘Z’ na ‘Y1’, czyli przepadłby commit ‘Z’. Można dodawać tylko nowe commity do gałęzi (wyjątkiem jest flaga -f do push. Jej niewłaściwe wykorzystanie może rozsynchronizować repozytoria).

W tym momencie użytkownik uruchamia git pull, które pobiera zmianę ‘Z’ i aktualizuje branch origin/master:

(lokalnie)
|Y1 - [master]    |Z - [origin/master]
|Y----------------/
|X

W zależności od trybu wywołania git pull:

  • (domyślnie) wykona merge gałęzi master i origin/master:
    |MERGE - [master][origin/master]
    |   \
    |Y1 Z
    |   /
    |  /
    |Y 
    |X
    

    W tym momencie git push zadziała, bo z punktu widzenia repozytorium, do gałęzi master został dodany nowy commit (nad Z pojawił się commit MERGE).

  • (git pull –rebase) utworzy lustrzane commity(rebase), które będą zaczynały się z gałęzie origin/master:
    |!Y1 [master][origin/master]
    |Z   |Y1 (na ten commit nie wskazuje już żaden branch)
    |Y---/
    |X
    

    !Y1 – to commit z przeniesieniem zmian z Y1, ale utworzonych na podstawie stanu z commita Z. Rebase tworzy commity o takiej samej nazwie co oryginalne. Wykrzyknika w !Y1 użyłem, żeby zasygnalizować, że to jest nowy commit

    Podobnie jak w powyższym przypadku, zdalne repozytorium widzi jedynie jeden nowy commit i push się powiedzie.

WARTO WIEDZIEĆ

  • polecenie git fetch pobiera jedynie zmiany ze zdalnego repozytorium i modyfikuje gałęzie origin/*,
    w efekcie polecenie git pull jest najczęściej równoznaczne z git fetch,
    git merge,
    a polecenie git pull –rebase jest najczęściej równoznaczne z git fetch, git rebase
  • W przypadku kiedy uruchamiany jest git pull, a nie ma lokalnie zmian do wypchnięcia, git automatycznie przesunie lokalną gałąź na zdalną. Taka operacja nazywa się ‘fast-forward’. W takiej sytuacji git merge i git rebase zadziałają identycznie.

KONFLIKTY

Do tej pory artykuł traktował gita jako bazę danych przechowującą różne stany katalogu roboczego. Jednak poza składowaniem samych plików, analizowana jest ich treść. Podczas łączenia synchronizacji mogą występować sytuacje, w których dwóch użytkowników wykonało zmiany w tym samym pliku. Taka sytuacja jest nazywana konfliktem.

Podczas operacji ‘rebase/merge/cherry-pick’ tworzone są nowe commity na podstawie już istniejących.
Kiedy okaże się, że dwa commity zmieniły ten sam zasób, git próbuje rozwiązać konflikt. W przypadku plików tekstowych, wykonywane jest porównanie.

  • jeżeli inne części pliku zostały zmienione przez oba commity, konflikt jest rozwiązywany automatycznie
  • jeżeli zmienione części pliku tekstowego się pokrywają lub zmiana nastąpiła w pliku nie tekstowym, konflikt pozostaje do rozwiązania przez użytkownika
    W takim momencie operacja się zatrzymuje, a pliki które są podmiotami konfliktu, są odpowiednio oznaczone (widoczne w git status).

Rozwiązaniem konfliktu jest doprowadzenie plików do oczekiwanego stanu i dodanie ich do ‘indeksu’ czyli git add.

Po rozwiązaniu wszystkich konfliktów, w zależności od scenariusza należy uruchomić:

  • merge – git commit
  • rebase – git rebase –continue
  • cherry-pick – git cherry-pick –continue

Jeżeli z jakiegoś powodu nie można rozwiązać konfliktu, to operacje rebase/cherry-pick/merge można przerwać odpowiednio poleceniami:

  • merge – git reset –hard
  • rebase – git rebase –abort
  • cherry-pick – git cherry-pick –abort

W przypadku merge’a, ekran konfliktu automatycznie przerywa operacje, więc żeby cofnąć się do stanu z przed merge’a należy zresetować katalog roboczy do stanu z repozytorium ‘git reset –hard’.

Kiedy w tekstowym pliku pojawia się konflikt, którego git nie potrafi automatycznie rozwiązać, dodawane są w nim znaczniki. Np. w przypadku merge’a z gałęzią KONFLIKT pojawiło się:

<<<<<<< HEAD
INNY TEKST, KTÓRY SPOWODUJE KONFLIKT
=======
Ten tekst powoduje konflikt
>>>>>>> KONFLIKT

od ‘<<<<<<< HEAD’ do ‘=======’ to zmiany w obecnej gałęzi, od ‘=======’ do ‘>>>>>>> KONFLIKT’ to zmiany w mergowanej gałęzi, gdzie KONFLIKT jest nazwą gałęzi.

Od użytkownika zależy jak będzie wyglądał ostatecznie plik. Te znaczniki są tylko podpowiedzią i nic nie stoi na przeszkodzie, żeby w takim stanie dodać plik przez git add (czego nie polecam, a co się czasami zdarza w pośpiechu) i w efekcie umieścić w repozytorium.

W praktyce konflikty są rozwiązywane za pomocą oprogramowania, które pozwala wybrać wersję, niemniej warto zapamiętać, że przez git add oznaczamy już poprawiony plik.

WAŻNE
W przypadku konfliktu w trakcie rebase, kolejność gałęzi jest odwrócona. Rebase jest wykonywany z punktu widzenia docelowej gałęzi, na którą są nakładane ponownie poszczególne commity. W takim przypadku, HEAD będzie wskazywał na tę gałąź, a nakładane zmiany będą oznaczone jako obce. Jest to częsty powód powstawania błędów podczas synchronizacji.

POPRAWIANIE COMMITÓW

W trakcie pracy na pewno zdarzy się pomyłkowo utworzony commit lub wypchnięcie zmian z błędami.

git revert powoduje odwrócenie zmian wybranego commita. Operacja tworzy nowy commit, który przywraca zmodyfikowane uprzednio pliki. Trzeba pamiętać, że jest to kolejny commit i należy go wypchnąć.

Jeżeli commit został utworzony z błędem i nie został jeszcze wypchnięty, można zastosować polecenia:

  • git reset
  • git commit –amend
  • git rebase -i

git reset NAZWABRANCHA ustawia aktualny branch na commit wskazany przez NAZWABRANCHA. W przypadku użycia flagi –hard, wszystkie lokalne zmiany z katalogu roboczego (również z indeksu) są usuwane.

WARTO WIEDZIEĆ
git działa wyłącznie na plikach i katalogach, które są przez niego śledzone. Uruchomienie polecenia ‘git reset –hard’ nie usunie plików, które nie są dodane do repozytorium. Służy do tego polecenie ‘git clean’

Reset oferuje opcje : soft/mixed(domyślny)/hard/keep/merge ( po wyczerpujące informacje odsyłam do git help reset)

Polecenie git reset –soft HEAD~1 cofa commit do etapu zaraz przed ostatnim poleceniem git commit.
HEAD~1 oznacza, żeby przenieść się do commita o jeden wcześniej niż HEAD.

Polecenie git commit –amend zastępuje obecny najnowszy commit. Polecenie wygodne w przypadku literówki w tytule lub żeby dodać dodatkowe zmiany w plikach.

Polecenie git rebase -i to tzw. interaktywny rebase. Git uruchamia edytor, w którym użytkownik może określić jakie kroki chce wykonać z poszczególnym commitem. Pozwala m.in. złączyć commity, zmienić nazwę commita, pominąć commit.

Odzyskanie usuniętego lokalnie commita

Eksperymentując z git commit –amend lub git reset –hard zdarza się, że naniesiona poprawka jeszcze pogarsza sytuację.

Przykładowo, używając polecenia

git reset --hard HEAD~1

usunięto commit, który jednak miał w sobie część istotnych treści.

Ze względu na to, że branch jest tylko wskaźnikiem, można go ponownie ustawić na ‘usuniętym’ commitcie.

Do poznania historii zmian brancha HEAD, służy komenda git reflog

Przykład wyniku git reflog zaraz po ‘git reset –hard HEAD~1’

d31ab44 HEAD@{0}: reset: moving to HEAD~1
15e423a HEAD@{1}: checkout: moving from 2634d9211302b62056898f9856ff18e196a8b038 to master
2634d92 HEAD@{2}: checkout: moving from 3b13d1bed0296e044fed99cd872f44c5c10ec826 to HEAD~1
3b13d1b HEAD@{3}: checkout: moving from master to origin/master
15e423a HEAD@{4}: checkout: moving from 3b13d1bed0296e044fed99cd872f44c5c10ec826 to master
3b13d1b HEAD@{5}: checkout: moving from master to origin/master

Znaczniki HEAD@{0}, HEAD@{1} itd. są to aliasy do commitów, wypisanych w pierwszej kolumnie.

Komentarz opisuje jaka komenda gita doprowadziła do obecnego stanu.

Żeby branch master ponownie wskazywał na commit z przed ‘git reset –hard’, należy przenieść go na etap HEAD@{1}. Np. komendą git reset –hard HEAD@{1}.

W efekcie master będzie ponownie wskazywał ‘usunięty’ commit.
Do refloga zostanie dodany nowy wpis opisujący zmianę:

15e423a HEAD@{0}: reset: moving to HEAD@{1}
d31ab44 HEAD@{1}: reset: moving to HEAD~1

WAŻNE
Baza danych gita opiera się na zasadzie niezmiennych struktur, dlatego commity nie są modyfikowane. Ze względu na ograniczone zasoby dyskowe, cyklicznie (domyślnie co 2 tygodnie) git, podczas synchronizacji repozytorium, uruchamia Garbage Colletor (git gc). Garbage Collector wyszukuje commity, które nie są oznaczone przez jakikolwiek branch/tag i je usuwa.

Podsumowując

Git oferuje bardzo duże możliwości w organizowaniu repozytorium. Ze względu na swój rozproszony charakter jest niezawodny. Organizacja bazy danych uniemożliwia usuwanie treści, więc nawet omyłkowo usunięte dane, jeżeli tylko były scommitowane do gita, są do odzyskania. Znajomość metody poruszania się po gałęziach i narzędzi do ich organizacji niewątpliwie ułatwia codzienną pracę z tym programem.

Dla osób bardziej dociekliwych polecam dokumentację gdzie opisane są wszystkie zagadnienia związane z budową bazy danych i szczegółowy opis komend.

4 / 5
Tagi: , , ,

Imię i nazwisko (wymagane)

Adres email (wymagane)

Temat

Treść wiadomości

Zostaw komentarz