Git como ferramenta de debug

21 minuto(s) de leitura

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 o byebug 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:

Git grep vs BSD grep and GNU grep, em um Macbook Air M1

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.

Git grep com --heading e --break, procurando por looks_like em todos os códigos-fonte C.

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 com -W mostrando a função toda que contém looks_like.

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:

Git blame. A primeira coluna mostra os primeiros caracteres do hash do último commit que alterou aquela linha, Main.hs é o nome do arquivo, Lucas Oshiro (também conhecido como eu) é a última pessoa que alterou essas linhas. Você também pode ver a data e a hora dessa última mudança.

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:

Histórico de commits.

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:

O mesmo que a última imagem, porém usando a flag -p

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:

  1. 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;

  2. (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:

git log -L em ação. Note que estou usando delta para formatar a saída

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.

Perdão, Git Blame...

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:

calc.py executando.

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:

Ah não...

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:

Nada útil aqui. A mensagem de commit diz nada sobre o código (só nome de frutas...) e a única mudança entre um commit e outro é um valor hexadecimal.

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.

Iniciando a bisseção

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:

Não faz nada, porém, pelo menos não está dividindo por zero.

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.

E descobrimos que esse commit é um dos ruins...

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:

Executando git bisect bad e git bisect good até que a busca binária chegue ao fim.

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:

Sim, isso mesmo: é um comando que encontra o bug para você!

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!

#5 no HN!!!

Depois de receber alguns feedbacks, eu atualizei este post com git log -L:funcname:file, que eu não conhecia antes desta resposta!

Atualizado em: