O Modelo de Objetos do Git
A maior parte da confusão com o Git - "por que meu diff de notebook é uma parede de ruído", "por que meu branch desapareceu", "por que rebase é perigoso, mas merge não é" - vem de imaginar o Git como uma pilha de diffs aplicados em ordem, como um histórico de desfazer muito literal. Não é isso que ele é.
O Git é um banco de dados endereçável por conteúdo de alguns tipos de objetos, ligados entre si em um grafo por hashes. Cada comando que você realmente executa - commit, branch, merge, rebase, tag - é uma operação específica e bastante mecânica nesse grafo. Comandos Essenciais do Git cobre o conjunto de comandos diários em torno dos quais esta seção se constrói; esta página é o modelo por baixo dele - a razão pela qual .gitignore exclui .venv/, por que os diffs de notebook se parecem com o que se parecem, e por que um hotfix branch é cortado de uma tag em vez de main.
Resumo
- O Git armazena quatro tipos de objetos - blobs, trees, commits e tags - cada um endereçado pelo hash de seu próprio conteúdo, ligados em um grafo acíclico direcionado; branches e tags são apenas ponteiros nomeados nesse grafo.
- Por que Importa: Quase toda operação "segura vs. perigosa" do Git é segura ou perigosa especificamente por causa do que ela faz a este grafo, e vários pontos problemáticos específicos do Python (diffs de notebook, inchaço de venv, churn de lockfile) rastreiam diretamente de volta ao que é realmente armazenado como um snapshot.
- Conceitos Chave: blob, tree, commit, ref, DAG (grafo acíclico direcionado), armazenamento endereçável por conteúdo.
- Quando Usar Este Modelo: Decidir entre merge e rebase, entender por que um diretório
.venv/nunca deve ser commitado, raciocinar sobre por que um notebook Jupyter faz diff mal por padrão, ou explicar por que um hotfix branch é criado a partir de uma tag de release em vez demain. - Limitações / Trade-offs: O modelo explica o que as operações do Git fazem ao histórico, ele não substitui as convenções de fluxo de trabalho reais de uma equipe, que ainda precisam ser acordadas e aplicadas (veja Melhores Práticas do Git).
- Tópicos Relacionados: ferramentas de diff de notebook, estratégia de branching, churn de lockfile, proteção de branch.
Fundamentos
O armazenamento do Git é construído a partir de quatro tipos de objetos, e cada um deles é identificado pelo hash de seu próprio conteúdo, não por um nome de arquivo, não por um número de sequência, pelo próprio conteúdo.
Um blob armazena o conteúdo bruto do arquivo, apenas bytes, sem nome de arquivo anexado. Um tree armazena uma listagem de diretório: nomes, modos e o hash do blob ou sub-tree para o qual cada nome aponta. Um commit armazena um ponteiro para uma tree (o estado inteiro do repositório naquele momento), um ponteiro para seu commit pai (ou dois, para um merge), um autor e uma mensagem. Uma tag (do tipo anotado) é seu próprio pequeno objeto apontando para um commit, tipicamente com uma mensagem e assinatura.
A consequência que surpreende as pessoas que vêm de um modelo mental de "sequência de diffs": um commit é um snapshot completo, não um diff. Quando você executa git commit, o Git escreve um objeto tree representando o projeto inteiro naquele instante. Ele faz isso eficientemente, porque qualquer arquivo ou sub-tree byte-idêntico a um commit anterior já tem um objeto com esse hash exato, então o Git o reutiliza em vez de armazená-lo duas vezes.
O diff que você vê em git show ou git log -p é computado sob demanda comparando dois snapshots, não é o que está realmente armazenado.
Uma analogia simples: imagine um arquivo onde cada pasta e cada documento é rotulado não pelo nome, mas por um checksum de seu conteúdo exato, e documentos idênticos em qualquer lugar do arquivo compartilham uma cópia física, não importa quantas pastas o referenciem. Um commit é um cartão que diz "o arquivo parecia exatamente com isto, veja o rótulo X para a pasta de nível superior, e o cartão anterior era Y." Esse é todo o modelo.
echo "myproject/.venv/" >> .gitignore
echo "**/__pycache__/" >> .gitignore
git add .gitignore pyproject.toml uv.lock src/
git commit -m "chore: excluir venv local e caches de bytecode"Mecânicas e Interações
Como os objetos se ligam uns aos outros por hash, o histórico completo de um repositório forma um grafo acíclico direcionado (DAG): commits apontam para trás para seus pais, nunca para frente, e nunca em um ciclo.
v1.4.0 (tag) v1.5.0 (tag)
│ │
...──A────B───────C────D────E─────────F──... main
\ /
C1───C2───C3───────────┘ feature/tax-report (merged)
hotfix/1.4.1 branches a partir da tag v1.4.0, não da ponta atual de main:
C (v1.4.0)
\
H1───H2 hotfix/1.4.1
Um branch (main, feature/tax-report) não é um contêiner que guarda um conjunto de commits, é um único ponteiro móvel (uma "ref") para um commit, atualizado automaticamente para apontar para cada novo commit à medida que você faz um. Criar um branch é quase gratuito precisamente por causa disso: git branch feature/x escreve um pequeno arquivo contendo um hash de commit, nada mais. Cada commit no grafo pertence ao próprio grafo, alcançável de onde quer que seja alcançável, não "propriedade" de qualquer ponteiro de branch que por acaso esteja sobre ele no momento.
Um merge commit é o único lugar onde a forma do DAG se torna genuinamente importante: é um objeto commit com dois ponteiros pais em vez de um, que é o que faz do grafo um DAG em vez de uma simples cadeia.
Rebase e merge resolvem o mesmo problema, trazendo o trabalho de outro branch para o seu, através de operações de grafo genuinamente diferentes, não apenas comandos diferentes para o mesmo resultado. Um merge adiciona um novo commit de dois pais no topo de ambas as histórias, deixando todos os commits originais intocados. Um rebase reproduz os commits do seu branch um por um em uma nova base, computando o diff de cada um, e então criando um objeto commit totalmente novo com esse diff aplicado no topo da nova base, mesmo conteúdo, pai diferente, hash diferente. Os commits originais ainda existem no grafo até serem coletados pelo garbage collector; o ponteiro do branch rebaseado agora aponta simplesmente para as novas cópias.
Considerações Avançadas e Aplicações
Equipes Python encontram este modelo de snapshot de frente com dois pontos problemáticos muito específicos que não têm nada a ver com o Git ser não confiável e tudo a ver com o que é tratado como um blob.
Um notebook Jupyter (.ipynb) é um único arquivo JSON que agrupa células de origem e suas saídas renderizadas, incluindo imagens codificadas em base64, em um único documento. Como um commit armazena a árvore inteira, editar uma linha de código, mas reexecutar uma célula muda o JSON de saída também, então um "diff" de uma linha no código fonte pode aparecer como uma parede de JSON alterado no blob commitado. Ferramentas como nbstripout (um hook de pre-commit que remove células de saída antes de staging) ou nbdime (uma ferramenta de diff e merge ciente de notebooks) existem especificamente porque o diff de texto plano do Git não tem consciência especial da estrutura do notebook, ele faz diff do JSON como qualquer outro arquivo de texto - veja Git para Dados/Notebooks para a configuração concreta.
Um ambiente virtual (.venv/) ou diretório __pycache__/ commitado por acidente incha cada clone e checkout futuro, porque esses arquivos se tornam parte de cada snapshot a partir desse commit em diante, mesmo após a remoção posterior, os blobs históricos permanecem no armazenamento de objetos a menos que o próprio histórico seja reescrito. É precisamente por isso que um .gitignore do Python excluindo .venv/, __pycache__/, *.pyc e .mypy_cache/ desde o commit um é mais importante do que pode parecer à primeira vista.
Esse comportamento de rebase, novos commits, não originais editados, é exatamente por que "nunca fazer rebase (ou force-push) de um branch compartilhado" é uma regra real e não apenas um aviso por si só. Se duas pessoas têm um branch descarregado e uma faz force-push de uma versão rebaseada, o ref agora aponta para um conjunto diferente de objetos de commit com hashes diferentes daqueles que o histórico local da outra pessoa ainda aponta, o Git não tem como reconciliar "o mesmo trabalho, hash diferente" automaticamente.
Excluir um branch apenas exclui o ponteiro, não os commits em si, eles se tornam inacessíveis (nenhuma ref aponta mais para eles), mas permanecem no armazenamento de objetos do repositório até que o garbage collection eventualmente os remova, que é o mecanismo real por trás de git gc.
| Estratégia | O que faz ao grafo | Força | Melhor Ajuste |
|---|---|---|---|
| Merge commit | Adiciona um commit de dois pais; originais intocados | Preserva o histórico exato, seguro em branches compartilhados | Branches de longa duração, branches de release de volta para main |
| Rebase | Reproduz commits como novos objetos em uma nova base | Histórico linear e legível | Branches de feature locais antes de serem compartilhados/enviados |
| Squash merge | Colapsa um branch inteiro em um novo commit | Fácil de cherry-pick, histórico limpo de main | Branches de feature mesclando em main, especialmente notebooks |
| Cherry-pick | Copia o diff de um commit existente para outro branch como um novo commit | Move exatamente uma alteração, independente do resto do seu branch | Backport de um hotfix ou commit único para um branch de release |
Equívocos Comuns
- "Um commit armazena um diff do commit anterior." Ele armazena um snapshot completo de toda a árvore do projeto naquele momento; o diff que é mostrado é computado comparando dois snapshots em tempo real, não lido do armazenamento.
- "Um branch é um contêiner que contém um conjunto de commits." Um branch é um único ponteiro para um commit, os commits em si pertencem ao grafo e são simplesmente alcançáveis a partir desse ponteiro, não de propriedade dele.
- "Diffs de notebook são barulhentos porque o Git é ruim em lidar com arquivos grandes." Eles são barulhentos porque as saídas de um notebook são armazenadas inline no mesmo blob JSON que seu código fonte, e o Git faz diff desse blob inteiro como texto plano sem consciência específica de notebook.
- "Excluir
.venv/de um commit posterior o remove do repositório." Ele o remove a partir desse ponto em diante; os blobs históricos de commits anteriores permanecem no armazenamento de objetos e o tamanho do clone e o tamanho do histórico só diminuem se esse histórico for realmente reescrito, o que é disruptivo em um repositório compartilhado. - "Rebase edita os commits originais para movê-los." Ele cria objetos de commit inteiramente novos com novos hashes contendo as mesmas alterações, os originais permanecem no grafo até que nada aponte para eles e sejam eventualmente coletados.
- "Force-push é apenas uma versão mais forte de um push normal." Um push normal só tem sucesso se for um fast-forward; force-push ignora essa verificação e pode mover um ref compartilhado para longe de commits nos quais outros ainda dependem.
FAQs
O que é o modelo de objetos do Git, em uma frase?
Um armazenamento endereçável por conteúdo de quatro tipos de objetos - blobs, trees, commits, tags - cada um identificado pelo hash de seu próprio conteúdo e ligado em um grafo acíclico direcionado no qual branches e tags simplesmente apontam.
Um commit do Git é um snapshot ou um diff?
Um snapshot completo de toda a árvore do projeto naquele momento, referenciando um commit pai, não um diff. O Git o armazena eficientemente reutilizando qualquer arquivo ou sub-árvore que seja byte-idêntico a um já armazenado.
O que é mecanicamente um branch?
Um pequeno arquivo contendo um hash de commit, nada mais. Commitar em um branch apenas atualiza esse ponteiro para o hash do novo commit.
Por que os diffs de notebook Jupyter parecem muito mais barulhentos do que o diff de um arquivo `.py` simples?
Porque as células de saída de um notebook, incluindo imagens embutidas, vivem no mesmo blob JSON que seu código fonte, então reexecutar uma célula muda o snapshot rastreado mesmo que a linha de código fonte não tenha mudado significativamente. O Git faz diff desse JSON como texto plano sem consciência da estrutura do notebook.
Por que commitar `.venv/` incha um repositório mesmo depois de ser removido posteriormente?
Porque cada commit é um snapshot completo, os blobs históricos desses arquivos permanecem como parte de commits anteriores no armazenamento de objetos, independentemente da remoção posterior. O tamanho do clone e o tamanho do histórico só diminuem se esse histórico for realmente reescrito, o que é disruptivo em um repositório compartilhado.
Qual é a diferença mecânica real entre merge e rebase?
Um merge adiciona um novo commit com dois pais no topo de ambas as histórias existentes, deixando todos os commits originais intocados. Um rebase reproduz os diffs dos seus commits em uma nova base, produzindo objetos de commit inteiramente novos com novos hashes.
Por que fazer rebase de um branch compartilhado causa problemas para colaboradores?
Porque o rebase produz novos objetos de commit com hashes diferentes dos originais, um rebase com force-push move o ref do branch para apontar para commits que o histórico local dos colaboradores não reconhece como o mesmo trabalho.
Se eu excluir um branch por engano, o trabalho realmente se foi?
Geralmente não imediatamente, excluir um branch apenas remove o ponteiro, e os commits para os quais ele apontava permanecem no armazenamento de objetos como objetos inacessíveis até que o garbage collection eventualmente os remova. git reflog frequentemente encontra o último hash de commit para que o branch possa ser recriado.
Por que um hotfix branch geralmente começa a partir de uma tag de release em vez de `main`?
Uma tag é um ponto fixo no grafo representando exatamente o que foi lançado como essa versão, enquanto main pode já conter trabalho não lançado quando um hotfix é necessário. Fazer um branch a partir da tag garante que o hotfix comece precisamente do que está realmente em execução em produção.
O que o squash-merging realmente faz ao grafo de commits?
Ele computa um diff combinado em todos os commits do branch de feature e o escreve como um único novo commit com um pai no branch de destino, em vez de preservar cada commit intermediário.
Por que fazer force-push em `main` especificamente é perigoso, mecanicamente?
Force-push ignora a verificação normal de fast-forward do Git e pode mover o ref main para apontar para um commit diferente do que os colaboradores esperam em seus históricos locais, deixando órfão o trabalho construído sobre os commits que foram movidos para longe.
Relacionados
- Comandos Essenciais do Git - o conjunto de comandos diários que este modelo explica
- Git para Dados/Notebooks -
nbstripoutenbdimena prática - Branching & Merging - estratégias de branch construídas sobre este modelo de grafo
- Git Worktrees - um segundo diretório de trabalho descarregado do mesmo grafo
- Melhores Práticas do Git - regras condensadas que seguem deste modelo
Versões de Stack: Esta página é conceitual e não está vinculada a uma versão específica de stack.