FK~

Moje odkazy

Ostatní odkazy

Close Windows
Nenajdete mě na Facebooku ani Twitteru
Rozpad EU
Jsem členem FSF
There Is No Cloud …just other people's computers.
Sane software manifesto / Manifest příčetného softwaru

Git a kopírování souborů

vydáno: 16. 6. 2022 23:46, aktualizováno: 16. 6. 2022 23:46

Git a Mercurial jsou stejná generace verzovacích systémů: distribuované VCS. Vznikly v roce 2005 jen pár měsíců po sobě. Jednou z nevýhod Gitu oproti Mercurialu je to, že nepodporuje kopírování souborů. Zatímco v Mercurialu máme hg mvhg cp, v Gitu máme jen git mv. Soubor – myšleno včetně jeho historie – můžeme v Gitu jen přesunout, ale ne zkopírovat. Tedy alespoň ne jednoduše… V dnešním článku si ukážeme trik, jak i v Gitu soubory kopírovat včetně jejich historie nebo přesouvat jejich části jinam, což se hodí obecně, i např. v Mercurialu nebo jiných VCS.

loga verzovacích systémů: Subversion, Git, Mercurial, Bazaar, Fossil, Monotone, Pijul, BitKeeper, GNU Arch, Darcs

K čemu je to dobré?

Historie verzovacího systému obsahuje cenné informace, které se hodí, když studujeme nebo upravujeme starší kód – je dobré vědět, kdo, kdy a hlavně proč danou část kódu napsal. To nám dává potřebný kontext, bez kterého by pochopení dané logiky bylo obtížnější a zdlouhavější. V ideálním případě dohledáme konkrétní změnový požadavek, na základě kterého byla daná úprava do kódu zanesena.

Pokud kód jen připisujeme, historie se zachovává automaticky. Horší je to ale v případě, kdy refaktorujeme např. přesuneme některé metody do samostatných souborů. Git v tomto případě ztratí historii přesunutých řádků a jako autora eviduje toho, kdo je přesunul. Existuje sice volba git log --follow (Git se pak snaží uhodnout, kdy šlo o kopii nebo přesun), ale to je takové pseudo-řešení ve stylu s křížkem po funuse a s podobným refaktoringem nám stejně nepomůže.

Oproti tomu v Mercurialu uděláme jen např. hg cp PůvodníTřída.java NováTřída.java v původním souboru metodu smažeme, v novém smažeme všechny ostatní a změníme název třídy. Nebo uděláme refaktoring – vytažení metody do nové třídy – pomocí IDE a následně jen řekneme verzovacímu systému, že má soubor evidovat jako kopii: hg cp --after PůvodníTřída.java NováTřída.java. Historie zůstane zachována a autorem přesunutých řádků je stále původní autor, nikoli ten, kdo je přesunul.

Stejně dobře funguje kopírování souborů i v – dnes již poněkud obstarožním – systému Subversion. Zde uděláme jen svn cp PůvodníTřída.java NováTřída.java a historie zůstane zachována. Funkce blame/annotate pak funguje i ve všech nástrojích jako např. Netbeans IDE, kde můžeme hezky sledovat historii nebo autorství jednotlivých řádků.

Prostá kopie

Abychom dosáhli podobného výsledku v Gitu, musíme vytvořit novou větev. V ní soubor přejmenujeme (kód v tuhle chvíli nemusí dávat smysl, nemusí jít ani zkompilovat, ale to nevadí). Následně se přepneme na původní větev a začleníme větev s přejmenovaným souborem. Musíme ale říct Gitu, aby nedělal tzv. fast-forward (pouhé posunutí štítku větve/záložky bez přidání nového uzlu a dvou hran do grafu) a aby nás nechal do kódu ještě sáhnout a udělat další změny.

echo "Ahoj," >> ahoj.txt
echo "tohle psala" >> ahoj.txt
echo "Dominika" >> ahoj.txt
git add ahoj.txt 
git commit --author="Dominika <dominika@example.com>" -m "vytvořen soubor ahoj.txt"

git checkout -b kopie
git mv ahoj.txt nazdar.txt
git commit --author="Howard <howard@example.com>" -m "přejmenování: ahoj.txt → nazdar.txt"

