
Foi-me mostrado a prova: a saída de dois comandos. O primeiro é
git show deadbeef
- mostrou mudanças no arquivo, vamos chamá-lo de Page.php. O método canBeEdited e seu uso foram adicionados a ele.
E na saída do segundo comando -
git log -p Page.php
- não houve commit de deadbeef. E na versão atual do arquivo Page.php não havia nenhum método canBeEdited.
Não encontrando uma solução rapidamente, fizemos outro patch para o mestre, definimos as alterações - e eu decidi que voltaria ao problema com uma mente nova.
"Fora do assunto"
, Git. , , .
Foi feito de propósito? O arquivo foi renomeado?
Comecei a procurar o problema pedindo ajuda no chat da equipe de engenheiros de lançamento. Eles são responsáveis por hospedar repositórios e automatizar processos relacionados ao Git, entre outras coisas. Para ser honesto, eles provavelmente poderiam ter removido o patch, mas fariam isso sem deixar vestígios.

Um dos engenheiros de lançamento sugeriu executar git log com a opção --follow. Talvez o arquivo tenha sido renomeado e, portanto, o Git não mostra algumas das mudanças.
--follow
Continue listando o histórico de um arquivo além de renomear (funciona apenas para um único arquivo).
(Mostra o histórico do arquivo após renomeá-lo (funciona apenas para arquivos individuais))
Havia
git log --follow Page.php
um deadbeef na saída , mas nenhum arquivo foi excluído ou renomeado. E ainda não era visível que o método canBeEdited foi excluído em algum lugar. A opção a seguir parecia desempenhar um papel nesta história, mas para onde as mudanças foram ainda não estava claro.
Infelizmente, o repositório em questão é um dos maiores que temos. Desde o momento em que o primeiro patch foi introduzido até o seu desaparecimento, havia 21.000 commits. Também foi uma sorte que o arquivo necessário foi editado apenas em dez deles. Estudei todos eles e não achei nada interessante.
Procuramos testemunhas! Precisamos de um urso vivo
Pare! Estávamos apenas procurando o deadbeef? Vamos pensar logicamente: deve haver um commit, vamos chamá-lo de livebear, após o qual deadbeef não é mais exibido no histórico do arquivo. Talvez isso não nos dê nada, mas nos dará algumas reflexões.
Existe um comando git bisect para pesquisar o histórico do Git. De acordo com a documentação , ele permite que você encontre o commit no qual o bug apareceu pela primeira vez. Na prática, ele pode ser usado para localizar qualquer momento na história se você souber como determinar se esse momento chegou. Nosso bug era a falta de mudanças no código. Eu poderia verificar isso com outro comando - git grep. Afinal, foi o suficiente para mim saber se existe um método canBeEdited em Page.php. Um pouco de depuração e leitura da documentação:
livebear [build]: Mesclar branch origin / XXX em build_web_yyyy.mm.dd.hh
Parece um commit de mesclagem normal de um branch de tarefa com um branch de lançamento. Mas com esse commit consegui reproduzir o problema:
$ git checkout -b test livebear^1 2>/dev/null $ grep -c canBeEdited Page.php 2 $ git merge —-no-edit -—no-stat livebear^2 Removing … … Removing … Merge made by the ‘recursive’ strategy. $ grep -c canBeEdited Page.php 0 $ git log -p Page.php | grep -c canBeEdited 0
É verdade que não achei nada interessante em livebear e sua conexão com o nosso problema permaneceu obscura. Depois de pensar um pouco, enviei os resultados das minhas pesquisas ao desenvolvedor: concordamos que, mesmo que descubramos a verdade, o esquema de reprodução será muito complicado e não poderemos nos segurar contra algo assim no futuro. Portanto, decidimos oficialmente parar de pesquisar.
No entanto, minha curiosidade permaneceu insatisfeita.
A persistência não é um vício, mas um grande nojento
Várias vezes voltei ao problema, executei git bisect e encontrei mais e mais commits. Todos são suspeitos, todos são fusões, mas isso não me deu nada. Parece-me que um commit veio para mim com mais freqüência do que outros, mas não tenho certeza se foi ele o culpado no final.
É claro que tentei outros métodos de pesquisa também. Por exemplo, várias vezes eu passei pelos 21.000 commits que foram feitos no momento do problema. Não foi muito emocionante, mas me deparei com um padrão interessante. Executei o mesmo comando:
git grep -c canBeEdited {commit} -- Page.php
Descobriu-se que os commits "ruins", que não tinham o código necessário, estavam no mesmo branch! E uma pesquisa neste tópico rapidamente me levou a uma pista:
changekiller Merge branch 'master' em TICKET-XXX_description
Esta também foi uma fusão de dois ramos. E ao tentar repeti-lo localmente, houve um conflito no arquivo necessário - Page.php. A julgar pelo estado do repositório, o desenvolvedor deixou sua versão do arquivo, descartando as alterações do mestre (ou seja, foram perdidas). Muito tempo se passou e o desenvolvedor não se lembrava exatamente do que acontecia, mas na prática a situação se reproduzia em uma sequência simples:
git checkout -b test changekiller^1 git merge -s ours changekiller^2
Resta entender como uma sequência legítima de ações poderia ter levado a tal resultado. Não encontrando nada sobre isso na documentação, entrei no código-fonte.
É o assassino Git?

