Chapters ▾ 2nd Edition

7.7 Ferramentas do Git - Reset Desmistificado

Reset Desmistificado

Antes de passar para ferramentas mais especializadas, vamos falar sobre os comandos reset e checkout do Git. Estes comandos são duas das partes mais confusas do Git quando você os encontra pela primeira vez. Eles fazem tantas coisas que parece impossível realmente entendê-los e empregá-los adequadamente. Para isso, recomendamos uma metáfora simples.

As Três Árvores

Uma maneira mais fácil de pensar sobre reset e checkout é através do modelo mental de que o Git é um gerenciador de conteúdo de três árvores diferentes. Por “árvore” aqui, queremos dizer realmente “coleção de arquivos”, não especificamente a estrutura de dados. Há alguns casos em que o index não age exatamente como uma árvore, mas para nossos propósitos é mais fácil pensar sobre ele dessa forma por enquanto.

O Git, como sistema, gerencia e manipula três árvores em sua operação normal:

Árvore (Tree) Papel

HEAD

Snapshot do último commit, próximo pai

Index

Snapshot proposto do próximo commit

Working Directory (Diretório de Trabalho)

Sandbox (Área de testes)

O HEAD

O HEAD é o ponteiro para a referência da branch atual, que por sua vez é um ponteiro para o último commit feito naquela branch. Isso significa que o HEAD será o pai do próximo commit que for criado. Geralmente é mais simples pensar no HEAD como o snapshot do seu último commit naquela branch.

Na verdade, é bem fácil ver como é esse snapshot. Aqui está um exemplo de como obter a listagem real do diretório e os checksums SHA-1 para cada arquivo no snapshot do HEAD:

$ git cat-file -p HEAD
tree cfda3bf379e4f8dba8717dee55aab78aef7f4daf
author Scott Chacon  1301511835 -0700
committer Scott Chacon  1301511835 -0700

initial commit

$ git ls-tree -r HEAD
100644 blob a906cb2a4a904a152...   README
100644 blob 8f94139338f9404f2...   Rakefile
040000 tree 99f1a6d12cb4b6f19...   lib

Os comandos Git cat-file e ls-tree são comandos “plumbing” (de encanamento/baixo nível) que são usados para coisas de nível mais baixo e não são realmente usados no trabalho diário, mas eles nos ajudam a ver o que está acontecendo aqui.

O Index

O index é o seu próximo commit proposto. Também nos referimos a esse conceito como “Staging Area” (Área de Stage) do Git, pois é para lá que o Git olha quando você roda git commit.

O Git preenche esse index com uma lista de todo o conteúdo dos arquivos que sofreram checkout pela última vez no seu diretório de trabalho e como eles se pareciam quando originalmente sofreram checkout. Você então substitui alguns desses arquivos por novas versões deles, e o git commit converte isso na árvore para um novo commit.

$ git ls-files -s
100644 a906cb2a4a904a152e80877d4088654daad0c859 0	README
100644 8f94139338f9404f26296befa88755fc2598c289 0	Rakefile
100644 47c6340d6459e05787f644c2447d2595f5d3a54b 0	lib/simplegit.rb

Novamente, aqui estamos usando git ls-files, que é mais um comando de bastidores que mostra como o seu index se parece atualmente.

O index não é tecnicamente uma estrutura de árvore — ele é implementado como um manifesto nivelado (flattened) — mas para nossos propósitos, é próximo o suficiente.

O Diretório de Trabalho (Working Directory)

Finalmente, você tem o seu diretório de trabalho (também comumente chamado de “working tree” ou árvore de trabalho). As outras duas árvores armazenam o seu conteúdo de forma eficiente, porém inconveniente, dentro da pasta .git. O diretório de trabalho descompacta-os em arquivos reais, o que torna muito mais fácil para você editá-los. Pense no diretório de trabalho como uma sandbox (área de testes), onde você pode experimentar as alterações antes de comitá-las na sua staging area (index) e então no histórico.

$ tree
.
├── README
├── Rakefile
└── lib
    └── simplegit.rb

1 directory, 3 files

O Fluxo de Trabalho (Workflow)

O fluxo de trabalho típico do Git é gravar snapshots do seu projeto em estados sucessivamente melhores, manipulando essas três árvores.

Fluxo de trabalho típico do Git
Figure 137. Fluxo de trabalho típico do Git

Vamos visualizar esse processo: digamos que você entre num diretório novo com um único arquivo nele. Chamaremos esta de v1 do arquivo, e a indicaremos em azul. Agora nós rodamos git init, o que criará um repositório Git com uma referência HEAD que aponta para a branch master não nascida (unborn).

