Git
Chapters ▾ 2nd Edition

7.7 Git Tools - Reset ontrafeld

Reset ontrafeld

Voordat we doorgaan naar de meer gespecialiseerde instrumenten, laten we eerst de reset en checkout commando’s bespreken. Deze commando’s zijn twee van de meest verwarrende delen van Git als je ze voor het eerst tegenkomt. Ze doen zo veel dingen, dat het bijkans onmogelijk is om ze echt te begrijpen en juist toe te passen. Hiervoor stellen we een eenvoudige metafoor voor.

De drie boomstructuren

Een eenvoudiger manier om je reset en checkout voor te stellen is door je voor te stellen dat Git een gegevensbeheerder is van drie boomstructuren. Met “boom” bedoelen we hier eigenlijk “verzameling van bestanden”, en niet een bepaalde gegevensstructuur. (Er zijn een paar gevallen waarbij de index zich niet echt als een boomstructuur gedraagt, maar voor dit doeleinde is het eenvoudiger om het je als zodanig voor te stellen).

Git als systeem beheert en manipuleert deze boomstructuren bij de gewone operaties:

Boom Rol

HEAD

Laatste commit snapshot, volgende ouder

Index

Voorgestelde volgende commit snapshot

Working Directory

Speeltuin

De HEAD

HEAD is de verwijzing naar de huidige branch referentie, wat op zijn beurt een verwijzing is naar de laatste commit die gemaakt is op die branch. Dat houdt in dat HEAD de ouder zal zijn van de volgende commit die wordt gemaakt. Het is over het algemeen het eenvoudigste om HEAD te zien als de snapshot van je laatste commit op die branch.

Het is in feite redelijk eenvoudig om te zien hoe die snapshot eruit ziet. Hier is een voorbeeld hoe de echte directory inhoud en SHA-1 checksum voor elk bestand in de HEAD snapshot te krijgen:

$ git cat-file -p HEAD
tree cfda3bf379e4f8dba8717dee55aab78aef7f4daf
author Scott Chacon  1301511835 -0700
committer Scott Chacon  1301511835 -0700

initial commit

$ git ls-tree -r HEAD
100644 blob a906cb2a4a904a152...   README
100644 blob 8f94139338f9404f2...   Rakefile
040000 tree 99f1a6d12cb4b6f19...   lib

De cat-file en ls-tree commando’s zijn “binnenwerk” (plumbing) commando’s die gebruikt worden door de lagere functies en niet echt gebruikt worden in dagelijkse toepassingen, maar ze helpen ons om te zien wat er eigenlijk gebeurt.

De index

De index is je voorstel voor de volgende commit. We hebben hieraan ook gerefereerd als de “staging area” van Git, omdat dit is waar Git naar kijkt als je git commit aanroept.

Git vult deze index met een lijst van de inhoud van alle bestanden die als laatste waren uitgechecked naar je werk directory en hoe ze eruit zagen toen ze oorspronkelijk waren uitgecheckt. Je vervangt enkele van deze bestanden met nieuwe versies ervan, en git commit converteert dit dan naar de boomstructuur voor een nieuwe commit.

$ git ls-files -s
100644 a906cb2a4a904a152e80877d4088654daad0c859 0	README
100644 8f94139338f9404f26296befa88755fc2598c289 0	Rakefile
100644 47c6340d6459e05787f644c2447d2595f5d3a54b 0	lib/simplegit.rb

Hier gebruiken we nogmaals ls-files, wat meer een achter-de-schermen commando is dat je laat zien hoe je index er op dit moment uitziet.

Technisch gesproken is de index geen boomstructuur — het wordt eigenlijk geïmplementeerd als een geplette manifest — maar voor dit doeleinde is het goed genoeg.

De werk directory

En als laatste is er je werk directory (working directory, ook vaak aan gerefereerd als de “working tree”). De andere twee boomstructuren slaan hun inhoud op een efficient maar onhandige manier op, in de .git directory. De werk directory pakt ze uit in echte bestanden, wat het makkelijker voor je maakt om ze te bewerken. Zie de werk directory als een speeltuin, waar je wijzigingen kunt uitproberen voordat je ze naar je staging area (index) commit en daarna naar de historie.

$ tree
.
├── README
├── Rakefile
└── lib
    └── simplegit.rb

1 directory, 3 files

De Workflow

Het voornaamste doel van Git is om opeenvolgende snapshots te op te slaan van verbeteringen aan je project, door deze drie bomen te manipuleren.

reset workflow

