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.zipprojeto_final_v2.zipprojeto_final_v2_editado.zipprojeto_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:
- O que mudou de um arquivo para o outro.
- Quem fez a alteração em um trabalho colaborativo.
- Quando a alteração foi feita e por qual motivo.
- 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.
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):
- velocidade;
- design simples;
- suporte forte a desenvolvimento não linear (milhares de ramificações paralelas);
- ser totalmente distribuído;
- 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:
- 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.
- 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).
- 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.
- 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
#> e69de29bb2d1d6434b8b29ae775ad8c2e48c5391O mesmo cálculo em Python:
import hashlib
conteudo = b"hello\n"
cabecalho = f"blob {len(conteudo)}\0".encode()
print(hashlib.sha1(cabecalho + conteudo).hexdigest())
#> ce013625030ba8dba906f756967f9e9ca394464aConsequê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).
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
mainefeatureé \(C\), o que pode ser conferido comgit merge-base main feature. - Os commits que estão em
featuremas não emmainsão \(\{E, D, C, B, A\} \setminus \{G, F, C, B, A\} = \{D, E\}\); é exatamente isso quegit log main..featurelista.
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/mainObserve 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:
- Compressão individual: cada objeto solto é comprimido com zlib.
- 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\).
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:

- 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.
- Á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. - 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
HEADaponta; - \(S_{\text{índice}}\): o instantâneo que seria gravado se fizéssemos
git commitagora (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-originAs 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.
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 initEsse 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.gitO 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 statusUma 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 rastreadoPara 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 mainPara 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 mainEsses 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.
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 discoO 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
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 |
- O canto inferior direito dá \(L = L(7, 6) = 4\).
- Logo, \(D = 7 + 6 - 2 \times 4 = 5\) edições: \(7 - 4 = 3\) remoções e \(6 - 4 = 2\) inserções.
- 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 +CPodemos 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
#> +CO 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.
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 mescladocheckout, 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 guardadas5.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-outliersHá 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.
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 diff3Com 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 <- 8Quando 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:
- Execute
git statuspara ver quais arquivos estão em conflito (both modified). - Abra cada arquivo, decida o conteúdo correto (pode ser uma das versões, uma combinação ou algo novo) e apague os marcadores.
- Prepare o arquivo resolvido com
git add parametros.R. - 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.
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 |
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.Rgit 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 correspondentes5.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 a1b2c3dSe 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 entradaPor 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 originalAcompanhando 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).
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.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 localApó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 originbaixa os commits novos do remoto e atualizaorigin/main, mas não altera os seus ramos nem o diretório de trabalho. É seguro executá-lo a qualquer momento.git pullequivale agit fetchseguido degit merge origin/main(ou degit rebase, se configurado comgit 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/mainSe 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 é:
- Fazer o fork no GitHub e cloná-lo localmente.
- Adicionar o repositório original como um segundo remoto, por convenção chamado
upstream:git remote add upstream https://github.com/original/projeto.git. - Criar um ramo, fazer os commits e enviá-lo ao seu fork (
git push origin meu-ramo). - Abrir o PR no GitHub, do seu ramo para o
maindo repositório original. - Responder à revisão, fazendo novos commits no mesmo ramo (o PR é atualizado automaticamente).
- 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/MA 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
mainquando concluída. - GitHub Flow: uma formalização leve do anterior, muito usada em projetos de dados e aplicações web:
maindeve estar sempre em estado funcional;- para qualquer trabalho, crie um ramo com nome descritivo a partir de
main; - faça commits nesse ramo e envie-o ao GitHub regularmente;
- abra um pull request para pedir revisão e discutir;
- após aprovação (e verificações automáticas), mescle em
main; - apague o ramo.
- Git Flow: um modelo mais elaborado, com ramos permanentes
mainedevelope 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:
- 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. - Uma linha em branco separando o título do corpo.
- Um corpo opcional explicando o quê e por quê (o como já está no diff), com linhas de cerca de 72 caracteres.
- 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).
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 #12na 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 ramogh-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 0chmod +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 umpush, 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: pytestA 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-pagesO 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 commitAssim, 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 acimaO 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 arquivorenv.lock(que deve ser versionado); em outro computador,renv::restore()reinstala exatamente essas versões. - Python, com
venvepip:
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.ymlcumpre 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:
- Um repositório por projeto, com estrutura previsível (
dados/,R/ousrc/,relatorios/,saidas/) e um README. - 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.
- Dados brutos são somente leitura: nunca edite manualmente um arquivo de dados original; toda transformação deve estar em código versionado.
- 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.
- Registre o ambiente computacional: versões dos pacotes (
renvno R,requirements.txtouenvironment.ymlno Python) devem ser versionadas junto com o código. - Commits frequentes e pequenos, com mensagens informativas, e
pushao final de cada sessão de trabalho (o GitHub também funciona como backup). - 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. - 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 --all5.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 addegit commitmovem as alterações entre essas áreas;.gitignoreexclui 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,revertereflogpermitem 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 statuscompara 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
diff3exibe a base nos marcadores. stash(pilha de commits temporários),cherry-pick(aplicação do diff de um único commit) ebisect(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
- 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?
- Calcule manualmente (com
printfesha1sum, ou em Python comhashlib) o identificador do blob de um arquivo contendo apenas a linhaEstatística UFPEe compare com a saída degit hash-object. Atenção: o tamanho deve ser contado em bytes (caracteres acentuados ocupam 2 bytes em UTF-8). - 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?
- Considere o histórico em que
maincontém os commits \(A \to B \to C \to H\) e o ramoexpcontém \(A \to B \to D \to E\), sendo que \(F\) é um commit de mesclagem com pais \(E\) e \(C\) no ramoexp. Desenhe o grafo, determine o conjunto de ancestrais de \(H\) e de \(F\) e a base de mesclagem entremaineexp. - 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). - 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 comgit log --oneline --graph --all. - Escreva um
.gitignorepara um projeto de ciência de dados em R e Python que contenha: dados brutos emdados/brutos/, um arquivo.envcom credenciais, figuras PNG geradas emfiguras/(excetofiguras/logo.png) e arquivos temporários do Jupyter. - Explique a diferença entre
git reset --soft,git reset --mixedegit reset --hard. Por quegit reverté preferível agit resetpara desfazer um commit já enviado ao GitHub? - Compare
mergeerebasequanto ao histórico resultante e aos hashes dos commits. Justifique, usando o modelo de dados do Git, por que o rebase altera os hashes. - 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”.
- Configure uma chave SSH, cadastre-a no GitHub, crie um repositório e envie um projeto R com README,
.gitignoree licença MIT. Em seguida, useusethis::git_sitrep()para diagnosticar sua configuração. - 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.
- Publique no GitHub Pages um relatório HTML simples gerado com Quarto. Descreva os passos realizados.
- 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. - 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).
- 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. - Gere um arquivo CSV com 10 000 linhas, faça cinco commits alterando poucas linhas e compare as saídas de
git count-objects -vHantes e depois degit gc. Repita com um arquivo.zipdo mesmo CSV. Explique a diferença usando o conceito de compressão por deltas. - Um histórico linear tem 2 000 commits entre uma versão boa e uma ruim. Quantos testes, no pior caso, o
git bisectprecisa? E se o predicado “o erro está presente” não fosse monótono (o erro aparece, some e reaparece), o que poderia dar errado? - Construa um histórico com uma mesclagem cruzada (criss-cross): dois ramos que se mesclam mutuamente. Mostre com
git merge-base --allque existem duas bases de mesclagem e explique, usando a definição de ordem parcial, por que nenhuma delas é “mais recente” que a outra. - Escreva um hook
commit-msgque 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. - Escreva um workflow do GitHub Actions que, a cada pull request, instale as dependências de um projeto R com
renve rode os testes dotestthat. Que vantagem a CI traz para a reprodutibilidade? - 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.
- 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
difforiginal 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,gitrevisionsegithooks, e a documentação do GitHub Actions (https://docs.github.com/actions).