Repositório Git recém-inicializado com arquivo unstaged no diretório de trabalho
Figure 138. Repositório Git recém-inicializado com arquivo unstaged no diretório de trabalho

Neste ponto, apenas a árvore do diretório de trabalho tem algum conteúdo.

Agora queremos comitar este arquivo, então usamos git add para pegar o conteúdo do diretório de trabalho e copiá-lo para o index.

Arquivo é copiado para o index no `git add`
Figure 139. Arquivo é copiado para o index no git add

Então nós rodamos git commit, o que pega o conteúdo do index e o salva como um snapshot permanente, cria um objeto de commit que aponta para esse snapshot, e atualiza a master para apontar para esse commit.

O passo do `git commit`
Figure 140. O passo do git commit

Se rodarmos git status, não veremos alterações, porque todas as três árvores são as mesmas.

Agora queremos fazer uma alteração nesse arquivo e comitá-lo. Passaremos pelo mesmo processo; primeiro, nós alteramos o arquivo no nosso diretório de trabalho. Vamos chamar isso de v2 do arquivo, e indicá-la em vermelho.

Repositório Git com arquivo alterado no diretório de trabalho
Figure 141. Repositório Git com arquivo alterado no diretório de trabalho

Se rodarmos git status agora, veremos o arquivo em vermelho como “Changes not staged for commit” (Alterações não preparadas para commit), porque essa entrada difere entre o index e o diretório de trabalho. Em seguida, nós rodamos git add nele para prepará-lo (stage) no nosso index.

Preparando a alteração para o index
Figure 142. Preparando (Staging) a alteração para o index

Neste ponto, se rodarmos git status, veremos o arquivo em verde sob “Changes to be committed” (Alterações a serem comitadas) porque o index e o HEAD diferem — isto é, o nosso próximo commit proposto é agora diferente do nosso último commit. Finalmente, nós rodamos git commit para finalizar o commit.

O passo do `git commit` com arquivo alterado
Figure 143. O passo do git commit com arquivo alterado

Agora, git status não nos dará nenhuma saída, porque todas as três árvores estão iguais novamente.

Trocar de branch ou clonar passa por um processo semelhante. Quando você faz checkout num branch, isso altera o HEAD para apontar para a nova ref de branch, popula o seu index com o snapshot desse commit, e então copia o conteúdo do index para o seu diretório de trabalho.

O Papel do Reset

O comando reset faz mais sentido quando visto neste contexto.

Para os propósitos destes exemplos, digamos que nós modificamos file.txt novamente e o comitamos uma terceira vez. Então agora nosso histórico está assim:

Repositório Git com três commits
Figure 144. Repositório Git com três commits

Vamos agora percorrer exatamente o que o reset faz quando você o chama. Ele manipula diretamente estas três árvores de uma forma simples e previsível. Ele faz até três operações básicas.

Passo 1: Mover o HEAD

A primeira coisa que o reset fará é mover para o que o HEAD aponta. Isso não é o mesmo que alterar o próprio HEAD (que é o que o checkout faz); reset move a branch para a qual o HEAD está apontando. Isso significa que se o HEAD estiver configurado para a branch master (i.e. você está atualmente na branch master), rodar git reset 9e5e6a4 começará por fazer a master apontar para 9e5e6a4.

Reset soft
Figure 145. Reset soft

Não importa qual forma de reset com um commit você invoque, essa é a primeira coisa que ele sempre tentará fazer. Com o reset --soft, ele simplesmente parará por aí.

Agora, reserve um segundo para olhar aquele diagrama e perceber o que aconteceu: ele essencialmente desfez o último comando git commit. Quando você roda o git commit, o Git cria um novo commit e move a branch para a qual o HEAD aponta para ele. Quando você faz reset voltando para HEAD~ (o pai do HEAD), você está movendo a branch de volta para onde ela estava, sem alterar o index ou o diretório de trabalho. Você poderia agora atualizar o index e rodar git commit novamente para realizar o que o git commit --amend teria feito (veja Alterando o Último Commit).

Passo 2: Atualizando o Index (--mixed)

Note que se você rodar git status agora, verá em verde a diferença entre o index e o que é o novo HEAD.

A próxima coisa que o reset fará é atualizar o index com o conteúdo do snapshot para o qual o HEAD aponta agora.

Reset mixed
Figure 146. Reset mixed

Se você especificar a opção --mixed, o reset parará neste ponto. Este também é o padrão, então se você não especificar nenhuma opção (apenas git reset HEAD~ neste caso), é aqui que o comando vai parar.

