Git subtree

Git subtree

Dziel i łącz repozytoria Gita dzięki komendzie subtree.

środa, 7 czerwca 2017

Informatyka

System kontroli wersji Git nie wersjonuje poszczególnych plików z osobna - każda z wersji jest zapisem stanu całego repozytorium w danym momencie. Według mnie jest to jedna z najważniejszych cech Gita, dzięki której praca z kodem jest wyjątkowo wygodna, zwłaszcza gdy porównam wrażenia do korzystania z innych systemów takich, jak Subversion czy (o zgrozo) ClearCase. Niestety, jednocześnie stanowi ona poważne utrudnienie, gdy stwierdzamy, że nasz układ repozytoriów nam nie odpowiada i trzeba go zmienić. Nie możemy wszak po prostu wziąć sobie części plików i przenieść ich do nowego repozytorium, gdyż cała historia ich zmian zostanie poprzedniej lokalizacji. Jest jednak pewna komenda w Gicie, która może nam pomóc.

Git subtree

W dużym skrócie, git subtree to wyjątkowo wszechstronne narzędzie, które narodziło się jako niezależny projekt będący alternatywą dla git submodules, a później zostało włączone do Gita jako jedna z jego standardowych komend. Obecnie wciąganie np. biblioteki do projektu poprzez system kontroli wersji straciło mocno na znaczeniu dzięki systemom zarządzania zależnościami, jednak komenda ta ma też wiele innych zastosowań. Dwa podstawowe scenariusze, w których ją wykorzystuję, to:

  • wydzielenie fragmentu jednego repozytorium jako nowe repozytorium, z zachowaniem historii zmian,
  • podłączenie jednego repozytorium do drugiego jako jego część, z zachowaniem historii zmian.

Jak działa Git subtree?

Nazwa komendy wzięła się od sposobu jej pracy, który najłatwiej jest pokazać obrazowo:

Ilustracja
Działanie komendy git subtree

W Gicie mamy trzy rodzaje obiektów:

  • bloby - zawartość plików,
  • drzewa (trees) - mapa ścieżek katalogowych na bloby - to one definiują strukturę katalogową i mówią, jaką treść mają poszczególne pliki,
  • commity - opakowują drzewo dodatkowymi informacjami: autor, czas powstania, a także poprzednie wersje. To one reprezentują wersje oraz całą historię.

W typowym przypadku utworzona w ten sposób struktura jest płaska: każdy commit wskazuje na jedno drzewo, które zawiera ścieżki do wszystkich plików i mapuje je na odpowiednie bloby z zawartością. Jednak technicznie nie ma żadnych przeciwwskazań, żeby ścieżka w drzewie wskazywała na inne drzewo - i właśnie na tym bazuje git subtree. Podpięcie jednego repozytorium (A) pod drugie (B) polega tutaj na tym, że:

  • drzewo T1 z repozytorium A jest powiązane zarówno z commitem A1, jak i jest podpięte pod ścieżkę /xyz w drzewie T2 opisującym strukturę katalogową repozytorium B,
  • commit B1 jest powiązany z drzewem T2 i za rodzica ma inny commit B2 (wcześniejszy stan repozytorium B),
  • jednocześnie, commit B1 ma drugiego rodzica: A1 - w ten sposób łączymy historie obu projektów,
  • z punktu widzenia commitu A1 hipotetyczny plik foo.txt będzie widoczny pod ścieżką /foo.txt, zaś z z punktu widzenia commitu B1 - pod ścieżką /xyz/foo.txt.

Dzięki takiej organizacji Git wie, że przed commitem B1 oba projekty były rozwijane niezależnie. Historie każdego z nich są zachowane w naszym repozytorium, co pozwala nam bez przeszkód korzystać z takich komend, jak git log czy git blame. Jednocześnie, od tego momentu projekt A jest częścią projektu B i dalsza historia będzie już traktować je jako jedną całość.

W przypadku dzielenia repozytorium na mniejsze, git subtree wykonuje pod spodem operację rebase, jednak robi to w specyficzny sposób. Jako argument wejściowy dla komendy podajemy jeden z katalogów, który chcemy wydzielić do oddzielnego repozytorium. Komenda przegląda wtedy całą historię, szukając commitów, które w jakikolwiek sposób zmieniały pliki w tym katalogu i buduje z boku nową historię uwzględniającą wyłącznie te zmiany. Nowe commity oczywiście będą miały inne klucze, ale wszelkie metadane (w szczególności autor i data) pozostaną takie same. subtree nie zrobi jednak kopii tagów - byłoby to problematyczne, gdyż w danym repozytorium nie mogą istnieć dwa tagi o takiej samej nazwie, a do tego by się to sprowadzało.

