Qual a diferença entre as opções "--soft", "--mixed e "--hard" no git reset?

Este post é mais um da série sobre Git que estou escrevendo. Os anteriores são:

Para mostrar melhor o que o git reset faz, primeiro vamos estabelecer um "repositório base" e a partir dele usar as diferentes opções do reset.

Então a primeira parte será sobre a montagem do cenário, que ficou um pouco longa e parece que não tem nada a ver com o assunto, mas tenha paciência que no final (espero) você entenderá, pois na segunda parte eu uso esse cenário como base para mostrar o que cada opção do reset faz. Vamos lá:

Montando o cenário

Eu montei um pequeno repositório com apenas um arquivo chamado arq.txt, com 3 linhas:

$ ls
arq.txt

$ cat arq.txt
primeiro
segundo
terceiro

No momento, o repositório está com 3 commits:

$ git log --oneline
668185f (HEAD -> master) terceiro commit
8bc39c6 segundo commit
36da346 primeiro commit

$ git status
On branch master
nothing to commit, working tree clean

O git status mostra que o arquivo não possui modificações, se comparado ao último commit.

Também podemos ver a diferença entre os commits, para sabermos o que mudou de um para outro (isso será importante para entendermos o que as diferentes opções de reset fazem).

Primeiro vamos ver a diferença entre o primeiro e segundo commit:

$ git diff 36da346 8bc39c6
diff --git a/arq.txt b/arq.txt
index 98fdf1f..e274503 100644
--- a/arq.txt
+++ b/arq.txt
@@ -1 +1,2 @@
 primeiro
+segundo

A diferença é que a linha "segundo" foi adicionada ao arquivo (indicado pelo sinal + logo antes do texto "segundo"). Já entre o segundo e terceiro commit:

$ git diff 8bc39c6 668185f
diff --git a/arq.txt b/arq.txt
index e274503..c14b6c1 100644
--- a/arq.txt
+++ b/arq.txt
@@ -1,2 +1,3 @@
 primeiro
 segundo
+terceiro

Foi adicionada a linha "terceiro".

Ou seja, no primeiro commit o arquivo tinha 1 linha, no segundo commit foi adicionada a segunda linha, e no terceiro commit foi adicionada a terceira linha (e é assim que o arquivo está atualmente).

Agora vou adicionar mais uma linha no arquivo e rodar git add:

$ echo quarto >> arq.txt
$ cat arq.txt
primeiro
segundo
terceiro
quarto

$ git add arq.txt
$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    modified:   arq.txt

O arquivo está marcado para ir no próximo commit ("Changes to be committed"). Mas em vez de fazer o commit, vou adicionar mais uma linha no arquivo (mas não vou rodar git add):

$ echo quinto >> arq.txt
$ cat arq.txt
primeiro
segundo
terceiro
quarto
quinto

$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    modified:   arq.txt

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

    modified:   arq.txt

Repare que agora o arquivo está tanto em "Changes to be committed" (irá no próximo commit) quanto em "Changes not staged for commit" (não irá no próximo commit). E saber porque isso acontece é importante para entender o que cada opção do reset faz.

Por que isso acontece?

Esse cenário que montei serve para entendermos alguns conceitos importantes.

O primeiro é o working directory (diretório de trabalho, ou working tree, ou working dir). É basicamente o que está no seu disco. No caso, é o arquivo que estou mexendo, com as 5 linhas:

$ cat arq.txt
primeiro
segundo
terceiro
quarto
quinto

Essas alterações só estarão no próximo commit se eu mandá-las para o staging area (que também é chamada de index ou cache). É isso que acontece quando eu faço git add arquivo: o conteúdo do arquivo (que está no working dir) é mandado para o staging area.

Neste caso, somente a quarta linha está no staging area (eu fiz um git add quando o arquivo tinha 4 linhas), mas a quinta linha não (pois ela foi editada depois do git add).

Podemos ver as diferenças usando diff:

$ git diff --cached
diff --git a/arq.txt b/arq.txt
index c14b6c1..f9efd34 100644
--- a/arq.txt
+++ b/arq.txt
@@ -1,3 +1,4 @@
 primeiro
 segundo
 terceiro
+quarto

A opção --cached serve para comparar o cache (outro nome para staging area) com o último commit. E veja que a diferença é a quarta linha.

diff sem argumentos compara o working dir com o staging area:

$ git diff
diff --git a/arq.txt b/arq.txt
index f9efd34..a166d58 100644
--- a/arq.txt
+++ b/arq.txt
@@ -2,3 +2,4 @@ primeiro
 segundo
 terceiro
 quarto
+quinto

E repare que a diferença é a quinta linha.

É por isso que o arquivo está ao mesmo tempo listado para ir e não ir no próximo commit. A quarta linha está no staging area e irá no próximo commit. A quinta linha está no working dir mas não está no staging area e não irá no próximo commit.

Outra maneira de verificar o que está no staging area é com o comando ls-files:

$ git ls-files -s
100644 f9efd34b6e87df23f7b0c0275daaacff6b7ef8f0 0   arq.txt

E para ver o conteúdo do arquivo, basta usar cat-file passando o hash do arquivo (como tudo no Git, não precisa ser o hash todo, apenas um pedaço inicial que não seja ambíguo):

$ git cat-file -p f9efd34b6e
primeiro
segundo
terceiro
quarto

Como podemos ver, o arquivo que está no staging area possui a quarta linha.


Usando reset

Agora que já temos o cenário inicial, vamos ver o que cada opção de reset faz. Lembrando da nossa situação inicial, temos 3 commits:

$ git log --oneline
668185f (HEAD -> master) terceiro commit
8bc39c6 segundo commit
36da346 primeiro commit

O HEAD - que de forma simplificada, é o ponteiro para o branch atual (é um pouco mais que isso, na verdade) - está apontando para o master (que é o branch padrão - embora a partir de outubro de 2020, o nome tenha sido alterado para main no GitHub, e outros serviços fizeram o mesmo). Basicamente, o HEAD diz onde estamos, e se criarmos outro commit, ele será colocado depois do commit para o qual o HEAD aponta.

No staging area temos a quarta linha do arquivo (que estará no próximo commit), e no working dir temos a quarta e quinta linhas.

--soft

A opção --soft move o HEAD para o commit indicado, sem mudar o working dir e o staging area. Ou seja, se eu passar como parâmetro o hash do segundo commit:

$ git reset --soft 8bc39c6

Agora o HEAD aponta para o commit indicado:

$ git log --oneline
8bc39c6 (HEAD -> master) segundo commit
36da346 primeiro commit

Mas o working dir continua o mesmo (o arquivo no meu disco continua com as 5 linhas):

$ cat arq.txt
primeiro
segundo
terceiro
quarto
quinto

E o staging area continua com 4 linhas:

$ git diff --cached
diff --git a/arq.txt b/arq.txt
index e274503..f9efd34 100644
--- a/arq.txt
+++ b/arq.txt
@@ -1,2 +1,4 @@
 primeiro
 segundo
+terceiro
+quarto

Note que agora o diff identificou que a terceira e quarta linhas foram adicionadas. Isso acontece porque agora o último commit é o segundo, e neste commit o arquivo só tinha 2 linhas. Ao compará-lo com o staging area (que está com 4 linhas), a diferença está na terceira e quarta linhas.

Vamos conferir também com ls-files:

$ git ls-files -s
100644 f9efd34b6e87df23f7b0c0275daaacff6b7ef8f0 0   arq.txt

Repare que o hash do arquivo continua o mesmo, o que indica que o staging area não foi alterado:

$ git cat-file -p f9efd34b6e
primeiro
segundo
terceiro
quarto

O arquivo que está no staging area continua com a quarta linha. Ou seja, reset --soft não alterou seu conteúdo.

