DevOps, CI/CD e contêineres

Fundamentos de Git: commits, working tree, remotos e como desfazer mudanças

Iniciante10 min de leituraAtualizado

Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês

Fica muito mais fácil usar bem o Git quando você entende o modelo por trás dos comandos: um grafo de snapshots imutáveis, três lugares onde uma mudança pode estar e branches que não passam de ponteiros móveis. Este guia constrói esse modelo e depois cobre o fluxo de trabalho diário — clone, add, commit, fetch, pull, push — e as diferentes formas de desfazer as coisas, para você se recuperar de erros sem medo.

O que você vai aprender

  • Explicar como o Git guarda o histórico como um grafo de snapshots endereçados por conteúdo
  • Mover mudanças de forma deliberada entre o working tree, o índice e o repositório
  • Sincronizar com um remoto usando fetch, pull e push e entender o que eles informam
  • Desfazer erros com restore, reset, revert e o reflog, escolhendo sempre a opção segura
  • Configurar o Git, ignorar arquivos corretamente e escrever mensagens de commit que ajudem quem vai ler depois

A maioria dos tutoriais de Git ensina uma sequência de comandos para decorar. Isso funciona até algo dar errado — e, com Git, uma hora algo sempre dá errado: um commit na branch errada, um pull que gerou um merge inesperado, um reset que parece ter engolido uma tarde de trabalho. A saída é entender o pequeno conjunto de ideias sobre o qual os comandos são construídos. O modelo do Git é mais simples do que a linha de comando dá a entender, e, quando você consegue visualizar o que um comando faz com o grafo de commits, o manual de referência passa a fazer sentido e o pânico vai embora. Este guia é voltado ao Git 2.4x e usa os comandos modernos switch e restore onde materiais mais antigos usavam checkout para tudo.

O que o Git armazena

Um repositório Git é um banco de dados com quatro tipos de objeto, cada um identificado pelo hash SHA-1 (ou SHA-256, em repositórios mais novos) do seu conteúdo:

  • Um blob é o conteúdo de um arquivo, sem nome e sem metadados.
  • Um tree é a listagem de um diretório: nomes, permissões e os hashes dos blobs e sub-trees que ele contém.
  • Um commit aponta para um tree (o snapshot completo do projeto naquele momento) e para zero ou mais commits pais, e carrega o autor, o committer, os timestamps e a mensagem.
  • Um objeto tag associa a um commit um nome, uma mensagem e, opcionalmente, uma assinatura.

Daí decorrem duas consequências. Primeiro, um commit é um snapshot, não um diff: o Git armazena a árvore inteira a cada vez, deduplicada por hash, de modo que arquivos inalterados não custam nada e dá para fazer checkout de qualquer commit sem reprocessar o histórico. Os diffs são calculados sob demanda. Segundo, tudo é imutável e endereçado por conteúdo: mude um único byte em qualquer ponto do histórico e todos os hashes acima dele mudam. É por isso que "reescrever o histórico" significa criar commits novos, e é por isso que um hash é um identificador confiável de exatamente um estado do código.

Os commits, ligados aos seus pais, formam um grafo acíclico dirigido. Uma branch não passa de um arquivo contendo o hash de um commit — um ponteiro móvel que avança quando você faz commit nela. O HEAD é um ponteiro para a branch em que você está no momento. Criar uma branch é escrever 41 bytes em um arquivo, e é por isso que a operação é instantânea e que quem usa Git cria branches com muito mais liberdade do que quem usa sistemas mais antigos.

As três áreas

Uma mudança passa por três lugares, e boa parte da confusão com o Git vem de não saber qual deles um comando afeta.

ÁreaO que éComandos que escrevem nela
Working treeOs arquivos em disco que você editaeditor, git restore, git switch, git merge
Índice (staging area)O próximo commit proposto, guardado em .git/indexgit add, git rm, git restore --staged, git reset
RepositórioO banco de objetos e as refs dentro de .gitgit commit, git fetch, git merge, git reset

