O comando git reset é uma ferramenta complexa e versátil para desfazer alterações. Ele tem três formas principais de invocação. Estas formas correspondem aos argumentos --soft, --mixed, --hard da linha de comandos. Cada um dos três argumentos corresponde a um mecanismo de gerenciamento do estado interno do Git: a árvore de commits (HEAD), o índice de staging e o diretório de trabalho.

Git reset e as três árvores do Git

Para compreender com clareza o uso do git reset, primeiro a gente precisa entender os sistemas de gerenciamento de estado interno do Git. Às vezes, estes mecanismos são chamados de "três árvores" do Git. Árvore pode ser um nome inadequado, pois elas não são estruturas de dados em árvore tradicional. No entanto, são estruturas de dados baseadas em ponto central e indicador, que o Git utiliza para monitorar a linha do tempo das edições. A melhor forma de demonstrar estes mecanismos é criar um conjunto de alterações em um repositório e acompanhar ele até as três árvores.

Para começar, a gente vai criar um novo repositório com os comandos abaixo:

$ mkdir git_reset_test
$ cd git_reset_test/
$ git init .
Initialized empty Git repository in /git_reset_test/.git/
$ touch reset_lifecycle_file
$ git add reset_lifecycle_file
$ git commit -m"initial commit"
[main (root-commit) d386d86] initial commit
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 reset_lifecycle_file

O código de exemplo acima cria um novo repositório do Git com um único arquivo vazio, reset_lifecycle_file. Neste momento, o repositório de exemplo tem um único commit (d386d86) da adição de reset_lifecycle_file.

O diretório de trabalho

A primeira árvore que a gente vai examinar é o "Diretório de trabalho". Esta árvore está sincronizada com o sistema de arquivos local e representa as alterações imediatas feitas no conteúdo de arquivos e diretórios.


$ echo 'hello git reset' > reset_lifecycle_file
$ git status 
On branch main
Changes not staged for commit: 
(use "git add ..." to update what will be committed) 
(use "git checkout -- ..." to discard changes in working directory) 
modified: reset_lifecycle_file

No repositório de demonstração, a gente modifica e adiciona conteúdo a reset_lifecycle_file. A execução do git status mostra que o Git tem conhecimento das alterações no arquivo. Estas alterações agora fazem parte da primeira árvore, o "Diretório de trabalho" O Git status pode ser usado para exibir as alterações no Diretório de trabalho. Elas são exibidas em vermelho com um prefixo "modified"

Índice de staging

A próxima é a árvore "Índice de staging". Esta árvore está monitorando as alterações no Diretório de trabalho que foram promovidas pelo git add e que devem ser armazenadas no próximo commit. Esta árvore é um mecanismo complexo e interno de armazenamento em cache. O Git em geral tenta ocultar do usuário os dados da implementação do Índice de staging.

Para ver com precisão o estado do Índice de staging, é preciso utilizar um comando Git menos conhecido, o git ls-files. O comando git ls-files é, em resumo, um utilitário de depuração para inspecionar o estado da árvore do Índice de staging.

git ls-files -s
100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0   reset_lifecycle_file

Aqui, a gente executa o git ls-files com a opção -s ou --stage. Sem a opção -s, o resultado do git ls-files é apenas uma lista de nomes de arquivos e caminhos que fazem parte do índice no momento. A opção -ss exibe metadados adicionais para os arquivos do Índice de staging. Estes metadados são os bits do modo, o nome do objeto e o número de preparação do conteúdo preparado. Aqui a gente está interessado no nome do objeto, o segundo valor (d7d77c1b04b5edd5acfc85de0b592449e5303770). Este é um código SHA-1 padrão de objeto do Git. É um código do conteúdo dos arquivos. O histórico de commits armazena os próprios SHAs de objeto para identificar indicadores de commits e referências, e o Índice de staging tem os próprios SHAs de objeto para monitorar as versões dos arquivos no índice.

Em seguida, a gente vai promover o reset_lifecycle_file modificado no Índice de staging.


$ git add reset_lifecycle_file 

$ git status 

On branch main Changes to be committed: 

(use "git reset HEAD ..." to unstage) 

modified: reset_lifecycle_file

