Raggiungibilità e annullamento dei commit
Nella scorsa puntata abbiamo iniziato a prendere confidenza con i branch; riprendiamo il discorso andando a verificare gli attuali branch nel nostro repository. Per vedere tutti i branch locali, il comando da eseguire è semplicemente git branch:
[5] ~/es03 (master) $ git branch bevande * master
Git ci mostra i vari branch presenti, evidenziando con un asterisco e con il colore verde il branch sul quale siamo attualmente posizionati.
git log
Un modo per avere una vista più dettagliata è quello di usare il comando git log, con le opzioni che abbiamo già visto nel precedente articolo:
[9] ~/es03 (master) $ git log --graph --decorate --oneline --all * e29f5c6 (HEAD -> master) Aggiunge lista snack a disposizione per l’ufficio | * 4072e76 (bevande) Aggiunge l’acqua alle bibite |/ * 87cacc7 Aggiunge una matita * 6cf12f4 Aggiunge una penna alla lista della cancelleria
Come pretesto per continuare la nostra esplorazione, supponiamo di aver commesso questo errore: abbiamo aggiunto la lista degli snack al branch master, che invece conteneva la sola cancelleria; vogliamo quindi annullare questo commit. Si può fare? E se sì, come?
Il comando git reset
In Git questo è possibilissimo, ed è anche piuttosto semplice. Dobbiamo far uso del comando git reset, analizzandone il comportamento. Il comando git reset è un comando che permette di spostare HEAD, quel puntatore, quella “bandierina” che indica il commit sul quale siamo testé posizionati.
Per tornare al nostro caso, possiamo quindi usare il comando git reset per spostare HEAD dall’ultimo commit effettuato su master (e29f5c6) al penultimo; per effettuare questa operazione ci avvaliamo del sistema che Git mette a disposizione per indicare i commit passati.
Riferimenti al passato
In Git si ha spesso la necessità di fare riferimento a un commit eseguito in passato, ad esempio come in questo caso; per questo scopo possiamo usare il riferimento HEAD seguito da uno dei due caratteri speciali tilde ~ e caret ^.
Usare un caret significa fondamentalmente fare “un passo indietro”, mentre usarne due significa fare due passi indietro e così via. Volendo tornare indietro di più passi, digitare decine di caret non sarebbe molto comodo; è per questo che quando si ha bisogno di tornare indietro di molto si può usare la tilde: allo stesso modo, ~1 significa “un passo indietro”, mentre ~25 significa venticinque passi indietro e così via.
Necessità particolari posso rendere articolato l’uso di questi due operatori; nei riferimenti bibliografici [1] trovate alcuni link che approfondiscono meglio l’argomento. Per quanto ci riguarda, quel che abbiamo appena imparato è sufficiente per continuare; passiamo quindi all’azione e proviamo a tornare indietro di un commit sul ramo master usando il caret:
[10] ~/es03 (master) $ git reset --hard HEAD^ HEAD is now at 87cacc7 Aggiunge una matita [11] ~/es03 (master) $ git log --graph --decorate --oneline --all * 4072e76 (bevande) Aggiunge l’acqua alle bibite * 87cacc7 (HEAD -> master) Aggiunge una matita * 6cf12f4 Aggiunge una penna alla lista della cancelleria
Come avrete notato, HEAD ora si trova sul commit che fino a poco fa era il penultimo; quello che era l’ultimo, il commit “con gli snack”, è sparito. Non fate caso all’opzione –hard; più avanti ne vedremo il significato.
Spostamenti o reset?
Ma quindi questo comando git reset di fatto “resetta” i commit? Li elimina? Non è proprio così; git reset muove HEAD, e incidentalmente questo può causare degli effetti collaterali. Vi ricordate la storia delle foglie? Diceva che un commit, per essere raggiungibile, deve essere contrassegnato da una label, da un branch, in maniera diretta o indiretta. Se né lui né uno dei suoi commit “discendenti” è contrassegnato da un branch, quel commit diventa “irraggiungibile”, e di conseguenza non appare più quando ad esempio eseguiamo un git log. Ma questo non significa che sia scomparso.
Recuperare un commit resettato
Andiamo avanti con la nostra storia sul repository, e supponiamo ora di aver realizzato che, in fin dei conti, quel commit con gli snack ci serviva: c’è un modo per recuperarlo? Certamente!
Usando sempre il comando git reset, possiamo muovere di nuovo HEAD su quel commit, “resuscitandolo”. Siccome però quel commit non è referenziato da nessun branch, l’unico modo per recuperarlo è conoscerne l’hash, o per lo meno saperne i primi caratteri.
In questo caso siamo fortunati: se nel frattempo non avete chiuso la shell di Git, molto probabilmente uno scroll verso l’altro vi porterà a vedere il precedente comando git log eseguito quando ancora era visibile. Ma, se per caso la shell fosse stata chiusa? Come facciamo a trovare l’hash del commit?
I reflogs
Git non finisce mai di stupirci; fra i tanti comandi e le molteplici funzionalità, trovano spazio i reflogs. I reflogs sono sostanzialmente dei file di log dove Git annota ogni spostamento avvenuto sulle reference, indicando per ognuno il comando che ha provocato lo spostamento; proviamo a dare un’occhiata al reflog di HEAD:
[12] ~/es03 (master) $ git reflog 87cacc7 HEAD@{0}: reset: moving to HEAD^ e29f5c6 HEAD@{1}: checkout: moving from bevande to master 4072e76 HEAD@{2}: checkout: moving from master to bevande e29f5c6 HEAD@{3}: checkout: moving from bevande to master 4072e76 HEAD@{4}: checkout: moving from master to bevande e29f5c6 HEAD@{5}: commit: Aggiunge lista snack a disposizione per l’ufficio 87cacc7 HEAD@{6}: checkout: moving from bevande to master 4072e76 HEAD@{7}: commit: Aggiunge l’acqua alle bibite 87cacc7 HEAD@{8}: checkout: moving from master to bevande 87cacc7 HEAD@{9}: commit: Aggiunge una matita 6cf12f4 HEAD@{10}: commit (initial): Aggiunge una penna alla lista della cancelleria
Il comando reflog può ricevere opzionalmente il nome della reference, ossia del branch per cui si vogliono vedere gli spostamenti effettuati; se nulla viene indicato, il default mostrerà gli spostamenti di HEAD. Inoltre, avviso che il vostro risultato potrebbe essere diverso dal mio, ma è normale: potrei infatti aver effettuato qualche spostamento extra mentre eseguivo le prove per la redazione di questo articolo.
Analizzare gli spostamenti
Ma ora analizziamo un po’ l’output di questo comando: come potete notare, sono presenti tutti gli spostamenti effettuati da HEAD fino a ora, ordinati in ordine cronologico inverso: HEAD@{0} è infatti l’ultimo spostamento effettuato, eseguito tramite git reset HEAD^.
Se scorriamo un po’ la lista, vedremo che sei movimenti prima, a HEAD@{5}, è loggato uno spostamento dovuto ad un commit, e dal commento capiamo che è quello relativo agli snack, quello che ci interessa; a questo punto possiamo annotarci lo hash che leggiamo alla sua sinistra e procedere col prossimo step, “alla ricerca del commit perduto”.
Eseguiamo quindi un reset utilizzando l’hash come destinazione dello spostamento:
[19] ~/es03 (master) $ git reset --hard e29f5c6 HEAD is now at e29f5c6 Aggiunge lista snack a disposizione per l’ufficio [20] ~/es03 (master) $ git log --graph --decorate --oneline --all * e29f5c6 (HEAD -> master) Aggiunge lista snack a disposizione per l’ufficio | * 4072e76 (bevande) Aggiunge l’acqua alle bibite |/ * 87cacc7 Aggiunge una matita * 6cf12f4 Aggiunge una penna alla lista della cancelleria
Boom! Il commit perduto è “resuscitato”, ed è tornato esattamente dove stava prima! A dire il vero non se n’è mai andato, ma come dicevamo prima, semplicemente era diventato irraggiungibile, o unreachable come Git è solito chiamare questo genere di commit.
Questo semplice esempio ci ha dato modo di osservare da un’altra angolazione l’architettura interna di Git e degli oggetti che compongono un suo repository. A questo punto dovrebbe essere un po’ più chiaro come Git gestisce le relazioni tra i commit, e come questi possano sparire e riapparire attraverso lo spostamento di un branch da un commit a un altro.
Quando Git perde la testa
Siccome l’argomento è abbastanza peculiare, e siccome noi siamo dei testoni, vogliamo provare a fare di nuovo casino, per vedere un altro modo in cui è possibile cavarci d’impiccio. Annulliamo di nuovo questo commit degli snack, usando il solito comando git reset:
[21] ~/es03 (master) $ git reset --hard HEAD^ HEAD is now at 87cacc7 Aggiunge una matita [22] ~/es03 (master) $ git log --graph --decorate --oneline --all * 4072e76 (bevande) Aggiunge l’acqua alle bibite * 87cacc7 (HEAD -> master) Aggiunge una matita * 6cf12f4 Aggiunge una penna alla lista della cancelleria
Bene, ora proviamo a spostarci con HEAD su questo commit; non usiamo più il comando git reset, che come abbiamo visto sposta l’etichetta dell’attuale branch, ma usiamo il comando git checkout, che invece sposta la reference HEAD da dove si trova ora — ossia il branch master — direttamente su di un commit, referenziandolo direttamente attraverso l’hash:
[23] ~/es03 (master) $ git checkout e29f5c6 Note: checking out ‘e29f5c6’. You are in ‘detached HEAD’ state. You can look around, make experimental changes and commit them, and you can discard any commits you make in this state without impacting any branches by performing another checkout. If you want to create a new branch to retain commits you create, you may do so (now or later) by using -b with the checkout command again. Example: git checkout -b <new-branch-name> HEAD is now at e29f5c6... Aggiunge lista snack a disposizione per l’ufficio
Wow, abbiamo appena perso… la testa!
Che fine ha fatto lo HEAD?
Ci sono un sacco di cose nuove da vedere qui; ma non abbiate paura, non è complicato: leggiamo assieme passo passo il lungo messaggio che Git ci ha mostrato.
Prima però, fermiamoci un secondo a riflettere: Git è gentile, e spesso ci fornisce un sacco di informazioni utili nei suoi messaggi di output. Non sottovalutate questo comportamento, e soprattutto non ignorate i messaggi: soprattutto all’inizio, leggere con attenzione i messaggi di Git permette di imparare molto.
Torniamo al messaggio: qui Git dice che siamo in uno stato di detached HEAD, ossia “testa staccata”. Sembra una cosa brutta, al limite dell’horror, ma fortunatamente qui non ci sono fantasmi vendicativi da temere; semplicemente, essere in questo stato significa che HEAD non fa riferimento a un ramo, a un branch, ma direttamente a un commit, che in questo caso è il e29f5c6.
Diamo un’occhiata col solito comando di log:
[24] ~/es03 ((e29f5c6...)) $ git log --graph --decorate --oneline --all * e29f5c6 (HEAD) Aggiunge lista snack a disposizione per l’ufficio | * 4072e76 (bevande) Aggiunge l’acqua alle bibite |/ * 87cacc7 (master) Aggiunge una matita * 6cf12f4 Aggiunge una penna alla lista della cancelleria
Prima di tutto, nel prompt della shell si può vedere che tra le parentesi, che ora sono raddoppiate, non c’è il nome di un branch, bensì i primi 7 caratteri del commit, ((e29f5c6…)).
Inoltre, l’etichetta HEAD è ora appiccicata a quel commit, mentre i branch, soprattutto quello master, rimangono al loro posto. Di conseguenza il file HEAD contiene ora l’hash di quel commit, e non un riferimento a un branch come prima; spiamo dentro al file HEAD:
[25] ~/es03 ((e29f5c6...)) $ cat .git/HEAD e29f5c64d23f8aa0c6613fcf43e418df4ae286ad
Come vedete, ecco servito l’hash del commit degli snack.
Proseguiamo con la lettura del messaggio; Git dice che in questo stato possiamo guardarci intorno, fare esperimenti, fare nuovi commit se vogliamo, e scartare il tutto semplicemente rispostandoci su di un ramo esistente o salvare il lavoro svolto creando un nuovo ramo. Siete in grado di spiegare perché questo è vero? Io credo che ora lo siate, ma nel dubbio, lo ripassiamo insieme.
Reachability
Questo è possibile grazie al sistema di raggiungibilità (reachbility) dei commit, naturalmente. Se facciamo alcuni commit, e poi rispostiamo HEAD in un branch esistente, questo commit degli snack e tutti i suoi figli diventano irraggiungibili.
Rimangono in stato raggiungibile fino a quando HEAD è in cima all’ultimo di loro, ma quando si sposta HEAD con un git checkout, i commit figli sono andati. Allo stesso tempo, se si crea un nuovo ramo prima di spostare HEAD, ci sarà un’etichetta, un puntatore che Git può usare per raggiungere questi commit.
Un altro po’ di danno
Vogliamo provare? Ma sì, dài. Facciamo un altro po’ di danno: sovrascriviamo il file della cancelleria scrivendoci dentro qualcos’altro, ed eseguiamo di seguito un commit:
[26] ~/es03 ((e29f5c6...)) $ echo “Bug!” > cancelleria.txt [32] ~/es03 ((e29f5c6...)) $ git commit -am “Un bug ha cancellato la lista della cancelleria!” [detached HEAD 37ddfbd] Un bug ha cancellato la lista della cancelleria! 1 file changed, 1 insertion(+), 2 deletions(-) [33] ~/es03 ((37ddfbd...)) $ git log --graph --decorate --oneline --all * 37ddfbd (HEAD) Un bug ha cancellato la lista della cancelleria! * e29f5c6 Aggiunge lista snack a disposizione per l’ufficio | * 4072e76 (bevande) Aggiunge l’acqua alle bibite |/ * 87cacc7 (master) Aggiunge una matita * 6cf12f4 Aggiunge una penna alla lista della cancelleria
Interessante! Ora abbiamo un nuovo commit, il 37ddfbd, successivo a quello degli snack e29f5c6, e sul quale non abbiamo nessun altro puntatore se non HEAD.
Proviamo ora la prima ipotesi: spostiamoci su master, e vediamo se questi due commit tornano a sparire dalla nostra vista:
[34] ~/es03 ((37ddfbd...)) $ git checkout master Warning: you are leaving 2 commits behind, not connected to any of your branches: 37ddfbd Un bug ha cancellato la lista della cancelleria! e29f5c6 Aggiunge lista snack a disposizione per l’ufficio If you want to keep them by creating a new branch, this may be a good time to do so with: git branch <new-branch-name> 37ddfbd Switched to branch ‘master’ [35] ~/es03 (master) $ git log --graph --decorate --oneline --all * 4072e76 (bevande) Aggiunge l’acqua alle bibite * 87cacc7 (HEAD -> master) Aggiunge una matita * 6cf12f4 Aggiunge una penna alla lista della cancelleria
È proprio così!
Ma prima di proseguire, soffermiamoci di nuovo a leggere il messaggio di Git in risposta al nostro git checkout. Come vedete, Git ci ricorda che, con questa operazione, ci stiamo di fatto lasciando alle spalle ben 2 commit e che, se vogliamo conservarli, dobbiamo creare un nuovo branch che punti all’ultimo commit eseguito, suggerendoci addirittura il comando.
Seguiamo il suo consiglio, e creiamo un nuovo branch dal nome “snack”:
[36] ~/es03 (master) $ git branch snack 37ddfbd [37] ~/es03 (master) $ git log --graph --decorate --oneline --all * 37ddfbd (snack) Un bug ha cancellato la lista della cancelleria! * e29f5c6 Aggiunge lista snack a disposizione per l’ufficio | * 4072e76 (bevande) Aggiunge l’acqua alle bibite |/ * 87cacc7 (HEAD -> master) Aggiunge una matita * 6cf12f4 Aggiunge una penna alla lista della cancelleria
Spostiamoci sul branch appena creato:
[38] ~/es03 (master) $ git checkout snack Switched to branch ‘snack’
Ora scartiamo l’ultimo commit, quello col bug:
[39] ~/es03 (snack) $ git reset --hard HEAD~1 HEAD is now at e29f5c6 Aggiunge lista snack a disposizione per l’ufficio [40] ~/es03 (snack) $ git log --graph --decorate --oneline --all * e29f5c6 (HEAD -> snack) Aggiunge lista snack a disposizione per l’ufficio | * 4072e76 (bevande) Aggiunge l’acqua alle bibite |/ * 87cacc7 (master) Aggiunge una matita * 6cf12f4 Aggiunge una penna alla lista della cancelleria
Molto bene! Non solo abbiamo recuperato il commit degli snack, ma ora lo abbiamo spostato in branch separato, togliendolo dal ramo master che invece terremo per la cancelleria.
Conclusioni
In questo appuntamento abbiamo scoperto nuove cose sulle reference di Git e sulla raggiungibilità dei commit; usando git reset abbiamo visto come sia possibile muovere le etichette che denotano i branch, sfruttando a nostro vantaggio alcuni effetti collaterali come ad esempio l’eliminazione (apparente) dei commit.
Usando git checkout in modo differente, invece, abbiamo fatto la conoscenza dello stato di detached HEAD, che ci consente di operare in una sorta di “zona protetta” dove è possibile fare commit per poi conservarli o scartarli a nostro piacere.
Nei prossimi appuntamenti continueremo a lavorare con reference e branch, fino a giungere al punto in cui nulla sarà più un segreto.
Ferdinando Santacroce lavora come programmatore presso Intré.
Cominciò tutto quando, all’età di 13 anni, ricevette in regalo il suo primo computer, un Commodore64. Capì che la cosa era seria quando invece che giocare come tutti i suoi amici ai soliti giochini comprati in edicola, si divertiva a scrivere piccoli programmi che facevano emettere suoni allo speaker.
Dopo il diploma, diventa insegnante tecnico pratico, ruolo grazie al quale impara molto. Per anni svolge un doppio incarico, in veste di insegnante e di web master — ai tempi, i full-stack developer si chiamavano così — togliendosi anche qualche piccola soddisfazione.
Da una dozzina d’anni si occupa esclusivamente di sviluppo software; ha lavorato per anni nel mercato farmaceutico italiano, passando poi all’e-commerce e giungendo infine ad occuparsi di industria.
Trascorre le sue giornate facendosi spazio tra dettagli e peculiarità delle ultime tecnologie e l'affascinante mondo delle discipline agili, per i quali nutre un profondo interesse.
Nel 2015 ha pubblicato un libro per Packt, “Git Essentials” (http://www.amazon.it/dp/B00WX1CWIC), ed è entrato a far parte dell'Italian Agile Movement, organizzazione senza scopo di lucro che ogni anno organizza gli Italian Agile Days (http://www.agileday.it/front/).