git checkout master 
git merge --no-ff --no-commit kopie 
git reset HEAD ahoj.txt
git checkout ahoj.txt
git commit --author="Howard <howard@example.com>" -m "kopie: ahoj.txt → nazdar.txt …"
git branch --delete kopie # v příkladu jsem pomocnou větev pro názornost zachoval

Graf historie vypadá takto:

$ git log --all --graph 
*   commit 84a6cd6c13f971289a4cdc040662e633e35b5608 (HEAD -> master)
|\  Merge: ad7b3ba 0c7f338
| | Author: Howard <howard@example.com>
| | Date:   Sun Jun 12 22:31:12 2022 +0200
| | 
| |     kopie: ahoj.txt → nazdar.txt
| |     Merge branch 'kopie'
| | 
| * commit 0c7f338dec62a6e5a97d2e70ea60599ba15fbaa5 (kopie)
|/  Author: Howard <howard@example.com>
|   Date:   Sun Jun 12 22:30:02 2022 +0200
|   
|       přejmenování: ahoj.txt → nazdar.txt
| 
* commit ad7b3ba1d4d855b999b25b5458a7efe363840696 (origin/master)
  Author: Dominika <dominika@example.com>
  Date:   Sun Jun 12 22:28:37 2022 +0200
  
      vytvořen soubor ahoj.txt

A autorem řádků v obou souborech je Dominika:

$ git blame nazdar.txt
^ad7b3ba ahoj.txt (Dominika 2022-06-12 22:28:37 +0200 1) Ahoj,
^ad7b3ba ahoj.txt (Dominika 2022-06-12 22:28:37 +0200 2) tohle psala
^ad7b3ba ahoj.txt (Dominika 2022-06-12 22:28:37 +0200 3) Dominika

$ git blame ahoj.txt
^ad7b3ba (Dominika 2022-06-12 22:28:37 +0200 1) Ahoj,
^ad7b3ba (Dominika 2022-06-12 22:28:37 +0200 2) tohle psala
^ad7b3ba (Dominika 2022-06-12 22:28:37 +0200 3) Dominika

Vidíme i původní datum a u souboru nazdar.txt je uvedeno, že řádky vznikly původně v souboru ahoj.txt.

Stažení příkladu:

git clone https://git-zaloha.frantovo.cz/git.frantovo.cz/git-cp/01-prosta-kopie/

Alternativní postup je, že původní soubor přejmenujeme na nějaký dočasný název, máme trochu jednodušší git merge a následně soubor zase přejmenujeme zpět. Ale to mi připadá méně vhodné, protože do historie zanášíme pomocné přejmenování, které tam z logiky věci být nemělo.

Přesun části kódu do nového souboru

Podobně jako v předchozím případě postupujeme, když děláme refaktoring a přesouváme např. některé metody do nové třídy. Začátek je stejný a před posledním git commit změníme název třídy uvnitř nového souboru a v obou smažeme část, která patří do toho druhého.

Původní zdroj:

public class PrvniTrida {

	private int pocitadlo = 0;
	
	public void pozdrav() {
		System.out.println("ahoj " + ++pocitadlo);
	}

	public static void main(String[] args) {
		PrvniTrida t = new PrvniTrida();
		t.pozdrav();
		t.pozdrav();
		t.pozdrav();
	}

}

Postup:

git add PrvniTrida.java 
git commit --author="Dominika <dominika@example.com>" -m "vytvořen soubor PrvniTrida.java"

git checkout -b kopie
git mv PrvniTrida.java DruhaTrida.java 
git commit --author="Howard <howard@example.com>" -m "přejmenování: PrvniTrida.java → DruhaTrida.java"

git checkout master 
git merge --no-ff --no-commit kopie 
git reset HEAD PrvniTrida.java
git checkout -- PrvniTrida.java

# upravíme obě třídy, aby každá obsahovala svoji část kódu

git commit --author="Howard <howard@example.com>" -m "refaktoring metody: PrvniTrida.java → DruhaTrida.java …"

Autorem metody pozdrav() a proměnné pocitadlo je pořád Dominika, přestože se kód teď nachází v jiném souboru:

$ git blame PrvniTrida.java
^4f367a2 (Dominika 2022-06-14 18:57:30 +0200  1) public class PrvniTrida {
^4f367a2 (Dominika 2022-06-14 18:57:30 +0200  2) 
^4f367a2 (Dominika 2022-06-14 18:57:30 +0200  3)        public static void main(String[] args) {
a4e820b6 (Howard   2022-06-14 18:59:36 +0200  4)                DruhaTrida t = new DruhaTrida();
^4f367a2 (Dominika 2022-06-14 18:57:30 +0200  5)                t.pozdrav();
^4f367a2 (Dominika 2022-06-14 18:57:30 +0200  6)                t.pozdrav();
^4f367a2 (Dominika 2022-06-14 18:57:30 +0200  7)                t.pozdrav();
^4f367a2 (Dominika 2022-06-14 18:57:30 +0200  8)        }
^4f367a2 (Dominika 2022-06-14 18:57:30 +0200  9) 
^4f367a2 (Dominika 2022-06-14 18:57:30 +0200 10) }

$ git blame DruhaTrida.java
a4e820b6 DruhaTrida.java (Howard   2022-06-14 18:59:36 +0200 1) public class DruhaTrida {
^4f367a2 PrvniTrida.java (Dominika 2022-06-14 18:57:30 +0200 2) 
^4f367a2 PrvniTrida.java (Dominika 2022-06-14 18:57:30 +0200 3)         private int pocitadlo = 0;
^4f367a2 PrvniTrida.java (Dominika 2022-06-14 18:57:30 +0200 4)         
^4f367a2 PrvniTrida.java (Dominika 2022-06-14 18:57:30 +0200 5)         public void pozdrav() {
^4f367a2 PrvniTrida.java (Dominika 2022-06-14 18:57:30 +0200 6)                 System.out.println("ahoj " + ++pocitadlo);
^4f367a2 PrvniTrida.java (Dominika 2022-06-14 18:57:30 +0200 7)         }
^4f367a2 PrvniTrida.java (Dominika 2022-06-14 18:57:30 +0200 8) 
^4f367a2 PrvniTrida.java (Dominika 2022-06-14 18:57:30 +0200 9) }

Stažení příkladu:

git clone https://git-zaloha.frantovo.cz/git.frantovo.cz/git-cp/02-presun-kodu-do-noveho-souboru/

Graf verzí vypadá obdobně jako v předchozím případě:

$ git log --all --graph 
*   commit a4e820b6c91e3f9422e107aee38007d8b965db03 (HEAD -> master, origin/master)
|\  Merge: 4f367a2 2de9241
| | Author: Howard <howard@example.com>
| | Date:   Tue Jun 14 18:59:36 2022 +0200
| | 
| |     refaktoring metody: PrvniTrida.java → DruhaTrida.java
| |     Merge branch 'kopie'
| | 
| * commit 2de924134fe486d977cfc4593f3be8262ed444e4 (origin/kopie, kopie)
|/  Author: Howard <howard@example.com>
|   Date:   Tue Jun 14 18:58:23 2022 +0200
|   
|       přejmenování: PrvniTrida.java → DruhaTrida.java
| 
* commit 4f367a2368e9cb3eafc68fc576fcad56cf9195b8
  Author: Dominika <dominika@example.com>
  Date:   Tue Jun 14 18:57:30 2022 +0200
  
      vytvořen soubor PrvniTrida.java

Zde mimochodem vidíme, že v rámci git merge se můžou odehrát i významné zásahy do kódu potažmo do logiky programu. Nelze tedy automaticky předpokládat, že tyto operace ve VCS jsou neškodné a že program bude (správně) fungovat protože přece obě verze, které se tu spojily, fungovaly. Dokonce i když do tohoto procesu nikdo ručně nezasahuje a git merge proběhne „hladce“, výsledkem může být nezkompilovatelný kód nebo nefunkční program.

Přesun části kódu do jiného souboru

Tento případ je hodně podobný, ale liší se v tom, že soubor DruhaTrida.java již existuje, takže ho ve větvi kopie přepíšeme souborem PrvniTrida.java, abychom správně navázali historii řádků, které chceme přesunout. Musíme to ale udělat ve dvou krocích, protože jinak by to Git považoval za smazání souboru PrvniTrida.java a modifikaci DruhaTrida.java, nikoli za přejmenování/přepis. Kód je v tu chvíli nezkompilovatelný, ale to nevadí. Po přepnutí se zpět na hlavní větev začneme git merge a před jeho dokončením pomocí git commit dáme vše zase do pořádku.