Aqui a gente invocou o git add reset_lifecycle_file, que adiciona o arquivo ao Índice de Staging. Invocar git status agora exibe reset_lifecycle_file em verde em "Alterações para fazer commit". É importante notar que o git status não é uma representação verdadeira do Índice de Staging. A saída do comando git status exibe alterações entre o Histórico de Commit e o Índice de Staging. Vamos examinar o conteúdo do Índice de Staging neste momento.

 $ git ls-files -s 100644 d7d77c1b04b5edd5acfc85de0b592449e5303770 0 reset_lifecycle_file

É possível ver que o SHA do objeto reset_lifecycle_file foi atualizado de e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 para d7d77c1b04b5edd5acfc85de0b592449e5303770.

Histórico de commits

A última árvore é o histórico de commits. O comando git commit adiciona alterações a um snapshot permanente que existe no histórico de commits. Esse snapshot também inclui o estado do Índice de staging no momento do commit.

$ git commit -am"update content of reset_lifecycle_file"
[main dc67808] update content of reset_lifecycle_file
1 file changed, 1 insertion(+)
$ git status
On branch main
nothing to commit, working tree clean

Aqui, a gente cria um novo commit com uma mensagem de "update content of resetlifecyclefile". O conjunto de alterações foi adicionado ao histórico de commits. Executar o git status neste momento mostra que não há alterações pendentes em nenhuma das árvores. Executar o git log vai exibir o histórico de commits. Agora que a gente acompanhou o conjunto de alterações pelas três árvores, dá para começar a utilizar o git reset.

Como funciona

Na superfície, o git reset tem comportamento semelhante ao git checkout. Enquanto o git checkout opera apenas no indicador de referência HEAD, o git reset move o indicador de referência HEAD e o indicador de referência do branch atual. Para demonstrar melhor esse comportamento, considere o exemplo a seguir:

Quatro pontos centrais com um "ponto central principal" na última posição

Este exemplo demonstra uma sequência de commits na ramificação main. A referência HEAD e a referência da ramificação main apontam no momento para o commit d. Agora a gente vai executar e comparar ambos, o git checkout b e o git reset b.

git checkout b

Quatro pontos centrais em que o principal aponta para o último ponto central e com HEAD para o segundo ponto central

Com o git checkout, a referência da ramificação main ainda está apontando para d. A referência HEAD foi movida e agora aponta para o commit b. O repositório agora está em um estado "HEAD desconectado".

git reset b

Dois conjuntos de dois pontos centrais com HEAD e principal apontando para o segundo do primeiro conjunto

Em comparação, o git reset move ambos, HEAD e as refs da ramificação, para a confirmação especificada.

Além de atualizar os indicadores de ref do commit, o git reset modifica o estado das três árvores. A modificação do indicador de ref sempre ocorre e é atualizada na terceira árvore, a árvore de commits. Os argumentos --soft, --mixed e --hard orientam como modificar as árvores do Índice de staging e do Diretório de trabalho.

Opções principais

A chamada padrão do git reset tem argumentos implícitos de --mixed e HEAD. Ou seja: executar o git reset é equivalente a executar o git reset --mixed HEAD. Desta forma, HEAD é o commit especificado. Ao invés de HEAD, qualquer código de commit SHA-1 do Git pode ser usado.

Diagrama do escopo de git resets

--hard

Esta é a opção mais direta, usada e PERIGOSA. Quando --hard é transmitido, os indicadores de ref do histórico de commits são atualizados para o commit especificado. Então, o Índice de staging e o Diretório de trabalho são redefinidos para corresponder ao commit especificado. Quaisquer alterações prévias pendentes no Índice de staging e no Diretório de trabalho são redefinidas para corresponder ao estado da árvore de commits. Ou seja: qualquer trabalho pendente que estava no Índice de staging e no Diretório de trabalho é perdido.

Para demonstrar, a gente vai continuar com o repositório de exemplo com três árvores criado antes. Primeiro, a gente vai fazer algumas alterações no repositório. Execute os seguintes comandos no repositório de exemplo:

$ echo 'new file content' > new_file
$ git add new_file
$ echo 'changed content' >> reset_lifecycle_file
 