Laten we dit proces eens visualiseren: stel je gaat een nieuwe directory in waarin een enkel bestand staat. We noemen dit v1 van het bestand, en we geven het in blauw weer. Nu roepen we git init aan, wat een Git repository aanmaakt met een HEAD referentie die verwijst naar een ongeboren branch (master bestaat nog niet).

reset ex1

Op dit moment heeft alleen de boom van de werk directory inhoud.

Nu willen we dit bestand committen, dus we gebruiken git add om de inhoud van de werk directory te pakken en dit in de index te kopiëren.

reset ex2

Dan roepen we git commit aan, wat de inhoud van de index pakt en deze bewaart als een permanente snapshot, een commit object aanmaakt die naar die snapshot wijst en dan master update die naar die commit wijst.

reset ex3

Als we nu git status aanroepen zien we geen wijzigingen, omdat alle drie bomen hetzelfde zijn.

Nu willen we dat bestand wijzigen en deze committen. We volgen hetzelfde proces; eerst wijzigen we het bestand in onze werk directory. Laten we deze v2 van het bestand noemen, en deze in het rood weergeven.

reset ex4

Als we nu git status aanroepen, zullen we het bestand in het rood zien als “Changes not staged for commit,” omdat deze versie van het bestand verschilt tussen de index en de werk directory. Nu gaan we git add aanroepen om het in onze index te stagen ("to stage": klaarzetten).

reset ex5

Als we op dit moment git status aanroepen zullen we het bestand in het groen zien onder “Changes to be committed” omdat de index en HEAD verschillen — dat wil zeggen, onze voorgestelde volgende commit verschilt nu van onze laatste commit. Tot slot roepen we git commit aan om de commit af te ronden.

reset ex6

Nu zal git status geen uitvoer laten zien, omdat alle drie bomen weer hetzelfde zijn.

Tussen branches overschakelen of klonen volgen een vergelijkbaar proces. Als je een branch uitcheckt, wijzigt dit HEAD door het te laten wijzen naar de nieuwe branch referentie, het vult je index met de snapshot van die commit, en kopieert dan de inhoud van de index naar je werk directory.

De rol van reset

Het reset commando krijgt in dit licht meer betekenis.

Laten we, voor het doel van deze voorbeelden, stellen dat we file.txt weer gewijzigd hebben en het voor de derde keer gecommit. Nu ziet onze historie er dus als volgt uit:

reset start

Laten we nu een stap voor stap bespreken wat reset doet als je het aanroept. Het manipuleert deze drie bomen op een eenvoudige en voorspelbare manier. Het voert tot drie basale handelingen uit.

Stap 1: Verplaats HEAD

Het eerste wat reset zal doen is hetgeen waar HEAD naar verwijst verplaatsen. Dit is niet hetzelfde als HEAD zelf wijzigen (dat is wat checkout doet); reset verplaatst de branch waar HEAD naar verwijst. Dit houdt in dat als HEAD naar de master-branch wijst (d.i. je bent nu op de master-branch), het aanroepen van git reset 9e5e6a4 zal beginnen met master naar 9e5e6a4 te laten wijzen.

reset soft

Het maakt niet uit welke variant van reset met een commit je aanroept, dit is het eerste wat het altijd zal proberen te doen. Met reset --soft, zal het eenvoudigweg daar stoppen.

Kijk nu nog een keer naar het diagram en besef wat er gebeurd is: het heeft feitelijk de laatste git commit commando ongedaan gemaakt. Als je git commit aanroept, maakt Git een nieuwe commit en verplaatst de branch waar HEAD naar wijst daarnaar toe. Als je naar HEAD~ (de ouder van HEAD) terug reset, verplaats je de branch terug naar waar het was, zonder de index of werk directory te wijzigen. Je kunt de index nu bijwerken en git commit nogmaals aanroepen om te bereiken wat git commit --amend gedaan zou hebben (zie De laatste commit veranderen).

Stap 2: De Index bijwerken (--mixed)

Merk op dat als je git status nu aanroept dat je het verschil tussen de index en wat de nieuwe HEAD is in het groen ziet.

Het volgende wat reset zal gaan doen is de index bijwerken met de inhoud van de snapshot waar HEAD nu naar wijst.

reset mixed

Als je de --mixed optie hebt opgegeven, zal reset op dit punt stoppen. Dit is ook het standaard gedrag, dus als je geen enkele optie hebt opgegeven (dus in dit geval alleen git reset HEAD~), is dit waar het commando zal stoppen.

