Como desenvolvedor, você trabalha com Git o tempo todo.
Você já chegou a um ponto em que disse: "Uh-oh, o que eu acabei de fazer?"
Este post lhe dará as ferramentas para reescrever a história com confiança.
Eu também dei uma palestra ao vivo cobrindo o conteúdo deste post. Se você preferir um vídeo (ou deseja assisti-lo junto com a leitura) — você pode encontrá-lo
Estou trabalhando em um livro sobre Git! Você está interessado em ler as versões iniciais e fornecer feedback? Envie-me um e-mail: [email protected]
Antes de entender como desfazer as coisas no Git, você deve primeiro entender como registramos as alterações no Git. Se você já conhece todos os termos, sinta-se à vontade para pular esta parte.
É muito útil pensar no Git como um sistema para gravar instantâneos de um sistema de arquivos no tempo. Considerando um repositório Git, ele possui três “estados” ou “árvores”:
Normalmente, quando trabalhamos em nosso código-fonte, trabalhamos a partir de um diretório de trabalho . Um diretório de trabalho (ectrory) (ou árvore de trabalho ) é qualquer diretório em nosso sistema de arquivos que tenha um repositório associado a ele.
Ele contém as pastas e arquivos do nosso projeto e também um diretório chamado .git
. Descrevi o conteúdo da pasta .git
com mais detalhes em
Depois de fazer algumas alterações, você pode querer gravá-las em seu repositório . Um repositório (resumindo: repo ) é uma coleção de commits , cada um dos quais é um arquivo de como era a árvore de trabalho do projeto em uma data passada, seja na sua máquina ou na de outra pessoa.
Um repositório também inclui outras coisas além de nossos arquivos de código, como HEAD
, branches, etc.
No meio, temos o índice ou a área de preparação ; esses dois termos são intercambiáveis. Quando checkout
uma ramificação, o Git preenche o índice com todo o conteúdo do arquivo que foi verificado pela última vez em nosso diretório de trabalho e como eles se pareciam quando foram originalmente verificados.
Quando usamos git commit
, o commit é criado com base no estado do índice .
Assim, o índice, ou a área de preparação, é o seu playground para o próximo commit. Você pode trabalhar e fazer o que quiser com o índice , adicionar arquivos a ele, remover coisas dele e, somente quando estiver pronto, vá em frente e se comprometa com o repositório.
Hora de colocar a mão na massa 🙌🏻
Use git init
para inicializar um novo repositório. Escreva algum texto em um arquivo chamado 1.txt
:
Dos três estados de árvore descritos acima, onde está 1.txt
agora?
Na árvore de trabalho, como ainda não foi introduzido no índice.
Para organizá- lo, para adicioná-lo ao índice, use git add 1.txt
.
Agora, podemos usar git commit
para confirmar nossas alterações no repositório.
Você criou um novo objeto commit, que inclui um ponteiro para uma árvore que descreve toda a árvore de trabalho. Neste caso, será apenas 1.txt
dentro da pasta raiz. Além de um ponteiro para a árvore, o objeto commit inclui metadados, como carimbos de data/hora e informações do autor.
Para obter mais informações sobre os objetos no Git (como commits e árvores),
(Sim, “check-out”, trocadilho intencional 😇)
O Git também nos informa o valor SHA-1 desse objeto de confirmação. No meu caso, foi c49f4ba
(que são apenas os 7 primeiros caracteres do valor SHA-1, para economizar espaço).
Se você executar este comando em sua máquina, obterá um valor SHA-1 diferente, pois é um autor diferente; além disso, você criaria o commit em um carimbo de data/hora diferente.
Quando inicializamos o repositório, o Git cria uma nova ramificação (chamada main
por padrão). Emain
. O que acontece se você tiver várias filiais? Como o Git sabe qual branch é o branch ativo?
O Git tem outro ponteiro chamado HEAD
, que aponta (geralmente) para um branch, que então aponta para um commit. Por falar nisso,HEAD
Hora de introduzir mais mudanças no repositório!
Agora, quero criar outro. Então, vamos criar um novo arquivo e adicioná-lo ao índice, como antes:
Agora, é hora de usar git commit
. É importante ressaltar que git commit
faz duas coisas:
Primeiro, ele cria um objeto commit, então há um objeto dentro do banco de dados interno do Git com um valor SHA-1 correspondente. Este novo objeto commit também aponta para o commit pai. Esse é o commit para o qual HEAD
estava apontando quando você escreveu o comando git commit
.
Em segundo lugar, git commit
move o ponteiro do branch ativo — em nosso caso, seria main
, para apontar para o objeto commit recém-criado.
Para reescrever a história, vamos começar desfazendo o processo de introdução de um commit. Para isso, vamos conhecer o comando git reset
, uma ferramenta superpoderosa.
git reset --soft
Portanto, o último passo que você fez antes foi git commit
, o que na verdade significa duas coisas — Git criou um objeto commit e moveu main
, o branch ativo. Para desfazer esta etapa, use o comando git reset --soft HEAD~1
.
A sintaxe HEAD~1
refere-se ao primeiro pai de HEAD
. Se eu tivesse mais de um commit no grafo de commit, diga “Commit 3” apontando para “Commit 2”, que por sua vez está apontando para “Commit 1”.
E diga que HEAD
estava apontando para “Commit 3”. Você poderia usar HEAD~1
para se referir a “Commit 2”, e HEAD~2
se referiria a “Commit 1”.
Então, de volta ao comando: git reset --soft HEAD~1
Este comando pede ao Git para alterar qualquer HEAD
que esteja apontando. (Nota: Nos diagramas abaixo, eu uso *HEAD
para “qualquer coisa que HEAD
esteja apontando”). Em nosso exemplo, HEAD
está apontando para main
. Portanto, o Git apenas mudará o ponteiro de main
para apontar para HEAD~1
. Ou seja, main
apontará para “Commit 1”.
No entanto, esse comando não afetou o estado do índice ou da árvore de trabalho. Portanto, se você usar git status
, verá que 2.txt
está preparado, assim como antes de executar git commit
.
E o git log?
Ele começará em HEAD
, vá para main
e depois para “Commit 1”. Observe que isso significa que “Commit 2” não está mais acessível em nosso histórico.
Isso significa que o objeto de confirmação de “Commit 2” foi excluído? 🤔
Não, não foi deletado. Ele ainda reside no banco de dados interno de objetos do Git.
Se você enviar o histórico atual agora, usando git push
, o Git não enviará “Commit 2” para o servidor remoto, mas o objeto commit ainda existe em sua cópia local do repositório.
Agora, faça commit novamente — e use a mensagem de commit de “Commit 2.1” para diferenciar esse novo objeto do “Commit 2” original:
Por que “Commit 2” e “Commit 2.1” são diferentes? Mesmo se usássemos a mesma mensagem de commit, e mesmo que eles apontem para1.txt
e 2.txt
), eles ainda possuem timestamps diferentes, pois foram criados em momentos diferentes.
No desenho acima, mantive o “Commit 2” para lembrar que ele ainda existe no banco de dados de objetos internos do Git. Ambos “Commit 2” e “Commit 2.1” agora apontam para “Commit 1”, mas apenas “Commit 2.1” é acessível a partir HEAD
.
É hora de voltar ainda mais e desfazer ainda mais. Desta vez, use git reset --mixed HEAD~1
(observação: --mixed
é a opção padrão para git reset
).
Este comando começa da mesma forma que git reset --soft HEAD~1
. O que significa que ele pega o ponteiro de qualquer HEAD
que esteja apontando agora, que é o branch main
, e o define como HEAD~1
, em nosso exemplo — “Commit 1”.
Em seguida, o Git vai além, desfazendo efetivamente as alterações que fizemos no índice. Ou seja, alterando o índice para que coincida com o HEAD
atual, o novo HEAD
após defini-lo na primeira etapa.
Se executarmos git reset --mixed HEAD~1
, isso significa que HEAD
seria definido como HEAD~1
(“Commit 1”) e, em seguida, Git corresponderia o índice ao estado de “Commit 1” — nesse caso, significa que 2.txt
não fará mais parte do índice.
É hora de criar um novo commit com o estado do original “Commit 2”. Desta vez, precisamos preparar 2.txt
novamente antes de criá-lo:
Vá em frente, desfaça ainda mais!
Vá em frente e execute git reset --hard HEAD~1
Novamente, o Git começa com o estágio --soft
, configurando qualquer HEAD
para o qual esteja apontando ( main
), para HEAD~1
(“Commit 1”).
Até agora tudo bem.
Em seguida, passando para o estágio --mixed
, combinando o índice com HEAD
. Ou seja, o Git desfaz a preparação de 2.txt
.
É hora da etapa --hard
onde o Git vai ainda mais longe e combina o diretório de trabalho com o estágio do índice. Neste caso, significa remover 2.txt
também do diretório de trabalho.
(**Observação: neste caso específico, o arquivo não foi rastreado, portanto não será excluído do sistema de arquivos; não é realmente importante para entender git reset
).
Portanto, para introduzir uma alteração no Git, você tem três etapas. Você altera o diretório de trabalho, o índice ou a área de preparação e, em seguida, confirma um novo instantâneo com essas alterações. Para desfazer essas alterações:
git reset --soft
, desfazemos a etapa de confirmação.
git reset --mixed
, também desfazemos a etapa de preparação.
git reset --hard
, desfazemos as alterações no diretório de trabalho. Então, em um cenário da vida real, escreva “Eu amo o Git” em um arquivo ( love.txt
), pois todos nós amamos o Git 😍. Vá em frente, stage e confirme isso também:
Opa!
Na verdade, eu não queria que você o cometesse.
O que eu realmente queria que você fizesse era escrever mais algumas palavras de amor neste arquivo antes de enviá-lo.
O que você pode fazer?
Bem, uma maneira de superar isso seria usar git reset --mixed HEAD~1
, desfazendo efetivamente as ações de commit e staging que você realizou:
Portanto, main
aponta para “Commit 1” novamente e love.txt
não faz mais parte do índice. No entanto, o arquivo permanece no diretório de trabalho. Agora você pode ir em frente e adicionar mais conteúdo a ele:
Vá em frente, prepare e confirme seu arquivo:
Muito bem 👏🏻
Você tem esse histórico claro e agradável de “Commit 2.4” apontando para “Commit 1”.
Agora temos uma nova ferramenta em nossa caixa de ferramentas, git reset
💪🏻
Essa ferramenta é super, super útil e você pode realizar quase tudo com ela. Nem sempre é a ferramenta mais conveniente de usar, mas é capaz de resolver quase todos os cenários de reescrita da história se você usá-la com cuidado.
Para iniciantes, recomendo usar apenas git reset
para quase todas as vezes que você quiser desfazer no Git. Depois de se sentir confortável com isso, é hora de passar para outras ferramentas.
Consideremos outro caso.
Crie um novo arquivo chamado new.txt
; palco e cometer:
Ops. Na verdade, isso é um erro. Você estava em main
e eu queria que você criasse este commit em um branch de recursos. meu mal 😇
Existem duas ferramentas mais importantes que eu quero que você tire deste post. O segundo é git reset
. A primeira e muito mais importante é colocar no quadro branco o estado atual versus o estado em que você deseja estar.
Para este cenário, o estado atual e o estado desejado têm a seguinte aparência:
Você notará três mudanças:
main
aponta para “Commit 3” (o azul) no estado atual, mas para “Commit 2.4” no estado desejado.
A ramificação feature
não existe no estado atual, mas existe e aponta para “Commit 3” no estado desejado.
HEAD
aponta para o main
no estado atual e para feature
no estado desejado.
Se você pode desenhar isso e sabe como usar git reset
, você pode definitivamente sair dessa situação.
Então, novamente, a coisa mais importante é respirar e extrair isso.
Observando o desenho acima, como passamos do estado atual ao desejado?
Claro que existem algumas formas diferentes, mas vou apresentar apenas uma opção para cada cenário. Sinta-se à vontade para brincar com outras opções também.
Você pode começar usando git reset --soft HEAD~1
. Isso definiria main
para apontar para o commit anterior, “Commit 2.4”:
Olhando novamente para o diagrama atual versus desejado, você pode ver que precisa de um novo branch, certo? Você pode usar git switch -c feature
para ele ou git checkout -b feature
(que faz a mesma coisa):
Este comando também atualiza HEAD
para apontar para a nova ramificação.
Como você usou git reset --soft
, você não alterou o índice, então ele tem exatamente o estado que você deseja confirmar - que conveniente! Você pode simplesmente se comprometer com o ramo feature
:
E você chegou ao estado desejado 🎉
Pronto para aplicar seu conhecimento a casos adicionais?
Adicione algumas alterações a love.txt
e também crie um novo arquivo chamado cool.txt
. Organize-os e comprometa-se:
Oh, opa, na verdade eu queria que você criasse dois commits separados, um para cada mudança 🤦🏻
Quer experimentar este você mesmo?
Você pode desfazer as etapas de commit e staging:
Após esse comando, o índice não inclui mais essas duas alterações, mas ambas ainda estão em seu sistema de arquivos. Portanto, agora, se você apenas colocar em stage love.txt
, poderá confirmá-lo separadamente e, em seguida, fazer o mesmo para cool.txt
:
Legal 😎
Crie um novo arquivo ( new_file.txt
) com algum texto e adicione algum texto a love.txt
. Organize ambas as alterações e confirme-as:
Opa 🙈🙈
Então, desta vez, eu queria que fosse em outro branch, mas não em um novo branch, mas em um branch já existente.
Então o que você pode fazer?
Vou te dar uma dica. A resposta é muito curta e muito fácil. O que fazemos primeiro?
Não, não reset
. Nos desenhamos. Essa é a primeira coisa a fazer, pois tornaria tudo muito mais fácil. Então este é o estado atual:
E o estado desejado?
Como você sai do estado atual para o estado desejado, o que seria mais fácil?
Então, uma maneira seria usar git reset
como você fez antes, mas há outra maneira que eu gostaria que você tentasse.
Primeiro, mova HEAD
para apontar para a ramificação existing
:
Intuitivamente, o que você quer fazer é pegar as alterações introduzidas no commit azul e aplicá-las (“copiar e colar”) no topo da ramificação existing
. E o Git tem uma ferramenta só para isso.
Para pedir ao Git para fazer as alterações introduzidas entre este commit e seu commit pai e apenas aplicar essas alterações no branch ativo, você pode usar git cherry-pick
. Este comando pega as mudanças introduzidas na revisão especificada e as aplica ao commit ativo.
Ele também cria um novo objeto commit e atualiza a ramificação ativa para apontar para esse novo objeto.
No exemplo acima, eu especifiquei o identificador SHA-1 do commit criado, mas você também pode usar git cherry-pick main
, já que o commit cujas alterações estamos aplicando é aquele para o qual main
está apontando.
Mas não queremos que essas alterações existam no ramo main
. git cherry-pick
apenas aplicou as alterações ao branch existing
. Como você pode removê-los do main
?
Uma maneira seria switch
para main
e, em seguida, usar git reset --hard HEAD~1
:
Você fez isso! 💪🏻
Observe que git cherry-pick
realmente calcula a diferença entre o commit especificado e seu pai e, em seguida, aplica-os ao commit ativo. Isso significa que, às vezes, o Git não conseguirá aplicar essas alterações, pois pode ocorrer um conflito, mas isso é assunto para outro post.
Além disso, observe que você pode pedir ao Git para cherry-pick
as alterações introduzidas em qualquer commit, não apenas nos commits referenciados por um branch.
Adquirimos uma nova ferramenta, então temos git reset
e git cherry-pick
em nosso currículo.
Ok, então outro dia, outro repo, outro problema.
Crie um commit:
E push
-o para o servidor remoto:
Hum, opa 😓…
Acabei de notar uma coisa. Há um erro de digitação aí. Eu escrevi This is more tezt
em vez de This is more text
. Opa. Então, qual é o grande problema agora? Eu push
ed, o que significa que outra pessoa já pode ter pull
essas alterações.
Se eu substituir essas alterações usando git reset
, como fizemos até agora, teremos histórias diferentes e o inferno pode explodir. Você pode reescrever sua própria cópia do repositório o quanto quiser até que você o push
.
Depois de push
a alteração, você precisa ter certeza de que ninguém mais buscou essas alterações se for reescrever o histórico.
Como alternativa, você pode usar outra ferramenta chamada git revert
. Este comando pega o commit que você está fornecendo e calcula o Diff de seu commit pai, assim como git cherry-pick
, mas desta vez, ele calcula as alterações inversas.
Portanto, se no commit especificado você adicionar uma linha, o inverso excluirá a linha e vice-versa.
git revert
criou um novo objeto commit, o que significa que é uma adição ao histórico. Ao usar git revert
, você não reescreveu o histórico. Você admitiu seu erro passado, e este commit é um reconhecimento de que você cometeu um erro e agora o corrigiu.
Alguns diriam que é a forma mais madura. Alguns diriam que não é um histórico tão limpo quanto você obteria se usasse git reset
para reescrever o commit anterior. Mas esta é uma forma de evitar reescrever a história.
Agora você pode corrigir o erro de digitação e confirmar novamente:
Sua caixa de ferramentas agora está carregada com uma nova ferramenta brilhante, revert
:
Faça algum trabalho, escreva algum código e adicione-o a love.txt
. Organize esta alteração e confirme-a:
Fiz o mesmo na minha máquina e usei a tecla de seta Up
no teclado para rolar de volta para os comandos anteriores e, em seguida, pressionei Enter
e… Uau.
Opa.
Acabei de usar git reset --hard
? 😨
O que realmente aconteceu? Git moveu o ponteiro para HEAD~1
, então o último commit, com todo o meu precioso trabalho, não pode ser acessado a partir do histórico atual. O Git também removeu todas as alterações da área de teste e, em seguida, combinou o diretório de trabalho com o estado da área de teste.
Ou seja, tudo condiz com esse estado onde meu trabalho está... desaparecido.
Hora de surtar. Pirando.
Mas, realmente, há uma razão para surtar? Na verdade não... Somos pessoas relaxadas. O que nós fazemos? Bem, intuitivamente, o commit realmente acabou? Não porque não? Ele ainda existe dentro do banco de dados interno do Git.
Se eu soubesse onde fica, saberia o valor SHA-1 que identifica esse commit, poderíamos restaurá-lo. Eu poderia até desfazer o desfazer e reset
para este commit.
Portanto, a única coisa que realmente preciso aqui é o SHA-1 do commit “excluído”.
Então a pergunta é, como faço para encontrá-lo? git log
seria útil?
Bem, na verdade não. git log
iria para HEAD
, que aponta para main
, que aponta para o commit pai do commit que estamos procurando. Em seguida, git log
rastrearia a cadeia pai, que não inclui o commit com meu precioso trabalho.
Felizmente, as pessoas muito inteligentes que criaram o Git também criaram um plano de backup para nós, chamado de reflog
.
Enquanto você trabalha com o Git, sempre que você altera HEAD
, o que pode ser feito usando git reset
, mas também outros comandos como git switch
ou git checkout
, o Git adiciona uma entrada ao reflog
.
Encontramos nosso commit! É aquele que começa com 0fb929e
.
Também podemos nos relacionar com ele por seu “apelido” — HEAD@{1}
. Assim como Git usa HEAD~1
para chegar ao primeiro pai de HEAD
, e HEAD~2
para se referir ao segundo pai de HEAD
e assim por diante, Git usa HEAD@{1}
para se referir ao primeiro reflog pai de HEAD
, onde HEAD
apontou na etapa anterior.
Também podemos pedir git rev-parse
para nos mostrar seu valor:
Outra maneira de visualizar o reflog
é usando git log -g
, que pede git log
para realmente considerar o reflog
:
Vemos acima que o reflog
, assim como HEAD
, aponta para main
, que aponta para “Commit 2”. Mas o pai dessa entrada no reflog
aponta para “Commit 3”.
Então, para voltar ao “Commit 3”, você pode simplesmente usar git reset --hard HEAD@{1}
(ou o valor SHA-1 de “Commit 3”):
E agora, se git log
:
Nós salvamos o dia! 🎉👏🏻
O que aconteceria se eu usasse esse comando novamente? E executou git commit --reset HEAD@{1}
? Git definiria HEAD
para onde HEAD
estava apontando antes da última reset
, significando “Commit 2”. Podemos continuar o dia todo:
Olhando para nossa caixa de ferramentas agora, ela está repleta de ferramentas que podem ajudá-lo a resolver muitos casos em que as coisas dão errado no Git:
Com essas ferramentas, agora você entende melhor como o Git funciona. Existem mais ferramentas que permitem reescrever o histórico especificamente, git rebase
), mas você já aprendeu muito neste post. Em postagens futuras, vou mergulhar no git rebase
também.
A ferramenta mais importante, ainda mais importante do que as cinco ferramentas listadas nesta caixa de ferramentas, é colocar no quadro branco a situação atual versus a desejada. Acredite em mim, isso fará com que cada situação pareça menos assustadora e a solução mais clara.
Eu também dei uma palestra ao vivo cobrindo o conteúdo deste post. Se você preferir um vídeo (ou deseja assisti-lo junto com a leitura) — você pode encontrá-lo
Em geral,
Omer tem mestrado em Linguística pela Universidade de Tel Aviv e é o criador do
Publicado pela primeira vez aqui