Esses comandos criaram um novo arquivo chamado new_file e o adicionaram ao repositório. O conteúdo de reset_lifecycle_file também vai ser modificado. Após realizar as alterações, vamos agora examinar o estado do repositório usando o git status.

$ git status
On branch main
Changes to be committed:
   (use "git reset HEAD ..." to unstage)

new file: new_file

Changes not staged for commit:
   (use "git add ..." to update what will be committed)
   (use "git checkout -- ..." to discard changes in working directory)

modified: reset_lifecycle_file

É possível ver que agora há alterações pendentes no repositório. A árvore do Índice de staging tem uma alteração pendente da adição do new_file e o Diretório de trabalho tem uma alteração pendente das modificações realizadas em reset_lifecycle_file.

Antes de avançar, também vamos examinar o estado do Índice de staging:

$ git ls-files -s
100644 8e66654a5477b1bf4765946147c49509a431f963 0 new_file
100644 d7d77c1b04b5edd5acfc85de0b592449e5303770 0 reset_lifecycle_file

É possível ver que o new_file foi adicionado ao índice. Atualizações foram realizadas em reset_lifecycle_file, mas o SHA do Índice de staging (d7d77c1b04b5edd5acfc85de0b592449e5303770) permanece o mesmo. Esse comportamento é esperado, já que o git add não foi usado para realizar essas alterações no Índice de staging. Essas alterações existem no Diretório de trabalho.

Vamos executar o git status e examinar o estado atual do repositório.

$ git reset --hard
HEAD is now at dc67808 update content of reset_lifecycle_file
$ git status
On branch main
nothing to commit, working tree clean
$ git ls-files -s
100644 d7d77c1b04b5edd5acfc85de0b592449e5303770 0 reset_lifecycle_file

Aqui a gente executou um "hard reset" usando a opção --hard. O Git exibe o resultado indicando que HEAD está apontando para o commit mais recente dc67808. A seguir, a gente verifica o estado do repositório com o git status. O Git indica que não existem alterações pendentes. A gente também examina o estado do Índice de staging e vê que ele foi redefinido para um ponto antes de new_file ser adicionado. As modificações feitas em reset_lifecycle_file e a adição do new_file foram excluídas. Não se esqueça: esta perda de dados não pode ser desfeita.

--mixed

Este é o modo operante padrão. Os indicadores de ref são atualizados. O Índice de staging é redefinido para o estado do commit especificado. Quaisquer alterações desfeitas no Índice de staging são movidas para o Diretório de trabalho. Vamos continuar.

$ echo 'new file content' > new_file
$ git add new_file
$ echo 'append content' >> reset_lifecycle_file
$ git add reset_lifecycle_file
$ git status
On branch main
Changes to be committed:
    (use "git reset HEAD ..." to unstage)

new file: new_file
modified: reset_lifecycle_file


$ git ls-files -s
100644 8e66654a5477b1bf4765946147c49509a431f963 0 new_file
100644 7ab362db063f9e9426901092c00a3394b4bec53d 0 reset_lifecycle_file

No exemplo acima, a gente fez algumas modificações no repositório. Outra vez, a gente adicionou um new_file e modificou o conteúdo de reset_lifecycle_file. Estas alterações foram, então, aplicadas ao Índice de staging com git add. Com o repositório neste estado, agora a gente executa a redefinição.

$ git reset --mixed
$ git status
On branch main
Changes not staged for commit:
    (use "git add ..." to update what will be committed)
    (use "git checkout -- ..." to discard changes in working directory)

modified: reset_lifecycle_file

Untracked files:
    (use "git add ..." to include in what will be committed)

new_file


no changes added to commit (use "git add" and/or "git commit -a")
$ git ls-files -s
100644 d7d77c1b04b5edd5acfc85de0b592449e5303770 0 reset_lifecycle_file

Aqui a gente executou um "mixed reset". Para reiterar, --mixed é o modo padrão e tem o mesmo efeito que executar o git reset. Examinando o resultado de git status e git ls-files, a gente pode ver que o Índice de staging foi redefinido para um estado em que reset_lifecycle_file é o único arquivo no índice. O SHA do objeto reset_lifecycle_file foi redefinido para a versão anterior.