Agora, reserve outro segundo para olhar aquele diagrama e perceber o que aconteceu: ele ainda desfez o seu último commit, mas também tirou do stage (unstaged) tudo. Você reverteu (rolled back) para antes de rodar todos os seus comandos git add e git commit.

Passo 3: Atualizando o Diretório de Trabalho (--hard)

A terceira coisa que o reset fará é fazer o diretório de trabalho se parecer com o index. Se você usar a opção --hard, ele continuará para esta etapa.

Reset hard
Figure 147. Reset hard

Então, vamos pensar sobre o que acabou de acontecer. Você desfez o seu último commit, os comandos git add e git commit, e todo o trabalho que você fez no seu diretório de trabalho.

É importante notar que esta flag (--hard) é a única maneira de tornar o comando reset perigoso, e um dos pouquíssimos casos em que o Git realmente destruirá dados. Qualquer outra invocação de reset pode ser facilmente desfeita, mas a opção --hard não, pois ela substitui forçosamente (overwrites) arquivos no diretório de trabalho. Neste caso particular, nós ainda temos a versão v3 do nosso arquivo em um commit no nosso banco de dados (DB) do Git, e poderíamos recuperá-la olhando para o nosso reflog, mas se não a tivéssemos comitado, o Git ainda assim teria sobrescrito o arquivo e ele seria irrecuperável.

Recapitulação

O comando reset substitui (overwrites) estas três árvores numa ordem específica, parando quando você manda:

  1. Move a branch para a qual o HEAD aponta (pare aqui se usar --soft).

  2. Faz o index ficar igual ao HEAD (pare aqui a menos que use --hard).

  3. Faz o diretório de trabalho ficar igual ao index.

Reset com um Caminho (Path)

Isso cobre o comportamento do reset em sua forma básica, mas você também pode fornecer a ele um caminho (path) para agir sobre. Se você especificar um caminho, o reset pulará o passo 1 e limitará o restante de suas ações a um arquivo ou conjunto de arquivos específico. Isso até que faz sentido — o HEAD é apenas um ponteiro, e você não pode apontar para parte de um commit e parte de outro. Mas o index e o diretório de trabalho podem ser parcialmente atualizados, então o reset prossegue com os passos 2 e 3.

Então, assuma que rodamos git reset file.txt. Esta forma (já que você não especificou um SHA-1 de commit ou branch, e não especificou --soft ou --hard) é um atalho para git reset --mixed HEAD file.txt, o qual irá:

  1. Mover a branch para a qual o HEAD aponta (pulado).

  2. Fazer o index ficar igual ao HEAD (pare aqui).

Então, essencialmente, ele apenas copia o file.txt do HEAD para o index.

Reset mixed com um caminho
Figure 148. Reset mixed com um caminho (path)

Isso tem o efeito prático de tirar o arquivo do stage (unstaging). Se olharmos para o diagrama desse comando e pensarmos sobre o que o git add faz, eles são exatamente o oposto.

Preparando arquivo para o index
Figure 149. Preparando arquivo (Staging) para o index

É por isso que a saída do comando git status sugere que você rode isto para tirar um arquivo do stage (veja Retirando a Preparação de um Arquivo Preparado (Unstaging) para mais informações sobre isso).

Poderíamos facilmente não deixar o Git assumir que queríamos dizer “pegue os dados do HEAD” especificando um commit específico para puxar (pull) aquela versão do arquivo. Nós apenas rodaríamos algo como git reset eb43bf file.txt.

Reset soft com um caminho para um commit específico
Figure 150. Reset soft com um caminho para um commit específico

Isso efetivamente faz a mesma coisa como se tivéssemos revertido o conteúdo do arquivo para v1 no diretório de trabalho, rodado git add nele, e então revertido de volta para v3 novamente (sem realmente passar por todos esses passos). Se rodarmos git commit agora, ele registrará uma alteração que reverte esse arquivo de volta para v1, mesmo que nunca o tenhamos realmente tido no nosso diretório de trabalho novamente.

Também é interessante notar que, assim como o git add, o comando reset aceitará a opção --patch para remover partes do conteúdo do stage trecho por trecho (hunk-by-hunk). Assim, você pode remover do stage ou reverter conteúdos seletivamente.

Juntando (Squashing)

Vamos olhar como fazer algo interessante com este poder recém-descoberto — juntar (squashing) commits.

Digamos que você tenha uma série de commits com mensagens como “oops.”, “WIP” (work in progress) e “forgot this file” (esqueci desse arquivo). Você pode usar o reset para juntá-los rápida e facilmente em um único commit que faz você parecer muito esperto. A seção Juntando Commits (Squashing) mostra outra maneira de fazer isso, mas neste exemplo é mais simples usar o reset.

