Juntando commits com git rebase --interactive

Artigos - 14/Mai/2020 - por Luis Arantes

Em nosso dia a dia como desenvolvedores trabalhando com o Git, se tornou praticamente padrão criarmos vários commits ao longo do expediente de trabalho. Às vezes criamos uma sequência de commits sem muita preocupação com a coerência e ordenação ou mesmo com suas mensagens. Ao final de um dia de trabalho, se o código estiver pela metade, escrevemos a mensagem do commit com o "WIP", para retomá-lo no dia seguinte e continuar fazendo mais commits depois dele.

No final da tarefa essa sequência de commits pode ficar bastante difícil de ser compreendida e, em algum momento surge a vontade de organizá-los melhor, seja para outro desenvolvedor revisá-los ou simplesmente para deixar o histórico mais limpo antes de colocar o código novo na branch master.

O comando git rebase --interactive (ou git rebase -i) permite a junção de vários commits de maneira bem rápida. Vamos mostrar como o rebase funciona alterando um histórico de commits simples.

A imagem abaixo apresenta o log de um projeto fictício:

Considerando o histórico acima, vamos supor que queiramos unificar os commits fc136fd e 38e7df0, referentes à inclusão do arquivo3, além de atualizar a mensagem do commit unificado (pois, neste cenário hipotético, a mensagem dele foi escrita às pressas e também não foi indicada a extensão do arquivo, destoando do restante do histórico). Depois disso, iremos também juntar todos os quatro commits com mensagens de atualização do arquivo2 em um único commit. Vamos demonstrar isso em duas etapas.

Etapa 1: juntar commits do arquivo3

Para modificar o histórico sobre o arquivo3 é necessário apontar o git rebase para o commit anterior ao commit mais antigo entre os que precisam ser alterados. Nesse exemplo, trata-se do commit c20ea79, que precede o commit fc136fd. Dessa maneira, com o comando abaixo, o Git vai listar todos os commits compreendidos no intervalo após o commit referenciado até o mais recente do projeto.

git rebase --interactive c20ea79

ou

git rebase -i c20ea79

Este comando irá mostrar os commits, conforme imagem a seguir, no editor padrão configurado no Git (no exemplo abaixo, o editor padrão é o VIM).

Note que o git rebase mostra abaixo da lista dos commits diversas opções de ações que podem ser aplicadas aos commits selecionados. Além disso, é importante saber que os commits são mostrados do mais antigo para o mais recente, pois é nessa ordem que eles serão alterados pelo git rebase. Isso pode gerar alguma confusão, já que é a ordem inversa de exibição do comando git log.

Para aplicar as ações descritas anteriormente sobre o arquivo3 é necessário editar sua mensagem de commit, além de fundir os dois commits que atuam no arquivo. É preciso modificar no editor as ações a serem aplicadas em fc136fd e 38e7df0, como mostra a imagem seguinte:

Foi escolhida a ação reword para o commit fc136fd - opção que permite editar a sua mensagem -, e a ação squash para o commit 38e7df0 - que irá uni-lo ao commit anterior. A opção squash preserva a mensagem de 38e7df0 e mostra as duas mensagens no editor, para que possamos alterá-las novamente caso seja necessário.

Ao serem confirmadas as ações no editor (no caso do VIM, por exemplo, é preciso salvar a lista de ações após as edições, e depois sair do editor), o Git irá começar a aplicar as ações definidas.

Inicialmente será mostrado no editor o primeiro commit da lista - fc136fd -, para que sua mensagem seja editada (pois foi escolhida a ação de reword para ele). Concluída a edição, este commit é reaplicado no projeto.

O Git prossegue para executar a ação de squash no segundo commit (38e7df0). Neste momento o editor mostrará novamente o primeiro commit, pois a ação escolhida para 38e7df0 - de junção de commits - também envolve alterar o commit anterior. A ação de squash faz com que sejam exibidas as mensagens de ambos os commits para que a partir delas seja escolhida uma mensagem definitiva.

Neste momento do processo o editor simplesmente vai estar exibindo em tela as alterações como se estivesse sendo feito um commit de maneira habitual. A diferença ocorre após o editor ser fechado. Será gerado um novo commit contendo as alterações que inicialmente pertenciam a fc136fd e 38e7df0. Este novo commit será reaplicado no lugar de fc136fd e 38e7df0, que deixarão de existir.

O Git seguirá então para as próximas etapas, aplicando os commits que estão marcados com a ação padrão pick, - em que o Git apenas os insere novamente no histórico - até chegar no último commit da lista. Ao final do processo, o histórico fica como na imagem a seguir:

O commit 9829738 agora contém as alterações que pertenciam a fc136fd e 38e7df0. Note que todos os commits seguintes, que estavam definidos com a ação pick, possuem hashes diferentes de antes da execução do git rebase. O processo de reaplicar cada um dos commits envolvidos no rebase gera commits totalmente novos, e por isso os hashes são diferentes. O histórico de commits é reescrito!

Etapa 2: juntar commits do arquivo2

A segunda etapa desse processo envolve colocar todas as alterações ao arquivo2 num único commit. Para este caso, o git rebase será executado com uma opção diferente.

É possível passar como parâmetro também a quantidade de commits a partir do HEAD - a referência que por padrão aponta para o último commit criado na branch atual. No caso da imagem acima, é preciso alterar o histórico a partir de ac81f69 para manipular todos os commits de atualização do arquivo2. São no total 6 commits para chegar ao HEAD. Para isso, podemos executar o seguinte comando:

git rebase --interactive HEAD~6

ou

git rebase -i HEAD~6

Este comando irá listar os 6 commits no editor para escolher as ações que serão executadas em cada um deles.

Os commits de interesse, conforme demonstrados na imagem, são ac81f69, 11a70e6, 871cc93 e 4f06035. Desta vez será aplicada a ação padrão de pick no primeiro deles e nos outros três será executada a ação de fixup para uní-los ao primeiro (ac81f69) e descartar suas mensagens. Note, porém, que entre esses quatro commits há o commit 4d5ef0e - relacionado a outro arquivo do projeto. Por isso, colocar a ação de fixup nos commits 871cc93 e 4f06035 fará com que o Git os junte a 4d5ef0e, fundindo commits que não deveriam estar juntos. Para realizar a junção correta é possível trocar a ordem dos commits, movendo suas linhas pelo editor de modo que todos aqueles relacionados à atualização do arquivo2 ocorram em sequência, e o commit sobre o outro arquivo ocorra depois deles. Depois dessa troca de ordem, o histórico ficaria assim:

Alterar a ordem de commits é uma ação que deve ser feita com cautela, pois pode gerar conflitos. No final deste artigo está descrito brevemente como resolver os conflitos durante a execução do git rebase para o caso de acontecerem.

A segunda imagem que foi mostrada neste artigo, com as informações sobre as ações que podem ser executadas nos commits selecionados, mostra que também é possível abreviar os nomes das ações. Para demonstrar um exemplo, na imagem a seguir estão representadas as mesmas ações definidas na imagem anterior, porém com seus nomes abreviados:

Ao confirmar as ações, o Git irá pegar os três commits com ação fixup (11a70e6, 871cc93 e 4f06035) e juntá-los um por um com o anterior (ac81f69). Com isso, um novo commit é gerado, contendo todas as alterações dos quatro fundidos e a mensagem seguirá sendo a mensagem de ac81f69. Após isso, os demais commits da lista são reaplicados para completar o comando de rebase.

No final de todo processo o histórico ficará como na imagem abaixo:

Cuidado com os conflitos

Modificar commits com o git rebase, especialmente se a ordem deles for alterada, pode ocasionar conflitos durante o processo.

Caso isso aconteça, é necessário alterar os arquivos para resolver os conflitos, adicionar os arquivos à área de staging com git add - como se eles fossem ser "commitados" normalmente - e depois executar: git rebase --continue.

Pode ser necessário que estas ações tenham que ser repetidas algumas vezes, até que o processo de rebase seja concluído.

Uma maneira de evitar que estes conflitos surjam é manter os commits do projeto pequenos e contextuais, - fazer commits atômicos -, de modo que seja fácil movê-los ou alterá-los quando se julgar necessário.

ATENÇÃO, você está reescrevendo o histórico de commits

Toda ação de git rebase, como já explicado anteriormente, vai gerar commits com hashes diferentes. Se os commits antigos já estiverem num repositório remoto, vai ser preciso sobrescrever estes commits na branch remota, pois seus hashes estarão diferentes. O Git vai considerar que os histórico de alterações local divergiu do remoto. Neste cenário, é necessário informar o parâmetro --force ou --force-with-lease na hora de executar o git push do histórico alterado. É preciso ter muita cautela no momento de executar estes comandos, pois um descuido pode resultar na perda de arquivos, ou até mesmo de commits.

Em última instância, também é possível recuperar o código perdido durante um processo de git rebase. O rebase é poderoso, porém o Git é mais poderoso. Os comandos git reflog e git reset podem ser utilizados para restaurar o histórico de commits anterior ao rebase.

Reiterando o que foi dito acima, agir com cautela vai garantir que você jamais perca código e consiga recuperar qualquer arquivo versionado. O Git te dá este poder.

Referências

Foto de perfil do autor
Luis Arantes

Dev na Rebase

Desenvolvedor Ruby que gosta de falar sobre código, videogames e bandas de heavy metal obscuras