Dzielenie repozytoriów

Zacznijmy od stworzenia sobie przykładowej struktury repozytorium:

$ mkdir subproject1
$ mkdir subproject2
$ touch subproject1/foo.txt
$ touch subproject1/bar.txt
$ touch subproject2/joe.txt
$ touch subproject2/moo.txt
$ git init .
Initialized empty Git repository in /subtrees/.git/
$ git add subproject1
$ git add subproject2
$ git commit -m 'Pierwszy commit'
[master (root-commit) 04736f4] Pierwszy commit
 4 files changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 subproject1/bar.txt
 create mode 100644 subproject1/foo.txt
 create mode 100644 subproject2/joe.txt
 create mode 100644 subproject2/moo.txt
$ vim subproject1/foo.txt 
$ vim subproject2/joe.txt 
$ git add subproject1
$ git add subproject2
$ git commit -m 'Zmiana w subproject1 i subproject2'
[master 30af13e] Zmiana w subproject1 i subproject2
 2 files changed, 2 insertions(+)
$ vim subproject2/moo.txt 
$ git add subproject2/
$ git commit -m 'Zmiana w subproject2'
[master 9c183c3] Zmiana w subproject2
 1 file changed, 1 insertion(+)
$ vim subproject1/bar.txt 
$ git add subproject1/
$ git commit -m 'Zmiana w subproject1'
[master 9034cf0] Zmiana w subproject1
 1 file changed, 1 insertion(+)

Wyświetlmy sobie historię zmian:

$ git log --oneline
9034cf0 (HEAD -> master) Zmiana w subproject1
9c183c3 Zmiana w subproject2
30af13e Zmiana w subproject1 i subproject2
04736f4 Pierwszy commit

Spróbujmy wydzielić katalog subproject1 jako oddzielny projekt. Zaczynamy od wywołania komendy subtree:

$ git subtree split --prefix=subproject1 -b subproject1-branch

W tym momencie Git zacznie przeszukiwać historię i budować z boku drugą wyłącznie z tych commitów, które zmieniają pliki w katalogu subproject1. Aby móc później się do nich odwołać, zostanie dla nich utworzona gałąź subproject1-branch. Gdy po zakończeniu zrobimy ponownie git log --oneline, zauważymy, że... naszych nowych commitów nigdzie nie ma! Nie jest to do końca prawda - one istnieją, tyle że nie mają powiązania z główną historią, dlatego git log nie jest w stanie ich pokazać. Nic nie stoi na przeszkodzie, aby w jednym repozytorium mieć dwie całkowicie niezależne historie commitów (równie dobrze jedną z nich mogłaby być historia jądra Linuksa). Aby się do nich dostać, musimy przełączyć się na nową gałąź:

$ git checkout subproject1-branch
$ git log --oneline
cdcde44 (HEAD -> subproject1-branch) Zmiana w subproject1
d6a7dfa Zmiana w subproject1 i subproject2
3141e8a Pierwszy commit
$ ls
foo.txt bar.txt

Widzimy tu dwie zasadnicze rzeczy:

  • nowa historia na gałęzi subproject1-branch zawiera tylko commity zmieniające nasz podkatalog - metadane zostały zachowane, ale klucze są inne,
  • pliki leżące oryginalnie w podkatalogu, teraz leżą bezpośrednio w głównym katalogu naszego repozytorium - z punktu widzenia nowej historii katalog subproject1 nie istnieje!

Tak utworzoną historię możemy wyeksportować do oddzielnego repozytorium:

$ mkdir ../subproject1
$ git init ../subproject1
Initialized empty Git repository in ../subproject1/.git/
$ git remote add subproject1-origin ../subproject1
$ git push subproject1-origin subproject1-branch
Counting objects: 9, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (6/6), done.
Writing objects: 100% (9/9), 796 bytes | 0 bytes/s, done.
Total 9 (delta 0), reused 0 (delta 0)
To ../subproject1
 * [new branch]      subproject1-branch -> subproject1-branch

Niepotrzebny już katalog subproject w oryginalnym repozytorium po prostu kasujemy:

$ git checkout master
Switched to branch 'master'
$ git rm -r subproject1
rm 'subproject1/bar.txt'
rm 'subproject1/foo.txt'
$ git commit -m 'subproject1 wydzielony jako oddzielne repozytorium'
[master cea0a79] subproject1 wydzielony jako oddzielne repozytorium
 2 files changed, 2 deletions(-)
 delete mode 100644 subproject1/bar.txt
 delete mode 100644 subproject1/foo.txt

