Narzędzia programisty: git (część 1)

Ludzie dzieją się na dwie grupy: tych, co robią backupy i tych, co będą robili. Jednak jeśli cały dzień grzebiesz przy kodzie i chcesz mieć możliwość powrotu do kodu sprzed 10 minut, to ręczne kopiuj-wklej, bądź nawet zipowanie całego projektu, może być cokolwiek popierdolone.

Gorzej, jeśli projektu nie klepiesz sam(a): jak wówczas pogodzić zmiany wprowadzane jednocześnie przez iluś ludzi w iluś plikach? Umawiać się, kto kiedy będzie edytował, który plik? Nie bardzo. Trzymanie wszystkiego na Dropboxie w nadziei, że gdy Jasiu zapisze plik, to synchronizacja szybko przerzuci zmiany do ciebie, a następnie edytor ładnie poprosi o odświeżenie pliku, a ty bez problemu połączysz zmiany, też jest nieco naiwne.

Rozwiązanie starego problemu

Ten problem już dawno rozwiązano przy pomocy koncepcji zwanej systemem kontroli wersji. Zmiany, które wprowadzasz w projekcie regularnie zapisujsz do systemu, on śledzi zarówno zmiany wprowadzone przez ciebie, jak i te wprowadzone przez innych, dba o utrzymanie histori (dostępny snapshot projektu w dowolnym momencie!) i stara się ułatwić łączenie równoległe przeprowadzonych zmian.

W międzyczasie zdążyliśmy obejrzeć już kilka generacji systemów kontroli wersji, wprowadzających kolejne ulepszenia. Obecnie najpopularniejszym jest Git.

Pierwsze kroki z gitem

Zakładam, że umiesz uruchomić terminal, zaś git jest już zainstalowany i dostępny w terminalu. Jeśli git --version zamiast numeru wersji wyświetla błąd, to jest to problem, którego już za ciebie nie rozwiążę.

Na początek stwórzmy sobie katalog na którym będziemy mogli sobie poużywać, np. test.

$ mkdir test

Następnie wejdźmy do katalogu i poinformujmy wszechświat, że chcemy, aby folder ten cieszył się błogosławieństwem gita.

$ cd test
$ git init

Jeśli zajrzymy do katalogu (i, jeśli jesteśmy na uniksie, mamy włączone pokazywanie plików ukrytych) zobaczymy, że został tam utworzony katalog .git. W tym katalogu trzymane są wszystkie dane potrzebne przez gita do ratowania nam dupska raz za razem, więc dobrze będzie nie grzebać w nim, ani go nie wywalać. Chyba, że mamy ważny powód pozbycia się całej historii naszego projektu.

Wpiszmy teraz w terminal polecenie

$ git status
On branch master

Initial commit

nothing to commit (create/copy files and use "git add" to track)

Git poinformował nas, że:

  • jesteśmy na branchu master - branch to tak jakby odnoga historii projektu (wyjaśnimyo to sobie jeszcze dokładnie za chwilę), zaś master to pierwsza (i domyślna) odnoga jaka jest tworzona,
  • git utworzył pierwotną wersję projektu - określenie commit odnosi się do stworzenia kolejnego snapshotu projektu w historii, który dostaje swój opis, autora i datę utworzenia,
  • na końcu mamy informację, czy pojawiły się jakieś zmiany od ostatniej zapisanej historii.

Spróbujmy w dowolnym edytorze utworzyć plik tekstowy test.txt i wstawmy do niego coś w stylu:

line 1
line 2
line 3

Teraz w terminalu:

On branch master

Initial commit

Untracked files:
  (use "git add <file>..." to include in what will be committed)

	test.txt

nothing added to commit but untracked files present (use "git add" to track)

pojawiła się informacja, że mamy nowy, nieśledzony (untracked) plik. Nadal nic nie jest jeszcze dodane do commita. Spróbujmy zgodnie z sugestią wywołać polecenie git add:

$ git add test.txt
$ git status
On branch master

Initial commit

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)

	new file:   test.txt


Utworzenie przez nas pliku zostało oznaczone do zacommitowania. Spróbujmy to zrobić:

$ git commit

*** Please tell me who you are.

Run

  git config --global user.email "you@example.com"
  git config --global user.name "Your Name"

to set your account's default identity.
Omit --global to set the identity only in this repository.

fatal: unable to auto-detect email address (got 'dev@maddening.(none)')

Okazuje się, że commit nie może zostać utworzony, bo nie mamy jeszcze uzupełnionego naszego nazwiska i adresy, tak więc git nie mógłby zapisać, kto utworzył commit. Zróbmy, więc to o co prosi nas git:

git config --global user.email "jan.kowalski@gmail.com"
git config --global user.name "Jan Kowalski"

Ponownie spróbujmy zacommitować zmiany:

$ git commit -m "My first commit"
[master (root-commit) f5158ec] My first commit
 1 file changed, 4 insertions(+)
 create mode 100644 test.txt

Stało się kilka rzeczy. Spróbujmy wywołać kilka poleceń i zobaczyć ich wyniki:

$ git status
On branch master
nothing to commit, working tree clean
$ git log
commit f5158ec3e9d2b39e4981a4f85d203da2538de1b8 (HEAD -> master)
Author: Jan Kowalski <jan.kowalski@gmail.com>
Date:   Fri Aug 4 20:13:16 2017 +0200

    My first commit

Okazało się, że git status nie pokazuje żadnych zmian przy obecnym stanie katalogu - nic dziewnego jeśli utrwaliliśmy wszystkie zmiany (utworzenie pliku) jako obecny stan. Nasze działanie widać przy przejrzeniu logu przy git log. Składa się on obecnie z 1 commita, opisanego jako My first commit i dokonanego przez Jan Kowalski.

Zmiany, zmiany, zmiany

Wyedytujmy plik test.txt, aby miał następującą postać:

line 2
line 2.5
line 3

Przetestujmy co teraz powie nam git, gdy wywołaby kilka poleceń:

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

	modified:   test.txt

no changes added to commit (use "git add" and/or "git commit -a")
$ git diff
diff --git a/test.txt b/test.txt
index ada066f..3e07f79 100644
--- a/test.txt
+++ b/test.txt
@@ -1,4 +1,4 @@
-line 1
 line 2
+line 2.5
 line 3
 

Jak widać jest tu lekki szum informacyjny, no więc po kolei:

  • git status pokazuje nam, że plik test.txt został zmodyfikowany - plik już nie jest uznawany za nowy, ale jako istniejący plik zmiany w którym są śledzone przez gita,
  • podobnie jak wcześniej możemy użyć git add test.txt, aby zastage’ować zmiany do commita,
  • git diff pokazuje nam jak plik został zmieniony - usunięto linijkę line 1 oraz dodano line 2.5 - te minusy oraz plusy oznaczają dokładnie to: różnice pomiędzy poprzednią zapisaną wersją pliku, a obecną.

Zapiszmy więc zmiany jako następny commit:

$ git add test.txt
$ git commit -m "Changed test.txt"
[master 0fdaf2c] Changed test.txt
 1 file changed, 1 insertion(+), 1 deletion(-)

Drzewo może mieć wiele gałęzi

Jeśli spojrzymy na historię naszego mini repozytorium, zobaczymy, że zmiany zapisują się w pewien ciąg:

$ git log
commit 0fdaf2c15989890cc2004962b436711eae4001e9 (HEAD -> master)
Author: Jan Kowalski <jan.kowalski@gmail.com>
Date:   Mon Aug 28 16:16:33 2017 +0200

    Changed test.txt

commit f5158ec3e9d2b39e4981a4f85d203da2538de1b8
Author: Jan Kowalski <jan.kowalski@gmail.com>
Date:   Fri Aug 4 20:13:16 2017 +0200

    My first commit

(Tak, obijałem się z pisaniem tego posta).

Z loga możemy wyczytać, że najnowszy post jest głową brancha master. Tzn. jest to tam napisane, ale bez używania słowa branch. Moglibyśmy z tego wywnioskować, że branch to pewien ciąg commitów od początku historii do teraz. Dlaczego jednak mielibyśmy go nazywać? Przeprowadźmy pewien test.

$ git checkout -B test-a
Switched to a new branch 'test-a'

Utworzyliśmy w ten sposób nową gałąź o nazwie test-a. Póki co, jest ona identyczna z branchem master.

$ git log
commit 0fdaf2c15989890cc2004962b436711eae4001e9 (HEAD -> test-a, master)
Author: Jan Kowalski <jan.kowalski@gmail.com>
Date:   Mon Aug 28 16:16:33 2017 +0200

    Changed test.txt

commit f5158ec3e9d2b39e4981a4f85d203da2538de1b8
Author: Jan Kowalski <jan.kowalski@gmail.com>
Date:   Fri Aug 4 20:13:16 2017 +0200

    My first commit

Spróbujmy dodać do pliku nową linijkę

line 2
line 2.5
line 3
line a

i zacommitować:

$ git add test.txt 
$ git commit -m "Branch a change"
[test-a 2d999ce] Branch a change
 1 file changed, 1 insertion(+)
$ git log
commit 2d999ce3db73686553cadb0af2ad6f0010e3db90 (HEAD -> test-a)
Author: Jan Kowalski <jan.kowalski@gmail.com>
Date:   Mon Aug 28 19:31:49 2017 +0200

    Branch a change