O importante a lembrar aqui é que git status mostra que existem modificações em reset_lifecycle_file e que existe um arquivo não rastreado: new_file. Este é o comportamento explícito de --mixed. O Índice de staging foi redefinido e as alterações pendentes foram movidas para o Diretório de trabalho. Compare este caso com o do --hard reset, no qual o Índice de staging foi redefinido e o Diretório de trabalho também foi redefinido, perdendo estas atualizações.

--soft

Quando o argumento --soft é transmitido, os indicadores de ref são atualizados e a redefinição para aí. O Índice de staging e o Diretório de trabalho permanecem intocados. Este comportamento pode ser difícil de demonstrar com clareza. A gente vai continuar com o repositório de demonstração e preparar ele para um soft reset.


$ git add reset_lifecycle_file 

$ git ls-files -s 

100644 67cc52710639e5da6b515416fd779d0741e3762e 0 reset_lifecycle_file 

$ git status 

On branch main

Changes to be committed: 

(use "git reset HEAD ..." to unstage) 

modified: reset_lifecycle_file 

Untracked files: 

(use "git add ..." to include in what will be committed) 

new_file

Aqui, a gente utilizou outra vez git add para promover o reset_lifecycle_file modificado no Índice de Staging. A gente confirmou que o índice foi atualizado com o resultado git ls-files. O resultado de git status agora exibe as "Alterações para fazer commits" em verde. O new_file dos exemplos anteriores está solto no Diretório de trabalho como um arquivo não monitorado. A gente executa rm new_file para excluir o arquivo, já que ele não é mais necessário nos exemplos seguintes.

Com o repositório neste estado, agora executamos uma redefinição soft.

$ git reset --soft
$ git status
On branch main
Changes to be committed:
    (use "git reset HEAD ..." to unstage)

modified: reset_lifecycle_file
$ git ls-files -s
100644 67cc52710639e5da6b515416fd779d0741e3762e 0 reset_lifecycle_file

A gente executou um "soft reset". Examinar o estado do repositório com git status e git ls-files mostra que nada mudou. Este é o comportamento esperado. Um soft reset apenas redefine o Histórico de commits. Por padrão, o git reset é executado com HEAD como commit alvo. Como o Histórico de commits já estava localizado em HEAD e a gente fez a redefinição explícita para HEAD, nada aconteceu de fato.

Para entender e utilizar melhor o --soft, a gente precisa de um commit alvo que não seja HEAD. O reset_lifecycle_file está aguardando no Índice de staging. A gente vai criar um novo commit.

$ git commit -m"introduzir conteúdo em reset_lifecycle_file"

Neste momento, o repositório deve ter três commits. A gente vai voltar para o primeiro commit. Para chegar lá, a gente vai precisar do ID do primeiro commit. Ele pode ser encontrado examinando o resultado de git log.

$ git log
commit 62e793f6941c7e0d4ad9a1345a175fe8f45cb9df
Author: bitbucket 
Date: Fri Dec 1 15:03:07 2017 -0800
prepend content to reset_lifecycle_file

commit dc67808a6da9f0dec51ed16d3d8823f28e1a72a
Author: bitbucket 
Date: Fri Dec 1 10:21:57 2017 -0800

update content of reset_lifecycle_file

commit 780411da3b47117270c0e3a8d5dcfd11d28d04a4

Author: bitbucket 
Date: Thu Nov 30 16:50:39 2017 -0800

initial commit

Os IDs do histórico de commits são exclusivos para cada sistema. Ou seja: o ID de commit do exemplo é diferente do que aparece na sua máquina. O ID do commit relevante para o exemplo é 780411da3b47117270c0e3a8d5dcfd11d28d04a4. Este é o ID que corresponde ao "commit inicial". Depois de localizar esse ID, a gente o usa como alvo para o soft reset.

Antes de retroceder, primeiro a gente verifica o estado atual do repositório.

$ git status && git ls-files -s
On branch main
nothing to commit, working tree clean
100644 67cc52710639e5da6b515416fd779d0741e3762e 0 reset_lifecycle_file

