Git

Git jest narzędziem służącym do kontroli wersji. Stanowi wolne oprogramowanie i podlega licencji GNU GPL w wersji 2. Jest domyślnie zainstalowany w większości dostępnych dystrybucji systemu Linux i powszechnie używany przez programistów. Za jego pomocą możemy wygodnie zapisywać i śledzić zmiany w katalogu plików. Git radzi sobie z plikami niezależnie od ich formatu (zatem nie służy wyłącznie do śledzenia zmian w kodzie źródłowym), jednakże wiele jego funkcjonalności ma zastosowanie tylko do plików tekstowych.

Rozproszenie pracy

Git jest systemem rozproszonym. Oznacza to, że nie istnieje w nim jedno główne repozytorium (zarządzany przez Gita katalog plików). Repozytorium utworzone w katalogu na maszynie X możemy sklonować (git clone) do katalogu na maszynie Y, następnie:

  1. Wprowadzić zmiany w repozytorium-klonie (edycja plików projektu)
  2. Zapisać zmiany (git add, git commit).
  3. Spropagować stan lokalnego repozytorium do oryginalnego (git push).

W ten sposób każdy programista w zespole może pracować nad projektem lokalnie, bez stałego dostępu do zdalnego repozytorium - propagując zmiany tylko wtedy, gdy chce się nimi podzielić z resztą zespołu.

Git powstał z myślą o projektach, nad którymi pracują rozproszone zespoły programistów - stworzył go Linus Torvalds jako system kontroli wersji dla jądra Linux.

Praca na lokalnym repozytorium

Inicjalizacja

Utwórzmy lokalny katalog o nazwie projekt. Będzie to główny katalog naszego projektu.

$ mkdir projekt

Teraz wejdźmy do tego katalogu i spróbujmy użyć Gita.

$ cd projekt
$ git status
fatal: not a git repository (or any of the parent directories): .git

Program Git poinformował nas, że nie znajdujemy się obecnie w żadnym repozytorium - nie jest nim ani nasz obecny katalog roboczy, ani żaden z zawierających go katalogów. Stwórzmy zatem repozytorium, które obejmuje nasz katalog roboczy.

$ git init
Initialized empty Git repository in ~/repozytorium/.git/

Zobaczmy, czy coś się w naszym projekcie zmieniło.

$ ls -a
. .. .git

Pojawił się w nim ukryty folder o nazwie .git. W tym folderze Git będzie zapisywać wszystkie dane, których potrzebuje do zarządzania repozytorium. W większości przypadków nie należy edytować go ręcznie i można zapomnieć o jego istnieniu - zarządzać nim będzie Git.

Przydatnym poleceniem do zorientowania się, jaki jest stan repozytorium, jest git status.

$ git status
On branch master

No commits yet

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

Git poinformował nas o trzech rzeczach:

  1. Aktualnie repozytorium znajduje się na gałęzi (branchu) o nazwie master
  2. Na gałęzi master nie ma żadnych commitów
  3. W repozytorium nie ma żadnych zmian

Pierwszy commit

Nie przejmując się na razie gałęziami, spróbujmy utworzyć pierwszego commita.

Commit reprezentuje zestaw zmian w plikach projektu i jest podstawowym budulcem repozytorium. Dokładniej - repozytorium Git jest acyklicznym grafem, w którym węzłami są commity. Prześledzenie historii commitów od początku repozytorium do wybranego commita i nałożenie po kolei odpowiadających im zmian skutkuje odbudowaniem stanu repozytorium z wybranego punktu w czasie.

Spróbujmy teraz wprowadzić jakieś zmiany w projekcie. Utworzymy plik Main.java i umieścimy w nim prosty program.

$ touch Main.java
$ vim Main.java

Zamiast programu Vim możemy oczywiście użyć preferowanego przez nas edytora tekstu. Umieśćmy w pliku Main.java następujący program:

public class Main {
    public static void main(String[] args) {
        System.out.println("Hello world!");
    }
}

Teraz zobaczmy, co o tym myśli Git.

$ git status
On branch master

No commits yet

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

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