Digamos que você tenha um projeto onde o primeiro commit tem um arquivo, o segundo commit adicionou um novo arquivo e alterou o primeiro, e o terceiro commit alterou o primeiro arquivo novamente. O segundo commit foi um trabalho em andamento e você quer juntá-lo.

Repositório Git
Figure 151. Repositório Git

Você pode rodar git reset --soft HEAD~2 para mover a branch HEAD de volta para um commit mais antigo (o commit mais recente que você quer manter):

Movendo HEAD com reset soft
Figure 152. Movendo HEAD com reset soft

E então simplesmente rodar git commit novamente:

Repositório Git com commit juntado
Figure 153. Repositório Git com commit juntado (squashed)

Agora você pode ver que o seu histórico alcançável, o histórico que você faria push, agora parece que você teve um commit com file-a.txt v1, então um segundo que modificou file-a.txt para v3 e adicionou file-b.txt. O commit com a versão v2 do arquivo não está mais no histórico.

Fazer Checkout (Check It Out)

Finalmente, você pode se perguntar qual é a diferença entre checkout e reset. Assim como o reset, o checkout manipula as três árvores, e é um pouco diferente dependendo se você passa um caminho de arquivo para o comando ou não.

Sem Caminhos (Paths)

Rodar git checkout [branch] é muito parecido com rodar git reset --hard [branch] no sentido de que ele atualiza todas as três árvores para você para ficarem parecidas com [branch], mas há duas diferenças importantes.

Primeiro, diferentemente do reset --hard, o checkout é seguro para o diretório de trabalho; ele verificará para se certificar de que não está apagando arquivos que tenham alterações neles. Na verdade, ele é um pouco mais inteligente que isso — ele tenta fazer um merge trivial no diretório de trabalho, de modo que todos os arquivos que você não alterou serão atualizados. O reset --hard, por outro lado, substituirá tudo de forma generalizada sem verificar.

A segunda diferença importante é como o checkout atualiza o HEAD. Enquanto o reset moverá a branch para a qual o HEAD aponta, o checkout moverá o próprio HEAD para apontar para outra branch.

Por exemplo, digamos que temos as branches master e develop que apontam para commits diferentes, e nós estamos atualmente na develop (então o HEAD aponta para ela). Se rodarmos git reset master, a própria develop agora apontará para o mesmo commit que a master. Se, em vez disso, rodarmos git checkout master, a develop não se move, o HEAD sim. O HEAD agora apontará para master.

Então, em ambos os casos estamos movendo o HEAD para apontar para o commit A, mas como nós fazemos isso é muito diferente. O reset moverá a branch para a qual o HEAD aponta, o checkout move o próprio HEAD.

`git checkout` e `git reset`
Figure 154. git checkout e git reset

Com Caminhos (Paths)

A outra maneira de rodar checkout é com um caminho de arquivo, que, assim como o reset, não move o HEAD. Ele é exatamente como git reset [branch] file no sentido de que ele atualiza o index com esse arquivo naquele commit, mas ele também sobrescreve o arquivo no diretório de trabalho. Seria exatamente como git reset --hard [branch] file (se o reset permitisse que você rodasse isso) — ele não é seguro para o diretório de trabalho e não move o HEAD.

Além disso, assim como git reset e git add, o checkout aceitará a opção --patch para permitir que você reverta o conteúdo de um arquivo seletivamente trecho por trecho (hunk-by-hunk).

Resumo

Esperamos que agora você entenda e se sinta mais confortável com o comando reset, mas provavelmente ainda esteja um pouco confuso sobre como exatamente ele difere do checkout e não consiga se lembrar de todas as regras das diferentes invocações.

Aqui está uma folha de dicas (cheat-sheet) sobre quais comandos afetam quais árvores. A coluna “HEAD” lê “REF” se o comando mover a referência (branch) para a qual o HEAD aponta, e “HEAD” se ele mover o próprio HEAD. Preste atenção especial à coluna 'WD Seguro?' — se disser NÃO, reserve um segundo para pensar antes de rodar o comando.

HEAD Index Workdir WD Seguro?

Nível do Commit

reset --soft [commit]

REF

NÃO

NÃO

SIM

reset [commit]

REF

SIM

NÃO

SIM

reset --hard [commit]

REF

SIM

SIM

NÃO

checkout <commit>

HEAD

SIM

SIM

SIM

Nível do Arquivo

reset [commit] <paths>

NÃO

SIM

NÃO

SIM

checkout [commit] <paths>

NÃO

SIM

SIM

NÃO