$ git log --all --graph
*   commit db95c22d204717122b28b426147d92cca08ebda8 (HEAD -> master, origin/master)
|\  Merge: 39fd543 2b175f9
| | Author: Howard <howard@example.com>
| | Date:   Thu Jun 16 00:56:43 2022 +0200
| | 
| |     refaktoring metody: PrvniTrida.java → DruhaTrida.java
| |     Merge branch 'kopie'
| | 
| * commit 2b175f939bfb7065c38e4c9b9ba3056a37054599 (kopie)
| | Author: Howard <howard@example.com>
| | Date:   Thu Jun 16 00:53:41 2022 +0200
| | 
| |     přejmenování: PrvniTrida.java → DruhaTrida.java
| | 
| * commit a2fb5b3231680c4358e555c0cf73fe49d58f62de
|/  Author: Howard <howard@example.com>
|   Date:   Thu Jun 16 00:53:19 2022 +0200
|   
|       smazání: DruhaTrida.java
| 
* commit 39fd5435e2b818b2e620ea11fc3697232e707732
  Author: Dominika <dominika@example.com>
  Date:   Thu Jun 16 00:48:55 2022 +0200
  
      vytvořeny soubory PrvniTrida.java a DruhaTrida.java

Stažení příkladu:

git clone https://git-zaloha.frantovo.cz/git.frantovo.cz/git-cp/03-presun-kodu-do-jineho-souboru/

Podle Gitu je autorem všech řádků v druhém souboru Dominika:

$ git blame PrvniTrida.java
^39fd543 (Dominika 2022-06-16 00:48:55 +0200  1) public class PrvniTrida {
^39fd543 (Dominika 2022-06-16 00:48:55 +0200  2) 
^39fd543 (Dominika 2022-06-16 00:48:55 +0200  3)        public static void main(String[] args) {
db95c22d (Howard   2022-06-16 00:56:43 +0200  4)                DruhaTrida t = new DruhaTrida();
^39fd543 (Dominika 2022-06-16 00:48:55 +0200  5)                t.pozdrav();
^39fd543 (Dominika 2022-06-16 00:48:55 +0200  6)                t.pozdrav();
^39fd543 (Dominika 2022-06-16 00:48:55 +0200  7)                t.pozdrav();
^39fd543 (Dominika 2022-06-16 00:48:55 +0200  8)        }
^39fd543 (Dominika 2022-06-16 00:48:55 +0200  9) 
^39fd543 (Dominika 2022-06-16 00:48:55 +0200 10) }

$ git blame DruhaTrida.java
^39fd543 DruhaTrida.java (Dominika 2022-06-16 00:48:55 +0200 1) public class DruhaTrida {
^39fd543 DruhaTrida.java (Dominika 2022-06-16 00:48:55 +0200 2) 
^39fd543 PrvniTrida.java (Dominika 2022-06-16 00:48:55 +0200 3)         private int pocitadlo = 0;
^39fd543 PrvniTrida.java (Dominika 2022-06-16 00:48:55 +0200 4)         
^39fd543 PrvniTrida.java (Dominika 2022-06-16 00:48:55 +0200 5)         public void pozdrav() {
^39fd543 PrvniTrida.java (Dominika 2022-06-16 00:48:55 +0200 6)                 System.out.println("ahoj " + ++pocitadlo);
^39fd543 PrvniTrida.java (Dominika 2022-06-16 00:48:55 +0200 7)         }
^39fd543 PrvniTrida.java (Dominika 2022-06-16 00:48:55 +0200 8) 
^39fd543 DruhaTrida.java (Dominika 2022-06-16 00:48:55 +0200 9) }

Ona ale ve skutečnosti napsala jen řádky 1 a 9, zatímco ty zbývající tam transplantoval Howard – ze souboru, který tedy rovněž psala Dominika, ale její kód se původně nacházel v jiném kontextu…

Přesun části kódu na jiné místo v témže souboru

