Executando verificação de segurança...
16
kht
12 min de leitura ·

[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.

repositório Git


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)

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:


Texto retirado da minha resposta no Stack Overflow.

Carregando publicação patrocinada...
1

Que engracado, hoje eu acordei e pensei em escrever sobre git internals aqui no tab news. Quando entrei estava la a sua publicacao, que alias esta bem melhor do que a que eu teria escrito. Entao parabens, toma meu upvote.

1

Obrigado!

Tive a ideia ao ver que não tem muitos posts sobre o assunto.

E se quiser, pode complementar. Afinal, o assunto é extenso...

1

Fiquei interessado em como funciona o packfile, entendi que o Git decide criar um packfile quando a quantidade e o tamanho dos loose objects atingem um limite determinado pela heurística do Git, e quando a criação de um packfile pode resultar em economia significativa, mas você sabe onde tem um conteúdo para entender melhor como essa heurística funciona?

Parabéns, o conteúdo ficou ótimo!

1

Eu não sei todos os detalhes, até porque é detalhe de implementação e eles podem mudar de uma versão para outra.

Mas enfim, sei que alguns comandos (como git fetch, git rebase, git commit, entre outros) chamam git gc --auto "por baixo dos panos". E este comando, segundo a documentação, verifica o valor de gc.auto (que inclusive pode ser alterado com git config) para decidir o que fazer.

Basicamente, quando a quantidade de objetos soltos (loose objects) excede o valor de gc.auto, é feito o packing (na verdade, a documentação diz que "When there are approximately more than this", mas não deixa claro o que é esse "aproximadamente"). A versão atual (2.40.0) diz que o valor default é 6700, mas como eu já disse, isso é detalhe de implementação e pode mudar sem aviso em versões futuras.

Não achei mais detalhes, então provavelmente o jeito é ver o código fonte :-)

1

muito top sua publicação. Eu tive alguns problemas com branchs de commits e PR no começo do mês. Na hora de juntar arquivos que foram alterados em algumas branchs voltavam quando mergeavamos outras branchs. agora entendi o pq isso estava acotnecendo.

1

Uma dificuldade que eu tinha era que eu não conseguia visualizar na minha mente o que cada comando fazia com o repositório. Eu ainda estava com aquela imagem de trunk/branches do SVN, e não conseguia fazer um paralelo com o Git.

O que abriu minha cabeça foi este artigo. É longo, porém muito esclarecedor. Depois de lê-lo, consegui criar um modelo mental do repositório e hoje consigo entender melhor o que cada comando faz. Isso, junto com o entendimento sobre os internals, me fez usar o Git de maneira bem mais assertiva (bem melhor do que eu fazia no início, que era basicamente rodar os comandos e torcer pra dar certo).

1
2