Git poinformował nas, że w katalogu znajduje się nowy plik, który nie jest "śledzony". To znaczy, że nie znajdował się wcześniej w repozytorium. W celu utworzenia naszego commita musimy najpierw powiedzieć Gitowi, żeby przygotował nasz plik do zacommitowania.

$ git add Main.java
$ git status
On branch master

No commits yet

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

Jeżeli sprawdzimy zawartość pliku Main.java, okaże się, że nie zaszła w nim żadna zmiana. Git posługuje się jedynie ukrytym katalogiem .git, żeby śledzić stan repozytorium.

Plik Main.java został dodany do grupy plików przygotowanych do zacommitowania. Posługując się zaproponowaną przez Gita komendą możemy go z niej usunąć. Zamiast tego utwórzmy z naszych zmian commita.

$ git commit

W trakcie wykonywania tej komendy Git uruchomił dla nas edytor tekstu. W edytorze znajduje się podpowiedź, czego Git od nas oczekuje. Mamy wpisać treść komentarza dla nowego commita - każdy commit musi mieć komentarz. Zgodnie z zasadami sztuki komentarze powinny w zwięzły i zrozumiały sposób opisywać zmiany, które niosą ze sobą commity. W ten sposób inni programiści (i my sami następnego dnia) wiedzą, czego mogą się po nich spodziewać. Ułatwia to szukanie w historii projektu konkretnych zmian.

Możemy skonfigurować wybór edytora tekstu, który zostanie uruchomiony. O tym więcej w pomocniczej sekcji konfiguracja. Być może zdarzy się też, że Git nakrzyczy na nas za próbę stworzenia commita bez skonfigurowanej nazwy i adresu mailowego użytkownika. Rozwiązanie tego problemu również znajduje się w sekcji konfiguracja.

Możemy pominąć niewygodny krok wpisywania treści komentarza w edytor tekstu, wywołując komendę w poniższy sposób:

$ git commit -m "Proste Hello world"
[master (root-commit) 5ef9e31] Proste Hello world
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 Main.java
$ git status
On branch master
nothing to commit, working tree clean

Zmiana istniejącego pliku

Spróbujmy teraz wprowadzić nowe zmiany do projektu, dodając komentarz do pliku Main.java.

public class Main {
    public static void main(String[] args) {
        System.out.println("Hello world!"); // a friendly greeting
    }
}

Zobaczmy, co na to powie Git.

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

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

Podobnie jak poprzednio, dostaliśmy informację o zmianach, które nie są przygotowane do zacommitowania.

Zadanie 1 Spróbuj samodzielnie utworzyć z tych zmian nowego commita.

Innym przydatnym poleceniem Gita jest git log, które umożliwia nam obejrzenie historycznych commitów.

$ git log
commit ee32dd1562f2450075204ee24042b4ab38ed5e59 (HEAD -> master)
Author: ... <...>
Date:   ...

    Komentarz w Main.java

commit 0a6730fb9ce33de39f7308bdd01071b94ba0c3c5
Author: ... <...>
Date:   ...

    Proste Hello world

Powinniśmy zobaczyć dwa commity, które dotychczas utworzyliśmy. Oprócz komentarzy git log wyświetla również autorów commitów i daty ich utworzenia. Zostały również wypisane dwa długie ciągi znaków - ee32dd1562f2450075204ee24042b4ab38ed5e59 i 0a6730fb9ce33de39f7308bdd01071b94ba0c3c5. Są to deterministyczne, unikalne hasze wyliczone z danych i metadanych każdego commita. Przy ich pomocy możemy identyfikować commity.

Cofanie się w historii repozytorium

Spróbujmy teraz cofnąć nasze repozytorium do wersji sprzed dodania komentarza (hasz commita prawdopodobnie będzie się różnić, należy wyciągnąć go z danych wypisanych przez git log).

$ git checkout 0a6730fb9ce33de39f7308bdd01071b94ba0c3c5

W odpowiedzi na tę komendę Git wyświetli nam ostrzeżenie, że znajdujemy się teraz w stanie detached HEAD. Znaczy to tyle, że nie znajdujemy się na ostatnim commicie. Jeżeli sprawdzimy teraz zawartość pliku Main.java, zobaczymy że komentarz zniknął. Znajdujemy się w miejscu historii, w którym nie został jeszcze dodany. Żeby opuścić stan detached HEAD, wrócimy do poprzedniego miejsca, w którym byliśmy.

$ git checkout -

Powyższa komenda mówi Gitowi, że chcemy niejako "zrobić krok w tył".

Podglądywanie zmian

Żeby podejrzeć zmiany, które wprowadził dany commit, nie musimy cofać się do commita przed nim i ręcznie porównywać plików. Służy do tego odpowiednia komenda (ponownie, hasz commita prawdopodobnie będzie się różnić).

$ git show ee32dd1562f2450075204ee24042b4ab38ed5e59
commit ee32dd1562f2450075204ee24042b4ab38ed5e59 (HEAD -> master)
Author: ... <...>
Date:   ...

    Komentarz w Main.java

diff --git a/Main.java b/Main.java
index c422429..c4f4983 100644
--- a/Main.java
+++ b/Main.java
@@ -1,6 +1,6 @@
 public class Main {
     public static void main(String[] args) {
-        System.out.println("Hello world!");
+        System.out.println("Hello world!"); // a friendly greeting
     }
 }

Zmianę linii Git potraktował jako usunięcie starej linii i dodanie nowej (znaki - i + z lewej strony). Ten sposób prezentowania zmian jest jedną z funkcjonalności Gita, które działają tylko dla plików tesktowych (na szczęście większość plików w projektach informatycznych jest tekstowa).

Zadanie 2 Spróbuj samodzielnie podejrzeć zawartość pierwszego commita, w którym utworzyliśmy plik Main.java.

Ignorowanie wybranych plików

Przy pomocy komendy git add możemy wskazywać Gitowi poszczególne pliki jako przygotowane do zacommitowania. Możemy również wskazać cały katalog - zamiast nazwy pliku wystarczy wtedy użyć nazwy katalogu. Jednakże nie wszystkie lokalne pliki powinny znaleźć się w repozytorium. Na przykład kompilacja naszego programu Main.java pozostawiła w projekcie plik wynikowy o nazwie Main.class. Takich skompilowanych źródeł projektu zazwyczaj nie chcemy umieszczać w repozytorium. Na szczęście Git przewiduje takie sytuacje.

W specjalnym pliku o nazwie .gitignore możemy umieścić wzorce nazw plików i katalogów, które Git ma ignorować - takie pliki i katalogi nie będą pojawiać się w wynikach komend Gita, nie będą również brane pod uwagę podczas obsługi komendy git add. Składnia pliku .gitignore jest całkiem bogata - obsługiwane wzorce można znaleźć tutaj.

Zadanie 3 Spróbuj samodzielnie utworzyć plik .gitignore i przy jego pomocy wyłączyć z repozytorium plik Main.class. Następnie, w celu przygotowania projektu na przyszłe zmiany, spróbuj wyłączyć z repozytorium wszystkie pliki o rozszerzeniu .class - niezależnie od tego, gdzie w projekcie się znajdą. Dodaj plik .gitingore do repozytorium w osobnym commicie.

Repozytorium Git może zawierać wiele plików .gitignore umieszczonych w różnych katalogach. Każdy .gitignore wpływa na zachowanie Gita jedynie w zakresie katalogu, w którym jest umieszczony. Załóżmy następującą strukturę projektu:

- projekt
  - .gitignore
  - Main.java
  - Main.class
  - logi.logs
  - pakiet
    - .gitignore
    - InnaKlasa.java
    - InnaKlasa.class
    - logi.logs

Plik projekt/.gitignore wyłącza z repozytorium wszystkie pliki o rozszerzeniu .class, a plik projekt/pakiet/.gitignore - wszystkie pliki o rozszerzeniu .logs. W tej sytuacji pliki projekt/Main.class, projekt/pakiet/InnaKlasa.class oraz projekt/pakiet/logi.logs będą ignorowane przez Gita. Jednakże plik projekt/logi.logs nie będzie ignorowany, ponieważ zakres drugiego pliku .gitignore ogranicza się do jego katalogu.

Gałęzie

