Versionamento de código é essencial nos dias de hoje para acompanhar as mudanças. Em uma equipe com diversos programadores, é muito comum alterar os mesmos arquivos, que exigem um cuidado para que o trabalho de um não entre em conflito com os dos outros. Para isso, o git é uma ferramenta muito eficiente para gerenciar essas modificações e facilitar na resolução desses conflitos. Entretanto, existe uma situação muito peculiar que poucos desenvolvedores conhecem, mas que podem causar problemas nessa resolução de conflitos. Pior do que um conflito, é não ocorrer um conflito em uma situação que achamos que deveria ocorrer um conflito.
Cherry-pick é um comando muito citado em tutoriais na internet para pegar as alterações que estão em uma branch e adicioná-las em outra branch. Basta eu pegar o hash do commit, o nome da branch ou da tag, fazer checkout para a branch que eu quero adicionar as alterações e usar o commando:
git cherry-pick commit/branch/tag
Existe, contudo, um fato que poucos desenvolvedores observam: o commit é duplicado no repositório. Vamos ver o seguinte cenário:
Nós temos um repositório git com um arquivo chamado frutas.txt. Ele contém o nome de uma fruta. Na branch master, esse arquivo contém apple. Na branch new-feature, esse arquivo é alterado para orange e é feito um cherry-pick na develop desse commit. Depois fazemos um merge dessa funcionalidade na develop. O que irá ocorrer?
Como podemos ver, na develop, temos a fruta orange. Na branch new-feature, temos orange. Nesse caso, é fácil deduzir o que vai ocorrer: o merge irá ocorrer sem conflito.
Vamos para o segundo caso. Na branch master, eu tenho a palavra apple. Na branch new-feature, apple é substituída por orange. Na develop, é feito um cherry-pick de orange. Em seguida, é feito um commit de banana na new-feature. Por fim, é feito um merge de new-feature na develop. O que irá ocorrer neste merge:
Se na develop, temos orange e, na new-feature, temos banana, o mais lógico é que o git não irá conseguir resolver automaticamente esse merge e informará que há um conflito para ser resolvido. De fato, é exatamente o que ocorre neste caso. O desenvolvedor irá precisar especificar qual o código correto para esse merge.
Por fim, vamos para um terceiro caso. Na branch master, nós temos apple. Na branch new-feature, temos a alteração de apple para orange. Em seguida, é feito um cherry-pick desse commit de orange na develop. Em seguida, percebemos que orange não é a fruta correta e revertemos o commit anterior para apple. Por fim, é feito o merge de new-feature na develop. O que irá ocorrer neste merge?
Nesse caso, embora não intuituvo, não há conflito no git. A resposta é que o git irá resolver automaticamente para orange. Isso pode parecer estranho, pois a develop e new-feature estão diferentes e, mais estranho ainda, o git resolveu para orange mesmo que tenhamos feito o commit para apple por último. Parece que o commit que reverteu de orange para apple foi perdido, correto? Essa situação é algo que eu já presenciei em um projeto: o desenvolvedor garantia que tinha feito a mudança, quando o histórico foi verificado, foi possível encontrar a alteração, mas em algum momento, essa alteração foi perdida em algum merge. Não foi erro na resolução de conflito, pois não houve conflito para ser resolvido. O que houve foi um cherry-pick feito incorretamente.
Para entender esse caso, vamos analisar como o git resolve conflito. Ele utiliza um algoritmo chamada 3-way merge. Não basta comparar apenas as duas branch que estão sendo mergeadas. É necessário comparar o ancestral comum também. Só há diferenças se o ancestral comum está diferente das branches. No nosso exemplo, o ancestral comum da branch develop e da branch new-feature é o commit que a branch main está apontando. Observe que a main tem a palavra apple. Se analisarmos a branch develop, ela tem a palavra orange. Logo houve mudança. Se olharmos a branch new-feature, vemos que ela contém apple. Da branch main para new-feature, não há diferenças, pois as duas têm exatamente o mesmo conteúdo. Por isso, não há mudanças nesta branch. Se só há mudanças na develop, não há conflitos, ou seja, a alteração da develop é a única diferença. Logo, o git irá resolver o conflito para orange automaticamente.
Qual foi o erro neste caso? Houve um commit que foi feito em duas branches diferentes, mas o git não conseguiu reconhecer que a fonte de alteração foi a mesma. O cherry-pick escondeu essa informação do histórico. Neste caso, precisamos fazer a alteração no ancestral comum e fazer o merge com as duas branches. Podemos fazer checkout na branch main, fazer o cherry-pick de orange e fazer o merge com a develop. Quando for feito o merge desse branch na new-feature, é muito importante que seja um merge sem alteração. Vamos recordar que a branch new-feature já tem as alteração de orange. Se esse merge tem alguma alteração no código, há alguma coisa errada nesse merge. Quando a branch new-feature for mergeada na develop, o git irá resolver automaticamente o conflito. Contudo, como o ancestral comum agora tem orange, o git irá resolver para apple, que é o comportamento mais intuitivo neste caso.
Conclusão
É importante observar que a fonte desse problema foi o fato de ter dois commits com as mesmas alterações. O cherry-pick é um dos comandos que geram isso de modo mais explícito. Todavia, não é o único comando. Qualquer rebase, squash ou mesmo CTRL+C/CTRL+V de um trecho de código pode resultar nesse problema. Por isso, conhecer esse caso é importante para identificar quando essa situação pode ocorrer no seu repositório e evitá-la.