Chapters ▾ 2nd Edition

10.7 Git Internals (Por Dentro do Git) - Manutenção e Recuperação de Dados

Manutenção e Recuperação de Dados

Eventualmente você precisará fazer alguma limpeza – tornar o repositório mais compacto, limpar um repositório que foi importado, ou recuperar trabalho perdido. Essa seção cobrirá alguns desses cenários.

Manutenção

Ocasionalmente, o Git automaticamente executa um comando chamado “auto gc”. Na maior parte do tempo, este comando não faz nada. No entanto, se há muitos objetos loose (objetos soltos que não estão dentro de um packfile) ou muitos packfiles (arquivos de pacotes), o Git inicializa uma versão do comando git gc de pleno direito. O “gc” significa garbage collect (coleta de lixo), e o comando faz algumas coisas: ele junta todos os objetos soltos e os coloca em um packfile, consolida os packfiles em um grande packfile, e apaga os objetos que não são alcançáveis a partir de nenhum commit e que têm alguns meses de idade.

Você pode rodar um auto gc manualmente da seguinte maneira:

$ git gc --auto

Novamente, geralmente isso não faz nada. Você deve ter mais ou menos uns 7.000 objetos do tipo loose (objetos soltos) ou mais de 50 do tipo packfiles para que o Git dispare um comando gc de fato. Você pode modificar esses limites com as definições de configuração gc.auto e gc.autopacklimit, respectivamente.

A outra coisa que o gc fará é empacotar as suas referências para dentro de um único arquivo. Suponha que o seu repositório contenha os seguintes branches e tags:

$ find .git/refs -type f
.git/refs/heads/experiment
.git/refs/heads/master
.git/refs/tags/v1.0
.git/refs/tags/v1.1

Se você rodar o git gc, você não terá mais esses arquivos no diretório refs. O Git vai movê-los visando a eficiência para um arquivo chamado .git/packed-refs que se parece com isso:

$ cat .git/packed-refs
# pack-refs with: peeled fully-peeled
cac0cab538b970a37ea1e769cbbde608743bc96d refs/heads/experiment
ab1afef80fac8e34258ff41fc1b867c702daa24b refs/heads/master
cac0cab538b970a37ea1e769cbbde608743bc96d refs/tags/v1.0
9585191f37f7b0fb9444f35a9bf50de191beadc2 refs/tags/v1.1
^1a410efbd13591db07496601ebc7a059dd55cfe9

Se você atualizar alguma referência, o Git não editará esse arquivo, em vez disso, ele criará um novo arquivo em refs/heads. Para obter o SHA-1 apropriado para uma determinada referência, o Git verifica por essa referência no diretório refs e então verifica o arquivo packed-refs como alternativa. Então se você não conseguir achar a referência no diretório refs, ela provavelmente estará no seu arquivo packed-refs.

Note a última linha do arquivo, que começa com um ^. Isto significa que a tag diretamente acima é uma tag anotada (annotated tag) e que a linha é o commit para o qual a tag apontada está orientada.

Recuperação de Dados

Em algum ponto na sua jornada com o Git, você pode perder um commit acidentalmente. Geralmente, isto acontece porque você forçou a deleção de um branch que tinha algum trabalho, e acontece que você afinal de contas o queria; ou você fez um hard-reset num branch, consequentemente abandonando commits que você desejava manter. Assumindo que isto aconteça, como você pode recuperar os seus commits de volta?

Aqui está um exemplo que faz um hard-reset no branch master do seu repositório de testes para um commit mais antigo e em seguida recupera os commits perdidos. Primeiro, vamos verificar onde o seu repositório está neste exato ponto:

$ git log --pretty=oneline
ab1afef80fac8e34258ff41fc1b867c702daa24b Modify repo.rb a bit
484a59275031909e19aadb7c92262719cfcdf19a Create repo.rb
1a410efbd13591db07496601ebc7a059dd55cfe9 Third commit
cac0cab538b970a37ea1e769cbbde608743bc96d Second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d First commit

Agora, mova o branch master de volta para o commit do meio:

$ git reset --hard 1a410efbd13591db07496601ebc7a059dd55cfe9
HEAD is now at 1a410ef Third commit
$ git log --pretty=oneline
1a410efbd13591db07496601ebc7a059dd55cfe9 Third commit
cac0cab538b970a37ea1e769cbbde608743bc96d Second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d First commit

Você efetivamente perdeu os dois commits superiores – você não tem nenhum branch a partir do qual aqueles commits sejam alcançáveis. Você precisa encontrar o SHA-1 do último commit e então adicionar um branch que aponte para ele. O truque é encontrar aquele último commit SHA-1 – não é como se você o tivesse memorizado, certo?

Muitas vezes, a maneira mais rápida é usar uma ferramenta chamada git reflog. Conforme você está trabalhando, o Git grava silenciosamente qual é o seu HEAD toda vez que você o altera. A cada vez que você comita ou muda de branch, o reflog é atualizado. O reflog também é atualizado pelo comando git update-ref, o que é outro motivo para usá-lo em vez de simplesmente escrever o valor do SHA-1 nos seus arquivos de ref, como vimos em Referências do Git. Você pode ver por onde você esteve a qualquer momento, executando git reflog:

$ git reflog
1a410ef HEAD@{0}: reset: moving to 1a410ef
ab1afef HEAD@{1}: commit: Modify repo.rb a bit
484a592 HEAD@{2}: commit: Create repo.rb

Aqui podemos ver os dois commits dos quais fizemos checkout, entretanto não há muita informação aqui. Para ver a mesma informação de uma forma muito mais útil, nós podemos executar git log -g, o qual dará a você uma saída de log normal para o seu reflog.

$ git log -g
commit 1a410efbd13591db07496601ebc7a059dd55cfe9
Reflog: HEAD@{0} (Scott Chacon <schacon@gmail.com>)
Reflog message: updating HEAD
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri May 22 18:22:37 2009 -0700

		Third commit

commit ab1afef80fac8e34258ff41fc1b867c702daa24b
Reflog: HEAD@{1} (Scott Chacon <schacon@gmail.com>)
Reflog message: updating HEAD
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri May 22 18:15:24 2009 -0700

       Modify repo.rb a bit

Parece que o commit mais abaixo é o que você perdeu, então você pode recuperá-lo criando um novo branch naquele commit. Por exemplo, você pode criar um branch chamado recover-branch a partir daquele commit (ab1afef):

$ git branch recover-branch ab1afef
$ git log --pretty=oneline recover-branch
ab1afef80fac8e34258ff41fc1b867c702daa24b Modify repo.rb a bit
484a59275031909e19aadb7c92262719cfcdf19a Create repo.rb
1a410efbd13591db07496601ebc7a059dd55cfe9 Third commit
cac0cab538b970a37ea1e769cbbde608743bc96d Second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d First commit

Legal – agora você tem um branch chamado recover-branch onde o seu branch master costumava estar, fazendo com que os dois primeiros commits sejam alcançáveis de novo. A seguir, suponha que a sua perda por alguma razão não estava no reflog – você pode simular isto removendo a recover-branch e apagando o reflog. Agora os dois primeiros commits não são alcançáveis por mais nada:

$ git branch -D recover-branch
$ rm -Rf .git/logs/

Pelo fato de os dados do reflog serem mantidos no diretório .git/logs/, você efetivamente não tem nenhum reflog. Como você consegue recuperar esse commit a esta altura? Uma forma é usar o utilitário git fsck, o qual verifica a integridade do seu banco de dados. Se você executá-lo com a opção --full, ele mostra a você todos os objetos que não são apontados por outro objeto:

$ git fsck --full
Checking object directories: 100% (256/256), done.
Checking objects: 100% (18/18), done.
dangling blob d670460b4b4aece5915caf5c68d12f560a9fe3e4
dangling commit ab1afef80fac8e34258ff41fc1b867c702daa24b
dangling tree aea790b9a58f6cf6f2804eeac9f0abbe9631e4c9
dangling blob 7108f7ecb345ee9d0084193f147cdad4d2998293

Neste caso, você consegue ver o seu commit faltante depois do texto “dangling commit” (commit solto/perdido). Você pode recuperá-lo da mesma forma, adicionando um branch que aponte para aquele SHA-1.

Removendo Objetos

Há muitas coisas ótimas sobre o Git, mas um recurso que pode causar problemas é o fato de que um git clone baixa o histórico inteiro do projeto, incluindo cada versão de cada arquivo. Isto é bom se o projeto inteiro se trata de código-fonte, pois o Git é altamente otimizado para comprimir esses dados de maneira eficiente. Porém, se alguém em algum momento no histórico do seu projeto adicionou um único arquivo enorme, todo clone para todo o sempre será forçado a baixar aquele arquivo grande, mesmo que ele tenha sido removido do projeto no commit logo em seguida. Como ele é alcançável a partir do histórico, ele sempre estará lá.

Isto pode ser um enorme problema quando você estiver convertendo repositórios do Subversion ou Perforce para o Git. Como você não baixa o histórico inteiro naqueles sistemas, este tipo de adição traz poucas consequências. Se você fez uma importação de outro sistema ou descobriu de alguma forma que o seu repositório está bem maior do que deveria ser, veja aqui como você pode encontrar e remover grandes objetos.

Fique avisado: esta técnica é destrutiva para o histórico dos seus commits. Ela reescreve todo objeto de commit a partir da tree mais antiga que você precisa modificar para remover uma referência de arquivo grande. Se você fizer isto imediatamente após uma importação, antes de alguém ter começado a basear seu trabalho no commit, você não terá problemas – do contrário, você terá de notificar todos os contribuidores que eles devem fazer o rebase de seus trabalhos para os seus novos commits.

Para demonstrar, você vai adicionar um arquivo grande no seu repositório de testes, removê-lo no commit seguinte, encontrá-lo, e removê-lo permanentemente do repositório. Primeiro, adicione um objeto grande ao seu histórico:

$ curl -L https://www.kernel.org/pub/software/scm/git/git-2.1.0.tar.gz > git.tgz
$ git add git.tgz
$ git commit -m 'Add git tarball'
[master 7b30847] Add git tarball
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 git.tgz

Ops – você não queria adicionar um enorme tarball ao seu projeto. Melhor se livrar disto:

$ git rm git.tgz
rm 'git.tgz'
$ git commit -m 'Oops - remove large tarball'
[master dadf725] Oops - remove large tarball
 1 file changed, 0 insertions(+), 0 deletions(-)
 delete mode 100644 git.tgz

Agora, rode o gc no seu banco de dados e veja quanto espaço você está usando:

$ git gc
Counting objects: 17, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (13/13), done.
Writing objects: 100% (17/17), done.
Total 17 (delta 1), reused 10 (delta 0)

Você pode executar o comando count-objects para ver rapidamente a quantidade de espaço que você está consumindo:

$ git count-objects -v
count: 7
size: 32
in-pack: 17
packs: 1
size-pack: 4868
prune-packable: 0
garbage: 0
size-garbage: 0

A entrada size-pack é o tamanho dos seus packfiles em kilobytes, então você está utilizando quase 5MB. Antes do último commit, você estava usando algo mais perto de 2K – claramente, remover o arquivo do commit anterior não o removeu do seu histórico. Toda vez que alguém clonar este repositório, terá que baixar todos os 5MB apenas para obter este projeto pequeno, porque você acidentalmente adicionou um arquivo grande. Vamos nos livrar disto.

Primeiro você tem que encontrá-lo. Neste caso, você já sabe de que arquivo se trata. Mas suponha que você não soubesse; como você identificaria qual ou quais arquivos estariam tomando tanto espaço? Se você rodar git gc, todos os objetos estarão em um packfile; você pode identificar os grandes objetos executando um outro comando plumbing chamado git verify-pack e ordenando pelo terceiro campo da saída, que é o tamanho do arquivo. Você também pode repassá-lo pelo pipe usando o comando tail porque você está interessado somente nos últimos poucos maiores arquivos:

$ git verify-pack -v .git/objects/pack/pack-29…69.idx \
  | sort -k 3 -n \
  | tail -3
