5  Controle de Versão com Git e GitHub

A reprodutibilidade e a colaboração dependem de ferramentas de controle de versão. Aqui estudamos o Git e a plataforma GitHub, começando pela motivação histórica e pelo modelo de dados interno do Git (que explica por que os comandos funcionam como funcionam), passando pelos algoritmos por trás do diff, da mesclagem e da busca de erros, e avançando para o fluxo de trabalho cotidiano, a colaboração em equipe, a automação com hooks e integração contínua e o versionamento de dados e de ambientes. Para o estatístico e o cientista de dados, o controle de versão não é um luxo de programadores: é a infraestrutura que garante que uma análise possa ser auditada, revisada, compartilhada e refeita (Bryan 2018; Wilson et al. 2017).

5.1 O Problema do Controle de Versão Manual

Quem nunca se viu organizando arquivos de um trabalho em pastas do computador com nomes do tipo:

  • projeto_final.zip
  • projeto_final_v2.zip
  • projeto_final_v2_editado.zip
  • projeto_final_ultimo_revisao_do_professor.zip

Essa abordagem de controle manual de versões é caótica, ineficiente e perigosa. Ela impede que você saiba com precisão:

  1. O que mudou de um arquivo para o outro.
  2. Quem fez a alteração em um trabalho colaborativo.
  3. Quando a alteração foi feita e por qual motivo.
  4. Como reverter o projeto de volta a um ponto no tempo em que tudo funcionava perfeitamente caso algo quebre repentinamente.

Há ainda problemas menos óbvios. Quando duas pessoas editam cópias diferentes do mesmo arquivo (por exemplo, enviadas por e-mail), alguém precisa combinar manualmente as alterações, linha a linha, com grande risco de perder trabalho. E, ao revisar um relatório antigo, não há como saber qual versão do script gerou cada tabela, o que destrói a rastreabilidade da análise.

Para solucionar de forma profissional esses problemas nasceram os Sistemas de Controle de Versão (Version Control Systems, VCS), dos quais o Git é o mais poderoso e adotado globalmente. Em um VCS tradicional (Centralizado), existe apenas um servidor que guarda o histórico, e os desenvolvedores dependem de conexão constante com ele. O Git revolucionou isso ao implementar um modelo diferente, como veremos a seguir.

Definição

Um sistema de controle de versão é um software que registra as alterações feitas em um conjunto de arquivos ao longo do tempo, de modo que seja possível (i) recuperar qualquer versão anterior, (ii) comparar versões, (iii) identificar o autor, o momento e a justificativa de cada alteração e (iv) combinar alterações feitas em paralelo por pessoas diferentes.


5.2 Breve História dos Sistemas de Controle de Versão

A história dos VCS pode ser dividida em três gerações, cada uma resolvendo limitações da anterior (Chacon e Straub 2014; Loeliger e McCullough 2012).

5.2.1 Primeira geração: sistemas locais

Os primeiros sistemas operavam sobre arquivos individuais em uma única máquina. O SCCS (Source Code Control System), desenvolvido por Marc Rochkind nos Laboratórios Bell em 1972, e o RCS (Revision Control System), criado por Walter Tichy no início dos anos 1980, guardavam, para cada arquivo, uma sequência de diferenças (deltas) entre versões sucessivas. Funcionavam bem para um programador sozinho, mas não ofereciam suporte real à colaboração: era preciso “travar” (lock) o arquivo para editá-lo.

5.2.2 Segunda geração: sistemas centralizados

Para permitir que equipes trabalhassem juntas, surgiram os sistemas centralizados, como o CVS (Concurrent Versions System, por volta de 1990) e o Subversion (SVN, lançado em 2000). Neles existe um único servidor que guarda todo o histórico; cada desenvolvedor possui apenas uma cópia de trabalho (working copy) da versão mais recente.

Esse modelo tem vantagens administrativas (controle de acesso em um único lugar), mas também limitações sérias:

  • Ponto único de falha: se o servidor cai, ninguém consegue registrar alterações; se o disco do servidor se corrompe sem backup, o histórico inteiro é perdido.
  • Dependência de rede: consultar o histórico ou registrar uma versão exige conexão.
  • Ramificações caras: criar e mesclar ramificações (branches) era lento e propenso a erros, o que desencorajava a experimentação.

5.2.3 Terceira geração: sistemas distribuídos

Nos sistemas distribuídos (Distributed Version Control Systems, DVCS), cada cópia do repositório é um repositório completo, com todo o histórico. Não há, tecnicamente, um servidor “especial”: qualquer cópia pode servir de backup de qualquer outra, e a maioria das operações (consultar histórico, registrar versões, criar ramificações) é local e, portanto, quase instantânea.

O Git nasceu nesse contexto. Entre 2002 e 2005, o desenvolvimento do núcleo (kernel) do Linux utilizava o BitKeeper, um DVCS proprietário cedido gratuitamente à comunidade. Em abril de 2005, a licença gratuita foi revogada e Linus Torvalds, criador do Linux, decidiu escrever seu próprio sistema. Os objetivos de projeto eram explícitos (Chacon e Straub 2014):

  1. velocidade;
  2. design simples;
  3. suporte forte a desenvolvimento não linear (milhares de ramificações paralelas);
  4. ser totalmente distribuído;
  5. lidar eficientemente com projetos grandes como o kernel do Linux.

Em poucas semanas o Git já gerenciava o próprio código do Linux. A manutenção do projeto passou, ainda em 2005, para Junio Hamano, que a conduz até hoje. No mesmo ano surgiu o Mercurial, outro DVCS com filosofia semelhante. Em 2008 foi lançado o GitHub, que popularizou o Git ao oferecer hospedagem na nuvem com uma interface social de colaboração (adquirido pela Microsoft em 2018).

Característica Locais (RCS) Centralizados (SVN) Distribuídos (Git)
Onde fica o histórico Na máquina local Apenas no servidor Em todas as cópias
Trabalho offline Sim (sem colaboração) Limitado Completo
Colaboração Não Sim Sim
Custo de criar branches Alto Alto Muito baixo
Ponto único de falha Sim Sim Não

5.2.4 Por que o estatístico deve usar controle de versão?

Bryan (2018) argumenta que o Git, aliado ao GitHub, resolve três problemas recorrentes no trabalho estatístico: (i) a organização de um projeto em um diretório auto-contido, cuja evolução fica documentada; (ii) a colaboração, inclusive com o “eu do futuro”, que precisa entender por que uma decisão de modelagem foi tomada; e (iii) a publicação e a transparência, pois o repositório pode ser tornado público junto com o artigo ou relatório. Esses argumentos se conectam diretamente ao tema da reprodutibilidade, que será aprofundado no Capítulo de Documentos Dinâmicos com Markdown e RMarkdown.

Argumentos semelhantes aparecem em textos dirigidos a cientistas de outras áreas. Ram (2013) mostra como o Git facilita a transparência em pesquisas de ecologia e biologia, registrando cada decisão analítica com data, autor e justificativa; Blischak, Davenport, e Wilson (2016) oferecem uma introdução curta voltada a pesquisadores sem formação em computação; e Perez-Riverol et al. (2016) reúnem dez regras práticas para aproveitar o Git e o GitHub em projetos científicos, desde a documentação até a citação de software. Um ponto comum a todos esses trabalhos é a ideia de que o histórico de versões funciona como um caderno de laboratório (lab notebook) da análise: ele responde, anos depois, a perguntas como “qual versão do código gerou a Tabela 3 do relatório?” ou “quando passamos a excluir os questionários incompletos, e por quê?”.


5.3 O que é Git vs. GitHub?

É comum ver iniciantes confundirem Git e GitHub, tratando-os como se fossem o mesmo produto. No entanto, eles são ferramentas distintas que se complementam perfeitamente:

  • Git: É um projeto de código aberto, com manutenção ativa, focado em ser um Sistema de Controle de Versão Distribuído (SCVD ou DVCS) (Chacon e Straub 2014). Ao contrário dos sistemas tradicionais centralizados, no Git, cada desenvolvedor possui uma cópia completa de todo o histórico do repositório no seu próprio computador, o que permite trabalhar e salvar o progresso totalmente offline. O Git foi projetado pensando em desempenho, segurança e flexibilidade, e suas principais características incluem a ramificação eficiente (branching), mesclagem simples (merging) e um histórico de alterações completo e imutável. Ele roda localmente e guarda os instantâneos de todo o projeto em um diretório oculto chamado .git.
  • GitHub: É uma plataforma de hospedagem de repositórios Git baseada na nuvem. O GitHub permite que você compartilhe seus repositórios locais com a equipe (sincronizando o projeto que está no seu computador com a nuvem), colabore em projetos de código aberto, gerencie tarefas e publique páginas web diretamente. Existem plataformas alternativas equivalentes ao GitHub, como o GitLab e o Bitbucket.

Uma analogia útil: o Git está para o GitHub assim como o R está para um servidor que hospeda scripts de R. O Git funciona perfeitamente sem o GitHub (por exemplo, para versionar um projeto pessoal que nunca sairá do seu computador); o GitHub, por sua vez, é inútil sem o Git.


5.4 O Modelo de Dados do Git

Muitos usuários aprendem Git como uma lista de comandos “mágicos”. Essa abordagem funciona até o primeiro problema. Entender o modelo de dados do Git — como ele representa internamente o histórico — torna o comportamento dos comandos previsível. O modelo é surpreendentemente simples e elegante.

5.4.1 Instantâneos, não diferenças

Sistemas como RCS e SVN armazenam o histórico como um arquivo base acrescido de uma sequência de diferenças (deltas). O Git adota outra perspectiva: cada versão registrada (commit) é um instantâneo (snapshot) completo do projeto. Se um arquivo não mudou entre duas versões, o Git não o duplica; apenas aponta para o conteúdo já armazenado. Conceitualmente, então, o histórico é uma sequência de fotografias do diretório, e não uma lista de remendos.

Formalmente, seja \(\mathcal{F}\) o conjunto de caminhos de arquivos e \(\Sigma^*\) o conjunto de sequências de bytes. Um instantâneo pode ser visto como uma função parcial \[ S: \mathcal{F} \to \Sigma^*, \] que associa a cada caminho o conteúdo do arquivo naquele momento. A diferença (diff) entre duas versões não é armazenada: ela é calculada sob demanda comparando \(S_1\) e \(S_2\) (o Git armazena compactações por deltas apenas como otimização de espaço nos chamados packfiles, de forma transparente ao usuário).

5.4.2 Os quatro tipos de objetos

Tudo o que o Git guarda no diretório .git/objects é um objeto, de um de quatro tipos:

  1. Blob (binary large object): o conteúdo de um arquivo, sem nome e sem metadados. Dois arquivos com conteúdo idêntico, mesmo com nomes diferentes, correspondem ao mesmo blob.
  2. Tree (árvore): representa um diretório. É uma lista de entradas, cada uma com modo (permissões), tipo, nome e o identificador de um blob (arquivo) ou de outra tree (subdiretório).
  3. Commit: representa um instantâneo do projeto. Contém o identificador da tree raiz, os identificadores dos commits pais, o autor, o committer, as datas e a mensagem.
  4. Tag anotada: um rótulo permanente para um commit, com autor, data e mensagem (útil para marcar versões, como v1.0).

Por exemplo, um projeto com a estrutura

projeto/
|-- README.md
`-- R/
    `-- analise.R

é representado por um commit que aponta para uma tree raiz; esta contém uma entrada README.md (apontando para um blob) e uma entrada R (apontando para outra tree), que por sua vez contém analise.R (apontando para outro blob).

5.4.3 Endereçamento por conteúdo e funções hash

Como o Git dá nome a esses objetos? Usando uma função hash criptográfica. Uma função hash é uma função \[ h: \Sigma^* \to \{0,1\}^{m}, \] que mapeia sequências de bytes de tamanho arbitrário em cadeias de tamanho fixo \(m\). Historicamente o Git utiliza o SHA-1, com \(m = 160\) bits, representados como 40 dígitos hexadecimais (versões recentes oferecem também suporte experimental ao SHA-256, com \(m = 256\)). Boas funções hash criptográficas têm as propriedades:

  • Determinismo: a mesma entrada produz sempre a mesma saída.
  • Efeito avalanche: alterar um único bit da entrada muda, em média, metade dos bits da saída.
  • Resistência a colisões: é computacionalmente inviável encontrar \(x \neq y\) com \(h(x) = h(y)\).