A documentação disse que o git log recebe vários commits como entrada e deve mostrar ao usuário seus commits pais, excluindo os pais dos commits enviados com um ^ na frente deles. Portanto, git log A ^ B deve mostrar commits que são pais de A e não pais de B.
O código de comando acabou sendo bastante complexo. Havia muitas otimizações diferentes para trabalhar com memória e, em geral, ler código C nunca me pareceu uma experiência muito agradável. A lógica básica pode ser representada com o seguinte pseudocódigo:
// , commit commit; rev_info revs; revs = setup_revisions(revisions_range); while (commit = get_revision(revs)) { log_tree_commit(commit); }
Aqui, a função get_revision aceita revs, um conjunto de sinalizadores de controle, como entrada. Cada uma de suas chamadas deve parecer dar o próximo commit para processamento na ordem certa (ou vazio, quando chegamos ao fim). Há também uma função setup_revisions que preenche a estrutura revs e log_tree_commit, que exibe informações na tela.
Tive a sensação de que descobri onde procurar o problema. Passei um arquivo específico (Page.php) para o comando, pois estava interessado apenas em suas alterações. Isso significa que o log do git deve ter algum tipo de lógica para filtrar commits "extras". As funções setup_revisions e get_revision foram usadas em muitos lugares - dificilmente o problema com elas. Isso deixou log_tree_commit.
Para minha alegria indescritível, nesta função realmente havia um código que calcula quais alterações foram feitas em um determinado commit. Achei que a lógica geral deveria ser mais ou menos assim:
void log_tree_commit(commit) { if (tree_has_changed(commit, commit->parents)) { log_tree_commit_1(commit); } }
Mas quanto mais eu olhava para o código real, mais percebia que estava errado. Esta função apenas imprime mensagens. Então acredite em seus sentimentos depois disso!
Voltei para as funções setup_revisions e get_revision. A lógica de seu trabalho era difícil de entender - a "névoa" de funções auxiliares interferia, algumas das quais eram necessárias para funcionar corretamente com ponteiros e memória. Tudo parecia como se a lógica principal fosse uma simples travessia da árvore de commits, ou seja, um algoritmo razoavelmente padrão:
rev_info setup_revisions(revisions_range, ...) { rev_info rev; commit commit; // — for (commit = get_commit_from_range(revisions_range)) { revs->commits = commit_list_append(commit, revs->commits) } } commit get_revision(rev_info revs) { commit c; commit l; c = get_revision_1(revs); for (l = c->parents; l; l = l->next) { commit_list_insert(l, &revs->commits); } return c; } commit get_revision_1(rev_info revs) { return pop_commit(revs->commits); }
Uma lista é criada (revs-> commits), o primeiro (topo) elemento da árvore de commits é colocado lá. Então, os commits do início são gradualmente retirados desta lista, e seus pais são adicionados ao final.
Lendo o código, descobri que entre a "névoa" das funções auxiliares, há uma lógica complexa para filtrar commits, que venho procurando há tanto tempo. Isso acontece na função get_revision_1:
commit get_revision_1(rev_info revs) { commit commit; commit = pop_commit(revs->commits); try_to_sipmlify_commit(commit); return commit; } void try_to_simplify_commit(commit commit) { for (parent = commit->parents; parent; parent = parent->next) { if (rev_compare_tree(revs, parent, commit) == REV_TREE_SAME) { parent->next = NULL; commit->parents = parent; } } }
No caso em que várias ramificações estão sendo mescladas, se o estado do arquivo permanecer o mesmo de uma delas, não faz sentido considerar outras ramificações. Se o estado do arquivo não mudou em nenhum lugar, sairemos apenas do primeiro ramo.
Exemplo. Vamos denotar por zero os commits nos quais o arquivo não foi alterado, por um - aqueles nos quais o arquivo foi alterado, e X - a fusão das ramificações.

Nessa situação, o código não considerará o branch do recurso - não há alterações nele. Se o arquivo foi alterado lá, então em X as alterações foram "descartadas", o que significa que seu histórico não é muito relevante: esse código não está mais lá.
Algo semelhante aconteceu conosco. Dois desenvolvedores fizeram alterações no mesmo arquivo - Page.php, um no branch master, no commit do deadbeef e o segundo no branch de tarefas.
Quando o segundo desenvolvedor mesclou as alterações do branch master no branch da tarefa, ocorreu um conflito, no processo de resolução, o qual ele simplesmente descartou as alterações do master. O tempo passou, ele concluiu o trabalho na tarefa e o branch da tarefa foi carregado para o mestre, removendo assim as alterações do commit do deadbeef.
O próprio commit permaneceu. Mas se você executar o git log com o parâmetro Page.php, não verá o commit deadbeef na saída.
A otimização é um trabalho ingrato
Corri para estudar cuidadosamente as regras para enviar alterações e bugs ao próprio Git. Afinal, eu pensei que tinha encontrado um problema realmente sério: apenas pense, alguns commits simplesmente desaparecem da saída - e este é o comportamento padrão! Felizmente, as regras revelaram-se volumosas, o tempo estava atrasado e na manhã seguinte meu fusível sumiu.
Percebi que essa otimização acelera muito o desempenho do Git em grandes repositórios como o nosso. Também há documentação para isso em man git-rev-list , e esse comportamento pode ser desativado facilmente.
A propósito, como --follow está envolvido nessa história?
Na verdade, existem muitas maneiras de influenciar o funcionamento dessa lógica. Especificamente, sobre o sinalizador de seguir no código Git, um comentário foi encontrado 13 anos atrás:
Não é possível podar commits com renomear a seguir: os caminhos mudam.
(Tradução: não é possível lançar commits quando a renomeação está em andamento: os caminhos podem mudar)
PS
Eu mesmo estou com a equipe de engenharia de lançamento do Badoo há vários anos, e muitos na empresa acreditam que entendemos o Git.

(Tradução. Original: xkcd.com/1597 )
Nesse sentido, temos que lidar com os problemas que surgem neste sistema, e alguns deles me parecem bastante curiosos - como, por exemplo, os descritos neste artigo. Muitas vezes os problemas são resolvidos rapidamente: já encontramos muitas coisas, algo está bem descrito na documentação. Este caso foi uma exceção.
Na verdade, a documentação realmente tinha uma seção de Simplificação de Histórico, mas era apenas para o comando git rev-list e eu não pensei em olhar lá. Seis meses atrás, esta seção foi incluída no manual do comando git log, mas nosso caso aconteceu um pouco antes - eu simplesmente não tive tempo de terminar este artigo. (*)
E para finalizar, tenho um pequeno bônus para quem leu até o fim. Tenho um repositório muito pequeno onde o problema é reproduzido:
$ git clone https://github.com/Md-Cake/lost-changes.git Cloning into 'lost-changes'... … $ git log --oneline test.php edfd6a4 master: print 3 between 1 and 2 096d4cf init $ git log --oneline --full-history test.php afea493 (HEAD -> master, origin/master, origin/HEAD) Merge branch 'changekiller' 57041b8 (origin/changekiller) print 4 between 1 and 2 edfd6a4 master: print 3 between 1 and 2 096d4cf init
Obrigado pela atenção!
(*) UPD: descobri que a seção de simplificação do histórico estava na documentação do comando git log há muito mais de seis meses e eu simplesmente a ignorei. Obrigado você é demaisque chamou a atenção para isso!