dadf7258d699da2c8d89b09ef6670edb7d5f91b4 commit 229 159 12
033b4468fa6b2a9547a70d88d1bbe8bf3f9ed0d5 blob   22044 5792 4977696
82c99a3e86bb1267b236a4b6eff7868d97489af1 blob   4975916 4976258 1438

O objeto grande está no final: 5MB. Para descobrir que arquivo é este, você usará o comando rev-list, que você usou rapidamente em Impondo um Formato de Mensagem de Commit Específico. Se você passar a flag --objects para o rev-list, ele lista todos os SHA-1s de commits e também os SHA-1s de blobs com os caminhos de arquivos associados a eles. Você pode usar isso para encontrar o nome do seu blob:

$ git rev-list --objects --all | grep 82c99a3
82c99a3e86bb1267b236a4b6eff7868d97489af1 git.tgz

Agora, você precisa remover este arquivo de todas as trees no seu passado. Você pode ver com facilidade quais commits modificaram esse arquivo:

$ git log --oneline --branches -- git.tgz
dadf725 Oops - remove large tarball
7b30847 Add git tarball

Você precisa reescrever todos os commits seguintes a 7b30847 para remover totalmente este arquivo do seu histórico no Git. Para fazer isso, você usa o filter-branch, que você usou em Reescrevendo o Histórico:

$ git filter-branch --index-filter \
  'git rm --ignore-unmatch --cached git.tgz' -- 7b30847^..
Rewrite 7b30847d080183a1ab7d18fb202473b3096e9f34 (1/2)rm 'git.tgz'
Rewrite dadf7258d699da2c8d89b09ef6670edb7d5f91b4 (2/2)
Ref 'refs/heads/master' was rewritten

A opção --index-filter é similar à opção --tree-filter usada em Reescrevendo o Histórico, exceto que, em vez de passar um comando que modifica arquivos checados no disco, você estará modificando sua staging area (área de preparação) ou o index em cada vez.

Em vez de remover um arquivo específico com algo como rm file, você terá de removê-lo com git rm --cached – você precisa removê-lo a partir do índice, não a partir do disco. A razão para se fazer desta forma é a velocidade – porque o Git não tem que efetuar checkout de cada revisão pro disco antes de rodar o seu filtro, o processo pode ser muito, muito mais veloz. Você pode realizar a mesma tarefa usando --tree-filter caso queira. A opção --ignore-unmatch para o git rm diz a ele para não falhar caso o padrão que você está tentando remover não estiver ali. Por fim, você pede para o filter-branch reescrever o seu histórico apenas a partir do commit 7b30847 em diante, pois você sabe que é de onde o problema se originou. Do contrário, ele irá começar a partir do início e vai, de maneira desnecessária, levar mais tempo.

Seu histórico não contém mais nenhuma referência a esse arquivo. Entretanto, o seu reflog e um novo conjunto de refs que o Git adicionou sob .git/refs/original quando você rodou o filter-branch ainda contêm, então você terá que removê-los para então reempacotar o banco de dados. Você precisa se livrar de qualquer coisa que tenha um ponteiro para esses antigos commits antes de reempacotar:

$ rm -Rf .git/refs/original
$ rm -Rf .git/logs/
$ git gc
Counting objects: 15, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (11/11), done.
Writing objects: 100% (15/15), done.
Total 15 (delta 1), reused 12 (delta 0)

Vamos ver quanto espaço você salvou.

$ git count-objects -v
count: 11
size: 4904
in-pack: 15
packs: 1
size-pack: 8
prune-packable: 0
garbage: 0
size-garbage: 0

O tamanho do repositório empacotado baixou para 8K, o que é bem melhor que 5MB. Você pode ver pelo valor do tamanho que o grande objeto ainda está nos seus loose objects, portanto não se foi; mas ele não será transferido num push ou num clone subsequente, que é o mais importante. Se você quisesse muito, você poderia remover o objeto por completo executando o git prune com a opção --expire:

$ git prune --expire now
$ git count-objects -v
count: 0
size: 0
in-pack: 15
packs: 1
size-pack: 8
prune-packable: 0
garbage: 0
size-garbage: 0