Capítulo 8. Segredos Revelados

Vamos dar uma espiada sob o capô e explicar como o Git realiza seus milagres. Será uma explicação superficial. Para detalhes mais aprofundados consultar o manual do usuário.

Invisibilidade

Como pode o Git ser tão discreto? Fora ocasionais commit e merge, você pode trabalhar como se desconhecesse que existe um controle de versões. Isto é, até que precise dele, e é quando você ficará agradecido ao Git por estar vigiando o que faz o tempo todo.

Outros sistemas de controle de versões não deixam você esquecê-los. As permissões dos arquivos são apenas de leitura, a menos que você diga ao servidor quais arquivos tem a intenção de editar. Os comandos mais básicos podem demorar muito quando o número de usuários aumenta. O trabalho pode ser interrompido quando a rede ou o servidor central para.

Ao contrário, o Git guarda o histórico do seu projeto no diretório .git no diretório de trabalho. Essa é a sua cópia do histórico, de modo que você pode ficar offline até que deseje se comunicar com os outros. Você tem total controle sobre o destino de seus arquivos, pois o Git pode facilmente recriar um estado salvo a partir do .git a qualquer hora.

Integridade

A maioria das pessoas associam criptografia com manter informações secretas, mas outra aplicação igualmente importante é manter a integridade da informação. O uso correto das funções criptográficas de hash pode prevenir o corrupção acidental ou intencional dos dados.

Um hash SHA1 pode ser entendido como um número identificador único de 160 bits para cada sequência de bytes que você vai encontrar na vida. Na verdade mais que isso: para cada sequência de bytes que qualquer ser humano jamais usará durante várias vidas.

Como um hash SHA1 é, ele mesmo, uma sequência de bytes, podemos gerar um hash de sequências de bytes formada por outros hash. Essa observação simples é surpreendentemente útil: a procura por cadeias hash (hash chains). Vamos ver mais adiante como o Git a utiliza para garantir com eficiência a integridade de dados.

A grosso modo, o Git mantém os seus arquivos no subdiretório .git/objects, onde ao invés de ter arquivos com nomes “normais”, vamos encontrar somente identificadores (Ids). Utilizando os Ids como nomes de arquivos, bem como uns poucos lockfiles e alguns truques com o timestamp, o Git transforma qualquer sistema de arquivos simples em um poderoso banco de dados.

Inteligência

Como o Git sabe que um arquivo foi renomeado, mesmo que você nunca tenha mencionado o fato explicitamente? Com certeza, você executou git mv, mas isto é exatamente o mesmo que um git rm seguido por um git add.

A análise heurística do Git verifica além das ações de renomear e de cópias sucessivas entre versões. De fato, ele pode detectar até pedaços de código sendo movidos ou copiados entre arquivos! Embora não cubra todos os casos, faz um trabalho decente, e esta característica está sendo sempre aprimorada. Caso não funcione com você, tente habilitar opções mais refinadas para detecção de cópias e considere uma atualização.

Indexando

Para cada arquivo monitorado, o Git armazena informações como: tamanho, hora de criação e última modificação, em um arquivo conhecido como index. Para determinar se um arquivo foi modificado, o Git compara seus status atual com o que tem no index. Se coincidem, então ele pode ignorar o arquivo.

Já que verificações de status são imensamente mais baratas que ler o conteúdo do arquivo, se você editar poucos arquivos, o Git vai atualizar seus status quase que instantaneamente.

Falamos anteriormente que o index é uma área de atuação (staging). Por que um monte de status de arquivos é uma area de atuação (staging)? É porque o comando add coloca os arquivos no banco de dados do Git e atualiza esse status, enquanto o comando commit, sem opções, cria um commit baseado somente no status e arquivos já existentes no banco de dados.

Origem do Git

Esta mensagem na lista de discussão do Linux Kernel descreve a sequência de eventos que levaram ao Git. A discussão inteira é um sitio arqueológico fascinante para historiadores do Git.

O Banco de Dados de Objetos

Cada versão de seus dados é mantida em um bando de dados de objetos, que reside no subdiretório .git/objects: os outros residentes de .git/ armazenam menos dados: o index, nomes dos branchs, etiquetas (tags), configurações de opções, logs, a localização do commit HEAD, e outros. O bando de dados objeto é elementar e elegante, e a origem do poder do Git.