Kijk nu nog een keer naar het diagram en besef wat er gebeurd is: het heeft nog steeds je laatste commit ongedaan gemaakt, maar nu ook alles unstaged.

Stap 3: De working directory bijwerken (--hard)

Het derde wat reset zal doen is ervoor zorgen dat de werk directory gaat lijken op de index. Als je de --hard optie gebruikt, zal het doorgaan naar dit stadium.

reset hard

Laten we eens overdenken wat er zojuist is gebeurd. Je hebt je laatste commit ongedaan gemaakt, de git add en git commit commando’s, en al het werk wat je in je werk directory gedaan hebt.

Het is belangrijk op te merken dat deze vlag (--hard) de enige manier is om het reset commando gevaarlijk te maken, en een van de weinige gevallen waar Git daadwerkelijk gegevens zal vernietigen. Elke andere aanroep van reset kan redelijk eenvoudig worden teruggedraaid, maar de --hard optie kan dat niet, omdat het keihard de bestanden in de werk directory overschrijft. In dit specifieke geval, hebben we nog steeds de v3 versie van ons bestand in een commit in onze Git database, en we zouden het kunnen terughalen door naar onze reflog te kijken, maar als we het niet zouden hebben gecommit, zou Git het bestand nog steeds hebben overschreven en zou het niet meer te herstellen zijn.

Samenvattend

Het reset commando overschrijft deze drie bomen in een vastgestelde volgorde, en stopt waar je het toe opdraagt:

  1. Verplaats de branch waar HEAD naar wijst (stop hier als --soft)

  2. Laat de index eruit zien als HEAD (stop hier tenzij --hard)

  3. Laat de werk directory eruit zien als de index

Reset met een pad (path)

Dit dekt het gedrag van reset in zijn eenvoudige vorm, maar je kunt er ook een path bij opgeven waar het op moet acteren. Als je een path opgeeft, zal reset stap 1 overslaan, en de rest van de acties beperken tot een specifiek bestand of groep van bestanden. Dit is ergens wel logisch — HEAD is maar een verwijzing, en je kunt niet naar een deel van een commit wijzen en deels naar een andere. Maar de index en werk directory kunnen deels worden bijgewerkt, dus reset gaat verder met stappen 2 en 3.

Dus, laten we aannemen dat we git reset file.txt aanroepen. Deze vorm (omdat je niet een specifieke SHA-1 van een commit of branch meegeeft, en je hebt geen --soft of --hard meegegeven) is dit een verkorte vorm van git reset --mixed HEAD file.txt en dit zal:

  1. De branch waar HEAD naar wijst verplaatsen (overgeslagen)

  2. De index eruit laten zien als HEAD (stop hier)

Dus effectief wordt alleen file.txt van HEAD naar de index gekopiëerd.

reset path1

Dit heeft het praktische effect van het bestand unstagen. Als we kijken naar het diagram voor dat commando en denken aan wat git add doet, zijn ze exact elkaars tegenpolen.

reset path2

Dit is de reden waarom de uitvoer van het git status commando je aanraadt om dit aan te roepen om een bestand te unstagen. (Zie Een gestaged bestand unstagen voor meer hierover.)

We hadden net zo makkelijk Git niet laten aannemen dat we “pull de data van HEAD” bedoelen door een specifieke commit op te geven om die versie van het bestand te pullen. We hadden ook iets als git reset eb43bf file.txt kunnen aanroepen.

reset path3

Feitelijk gebeurt hier hetzelfde als wanneer we de inhoud van het bestand naar v1 in de werk directory hadden teruggedraaid, git add ervoor hadden aangeroepen, en daarna het weer hadden teruggedraaid naar v3 (zonder daadwerkelijk al deze stappen te hebben gevolgd). Als we nu git commit aanroepen, zal het een wijziging vastleggen die het bestand naar v1 terugdraait, ook al hebben we het nooit echt weer in onze werk directory gehad.

Het is ook interessant om op te merken dat net als git add, het reset commando een --patch optie accepteert om inhoud in deelsgewijs te unstagen. Dus je kunt naar keuze inhoud unstagen of terugdraaien (revert).

Samenpersen (Squashing)

Laten we nu kijken hoe we iets interessants kunnen doen met deze vers ontdekte krachten — commits samenpersen (squashen).

Stel dat je een reeks van commits met berichten als “oops.”, “WIP” en “dit bestand vergeten”. Je kunt reset gebruiken om deze snel en makkelijk in een enkele commit te samenpersen waardoor je ontzettend slim zult lijken. (Een commit samenpersen (squashing) laat je een andere manier zien om dit te doen, maar in dit voorbeeld is het makkelijker om reset te gebruiken.)