O índice é a peça que mais causa estranheza a quem está começando. Ele existe para você montar um commit de forma deliberada: você edita cinco arquivos, prepara os três que fazem sentido juntos, faz o commit e depois prepara os outros dois em um commit separado. O git add -p vai além e prepara hunks individuais dentro de um arquivo. O git status sempre mostra tanto o que está preparado quanto o que foi modificado e não está; o git diff compara o working tree com o índice, e o git diff --staged compara o índice com o último commit.

git status                # what is staged, modified, untracked
git add src/Order.java    # stage one file
git add -p                # stage hunks interactively
git diff --staged         # review exactly what the commit will contain
git commit -m "Reject orders with a negative quantity"

O fluxo de trabalho diário

Partindo de um projeto existente:

git clone [email protected]:acme/shop.git
cd shop
git switch -c fix-negative-quantity     # create and switch to a branch
# ... edit, test ...
git add -A
git commit
git push -u origin fix-negative-quantity  # -u sets the upstream so later pushes need no arguments

O clone copia o repositório inteiro — todo o histórico, todas as branches — e configura um remoto chamado origin apontando para a origem. Para cada branch do remoto você ganha uma branch de rastreamento remoto: origin/main, origin/release-2.4 e assim por diante. Elas são marcadores somente leitura de como o remoto estava na última vez que você se comunicou com ele; você nunca faz commit diretamente nelas.

O git fetch atualiza as branches de rastreamento remoto e baixa os objetos novos, sem alterar nada nas suas próprias branches. O git pull é um fetch seguido da integração de origin/<branch> na sua branch atual, por merge ou rebase, conforme a configuração. Como um pull pode gerar um commit de merge que você não queria, muitos desenvolvedores configuram git config --global pull.rebase true, para que os commits locais sejam reaplicados em cima dos que foram baixados e o histórico continue linear; o guia de fluxos de trabalho com branches discute esse trade-off.

O git push envia seus commits para o remoto e faz a branch de lá avançar. Ele é recusado se a branch remota tiver commits que você não tem — um non-fast-forward —, o que impede você de sobrescrever o trabalho de outra pessoa. A resposta correta é fazer fetch, integrar e fazer push de novo; reserve o --force para branches em que só você trabalha e, mesmo assim, prefira --force-with-lease, que falha se o remoto tiver mudado desde o seu último fetch.

Lendo o histórico

git log --oneline --graph --decorate -20   # compact graph with branch and tag labels
git log -p -- src/Order.java               # every change to one file, with diffs
git log --author=alice --since=2.weeks
git show a1b2c3d                           # one commit: metadata plus diff
git blame src/Order.java                   # which commit last touched each line
git diff main...feature                    # what feature adds relative to the merge base

Vale a pena aprender a forma com três pontos da última linha: A..B significa "commits alcançáveis a partir de B, mas não de A", enquanto A...B no git diff significa "mudanças em B desde que ela divergiu de A", que é exatamente o que você quer ao revisar uma branch.

Desfazendo com segurança

O Git oferece várias formas de desfazer, e a escolha certa depende de duas perguntas: a mudança já foi commitada? E já foi compartilhada?

Descartar edições não commitadas no working tree. git restore <file> substitui o arquivo pela versão do índice. A mudança se perde de vez — ela nunca foi commitada —, então confira o git diff antes.

Tirar do índice sem perder as edições. git restore --staged <file> copia de volta para o índice a versão do último commit e não mexe no working tree.

Corrigir o último commit. git commit --amend substitui o commit da ponta por um novo contendo o índice atual (e uma mensagem possivelmente editada). Tudo bem antes do push; depois do push, isso reescreve histórico compartilhado.

Mover uma branch para trás. git reset move o ponteiro da branch atual para outro commit. Com --soft, o índice e o working tree mantêm as mudanças posteriores (útil para juntar os últimos commits em um só); com --mixed, o padrão, o índice é resetado, mas os arquivos ficam intactos; com --hard, tudo passa a corresponder ao commit de destino e o trabalho não commitado é destruído.

git reset --soft HEAD~3     # collapse the last three commits into staged changes
git commit -m "Implement discount codes"

Desfazer um commit compartilhado. git revert <hash> cria um commit novo que aplica a mudança inversa. O histórico é preservado, nada do que outras pessoas baixaram fica inválido, e essa é a única forma correta de desfazer algo na main depois do push.

Recuperar commits "perdidos". Toda vez que o HEAD se move, o Git registra onde ele estava no reflog. Depois de um reset malfeito ou de uma branch apagada, git reflog lista as posições recentes, e git reset --hard HEAD@{2} ou git switch -c rescued <hash> traz os commits de volta. Objetos inalcançáveis são mantidos por pelo menos 30 dias por padrão, então quase tudo o que foi commitado pode ser recuperado dentro desse prazo.

Stash

O git stash salva as mudanças do working tree e do índice em uma pilha e limpa o working tree, para você trocar de branch, corrigir algo urgente e voltar depois. O git stash pop restaura o stash mais recente e o descarta; o git stash list mostra o que está guardado; git stash push -m "wip: pricing" -- src/pricing guarda só alguns caminhos, com um rótulo. Stashes também são commits, e um pop que gera conflito mantém tanto o stash quanto os marcadores de conflito, então nada se perde.

Ignorando arquivos e configurando o Git

Um arquivo .gitignore lista padrões de arquivos que o Git não deve rastrear — saída de build, diretórios de dependências, configurações de editor, segredos. Os padrões são avaliados em relação ao diretório do próprio arquivo; uma / no início ancora o padrão nesse diretório, uma / no final casa apenas com diretórios, e ! nega o padrão.

# Build output
/target/
/build/
node_modules/

# IDE and OS noise
.idea/
*.iml
.vscode/
.DS_Store

# Never commit secrets
.env
*.pem

Duas coisas costumam pegar as pessoas de surpresa. O .gitignore não afeta arquivos que já são rastreados — primeiro é preciso tirá-los com git rm --cached. E uma entrada de ignore não é um controle de segurança: um arquivo commitado uma única vez fica no histórico até o histórico ser reescrito, e qualquer segredo que ele continha precisa ser trocado.

A configuração existe em três níveis — sistema, global (~/.gitconfig) e repositório (.git/config) —, cada um sobrepondo o anterior. As configurações que vale aplicar em toda máquina nova:

git config --global user.name "Alice Martin"
git config --global user.email [email protected]
git config --global init.defaultBranch main
git config --global pull.rebase true
git config --global push.autoSetupRemote true   # first push needs no -u
git config --global core.autocrlf input          # on Linux/macOS; "true" on Windows
git config --global rerere.enabled true          # remember how you resolved conflicts

Tags

Uma tag marca um commit como importante, geralmente uma release. Tags leves (git tag v2.4.0) são simples ponteiros; tags anotadas (git tag -a v2.4.0 -m "Release 2.4.0") são objetos com autor, data e mensagem, e podem ser assinadas. Use tags anotadas para releases: elas registram a procedência e o git describe dá preferência a elas. Tags não são enviadas por padrão — use git push origin v2.4.0 ou git push --tags — e, ao contrário das branches, foram feitas para ser permanentes: mover uma tag que outras pessoas já baixaram causa confusão duradoura.

Escrevendo commits que valem a leitura

Um commit é uma unidade de mudança e uma mensagem para quem vai ler o histórico um ano depois, muitas vezes no meio de um bisect atrás de um bug. Mantenha cada commit restrito a uma única mudança lógica, para que ele possa ser revertido ou aplicado com cherry-pick isoladamente. Escreva a linha de assunto no imperativo ("Add retry to payment client"), com cerca de 50 caracteres no máximo, e coloque o porquê no corpo: qual problema existia, por que essa abordagem, quais alternativas foram descartadas. O diff já mostra o que mudou; a mensagem existe para explicar o que o diff não consegue.

O git bisect é a recompensa por commits pequenos e bem descritos. A partir de um commit sabidamente bom e de outro sabidamente ruim, ele faz checkout do ponto intermediário, você (ou um script) informa se está bom ou ruim, e ele vai cercando o commit culpado em um número logarítmico de passos. Só é útil quando todo commit compila e faz uma única coisa.