Cada arquivo dentro de .git/objects é um objeto. Existem 3 tipos de objetos que nos interessam: objetos blob, objetos árvores (tree), e objetos commit.

Blobs

Primeiro, um truque mágico. Peque um nome de arquivo, qualquer arquivo. Em um diretório vazio, execute

$ echo sweet > YOUR_FILENAME
$ git init
$ git add .
$ find .git/objects -type f

Voce verá .git/objects/aa/823728ea7d592acc69b36875a482cdf3fd5c8d.

Como eu posso saber disso, sem saber o nome do arquivo? É por que o hash SHA1 de:

"blob" SP "6" NUL "sweet" LF

é aa823728ea7d592acc69b36875a482cdf3fd5c8d, onde SP é espaço, NUL é o byte zero e LF é um linefeed. Você pode verificar isso, digitando:

$ printf "blob 6\000sweet\n" | sha1sum

O Git é endereçável-por-conteúdo: os arquivos não são armazenados de acordo com seus nomes de arquivos, e sim pelo hash de seus dados, em um arquivo que chamamos de objeto blob (blob object). Podemos pensar que o hash é um identificador único para o conteúdo do arquivo, de modo que estamos endereçando os arquivos pelo seu conteúdo. O blob 6 inicial é meramente um header que consiste do tipo do objeto e seu tamanho em bytes; isso simplifica a organização interna.

Assim eu poderia prever o que você irá ver. O nome do arquivo é irrelevante: somente os dados internos são utilizados para construir o objeto blob.

Você pode estar se perguntando o que acontece com os arquivos idênticos. Tente adicionar cópias de seu arquivo, com qualquer nome de arquivo. O conteúdo do .git/objects continua o mesmo não importa quantos você adiciona. O Git somente armazena o dado uma única vez.

A propósito, os arquivos dentro de .git/objects são comprimidos com a zlib de modo que você não consegue examiná-los diretamente. Faça uma filtragem por meio do zpipe -d, ou digite:

$ git cat-file -p aa823728ea7d592acc69b36875a482cdf3fd5c8d

que mostra o objeto em um formato legível.

Árvores

Mas onde estão os nomes de arquivos? Eles precisam ser armazenados em algum lugar. Git fica sabendo o nome do arquivo durante um commit:

$ git commit  # Type some message.
$ find .git/objects -type f

Você pode ver agora 3 objetos. Dessa vez eu não consigo dizer quais os dois nomes de arquivos, já que eles dependem parcialmente do nome do arquivo que você escolheu. Vamos prosseguir assumindo que você escolheu “rose”. Se não escolheu esse nome, você pode reescrever o histórico para ficar parecido com o que você fez:

$ git filter-branch --tree-filter 'mv YOUR_FILENAME rose'
$ find .git/objects -type f

Agora você poderá ver o arquivo .git/objects/05/b217bb859794d08bb9e4f7f04cbda4b207fbe9, porque esse é o hash SHA 1 de seu conteudo:

"tree" SP "32" NUL "100644 rose" NUL 0xaa823728ea7d592acc69b36875a482cdf3fd5c8d

Verifique que este arquivo contém efetivamente o que falamos acima, digitando:

$ echo 05b217bb859794d08bb9e4f7f04cbda4b207fbe9 | git cat-file --batch

Com o zpipe, é fácil verificar o hash:

$ zpipe -d < .git/objects/05/b217bb859794d08bb9e4f7f04cbda4b207fbe9 | sha1sum

A verificação do hash é mais complicada via cat-file porque sua saida contem mais do que os dados descomprimidos do arquivo object.

Esse arquivo é um objeto árvore (tree): uma lista de tuplas que consiste em um tipo de arquivo, um nome de arquivo e um hash. Em nosso exemplo, este tipo de arquivo é 100644, que significa que ‘rose` é um arquivo normal, e o hash é o objeto blob que contém o termo `rose’. Outros tipos possíveis de arquivos são executáveis, symlinks e diretorios. No ultimo exemplo, o hash aponta para um objeto árvore.

