Git subtree
Dziel i łącz repozytoria Gita dzięki komendzie subtree.
środa, 7 czerwca 2017
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:
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.
zobacz inne wpisy w temacie
Komentarze (0)