Diamo ora un’occhiata sotto il cofano e cerchiamo di capire come Git realizza i suoi miracoli. Per una descrizione approfondita fate riferimento al manuale utente.
Come fa Git ad essere così discreto? A parte qualche commit o merge occasionale, potreste lavorare come se il controllo di versione non esistesse. Vale a dire fino a che non è necessario, nel qual caso sarete felici che Git stava tenendo tutto sotto controllo per tutto il tempo.
Altri sistemi di controllo di versione vi forzano costantemente a confrontarvi con scartoffie e burocrazia. File possono essere solo acceduti in lettura, a meno che non dite esplicitamente al server centrale quali file intendete modificare. I comandi di base soffrono progressivamente di problemi di performance all’aumentare del numero utenti. Il lavoro si arresta quando la rete o il server centrale hanno problemi.
In contrasto, Git conserva tutta la storia del vostro
progetto nella sottocartella .git
della vostra cartella di lavoro. Questa
è la vostra copia personale della storia e potete quindi
rimanere offline fino a che non volete comunicare con altri.
Avete controllo totale sul fato dei vostri file perché Git
può ricrearli ad ogni momento a partire da uno stato salvato
in .git
.
La maggior parte della gente associa la crittografia con la conservazione di informazioni segrete ma un altro dei suoi importanti scopi è di conservare l’integrità di queste informazioni. Un uso appropriato di funzioni hash crittografiche può prevenire la corruzione accidentale e dolosa di dati.
Un codice hash SHA1 può essere visto come un codice unico di identificazione di 160 bit per ogni stringa di byte concepibile.
Visto che un codice SHA1 è lui stesso una stringa di byte, possiamo calcolare un codice hash di stringe di byte che contengono altri codici hash. Questa semplice osservazione è sorprendentemente utile: cercate ad esempio hash chains. Più tardi vedremo come Git usa questa tecnica per garantire efficientemente l’integrità di dati.
Brevemente, Git conserva i vostri dati nella sottocartella
.git/objects
, ma invece di
normali nomi di file vi troverete solo dei codici.
Utilizzando questi codici come nomi dei file, e grazie a
qualche trucco basato sull’uso di lockfile e timestamping, Git trasforma un
semplice sistema di file in un database efficiente e
robusto.
Come fa Git a sapere che avete rinominato un file anche se non gliel’avete mai detto esplicitamente? È vero, magari avete usato git mv, ma questo è esattamente la stessa cosa che usare git rm seguito da git add.
Git possiede dei metodi euristici stanare cambiamenti di nomi e copie tra versioni successive. Infatti, può addirittura identificare lo spostamento di parti di codice da un file ad un altro! Pur non potendo coprire tutti i casi, questo funziona molto bene e sta sempre costantemente migliorando. Se non dovesse funzionare per voi, provate le opzioni che attivano metodi di rilevamento di copie più impegnative, e considerate l’eventualità di fare un aggiornamento
Per ogni file in gestione, Git memorizza delle informazioni, come la sua taglia su disco, e le date di creazione e ultima modifica, un file detto indice. Per determinare su un file è stato cambiato, Git paragona il suo stato corrente con quello che è memorizzato nell’indice. Se le due fonti di informazione corrispondono Git non ha bisogno di rileggere il file.
Visto che l’accesso all’indice è considerabilmente più che leggere file, se modificate solo qualche file, Git può aggiornare il suo stato quasi immediatamente.
Prima abbiamo detto che l’indice si trova nell’area di staging. Com'è possibile che un semplice file contenente dati su altri file si trova nell’area di staging? Perché il comando add aggiunge file nel database di Git e aggiorna queste informazioni, mentre il comando commit senza opzioni crea un commit basato unicamente sull’indice e i file già inclusi nel database.
Questo messaggio della mailing list del kernel di Linux descrive la catena di eventi che hanno portato alla creazione di Git. L’intera discussione è un affascinante sito archeologico per gli storici di Git.
Ognuna delle versioni dei vostri dati è conservata nel
cosiddetto database di
oggetti che si trova nella sottocartella
.git/objects
; il resto del
contenuto di .git/
rappresenta
meno dati: l’indice, il nome delle branch, le tags, le
opzioni di configurazione, i logs, la posizione attuale del
commit HEAD, e così via. Il database di oggetti è semplice ma
elegante, e è la fonte della potenza di Git.
Ogni file in .git/objects
è
un oggetto. Ci sono
tre tipi di oggetti che ci riguardano: oggetti blob, oggetti albero (o tree
) e gli oggetti commit.
Prima di tutto un po' di magia. Scegliete un nome di file qualsiasi. In una cartella vuota eseguite:
$ echo sweet > VOSTRO_FILE $ git init $ git add . $ find .git/objects -type f
Vedrete .git/objects/aa/823728ea7d592acc69b36875a482cdf3fd5c8d
.
Come posso saperlo senza sapere il nome del file? Perché il codice hash SHA1 di:
"blob" SP "6" NUL "sweet" LF
è aa823728ea7d592acc69b36875a482cdf3fd5c8d, dove SP è uno spazio, NUL è un carattere di zero byte e LF un passaggio a nuova linea. Potete verificare tutto ciò digitando:
$ printf "blob 6\000sweet\n" | sha1sum
Git utilizza un sistema di classificazione per contenuti:
i file non sono archiviati secondo il loro nome, ma secondo
il codice hash del loro contenuto, in un file che chiamiamo
un oggetto blob.
Possiamo vedere il codice hash come identificativo unico del
contenuto del file. Quindi, in un certo senso, ci stiamo
riferendo ai file rispetto al loro contenuto. L’iniziale
blob 6
è semplicemente
un’intestazione che indica il tipo di oggetto e la sua
lunghezza in bytes; serve a semplificare la gestione
interna.
Ecco come ho potuto predire il contenuto di .git. Il nome del file non conta: solo il suo contenuto è usato per costruire l’oggetto blob.
Magari vi state chiedendo che cosa succede nel caso di
file identici. Provate ad aggiungere copie del vostro file,
con qualsiasi nome. Il contenuto di .git/objects
rimane lo stesso a prescindere
del numero di copie aggiunte. Git salva i dati solo una
volta.
A proposito, i file in .git/objects
sono copressi con zlib e
conseguentemente non potete visualizzarne direttamente il
contenuto. Passatele attraverso il filtro zpipe
-d, o eseguite:
$ git cat-file -p aa823728ea7d592acc69b36875a482cdf3fd5c8d
che visualizza appropriatamente l’oggetto scelto.
Ma dove vanno a finire i nomi dei file? Devono essere salvati da qualche parte. Git si occupa dei nomi dei file in fase di commit:
$ git commit # Scrivete un messaggio $ find .git/objects -type f
Adesso dovreste avere tre oggetti. Ora non sono più in grado di predire il nome dei due nuovi file, perché dipenderà in parte dal nome che avete scelto. Procederemo assumendo che avete scelto “rose”. Se questo non fosse il caso potete sempre riscrivere la storia per far sembrare che lo sia:
$ git filter-branch --tree-filter 'mv NOME_DEL_VOSTRO_FILE rose' $ find .git/objects -type f
Adesso dovreste vedere il file .git/objects/05/b217bb859794d08bb9e4f7f04cbda4b207fbe9
perché questo è il codice hash SHA1 del contenuto
seguente:
"tree" SP "32" NUL "100644 rose" NUL 0xaa823728ea7d592acc69b36875a482cdf3fd5c8d
Verificate che questo file contenga il contenuto precedente digitando:
$ echo 05b217bb859794d08bb9e4f7f04cbda4b207fbe9 | git cat-file --batch
È più facile verificare il codice hash con zpipe:
$ zpipe -d < .git/objects/05/b217bb859794d08bb9e4f7f04cbda4b207fbe9 | sha1sum
Verificare l’hash è più complicato con il comando cat-file perché il suo output contiene elementi ulteriori oltre al file decompresso.
Questo file è un oggetto tree: una lista di elementi
consistenti in un tipo di file, un nome di file, e un hash.
Nel nostro esempio il tipo di file è 100644, che indica che
rose
è un file normale e il
codice hash e il codice hash è quello di un oggetto di tipo
blob che contiene il
contenuto di rose
. Altri
possibili tipi di file sono eseguibili, link simbolici e
cartelle. Nell’ultimo caso il codice hash si riferisce ad un
oggetto tree.
Se avete eseguito filter-branch avrete dei vecchi oggetti di cui non avete più bisogno. Anche se saranno cancellati automaticamente dopo il periodo di ritenzione automatica, ora li cancelleremo per rendere il nostro esempio più facile da seguire
$ rm -r .git/refs/original $ git reflog expire --expire=now --all $ git prune
Nel caso di un vero progetto dovreste tipicamente evitare
comandi del genere, visto che distruggono dei backup. Se
volete un deposito più ordinato, è normalmente consigliabile
creare un nuovo clone. Fate inoltre attenzione a manipolare
direttamente il contenuto di .git
: che cosa succederebbe se un comando
Git è in esecuzione allo stesso tempo, o se se ci fosse un
improvviso calo di corrente? In generale i refs dovrebbero
essere cancellati con git
update-ref -d, anche se spesso sembrerebbe
sicuro cancella re refs/original
a mano.
Abbiamo spiegato 2 dei 3 tipi di oggetto. Il terzo è l’oggetto commit. Il suo contenuto dipende dal messaggio di commit, come anche dalla data e l’ora in cui è stato creato. Perché far in maniera di ottenere la stessa cosa dobbiamo fare qualche ritocco:
$ git commit --amend -m Shakespeare # Cambiamento del messaggio di commit $ git filter-branch --env-filter 'export GIT_AUTHOR_DATE="Fri 13 Feb 2009 15:31:30 -0800" GIT_AUTHOR_NAME="Alice" GIT_AUTHOR_EMAIL="alice@example.com" GIT_COMMITTER_DATE="Fri, 13 Feb 2009 15:31:30 -0800" GIT_COMMITTER_NAME="Bob" GIT_COMMITTER_EMAIL="bob@example.com"' # Ritocco della data di creazione e degli autori $ find .git/objects -type f
Dovreste ora vedere il file .git/objects/49/993fe130c4b3bf24857a15d7969c396b7bc187
che è il codice hash SHA1 del suo contenuto:
"commit 158" NUL "tree 05b217bb859794d08bb9e4f7f04cbda4b207fbe9" LF "author Alice <alice@example.com> 1234567890 -0800" LF "committer Bob <bob@example.com> 1234567890 -0800" LF LF "Shakespeare" LF
Come prima potete utilizzare zpipe o cat-file per verificare voi stessi.
Questo è il primo commi, non ci sono quindi commit genitori. Ma i commit seguenti conterranno sempre almeno una linea che identifica un commit genitore.
I segreti di Git sembrano troppo semplici. Sembra che basterebbe mescolare assieme qualche script shell e aggiungere un pizzico di codice C per preparare un sistema del genere in qualche ora: una combinazione di operazioni di filesystem di base e hashing SHA1, guarnito con lockfile e file di sincronizzazione per avere un po' di robustezza. Infatti questaè un descrizione accurata le prime versioni di Git. Malgrado ciò, a parte qualche astuta tecnica di compressione per risparmiare spazio e di indicizzazione per risparmiare tempo, ora sappiamo come Git cambia abilmente un sistema di file in un perfetto database per il controllo di versione.
Ad esempio, se un file nel database degli oggetti è corrotto da un errore sul disco i codici hash non corrisponderanno più e verremo informati del problema. Calcolando il codice hash del codice hash di altri oggetti è possibile garantire integrità a tutti i livelli. I commit sono atomici, nel senso che un commit non può memorizzare modifiche parziali: possiamo calcolare il codice hash di un commit e salvarlo in un database dopo aver creato i relativi oggetti tree, blob e commit. Il database degli oggetti è immune da interruzioni inaspettate dovute ad esempio a cali di corrente.
Possiamo anche far fronte ai tentativi di attacco più maliziosi. Supponiamo ad esempio che un avversario tenti di modificare di nascosto il contenuto di un file in una vecchia versione di un progetto. Per rendere il database degli oggetti coerente, il nostro avversario deve anche modificare il codice hash dei corrispondenti oggetti blob, visto che ora sarà una stringa di byte diversa. Questo significa che dovrà cambiare il codice hash di tutti gli oggetti tree che fanno riferimento al file, e di conseguenza cambiare l’hash di tutti gli oggetti commit in ognuno di questi tree, oltre ai codici hash di tutti i discendenti di questi commit. Questo implica che il codice hash dell’HEAD ufficiale differirà da quello del deposito corrotto. Seguendo la traccia di codici hash erronei possiamo localizzare con precisione il file corrotto, come anche il primo commit ad averlo introdotto.
In conclusione, purché i 20 byte che rappresentano l’ultimo commit sono al sicuro, è impossibile manomettere il deposito Git.
Che dire delle famose funzionalità di Git? Della creazione
di branch? Dei merge? Delle tag? Semplici dettagli. L’HEAD
corrente è conservata nel file .git/HEAD
che contiene un codice hash di un
oggetto commit. Il codice hash viene aggiornato durante un
commit e l’esecuzione di molti altri comandi. Le branch
funzionano in maniera molto simile: sono file in .git/refs/heads
. La stessa cosa vale per le
tag, salvate in .git/refs/tags
ma sono aggiornate da un insieme diverso di comandi.