Aqui a gente executa um comando combinado git status e git ls-files -s, que mostra que existem alterações pendentes no repositório e que o reset_lifecycle_file no Índice de staging está na versão 67cc52710639e5da6b515416fd779d0741e3762e. Com essa informação em mente, a gente vai executar um soft reset de volta para o primeiro commit.

$git reset --soft 780411da3b47117270c0e3a8d5dcfd11d28d04a4
$ git status && git ls-files -s
On branch main
Changes to be committed:
    (use "git reset HEAD ..." to unstage)

modified: reset_lifecycle_file
100644 67cc52710639e5da6b515416fd779d0741e3762e 0 reset_lifecycle_file

O código acima executa um "soft reset" e também invoca o comando combinado git status e git ls-files, que resulta no estado do repositório. A gente pode examinar o resultado do estado do repositório e observar algumas coisas interessantes. Em primeiro lugar, git status indica que existem modificações em reset_lifecycle_file e as destaca indicando as alterações preparadas para o próximo commit. Em segundo lugar, git ls-files indica que o Índice de staging não foi alterado e retém o SHA 67cc52710639e5da6b515416fd779d0741e3762e de antes.

Para explicar melhor o que aconteceu nesta redefinição, vamos examinar o git log:

$ git log commit 780411da3b47117270c0e3a8d5dcfd11d28d04a4 Author: bitbucket  Date: Thu Nov 30 16:50:39 2017 -0800 initial commit

O resultado do log agora informa que existe um único commit no Histórico de commits. Assim a gente tem uma ilustração clara do que o --soft fez. Assim como todas as chamadas de git reset, a primeira ação que a redefinição faz é redefinir a árvore de commits. Os exemplos anteriores com --hard e --mixed foram ambos em relação a HEAD e não desfizeram o que foi realizado na Árvore de commits. Durante um soft reset, é tudo o que acontece.

Então o motivo pelo qual o git status indica que há arquivos modificados pode gerar confusão. O --soft não interfere no Índice de staging, portanto, as atualizações do Índice de staging foram mantidas no histórico de commits. Essa informação pode ser confirmada pelo resultado de git ls-files -s, que mostra que o SHA de reset_lifecycle_file não foi alterado. Como um lembrete, o git status não mostra o estado das "três árvores", ele mostra, em resumo, uma diferença entre elas. Nesse caso, ele está mostrando que o Índice de staging está à frente das alterações no Histórico de commits, como se a gente já as tivesse realizado.

Redefinir versus reverter

Se o git revert é uma forma "segura" de desfazer alterações, você pode pensar no git reset como um método perigoso. Existe um risco real de perder trabalho com o git reset. O git reset nunca exclui um commit, no entanto, os commits podem se tornar "órfãos", o que significa que não vai existir um caminho de acesso direto partindo de uma ref. Em geral, os commits órfãos podem ser encontradas e restaurados usando o git reflog. O Git exclui para sempre quaisquer commits órfãos após executar o coletor de lixo interno. Por padrão, o Git é configurado para executar o coletor de lixo a cada 30 dias. O Histórico de commits é uma das "três árvores do git", as outras duas, o Índice de staging e o Diretório de trabalho, não são tão permanentes como os commits. Tome cuidado ao usar essa ferramenta, pois é um dos únicos comandos Git que tem o potencial de perder trabalho.

Enquanto a reversão foi desenvolvida para desfazer com segurança um commit público, o git reset foi desenvolvido para desfazer alterações locais do Índice de staging e do Diretório de trabalho. Por causa dos objetivos distintos, os dois comandos são implementados com diferenças: a redefinição remove por completo um conjunto de alterações, enquanto a reversão mantém o conjunto de alterações original e usa um novo commit para aplicar a ação de desfazer.

Não faça a redefinição no histórico público

Você nunca deve usar git reset quando algum instantâneo após tiver sido colocado em um repositório público. Depois de publicar um commit, você precisa supor que outros desenvolvedores dependem dele.

Remover um commit que outros membros da equipe continuaram desenvolvendo cria problemas sérios de colaboração. Quando eles tentarem sincronizar com seu repositório, vai parecer que um pedaço do histórico do projeto sumiu de repente. A sequência abaixo demonstra o que acontece quando você tenta redefinir um commit público. A ramificação de origin/main é a versão do repositório central da ramificação main local.