Stel dat je een project hebt waar de eerste commit een bestand heeft, de tweede commit een nieuw bestand toevoegde en het eerste wijzigde, en de derde commit het eerste bestand weer wijzigde. De tweede commit was een onderhanden werk en je wilt het samenpersen.

reset squash r1

Je kun git reset --soft HEAD~2 uitvoeren om de HEAD branch terug naar een oudere commit te verplaatsen (de eerste commit wil je behouden):

reset squash r2

En daarna eenvoudigweg git commit weer aanroepen:

reset squash r3

Je kunt nu zien dat je bereikbare historie, de historie die je zou gaan pushen, nu eruit ziet alsof je een commit had met file-a.txt v1, dan een tweede die zowel file-a.txt naar v3 wijzigt en file-b.txt toevoegt. De commit met de v2 versie van het bestand is niet meer in de historie aanwezig.

Check It Out

Als laatste, je kunt je afvragen wat het verschil is tussen checkout en reset. Net als reset, bewerkt checkout de drie bomen, en het verschilt enigszins afhankelijk van of je het commando een bestandspath geeft of niet.

Zonder paths

Het aanroepen van git checkout [branch] is vergelijkbaar met het aanroepen van git reset --hard [branch] in die zin dat het alle drie bomen voor je laat uitzien als [branch], maar er zijn twee belangrijke verschillen.

Ten eerste, in tegenstelling tot reset --hard, is checkout veilig voor de werk-directory; het zal controleren dat het geen bestanden weggooit waar wijzigingen in gemaakt zijn. Eigenlijk is het nog iets slimmer dan dat — het probeert een triviale merge in de werk directory te doen, zodat alle bestanden die je niet gewijzigd hebt bijgewerkt worden. Aan de ander kant zal reset --hard eenvoudigweg alles zonder controleren vervangen.

Het tweede belangrijke verschil is hoe het HEAD update. Waar reset de branch waar HEAD naar verwijst zal verplaatsen, zal checkout de HEAD zelf verplaatsen om naar een andere branch te wijzen.

Bijvoorbeeld, stel dat we master en develop-branches hebben die naar verschillende commits wijzen, en we staan op dit moment op develop (dus HEAD wijst daar naar). Als we git reset master aanroepen, zal develop zelf wijzen naar dezelfde commit waar master naar wijst. Als we echter git checkout master aanroepen, zal develop niet verplaatsen, HEAD wordt zelf verplaatst. HEAD zal nu naar master wijzen.

Dus in beide gevallen verplaatsen we HEAD om naar commit A te wijzen, maar hoe we dit doen verschilt enorm. reset zal de branch waar HEAD naar verwijst verplaatsen, checkout verplaatst HEAD zelf.

reset checkout

Met paths

De andere manier om checkout aan te roepen is met een bestands path die, zoals reset, HEAD niet verplaatst. Het is precies als git reset [branch] file in die zin dat het de index update met dat bestand op die commit, maar het overschrijft ook het bestand in de werk directory. Het zou precies zijn als git reset --hard [branch] file (als reset je dat zou toestaan) - het is niet veilig voor de werk directory, en het verplaatst HEAD niet.

En, zoals git reset en git add, accepteert checkout een --patch optie zodat je selectief stukje bij beetje bestandsinhoud kunt terugdraaien.

Samenvatting

Hopelijk begrijp je nu het reset commando en voel je je er meer mee op je gemak, maar je zult waarschijnlijk nog een beetje in verwarring zijn in hoe het precies verschilt van checkout en zul je je waarschijnlijk ook niet alle regels van verschillende aanroepen herinneren.

Hier is een spiekbrief voor welke commando’s welke bomen beïnvloeden. In de “HEAD” kolom staat “REF” als dat commando de referentie (branch) waar HEAD naar wijst verplaatst, en “HEAD” als het HEAD zelf verplaatst. Let met name op de WD Safe? kolom - als daar NO in staat, bedenk je een tweede keer voordat je dat commando gebruikt.

HEAD Index Workdir WD Safe?

Commit Level

reset --soft [commit]

REF

NO

NO

YES

reset [commit]

REF

YES

NO

YES

reset --hard [commit]

REF

YES

YES

NO

checkout <commit>

HEAD

YES

YES

YES

File Level

reset [commit] <paths>

NO

YES

NO

YES

checkout [commit] <paths>

NO

YES

YES

NO

scroll-to-top