commit 0fdaf2c15989890cc2004962b436711eae4001e9 (master)
Author: Jan Kowalski <jan.kowalski@gmail.com>
Date:   Mon Aug 28 16:16:33 2017 +0200

    Changed test.txt

commit f5158ec3e9d2b39e4981a4f85d203da2538de1b8
Author: Jan Kowalski <jan.kowalski@gmail.com>
Date:   Fri Aug 4 20:13:16 2017 +0200

    My first commit

Widać już, że gałęzie przestały być identyczne. master nadal stanowi historię od My first commit do Changed test.txt, podczas gdy test-a zawiera już dodatkowy commit. Spróbujmy wrócić do brancha master:

$ git checkout master
Switched to branch 'master'

Jeśli otworzymy teraz plik test.txt zobaczymy, że wrócił on do poprzedniej postaci. Dodajmy w takim wypadku inną linijkę gdzie indziej:

line master
line 2
line 2.5
line 3
$ git add test.txt 
$ git commit -m "Change on master"
[master c873cdc] Change on master
 1 file changed, 1 insertion(+)
$ git log 
commit c873cdceb0284fdd571a67e292511be1a445dbcc (HEAD -> master)
Author: Jan Kowalski <jan.kowalski@gmail.com>
Date:   Mon Aug 28 19:35:25 2017 +0200

    Change on master

commit 0fdaf2c15989890cc2004962b436711eae4001e9
Author: Jan Kowalski <jan.kowalski@gmail.com>
Date:   Mon Aug 28 16:16:33 2017 +0200

    Changed test.txt

commit f5158ec3e9d2b39e4981a4f85d203da2538de1b8
Author: Jan Kowalski <jan.kowalski@gmail.com>
Date:   Fri Aug 4 20:13:16 2017 +0200

    My first commit

W tym momencie widzimy, że historia obu branchy już się rozdzieliła na dwie. Od tej pory moglibyśmy każdą z gałęzi rozwijać osobno i wrzucań na nie osobne zmiany. W istocie często się tak robi! Kiedy więcej niż jeden programista pracuje nad projektem często zmiany jakie każdy z nich wprowadza dokonywane są na osobnych branchach. Dzięki temu mogą nie wchodzić sobie wzajemnie w drogę.

Jak jednak sprawić, aby zmiany dokonywane równolegle koniec końców połączyć ze sobą?

$ git merge test-a

Wówczas, git otworzy dla nas okno tekstowego edytora plików, abyśmy mogli dodać opis commita w którym mergujemy zmiany. (Do tej pory unikaliśmy tej przyjemności pisząc git commit -m "opis" zamiast samego git commit).

Merge branch 'test-a'

# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.

Okno edycji wiadomości zostanie otwarte wewnątrz Vim’a (poznasz po tym, że naciskanie klawisze nie mają związku z tym, co się dzieje na ekranie), naciśnij (ze 2 razy dla pewności ) ESC, a następnie wpisz :wq!, żeby zapisać i wyjść. Domyślna wiadomość ujdzie.

Auto-merging test.txt
Merge made by the 'recursive' strategy.
 test.txt | 1 +
 1 file changed, 1 insertion(+)

W tym momencie zmiany z obu wersji historii się połączyły, a master nie będzie się już plątał w zeznaniach. Konkretnie to zmiany z brancha test-a zostały dołączone do brancha master. Detale zostawmy sobie na nieco później.

The stupid content tracker

Jak powiedział jego twórca, git to głupie narzędzie do śledzenia zawartości. Nie potrafi inteligentnie stwierdzić, jak zmieniła się treść pliku: jedyne co rozumie to jakie linie zostały dodane/usunięte w pliku zapisanym czystym tekstem.

Oznacza to dla nas, że pliki office’a/graficze/muzyczne/itd. nie skorzystają na wrzuceniu ich do repozytorium. Git wychwyci jeśli je zmienimy, ale nie będzie w stanie powiedzieć jak. Nie będzie też w stanie rozwiązać za nas konfliktów tj. sytuacji, gdy podczas merga pojawiły się zmiany, które nie mogą być z automatu połączone np. edycja tej samej linijki na 2 różne sposoby. Te akurat problemy nauczumy się rozwiązywać w części drugiej.

Podsumowanie

Na razie pokazaliśmy sobie kilka rzeczy związanych z gitem:

  • tworzenie nowego repozytorium,
  • dodawanie zmian do przyszłego commita,
  • commitowanie zmian,
  • wybranchowienie się i mergowanie istniejących zmian.

Powiedzmy sobie wprost: te krótkie ćwiczenia to za mało, żeby dobrze to poczuć. Dlatego polecam trochę się pobawić i popatrzeć na git log, żeby zobaczyć jak historia się zmienia, kiedy edytujemy pliki i commitujemy zmiany.

Update 2017-09-04: część druga!