O identificador de um objeto é o hash do seu conteúdo precedido de um cabeçalho com o tipo e o tamanho em bytes. Para um blob com conteúdo \(c\) de \(n\) bytes: \[ \operatorname{id}(c) = \operatorname{SHA1}(\texttt{blob} \,\|\, \texttt{SP} \,\|\, n \,\|\, \texttt{NUL} \,\|\, c), \] em que \(\|\) denota concatenação, \(\texttt{SP}\) é o caractere de espaço, \(n\) é escrito em decimal e \(\texttt{NUL}\) é o byte nulo (representado como \0 em muitas linguagens). Isso se chama endereçamento por conteúdo (content-addressable storage): o nome do objeto é função do seu conteúdo.

Exemplo resolvido. O arquivo cujo conteúdo é a palavra hello seguida de uma quebra de linha tem \(n = 6\) bytes. O Git calcula o SHA-1 da sequência blob 6\0hello\n. Podemos conferir de três formas:

# 1) Pelo próprio Git
echo 'hello' | git hash-object --stdin
#> ce013625030ba8dba906f756967f9e9ca394464a

# 2) Reproduzindo o cálculo "à mão" com o utilitário sha1sum
printf 'blob 6\0hello\n' | sha1sum
#> ce013625030ba8dba906f756967f9e9ca394464a  -

# 3) Um arquivo vazio (n = 0) tem sempre o mesmo identificador
git hash-object /dev/null
#> e69de29bb2d1d6434b8b29ae775ad8c2e48c5391

O mesmo cálculo em Python:

import hashlib

conteudo = b"hello\n"
cabecalho = f"blob {len(conteudo)}\0".encode()
print(hashlib.sha1(cabecalho + conteudo).hexdigest())
#> ce013625030ba8dba906f756967f9e9ca394464a

Consequências importantes desse desenho:

  • Integridade: como o commit contém o hash da tree, que contém os hashes dos blobs, e como cada commit contém o hash dos pais, qualquer alteração em qualquer arquivo de qualquer versão antiga mudaria todos os hashes subsequentes. O histórico é, portanto, à prova de adulteração silenciosa (a mesma ideia das árvores de Merkle (Merkle 1988), usadas também em sistemas de arquivos distribuídos e em blockchains).
  • Deduplicação: conteúdos idênticos são armazenados uma única vez.
  • Imutabilidade: objetos nunca são alterados; “modificar” um commit (como em git commit --amend) na verdade cria um novo commit, com novo hash.

Qual é a chance de colisão acidental? Supondo que os hashes se comportem como variáveis uniformes independentes em \(N = 2^{160}\) valores, a probabilidade de que entre \(k\) objetos ao menos dois coincidam é dada pelo clássico problema do aniversário: \[ P(\text{colisão}) = 1 - \prod_{i=0}^{k-1}\left(1 - \frac{i}{N}\right) \approx 1 - e^{-k(k-1)/(2N)} \approx \frac{k^2}{2N}, \quad \text{para } k^2 \ll N. \] Para um repositório gigantesco com \(k = 10^{7}\) objetos, temos \(k^2/(2N) = 10^{14}/2^{161} \approx 10^{14}/(2{,}9 \times 10^{48}) \approx 3{,}4 \times 10^{-35}\), uma probabilidade desprezível. (Ataques deliberados contra o SHA-1 foram demonstrados em 2017, quando pesquisadores produziram dois arquivos PDF distintos com o mesmo SHA-1 (Stevens et al. 2017), o que motivou a migração gradual do Git para o SHA-256 e a adoção de detecção de colisões; para colisões acidentais, porém, o risco é irrelevante.)

Na prática, raramente precisamos dos 40 dígitos: o Git aceita prefixos únicos, normalmente de 7 caracteres (por exemplo, ce01362).

5.4.4 O histórico como um grafo acíclico dirigido

Cada commit aponta para zero ou mais commits pais: o primeiro commit do repositório não tem pai; um commit comum tem um pai; um commit de mesclagem (merge) tem dois (ou mais) pais. O histórico forma, portanto, um grafo acíclico dirigido (Directed Acyclic Graph, DAG).

Definição (grafo de commits)

O histórico de um repositório é um grafo dirigido \(G = (V, E)\) em que \(V\) é o conjunto de commits e \((c, p) \in E\) se, e somente se, \(p\) é pai de \(c\). O grafo é acíclico: não existe sequência \(c_1 \to c_2 \to \cdots \to c_k \to c_1\). Dizemos que \(a\) é ancestral de \(b\), e escrevemos \(a \preceq b\), se existe um caminho dirigido (possivelmente de comprimento zero) de \(b\) até \(a\).

Por que o grafo é necessariamente acíclico? Porque o hash de um commit depende dos hashes dos pais. Para que \(c\) fosse ancestral de si mesmo, seria preciso conhecer o hash de \(c\) antes de calculá-lo, o que é impossível (a menos de uma colisão). A relação \(\preceq\) é uma ordem parcial (reflexiva, antissimétrica e transitiva) sobre os commits.

Um conceito central para a mesclagem é o de ancestral comum mais recente, ou merge base: dados commits \(a\) e \(b\), a base de mesclagem é um ancestral comum \(m\) (isto é, \(m \preceq a\) e \(m \preceq b\)) tal que nenhum outro ancestral comum é descendente de \(m\).

Exemplo resolvido. Considere o histórico abaixo, em que o tempo flui da esquerda para a direita e as letras são commits:

          D---E      (ramo: feature)
         /
A---B---C---F---G    (ramo: main)
  • Pais: \(\operatorname{pai}(B) = A\), \(\operatorname{pai}(C) = B\), \(\operatorname{pai}(D) = C\), \(\operatorname{pai}(F) = C\), e assim por diante.
  • Ancestrais de \(E\): \(\{E, D, C, B, A\}\). Ancestrais de \(G\): \(\{G, F, C, B, A\}\).
  • Ancestrais comuns: \(\{E, D, C, B, A\} \cap \{G, F, C, B, A\} = \{A, B, C\}\).
  • O mais recente deles é \(C\) (pois \(A \preceq C\) e \(B \preceq C\)). Logo, a merge base de main e feature é \(C\), o que pode ser conferido com git merge-base main feature.
  • Os commits que estão em feature mas não em main são \(\{E, D, C, B, A\} \setminus \{G, F, C, B, A\} = \{D, E\}\); é exatamente isso que git log main..feature lista.

5.4.5 Referências: branches, HEAD e tags

Hashes são difíceis de memorizar. Por isso o Git mantém referências (refs), que são simplesmente nomes que apontam para commits:

  • Um branch (ramo) é um ponteiro móvel para um commit. Criar um branch é criar um arquivo de 41 bytes contendo um hash, o que explica por que ramificar no Git é tão barato. Quando você faz um commit estando em um branch, o ponteiro avança automaticamente para o novo commit.
  • HEAD é uma referência especial que indica onde você está agora: normalmente aponta para um branch (por exemplo, main), que por sua vez aponta para um commit. Se HEAD apontar diretamente para um commit, dizemos que estamos em estado de detached HEAD (HEAD destacado), útil para inspecionar versões antigas, mas no qual commits novos ficam “órfãos” se não forem associados a um branch.
  • Uma tag é um ponteiro fixo para um commit (não avança).

O Git oferece uma notação para navegar no grafo a partir de qualquer referência: HEAD~1 (o pai de HEAD), HEAD~3 (o bisavô, seguindo sempre o primeiro pai), HEAD^2 (o segundo pai de um commit de mesclagem). Podemos inspecionar os objetos diretamente:

git cat-file -t HEAD          # tipo do objeto
#> commit
git cat-file -p HEAD          # conteúdo do commit
#> tree 9a3f1c...
#> parent 4b2e7d...
#> author Maria <maria@exemplo.com> 1718035200 -0300
#> committer Maria <maria@exemplo.com> 1718035200 -0300
#>
#> Adiciona modelo de regressão
git cat-file -p HEAD^{tree}   # conteúdo da tree raiz

5.4.6 Anatomia do diretório .git

Todo o estado de um repositório vive no diretório oculto .git. Logo após um git init, ele contém:

Entrada Conteúdo
HEAD Arquivo de texto que indica o ramo atual, por exemplo ref: refs/heads/main
config Configurações locais do repositório (remotos, ramos rastreados etc.)
objects/ O banco de objetos (blobs, trees, commits e tags)
refs/ As referências: refs/heads/ (ramos), refs/tags/ (tags) e refs/remotes/ (ramos remotos)
index Arquivo binário com a área de preparação
logs/ O reflog, histórico local dos movimentos de cada referência
hooks/ Scripts executados automaticamente em certos eventos (veja a seção sobre hooks)
info/, description Arquivos auxiliares (por exemplo, info/exclude, um .gitignore local não versionado)

Cada objeto solto é guardado em objects/xx/yyyy..., em que xx são os dois primeiros dígitos hexadecimais do hash e yyyy... os 38 restantes; o conteúdo é a sequência tipo SP tamanho NUL conteúdo comprimida com o algoritmo zlib (deflate). A divisão em subdiretórios evita pastas com milhões de arquivos, o que degradaria o desempenho de muitos sistemas de arquivos.

Exemplo resolvido: um commit construído “à mão”. Os comandos do dia a dia (add, commit) são chamados de comandos de porcelana (porcelain), pois formam a interface amigável. Por baixo deles há comandos de encanamento (plumbing), que manipulam diretamente os objetos. Usando apenas encanamento, podemos refazer passo a passo o que o git commit faz:

git init -b main exemplo && cd exemplo
echo 'hello' > ola.txt

# Passo 1: grava o blob no banco de objetos (-w = write)
git hash-object -w ola.txt
#> ce013625030ba8dba906f756967f9e9ca394464a
find .git/objects -type f
#> .git/objects/ce/013625030ba8dba906f756967f9e9ca394464a

# Passo 2: registra o arquivo no índice (área de preparação)
git update-index --add ola.txt
git ls-files --stage
#> 100644 ce013625030ba8dba906f756967f9e9ca394464a 0    ola.txt

# Passo 3: transforma o índice em um objeto tree
git write-tree
#> 9485ad0b08f47193779b249f418c77a7df899642
git cat-file -p 9485ad0
#> 100644 blob ce013625030ba8dba906f756967f9e9ca394464a ola.txt

# Passo 4: cria o objeto commit apontando para a tree
c=$(echo "Primeiro commit feito à mão" | git commit-tree 9485ad0)

# Passo 5: move o ramo main para o novo commit
git update-ref refs/heads/main $c
git log --oneline
#> 57e16c9 Primeiro commit feito à mão
cat .git/HEAD
#> ref: refs/heads/main

Observe que os hashes do blob e da tree são os mesmos em qualquer computador (eles dependem apenas do conteúdo, do nome e das permissões do arquivo), enquanto o hash do commit varia, pois inclui autor e data. Podemos ainda abrir o objeto solto e conferir que ele é exatamente o cabeçalho mais o conteúdo, comprimidos:

import zlib

caminho = ".git/objects/ce/013625030ba8dba906f756967f9e9ca394464a"
with open(caminho, "rb") as f:
    print(zlib.decompress(f.read()))
#> b'blob 6\x00hello\n'

5.4.7 Armazenamento físico: objetos soltos e packfiles

Se cada commit é um instantâneo completo, um arquivo de dados de 300 KB modificado em 100 commits ocuparia 30 MB? Não. Conceitualmente o Git guarda instantâneos, mas fisicamente ele aplica duas camadas de economia:

  1. Compressão individual: cada objeto solto é comprimido com zlib.
  2. Compressão por deltas em packfiles: periodicamente (ou ao executar git gc, e sempre que dados são enviados pela rede), o Git reúne os objetos em um único arquivo .pack, acompanhado de um índice .idx. Dentro do pacote, objetos semelhantes são armazenados como uma base completa mais deltas (instruções do tipo “copie os bytes de \(i\) a \(j\) da base” e “insira estes bytes novos”).

A escolha das bases é heurística: o Git ordena os objetos por tipo, nome de arquivo e tamanho, e tenta comprimir cada um contra os vizinhos dentro de uma janela deslizante (por padrão, 10 objetos), aceitando cadeias de deltas de profundidade limitada (por padrão, 50). Uma escolha interessante é que, em geral, a versão mais recente é guardada completa e as antigas como deltas, pois é a mais acessada.

Exemplo resolvido. Criamos um arquivo dados.csv com 20 000 linhas (cerca de 279 KB) e fazemos cinco commits, acrescentando uma linha por vez:

git count-objects -vH       # antes da compactação
#> count: 15                # 5 commits + 5 trees + 5 blobs
#> size: 680.00 KiB         # objetos soltos, cada um comprimido com zlib
git gc                      # compacta em um packfile
git count-objects -vH
#> in-pack: 15
#> size-pack: 119.33 KiB
git verify-pack -v .git/objects/pack/*.idx | grep blob
#> 907de7e... blob 279490 119601 718
#> dc9a4c6... blob 17 30 120415 1 907de7e...
#> 4246a6c... blob 17 30 120493 1 907de7e...
#> bb4d808... blob 17 30 120571 1 907de7e...
#> 1c2cdb4... blob 17 30 120649 1 907de7e...

As colunas de verify-pack são: hash, tipo, tamanho (do objeto ou do delta), tamanho ocupado no pacote, posição no arquivo e, para deltas, a profundidade da cadeia e o objeto base. A versão mais recente (279 490 bytes) foi guardada completa (119 601 bytes após compressão), e cada uma das quatro versões anteriores virou um delta de apenas 17 bytes em relação a ela. O armazenamento caiu de 680 KiB para cerca de 119 KiB, uma razão de compressão de aproximadamente \(680/119{,}3 \approx 5{,}7\).

Por que arquivos binários grandes são problemáticos

A compressão por deltas funciona muito bem para texto que muda pouco entre versões. Para arquivos binários já comprimidos (.zip, .parquet, .xlsx, .png, modelos serializados), uma pequena mudança no conteúdo lógico altera quase todos os bytes do arquivo, os deltas se tornam praticamente do tamanho do original e cada versão é armazenada quase integralmente. Como todo clone contém todo o histórico, o tamanho do repositório cresce aproximadamente como \(\sum_{v} |\text{versão}_v|\), e cada colaborador paga esse custo. Veremos alternativas na seção sobre versionamento de dados.


5.5 Os Três Estados do Git no Fluxo de Trabalho Local

Para dominar o Git, você precisa entender que os arquivos dentro do seu diretório de projeto se movem entre três áreas lógicas distintas de controle local:

Estados e áreas de controle local no fluxo do Git
  1. Diretório de Trabalho (Working Directory): É a pasta física do seu computador onde você está criando e editando seus arquivos de código. As alterações feitas aqui ainda não estão registradas no histórico do Git.
  2. Área de Preparação (Staging Area / Index): É um arquivo temporário que lista quais arquivos modificados você deseja incluir no seu próximo registro do histórico. Você seleciona o que vai para o Staging usando o comando git add.
  3. Repositório Local (Local Repository): É onde o Git guarda os instantâneos consolidados e históricos do seu projeto em formato comprimido. O registro é efetivado quando você executa o comando git commit.

Do ponto de vista de cada arquivo individual, existe também um ciclo de vida:

  • Não rastreado (untracked): o arquivo existe na pasta, mas o Git nunca o registrou.
  • Não modificado (unmodified): idêntico à versão do último commit.
  • Modificado (modified): alterado no diretório de trabalho, mas ainda não preparado.
  • Preparado (staged): a versão atual foi copiada para a área de preparação e entrará no próximo commit.

Por que existe uma área de preparação intermediária? Ela permite construir commits atômicos, isto é, commits que agrupam apenas alterações logicamente relacionadas. Se você corrigiu um erro na função de limpeza dos dados e, ao mesmo tempo, começou a escrever um novo gráfico, pode preparar e registrar apenas a correção, deixando o gráfico para um commit posterior. Com git add -p é possível até selecionar trechos (hunks) específicos de um arquivo.

5.5.1 As três árvores e o que o git status realmente calcula

Uma forma precisa de pensar nas três áreas é tratá-las como três instantâneos no sentido da função \(S: \mathcal{F} \to \Sigma^*\) definida anteriormente:

  • \(S_{\text{HEAD}}\): o instantâneo do commit para o qual HEAD aponta;
  • \(S_{\text{índice}}\): o instantâneo que seria gravado se fizéssemos git commit agora (o índice guarda, para cada caminho, o modo, o hash do blob e informações do sistema de arquivos, como data de modificação e tamanho, usadas para detectar alterações rapidamente);
  • \(S_{\text{trab}}\): o conteúdo atual dos arquivos no disco.

O git status nada mais é do que o resultado de duas comparações: \(S_{\text{HEAD}}\) versus \(S_{\text{índice}}\) (as alterações “preparadas para o commit”, primeira coluna de git status -s) e \(S_{\text{índice}}\) versus \(S_{\text{trab}}\) (as alterações “não preparadas”, segunda coluna). Os comandos se interpretam, então, como cópias entre esses instantâneos:

Comando Efeito sobre os instantâneos
git add f \(S_{\text{índice}}(f) \leftarrow S_{\text{trab}}(f)\)
git commit Cria um commit com \(S_{\text{índice}}\) e avança o ramo: \(S_{\text{HEAD}} \leftarrow S_{\text{índice}}\)
git restore f \(S_{\text{trab}}(f) \leftarrow S_{\text{índice}}(f)\)
git restore --staged f \(S_{\text{índice}}(f) \leftarrow S_{\text{HEAD}}(f)\)
git switch ramo Os três instantâneos passam a refletir a ponta de ramo

Após um commit bem-sucedido, sem novas edições, vale \(S_{\text{HEAD}} = S_{\text{índice}} = S_{\text{trab}}\) (para os arquivos rastreados), e o git status informa nothing to commit, working tree clean.


5.6 Configuração Inicial

Antes do primeiro commit, o Git precisa saber quem você é, pois nome e e-mail são gravados em cada commit. A configuração é feita uma única vez por computador:

# Identidade (use o mesmo e-mail cadastrado no GitHub)
git config --global user.name "Maria da Silva"
git config --global user.email "maria.silva@exemplo.com"

# Nome padrão do ramo inicial em novos repositórios
git config --global init.defaultBranch main

# Editor de texto padrão para mensagens de commit
git config --global core.editor "nano"

# Conferindo todas as configurações e de onde cada uma vem
git config --list --show-origin

As configurações existem em três níveis, do mais geral ao mais específico: --system (todos os usuários da máquina), --global (seu usuário, arquivo ~/.gitconfig) e --local (apenas o repositório atual, arquivo .git/config). O nível mais específico prevalece.

Quebras de linha entre sistemas operacionais

Windows usa a sequência CRLF para finalizar linhas, enquanto Linux e macOS usam LF. Em equipes mistas, configure git config --global core.autocrlf true no Windows e git config --global core.autocrlf input no Linux/macOS, evitando que todas as linhas de um arquivo apareçam como “modificadas”.


5.7 Comandos Fundamentais do Git

5.7.1 1. Inicializando e Clonando Repositórios

Para criar um novo repositório local a partir de uma pasta existente no computador:

git init

Esse comando cria o diretório oculto .git, que contém o banco de objetos, as referências e as configurações do repositório. Apagar o .git significa apagar todo o histórico (os arquivos do diretório de trabalho permanecem).

Para clonar (baixar) um repositório existente na nuvem (como do GitHub) para a sua máquina local:

git clone https://github.com/usuario/nome-do-repositorio.git

O clone copia todo o histórico, e não apenas a versão mais recente, e configura automaticamente um repositório remoto chamado origin.

5.7.2 2. Rastreando e Preparando Arquivos

Para verificar o status atual dos arquivos no seu projeto (mostrando quais foram editados, excluídos ou que não estão sendo rastreados):

git status

Uma versão resumida, muito usada no dia a dia, é git status -s, que mostra duas colunas de códigos: a primeira refere-se à área de preparação e a segunda ao diretório de trabalho:

git status -s
#>  M R/limpeza.R        # modificado, ainda não preparado
#> M  R/modelo.R         # modificado e preparado
#> A  R/graficos.R       # arquivo novo, preparado
#> ?? dados/brutos.csv   # não rastreado

Para mover um arquivo modificado ou novo para a área de preparação (Staging Area):

# Adiciona um arquivo específico
git add analise.R

# Adiciona todos os arquivos modificados da pasta atual recursivamente
git add .

5.7.3 3. Gravando Alterações

Para registrar definitivamente as alterações da área de preparação no histórico do repositório local, sempre acompanhado de uma mensagem clara que descreve o que foi feito:

git commit -m "Adiciona script de análise de regressão linear para os dados de vendas"

Internamente, o git commit (i) cria blobs para os conteúdos preparados que ainda não existem no banco de objetos, (ii) cria as trees correspondentes aos diretórios, (iii) cria um objeto commit cujo pai é o commit atual de HEAD e (iv) move o branch atual para o novo commit.

5.7.4 4. Sincronizando com o GitHub (Remoto)

Para enviar seus registros históricos locais para o repositório hospedado no GitHub:

git push origin main

Para baixar e integrar as últimas alterações que seus colegas de equipe enviaram para o GitHub diretamente no seu repositório local:

git pull origin main

Esses comandos serão detalhados na seção sobre repositórios remotos.

5.7.5 5. Ignorando Arquivos com .gitignore

Nem todo arquivo deve ser versionado. Em projetos de ciência de dados, costumamos excluir do controle de versão:

  • arquivos gerados automaticamente (gráficos exportados, relatórios compilados, .Rhistory, __pycache__/);
  • dados muito grandes ou que podem ser baixados novamente de uma fonte oficial;
  • dados sensíveis (informações pessoais protegidas pela LGPD) e segredos (senhas, chaves de API);
  • configurações pessoais do editor.

Para isso, criamos na raiz do projeto um arquivo de texto chamado .gitignore, com um padrão por linha:

# Arquivos do R e do RStudio
.Rhistory
.RData
.Rproj.user/

# Python
__pycache__/
*.pyc
.ipynb_checkpoints/
.venv/

# Dados brutos grandes e saídas geradas
dados/brutos/
*.parquet
saidas/*.png

# Segredos
.env

# Exceção: versionar este arquivo apesar da regra acima
!saidas/logo.png

Os padrões usam curingas (globs): * casa com qualquer sequência de caracteres exceto /, ** casa com diretórios aninhados, uma barra final indica diretório e ! nega uma regra anterior. Atenção: o .gitignore só afeta arquivos não rastreados. Se um arquivo já foi commitado, é preciso removê-lo do índice com git rm --cached arquivo.

Segredos no histórico

Se uma senha ou chave de API for commitada e enviada ao GitHub, considere-a comprometida, mesmo que você a apague em um commit seguinte: ela continua no histórico (lembre-se de que cada commit é um instantâneo completo e imutável). A atitude correta é revogar a credencial imediatamente e gerar uma nova.

5.7.6 6. Inspecionando o Histórico e as Diferenças

O comando git log lista os commits a partir de HEAD, do mais recente ao mais antigo:

git log                          # forma completa
git log --oneline                # uma linha por commit (hash curto + título)
git log --oneline --graph --all  # desenha o grafo de todos os ramos
git log -p R/modelo.R            # histórico de um arquivo, com as diferenças
git log --author="Maria" --since="2 weeks ago"
git log -S "lm("                 # commits que adicionaram/removeram o texto "lm("

O comando git diff compara versões. É fundamental entender o que está sendo comparado em cada forma:

Comando Compara
git diff Diretório de trabalho \(\times\) área de preparação (o que ainda não foi preparado)
git diff --staged Área de preparação \(\times\) último commit (o que entrará no próximo commit)
git diff HEAD Diretório de trabalho \(\times\) último commit
git diff abc123 def456 Dois commits quaisquer
git diff main..feature As pontas de dois ramos

A saída segue o formato unificado (unified diff): linhas iniciadas por - foram removidas, linhas iniciadas por + foram adicionadas, e o cabeçalho @@ -10,4 +10,5 @@ indica que o trecho começa na linha 10, tinha 4 linhas na versão antiga e tem 5 na nova.

diff --git a/R/modelo.R b/R/modelo.R
@@ -10,4 +10,5 @@
 dados <- read.csv("dados/vendas.csv")
-modelo <- lm(vendas ~ preco, data = dados)
+modelo <- lm(vendas ~ preco + regiao, data = dados)
+summary(modelo)

O cálculo do diff é, ele próprio, um problema algorítmico clássico: encontrar a maior subsequência comum (longest common subsequence) entre as listas de linhas das duas versões; as linhas fora dela são as removidas e adicionadas. O Git usa por padrão o algoritmo de Myers (Myers 1986), que encontra uma sequência mínima de edições. Estudaremos esse problema em detalhe na seção “Como o Git Calcula Diferenças”.

Por fim, git show <commit> exibe a mensagem e as alterações de um commit específico, e git blame arquivo mostra, para cada linha, o último commit que a alterou (útil para descobrir por que uma linha existe).

5.7.7 7. Movendo e Removendo Arquivos

git mv analise.R R/analise.R   # renomeia/move e já prepara a alteração
git rm arquivo_antigo.R        # remove do diretório e do índice
git rm --cached dados.csv      # deixa de rastrear, mas mantém o arquivo no disco

O Git não registra renomeações explicitamente: um git mv equivale a apagar um caminho e criar outro com o mesmo blob. Ao exibir o histórico, o Git infere a renomeação comparando o conteúdo dos arquivos removidos e adicionados; se a similaridade for de pelo menos 50% (limiar padrão), o par é apresentado como renomeação. Para seguir o histórico de um arquivo através de renomeações, use git log --follow R/analise.R.


5.8 Como o Git Calcula Diferenças

Toda vez que executamos git diff, git log -p ou abrimos um pull request, o Git resolve um problema de otimização combinatória. Entender esse problema ajuda a interpretar diffs “estranhos” e é um belo exemplo de algoritmo clássico com uso cotidiano.

5.8.1 O problema da maior subsequência comum

Definição (subsequência comum e script de edição)

Sejam \(a = (a_1, \ldots, a_N)\) e \(b = (b_1, \ldots, b_M)\) duas sequências (no diff, cada elemento é uma linha do arquivo). Uma subsequência de \(a\) é obtida apagando zero ou mais elementos de \(a\) sem alterar a ordem dos restantes. Uma subsequência comum de \(a\) e \(b\) é uma sequência que é subsequência de ambas; denotamos por \(L = |\operatorname{LCS}(a, b)|\) o comprimento da maior delas. Um script de edição é uma sequência de operações de remoção (de um elemento de \(a\)) e inserção (de um elemento de \(b\)) que transforma \(a\) em \(b\).

Os dois conceitos são duais: os elementos mantidos por um script de edição formam uma subsequência comum, e vice-versa. Se mantemos \(L\) elementos, precisamos remover os outros \(N - L\) elementos de \(a\) e inserir os \(M - L\) elementos de \(b\) que faltam. Portanto, o número mínimo de edições é \[ D = (N - L) + (M - L) = N + M - 2L, \] e minimizar \(D\) equivale a maximizar \(L\).

O comprimento \(L\) pode ser calculado por programação dinâmica. Seja \(L(i, j)\) o comprimento da maior subsequência comum entre os prefixos \((a_1, \ldots, a_i)\) e \((b_1, \ldots, b_j)\). Então \[ L(i, j) = \begin{cases} 0, & \text{se } i = 0 \text{ ou } j = 0,\\ L(i-1, j-1) + 1, & \text{se } a_i = b_j,\\ \max\{L(i-1, j),\, L(i, j-1)\}, & \text{se } a_i \neq b_j. \end{cases} \] A justificativa é a seguinte: se os últimos elementos coincidem, vale a pena emparelhá-los; caso contrário, ao menos um deles fica fora da subsequência ótima, e testamos as duas possibilidades. O algoritmo preenche uma tabela de \((N+1) \times (M+1)\) células, com custo de tempo e de memória \(O(NM)\).

Exemplo resolvido. Usaremos o exemplo clássico do artigo de Myers (1986), com \(a = \texttt{ABCABBA}\) (\(N = 7\)) e \(b = \texttt{CBABAC}\) (\(M = 6\)), em que cada letra representa uma linha de um arquivo. A tabela \(L(i, j)\) é:

\(a_i \backslash b_j\) \(\varnothing\) C B A B A C
\(\varnothing\) 0 0 0 0 0 0 0
A 0 0 0 1 1 1 1
B 0 0 1 1 2 2 2
C 0 1 1 1 2 2 3
A 0 1 1 2 2 3 3
B 0 1 2 2 3 3 3
B 0 1 2 2 3 3 3
A 0 1 2 3 3 4 4
  1. O canto inferior direito dá \(L = L(7, 6) = 4\).
  2. Logo, \(D = 7 + 6 - 2 \times 4 = 5\) edições: \(7 - 4 = 3\) remoções e \(6 - 4 = 2\) inserções.
  3. Percorrendo a tabela de trás para frente (subindo na diagonal quando \(a_i = b_j\), e caso contrário indo para a célula vizinha de maior valor), recuperamos uma subsequência comum máxima, por exemplo \(\texttt{CBBA}\), e o script de edição correspondente.

A maior subsequência comum não é única: \(\texttt{CABA}\), \(\texttt{BABA}\) e \(\texttt{CBBA}\) têm todas comprimento 4. Por isso, dois algoritmos corretos podem produzir diffs diferentes (ambos mínimos) para o mesmo par de arquivos. O código abaixo implementa a recorrência e a reconstrução:

def lcs(a, b):
    n, m = len(a), len(b)
    L = [[0] * (m + 1) for _ in range(n + 1)]
    for i in range(1, n + 1):
        for j in range(1, m + 1):
            if a[i - 1] == b[j - 1]:
                L[i][j] = L[i - 1][j - 1] + 1
            else:
                L[i][j] = max(L[i - 1][j], L[i][j - 1])
    # Reconstrução do script de edição a partir do canto (n, m)
    i, j, script = n, m, []
    while i > 0 or j > 0:
        if i > 0 and j > 0 and a[i - 1] == b[j - 1]:
            script.append(" " + a[i - 1])
            i, j = i - 1, j - 1
        elif j > 0 and (i == 0 or L[i][j - 1] >= L[i - 1][j]):
            script.append("+" + b[j - 1])
            j -= 1
        else:
            script.append("-" + a[i - 1])
            i -= 1
    return L[n][m], script[::-1]

L, script = lcs(list("ABCABBA"), list("CBABAC"))
print(L, " ".join(script))
#> 4 -A -B  C -A  B +A  B  A +C

Podemos conferir com o próprio Git, gravando uma letra por linha em um arquivo versionado:

printf 'A\nB\nC\nA\nB\nB\nA\n' > s.txt
git add s.txt && git commit -m "Versão a"
printf 'C\nB\nA\nB\nA\nC\n' > s.txt
git diff
#> @@ -1,7 +1,6 @@
#> -A
#> -B
#>  C
#> -A
#>  B
#> +A
#>  B
#>  A
#> +C

O Git encontrou exatamente o mesmo script: 3 remoções, 2 inserções e a subsequência comum \(\texttt{CBBA}\) preservada.

5.8.2 O algoritmo de Myers

Para arquivos com dezenas de milhares de linhas, uma tabela \(N \times M\) é cara demais. Myers (1986) reformulou o problema como um caminho mínimo em um grafo de edição: os vértices são os pontos \((x, y)\) com \(0 \le x \le N\) e \(0 \le y \le M\); uma aresta horizontal \((x-1, y) \to (x, y)\) representa remover \(a_x\) e custa 1; uma aresta vertical \((x, y-1) \to (x, y)\) representa inserir \(b_y\) e custa 1; e, quando \(a_x = b_y\), existe uma aresta diagonal \((x-1, y-1) \to (x, y)\) de custo 0 (manter a linha). Um script de edição mínimo corresponde a um caminho de custo mínimo de \((0, 0)\) até \((N, M)\), e esse custo é justamente \(D\).

A ideia central é explorar o grafo por número de edições: para \(d = 0, 1, 2, \ldots\), o algoritmo calcula, em cada diagonal \(k = x - y\) (com \(k \in \{-d, -d+2, \ldots, d\}\)), o ponto mais distante alcançável com exatamente \(d\) edições, seguindo gulosamente as sequências de diagonais gratuitas (chamadas de “cobras”, snakes). O processo termina no primeiro \(d\) em que o ponto \((N, M)\) é atingido, o que garante a minimalidade. O custo é \(O((N + M)\,D)\): quando os dois arquivos são parecidos (\(D\) pequeno, o caso típico de um commit), o algoritmo é praticamente linear no tamanho dos arquivos. Uma variante refinada do mesmo artigo usa apenas memória \(O(N + M)\).

Algoritmo Custo de tempo Observação
Programação dinâmica (LCS) \(O(NM)\) Simples, mas inviável para arquivos grandes
Hunt-McIlroy (Hunt e McIlroy 1976) Depende do número de pares de linhas iguais Base do diff original do Unix
Myers (Myers 1986) \(O((N+M)D)\) Padrão do Git; ótimo quando \(D\) é pequeno
Patience e histogram Heurísticos Ancoram o diff em linhas únicas; diffs mais legíveis em código

O Git permite escolher o algoritmo com git diff --diff-algorithm=patience (ou histogram, minimal, myers). Os resultados são todos diffs corretos, mas não necessariamente iguais: em código com muitas linhas repetidas (como } ou linhas em branco), as variantes patience e histogram costumam alinhar melhor as funções e produzir diffs mais fáceis de revisar.

Diffs de dados tabulares e de texto corrido

O diff do Git trabalha linha a linha. Isso tem duas consequências práticas: (i) em um arquivo CSV, alterar um único valor marca a linha inteira como modificada, e ordenar o arquivo de outra forma gera um diff gigantesco, sem nenhuma mudança real nos dados; mantenha uma ordenação estável (por exemplo, pela chave primária) ao salvar dados versionados; (ii) em textos (.qmd, .md), escrever uma frase por linha produz diffs muito mais precisos do que parágrafos inteiros em uma única linha. Para textos, git diff --word-diff mostra as diferenças palavra a palavra.


5.9 Ramificações (Branches)

Um branch permite desenvolver uma linha de trabalho isolada: testar um novo modelo, reescrever a limpeza dos dados ou redigir uma seção do relatório sem afetar a versão estável que está em main. Como vimos, um branch é apenas um ponteiro, de modo que criá-lo é instantâneo.

git branch                      # lista os ramos locais (* marca o atual)
git branch modelo-xgboost       # cria um ramo apontando para o commit atual
git switch modelo-xgboost       # muda para o ramo (move HEAD)
git switch -c limpeza-outliers  # cria e muda em um único passo
git branch -d modelo-xgboost    # apaga um ramo já mesclado
git branch -D experimento       # força a remoção de um ramo não mesclado
checkout, switch e restore

Em versões antigas do Git, o comando git checkout era usado tanto para trocar de ramo quanto para descartar alterações em arquivos, o que confundia iniciantes. Desde a versão 2.23 (2019) existem dois comandos separados e mais seguros: git switch (trocar de ramo) e git restore (restaurar arquivos). O checkout continua funcionando e é encontrado em muitos tutoriais.

Ao trocar de ramo, o Git atualiza o diretório de trabalho para refletir o instantâneo do commit para o qual o novo ramo aponta. Se houver alterações não commitadas que conflitem com essa troca, o Git se recusa a prosseguir. Nesses casos, pode-se guardar temporariamente o trabalho com git stash:

git stash push -m "gráfico pela metade"  # guarda as alterações e limpa o diretório
git switch main
# ... corrige algo urgente em main ...
git switch modelo-xgboost
git stash pop                            # reaplica as alterações guardadas

5.10 Mesclagem (Merge)

Mesclar é incorporar o trabalho de um ramo em outro. Estando no ramo que vai receber as alterações, executamos:

git switch main
git merge limpeza-outliers

Há dois cenários possíveis, que dependem da forma do grafo.

5.10.1 Mesclagem por avanço rápido (fast-forward)

Se o ramo atual for ancestral do ramo a ser mesclado — isto é, se main não recebeu nenhum commit desde que limpeza-outliers foi criado —, basta mover o ponteiro main para frente. Nenhum commit novo é criado:

Antes:                         Depois de git merge limpeza-outliers:
A---B---C  (main)              A---B---C---D---E  (main, limpeza-outliers)
         \
          D---E  (limpeza-outliers)

Em termos da ordem parcial: se \(\texttt{main} \preceq \texttt{feature}\), então a mesclagem é um fast-forward.

5.10.2 Mesclagem de três vias (three-way merge)

Se ambos os ramos avançaram desde a bifurcação, não basta mover um ponteiro. O Git realiza uma mesclagem de três vias, usando três instantâneos: a base \(B\) (o ancestral comum mais recente), a ponta do ramo atual \(O\) (de ours, “nosso”) e a ponta do outro ramo \(T\) (de theirs, “deles”). O resultado é um novo commit de mesclagem com dois pais:

Antes:                         Depois de git merge feature:
          D---E  (feature)               D---E
         /                              /     \
A---B---C---F---G  (main)      A---B---C---F---G---M  (main)

A regra de decisão é aplicada para cada arquivo (e, dentro de cada arquivo, para cada região de linhas). Denotando por \(x_B\), \(x_O\) e \(x_T\) o conteúdo de uma região nas três versões:

\[ \operatorname{merge}(x_B, x_O, x_T) = \begin{cases} x_O, & \text{se } x_O = x_T \text{ (ambos iguais)},\\ x_T, & \text{se } x_O = x_B \text{ (só eles mudaram)},\\ x_O, & \text{se } x_T = x_B \text{ (só nós mudamos)},\\ \text{conflito}, & \text{caso contrário.} \end{cases} \]

A intuição é clara: a base permite saber quem mudou o quê. Sem ela (numa mesclagem de “duas vias”), seria impossível distinguir “eu apaguei esta linha” de “você adicionou esta linha”.

Exemplo resolvido. Considere um arquivo parametros.R com três linhas, nas três versões:

Linha Base (\(B\)) Nosso (\(O\), main) Deles (\(T\), feature) Resultado
1 n_arvores <- 100 n_arvores <- 100 n_arvores <- 500 n_arvores <- 500 (só eles mudaram)
2 semente <- 42 semente <- 42 semente <- 42 semente <- 42 (ninguém mudou)
3 prof_max <- 5 prof_max <- 10 prof_max <- 8 conflito (ambos mudaram de modo diferente)

As linhas 1 e 2 são resolvidas automaticamente; a linha 3 exige decisão humana.

Na prática: regiões, e não linhas isoladas

O Git não aplica a regra linha por linha, mas sim a regiões (hunks) obtidas dos diffs \(B \to O\) e \(B \to T\). Alterações em linhas adjacentes formam uma única região. Se, no exemplo, a linha semente <- 42 não separasse as duas alterações (isto é, se n_arvores e prof_max fossem linhas vizinhas), as duas mudanças cairiam na mesma região e o Git marcaria ambas como conflito, mesmo que a linha de n_arvores só tenha mudado de um lado. Para ver também a versão da base dentro dos marcadores de conflito, use o estilo diff3:

git config --global merge.conflictStyle diff3

Com ele, o conflito do arquivo com as linhas vizinhas aparece assim, com a base entre ||||||| e =======:

semente <- 42
<<<<<<< HEAD
n_arvores <- 100
prof_max <- 10
||||||| 78196a5
n_arvores <- 100
prof_max <- 5
=======
n_arvores <- 500
prof_max <- 8
>>>>>>> feature

Ver a base torna a decisão muito mais fácil: percebe-se imediatamente que n_arvores só foi alterado pelo outro ramo.

5.10.3 Como o Git encontra a base de mesclagem

Para calcular a base, o Git percorre o grafo a partir das duas pontas, marcando os commits alcançáveis a partir de cada uma (uma busca em largura que avança preferencialmente pelos commits mais recentes). Os commits marcados pelos dois lados são os ancestrais comuns; dentre eles, ficam os que não são ancestrais de outro ancestral comum. Formalmente, sendo \(A(x) = \{c : c \preceq x\}\) o conjunto de ancestrais de \(x\), \[ \operatorname{bases}(a, b) = \{\, m \in A(a) \cap A(b) \;:\; \nexists\, m' \in A(a) \cap A(b),\ m' \neq m,\ m \preceq m' \,\}. \]

Na maioria dos históricos esse conjunto tem um único elemento. Porém, em históricos com mesclagens cruzadas (criss-cross merges), em que dois ramos se mesclaram mutuamente, pode haver duas ou mais bases igualmente “recentes”, pois \(\preceq\) é apenas uma ordem parcial. O comando git merge-base --all a b lista todas. Nesse caso, a estratégia padrão do Git, chamada ort (padrão desde a versão 2.34, sucessora da estratégia recursive), primeiro mescla as bases entre si, gerando uma base “virtual”, e usa essa base virtual na mesclagem de três vias.

Durante um conflito, o índice passa a guardar três versões de cada arquivo conflitante, em “estágios” numerados: 1 (base), 2 (nosso) e 3 (deles). É possível inspecioná-las:

git ls-files -u                      # lista os estágios dos arquivos em conflito
#> 100644 ae4606c... 1  parametros.R
#> 100644 f07da72... 2  parametros.R
#> 100644 eb6626e... 3  parametros.R
git show :1:parametros.R | tail -1   # versão da base
#> prof_max <- 5
git show :2:parametros.R | tail -1   # nossa versão
#> prof_max <- 10
git show :3:parametros.R | tail -1   # versão deles
#> prof_max <- 8

Quando se quer adotar integralmente um dos lados para um arquivo, usa-se git checkout --ours arquivo ou git checkout --theirs arquivo, seguido de git add.


5.11 Conflitos e Como Resolvê-los

Um conflito de mesclagem ocorre quando os dois ramos alteraram a mesma região de um arquivo de maneiras diferentes (ou quando um ramo alterou um arquivo que o outro apagou). O Git interrompe a mesclagem e marca o arquivo assim:

n_arvores <- 500
semente <- 42
<<<<<<< HEAD
prof_max <- 10
=======
prof_max <- 8
>>>>>>> feature

O trecho entre <<<<<<< e ======= é a nossa versão (de HEAD); o trecho entre ======= e >>>>>>> é a versão do outro ramo. Para resolver:

  1. Execute git status para ver quais arquivos estão em conflito (both modified).
  2. Abra cada arquivo, decida o conteúdo correto (pode ser uma das versões, uma combinação ou algo novo) e apague os marcadores.
  3. Prepare o arquivo resolvido com git add parametros.R.
  4. Conclua com git commit (o Git sugere uma mensagem padrão de mesclagem).

Se preferir desistir e voltar ao estado anterior à mesclagem, use git merge --abort.

Prevenindo conflitos

Conflitos não são erros: são o Git pedindo uma decisão que ele não pode tomar sozinho. Ainda assim, podem ser minimizados: (i) faça git pull com frequência; (ii) mantenha ramos de vida curta; (iii) faça commits pequenos e focados; (iv) combine com a equipe quem edita quais arquivos; (v) evite reformatar arquivos inteiros (mudar indentação ou quebras de linha) em ramos de funcionalidade. Em arquivos texto como .R, .py e .qmd, conflitos são fáceis de resolver; em arquivos binários (.docx, .xlsx, .RData) o Git não consegue mesclar linhas, mais um motivo para preferir formatos de texto plano.


5.12 Rebase: Reescrevendo a Base de um Ramo

O rebase é uma alternativa à mesclagem para integrar alterações. Em vez de criar um commit de mesclagem, o rebase reaplica os commits do ramo atual, um a um, sobre a ponta de outro ramo:

Antes:                               Depois de (em feature) git rebase main:
          D---E  (feature)                           D'---E'  (feature)
         /                                          /
A---B---C---F---G  (main)            A---B---C---F---G  (main)

Os commits \(D'\) e \(E'\) contêm as mesmas alterações que \(D\) e \(E\), mas são commits novos, com hashes diferentes, pois têm pais diferentes (lembre-se: o hash do commit depende do hash do pai). Após o rebase, main é ancestral de feature, e uma mesclagem posterior será um simples fast-forward, produzindo um histórico linear.

Aspecto merge rebase
Histórico resultante Preserva a topologia real (bifurcações) Linear, mais fácil de ler
Cria commit de mesclagem Sim (exceto fast-forward) Não
Reescreve commits existentes Não Sim (novos hashes)
Segurança em ramos compartilhados Segura Perigosa
A regra de ouro do rebase

Nunca faça rebase de commits que já foram enviados a um repositório público e sobre os quais outras pessoas basearam trabalho. Como o rebase substitui commits por cópias com novos hashes, os colegas passariam a ter um histórico divergente, gerando duplicações e confusão. Use o rebase para organizar o seu trabalho local antes de compartilhá-lo.

O rebase interativo (git rebase -i HEAD~3) permite ainda reordenar, editar, juntar (squash) ou descartar os últimos commits, transformando uma série de commits do tipo “tentativa”, “corrige”, “agora vai” em um histórico limpo antes de abrir um pull request.


5.13 Desfazendo Alterações

Uma das maiores vantagens do controle de versão é poder voltar atrás. O comando adequado depende de onde está a alteração que se quer desfazer.

5.13.1 Descartar alterações no diretório de trabalho ou na área de preparação

# Descarta alterações não preparadas de um arquivo (volta à versão do índice)
git restore R/modelo.R

# Retira um arquivo da área de preparação (mantém as alterações no disco)
git restore --staged R/modelo.R

# Recupera a versão de um arquivo de um commit antigo
git restore --source=HEAD~3 R/modelo.R
Aviso

git restore arquivo descarta alterações não commitadas, que nunca foram registradas no banco de objetos. Elas não podem ser recuperadas pelo Git.

5.13.2 Corrigir o último commit

# Esqueceu um arquivo ou errou a mensagem do último commit
git add arquivo_esquecido.R
git commit --amend -m "Mensagem corrigida"

O --amend cria um novo commit que substitui o anterior; vale a mesma regra de ouro do rebase: só use em commits ainda não compartilhados.

5.13.3 Mover o ramo para trás: git reset

O git reset <commit> move o ponteiro do ramo atual para o commit indicado. As três variantes diferem quanto ao que acontece com a área de preparação e o diretório de trabalho:

Modo Move o ramo Altera a área de preparação Altera o diretório de trabalho Uso típico
--soft Sim Não Não Refazer os últimos commits como um só
--mixed (padrão) Sim Sim Não “Descommitar” mantendo as edições
--hard Sim Sim Sim Descartar tudo e voltar ao commit (perigoso)
git reset --soft HEAD~2   # desfaz os dois últimos commits, mantendo tudo preparado
git reset HEAD~1          # desfaz o último commit, alterações voltam ao diretório
git reset --hard HEAD~1   # apaga o último commit e as alterações correspondentes

5.13.4 Desfazer um commit já publicado: git revert

Se o commit problemático já foi enviado ao GitHub, não reescrevemos o histórico: criamos um novo commit que aplica a alteração inversa:

git revert a1b2c3d

Se o commit a1b2c3d adicionou a linha x <- log(y), o commit de reversão a remove. O histórico fica completo e honesto: mostra o erro e sua correção.

5.13.5 A rede de segurança: git reflog

O Git registra, localmente, todos os movimentos de HEAD no reflog. Mesmo após um reset --hard equivocado, os commits “perdidos” continuam no banco de objetos por algum tempo (por padrão, pelo menos 30 dias para entradas inalcançáveis):

git reflog
#> 3f2a1b9 HEAD@{0}: reset: moving to HEAD~1
#> 8c7d6e5 HEAD@{1}: commit: Ajusta modelo final
git reset --hard 8c7d6e5   # recupera o commit "perdido"

Vale aqui um conceito de teoria dos grafos: um objeto é alcançável se existe um caminho até ele a partir de alguma referência (ramo, tag, HEAD, entradas do reflog ou do stash). Objetos inalcançáveis são lixo em potencial, e o comando git gc os remove depois de um prazo de carência. É por isso que o reflog funciona como rede de segurança: enquanto uma entrada do reflog apontar para um commit, ele continua alcançável.


5.14 Ferramentas para o Dia a Dia: stash, cherry-pick e bisect

5.14.1 Guardando trabalho temporário com git stash

Já usamos o git stash para trocar de ramo com alterações pendentes. Internamente, cada entrada do stash é um commit de mesclagem comum: ele registra o estado do diretório de trabalho e tem como pais o commit de HEAD e um commit auxiliar com o estado do índice. Essas entradas formam uma pilha (estrutura LIFO, last in, first out), em que stash@{0} é a mais recente:

git stash push -m "gráfico pela metade"
git stash push -m "segundo"
git stash list
#> stash@{0}: On main: segundo
#> stash@{1}: On main: gráfico pela metade
git cat-file -p stash@{0} | head -3
#> tree 199119d...
#> parent b2d44ec...          # o commit de HEAD
#> parent a15f20b...          # o estado do índice
git stash show -p stash@{1}   # exibe o diff guardado em uma entrada
git stash apply stash@{1}     # reaplica, mantendo a entrada na pilha
git stash pop                 # reaplica a do topo e a remove da pilha
git stash drop stash@{1}      # descarta uma entrada

Por padrão o stash ignora arquivos não rastreados; a opção -u (--include-untracked) os inclui. Use o stash para interrupções curtas: trabalho que vai durar mais de um dia merece um ramo e um commit.

5.14.2 Copiando commits isolados com git cherry-pick

Às vezes queremos trazer um único commit de outro ramo, sem mesclar o ramo inteiro. Por exemplo, um colega corrigiu um erro na função de leitura dos dados em um ramo experimental que ainda não está pronto, e precisamos da correção em main. O cherry-pick calcula o diff entre o commit \(c\) e seu pai \(p(c)\) e o aplica sobre HEAD, criando um novo commit \(c'\) com a mesma mensagem e as mesmas alterações, mas com outro pai e, portanto, outro hash: \[ c' = \operatorname{commit}\big(\operatorname{HEAD} + [\,S_c - S_{p(c)}\,]\big). \] Tecnicamente, trata-se de uma mesclagem de três vias com base \(p(c)\), nosso lado HEAD e o lado deles \(c\); por isso o cherry-pick também pode gerar conflitos, resolvidos da maneira usual.

git switch main
git cherry-pick -x 1e43a01
git log -1 --format=%B
#> Corrige cálculo de y
#>
#> (cherry picked from commit 1e43a0114efac58cc956b82cbdd076879dc648c5)

A opção -x acrescenta à mensagem a origem do commit, o que preserva a rastreabilidade. O rebase, visto anteriormente, pode ser entendido como uma sequência de cherry-picks dos commits do ramo sobre a nova base.

5.14.3 Encontrando o commit que introduziu um erro: git bisect

Suponha que o relatório gerado hoje apresenta uma média errada, mas o relatório de três meses atrás estava correto, e que nesse intervalo houve centenas de commits. Testar commit por commit seria lento. O git bisect aplica busca binária ao histórico.

Formalmente, considere um histórico linear \(c_0, c_1, \ldots, c_n\), em que \(c_0\) é sabidamente bom e \(c_n\) sabidamente ruim, e um predicado \(P(c_i)\) = “o erro está presente em \(c_i\)”. Supomos que \(P\) é monótono: uma vez introduzido, o erro permanece, isto é, \(P(c_i) \Rightarrow P(c_j)\) para todo \(j \ge i\). Queremos o menor índice \(i^\ast\) com \(P(c_{i^\ast})\) verdadeiro. A cada passo, testamos o commit do meio do intervalo de candidatos e descartamos metade dele. Com \(n\) candidatos, o número de testes no pior caso é \[ T(n) = \lceil \log_2 n \rceil, \] contra até \(n - 1\) testes em uma busca sequencial. Para \(n = 1000\) commits, bastam \(\lceil \log_2 1000 \rceil = 10\) testes. Em históricos com mesclagens o Git generaliza a ideia: escolhe o commit que divide o conjunto de candidatos (no grafo) da forma mais equilibrada possível.

Exemplo resolvido. Um repositório tem 16 commits (Versão 1 a Versão 16) de um arquivo media.R; em algum momento, a função passou a dividir por \(n - 1\) em vez de \(n\). Sabemos que a versão 1 é boa e a 16 é ruim. Escrevemos um teste automático que retorna o código de saída 0 se o commit é bom e 1 se é ruim:

#!/bin/sh
# teste_media.sh: sai com 0 (bom) se media(c(2, 4, 6)) == 4; 1 (ruim) caso contrário
Rscript --vanilla -e 'source("media.R"); quit(status = !isTRUE(all.equal(media(c(2, 4, 6)), 4)))'

e deixamos o Git conduzir a busca:

git bisect start HEAD <hash-da-versão-1>   # primeiro o commit ruim, depois o bom
git bisect run ../teste_media.sh
#> Bisecting: 7 revisions left to test after this (roughly 3 steps)
#> [ca957b3...] Versão 8
#> Bisecting: 3 revisions left to test after this (roughly 2 steps)
#> [7465ee6...] Versão 12
#> Bisecting: 1 revision left to test after this (roughly 1 step)
#> [345b470...] Versão 10
#> Bisecting: 0 revisions left to test after this (roughly 0 steps)
#> [bae82b5...] Versão 11
#> bae82b5... is the first bad commit
git bisect reset                           # volta ao ramo original

Acompanhando passo a passo: os candidatos eram as versões 2 a 16 (\(n = 15\)). O teste na versão 8 deu “bom”, eliminando 2 a 8; na versão 12 deu “ruim”, restando 9 a 12; na 10, “bom”, restando 11 e 12; na 11, “ruim”. Foram \(4 = \lceil \log_2 15 \rceil\) testes, e o culpado é a versão 11. Sem um teste automático, usa-se o modo interativo, marcando cada commit visitado com git bisect good ou git bisect bad (e git bisect skip para commits que não podem ser testados; no modo automático, o código de saída 125 tem esse significado).

Testes automatizados também são para estatísticos

O bisect mostra o valor de ter testes automatizados: pequenas verificações de que funções retornam o esperado em casos conhecidos (por exemplo, que a média de \((2, 4, 6)\) é 4). Em R, o pacote testthat organiza esses testes; em Python, o pytest. Eles são também a base da integração contínua, discutida adiante.


5.15 Tags e Versionamento Semântico

Tags marcam pontos importantes do histórico: a versão de um pacote enviada ao CRAN, o código exato usado em um artigo submetido, a entrega de um trabalho da disciplina.

git tag -a v1.0.0 -m "Versão submetida ao periódico"  # tag anotada
git tag                                                # lista as tags
git show v1.0.0
git push origin v1.0.0                                 # tags não são enviadas por padrão
git push origin --tags                                 # envia todas

Uma convenção amplamente adotada é o versionamento semântico (Semantic Versioning), no formato MAIOR.MENOR.CORREÇÃO: incrementa-se a CORREÇÃO para correções compatíveis, a MENOR para novas funcionalidades compatíveis e a MAIOR para mudanças incompatíveis. No GitHub, uma tag pode ser transformada em uma release, com notas de versão e arquivos anexados.


5.16 Repositórios Remotos e Colaboração

Um remoto é outra cópia do repositório, normalmente hospedada em um servidor como o GitHub. Por convenção, o remoto de onde clonamos se chama origin.

git remote -v                                         # lista os remotos
git remote add origin git@github.com:maria/projeto.git
git push -u origin main   # envia e define main remoto como "upstream" do local

Após o -u, basta escrever git push e git pull. O Git mantém ramos de rastreamento remoto (por exemplo, origin/main), que são cópias locais e somente leitura do estado do remoto na última comunicação.

5.16.1 fetch versus pull

  • git fetch origin baixa os commits novos do remoto e atualiza origin/main, mas não altera os seus ramos nem o diretório de trabalho. É seguro executá-lo a qualquer momento.
  • git pull equivale a git fetch seguido de git merge origin/main (ou de git rebase, se configurado com git config --global pull.rebase true).
git fetch origin
git log --oneline main..origin/main   # o que os colegas fizeram e eu ainda não tenho
git merge origin/main

Se você tentar fazer push e o remoto tiver commits que você não tem, o Git rejeita o envio (non-fast-forward), justamente para não sobrescrever o trabalho alheio. A solução é fazer pull, resolver eventuais conflitos e então fazer push. Evite git push --force em ramos compartilhados.

5.16.2 Fork, Pull Request e Revisão de Código

No GitHub, a colaboração se organiza em torno de dois conceitos:

  • Fork: uma cópia de um repositório de outra pessoa sob a sua conta. É usado quando você não tem permissão de escrita no repositório original (por exemplo, para contribuir com um pacote de código aberto).
  • Pull Request (PR): uma proposta de mesclar um ramo em outro. O PR reúne as alterações, a discussão, os comentários linha a linha e os resultados de verificações automáticas. O nome vem da ideia de pedir ao mantenedor que “puxe” (pull) as suas alterações.

O fluxo típico de contribuição com fork é:

  1. Fazer o fork no GitHub e cloná-lo localmente.
  2. Adicionar o repositório original como um segundo remoto, por convenção chamado upstream: git remote add upstream https://github.com/original/projeto.git.
  3. Criar um ramo, fazer os commits e enviá-lo ao seu fork (git push origin meu-ramo).
  4. Abrir o PR no GitHub, do seu ramo para o main do repositório original.
  5. Responder à revisão, fazendo novos commits no mesmo ramo (o PR é atualizado automaticamente).
  6. Após a aprovação, o mantenedor mescla o PR.

A revisão de código (code review) é uma das práticas mais valiosas da engenharia de software e se aplica igualmente a análises estatísticas. Um bom revisor verifica: a análise responde à pergunta proposta? Os dados foram filtrados corretamente? As suposições do modelo foram checadas? O código é legível e reprodutível? Bons revisores fazem comentários específicos, gentis e acionáveis; bons autores enviam PRs pequenos, com descrição clara do objetivo e de como testar.

5.16.3 Autenticação: HTTPS com token ou SSH

Desde agosto de 2021, o GitHub não aceita mais a senha da conta para operações Git pela linha de comando. Há duas alternativas:

1. HTTPS com Personal Access Token (PAT). O token é uma cadeia gerada em Settings > Developer settings > Personal access tokens, com permissões (scopes) e prazo de validade definidos por você. Ele é usado no lugar da senha e deve ser guardado por um gerenciador de credenciais, nunca em um script.

2. SSH. Baseia-se em criptografia de chave pública: você gera um par de chaves; a pública é cadastrada no GitHub e a privada nunca sai do seu computador.

# Gera um par de chaves do tipo Ed25519
ssh-keygen -t ed25519 -C "maria.silva@exemplo.com"

# Exibe a chave pública, para copiar em GitHub > Settings > SSH and GPG keys
cat ~/.ssh/id_ed25519.pub

# Testa a conexão
ssh -T git@github.com
#> Hi maria! You've successfully authenticated, but GitHub does not provide shell access.

Com SSH, a URL do remoto tem a forma git@github.com:usuario/repositorio.git, em vez de https://github.com/usuario/repositorio.git.

5.16.4 Integridade versus autenticidade: commits assinados

O endereçamento por conteúdo garante integridade: ninguém altera um commit sem mudar seu hash. Mas ele não garante autenticidade: os campos author e committer são texto livre, definidos por git config user.name e user.email, e qualquer pessoa pode criar commits em nome de outra. A solução é a assinatura digital: o autor assina o objeto commit com sua chave privada, e qualquer pessoa verifica a assinatura com a chave pública correspondente. Se o conteúdo do commit for alterado, a verificação falha.

O Git suporta assinaturas com GPG e, desde a versão 2.34, também com chaves SSH, o que é bem mais simples para quem já usa SSH com o GitHub:

git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true    # assina todos os commits

git commit -m "Ajusta modelo final"
git cat-file -p HEAD | head -6
#> tree 3e3053c...
#> parent 114c735...
#> author Maria <maria@exemplo.com> 1793038253 -0300
#> committer Maria <maria@exemplo.com> 1793038253 -0300
#> gpgsig -----BEGIN SSH SIGNATURE-----
#>  U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgRSzCJWFzhLOlJ75RSiLUuJAX/M

A assinatura é gravada como um campo extra (gpgsig) do próprio objeto commit. Cadastrando a chave pública no GitHub como chave de assinatura (signing key), os commits passam a exibir o selo Verified. Localmente, a verificação é feita com git log --show-signature, desde que se informe ao Git quais chaves são confiáveis (opção gpg.ssh.allowedSignersFile).


5.17 Fluxos de Trabalho

Um fluxo de trabalho (workflow) é uma convenção da equipe sobre como usar ramos e mesclagens. Os principais são:

  • Centralizado: todos fazem commits diretamente em main. Simples, adequado a projetos individuais ou muito pequenos, mas arriscado em equipes.
  • Ramo por funcionalidade (feature branch): cada tarefa é desenvolvida em um ramo próprio, mesclado em main quando concluída.
  • GitHub Flow: uma formalização leve do anterior, muito usada em projetos de dados e aplicações web:
    1. main deve estar sempre em estado funcional;
    2. para qualquer trabalho, crie um ramo com nome descritivo a partir de main;
    3. faça commits nesse ramo e envie-o ao GitHub regularmente;
    4. abra um pull request para pedir revisão e discutir;
    5. após aprovação (e verificações automáticas), mescle em main;
    6. apague o ramo.
  • Git Flow: um modelo mais elaborado, com ramos permanentes main e develop e ramos auxiliares de release e hotfix. É indicado para software com ciclos de lançamento formais e é, em geral, excessivo para análises de dados.

Para um trabalho em grupo da disciplina, o GitHub Flow é a escolha recomendada.


5.18 Boas Mensagens de Commit

O histórico é um documento que será lido por outras pessoas (e por você mesmo daqui a seis meses). Mensagens como "update", "ajustes" ou "asdf" o tornam inútil. Convenções amplamente adotadas recomendam:

  1. Uma linha de título curta (idealmente até cerca de 50 caracteres), no modo imperativo, completando a frase “Se aplicado, este commit irá…”: Adiciona, Corrige, Remove, Refatora.
  2. Uma linha em branco separando o título do corpo.
  3. Um corpo opcional explicando o quê e por quê (o como já está no diff), com linhas de cerca de 72 caracteres.
  4. Commits atômicos: uma mudança lógica por commit.
Remove outliers de renda antes do ajuste do modelo

Observações com renda acima do percentil 99,5 eram erros de digitação
(valores multiplicados por 1000), confirmados com a equipe de coleta.
Sem a remoção, o coeficiente de escolaridade ficava subestimado.

Refs: #12

Algumas equipes adotam a especificação Conventional Commits, com prefixos como feat:, fix:, docs: e refactor:, que permitem gerar notas de versão automaticamente.

Ruim Bom
mudanças Corrige cálculo do IC de 95% na função resumo()
arrumei o gráfico Usa escala logarítmica no eixo x do gráfico de renda
versão final agora vai Adiciona validação cruzada 10-fold ao modelo de risco

5.19 Git e GitHub no RStudio

O RStudio possui integração nativa com o Git (painel Git), permitindo preparar arquivos, fazer commits, visualizar diffs, trocar de ramo, fazer push e pull com cliques. Para isso, o projeto deve ser um Projeto do RStudio (.Rproj) com o Git habilitado em Tools > Project Options > Git/SVN.

O pacote usethis automatiza as tarefas mais comuns diretamente do console do R (Bryan 2018):

# install.packages(c("usethis", "gitcreds"))
library(usethis)

# Configura nome e e-mail (equivalente ao git config --global)
use_git_config(user.name = "Maria da Silva",
               user.email = "maria.silva@exemplo.com")

# Abre o navegador para criar um Personal Access Token no GitHub
create_github_token()

# Armazena o token no gerenciador de credenciais do sistema
gitcreds::gitcreds_set()

# Diagnóstico completo da configuração Git/GitHub
git_sitrep()

# Dentro de um projeto: inicializa o Git e cria um .gitignore adequado ao R
use_git()

# Cria o repositório no GitHub e o conecta como remoto "origin"
use_github()

Usuários de Python encontram integração semelhante no VS Code, no PyCharm e no JupyterLab (extensão jupyterlab-git).

Notebooks Jupyter e controle de versão

Arquivos .ipynb são documentos JSON que incluem as saídas (inclusive imagens codificadas) e contadores de execução, o que gera diffs enormes e conflitos frequentes. Boas práticas: limpar as saídas antes do commit, usar ferramentas como nbdime para visualizar diferenças, ou preferir documentos de texto plano como .qmd (veja o Capítulo de Documentos Dinâmicos com Markdown e RMarkdown).


5.20 Outros Recursos do GitHub

  • README.md: o arquivo exibido na página inicial do repositório. Deve explicar o objetivo do projeto, como instalar as dependências, como reproduzir a análise e como citar o trabalho.
  • LICENSE: sem uma licença explícita, ninguém tem permissão legal de reutilizar o código. Licenças comuns são MIT, GPL-3 e, para textos e dados, Creative Commons.
  • Issues: um sistema de acompanhamento de tarefas, erros e discussões. Cada issue tem número, rótulos (labels), responsáveis e pode ser associada a marcos (milestones). Escrevendo Closes #12 na descrição de um PR, a issue 12 é fechada automaticamente quando o PR é mesclado.
  • GitHub Pages: hospedagem gratuita de sites estáticos diretamente de um repositório. É muito usada para publicar relatórios em HTML, portfólios e livros gerados com Quarto, como este. O Quarto possui o comando quarto publish gh-pages, que renderiza e publica o site em um ramo gh-pages.
  • GitHub Actions: um sistema de integração contínua que executa tarefas automaticamente a cada push ou PR (rodar testes, verificar um pacote R com R CMD check, renderizar um relatório).
  • Projects, Wiki e Discussions: quadros de tarefas no estilo kanban, documentação colaborativa e fóruns.

5.21 Ganchos (Hooks): Automação Local

Um hook (gancho) é um script que o Git executa automaticamente em determinados eventos. Os scripts ficam em .git/hooks/, com nomes padronizados e permissão de execução. Os mais úteis são:

Hook Quando roda Uso típico
pre-commit Antes de criar o commit Verificar estilo, impedir arquivos grandes ou segredos
commit-msg Após escrever a mensagem Validar o formato da mensagem (por exemplo, Conventional Commits)
pre-push Antes de enviar ao remoto Rodar os testes
post-merge Após uma mesclagem Reinstalar dependências, se o arquivo de dependências mudou

Se um hook do tipo pre- terminar com código de saída diferente de zero, a operação é cancelada. O exemplo abaixo impede commits de arquivos .env e de arquivos maiores que 50 MB:

#!/bin/sh
# .git/hooks/pre-commit
limite=$((50 * 1024 * 1024))
for f in $(git diff --cached --name-only --diff-filter=AM); do
  case "$f" in
    *.env|.env) echo "ERRO: '$f' parece conter segredos." >&2; exit 1 ;;
  esac
  tam=$(git cat-file -s ":$f")    # tamanho da versão preparada no índice
  if [ "$tam" -gt "$limite" ]; then
    echo "ERRO: '$f' tem $tam bytes (limite: $limite)." >&2; exit 1
  fi
done
exit 0
chmod +x .git/hooks/pre-commit
git add .env analise.R
git commit -m "teste"
#> ERRO: '.env' parece conter segredos.

Como o diretório .git não é versionado, os hooks não são compartilhados pelo clone. Para padronizá-los na equipe, costuma-se guardá-los em uma pasta versionada e apontar o Git para ela (git config core.hooksPath .githooks) ou usar ferramentas como o framework pre-commit (Python), configurado por um arquivo .pre-commit-config.yaml, com ganchos prontos para R (lintr, styler) e Python (black, ruff). Hooks locais podem ser contornados (git commit --no-verify); verificações obrigatórias devem rodar também no servidor, via integração contínua.


5.22 Integração Contínua com GitHub Actions

Integração contínua (continuous integration, CI) é a prática de verificar automaticamente cada alteração enviada ao repositório: instalar as dependências em uma máquina limpa, rodar os testes, verificar o estilo e, eventualmente, gerar artefatos (relatórios, sites, pacotes). Para um projeto de dados, a CI responde a uma pergunta valiosa: a análise roda do zero em outro computador? Se a resposta for não, o projeto não é reprodutível.

No GitHub, a CI é feita pelo GitHub Actions, cujos conceitos são:

  • Workflow: um processo automatizado descrito em um arquivo YAML na pasta .github/workflows/.
  • Evento (on): o que dispara o workflow, como um push, a abertura de um pull request, um horário agendado (schedule, com sintaxe cron) ou um acionamento manual (workflow_dispatch).
  • Job: um conjunto de passos executados em uma mesma máquina virtual (runner), como ubuntu-latest. Jobs diferentes rodam em paralelo por padrão.
  • Step: um comando de terminal (run) ou uma ação reutilizável (uses), publicada por terceiros.

Um workflow para um projeto Python que roda os testes com pytest em duas versões da linguagem, a cada push e pull request:

# .github/workflows/testes.yml
name: Testes

on:
  push:
    branches: [main]
  pull_request:

jobs:
  testes:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.11", "3.12"]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
      - run: pip install -r requirements.txt
      - run: pytest

A chave strategy.matrix cria uma cópia do job para cada combinação de valores (aqui, duas), um produto cartesiano útil para testar várias versões de linguagem ou sistemas operacionais. Para um projeto em R que renderiza um relatório Quarto e o publica no GitHub Pages:

# .github/workflows/relatorio.yml
name: Renderiza relatório

on:
  push:
    branches: [main]

jobs:
  render:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4
      - uses: r-lib/actions/setup-r@v2
      - uses: r-lib/actions/setup-renv@v2      # restaura os pacotes do renv.lock
      - uses: quarto-dev/quarto-actions/setup@v2
      - uses: quarto-dev/quarto-actions/publish@v2
        with:
          target: gh-pages

O resultado de cada execução aparece na aba Actions e ao lado de cada commit (um sinal verde ou um X vermelho). Nas configurações do repositório, é possível exigir que a CI passe antes que um pull request possa ser mesclado (branch protection rules). Segredos necessários à CI (tokens de APIs, por exemplo) devem ser cadastrados em Settings > Secrets and variables e acessados como ${{ secrets.NOME }}, nunca escritos no YAML.


5.23 Dados Grandes e Versionamento de Dados

O Git foi projetado para código-fonte: muitos arquivos pequenos de texto. Como vimos ao estudar os packfiles, arquivos binários grandes inflam o repositório para sempre, já que cada clone carrega todo o histórico. Além disso, o GitHub recusa arquivos acima de 100 MB. Há três abordagens complementares.

5.23.1 Git LFS (Large File Storage)

O Git LFS substitui, no repositório, cada arquivo grande por um pequeno arquivo-ponteiro de texto, enquanto o conteúdo real é guardado em um servidor separado e baixado apenas quando necessário:

git lfs install                  # uma vez por computador
git lfs track "*.parquet"        # grava a regra no arquivo .gitattributes
git add .gitattributes dados/vendas.parquet
git commit -m "Adiciona base de vendas via LFS"

O arquivo .gitattributes passa a conter a linha *.parquet filter=lfs diff=lfs merge=lfs -text, e o que o Git efetivamente versiona é um ponteiro como:

version https://git-lfs.github.com/spec/v1
oid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393
size 52428800

Note que o LFS reaproveita a ideia de endereçamento por conteúdo, agora com SHA-256. As cotas gratuitas de armazenamento e de transferência do GitHub para LFS são limitadas.

5.23.2 Versionamento de dados com DVC

O DVC (Data Version Control) é uma ferramenta de código aberto, escrita em Python, que estende a mesma ideia para fluxos de ciência de dados. Os dados ficam em um armazenamento remoto qualquer (um servidor, Google Drive, S3), e o Git versiona apenas pequenos arquivos de metadados .dvc, com o hash do conteúdo:

dvc init
dvc add dados/brutos.csv         # cria dados/brutos.csv.dvc e o inclui no .gitignore
git add dados/brutos.csv.dvc dados/.gitignore
git commit -m "Versiona base bruta com DVC"
dvc remote add -d armazenamento /mnt/servidor/dvc
dvc push                         # envia os dados ao armazenamento remoto
# Em outro computador, após o git clone:
dvc pull                         # baixa a versão dos dados correspondente ao commit

Assim, cada commit do Git passa a identificar o código e a versão exata dos dados, o que é exatamente o que a reprodutibilidade exige. O DVC também permite descrever pipelines (etapas de limpeza, modelagem e avaliação, com suas dependências) e reexecutar apenas as etapas cujas entradas mudaram.

5.23.3 Submódulos

Um submódulo é um repositório Git incluído dentro de outro, fixado em um commit específico. É útil, por exemplo, quando vários projetos compartilham um repositório de funções auxiliares ou de dados de referência:

git submodule add https://github.com/grupo/funcoes-comuns.git R/comum
git clone --recurse-submodules https://github.com/grupo/projeto.git
git submodule update --init --recursive   # se o clone foi feito sem a opção acima

O repositório principal guarda apenas o hash do commit do submódulo; atualizá-lo é uma decisão explícita, registrada em um commit. Submódulos são poderosos, mas costumam confundir iniciantes; use-os com parcimônia.


5.24 Reprodutibilidade do Ambiente: renv e Ambientes Virtuais

Versionar o código e os dados não basta: o mesmo script pode produzir resultados diferentes com versões diferentes dos pacotes. Por isso, o ambiente computacional também deve ser registrado no repositório. As ferramentas mais comuns são:

  • R, com renv: renv::init() cria uma biblioteca de pacotes isolada para o projeto; renv::snapshot() grava as versões exatas no arquivo renv.lock (que deve ser versionado); em outro computador, renv::restore() reinstala exatamente essas versões.
  • Python, com venv e pip:
python3 -m venv .venv               # cria o ambiente virtual (adicione .venv/ ao .gitignore)
source .venv/bin/activate           # no Windows: .venv\Scripts\activate
pip install pandas scikit-learn
pip freeze > requirements.txt       # registra as versões instaladas (versionar este arquivo)
pip install -r requirements.txt     # recria o ambiente em outra máquina
  • Conda: o arquivo environment.yml cumpre o mesmo papel, incluindo bibliotecas que não são do Python.

O Capítulo de Documentos Dinâmicos com Markdown e RMarkdown discute os níveis de reprodutibilidade do ambiente computacional, incluindo contêineres como o Docker.


5.25 Licenças e Citação de Repositórios

Publicar código no GitHub não significa autorizar seu uso: pela legislação de direito autoral, sem uma licença, todos os direitos são reservados. Ao criar um repositório público, escolha uma licença e grave-a no arquivo LICENSE (o GitHub e o usethis, com use_mit_license(), use_gpl3_license() ou use_cc_by_license(), geram o arquivo automaticamente).

Licença Tipo Exigência principal Uso típico
MIT Permissiva Manter o aviso de direitos autorais Código em geral, pacotes
Apache 2.0 Permissiva Aviso de direitos e de alterações; concede licença de patentes Software corporativo
GPL-3 Copyleft Obras derivadas distribuídas devem usar a mesma licença Software livre, muitos pacotes R
CC BY 4.0 Creative Commons Atribuição ao autor Textos, figuras, dados
CC0 Domínio público Nenhuma Dados que se quer reutilizáveis sem restrições

Licenças copyleft garantem que derivados continuem livres; licenças permissivas maximizam a reutilização, inclusive comercial. Para que o repositório seja citável em trabalhos acadêmicos, pode-se incluir um arquivo CITATION.cff (Citation File Format), que o GitHub reconhece e transforma em um botão Cite this repository, e arquivar uma release no Zenodo, que atribui um DOI permanente a ela.


5.26 Boas Práticas para Projetos de Ciência de Dados

Inspirados em Wilson et al. (2017), recomendamos:

  1. Um repositório por projeto, com estrutura previsível (dados/, R/ ou src/, relatorios/, saidas/) e um README.
  2. Versione o código, não os produtos: tudo o que pode ser regenerado a partir do código e dos dados brutos (figuras, tabelas, relatórios compilados) deve, em princípio, ficar fora do Git.
  3. Dados brutos são somente leitura: nunca edite manualmente um arquivo de dados original; toda transformação deve estar em código versionado.
  4. Arquivos grandes: o GitHub bloqueia arquivos acima de 100 MB e recomenda repositórios pequenos. Para dados volumosos, use armazenamento externo (com um script de download) ou ferramentas como o Git LFS (Large File Storage) e o DVC, discutidos na seção sobre versionamento de dados.
  5. Registre o ambiente computacional: versões dos pacotes (renv no R, requirements.txt ou environment.yml no Python) devem ser versionadas junto com o código.
  6. Commits frequentes e pequenos, com mensagens informativas, e push ao final de cada sessão de trabalho (o GitHub também funciona como backup).
  7. Automatize as verificações: testes (testthat, pytest) e a renderização dos relatórios devem rodar na integração contínua a cada pull request.
  8. Escolha uma licença e inclua instruções de citação em repositórios públicos.

5.27 Exemplo Completo: Uma Sessão de Trabalho

O roteiro a seguir resume um ciclo completo, do zero até um pull request:

# 1. Cria o projeto e o repositório
mkdir analise-vendas && cd analise-vendas
git init
echo "# Análise de Vendas" > README.md
printf ".Rhistory\n.RData\n.Rproj.user/\ndados/brutos/\n" > .gitignore
git add README.md .gitignore
git commit -m "Inicia projeto com README e .gitignore"

# 2. Conecta ao GitHub (repositório vazio criado previamente na interface web)
git remote add origin git@github.com:maria/analise-vendas.git
git push -u origin main

# 3. Desenvolve uma funcionalidade em um ramo
git switch -c modelo-regressao
# ... edita R/modelo.R ...
git add R/modelo.R
git commit -m "Ajusta regressão linear de vendas em função do preço"
git push -u origin modelo-regressao

# 4. Abre o pull request (na interface web ou com a CLI do GitHub)
gh pr create --title "Modelo de regressão" --body "Closes #3"

# 5. Após a aprovação e mesclagem no GitHub, atualiza o main local
git switch main
git pull
git branch -d modelo-regressao

# 6. Inspeciona o histórico
git log --oneline --graph --all

5.28 Resumo do Capítulo

  • O controle de versão resolve os problemas de rastreabilidade, colaboração e recuperação que o versionamento manual de arquivos não resolve. Os VCS evoluíram de sistemas locais (SCCS, RCS) para centralizados (CVS, SVN) e, finalmente, distribuídos (Git, Mercurial).
  • O Git, criado por Linus Torvalds em 2005, armazena o histórico como uma sequência de instantâneos, representados por objetos blob, tree, commit e tag, identificados por hashes criptográficos de seu conteúdo (endereçamento por conteúdo), o que garante integridade e deduplicação.
  • O histórico é um grafo acíclico dirigido de commits; branches e HEAD são apenas ponteiros, o que torna a ramificação barata. A base de mesclagem é o ancestral comum mais recente de dois commits.
  • Os arquivos transitam entre diretório de trabalho, área de preparação e repositório; git add e git commit movem as alterações entre essas áreas; .gitignore exclui arquivos do rastreamento.
  • A mesclagem pode ser fast-forward (simples movimento de ponteiro) ou de três vias (com commit de mesclagem); conflitos surgem quando ambos os lados alteram a mesma região e devem ser resolvidos manualmente.
  • O rebase produz histórico linear reescrevendo commits e nunca deve ser usado em commits compartilhados. restore, reset, revert e reflog permitem desfazer alterações em diferentes níveis.
  • O GitHub acrescenta remotos, forks, pull requests, revisão de código, issues, GitHub Pages e automação; o GitHub Flow é um fluxo de trabalho simples e eficaz para equipes de dados.
  • Fisicamente, os objetos são comprimidos com zlib e reunidos em packfiles com compressão por deltas, muito eficiente para texto e ineficiente para binários já comprimidos; para dados grandes, usam-se Git LFS, DVC ou armazenamento externo.
  • O git status compara três instantâneos (HEAD, índice e diretório de trabalho); os comandos de porcelana são compostos de comandos de encanamento (hash-object, write-tree, commit-tree, update-ref).
  • O diff resolve o problema da maior subsequência comum: o número mínimo de edições é \(D = N + M - 2L\); o algoritmo de Myers, padrão do Git, custa \(O((N+M)D)\) e é eficiente quando as versões são parecidas.
  • A base de mesclagem é obtida a partir dos conjuntos de ancestrais; durante um conflito, o índice guarda os estágios 1 (base), 2 (nosso) e 3 (deles), e o estilo diff3 exibe a base nos marcadores.
  • stash (pilha de commits temporários), cherry-pick (aplicação do diff de um único commit) e bisect (busca binária com \(\lceil \log_2 n ceil\) testes) completam o repertório cotidiano.
  • Hooks, GitHub Actions, commits assinados, registro do ambiente (renv, venv) e licenças levam o projeto de dados do controle de versão à reprodutibilidade e à publicação responsável.

5.29 Exercícios Propostos

  1. Explique, com suas palavras, três desvantagens de um sistema de controle de versão centralizado em relação a um distribuído. Em que situação um sistema centralizado poderia ser preferível?
  2. Calcule manualmente (com printf e sha1sum, ou em Python com hashlib) o identificador do blob de um arquivo contendo apenas a linha Estatística UFPE e compare com a saída de git hash-object. Atenção: o tamanho deve ser contado em bytes (caracteres acentuados ocupam 2 bytes em UTF-8).
  3. Usando a aproximação do problema do aniversário, calcule a probabilidade aproximada de colisão acidental entre \(k = 10^{9}\) objetos para hashes de 160 bits. E se o hash tivesse apenas 32 bits, para \(k = 10^{5}\) objetos? O que isso indica sobre o tamanho necessário de um hash?
  4. Considere o histórico em que main contém os commits \(A \to B \to C \to H\) e o ramo exp contém \(A \to B \to D \to E\), sendo que \(F\) é um commit de mesclagem com pais \(E\) e \(C\) no ramo exp. Desenhe o grafo, determine o conjunto de ancestrais de \(H\) e de \(F\) e a base de mesclagem entre main e exp.
  5. Para cada combinação abaixo de conteúdos de uma linha (base, nosso, deles), aplique a regra da mesclagem de três vias e dê o resultado: (a) (x <- 1, x <- 1, x <- 2); (b) (x <- 1, x <- 3, x <- 3); (c) (x <- 1, x <- 2, x <- 3); (d) (x <- 1, linha apagada, x <- 1).
  6. Crie um repositório local, faça três commits, crie um ramo, faça um commit nele, volte a main, faça outro commit que altere a mesma linha e mescle o ramo. Resolva o conflito e mostre o grafo final com git log --oneline --graph --all.
  7. Escreva um .gitignore para um projeto de ciência de dados em R e Python que contenha: dados brutos em dados/brutos/, um arquivo .env com credenciais, figuras PNG geradas em figuras/ (exceto figuras/logo.png) e arquivos temporários do Jupyter.
  8. Explique a diferença entre git reset --soft, git reset --mixed e git reset --hard. Por que git revert é preferível a git reset para desfazer um commit já enviado ao GitHub?
  9. Compare merge e rebase quanto ao histórico resultante e aos hashes dos commits. Justifique, usando o modelo de dados do Git, por que o rebase altera os hashes.
  10. Reescreva as mensagens de commit a seguir seguindo as boas práticas: “ajustes”, “corrigi o bug”, “atualização dos dados e do gráfico e do relatório”.
  11. Configure uma chave SSH, cadastre-a no GitHub, crie um repositório e envie um projeto R com README, .gitignore e licença MIT. Em seguida, use usethis::git_sitrep() para diagnosticar sua configuração.
  12. Em dupla, simule o GitHub Flow: um aluno cria o repositório; o outro faz um fork, cria um ramo, adiciona uma análise descritiva e abre um pull request; o primeiro revisa com pelo menos dois comentários de linha e, após os ajustes, mescla o PR.
  13. Publique no GitHub Pages um relatório HTML simples gerado com Quarto. Descreva os passos realizados.
  14. Usando a recorrência de programação dinâmica, construa à mão a tabela \(L(i, j)\) para \(a = \texttt{XMJYAUZ}\) e \(b = \texttt{MZJAWXU}\). Determine \(L\), o número mínimo de edições \(D\) e uma subsequência comum máxima. Confira o resultado gravando as letras, uma por linha, em um arquivo versionado e executando git diff.
  15. Mostre que \(D = N + M - 2L\) é de fato o número mínimo de inserções e remoções que transformam \(a\) em \(b\) (dica: mostre que todo script de edição define uma subsequência comum e vice-versa).
  16. Em um repositório de teste, crie um commit usando apenas comandos de encanamento (hash-object -w, update-index, write-tree, commit-tree, update-ref). Explique por que o hash da tree coincide com o de um colega que fizer o mesmo com o mesmo arquivo, mas o hash do commit não.
  17. Gere um arquivo CSV com 10 000 linhas, faça cinco commits alterando poucas linhas e compare as saídas de git count-objects -vH antes e depois de git gc. Repita com um arquivo .zip do mesmo CSV. Explique a diferença usando o conceito de compressão por deltas.
  18. Um histórico linear tem 2 000 commits entre uma versão boa e uma ruim. Quantos testes, no pior caso, o git bisect precisa? E se o predicado “o erro está presente” não fosse monótono (o erro aparece, some e reaparece), o que poderia dar errado?
  19. Construa um histórico com uma mesclagem cruzada (criss-cross): dois ramos que se mesclam mutuamente. Mostre com git merge-base --all que existem duas bases de mesclagem e explique, usando a definição de ordem parcial, por que nenhuma delas é “mais recente” que a outra.
  20. Escreva um hook commit-msg que rejeite mensagens cujo título tenha mais de 72 caracteres. Teste-o e explique por que ele não substitui uma verificação na integração contínua.
  21. Escreva um workflow do GitHub Actions que, a cada pull request, instale as dependências de um projeto R com renv e rode os testes do testthat. Que vantagem a CI traz para a reprodutibilidade?
  22. Compare Git LFS e DVC: onde ficam os dados, o que é versionado pelo Git e como um colaborador obtém uma versão antiga dos dados em cada caso.
  23. Para cada cenário, escolha uma licença e justifique: (a) um pacote R que você quer ver usado inclusive por empresas; (b) um software que deve permanecer livre em todas as versões derivadas; (c) uma base de dados de domínio público coletada pelo seu grupo de pesquisa.

5.30 Leituras Recomendadas

  • Chacon e Straub (2014) — Pro Git: a referência mais completa e acessível sobre Git, disponível gratuitamente online, inclusive em português. Os capítulos sobre ramificação e sobre o funcionamento interno (“Git Internals”) aprofundam o modelo de dados apresentado aqui.
  • Loeliger e McCullough (2012) — Version Control with Git: tratamento detalhado do modelo de objetos, do índice e das estratégias de mesclagem.
  • Bryan (2018) — artigo dirigido especificamente a estatísticos, com argumentos e orientações práticas para adoção do Git e do GitHub em análises de dados.
  • Wilson et al. (2017) — recomendações práticas e realistas de organização de projetos, controle de versão e gestão de dados em computação científica.
  • Sandve et al. (2013) — dez regras simples para pesquisa computacional reprodutível, várias delas diretamente ligadas ao controle de versão.
  • Perez-Riverol et al. (2016) e Blischak, Davenport, e Wilson (2016) — dois artigos curtos da revista PLoS Computational Biology sobre o uso de Git e GitHub em pesquisa científica; ótimos para compartilhar com colegas de outras áreas.
  • Ram (2013) — argumenta, com exemplos, como o Git aumenta a reprodutibilidade e a transparência em ciência.
  • Myers (1986) — o artigo original do algoritmo de diff usado pelo Git; a seção sobre o grafo de edição é acessível a quem conhece busca em grafos.
  • Hunt e McIlroy (1976) — o relatório técnico que descreve o algoritmo do diff original do Unix.
  • Stevens et al. (2017) — a primeira colisão prática do SHA-1, que motivou a transição do Git para o SHA-256.
  • A documentação oficial do Git (https://git-scm.com/docs), em especial as páginas gitglossary, gitrevisions e githooks, e a documentação do GitHub Actions (https://docs.github.com/actions).