Często w naszym projekcie pracujemy nad kilkoma rzeczami jednocześnie i niezależnie (zwłaszcze jeżeli pracujemy w zespole). Do śledzenia takich zmian Git proponuje nam rozwiązanie nazywane gałęziami (branchami). Gałąź jest odnogą repozytorium, która zaczyna się od określonego commita i zawiera własne commity. Możemy śledzić w niej zmiany niezależnie od tego, co znajduje się na innych gałęziach.

Żeby zobaczyć, jakie gałęzie istnieją w repozytorium, można posłużyć się następującą komendą:

$ git branch
* master

Jak widzimy, w naszym repozytorium znajduje się tylko jedna gałąź o nazwie master. Jest to główna gąłąź projektu, którą Git utworzył dla nas podczas obsługiwania komendy git init. Spróbujmy utworzyć nowegą gałąź.

$ git checkout -b wypisz-argumenty
$ git branch
  master
* wypisz-argumenty

Komenda git checkout -b wypisz-argumenty utworzyła dla nas nową gałź i automatycznie nas na nią przełączyła (aktywna gałąź jest oznaczona znakiem * w danych wypisywanych przez git branch). Jeżeli podejrzymy teraz historię przy pomocy git log, zobaczymy nasze poprzednie dwa commity - nowa gałąź odziedziczyła całą historię.

Zadanie 4 Spróbuj samodzielnie zmienić program Main.java, tak aby wypisywał na standardowe wyjście wszystkie swoje argumenty. Na początku zmień program tak, żeby wypisywał każdy argument w osobnej linii i utwórz ze zmian commita. Następnie zmień program tak, żeby wypisywał argumenty w jednej linii, odzielone spacjami. Ponownie utwórz ze zmian commita. Sprawdź historię gałęzi i podejrzyj zmiany wprowadzone w nowych commitach.

W zadaniu 3. zasymulowaliśmy typowy sposób pracy z repozytorium Git. Na osobnej gałęzi pracowaliśmy nad nową funkcjonalnością projektu. W trakcie tego procesu utworzyliśmy kilka commitów, ale nie "zaśmieciliśmy" głównej gałęzi projektu (master) niedokończoną pracą. Teraz nadszedł czas na propagację naszych gotowych zmian na głównegą gałąź.

$ git checkout master
$ git merge wypisz-argumenty

Cofnęliśmy sie na gałąź master i posłużyliśmy się komendą git merge, żeby wcielić do niego zmiany z gałęzi wypisz-argumenty. Jeżeli zbadamy zawartość pliku Main.java, okaże się, że zawiera on kod, który napisaliśmy na gałęzi wypisz-argumenty. Jeżeli zbadamy historię przy pomocy komendy git log, okaże się, że zawiera ona znajome nam commity.

Konflikty

Przed chwilą posłużyliśmy się osobną gałęzią wypisz-argumenty, żeby pracować nad nową funkcjonalnością naszego projektu, a następnie wcielić gotowe zmiany do głównej gałęzi master. Cały proces przebiegł bez problemów - Git przeanalizował wszystkie commity z gałęzi wypisz-argumenty, a następnie nałożył je jeden po drugim na gałąź master. Jednakże zespołowa praca z Gitem rzadko tak wygląda. W rzeczywistości programiści pracują nad projektem współbieżnie, niezależnie rozwijając swój kod na osobnych gałęziach. Kiedy przychodzi pora na git merge, często okazuje się, że ktoś nas ubiegł i w międzyczasie wprowadził na główną gałąź swoje zmiany. Git jest w stanie samodzielnie rozwiązać sytuacje, kiedy zmiany te dotyczą innych plików czy innych linii w obrębie tego samego pliku. Jeżeli jednak zmiany się ze sobą kłócą, wymagana jest manualna naprawa konfliktów.

Zadanie 5 Spróbuj samodzielnie utworzyć z gałęzi master dwie gałęzie. Następnie w obu zmień w różny sposób komentarz, który dodaliśmy w pliku Main.java. Utwórz ze zmian po jednym commicie na każdej gałęzi. Wróć do gałęzi master i wykonaj merge pierwszej gałęzi. Następnie wykonaj merge drugiej gałęzi. Użyj komendy git status i dowiedz się, gdzie wystąpił problem. Użyj edytora tekstu, żeby wprowadzić ręczne poprawki i podążaj za sugestiami git status, aby rozwiązać konflikt.