Postupnými úpravami kódu se můžeme dostat do stavu, kdy program sice dělá, co má, ale uspořádání metod nebo třeba konstant není optimální – logičtější a přehlednější by bylo jiné pořadí. V tu chvíli máme dilema, jestli kód refaktorovat a přijít o historii jednotlivých řádků nebo ne. Ve skutečnosti je to dilema falešné, protože můžeme mít oboje – jen je s tím trochu práce. Opět to můžeme řešit tím, že vytvoříme novou větev, čímž získáme dva předky a díky tomu se nám historie jednotlivých řádků správně naváže. V této pomocné větvi soubor trochu pročistíme a necháme v něm jen relevantní části.

Stažení příkladu:

git clone https://git-zaloha.frantovo.cz/git.frantovo.cz/git-cp/04-presun-kodu-do-tehoz-souboru/

V první verzi, kterou psala Dominika, byla na začátku metoda main() a až za ní proměnná a metoda pozdrav(). Howard pořadí změnil a metodu main() přesunul na konec souboru, ale díky tanečkům s pomocnou větví, zůstala autorem kódu Dominika:

$ git blame PrvniTrida.java
^06b3450 (Dominika 2022-06-16 01:18:37 +0200  1) public class PrvniTrida {
^06b3450 (Dominika 2022-06-16 01:18:37 +0200  2) 
^06b3450 (Dominika 2022-06-16 01:18:37 +0200  3)        private int pocitadlo = 0;
1162429c (Howard   2022-06-16 20:28:22 +0200  4) 
^06b3450 (Dominika 2022-06-16 01:18:37 +0200  5)        public void pozdrav() {
^06b3450 (Dominika 2022-06-16 01:18:37 +0200  6)                System.out.println("ahoj " + ++pocitadlo);
^06b3450 (Dominika 2022-06-16 01:18:37 +0200  7)        }
1162429c (Howard   2022-06-16 20:28:22 +0200  8) 
^06b3450 (Dominika 2022-06-16 01:18:37 +0200  9)        public static void main(String[] args) {
^06b3450 (Dominika 2022-06-16 01:18:37 +0200 10)                PrvniTrida t = new PrvniTrida();
^06b3450 (Dominika 2022-06-16 01:18:37 +0200 11)                t.pozdrav();
^06b3450 (Dominika 2022-06-16 01:18:37 +0200 12)                t.pozdrav();
^06b3450 (Dominika 2022-06-16 01:18:37 +0200 13)                t.pozdrav();
^06b3450 (Dominika 2022-06-16 01:18:37 +0200 14)        }
^06b3450 (Dominika 2022-06-16 01:18:37 +0200 15) 
^06b3450 (Dominika 2022-06-16 01:18:37 +0200 16) }

Pokud by Howard neudělal pomocnou větev a následně git merge --no-ff --no-commit kopie, Git by to považoval za smazání řádků a vytvoření nových, jejichž autorství by přisuzoval Howardovi. Informace o tom, kdo je skutečným autorem a z jaké doby kód pochází, by se ztratila.

Závěr

Dnes jsme si ukázali, jak dělat refaktoring a zachovat při tom původní informace o autorství a další metadata. Vzhledem k tomu, že v Gitu je tento postup poměrně pracný (a přidává do grafu další odbočku), ho asi nebudeme používat vždy – nebo ho automatizujeme pomocí skriptů. Tak jako tak, jsou ale případy, kdy se ta práce a pečlivost vyplatí a zachování historie a kontinuita stojí za to.

Dále bychom měli pamatovat na to, že s historií ve verzovacím systému jde poměrně kreativně manipulovat. A to i v případě, kdy vyloženě nefalšujeme jméno autora a datum (tam si můžeme napsat, co chceme). Metadata ve verzovacím systému mohou být na první pohled zavádějící, i když přímočarým podvodům zabráníme a nastavíme náš server tak, aby při přijímání push operace kontroloval, že datum je smysluplné a že autor jednotlivých commitů je shodný s uživatelem, který provádí push. Jednotlivé řádky psané někým jiným totiž můžeme při troše snahy poskládat tak, že z toho bude úplně jiný program, než dotyčný napsal. Historie ve verzovacím systému nám pomáhá, pokud je pravdivá a byla budována s dobrým úmyslem. Pokud ale provádíme audit nebo obecně předpokládáme i zlý úmysl, nesmíme se nechat zmást výpisem git blame – jednotlivé řádky sice mohou pocházet od důvěryhodného autora a můžou být i dost staré (prověřené časem), ale někdo je mohl přeskládat tak, že program teď dělá něco úplně jiného. Nemluvě o tom, že smysl programu lze zásadně změnit i tím, že některé řádky jednoduše smažeme, což v git blame není vidět vůbec.

