Capitolo 8. Segreti rivelati

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.

Invisibilità

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.

Integrità

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.

Intelligenza

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

Indicizzazione

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.

Le origini di Git

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.

Il database di oggetti

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.

Oggetti blob

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.

Oggetti tree

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.

Oggetti commit

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.

Indistinguibile dalla magia

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.