Primeiro um banco de dados, depois uma ferramenta
O Git se apresenta como ferramenta de controle de versão, e a maioria das pessoas o conhece como um conjunto de encantamentos: add, commit, push e uma reza. Por baixo, ele é algo mais simples e mais estranho: um banco de dados endereçado por conteúdo. Cada pedaço de conteúdo que o Git guarda — os bytes de um arquivo, a listagem de um diretório, um commit — passa por um hash, e o hash é o nome do objeto. Peça por ce013625... e você recebe exatamente os bytes cujo hash dá ce013625..., de qualquer cópia do repositório, para sempre. Conteúdo idêntico ganha o mesmo nome em todo lugar; conteúdo corrompido para de bater com o próprio nome e é pego no momento em que é lido.
Três tipos de objeto carregam quase tudo. Um blob é conteúdo de arquivo, e só — sem nome de arquivo, sem permissões, só bytes. Uma tree é a listagem de um diretório: nomes, modos e os hashes dos blobs e trees abaixo dela. Um commit aponta para uma tree (o snapshot completo do projeto naquele momento), para o commit pai — ou pais —, e carrega autor, data e mensagem. Esse é o modelo de dados inteiro. Todo o resto do Git é conveniência para produzir, nomear ou comparar esses objetos.
Snapshots, não diffs
O mal-entendido mais teimoso sobre o Git é achar que um commit guarda o que mudou. Não guarda. Todo commit aponta para uma tree completa — o projeto inteiro, como estava naquele commit. Quando você olha um diff, o Git o calcula na hora, comparando dois snapshots; a diferença é apresentação, não a coisa guardada. (O armazenamento fica pequeno porque arquivo que não mudou produz o mesmo hash de blob e é só referenciado de novo, e os pack files depois comprimem os objetos por delta internamente — detalhe de implementação invisível ao modelo.)
Esse único fato explica comportamentos que de outro jeito parecem arbitrários. Trocar de branch é rápido porque é só materializar outra tree. Um cherry-pick consegue conflitar de jeitos surpreendentes porque ele precisa reconstruir uma mudança a partir de dois snapshots e reaplicá-la sobre um terceiro. E a história pode ser reescrita à vontade antes de ser compartilhada porque commits são valores imutáveis — você nunca edita um commit, você cria outro e move um ponteiro.
Uma branch tem 41 bytes
Uma branch no Git é um arquivo contendo um hash de commit e uma quebra de linha. Essa é a implementação inteira. Criar uma branch escreve 41 bytes; apagar uma remove um arquivo; "estar" numa branch significa que o ponteiro especial HEAD referencia essa branch, de modo que o próximo commit ao mesmo tempo ganha o commit atual como pai e avança o ponteiro. Não existe contêiner, não existe cópia dos arquivos, não existe registro em lugar nenhum. É por isso que criar branch no Git é instantâneo onde, nos sistemas que ele substituiu, era um evento — às vezes literalmente uma cerimônia envolvendo o administrador do servidor — e por isso os fluxos de trabalho construídos sobre branches baratas e descartáveis só viraram norma depois que o Git as tornou de graça.
O merge é onde os ponteiros encontram a aritmética. O Git acha o ancestral comum de dois commits e faz uma comparação de três vias: o que cada lado mudou em relação àquela base compartilhada. Mudanças em regiões diferentes se combinam em silêncio; mudanças nas mesmas linhas viram conflito, que o Git se recusa a resolver no chute — ele escreve as duas versões no arquivo e para, na teoria de que quem entende o código é que deve decidir. O rebase chega ao mesmo estado final por uma história diferente: em vez de um commit de merge juntando duas linhas, ele reaplica seus commits um por um sobre a base nova, produzindo uma linha reta ao custo de reescrevê-los (pais novos significam hashes novos — daí a regra de bolso de só fazer rebase do que você ainda não compartilhou).
As três áreas, ou por que os comandos finalmente fazem sentido
Quase todo comando confuso do Git está mexendo numa de três coisas: a working tree (os arquivos que você edita), o index, também chamado de staging area (o snapshot sendo montado para o próximo commit), e o HEAD (o último commit). git add copia da working tree para o index. git commit transforma o index numa tree mais um objeto de commit e avança a branch. git status não é nada além de uma comparação de duas vias entre as três áreas. As várias formas alarmantes de git reset diferem só em qual das três elas movem. Quem aprende esse modelo primeiro tende a parar de decorar comandos, porque os comandos viram frases sobre três lugares bem definidos.
Distribuído quer dizer que todo clone é a coisa de verdade
O Git foi escrito em 2005, em mais ou menos duas semanas, por — porque o kernel do Linux tinha perdido a licença de uso do BitKeeper, o sistema proprietário que (contra os hábitos da época) já tinha ensinado aos desenvolvedores do kernel qual era a sensação de um controle de versão distribuído. Os antecessores que a maior parte da indústria usava de fato, CVS e Subversion, guardavam a única história verdadeira num servidor; a cópia do desenvolvedor era uma área de trabalho e pouco mais, e a maioria das operações era chamada de rede.
O Git inverteu isso: git clone copia o banco de objetos inteiro, cada commit até o primeiro. História, diffs, branches, log, blame — tudo é local, e é por isso que é rápido, e por isso não existe cópia privilegiada no protocolo em si. "O servidor" num time que usa Git é uma convenção social: um clone que todo mundo combina de tratar como ponto de encontro. O protocolo ficaria perfeitamente satisfeito com desenvolvedores trocando commits entre si — os objetos são endereçados por conteúdo, então o mesmo commit é o mesmo commit onde quer que ele pouse.
Os nomes que a maioria aprende como Git avançado são a contabilidade desse modelo. Um remote é um marcador com o endereço de outro clone. fetch copia para você os objetos novos de lá e atualiza o seu registro de onde as branches de lá apontam; pull é um fetch seguido de um merge (ou rebase) na sua branch; push manda seus objetos para lá e pede que um ponteiro se mova. Nada nessa lista edita história que os outros têm — que é a propriedade quieta de segurança em que o desenho inteiro se apoia.
O que o Git não faz
Dois silêncios do desenho valem conhecer de propósito. O Git rastreia conteúdo, não arquivos: um diretório vazio é invisível para ele (não há entrada de tree a fazer), e um rename não é registrado em lugar nenhum — ele é detectado depois, quando uma remoção e uma adição parecem suficientemente parecidas. E os hashes do Git autenticam conteúdo, não autoria: a linha author de um commit é uma alegação em texto livre que qualquer um escreve. Verificar quem fez um commit é um mecanismo separado (commits e tags assinados), colocado por cima para os projetos que precisam disso.
A função de hash em si carrega um asterisco. O Git nasceu no -1, que já não resiste a colisão contra um atacante com verba; o Git blindou a própria implementação contra as construções de colisão conhecidas e definiu um formato de repositório em SHA-256, e a migração do ecossistema é um projeto longo e deliberadamente sem emoção (veja o panorama de funções de hash para o que é resistência a colisão e por que ela se desgasta). Para integridade contra acidente — o trabalho que o hash faz um bilhão de vezes por dia — o SHA-1 segue inteiramente eficaz.
Por que esse desenho ganhou
Em 2005 o campo estava cheio: o CVS era o incumbente de quem todo mundo tinha cicatriz, o Subversion o sucessor respeitável, e uma primeira geração de ferramentas distribuídas (BitKeeper no comercial; Mercurial, nascido no mesmo mês que o Git) provava o conceito. O Git ganhou num composto de velocidade (operações locais, na escala do kernel, no hardware de 2005), um modelo de dados simples o bastante para merecer confiança e, decisivamente, o efeito de rede que chegou quando a hospedagem tornou o compartilhar sem atrito — uma história que pertence às plataformas construídas em cima dele, e ao fato estranho de que o encanamento por baixo nunca precisou mudar para sustentar nada disso. A interface seca e de quinas vivas nunca amaciou muito; o modelo por baixo é que se revelou a coisa que vale aprender.