Moje odkazy
Obsah článku:
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 mv
a hg 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.
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ů.
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.
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.
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…
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.
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]
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.
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ší autora a commitera. 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é“.