--mixed

A opção --mixed é o default se nenhuma das opções for fornecida - mas no exemplo a seguir vou colocá-la só para ficar mais claro que estou usando-a.

Lembrando que estou partindo do cenário inicial: 3 commits (HEAD aponta para o terceiro commit 668185f), o arquivo no staging area tem 4 linhas e no working dir tem 5 linhas.

Esta opção faz o mesmo que --soft (move o HEAD para o commit indicado), mas não para por aí. Em seguida ele atualiza o staging area com o conteúdo que está neste commit:

$ git reset --mixed 8bc39c6
Unstaged changes after reset:
M   arq.txt

Agora o HEAD aponta para o commit 8bc39c6:

$ git log --oneline
8bc39c6 (HEAD -> master) segundo commit
36da346 primeiro commit

E o staging area foi atualizado com o mesmo conteúdo do commit (tanto que o reset gerou a mensagem "Unstaged changes after reset", indicando que algo foi alterado no staging area).

Podemos ver que diff --cached não mostra mais nenhuma diferença:

$ git diff --cached
(não mostra nada)

Ou seja, o staging area está igual ao último commit (que no caso, é o segundo commit, no qual o arquivo só tem 2 linhas). Vamos conferir com ls-files:

$ git ls-files -s
100644 e2745035197cf4b209887f1fcc056f1afe7ff23d 0   arq.txt

O hash do arquivo mudou (não é mais "f9efd34b6e..."), o que indica que de fato o staging area foi alterado. Vamos ver o seu conteúdo:

$ git cat-file -p e2745035
primeiro
segundo

E de fato o staging area está com apenas duas linhas, idêntico ao segundo commit. Mas o arquivo no meu working dir continua com 5 linhas:

$ cat arq.txt
primeiro
segundo
terceiro
quarto
quinto

--hard

A opção --hard faz tudo que --mixed faz, mas também sobrescreve o conteúdo do meu working dir.

Lembrando que estou partindo do cenário inicial: 3 commits (HEAD aponta para o terceiro commit 668185f), o arquivo no staging area tem 4 linhas e no working dir tem 5 linhas.

$ git reset --hard 8bc39c6
HEAD is now at 8bc39c6 segundo commit

Primeiro vamos conferir se ele fez o mesmo que --mixed: o HEAD deve estar no segundo commit e o staging area deve estar igual a este commit (ou seja, o arquivo deve estar com duas linhas):

$ git log --oneline
8bc39c6 (HEAD -> master) segundo commit
36da346 primeiro commit

$ git diff --cached
(não mostra nada)

$ git ls-files -s
100644 e2745035197cf4b209887f1fcc056f1afe7ff23d 0   arq.txt
$ git cat-file -p e2745035
primeiro
segundo

Como podemos ver, --hard fez o mesmo que --mixed. Mas ele fez algo a mais: meu working dir foi sobrescrito com o mesmo conteúdo do segundo commit:

$ cat arq.txt
primeiro
segundo

Resumindo

  • --soft: move o HEAD para o outro commit
  • --mixed: move o HEAD e sobrescreve o staging area
  • --hard: move o HEAD, sobrescreve o staging area e o working dir

Este exemplo foi claramente copiado inspirado por este artigo. Recomendo que leia, pois ele tem exemplos mais detalhados, principalmente para os casos em que você passa arquivos como parâmetros (pois aí o comportamento muda um pouco), entre outros casos de uso - mas como esses casos adicionais não são o foco do post, resolvi parar por aqui, pois já ficou gigante.

Baseado na minha resposta no Stack Overflow

A explicação ficou super clara! Parabéns pelo conteúdo e obrigado por compartilhar!

Ficou perfeito o post cara! Vou deixar salvo pra quando eu precisar fazer algo que eu sei que o git reset faz, mas não sei como. 99% das minhas ultilizações é o --hard HEAD quando só quero limpar as minhas mudanças