Quatro conjuntos de pontos centrais com origem/principal apontando para o último

Assim que você adiciona novos commits após a redefinição, o Git entende que o histórico local é diferente da origin/main, e o commit de merge necessário para sincronizar os repositórios pode confundir e frustrar a equipe.

Ou seja, tenha certeza de estar usando git reset em um experimento local que deu errado, e não em alterações publicadas. Se você precisar corrigir um commit público, o comando git revert foi desenvolvido para este fim.

Exemplos

 git reset 

Remova o arquivo especificado da área de staging, mas deixe o diretório de trabalho inalterado. Assim o arquivo é retirado do staging sem sobrescrever nenhuma alteração.

 git reset

Redefina a área de staging para corresponder ao commit mais recente, mas deixe o diretório de trabalho inalterado. Assim todos os arquivos são retirados do staging sem sobrescrever nenhuma alteração, possibilitando reconstruir o instantâneo preparado a partir do zero.

 git reset --hard

Redefina a área de staging e o diretório de trabalho para corresponder ao commit mais recente. Além de alterações desativadas, a marcação --hard diz ao Git para substituir todas as alterações no diretório de trabalho também. Dizendo de outra maneira: essa marcação apaga todas as alterações sem commit, então só use se quiser mesmo jogar fora os desenvolvimentos locais.

 git reset  

Mova a ponta da ramificação atual de volta para commit, redefina a área de staging para corresponder, mas deixe o diretório de trabalho inalterado. Todas as alterações feitas desde o vão residir no diretório de trabalho, o que permite refazer o commit do histórico do projeto usando instantâneos mais limpos e atômicos.

 git reset --hard  

Mova a ponta de ramificação atual de volta para e redefina a área de staging e o diretório de trabalho para corresponderem. Assim não apenas as mudanças sem commit são apagadas, mas todos os commits seguintes também.

Despreparar um arquivo

O comando git reset é encontrado com frequência ao preparar o instantâneo pretendido. O exemplo a seguir supõe que você tem dois arquivos chamados hello.py e main.py que você já adicionou ao repositório.

# Edit both hello.py and main.py

# Stage everything in the current directory
git add .

# Realize that the changes in hello.py and main.py
# should be committed in different snapshots

# Unstage main.py
git reset main.py

# Commit only hello.py
git commit -m "Make some changes to hello.py"

# Commit main.py in a separate snapshot
git add main.py
git commit -m "Edit main.py"

Como é possível ver, o git reset ajuda a manter o alto foco dos commits, permitindo que você retire as alterações que não estão relacionadas ao próximo commit.

Remover commits locais

O próximo exemplo mostra um caso de uso mais avançado. Ele demonstra o que acontece quando você trabalha por um tempo em um novo experimento, mas decide jogar ele fora por completo depois de fazer o commit de alguns snapshots.

# Create a new file called `foo.py` and add some code to it

# Commit it to the project history
git add foo.py
git commit -m "Start developing a crazy feature"

# Edit `foo.py` again and change some other tracked files, too

# Commit another snapshot
git commit -a -m "Continue my crazy feature"

# Decide to scrap the feature and remove the associated commits
git reset --hard HEAD~2

O comando git reset HEAD~2 move o branch atual dois commits para trás, fazendo a remoção efetiva dos dois snapshots recém-criados a partir do histórico do projeto. Este tipo de redefinição só deve ser usado em commits não publicados. Nunca execute a operação acima se você já tiver enviado, via push, os commits para um repositório compartilhado.

Resumo

Revisando, o git reset é um comando poderoso usado para desfazer alterações locais no estado de um repositório do Git. O git reset opera nas "três árvores do Git". Essas árvores são o Histórico de commits (HEAD), o Índice de staging e o Diretório de trabalho. Existem três opções da linha de comando que correspondem às três árvores. As opções --soft, --mixed e --hard podem ser transmitidas para o git reset.

Neste artigo, são usados diversos comandos do Git para ajudar a demonstrar os processos de redefinição. Saiba mais sobre esses comandos em suas respectivas páginas em: git status, git log, git add, git checkout, git reflog e git revert.

Pronto para aprender o git reset?

Tente este tutorial interativo.

Comece agora mesmo