A maior parte da confusão com o Git - "por que meu branch desapareceu", "por que rebase é perigoso, mas merge não é", "por que deletar um branch parece assustador" - 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 um punhado de 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. Fundamentos do Git para Equipes Node cobre o fluxo de trabalho baseado em trunk e guiado por tags que esta cookbook recomenda; esta página é o modelo por baixo dele - a razão pela qual as regras desse fluxo de trabalho (nunca force-push em main, crie hotfixes a partir de tags, cherry-pick em vez de merge em release/*) funcionam da maneira que funcionam.
O Git armazena quatro tipos de objetos - blobs, trees, commits e tags - cada um endereçado pelo hash SHA 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 - entender o grafo transforma regras memorizadas em regras raciocinadas.
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 force-push é destrutivo, raciocinar sobre por que os commits de um branch deletado não desaparecem imediatamente, ou explicar por que branches de hotfix são cortados de uma tag em vez de main.
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 de Git e GitHub).
Tópicos Relacionados: desenvolvimento baseado em trunk, cherry-picking, proteção de branch, tags de release acionadas por CI.
O armazenamento do Git é construído a partir de quatro tipos de objetos, e cada um deles é identificado pelo hash SHA-1 (ou, em repositórios mais novos, SHA-256) de seu próprio conteúdo - não por um nome de arquivo, não por um número de sequência, mas pelo próprio conteúdo.
Um blob armazena o conteúdo bruto de um 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 completo 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 de forma eficiente, porque qualquer arquivo, subdiretório ou tree inteira que seja byte-a-byte idêntica a um commit anterior já tem um objeto com aquele 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 recebe um rótulo 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 a referenciem. Um commit é um cartão que diz "o arquivo parecia exatamente assim - veja o rótulo X para a pasta de nível superior - e o cartão anterior era Y." Esse é todo o modelo.
Como os objetos se ligam 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 (ou pais), nunca para frente, e nunca em um ciclo.
v2.5.0 (tag) v2.6.0 (tag)
│ │
...──A────B───────C────D────E─────────F──... main
\ /
C1───C2───C3───────────┘ feature/refund-api (merged)
hotfix/2.5.1 branches a partir da tag v2.5.0, não da ponta atual de main:
C (v2.5.0)
\
H1───H2 hotfix/2.5.1
Um branch (main, feature/refund-api) 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ê o cria. 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 "pertence" a 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 pai em vez de um, o que faz do grafo um DAG em vez de uma simples cadeia. git checkout -b hotfix/2.5.1 v2.5.0, de Fundamentos do Git para Equipes Node, funciona porque uma tag é um ponto permanente e bem conhecido neste mesmo grafo - fazer um branch a partir dela significa que o hotfix começa de exatamente o que foi lançado como v2.5.0, não de onde quer que main esteja hoje, que pode já conter trabalho não lançado.
Rebase e merge resolvem o mesmo problema - "trazer o trabalho de outro branch para o meu" - 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 ambos os históricos, deixando todos os commits originais intocados. Um rebase reproduz os commits do seu branch um por um sobre uma nova base, o que significa que o Git calcula o diff de cada um, depois cria um objeto commit totalmente novo com esse diff aplicado sobre a 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 rebasado agora aponta para as novas cópias em vez disso.
Esse comportamento de rebase - novos commits, não originais editados - é exatamente por que "nunca fazer rebase (ou force-push) em um branch compartilhado" é uma regra real e não apenas uma cautela sem motivo. Se duas pessoas têm um branch em checkout e uma delas faz force-push de uma versão rebasada, a ref agora aponta para um conjunto diferente de objetos 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, e o resultado é um histórico duplicado ou conflitante. É precisamente por isso que Melhores Práticas de Git e GitHub proíbe force-push em main, release/* e hotfix/* - esses branches são compartilhados o suficiente que uma ref reescrita orfanaria silenciosamente a visão de histórico de outra pessoa.
Deletar um branch apenas deleta o ponteiro, não os commits em si - eles se tornam inacessíveis (nenhuma ref aponta para eles mais) mas permanecem no armazenamento de objetos do repositório até que o garbage collection eventualmente os remova, que é por isso que git reflog muitas vezes pode recuperar um branch que foi deletado por engano, às vezes por semanas depois.
Squash-merging um branch de feature (o padrão desta cookbook para merge em main - veja Fundamentos do Git para Equipes Node) é um colapso específico e deliberado deste modelo: em vez de um commit de merge de dois pais preservando cada commit intermediário, o Git calcula um diff combinado em todo o branch e o escreve como um único novo commit com um pai. É precisamente por isso que fazer cherry-pick de uma feature com squash para um branch release/* é simples - há exatamente um SHA de commit para fazer cherry-pick em vez de vários.
Em escala, o Git não mantém cada objeto como um arquivo solto separado para sempre - ele periodicamente empacota muitos objetos em packfiles comprimidos e remove aqueles que são inacessíveis e passaram de um período de carência, que é o mecanismo real por trás de git gc. Nada disso muda o modelo, apenas sua eficiência de armazenamento quando um repositório acumula anos de histórico.
Estratégia
O que faz ao grafo
Força
Melhor Encaixe
Merge commit
Adiciona um commit de dois pais; originais intocados
Preserva o histórico exato, seguro em branches compartilhados
Branches de longa duração, 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
Simples de fazer cherry-pick, histórico limpo de main
Branches de feature mesclando em main (padrão desta cookbook)
Cherry-pick
Copia o diff de um commit existente para outro branch como um novo commit
Move exatamente uma mudança, independente do resto do seu branch
Backporting de um hotfix ou commit único para release/*
"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 pertencem a ele.
"Deletar um branch deleta seus commits imediatamente." Ele remove o ponteiro; os commits se tornam inacessíveis, mas persistem no armazenamento de objetos até o garbage collection eventualmente os remover, que é por isso que git reflog pode frequentemente recuperá-los.
"Rebase edita os commits originais para movê-los." Ele cria objetos commit totalmente novos com novos hashes contendo as mesmas mudanças - os originais permanecem no grafo até que nada aponte para eles e sejam eventualmente coletados.
"Tags e branches são basicamente o mesmo tipo de coisa." Um ponteiro de branch se move para frente automaticamente com cada novo commit feito nele; uma tag é destinada a ser uma referência fixa a um commit específico e normalmente nunca se move depois de criada.
"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 uma ref compartilhada para longe de commits dos quais outros ainda dependem, orfanando sua visão do histórico.
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-a-byte idêntico a um já armazenado, mas conceitualmente cada commit é uma imagem completa do repositório.
O que é mecanicamente um branch?
Um pequeno arquivo contendo um hash de commit - nada mais. Fazer um commit em um branch apenas atualiza esse único ponteiro para o hash do novo commit; os commits pelos quais ele passa pertencem ao grafo compartilhado, não ao branch em si.
Por que criar um branch no Git é considerado "gratuito"?
Porque ele apenas escreve um ponteiro para um commit existente - nenhum arquivo é copiado, nenhum histórico é duplicado. O custo de um branch é essencialmente zero, independentemente do tamanho do histórico do repositório.
O que torna o histórico do Git especificamente um "DAG"?
Cada commit aponta para trás para seu(s) pai(s) e nunca para frente ou em um ciclo, e os commits de merge - os únicos commits com dois pais - são o que dão ao grafo uma estrutura de ramificação em vez de ser uma única linha reta.
Qual é a diferença mecânica real entre merge e rebase?
Um merge adiciona um novo commit com dois pais no topo de ambos os históricos existentes, deixando todos os commits originais intocados. Um rebase reproduz os diffs dos seus commits em uma nova base, produzindo objetos commit totalmente novos com novos hashes - os antigos ainda existem até serem coletados pelo garbage collector.
Por que fazer rebase em um branch compartilhado causa problemas para colaboradores?
Porque rebase produz novos objetos commit com hashes diferentes dos originais, então um rebase com force-push move a ref do branch para apontar para commits que o histórico local de um colaborador não reconhece como o mesmo trabalho - o Git não consegue reconciliar isso automaticamente.
Se eu deletar um branch por engano, o trabalho realmente se perde?
Geralmente não imediatamente - deletar 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 branch de hotfix no fluxo de trabalho desta cookbook começa de uma tag em vez de `main`?
Uma tag é um ponto fixo no grafo que representa 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 exatamente do que está realmente em produção.
O que o squash-merging realmente faz ao grafo de commits?
Ele calcula 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 ou adicionar um commit de merge de dois pais.
Por que fazer cherry-pick de um hotfix para `release/*` é mais simples após um squash merge?
Porque o squash-merging colapsou a feature inteira em um único commit, há exatamente um SHA para fazer cherry-pick - uma feature não-squashed exigiria pegar vários commits, ou pegar um commit de merge de uma forma que precise de um flag extra para especificar de qual pai usar o diff.
Por que fazer force-push em `main` especificamente é perigoso, mecanicamente?
Force-push ignora a verificação normal de apenas fast-forward do Git e pode mover a ref main para apontar para um commit diferente do que os históricos locais dos colaboradores esperam, orfanando qualquer trabalho construído sobre os commits que foram movidos para longe.