Git como ferramenta de debug
Tem certeza? Fazer debug com Git
Quais são as ferramentas que vêm à sua cabeça quando alguém diz “debug”? Deixa eu tentar adivinhar:
- um detector de vazamento de memória (ex: Valgrind);
- um profiler (ex: GNU gprof);
- uma função que para o seu programa e te dá um REPL (ex: a função
breakpoint
do Python ou obyebug
do Ruby); - alguma coisa que é de fato chamada depurador (ou “debugger”), como o GDB, ou algum similar que vêm nas IDEs;
- ou ainda a nossa velha amiga, a função
print
.
Certo, então neste texto eu tentarei te convencer a adicionar o Git a essa lista.
Quando você está versionando algum código com o Git, o repositório é uma
preciosa fonte de informação. Muitas pessoas apenas pensam no Git só como aquele
comando que em que se faz a sequência git add
, git commit
e git push
, da
mesma forma como se faria o upload de um arquivo para o Google Drive ou se
postaria uma foto no Instagram. Porém, como o Git mantém todo o histórico de
commits desde o mais antigo, ele é provavelmente a ferramenta que melhor
conhece o código. Cada versão de cada arquivo é armazenada no repositório (neste
texto estou me referindo por “repositório” o repositório local, não o do GitHub,
GitLab, Bitbucket, etc), e achar informação útil nele é como um trabalho de
arqueologia.
Então, vou então mostrar aqui alguns conceitos e ferramentas úteis para extrair tudo que você precisa a partir dele!
Recapitulando conceitos básicos
Antes de seguirmos em frente, vamos primeiro recapitular os conceitos básicos do Git.
Commits são as versões de um repositório. Eles são snapshots, não
deltas, isso significa que um commit não sabe o que foi alterado. Em vez
disso, um commit guarda o conteúdo de cada arquivo. Quando você executa
git show
, você não está olhando o conteúdo de um commit, você na verdade
está olhando um patch, ou seja, o que mudou em relação ao commit pai.
Essa mudança, porém, é feita de uma forma inteligente que economiza espaço.
Commits têm referências a seu(s) commit(s) pais (se houverem):
- um commit normal tem um único pai;
- um commit de merge tem dois pais;
- um commit de merge octopus tem dois ou mais pais;
- um commit inicial não tem pai.
Commits também armazenam quando e quem os criou!
Branches são só referências para commits. A bem da verdade, uma branch é só um arquivo que contém o hash de um commit;
O histórico de commits não é uma linha do tempo linear! Na verdade, é um DAG, um grafo acíclico direcionado, em outras palavras, ele é um conjunto de diferentes linhas do tempo que podem ter um passado em comum (uma bifurcação, ou fork point em inglês) e que podem ter estados onde timelines se encontram (um commit de merge), mas sem nenhum loop!
A área de staging, antigamente chamada de cache (nome ainda usado às
vezes…) e internamente chamada de index é o lugar onde um commit é
preparado (em outras palavras, é o lugar para onde você manda um arquivo quando
executa git add
). O conteúdo da área de staging é o conteúdo do seu último
commit, além das alterações que você fez usando git add
(aquelas que são
mostradas em verde no git status
).
O diretório de trabalho (em inglês, working directory) é o diretório onde
estão armazenados no disco os arquivos do seu projeto. Da perspectiva deu quem
está vendo ou de quem está escrevendo e executando o código, este pode parecer a
principal área do Git (comparado ao histórico de comits e à área de
staging). Porém, da perspectiva do Git, este é a menos importante, já que
tudo aqui pode ser modificado, apagado, criado, e o Git não irá registrar, a não
ser, claro, que você explicitamente diga a ele pra fazer isso (usando git add
,
por exemplo).
Se você não conhecia alguma coisa dessa seção, surigro fortemente que leia a seção 1.3 do livro Pro Git.
Pathspec e git ls-files
Vamos ao nosso primeiro conceito aqui: o pathspec (eu enrolo a língua pra falar essa palavra…). Um pathspec é uma string que pode ser passada como argumento para vários comandos do Git para especificar arquivos.
Uma forma legal de ver os pathspecs funcionando é usando git ls-files
. Esse
comando lista todos os arquivos na área de staging, porém, se você passar um
pathspec como parâmetro, ele irá listar todos os arquivos que casam com esse
pathspec.
O pathspec mais óbvio é o próprio enderço do arquivo. Se você tem um arquivo
chamado README.md
, então README.md
vai ser um pathspec que o representa, e
se ele está dentro de um diretório chamado src
, um pathspec que irá
identificá-lo é src/README.md
. Note que, por padrão, pathspecs são relativos
ao diretório atual
*
O primeiro superpoder do pathspec que irei mostrar a você é o *
. Esse
caracter casa com zero ou mais caracteres. Por exemplo, este comando lista
todos os arquivos que terminam em .c na área de staging e que estão no
diretório atual ou em seus subdiretórios, recursivamente:
git ls-files '*.c'
Talvez você esteja pensando: “nada novo até agora, é apenas uma expansão de
*”. Bem, na verdade não. Note que '*.c'
está entre aspas, isso significa que
ele não é uma string, e o shell não está expandindo ele. Em vez disso, quem está
fazendo a expansão é o próprio Git.
Mas qual é a diferença? Lembra que eu disse “no diretório atual ou em seus
subdiretórios”? Essa é a diferença entre o *
do pathspec e o do *
do
shell: nessa situação, o *
do shell irá casar apenas com arquivos no
diretório atual, enquanto que o pathspec *
irá casar com todos os arquivos
que estão no diretório atual ou em um subdiretório dele! Dessa forma, se
executarmos:
git ls-files *.c
o shell irá substituir *.c
por todos os arquivos que estão no diretório atual,
então o git ls-files
vai listar só eles.
:
+ palavras mágicas
:
é um caracter especial para os pathspecs que é seguido de uma palavra
mágina. Eu não irei entrar em detalhes sobre isso, mas existem dois casos que
acho muito úteis.
O primeiro é o :/
. Ele representa a raiz do repositório. Se você está em um
subdiretório e quer achar arquivos pelos seus caminhos absolutos (isto é,
relativos à raiz do repositório), você terá que usar :/
. Por exemplo, :/*.c
irá casar com todos os arquivos que terminam em .c no repositório, não
importa onde eles estão localizados.
O segundo é :!
. Se a gente colocar :!
na frente de um pathspec, então o
pathspec irá casar com todos os arquivos que não casam com o resto do
pathspec. Por exemplo:
git ls-files ':!*.c'
Esse comando irá listar todos os arquivos na área de staging que não terminam em .c.
Mais sobre pathspecs e git ls-files
Pathspecs são realmente úteis para selecionar arquivos para passar como
argumentos para o Git. Você pode ler mais sobre eles no glossário do Git (man
gitglossary
), procurando por “pathspec”.
O git ls-files
, que eu usei como exemplo para os pathspecs, é uma ótima
ferramenta para encontrar arquivos no repositório. Ele pode substiuir o comando
find
, já que tem uma sintaxe bem mais simples.
Git Grep
git grep
, como o próprio nome diz, é um grep
melhorado pelo Git.
E o que isso significa? Bom, lembra que eu disse que o Git é provavelmente a
ferramenta que conhece o seu código? git grep
se aproveita disso para fazer um
grep
melhorado.
A sintaxe do git grep
é, basicamente, a seguinte:
git grep [<flags>] [<padrão>] [<commit>] -- [<pathspec>]
Você pode usar várias flags do grep
no git grep
, como por exemplo, -E
,
-P
ou -i
. O pathspec e o commit são opcionais. O --
é opcional na
maior parte dos casos, porém, é recomendado usá-lo para evitar ambiguidades.
Se você passar umn commit, então o Git Grep vai procurar nele o padrão, mas só nele, não vai procurar em outros commits.
Se você não fornecer o pathspec, o Git Grep irá procurar pelo padrão em todos
os arquivos no diretório atual ou em seus subdiretórios. Ele é bem mais
rápido que o GNU Grep ou o BSD Grep. Você pode ver isso na imagem a seguir,
nela eu estou procurando por #include
no código fonte do próprio Git, usando
BSD Grep, GNU Grep e Git Grep, respectivamente:
Mas o que mais o Git Grep pode fazer? Bom, dê uma olhada nas flags
--heading
e --break
. --heading
agrupa a saída pelo arquivo onde está cada
linha, e --break
apenas insere uma linha entre os grupos. Isso é muito útil
para procurar, por exemplo, arquivos que chamam uma função ou usam uma constante.
Um recurso bem interessante do Git Grep é a flag -W
(ou
--show-function
). Quando se ela, o Git Grep não só vai mostrar a linha que
contém o que você está procurando, mas vai também mostrar toda a função
onde ela está localizada. Então, vamos olhar o mesmo comando que eu mostrei na
última imagem, porém, adicionando a flag -W
(git grep --heading --break -W 'looks_like' -- '*.c'
):
Git Grep é uma ferramenta incrível para encontrar código. Ok, mas você deve estar pensando “legal, mas eu consigo fazer algo parecido com isso com a navegação de código da minha IDE, pulando pra definições e para usos”. Isso é verdade para vários casos. Porém o Git Grep é realmente útil quando você quer procurar somente em alguns arquivos (usando um pathspec como restrição), quando você quer procurar por uma regex genérica em vez de um nome de uma variável ou função, quando você quer procurar em outro commit ou quando você só não quer abrir uam IDE e prefere procurar a partir do terminal. Não é uma ferramenta que substitui outras, e sim uma ferramenta complementa as outras.
Git Blame
Se você já usou alguma vez o git blame
, é bem provável que vocẽ esteja
esperando que eu fale sobre ele e como ele é maravilhoso. Se esse é o caso,
então por favor, não pule esta seção porque eu tenho algo realmente importante
para te dizer.
Se esse não é o caso e você nunca ouviu falar ou nunca usou o git blame
, ele é
uma ferramenta que mostra, para cada linha, quem foi a última pessoa que a
mudou, qual foi o commit em que essa alteração foi feita, e a data e
hora dessa mudança. Olhe para a imagem a seguir. Eu estou rodando
git blame Main.hs
, em que Main.hs
é um arquivo que eu escrevi:
Legal, então, isso me mostra evidências para que eu possa xingar o código de
alguém que trabalha comigo? Bom, na maior parte das vezes, sim, mas lembre-se: o
git blame
mostra apenas quem fez a última modificação. Talvez essa pessoa
apenas mudou o nome de uma variável, aplicou uma mudança no estilo do código,
moveu uma declaração de uma função para outro arquivo, ou qualquer outra mudança
que seja na pratica seja quase irrelevante para o funcionamento do
código. Muitas vezes a pessoa nem sabe o que o código faz (por exemplo, talvez a
pessoa apenas executou uma ferramenta que formata código, sem nem ter lido ele).
Isso também se aplica à outra informação: a data e a hora que é mostrado é apenas a última vez que a linha foi alterada, e não nos diz quando ela foi criada. A mesma coisa vale para o commit: o commit que é mostrado é o útilmo commit que alterou alguma coisa na linha, não aquele que alterou alguma coisa útil ou o commit que primeiro adicionou aquela linha.
O Git Blame (e outras ferramentas baseadas nele, como o Annotate nas IDEs da
JetBrains, o magit-blame
no Emacs, ou o GitLens no VSCode) é sem dúvida
bastante útil, porém, não é uma fonte da verdade. Se você quer saber algo além
de apenas o que ocorreu na última mudança na linha, então você precisará de algo
mais poderoso…
Git Log e seus poderes secretos
git log
é um dos mais famosos comandos do Git. Ele é comando que você executa
para ver o histórico de commits. Nada novo até aqui. Porém, ele tem alguns
recursos menos conhecidos que eu considero como sendo o próximo passo para
quando o Git Blame não é o suficiente para suas necessidades.
Passando um pathspec como argumento para o Git Log
Você pode restringir a saída do git log
passando um pathspec como seu
último argumento, assim: git log -- <pathspec>
. De novo, o --
pode ser
omitido na maior parte dos casos, porém, é uma boa prática mantê-lo para evitar
ambiguidades.
Quando você faz isso, a saída irá conter apenas os commits que introduziram uma mudança nos arquivos que casam com o pathspec, em relação a seus commits anteriores. Olhe esse exemplo:
Se você quer saber quando alguma coisa foi introduzida, você pode inspecionar esses commits. Quando você encontrar em qual commit foi feita essa alteração, você irá descobrir quem e quando introduziu o commit. Se a mensagem de commit foi bem escrita e se a mudança foi atômica, você ainda saberá porque o commit foi criado (e porque o código existe). Se você está usando GitHub, você também poderá copiar o hash desse commit e procurar o Pull Request que o contém, e isso é ainda mais informação, já que você pode ler a discussão e a revisão de código!
Esse recurso do Git Log salvou minha vida várias vezes. Se eu encontrava um trecho de código que era difícil de entender para que servia, em vez de tentar lê-lo, eu usava o Git Log para encontrar o commit que o introduziu e entender o que a pessoa que o escreveu estava tentando fazer, qual o contexto dessa introdução, qual o problema que ela queria resolver, e assim por diante. Apenas tente fazer isso!
A flag -p
Apesar de tudo, pode ser bem entendiante olhar cada commit manualmente. Você
pode usar -p
para mostrar o patch de cada commit. Em outras palavras, é
como executar git show
para cada commit que aparece no log:
A flag -L
Talvez o arquivo seja muito longo e você só quer checkar o log de uma pequena
porção dele. Então você pode usar a flag -L
para restringir para a parte do
código que te interessa. Ela tem duas variantes:
-
Você pode restringir pelos limites de um intervalo, que pode ser números de linhas ou expressão expressões regulares:
git log -L <start>,<end>:<file>
. Por exemplo:git log -L 10,20:my_file.c
vai mostrar o log para o intervalo entre as linhas 10 e 20 do my_file.c; -
(Eu acho esse ainda mais legal) Você pode ver o log de uma função:
git log -L :<function>:<file>
Neste exemplo, estou vendo o log da função M412
do arquivo M412.cpp
do
Marlin usando
git log -L :M412:M412.cpp
:
A flag -S
A flag -S
é um tesouro perdido no Git Log. Com ele, você pode ver todos os
commits que aumentaram ou diminuíram o número de ocorrências de uma
string. Pessoalmente falando, ele praticamente aposenta o Git Blame para mim:
mesmo se uma pessoa moveu um trecho de código para outro lugar ou para outro
arquivo, o git log -S
vai conseguir encontrar a introdução dele.
Na imagem a seguir, eu estou usando git log -S
para encontrar a primeira
introdução de uma string contida no arquivo hardcoded_values.c
(como você pode
ver na saída do primeiro git grep
). Depois, note que essa string não foi
originalmente introduzida nesse arquivo (como você pode ver na saída do segudo
git grep
). A princípio, ele era parte do arquivo state_machine.c
, então ele
foi movido para outro arquivo. Isso resolve o problema do Git Blame apenas
“culpar” a pessoa que moveu aquela linha, em vez da que a criou.
Você ainda pode usar -G
em vez de -S
. Isso permite usar uma expresão
regular em vez de uma string.
Git Bisect
O git blame
nos diz a última mudança de uma linha e o git log -S
nos diz
quando uma string foi introduzida ou removida. Porém, eles só operam sobre
texto. Para vários casos isso é suficiente, porém, às vezes você não quer
procurar por mudanças em um texto, mas quer procurar por mudanças no
comportamento do programa, como novos bugs ou qualquer outra coisa que não
esteja funcionando como esperado.
Nesses casos, o git blame
ou o git log -S
não serão suficientes, porque
você não sabe qual código causou essa mudança de comportamento, e você não sabe
exatamente o que procurar. Em projetos complexos, talvez essa mudança foi feita
em um lugar que você nunca imaginaria, como por exemplo, em uma classe ou função
que você pensou que não fosse relacionada à que quebrou.
E como o Git pode nos ajudar a encontrar essa mudança?
Senhoras e senhores, é uma honrar apresentar a vocês o meu comando favorito do Git: o Git Bisect! Ele nos permite encontrar o commit que quebrou alguma coisa no código. Dado um commit “bom” (um commit que não está quebrado, criado antes da introdução do bug), e um commit “ruim” (um commit em que o código certamente está quebrado), o Git irá fazer uma busca binária até que o commit quebrado seja encontrado.
Uma vez encontrado esse commit, você pode olhá-lo e conseguir todo o tipo de informação que discutimos anteriormente.
O Git Bisect pode ser usado de duas maneiras: uma mais manual, em que ele guia você até que o commit que introduziu o bug seja encontrado, e uma automática, que o Git encontra esse commit pra você.
Um exercício prático
Eu vou demonstrar o Git Bisect usando este repositório: https://github.com/lucasoshiro/bisect_demo. Ele é bem simples, apenas contém um único arquivo Python, com um código bem estranho e difícil de entender:
#!/usr/bin/env python3
from sys import argv
from math import log
ops = 0x2B2D2F2C2A5E3E5F
def func(a, b):
return '\n'.join(
(lambda r: f'{a} {f} {b} = {r}')(eval(f'{a}{f}{b}'))
for f in ops.to_bytes((int(log(ops, 16)) + 1) // 2, 'big').decode())
if __name__ == '__main__':
a, b = map(int, argv[1:])
print(func(a, b))
E o que ele faz? Bom, ele recebe dois números como argumentos, e realiza algumas operações usando eles:
Ele funciona para a maior parte dos números, exceto se o segundo é 0. Como uma das operações é a divisão e como nós não estamos tratando erros de divisão por zero nesse código, esta entrada quebra o script:
Como você pode ver, nós sabemos onde o código está quebrando, porém, não conseguimos ver nenhuma operação de divisão nele. Se quisermos corrigir esse código, primeiro precisamos saber o que causou essa divisão, e isso não está claro aqui.
Claro que podemos executar git log
aqui e tentar achar um commit que possa
ter inserido esse bug. Porém, mesmo executando git log -p -- calc.py
não
será de grande ajuda. Veja:
Hora do Git Bisect nos salvar!
Rodando o Git Bisect manualmente
A primeira coisa que você precisa fazer é iniciar a bisseção (a busca binária,
ou em inglês bisect), usando o comando git bisect start
. Se você executar git
status
, ele mostrará que a bisseção foi iniciada. Alguns shells que mostram
informações sobre o Git no prompt também mostram a bisseção foi iniciada. Se
você já fez o que precisava com a bisseção, então você deve executar git bisect
reset
para terminá-la.
Eu disse que para fazermos a bisseção, precisamos de um “commit ruim” e um “commit bom”. Neste caso, nós sabemos que o último commit é ruim, já que ele quebra quando passamos 0 como segundo parâmetro.
Em um cenário real, provavelmente você saberá qual é um “bom” commit, ele será
qualquer commit que contém um código que funciona, por exemplo, o commit da
última release que não esteja quebrada. No caso deste exemplo, eu digo a você
que o primeiro commit não tem bugs. Se você quiser ver isso, faça um
checkout para ele e tente executar calc.py
passando 0 como segundo argumento:
Dessa forma, agora sabemos um commit bom e um ruim, e nossa situação é esta (os commits estão em ordem cronológica, o primeiro commit é o primeiro):
8959689 Início <- BOM!
f1a445e banana
e516236 goiaba
0acc414 Laranja
18c911e Toranja
0f3d5c7 Limão
7e27a60 Framboesa
f16e5e7 Morango
c3eb7db Carambola <- RUIM!
E agora precisamos dizer ao Git Bisect sobre isso. Executamos
git bisect good 8959689
para dizer que o commit inicial é bom. Como o
commit atual é ruim, então podemos executar apenas git bisect bad
para dizer
ao Git Bisect isso, mas você poderia também executar git bisect bad c3eb7db
, ou
passar como argumento qualquer outro commit que você saiba que é ruim.
Depois de executar isso, o Git Bisect vai automaticamente fazer um checkout
para o commit que está no meio do histório entre o bom e o ruim, ou seja, o
commit 18c911e
(Toranja
). Após isso, executamos
./calc.py <algum número> 0
para decidir se esse commit é bom ou ruim.
Certo, então nossa situação é a seguinte:
8959689 Início <- BOM!
f1a445e banana
e516236 goiaba
0acc414 Laranja
18c911e Toranja <- RUIM!
0f3d5c7 Limão
7e27a60 Framboesa
f16e5e7 Morango
c3eb7db Carambola <- RUIM!
E, novamente, precisamos dizer ao Git Bisect que esse commit é um dos
ruins. Então, executamos git bisect bad
. Como você deve imaginar, agora o Git
Bisect vai fazer um checkout para o commit que está no meio dos commits
8959689
(Início
) and 18c911e
(Toranja
). Esse commit é o
e516236
(goiaba
).
Agora fazemos a mesma coisa de antes: executamos ./calc.py <something> 0
,
vemos se ele quebra ou não, se sim então executamos git bisect bad
, caso
contrário executamos git bisect good
, até que a busca binária termine e
encontre o commit com problemas. Fazemos isso aqui:
Finalmente encontramos o commit que introduziu o bug: 0acc414
(Laranja
). Agora você pode sair da bisseção com git bisect reset
, e o Git
irá voltar para o mesmo commit que você estava quando executou git bisect
start
. Se você está com curiosidade para saber porque essa mudança quebra o
código, aqui está uma dica: 0x2F é o valor ASCII do caracter /
em hexadecimal.
O Git Bisect é legal, porém, executá-lo manualmente dessa forma (verificando se
um commit é bom ou ruim e em seguida executando git bisect bad
ou git
bisect good
) pode ser bem entendiante. Dependendo da situação, é possível fazer
isso de forma automática usando git bisect run
!
Rodando o Git Bisect automaticamente
Caso exista um comando que diga se um commit é bom ou ruim, então o Git Bisect pode fazer a busca binária automaticamente! Esse comando pode ser qualquer coisa, como um shellscript, um script Python, um executável, um teste, entre várias outras coisas. O único requisito é que seu status code deve seguir algumas regras:
- Se o commit é bom, então o comando deverá devolver 0;
- Se o commit é ruim, então o comando deverá devolver qualquer coisa entre 1 e 127, inclusivamente, exceto 125;
- Se não é possível decidir se um commit é ruim ou bom, então ele precisa ser ignorado, e para isso o deverá devolver 125.
Neste exemplo, estamos verificando se o código lança uma exceção. Por padrão,
quando um código termina sua execução em um exceção em Python, então seu status
code é 1, ou é 0 quando a execução termina sem problemas. Então, apenas
executar ./calc.py <algum número> 0
é suficiente, já que ele irá devolver 0
quando tudo terminou sem problemas e 1 quando um bug ocorreu. Porém, lembre-se
de que esse não é sempre o caso, e dependendo é possível que você precise
escrever um script de teste para tomar essa decisão.
Nós começamos a bisseção da mesma forma que antes:
git bisect start
git bisect good <hash do commit bom>
git bisect bad
Como queremos fazer a bisseção automática usando como critério ./calc.py 14 0
,
então executamos git bisect run ./calc.py 14 0
. Funciona como mágica:
Depois disso, você também precisa executar git bisect reset
para terminar a
bisseção. E é isso. Não é legal?
Conclusão
Esses comandos me ajudaram muito quando eu navegava em bases de código bem grandes e preciava encontrar as causas bugs. Mas não apenas isso, eles podem ajudar a entender o código, por eles serem, essencialmente, ferramentas de busca. Simples e flexíveis, mas incrivelmente poderosas.
Obrigado pela leitura, se você encontrou algo errado ou tem alguma sugestão, abra uma issue no meu GitHub.
2024 update
Isto chegou em 5 no Hacker News!
Depois de receber alguns feedbacks, eu atualizei este post com
git log -L:funcname:file
, que eu não conhecia antes
desta resposta!