Git 101: rebase & merge
Não é novidade que o Git se tornou uma ferramenta essencial no dia a dia do desenvolvedor, aparecendo até mesmo em descrições de vagas como requisito mínimo para o candidato.
E não é uma surpresa. O Git é, realmente, uma ferramenta fantástica para gerenciamento de versão, mas para quem está começando com a ferramenta, pode ser complicado de entender. Eu mesmo pensava saber trabalhar com Git até que cheguei em um projeto que fazia uso apropriado da ferramenta. Tive dificuldades para entender como o rebase funciona, por exemplo, e vi colegas tendo as mesmas dificuldades.
Por isso, há quase um ano, fiz uma apresentação interna na ProFUSION explicando o rebase e o merge de uma maneira mais visual e simplificada. Este artigo é uma adaptação dessa apresentação.
Commits
Um commit pode ser entendido de forma análoga a uma “fotografia” do projeto em um determinado momento no tempo. Dessa forma, no futuro, seremos capazes de visualizar a evolução do nosso projeto, mudança após mudança, e, até mesmo, reverter todas as alterações feitas para restaurar o projeto à um ponto específico no passado.
Sendo um pouco mais específico, o commit é uma estrutura de dados que armazena, entre outras coisas, os arquivos que foram alterados por aquele commit, o nome e o e-mail do autor, uma mensagem que descreve as alterações feitas (geralmente escrita pelo desenvolvedor) e o hash do commit pai.
Como cada commit está “linkado” ao commit anterior (o que será abordado com mais detalhes em outro tópico), somos capazes de visualizar todo o histórico de alterações no projeto a partir de qualquer ponto dele. Isso significa que podemos escolher um commit no histórico e visualizar todas as alterações feitas até que o projeto tenha alcançado aquele ponto, ou então como o projeto evoluiu a partir daquele commit até o mais recente.
Hash do commit
Cada commit no Git possui um hash identificador, computado considerando os seguintes dados do commit:
- referência do commit na árvore do git
- parent commit (se houver)
- author
- committer
- timestamp
- diff
- descrição do commit
- assinatura do commit (se houver)
Qualquer alteração feita nesses dados refletirá em alterações no hash identificador do commit.
Tudo que fazemos no Git usa os hashes dos commits como referência. Mesmo quando usamos nomes de branches ou tags: no final, elas são apenas “apelidos” para o hash de algum commit.
Commit pai
Com exceção do primeiro commit do nosso projeto, todos os commits referenciam um “commit pai”, formando uma linha do tempo de alterações nos arquivos.
Há casos em que um commit pode ter mais de um commit pai, isso será melhor abordado no tópico de merge commits.
⚠️ Importante lembrar que o hash do commit pai também é levado em conta no cálculo do hash de um commit, ou seja, se o hash do commit pai mudar, o hash do commit filho também mudará.
Branch
Uma branch é apenas um “nome” que criamos para referenciar um hash no Git. Podemos ter quantas branches quisermos, com qualquer nome, e alterar o commit referenciado conforme precisarmos.
Na imagem abaixo temos duas branches, main
e feat/pagination
referenciando, respectivamente, os commits C3
e C4
.
Normalmente, branches são criadas para representar ramificações no desenvolvimento do sistema. É comum ter uma branch chamada main
, por exemplo, como a branch onde o código já testado e validado está, e outras branches criadas para desenvolver alguma feature ou corrigir algum bug sem afetar a branch main
até que tal funcionalidade esteja completamente pronta.
A imagem acima representa apenas uma branch feat/pagination
, mas, em projetos reais, haverão diversas branches sendo criadas e integradas novamente à main
. Neste cenário, nosso projeto acaba criando várias ramificações no desenvolvimento, a branch main
avança à medida que cada ramificação é integrada, ficando semelhante à imagem abaixo:
Aqui temos duas branches que seguiram linhas diferentes de desenvolvimento. A branch feat/pagination
foi criada a partir do commit C3
e tem o commit C6
implementando o que era necessário.
A branch main
, por sua vez, também avançou com o commit C5
, criando uma linha de desenvolvimento separada da feat/pagination
.
Alguns pontos importantes para perceber nesse cenário:
- Uma vez que a branch
feat/pagination
"nasceu" a partir do commitC3
, ela não tem as alterações introduzidas pelo commitC5
- Da mesma forma, nossa branch
main
não possui as alterações feitas pelo nosso commitC6
na branchfeat/pagination
- Se quisermos unir essas duas linhas de desenvolvimento em uma única branch, precisamos adotar uma estratégia de merge.
Tag
Similar a uma branch, a tag é um nome simbólico que pode referenciar outros objetos dentro do Git, incluindo commits.
A principal diferença entre as duas é que:
- Branches são usadas para referenciar uma ramificação no desenvolvimento do projeto. Quando criamos um novo commit, ele é colocado no final dessa ramificação e a branch é atualizada para referenciar o commit mais recente.
- Tags normalmente são usadas para referenciar revisões ou releases. Normalmente não alteramos a referência da tag depois de criada.
Nessa imagem temos os mesmos commits e branches da imagem anterior, mas com duas tags v1.0.0
e v1.1.0
.
Este é o uso mais comum de tags que vemos no dia a dia, porém, tags são muito versáteis e podem ser aplicadas de várias formas. Por exemplo: um ambiente com deploy automatizado pode criar tags no repositório para ajudar a relacionar a versão em produção do software com o repositório Git.
Rebase
Recapitulando um pouco o que falamos sobre branches, vimos que elas podem ser criadas a partir da main
, mas, com tantas branches sendo trabalhadas no projeto, é comum que a main
avance, recebendo novos commits, e a branch que criamos acabe ficando baseada em um commit que já não é a versão mais recente da main
.
Em cenários assim podemos querer que a nossa branch volte a ter como base o commit mais recente na main. Para isso, podemos usar o comando rebase
. O nome é um pouco sugestivo, este comando re-baseia, ou "move", uma branch. Isso ficará um pouco mais claro no próximo exemplo.
Na imagem abaixo a nossa branch feat/pagination
tem como base o commit C3
, ou seja, ela foi criada a partir dele, possivelmente quando a main
ainda apontava para esse commit. Porém, a main
recebeu um novo commit C5
. O rebase nos permite re-basear nossa branch para que ela se baseie novamente na main
mais recente, o C5
.
Executando o comando:
git checkout feat/pagination
git rebase main
Estamos dizendo ao Git algo como: “quero re-basear a branch atual sobre a minha main
".
O comando de rebase, assim como o de merge, que veremos mais a frente, também aceita o nome de uma segunda branch, que seria a branch a ser re-baseada. Quando omitimos o nome dessa branch, o Git assume que deve usar a branch atual.
Assim, executar os dois comandos acima é o equivalente a executar:
git rebase main feat/pagination
O resultado será como o da imagem abaixo.
Podemos ver que nossa branch foi movida e agora tem como base nosso commit C5
, mas outra coisa também aconteceu aqui: o hash dos commits que foram movidos agora são diferentes. Isso aconteceu porque o parent commit do nosso C6
foi alterado e, como vimos antes, o parent commit é considerado no cálculo do hash. Isso fez com que o cálculo do hash do C6
se alterasse. Por consequência, o parent commit do C7
também se alterou para o novo C6
, e o mesmo se repetiu para todos os commits filhos.
Mas como saber o que vai ser movido pelo rebase? Tínhamos no exemplo vários commits, rodamos um comando, e alguns deles foram re-baseados enquanto outros não foram tocados.
Isso pode parecer confuso no começo, mas para saber quais commits serão movidos pelo Git, precisamos procurar pelo primeiro “commit ancestral” em comum entre as duas branches. Olhando novamente para o nosso projeto antes do rebase:
Como estamos fazendo rebase da nossa feat/pagination
para a main
, precisamos identificar o primeiro commit em comum entre essas duas branches: o commit C3
. Assim sabemos que todos os commits após o C3
serão movidos: C6
, C7
e C8
.
Rebase com múltiplas branches
Um caso muito comum no dia a dia é ter branches baseadas em outras branches que não sejam a principal. Como as branches feat/button
e feat/pagination
no exemplo abaixo.
Imagine que você é o desenvolvedor trabalhando na branch feat/pagination, e a branch feat/button é de outro colega do projeto. Seu colega, para poder fazer merge com fast-forward, precisou fazer rebase da branch dele sobre a main. Isso pode levar a situações estranhas como, por exemplo, o seu pull request no GitHub/GitLab/etc passando a mostrar os seus commits e os commits do PR do seu colega. Isso acontece porque, ao fazer o rebase da branch feat/button, seu colega deixou o projeto assim:
A branch dele foi re-baseada sobre a main e, como vimos anteriormente, isso mudou o hash do commit C5
para C5'
. Enquanto a branch feat/pagination
ficou de fora do rebase e continuou baseada sobre o commit C5
antigo. Para nós, os commits C5
e C5'
podem parecer os mesmos, mas, para o Git, eles são commits diferentes.
Um erro comum é pensar que nossos commits referenciam branches pelos seus nomes como se fossem seus “parent commits”, mas não. Sempre são usados os hashes para isso, logo mover a branch feat/button
no exemplo acima não fez com que feat/pagination
continuasse sobre o commit correto.
Neste cenário, o primeiro commit em comum entre sua branch (feat/pagination
) e a branch do seu colega (feat/button
) passa a ser o commit C3
, por isso o Github começa a mostrar os commits dele no seu pull request, por exemplo.
Você já deve ter percebido que, para resolver isso, você precisa fazer rebase da sua branch novamente sobre a feat/button
. Mas, ao fazer isso da maneira que aprendemos até agora, você pode ter vários conflitos, afinal um rebase comum (git rebase feat/button feat/pagination
) tentaria mover ambos os commits C5
e C6
sobre o commit C5'
que, muito provavelmente, faz as mesmas alterações nos arquivos que o commit C5
. Isso é receita para conflitos. O que precisamos é fazer o rebase da nossa branch, mas remover o commit C5
do processo, pois já teremos o novo commit C5'
após re-basear nossa branch.
Rebase Interativo
Para ajudar nesse cenário podemos utilizar o rebase interativo. A lógica é a mesma do rebase comum, com a diferença de que no interativo o Git nos permitirá escolher quais commits serão re-baseados e até executar alguma ação diferente sobre cada commit, como alterar a mensagem, mesclar dois commits em um só, etc.
Por ora, vamos deixar de lado nossos grafos bonitinhos e encarar o terminal para ter uma explicação mais precisa do rebase interativo. Esse é o log de um repositório Git na mesma situação do último grafo apresentado.
Compare o grafo com a imagem abaixo, com atenção especial aos commits C5
e C5'
, nossas branches, e as ramificações.
A principal diferença é que aqui temos hashes reais, mas temos como mensagens dos commits os nossos conhecidos C3, C4, C5, etc, para facilitar a identificação.
Para executar um rebase interativo, só precisamos passar a opção -i
para o comando de rebase.
git rebase -i feat/button feat/pagination
Com esse comando, o Git abrirá nosso editor de texto com uma lista dos commits que serão re-baseados.
Logo nas primeiras linhas vemos os commits C5 e C6, o hash abreviado de cada um e a palavra pick
. Esse é um comando que diz ao Git para fazer rebase do commit como ele é, sem fazer nada especial sobre ele. Você pode alterar esse comando livremente para fazer a ação que desejar sobre cada um dos seus commits. Os comandos disponíveis e uma explicação sobre cada um deles pode ser encontrada logo abaixo no editor.
Tudo o que precisamos fazer, é usar um comando para dizer ao Git que não queremos utilizar o commit C5 que está ali. Podemos fazer isso de duas formas:
- mudar o
pick
paradrop
, isso fará o Git descartar o commit antes de fazer o rebase. - apagar a linha do commit C5, isso terá o mesmo efeito: o Git descartará aquele commit.
Vamos usar a segunda opção e apagar a linha do commit C5:
Agora é só salvar nosso arquivo e fechar o editor. O Git fará as operações que definimos. Olhando nosso histórico do Git veremos que tudo estará correto.
Nos exemplos do terminal eu alterei a mensagem dos commits para C5'
e C6'
propositalmente para fazer um paralelo com as representações dos hashes nos nossos grafos, mas é importante mencionar que o Git, por padrão, não fará nenhuma alteração nas mensagens dos commits.
Aqui a representação visual do resultado. 😉
Merge
Quando temos diferentes branches, como no exemplo apresentado no sub-tópico de branches, e queremos mesclá-las em uma única branch, precisamos adotar uma estratégia de merge.
O Git tem várias estratégias de merge diferentes, aplicando diferentes algoritmos em cada caso. Porém, neste artigo, o merge será abordado de uma maneira mais simples, explicando os dois casos mais comuns no dia a dia, merge commit e fast-forward.
Merge commit
Vamos usar o mesmo exemplo de antes. Temos nossas branches feat/pagination
e main
, cada uma com diferentes alterações no código.
O desenvolvimento da feat/pagination
está completo, e precisamos integrar essas alterações para a branch main
. Para isso, vamos utilizar um commit de merge executando os comandos git abaixo:
git checkout main
git merge feat/pagination
Agora, se observarmos nossos commits, veremos algo assim:
O comando de merge criou para nós o commit C7
, ele é nosso merge commit, e possui dois commit pais, o C5
e C6
. Dessa forma, o commit C7
"mescla" nossas duas branches, unificando as duas linhas de desenvolvimento em apenas uma.
Interessante notar que nossa branch main foi automaticamente atualizada para referenciar o commit C7
, enquanto a branch feat/pagination
foi preservada no commit C6
.
Para visualizar uma representação gráfica das ramificações no terminal, podemos usar o comando git log --graph
. Podemos ver nosso histórico de commits com uma representação ao lado esquerdo das ramificações e merges.
Fast-forward
O fast-forward é uma estratégia de merge utilizada pelo Git que não utiliza um merge commit, pois, como veremos, acaba não sendo necessário.
O fast-forward só pode ser utilizado quando a branch a ser re-baseada é descendente direta da branch alvo. Ou seja, no repositório abaixo:
Nossa branch feat/pagination
é descendente direta da main
, nesse caso, o fast-forward pode ser utilizado. O Git sempre utilizará essa estratégia quando for possível (a não ser que ele seja configurado para nunca utilizar fast-forward).
Os comandos para fazer o merge são os mesmos do caso anterior, a diferença será o resultado após o merge:
Como uma branch é descendente direta da outra, tudo o que o Git precisa fazer é mover a referência da main
para o mesmo commit da branch feat/pagination
, assim temos nossas duas branches integradas.
⚠️ É possível executar o merge com a opção
--ff-only
para que o Git retorne um erro caso não seja possível utilizar o fast-forward. Exemplo:git merge --ff-only feat/pagination
Rebase and Merge
Como mencionado anteriormente, o fast-forward só pode ser utilizado quando as branches são descendentes diretas uma da outra, caso contrário um merge commit será necessário.
Em casos que não queremos usar merge commits, mas temos branches que seguiram linhas diferentes e não são mais descendentes uma da outra, precisamos usar o rebase em conjunto ao merge. O rebase fará com que as branches se tornem descendentes, e o merge poderá atuar com o fast-forward. Ilustrando passo a passo, esse seria o caminho:
Para fazer merge com fast-forward das duas branches acima, primeiro, precisamos do rebase: git rebase main feat/pagination
.
Isso tornará nossas branches descendentes diretas:
Agora nosso merge: git merge --ff-only feat/pagination main
Utilizar ou não merge commits é algo que varia de um projeto a outro, e as vantagens e desvantagens de cada caso não serão abordadas aqui, o único foco deste artigo é explicar o funcionamento dos comandos para que você possa decidir qual é melhor (ou ajudar na decisão) 🙂.
Conclusão
Neste post foram explicados de maneira simples e visual como os comandos de merge e rebase atuam sobre os commits nos nossos repositórios. Com o objetivo de ajudar no entendimento desses comandos, que são os mais usados na maior parte do tempo.
Mas o Git é muito mais que isso, há muito mais a se explorar nos comandos apresentados, assim como há muitos comandos e funcionalidades que não foram mencionados nesse artigo.
Aqui segue uma lista do que você pode estudar a seguir:
- fixup commit (
git commit --fixup
+git rebase -i --autosquash
) - reflog (git reflog)
- remotes
- assinatura de commits e tags com chave gpg
- git bisect