Se você executar o filter-branch, você obterá objetos antigos que não precisa mais. Embora sejam eliminados automaticamente quando o período de armazenamento expirar, iremos deletá-los agora para tornar o nosso exemplo mais fácil de seguir:

$ rm -r .git/refs/original
$ git reflog expire --expire=now --all
$ git prune

Para projetos reais você deve tipicamente evitar comandos como esses, já que eles destroem os backups. Se você deseja um repositório limpo, é geralmente melhor criar um novo clone. Também, tome cuidado quando manipular diretamente o .git: e se um comando Git esta executando ao mesmo tempo, ou uma falha na alimentação eletrica ocorre? Geralmente, as referências podem ser deletadas com git update-ref -d, embora seja mais seguro remover manualmente o refs/original.

Commits

Explicamos 2 dos 3 objetos. O terceiro é o objeto commit. Seu conteúdo depende da mensagem de commit bem como da data e hora em que foi criado. Para combinar com o que temos aqui, vamos ter que fazer um pequeno truque:

$ git commit --amend -m Shakespeare  # Change the commit message.
$ 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"'  # Rig timestamps and authors.
$ find .git/objects -type f

Agora você pode ver .git/objects/49/993fe130c4b3bf24857a15d7969c396b7bc187 que é o hash SHA 1 de seu conteúdo:

"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

Como anteriormente, você pode executar o zpipe ou cat-file para ver você mesmo.

Esse é o primeiro commit, de modo que não existe nenhum commit pai, mas commit posteriores irão sempre conter no mínimo uma linha identificando o seu commit pai.

Indistinguível da Magia

O segredo do Git parece ser tão simples. Parece que você pode misturar um pouco de script shell e adicionar uma pitada de código C para cozinha-lo em questão de horas: uma mistura de operações básicas do sistema de arquivos e hash SHA 1, guarnecido com arquivos lock e fsyncs para robustez. De fato, isso descreve acuradamente as primeiras versões do Git. No entanto, além de truques engenhosos de empacotamento para economizar espaço, e truques engenhosos de indexação para economizar espaço, agora sabemos como o Git habilmente transforma um sistema de arquivos em um banco de dados perfeito para o controle de versões.

Por exemplo, se algum arquivo dentro do banco de dados de objetos é corrompido por um erro de disco, então o seu hash não irá corresponder mais, alertando-nos sobre o problema. Fazendo hash de hash de outros objetos, mantemos a integridade em todos os níveis. Os commits são atomicos, isto é, um commit nunca pode armazenar parcialmente as mudanças: só podemos calcular o hash de um commit e armazenar ele no banco de dados após ter armazenado todas as árvores relevantes, blobs e os commits pais. O banco de dados de objetos é imune a interrupções inesperadas tais como falha de alimentação eletrica.

Nós derrotamos até mesmo os adversários mais tortuosos. Suponha que alguem tente de maneira escondida, modificar o conteudo de um arquivo em uma versão antiga do projeto. Para manter o banco de dados dos objetos com uma aparência saudável, ele deve também alterar o hash do objeto blob correspondente, já que ele contem agora uma cadeia de bytes diferente. Isso significa que ele terá que alterar o hash de qualquer objeto árvore que referencia o arquivo, e em seguida alterar o hash de todos os objetos commit que estão envolvidos com esses objetos árvores, além dos hash de todos os descendentes desses commits. Isso significa que o hash do Head oficial será diferente do hash do repositório alterado. Seguindo a trilha dos hash não correspondentes podemos apontar o arquivo alterado, bem como o commit onde ele foi corrompido.

Resumindo, graças aos 20 bytes que representam o ultimo commit seguro, é impossível adulterar um repositório Git.

É sobre as famosas características do Git? Branching, Merging? Tags? Meros detalhes. O cabeçalho atual é mantido em um arquivo .git/HEAD, que contém um hash de um objeto commit. O hash é atualizado durante um commit bem como com muitos outros comandos. Branch são quase a mesma coisa: eles são arquivos em .git/refs/heads. Tags também: elas estão em .git/refs/tags mas são atualizadas por um conjunto diferente de comandos.