[Git internals] Como o Git grava os conjuntos de modificações do repositório?
Já parou pra pensar como o Git grava as informações sobre as modificações feitas nos arquivos? Ele guarda cada versão do arquivo? Ou somente a diferença entre eles (também chamado de "delta")? Como isso é gerenciado internamente?
Para entender melhor, vamos criar um repositório de teste e usar alguns comandos chamados de plumbing (os que lidam com os detalhes mais internos, considerados low level).
Introdução
Primeiramente, vale lembrar que no fundo um repositório do Git é um DAG (Directed Acyclic Graph - Grafo Acíclico Dirigido). Não vou entrar nos detalhes da definição matemática (que pode ser vista aqui), mas basicamente podemos pensar em um repositório do Git como sendo um conjunto de nós apontando para outros nós. E esses nós podem ser de vários tipos: commits, arquivos, etc. Cada um desses nós tem um identificador único, que são os hashes (aqueles "códigos gigantes", como 65e02ed772a5fa35a98409705fd43647af454443
).
De maneira geral, um nó do tipo commit aponta para outro(s) commit(s) (os commits pais - sim, quando é feito um merge, o commit resultante pode ter mais de um pai), mas também aponta para uma árvore de diretórios (chamado de tree), que por sua vez aponta para os arquivos referentes àquele commit.
Criando o repositório e analisando sua estrutura interna
No exemplo abaixo criaremos um repositório e iremos ver as "entranhas" do mesmo, usando os comandos de plumbing.
Os comandos abaixo foram rodados em Ubuntu 20.04.4 e Git 2.37.3. Em outras versões pode ser que a saída de alguns comandos seja diferente, com outras mensagens, etc. Mas a ideia básica permanece a mesma.
Primeiro vamos criar uma pasta e inicializar um novo repositório nela:
$ mkdir projeto
$ cd projeto/
$ git init
Com isso ele cria um repositório vazio. Se olharmos na pasta do projeto, veremos que foi criada uma pasta .git
. É ali que o Git guarda as informações do repositório.
Em seguida vamos criar um arquivo e commitá-lo:
$ echo abc > arquivo1.txt
$ git add .
$ git commit -m"criar arquivo1.txt"
[master (root-commit) 90e5167] criar arquivo1.txt
1 file changed, 1 insertion(+)
create mode 100644 arquivo1.txt
Com isso temos o primeiro commit:
$ git log
commit 90e5167946367108cc2b6185b7c943581336c23a
Author: Fulano <[email protected]>
Date: 2022-09-01 08:27:31
criar arquivo1.txt
E agora podemos usar o hash do commit (o código gigante 90e5167946367108cc2b6185b7c943581336c23a
), juntamente com o comando cat-file
para ver mais detalhes do mesmo:
$ git cat-file -p 90e5167946367108cc2b6185b7c943581336c23a
tree 3cbbb46b2c176c87ebb4bc286447deb26a45f196
author Fulano <[email protected]> 1662031651 -0300
committer Fulano <[email protected]> 1662031651 -0300
criar arquivo1.txt
Repare que o commit aponta para um objeto tree (indicado pela linha que começa com tree
), que representa uma árvore de arquivos e sub-diretórios. Podemos ver que este objeto tree também tem um hash, que pode ser usado com cat-file
para sabermos mais detalhes dele:
$ git cat-file -p 3cbbb46b2c176c87ebb4bc286447deb26a45f196
100644 blob 8baef1b4abc478178b004d62031cf7fe6db6f903 arquivo1.txt
Como podemos ver, o objeto tree contém apenas uma referência para um objeto do tipo blob, que no caso é o conteúdo do arquivo1.txt (que também possui seu próprio hash). E se fizermos git cat-file -p 8baef1b4abc478178b004d62031cf7fe6db6f903
, ele mostrará o conteúdo deste arquivo (no caso, o texto abc
mais a quebra de linha).
Mas também podemos ver o seu conteúdo diretamente, lendo o respectivo arquivo que está na pasta .git/objects
. Como o Git usa o zlib para compactar os arquivos, temos que descompactá-los:
$ zlib-flate -uncompress < .git/objects/8b/aef1b4abc478178b004d62031cf7fe6db6f903
blob 4abc
Para usar zlib-flate
, basta instalá-lo com sudo apt install qpdf
. Existem outras formas de descompactar, mas foge do escopo deste texto.
Repare que o conteúdo do arquivo é o tipo (no caso, "blob"), um espaço, o tamanho (4
) e o conteúdo do arquivo (o texto abc
e a quebra de linha, por isso que o tamanho é 4). Na verdade, depois do tamanho tem um byte zero, que é usado como separador, para indicar que depois dali é o conteúdo do arquivo. Se quiser "escovar os bits" para ver melhor, pode usar algum comando como o hexdump
:
$ zlib-flate -uncompress < .git/objects/8b/aef1b4abc478178b004d62031cf7fe6db6f903 | hexdump -C
00000000 62 6c 6f 62 20 34 00 61 62 63 0a |blob 4.abc.|
0000000b
Ali podemos ver o byte 00
logo depois do tamanho, e a quebra de linha (0a
) no final.
E agora vamos ver o que acontece ao modificar o arquivo.
Vamos alterar o arquivo1.txt e adicionar um novo arquivo2.txt:
$ echo def >> arquivo1.txt
$ echo xyz > arquivo2.txt
$ git add .
$ git c -m"mudar arquivo1, adicionar arquivo2"
[master 6759854] mudar arquivo1, adicionar arquivo2
2 files changed, 2 insertions(+)
create mode 100644 arquivo2.txt
$ git log --format=oneline
67598546881d02d1642cb0a2bd7093f0686cfa16 mudar arquivo1, adicionar arquivo2
90e5167946367108cc2b6185b7c943581336c23a criar arquivo1.txt
Foi criado um novo commit. Vamos usar cat-file
para ver qual é o objeto tree deste, e também para ver os arquivos desta tree:
# ver detalhes do commit
$ git cat-file -p 67598546881d02d1642cb0a2bd7093f0686cfa16
tree a68b978e09a6ff8b29160d8005bdcd3b5f71c86a
parent 90e5167946367108cc2b6185b7c943581336c23a
author Fulano <[email protected]> 1662033566 -0300
committer Fulano <[email protected]> 1662033566 -0300
mudar arquivo1, adicionar arquivo2
# ver detalhes da tree do commit
$ git cat-file -p a68b978e09a6ff8b29160d8005bdcd3b5f71c86a
100644 blob 5f5521fae22f9e33657c85e468d7bdd43f7350d8 arquivo1.txt
100644 blob cd470e619003f5e55999473fec485d85a8601e44 arquivo2.txt
Agora o commit aponta para uma nova tree, que por sua vez aponta para os arquivos arquivo1.txt
e arquivo2.txt
. Veja que o hash do arquivo1 mudou, já que ele foi alterado e portanto corresponde a um novo objeto blob.
Interessante notar também que agora o commit possui um parent, que nada mais é que o "commit pai" (ou seja, o commit anterior).
Porém, a versão anterior do arquivo1 continua existindo, tanto que podemos ver ambas com cat-file
:
# versão atual do arquivo (segundo commit)
$ git cat-file -p 5f5521fae22f9e33657c85e468d7bdd43f7350d8
abc
def
# versão antiga (do primeiro commit)
$ git cat-file -p 8baef1b4abc478178b004d62031cf7fe6db6f903
abc
E podemos escovar os bits desses arquivos descompactando-os diretamente:
# versão atual
$ zlib-flate -uncompress < .git/objects/5f/5521fae22f9e33657c85e468d7bdd43f7350d8
blob 8abc
def
# versão antiga
$ zlib-flate -uncompress < .git/objects/8b/aef1b4abc478178b004d62031cf7fe6db6f903
blob 4abc
Podemos ver que as diferentes versões do arquivo estão armazenadas em sua totalidade (portanto, nada de delta aqui). Inicialmente, é assim que o Git guarda os arquivos: cada alteração no arquivo gera um novo objeto blob, e cada um deles possui todo o conteúdo do arquivo. Estes são chamados de loose objects (algo como "objetos soltos").
Packfiles
Mas com o passar do tempo (conforme o repositório cresce e a quantidade de arquivos e alterações aumenta) o Git pode fazer o packing dos objetos, criando um packfile. Neste caso, ele pode fazer um delta compression no arquivo (já veremos isso em mais detalhes).
Para testar, eu criei mais 10 mil commits, sempre modificando o arquivo1:
$ for i in $(seq 1 10000); do echo $(date) >> arquivo1.txt && git commit -a -m"alterado em $(date)"; done
No caso, eu sempre adiciono uma linha nova contendo a data atual.
Vamos ver o último commit:
$ git log -1 --oneline
a7006d04 alterado em qui 01 set 2022 09:41:48 -03
Primeiro vamos ver a tree e os arquivos para o qual ela aponta:
# ver detalhes do commit
$ git cat-file -p a7006d04
tree a1c5e63ce53ab84598db22080a5064458292bf2c
# ... demais linhas do commit (parent, autor, data, etc) ...
# ver tree
$ git cat-file -p a1c5e63ce53ab84598db22080a5064458292bf2c
100644 blob 78b67d43faf1675eb8885550211097271a4cc27b arquivo1.txt
100644 blob cd470e619003f5e55999473fec485d85a8601e44 arquivo2.txt
# ver tamanho do arquivo1
$ git cat-file -s 78b67d43faf1675eb8885550211097271a4cc27b
290037
# descompacatar o arquivo1 e ver as 3 primeiras linhas
$ zlib-flate -uncompress < .git/objects/78/b67d43faf1675eb8885550211097271a4cc27b | head -3
blob 290037abc
def
qui 01 set 2022 09:36:14 -03
Repare que o arquivo referente a 78b67d43faf1675eb8885550211097271a4cc27b
ainda está compactado em .git/objects
. Mas a seguir veremos que nem todos estarão.
Um detalhe interessante é que, como o arquivo2 não foi modificado, a tree do último commit aponta para o mesmo blob que a tree do segundo commit. Ou seja, a tree de um commit sempre possui todas as entradas referentes à estrutura de arquivos completa. O que acontece é que, caso um arquivo não tenha sido modificado, não é criada outra cópia dele, e a tree aponta para o mesmo objeto blob.
Agora vamos ver um commit que foi colocado no packfile (veremos mais abaixo como eu descobri isso):
# ver detalhes do commit
$ git cat-file -p b2663a0ee53eecb9f364a9aa054f7b80d5734cb4
tree 35153e41fda0f4d3e50b67bf211a420c5e30883a
parent 4d223b4641d8ca59014d3c998bc3549210757cac
author Fulano <[email protected]> 1662035948 -0300
committer Fulano <[email protected]> 1662035948 -0300
alterado em qui 01 set 2022 09:39:08 -03
# ver detalhes da tree
$ git cat-file -p 35153e41fda0f4d3e50b67bf211a420c5e30883a
100644 blob 07e93cacac5e6a412107e34ee8a9f6711db09cdb arquivo1.txt
100644 blob cd470e619003f5e55999473fec485d85a8601e44 arquivo2.txt
# ver tamanho do arquivo1
$ git cat-file -s 07e93cacac5e6a412107e34ee8a9f6711db09cdb
56181
# porém, o blob correspondente não está em .git/objects
$ ls .git/objects/07/e93cacac5e6a412107e34ee8a9f6711db09cdb
ls: cannot access '.git/objects/07/e93cacac5e6a412107e34ee8a9f6711db09cdb': No such file or directory
Note que o arquivo referente ao blob do arquivo1 não está em .git/objects
. Então onde ele foi parar?
Já vimos que inicialmente cada alteração no arquivo gera um novo objeto blob (que por sua vez, é guardado em .git/objects
).
Mas com o passar do tempo (conforme o repositório cresce e a quantidade de arquivos e alterações aumenta) o Git pode fazer o packing dos objetos, criando um packfile. E neste caso, ele pode fazer um delta compression no arquivo.
No nosso caso, em algum momento no meio daqueles 10 mil commits, o Git decidiu (usando suas heurísticas internas) criar este packfile, e o commit b2663a0ee53eecb9f364a9aa054f7b80d5734cb4
é um dos que foram colocados neste arquivo. Na verdade, foram criados vários packfiles:
$ ls .git/objects/pack/
pack-58b4d7c6d98f33d81c7eed8306c2c298680ab574.idx pack-d838acaa7d25ce143e57c11aa85dd808ed924cbb.idx pack-fe4498b7648c104a9f493c02b572ba8e6a234789.idx
pack-58b4d7c6d98f33d81c7eed8306c2c298680ab574.pack pack-d838acaa7d25ce143e57c11aa85dd808ed924cbb.pack pack-fe4498b7648c104a9f493c02b572ba8e6a234789.pack
pack-a549c672e7fe758144ac131b58d2d09e0436e70d.idx pack-dbe28ff425c24fe667c00f087165e1ea88bb78f8.idx
pack-a549c672e7fe758144ac131b58d2d09e0436e70d.pack pack-dbe28ff425c24fe667c00f087165e1ea88bb78f8.pack
Para ler um packfile, eu usei o comando git verify-pack
. Rodando para cada um dos arquivos .idx
(que são os índices de cada packfile), eu pude verificar onde está o commit:
$ git verify-pack -v .git/objects/pack/pack-fe4498b7648c104a9f493c02b572ba8e6a234789.idx | grep b2663a0ee53eecb9f364a9aa054f7b80d5734cb4
b2663a0ee53eecb9f364a9aa054f7b80d5734cb4 commit 269 177 264219
Assim, descobri que o commit b2663a0ee53eecb9f364a9aa054f7b80d5734cb4
está no pack fe4498b7648c104a9f493c02b572ba8e6a234789
. Este commit não está em .git/objects/b2
, e sim no packfile. O mesmo vale para o arquivo1 que vimos acima, cujo hash 07e93cacac5e6a412107e34ee8a9f6711db09cdb
também não está em .git/objects
. Usando verify-pack
, vemos que ele também está no packfile:
$ git verify-pack -v .git/objects/pack/pack-fe4498b7648c104a9f493c02b572ba8e6a234789.idx| grep 07e93cacac5e6a412107e34ee8a9f6711db09cdb
07e93cacac5e6a412107e34ee8a9f6711db09cdb blob 9 21 417607 1 f762451817603f36e04f0d3fd429cab316232573
E aqui podemos ver algo interessante: a primeira coluna acima indica o hash do objeto, a segunda coluna é o tipo (blob
) e a terceira coluna é o tamanho. Veja que ela só ocupa 9 bytes. E a última coluna é o hash do "arquivo-base". Segundo a documentação, esta coluna só aparece quando o objeto está "deltificado" (ou seja, está armazenado como um delta - ele não tem todo o conteúdo do arquivo, e sim apenas a diferença com relação a uma versão anterior).
E se procurarmos por este arquivo-base, veremos que muitos outros arquivos são um delta com relação a ele:
$ git verify-pack -v .git/objects/pack/pack-fe4498b7648c104a9f493c02b572ba8e6a234789.idx| grep f762451817603f36e04f0d3fd429cab316232573
f762451817603f36e04f0d3fd429cab316232573 blob 60328 380 397727
d282fe4a22cc541caf0a5f232a8ca1b06f9973b4 blob 9 21 398107 1 f762451817603f36e04f0d3fd429cab316232573
65e02ed772a5fa35a98409705fd43647af454443 blob 9 20 398206 1 f762451817603f36e04f0d3fd429cab316232573
cb59c4bdec5b293b6bade9b2a6131668a7e6700d blob 9 20 398304 1 f762451817603f36e04f0d3fd429cab316232573
402e6a7b054399adbfee3e4f223662607f1e8304 blob 9 20 398401 1 f762451817603f36e04f0d3fd429cab316232573
# ... muitos outros arquivos que tem f762451817603f36e04f0d3fd429cab316232573 como base
Na primeira linha podemos ver o arquivo f762451817603f36e04f0d3fd429cab316232573
, ocupando 60328 bytes, e nas linhas seguintes os arquivos que estão armazenados como deltas em relação a ele, todos ocupando 9 bytes.
Se quisermos olhar algum desses arquivos, primeiro ele é reconstruído a partir da base, aplicando-se os deltas necessários (usando a quinta coluna, que é o offset: a posição no respectivo arquivo .pack
onde os dados do objeto estão). Por exemplo, um dos deltas acima, que só ocupa 9 bytes no packfile, terá seu tamanho total calculado se usarmos cat-file
:
$ git cat-file -s cb59c4bdec5b293b6bade9b2a6131668a7e6700d
59168
Conclusão
Nos momentos iniciais do repositório, o Git armazena os arquivos como loose objects: cada versão do arquivo gera um novo objeto blob, que possui todo o conteúdo do arquivo e é armazenado em .git/objects
, e a tree correspondente ao commit passa a apontar para este novo blob. Se um arquivo não foi modificado, a tree aponta para o mesmo blob que a tree do commit anterior estava apontando.
Conforme o repositório aumenta, em determinado momento (usando heurísticas internas) o Git decide fazer o packing dos objetos, criando os packfiles. Neste momento alguns objetos ainda serão armazenados em sua totalidade, enquanto outros serão armazenados como deltas em relação a estes.
Um detalhe importante é que as versões mais novas são mantidas intactas, enquanto as mais antigas são deltas tendo a mais nova como base. Isso porque uma versão armazenada como delta precisa ser reconstruída (pegando-se o arquivo base e aplicando os deltas) e isso é mais lento do que acessar o conteúdo todo "não-deltificado", e como é mais provável que você queira acessar as mais novas com mais frequência, optou-se por fazer assim.
Você pode forçar o packing através de git gc
(ou git gc --aggressive
, que muda alguns parâmetros para fazer um packing "mais agressivo"), mas na prática não devemos nos preocupar muito com isso, pois o Git faz o packing automaticamente, quando achar necessário.
De qualquer forma, a estrutura básica é:
- Um commit aponta para uma tree, que por sua vez aponta para arquivos (objetos blob), ou outras trees (no caso de ter sub-pastas)
- Se um arquivo não é modificado, várias trees apontarão para o mesmo blob (no caso, todas as trees dos commits nos quais não houve modificação).
- Se um arquivo é modificado, ele cria um novo objeto blob:
- no caso de ser loose, é um novo arquivo (uma nova entrada em
.git/objects
) que possui todo o conteúdo do mesmo - no caso de estar no packfile, pode ser um blob base (com todo o conteúdo do arquivo), ou um delta (a diferença com relação a outro blob)
- no caso de ser loose, é um novo arquivo (uma nova entrada em
O que muda é a forma como o conteúdo do arquivo é acessado. No caso de loose objects, basta ler o respectivo arquivo em .git/objects
. Já no caso de um delta no packfile, ele precisa ser reconstruído a partir do arquivo base.
Mais informações:
-
Git seems to store the whole file instead of diff, how to avoid that?
-
Git internals: how does Git store small differences between revisions?
Texto retirado da minha resposta no Stack Overflow.