Inne przydatne komendy

  1. git diff - pokazuje różnice między faktycznym stanem lokalnych plików, a ich wersją przechowywaną w repozytorium
  2. git rebase - alternatywa dla git merge, której powinniśmy używać, kiedy propagujemy zmiany na swojej prywatnej gałęzi (więcej o różnicach między git merge i git rebase tutaj)
  3. git stash - usuwa z projektu niezapisane zmiany i przechowuje je w schowku Gita
  4. git stash pop - zabiera zmiany przechowywane w schowku Gita i nakłada je na pliki projektu
  5. git reset - usuwa ostatnie commity, pozostawiając w plikach zawarte w nich zmiany (niezapisane)
  6. git reset --hard - usuwa ostatnie commity i zawarte w nich zmiany

Praca ze zdalnym repozytorium

Klonowanie repozytorium

Innym sposobem pracy z Gitem jest operowanie na lokalnej kopii zdalnego repozytorium. W wybranym katalogu utwórzmy teraz nowe repozytorium, tak samo jak wcześniej w scenariuszu.

$ mkdir nowy_projekt
$ git init

Na głównej gałęzi utwórzmy kilka commitów o wybranej treści. Następnie wejdźmy do nadkatalogu naszego projektu i sklonujmy repozytorium.

$ cd ..
$ git clone nowy_projekt klon

Git sklonował dla nas repozytorium nowy_projekt do nowego katalogu o nazwie klon. Możemy teraz sprawdzić status sklonowanego repozytorium.

$ cd klon
$ git status
On branch master
Your branch is up to date with 'origin/master'.

nothing to commit, working tree clean

Aktualizowanie sklonowanego repozytorium

Jeżeli sprawdzimy historię przy pomocy git log, zobaczymy commity ze zdalnego repozytorium. Wróćmy teraz do niego i dodajmy jeszcze jednego commita. Ponownie sprawdźmy historię w repozytorium-klonie.

Okazuje się, że repozytorium-klon nie zawiera nowych zmian - Git nie robi sam niczego, o co wprost nie poprosimy, w szczególności nie aktualizuje naszych repozytoriów. Żeby zaktualizować naszego klona, musimy użyć komendy git pull. Ponowne sprawdzenie historii pokaże, że klon został zaktualizowany.

Aktualizowanie oryginalnego repozytorium

Utwórzmy teraz nowego commita na gałęzi master w sklonowanym repozytorium. Żeby zaktualizować gałąź master w zdalnym repozytorium, musimy w sklonowanym repozytorium wykonać komendę git push.

Zadanie 6 Spróbuj samodzielnie utworzyć w sklonowanym repozytorium nową gałąź wraz z kilkoma commitami. Następnie użyj komendy git push, żeby zaktualizować zdalne repozytorium. Czy komenda się powiodła? Użyj komendy zaproponowanej przez Gita. Sprawdź, czy zdalne repozytorium zawiera nowe zmiany.

Git w IntelliJ IDEA

IntelliJ IDEA automatycznie wykryje istniejące repozytorium Git, jeżeli w otwartym katalogu takie istnieje. Jeżeli właśnie utworzyliśmy nowy projekt przy pomocy IDE lub otworzyliśmy katalog, w którym nie ma jeszcze repozytorium, możemy je w prosty sposób stworzyć. Wystarczy otworzyć dropdown menu VCS (Version Control System) z górnego paska interfejsu i wybrać opcję Create Git Repository.... Po utworzeniu repozytorium, menu VSC zostanie zastąpione przez menu Git. Zobaczymy tam znajome już nazwy, a interfejs pozwoli "wyklikać" wszystko w przyjazny sposób. Na przykład wybór Show Git Log otworzy nam nową zakładkę, w której oprócz listy commitów zobaczymy graficzne przedstawienie zawartej w nich historii.

Serwisy internetowe wspierające pracę z Gitem

Istnieje kilka serwisów internetowych, które oferują swoim użytkownikom dostęp do zdalnych repozytoriów Git. Najbardziej popularne spośród nich to:

  1. GitHub
  2. GitLab
  3. Bitbucket