Erros comuns

  • Commitar arquivos gerados e dependências. target/, node_modules/ e arquivos .class incham o repositório e causam conflitos sem fim. Ignore-os desde o primeiro commit.
  • git add . no diretório errado. Ele prepara tudo abaixo do diretório atual, inclusive arquivos que você não pretendia incluir. Confira o git status antes de commitar.
  • Fazer force push em uma branch compartilhada. Os pushes dos colegas são recusados, as branches locais deles divergem e alguém acaba reenviando o histórico antigo. Use --force-with-lease e apenas em branches pessoais.
  • Fazer pull com mudanças não commitadas. Um merge ou rebase sobre arquivos modificados gera conflitos confusos. Faça commit ou stash antes.
  • Tratar reset --hard como resposta para tudo. Ele destrói o trabalho não commitado; use stash no lugar e o reflog para voltar a commits.
  • Commitar segredos "temporariamente". Eles ficam no histórico. Troque a credencial imediatamente e use git filter-repo se for preciso limpar o histórico.
  • Commits gigantes. Um commit de 60 arquivos com o título "changes" é impossível de revisar, reverter ou usar no bisect. Prepare as mudanças em partes lógicas.

Principais pontos

  • O Git armazena snapshots imutáveis, endereçados por conteúdo e ligados em um grafo; branches são ponteiros móveis dentro desse grafo e o HEAD marca onde você está.
  • As mudanças ficam no working tree, no índice ou no repositório; status, diff e diff --staged dizem em qual, e o add monta o próximo commit de forma deliberada.
  • O fetch atualiza as branches de rastreamento remoto sem mexer no seu trabalho; o pull também integra; o push se recusa a sobrescrever commits que você não viu.
  • Desfaça trabalho não commitado com restore, reorganize commits locais com reset e amend, desfaça commits compartilhados com revert e recupere-se de quase tudo com o reflog.
  • Mantenha os arquivos gerados fora do repositório com .gitignore, configure pull.rebase e as opções de fim de linha globalmente e use tags anotadas para releases.
  • Commits pequenos, com um único propósito e mensagens que explicam o motivo, são o que torna o histórico, o bisect e a revisão de código úteis mais tarde.

Fontes e leituras complementares

Perguntas frequentes

Qual é a diferença entre git fetch e git pull?
O fetch baixa os commits novos e atualiza as branches de rastreamento remoto (como origin/main) sem mexer na sua branch nem no seu working tree. O pull é um fetch seguido de merge ou rebase na sua branch atual. Faça fetch primeiro quando quiser examinar o que mudou antes de integrar; use pull quando estiver pronto para integrar direto.
Fiz commit na branch errada. Como corrijo?
Se você ainda não fez push, anote o hash do commit pelo git log, mude para a branch certa e rode git cherry-pick com esse hash; depois volte para a branch errada e rode git reset --hard HEAD~1 para remover o commit de lá. Se a branch errada já foi enviada, prefira git revert para não reescrever um histórico que outras pessoas talvez já tenham baixado.
git reset --hard é perigoso?
Ele descarta de forma permanente as mudanças não commitadas do working tree e do índice — elas nunca estiveram em um commit, então nada consegue recuperá-las. Os commits que o reset deixa para trás não são apagados na hora: o git reflog continua listando-os por pelo menos 30 dias por padrão, e você pode voltar para eles com outro reset. Antes de um reset --hard, rode git status e guarde com git stash tudo o que possa querer manter.
Por que o Git diz 'detached HEAD'?
Normalmente o HEAD aponta para o nome de uma branch, e cada commit faz a branch avançar. Se você fizer checkout de um commit ou de uma tag específica, o HEAD passa a apontar diretamente para esse commit. Os commits feitos nesse estado não pertencem a nenhuma branch e acabam removidos pelo garbage collector, a menos que você rode git switch -c alguma-branch para criar uma naquele ponto.