Některé verzovací systémy (Monotone) staví již v základu na kryptografii a vše podepisují. V jiných (Mercurial, Fossil, Git, Bazaar, Darcs) můžeme podepisovat alespoň volitelně pomocí GPG. Nicméně i při použití kryptografie můžeme věřit jen celým podepsaným verzím, nikoli jednotlivým řádkům (i když je jejich autorství nesporné, mohly být vytrženy z kontextu).

Témata: [hack] [verzovací systémy] [taháky]

Komentáře čtenářů


kralyk, 30. 8. 2022 12:04 [odpovědět]

Vytváře merge commitu mi přijde trochu přehnané, spíš by bylo lepší na tohle využít toho, že git u commitů zaznamenává zvlášť položky 'author' a 'commiter' a při kopírování/přesouvání kódu nastavit autora na původního a commitera sebe. Bylo by fajn pro tohle mít nějakou automatizaci.

ale to je takové pseudo-řešení ve stylu s křížkem po funuse a s podobným refaktoringem nám stejně nepomůže.

Takhle ale historie v gitu funguje vždycky, git mv a b nedělá nic jiného než že přesune soubor a nastaguje odebrání a a přidání b, je to jako ručně udělat cp a b && git rm a && git add b. Že se stal přesun vyhodnotí nástroj až zpětně při prohlížení historie na základě smiliarity indexu, ty parametry té chytristiky se dají různě štělovat.


Franta, 30. 8. 2022 18:26, Verzovací systémy [odpovědět]

Vytváře merge commitu mi přijde trochu přehnané

Ano. Psal jsem to spíš jako ukázku, co taky dá dělat s tou historií. Smysl mi to dává u nějakého hodně hodnotného kódu.

spíš by bylo lepší na tohle využít toho, že git u commitů zaznamenává zvlášť položky 'author' a 'commiter' a při kopírování/přesouvání kódu nastavit autora na původního a commitera sebe.

Pak je ale celý commit pod jedním autorem, tak bys stejně musel v jednom commitu udělat refaktoring a v dalším přidat svoje změny. Ale hlavně tam nejde jen o autorství – někdy je dobré vědět, že tam ten kód je už třeba deset let, z toho se dá usuzovat, kvůli komu nebo proč vznikl atd. totéž komentář k původnímu commitu.

To by si člověk musel napsat skript, který tyhle informace překopíruje do nového commitu a rozliší autoracommitera. A to už si můžu napsat skript, který udělá tu novou větev. Byť to pak v historii není hezké.

Přijde mi, že ideální řešení (zatím) neexistuje a tahle generace verzovacích systémů nemusí být poslední. Spousta lidí bere Git jako nějakou konstantu, ale to je víceméně jen dané tím, že přišli k SW vývoji v době, kdy byl Git na vrcholu popularity. Před tím byla zase samozřejmost SVN, před tím CVS…

S něčím může pomoci Difftastic, který se dá naroubovat i na stávající verzovací systémy a rozumí syntaxi jednotlivých jazyků. Případně jsou tu pak „revoluční“ VCS jako Pijul (akorát si s tou revolucí dává nějak na čas) nebo spící projekty jako Fossil, Darcs a další, které vznikly už dávno, ale třeba někdy tu popularitu získají. Zjevně by to chtělo nějakou abstraktní vrstvu typu ODBC, JDBC, VFS, která by lidem umožnila používat různé VCS souběžně. Jinak tu hraje síťový efekt dost proti inovaci.

Takhle ale historie v gitu funguje vždycky, git mv a b nedělá nic jiného než že přesune soubor…

No právě – a to je jeden z důvodů, proč je mi bližší Mercurial. Ten si tu informaci uchovává explicitně. Ono v Gitu se někdy stane naopak to, že si spojí historii souborů, které spolu nesouvisí, jen byly „dost podobné“.

Přidat komentář

reagujete na jiný komentář (zrušit)
jméno nebo přezdívka
název příspěvku
webová stránka, blog
e-mailová adresa
nápověda: možnosti formátování
ochrana proti spamu a špatným trollům

Náhled komentáře