Serwisy takie mają wiele możliwości, a założenie na nich prywatnego konta jest zazwyczaj darmowe. Przy pomocy takiego serwisu możemy na przykład:

  1. Stworzyć nasze repozytorium, którego stan będzie przechowywany na serwerach dostawcy serwisu. Dzięki temu będziemy mogli sklonować je na każdą maszynę, na której aktualnie pracujemy. Nie ryzykujemy też, że nasza praca przepadnie wskutek awarii naszego prywatnego nośnika danych.
  2. Udostępnić nasze repozytorium innym, umożliwiając wygodną pracę niezależnym programistom. Główne repozytorium jest dostępne przez cały czas z serwerów dostawcy serwisu, zatem nie musimy hostować go sami. Ponadto możemy ustawić odpowiednie uprawnienia dla repozytorium, na przykład zezwalać na przeglądanie go wszystkim użytkownikom, ale na modyfikowanie - jedynie wybranym. W ten sposób rozwijanych jest wiele projektów otwartoźródłowych.
  3. Zdefiniować w naszym repozytorium systemy CI/CD. Większość serwisów oferuje użytkownikom możliwość automatyzowania różnych czynności. Na przykład za każdym razem, gdy ktoś zrobi merge do gałęzi main, serwis zbuduje projekt przy pomocy wskazanych przez nas skryptów i przetestuje gotowy program. Różne serwisy posługują się w tym celu swoją własną składnią. Na przykład GitHub oferuje deklaratywne definiowanie akcji w plikach yaml umieszczonych w projekcie w specjalnym katalogu .github/workflows - GitHub Actions.

Konfiguracja

Git jest wysoce konfigurowalny, do czego służy komenda git config. Git rozpoznaje trzy poziomy konfiguracji: lokalny, globalny i systemowy.

  1. Poziom lokalny aplikuje się do akcji podejmowanych w danym repozytorium, a konfiguracja jest przechowywana katalogu repozytorium w pliku .git/config.
  2. Poziom globalny aplikuje się do akcji podejmowanych przez danego użytkownika systemu, a konfiguracja jest przechowywana w pliku ~/.gitconfig (~ to skrót oznaczający katalog domowy użytkownika).
  3. Poziom systemowy aplikuje się do wszystkich akcji podejmowanych w systemie, a konfiguracja jest przechowywana w pliku zależnym od konkretnego systemu, na przykład /etc/gitconfig.

    W każdym z nich mamy dostęp do tych samych opcji. Szukając swojej konfiguracji, Git patrzy kolejno na konfigurację lokalną, globalną i systemową. Zatrzymuje się na pierwszej konfiguracji, w której znajduje się wartość dla danej opcji.

    Żeby globalnie ustawić w Git nazwę i adres mailowy użytkownika, należy posłużyć się następującymi komendami:

    $ git config --global user.name ab123456
    $ git config --global user.email ab123456@students.mimuw.edu.pl

Jeżeli w konkretnym repozytorium chcemy przedstawiać się inaczej, należy wejść do niego i dostosować konfigurację lokalną.

$ cd /sciezka/do/repozytorium
$ git config --local user.name ktos_inny
$ git config --local user.email ktos_inny@mail.com

Innym przydatnym ustawieniem jest program do edycji tekstu, przy pomocy którego Git będzie umożliwiał nam tworzenie komentarzy do commitów:

$ git config --global core.editor nano

lub

$ git config --global core.editor vim

lub dowolny preferowany edytor.

Żeby poznać wszystkie opcje ustawione w konfiguracji można posłużyć się komendą git config --list. Jeżeli interesuje nas konkretny poziom konfiguracji, możemy wywołać tę komendę tak samo jak komendy wyżej.

$ git config --local --list
$ git config --global --list
$ git config --system --list

Materiały do dalszej nauki

System kontroli wersji Git jest obecnie standardem w pracy programistów i jego znajomość jest w wielu sytuacjach konieczna. Informacje z tego scenariusza powinny wystarczyć do pracy nad projektami na tym przedmiocie, jednakże Git oferuje o wiele więcej możliwości. Więcej materiałów do nauki można znaleźć w interncie, na przykład tutaj i tutaj (forma bardziej interaktywna).