I gotowe. Od tego momentu mamy dwa niezależne repozytoria.

Scalanie repozytoriów

Aby scalić dwa repozytoria, będziemy potrzebowali jakiegoś dodatkowego repozytorium. Oto seria komend, które pozwolą nam je szybko utworzyć:

$ vim test1.txt
$ vim test2.txt
$ git init .
Initialized empty Git repository in /proj3/.git/
$ git add test1.txt
$ git add test2.txt
$ git commit -m 'Zmiany w test1 i test2'
[master (root-commit) 71898e2] Zmiany w test1 i test2
 2 files changed, 2 insertions(+) 
 create mode 100644 test1.txt
 create mode 100644 test2.txt
$ vim test1.txt
$ git add test1.txt 
$ git commit -m 'Zmiany w test1'
[master 40f27b9] Zmiany w test1
 1 file changed, 1 insertion(+)

Oto historia:

$ git log --oneline
40f27b9 (HEAD -> master) Zmiany w test1
71898e2 Zmiany w test1 i test2

Przejdźmy do naszego repozytorium subtrees z poprzedniego przykładu. Tym razem wszystko, co musimy zrobić, to odpalić jedną komendę:

$ git subtree add --prefix=proj3 ../proj3/ master
git fetch ../proj3/ master
warning: no common commits
remote: Counting objects: 7, done.
remote: Compressing objects: 100% (4/4), done.
remote: Total 7 (delta 1), reused 0 (delta 0)
Unpacking objects: 100% (7/7), done.
From ../proj3
 * branch            master     -> FETCH_HEAD
Added dir 'proj3'

Zobaczmy zawartość naszego projektu:

$ ls -la
drwxr-xr-x  5 ... users  4096 06-07 19:06 .
drwxr-xr-x 33 ... users 12288 06-07 19:00 ..
drwxr-xr-x  9 ... users  4096 06-07 19:06 .git
drwxr-xr-x  2 ... users  4096 06-07 19:06 proj3
drwxr-xr-x  2 ... users  4096 06-07 19:01 subproject2
$ ls -la proj3
drwxr-xr-x 2 ... users 4096 06-07 19:06 .
drwxr-xr-x 5 ... users 4096 06-07 19:06 ..
-rw-r--r-- 1 ... users   14 06-07 19:06 test1.txt
-rw-r--r-- 1 ... users    8 06-07 19:06 test2.txt

Jak widać, projekt pojawił się jako podkatalog w naszym głównym repozytorium. Zerknijmy sobie na historię:

$ git log --oneline --graph
*   d942dd7 (HEAD -> master) Add 'proj3/' from commit '40f27b98dba83fa2f4fbd606b13cc5aa59b3bfb6'
|\  
| * 40f27b9 Zmiany w test1
| * 71898e2 Zmiany w test1 i test2
* cea0a79 subproject1 wydzielony jako oddzielne repozytorium
* 9034cf0 Zmiana w subproject1
* 9c183c3 Zmiana w subproject2
* 30af13e Zmiana w subproject1 i subproject2
* 04736f4 Pierwszy commit

Mamy zachowane historie obu projektów, które w pewnym momencie się łączą. Od tego momentu pracujemy już tylko z jednym repozytorium ze wspólną historią - sprawdź sam!

Podsumowanie

Wpis ten pokazuje jedynie część możliwości komendy git subtree, skoncentrowaną głównie na reorganizacji układu repozytoriów, jednak to jeszcze nie koniec. Zachęcam do samodzielnych eksperymentów i poszukania dalszych informacji na jej temat.

Tomasz Jędrzejewski

Programista Javy, lider techniczny. W wolnych chwilach podróżuje, realizując od kilku lat projekty długodystansowych wypraw pieszych.

Autor zdjęcia nagłówkowego: Creativity103, CC-BY-2.0

zobacz inne wpisy w temacie

Informatyka

poprzedni wpis Niezmienne kolekcje w JDK9 następny wpis Jak pożenić Gradle'a z Javą 9

Komentarze (0)

Skomentuj

Od 3 do 40 znaków.

Wymagany, anonimizowany po zatwierdzeniu komentarza.

Odpowiedz na pytanie.

Edycja Podgląd

Od 10 do 8000 znaków.

Wszystkie komentarze są moderowane i muszą być zatwierdzone przed publikacją.

Klikając "Wyślij komentarz" wyrażasz zgodę na przetwarzanie podanych w nim danych osobowych do celów moderacji i publikacji komentarza, zgodnie z polityką prywatności: polityka prywatności