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:
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.
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.
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.
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.
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.
É possível ver que o SHA do objeto reset_lifecycle_file foi atualizado de e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 para d7d77c1b04b5edd5acfc85de0b592449e5303770.
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.
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.
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:
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.
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".
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.
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.
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:
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.
É 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:
É 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
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.
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.
git reset <file>
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.
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.
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.
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.
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.
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.
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.
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.