Git: merge com submódulos
Se você lida com um projeto versionado com o Git e que usa submódulos, talvez você já tenha passado por uma situação em que precisou alterar um submódulo em uma branch, e precisou fazer o merge dessa branch em outra.
Por exemplo, supondo que a estrutura do seu projeto seja esta a seguir, com dois arquivos Python (sendo um dentro de um subdiretório) e um diretório contendo uma biblioteca. Neste exemplo, essa biblioteca não é um diretório “copiado” para dentro do projeto, e sim um projeto separado mas que é incluído aqui através de um submódulo:
$ tree
├── algum_arquivo.py
├── algum_diretorio
│ └── outro_arquivo.py
└── biblioteca_que_fica_em_submodulo
│ └── arquivo_da_biblioteca.py
└── .gitmodules
Como essa biblioteca é incluída via submódulo, sua versão ficará fixada em um commit até que alguém explicitamente a mude. Isso pode ser feito, por exemplo, da seguinte forma:
$ git -C biblioteca_que_fica_em_submodulo fetch
$ git -C biblioteca_que_fica_em_submodulo checkout <commit da versão nova>
$ git add biblioteca_que_fica_em_submodulo
$ git commit
Caso você tenha feito isso em uma branch sua e queira que essa alteração entre
em outra branch, provavelmente você fará um merge. Supondo que essa outra
branch seja a main
e a sua seja chamada pr-branch
:
$ git checkout main
$ git merge pr-branch
O grafo de commits ficaria assim, com as setas pontilhadas apontando para as versões dos submódulos:
Como a imagem mostra, queremos saber para qual commit do submódulo o commit de merge vai apontar.
Caso a main
não tenha tido nenhuma alteração no submódulo enquanto você
desenvolvia a pr-branch
, o merge ocorre do jeito esperado e a
biblioteca fica na versão nova. Caso contrário, o comportamento não é tão
trivial:
- pode ser que a versão que esteja na
main
permaneça; - pode ser que a que esteja na
pr-branch
permaneça; - pode ser que aconteça um conflito que o Git não consiga resolver automaticamente sem intervenção de alguém.
A partir desse ponto, não encontrei algum material que explicasse de forma completa qual é o comportamento do Git nesses casos. Para entender o que acontece, precisei ir mais a fundo no Git, inclusive lendo o código fonte, e então resolvi escrever este post :-).
Breve explicação sobre objetos
Caso você já tenha familiaridade com o funcionamento dos objetos no Git, pode pular para a próxima seção. Aqui não é meu objetivo explicar a fundo como eles funcionam, só o que precisamos para entender o merge e os submódulos.
Então, caso você não tenha familiaridade com o funcionamento interno do Git, sugiro fortemente as seções 1.3 “What is Git?”, 10.2 “Git Objects” e 10.3 “Git References” do livro Pro Git, disponível gratuitamente em vários formatos em https://git-scm.com/book/en/v2.
Um tl;dr rápido com o que precisamos: os dados de um repositório (por “repositório” entenda o repositório local em cada máquina) é armazenado em unidades chamadas objetos. Esta é uma representação gráfica bem básica de como os objetos se relacionam entre si:
Um tipo de objeto é o nosso velho conhecido commit. Cada commit guarda o estado de todo o projeto no momento em que foi feito (e não apenas as alterações). Esse estado é chamado de snapshot, e pode ser entendido como uma “fotografia” do projeto naquele momento.
Outros tipos de objetos são tree e blob. Uma tree representa o estado de um diretório, sendo uma listagem de seus conteúdos: outros diretórios filhos (cada um com seu conteúdo representado por uma tree), ou arquivos (cada um com seu conteúdo representado por um blob).
Cada commit aponta para a tree que representa a raiz do projeto no estado que o commit guarda.
Cada commit aponta para seu(s) commit(s) pai(s), se houver:
- Um commit inicial não tem pai;
- Um commit comum tem apenas um pai;
- Um commit de merge tem dois ou mais pais, apontando para quais commits foram mesclados para que este commit fosse criado. Normalmente fazemos merge de apenas uma branch em outra, logo, o commit resultante normalmente tem dois pais.
Todo objeto é identificado por seu hash SHA-1. Objetos são únicos e imutáveis. Isso significa, por exemplo, que:
- dois arquivos iguais são representados pelo mesmo blob;
- dois diretórios iguais são representados pela mesma tree;
- um arquivo que tem o mesmo conteúdo em commits distintos é representado pelo mesmo blob em ambos;
- um arquivo que tem conteúdos diferentes em commits distintos é representado por blobs diferentes uns dos outros em cada um dos commits.
Uma branch não é um objeto. Ela é apenas uma referência, um ponteiro para um commit. Internamente, ela é simplesmente um arquivo com o hash de um commit dentro.
Breve explicação sobre merge
Fast-forward
Ainda no mesmo exemplo, quando fizemos git merge pr-branch
, podemos ter
uma situação que a pr-branch
aponta para um commit descendente do
commit que main
aponta. Nesse caso, por padrão o Git não faz um merge
verdadeiro, e sim um fast-forward. Isso significa que o Git simplesmente vai
alterar a main
para que aponte para o mesmo commit que a
pr-branch
.
Ilustrando como isso acontece, a situação antes do fast-forward era esta, com o grafo azul representando os commits do projeto, e o vermelho representando os commits do submódulo:
Depois do fast-forward:
Esse caso dispensa mais explicações quanto ao ponto principal do post, já que,
obviamente, a biblioteca estará na mesma versão que a pr_branch
após o
fast-forward, já que a main
passa a apontar para o mesmo commit que
pr-branch
.
Merge verdadeiro
Um merge verdadeiro ocorre caso o commit que pr_branch
aponta não
seja descendente do commit que main
aponta, sendo então criado o commit
com dois pais que mencionei anteriormente. Esse comportamento também pode ser
forçado mesmo quando é possível um fast-forward, usando a flag --no-ff
.
O git merge
pode ter comportamentos distintos de acordo com a estratégia
escolhida. Por padrão nas versões mais novas do Git a estratégia usada é a
ort
. Em versões um pouco mais antigas, a estretégia padrão é a recursive
.
Este post irá abordar a partir daqui, o comportamento da ort
. Porém, o que for
abordado também vale para a recursive
, mas não para as outras.
Three-way merge
A decisão sobre o que deve ser escolhido para entrar no commit de merge é feita por um algoritmo chamado three-way merge. Esse algoritmo se baseia em três commits: os dois commits apontados pelas branches que queremos fazer o merge, e o melhor ancestral comum desses dois commits.
Por “melhor ancestral comum” entre dois commits, entenda: um ancestral comum de dois commits X é melhor que outro ancestral comum Y se X for descendente de Y. O “melhor de todos” é o que será usado. Mais informações na manpage do git merge-base.
Nos três exemplos a seguir, o “melhor ancestral comum” das branches A e B é o commit amarelo:
Vamos denotar por A e B os dois commits apontados pelas branches que queremos fazer o merge, e por O o melhor ancestral comum a eles dois. Um arquivo poderá, então, ter até três versões diferentes, uma em A, uma em B e outra em O.
Para tomar a decisão de qual será usada, o three-way merge faz a seguinte regra:
- Se o arquivo tem o mesmo conteúdo em A e em B, no commit de merge ele terá esse conteúdo;
- Caso contrário:
- Se o arquivo tem o mesmo conteúdo em A e em O, no commit de merge ele terá o conteúdo de B;
- Se o arquivo tem o mesmo conteúdo em B e em O, no commit de merge ele terá o conteúdo de A;
- Caso contrário (o arquivo tem conteúdos distintos entre si em A, B e O): conflito.
Podemos ver isso na imagem a seguir. As bolinhas representam conteúdos diferentes do mesmo arquivo ao longo dos commits.
A comparação entre os estados não é feita usando o arquivo inteiro: o Git compara o hash dos blobs em cada um dos estados, o que é suficiente pra dizer se eles são iguais ou não (lembrando: caso o arquivo seja igual em commits diferentes, ele é representado pelo mesmo blob).
Caso haja um conflito e o arquivo for um arquivo de texto puro, o Git por padrão
ainda faz o three-way merge internamente no conteúdo. Dessa forma, por
exemplo, se a mudança de O para A seja em um local distinto de O pra B dentro do
arquivo, ambas são preservadas. Caso elas tenham sido feitas no mesmo local, o
Git adiciona as marcas de conflito para que sejam resolvidas pelo usuário
(aquelas <<<<<<<
, =======
e >>>>>>>
).
Arquivos binários não são resolvidos, o usuário deverá escolher qual versão que deverá ser usada no commit de merge.
Breve explicação sobre submódulos
Bom, se você chegou até aqui, provavelmente não precisa de uma introdução aos submódulos. Mas ainda assim, vale a pena mostrar como eles são representados internamente.
Vimos que cada tree representa o conteúdo de um diretório. Com o comando
git ls-tree
podemos ver o conteúdo de uma tree . Se fizermos
git ls-tree HEAD
podemos verificar o conteúdo da tree
do commit atual.
No nosso exemplo, seria algo parecido com isso:
$ git ls-tree HEAD
100644 blob 123abc456def123abc456def123abc456def9999 .gitmodules
100644 blob 1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1234 algum_arquivo.py
040000 tree aaaaabbbbbcccccdddddeeeeefffff1111122222 algum_diretorio
160000 commit fedcba0123456789fedcba012345678912345678 biblioteca_que_fica_em_submodulo
Os hashes nas linhas do algum_arquivo.py
e do .gitmodules
indicam os
blobs que armazenam seus respectivos conteúdos, e da mesma forma, o hash na
linha de algum_diretorio
indica a tree que armazena o conteúdo desse
diretório.
Porém, na linha da biblioteca_que_fica_em_submodulo
, o hash que aparece é
justamente o hash do commit do submódulo referente à versão atual da
biblioteca. Caso seja feita uma alteração da versão dessa biblioteca, esse hash
muda, consequentemente a tree que representa o diretório raiz do projeto será
outro objeto, ou seja, será outro snapshot.
Ilustrando isso, os submódulos ficam assim:
Além disso, ainda temos o arquivo .gitmodules
, que armazena propriedades dos
submódulos, como, por exemplo, seu diretório (path
) e a URL do repositório de
onde ele é clonado (url
). Mais informações sobre o .gitmodules
estão na sua
manpage.
Three-way merge de submódulos
O three-way merge ainda valerá se fizermos o merge de commits que tenham submódulos. E o mecanismo é o mesmo que serviria para fazer o three-way merge de arquivos, porém, em vez de comparar hashes de blobs, comparamos hashes de commits do submódulo:
- Se o submódulo aponta para o mesmo commit em A e B, esse commit será usado no commit de merge;
- Caso contrário:
- Se o submódulo aponta para o mesmo commit em A e em O, o commit para que ele aponta em B será usado no commit de merge;
- Se o submódulo aponta para o mesmo commit em B e em O, o commit para que ele aponta em A será usado no commit de merge;
- Caso contrário (o submódulo aponta para commits distintos em A, B e O): conflito.
Tudo tranquilo até aqui, mas o que acontece quando temos um conflito…
Resolução de conflitos de submódulos
Caso haja um conflito entre submódulos, dependendo da situação o Git tenta resolver automaticamente: caso o commit do submódulo em A seja descendente do commit do submódulo em B, o commit do submódulo em A será usado. O contrário também vale: caso o commit do submódulo em B seja descendente do commit do submódulo em A, o commit do submódulo em B será usado. Esse comportamento você pode ver aqui, no código-fonte do Git. Em outras palavras, é feito um fast-forward no submódulo.
Graficamente, a situação antes era esta:
Depois do merge com fast-forward de submódulo, a situação é esta:
Caso não seja possível esse fast-forward, o Git indica conflito e o usuário deverá resolver manualmente, como nesta situação:
Ainda assim, caso esses commits não sejam descendentes um do outro mas exista algum commit de merge que seja descendente de ambos, esse commit será sugerido para o usuário como uma possível solução para usar como commit do submódulo no merge, e cabe ao usuário aceitá-la ou não. Esse comportamento pode ser visto aqui, no código-fonte do Git.
Vale ressaltar também que essas formas de resolução de conflitos só são
possíveis caso estejam presentes localmente os objetos referentes aos commits
do submódulo. Caso eles não estejam presentes (por exemplo, por um git clone
sem --recursive
, ou por falta de um git submodule update
), o Git não será
capaz de resolver e irá indicar conflito.
E o GitHub e o GitLab?
A libgit2
é uma biblioteca em C que provê as funcionalidades do Git.
Segundo o README da libgit2
,
tanto o GitHub e o GitLab trabalham usando a libgit2
. A
libgit2
não tenta resolver conflitos de submódulos, como pode ser visto
aqui, no código-fonte da libgit2,
e isso também vale para o GitHub e o GitLab.
Para ver isso na prática:
$ git checkout main
$ git submodule update --init
$ git branch pr-branch
# Adicionando um commit à main
$ git -C biblioteca_que_fica_em_submodulo checkout algum_commit~ # esse ~ é proposital
$ git add biblioteca_que_fica_em_submodulo
$ git commit
# Adicionando um commit à pr-branch
$ git checkout pr-branch
$ git submodule update --init
$ git -C biblioteca_que_fica_em_submodulo checkout algum_commit # descendente do commit na main
$ git add biblioteca_que_fica_em_submodulo
$ git commit
# Git push, para fazermos um PR/MR logo em seguida
$ git push origin pr-branch # supondo que seu remote se chame origin
Chegamos a uma situação parecida com esta:
Localmente, nesse caso um git checkout main && git merge pr-branch
funciona,
porque o Git fará um fast-forward do submódulo. Mas se você abrir um Pull
Request (GitHub) ou Merge Request (GitLab) para a branch pr-branch
, vai ser
indicado um conflito no submódulo.
Como resolver?
Caso você esteja nessa situação, pode fazer isto:
$ git fetch origin main # supondo que seu remote se chame "origin"
$ git checkout pr-branch
$ git submodule update --init
$ git merge origin/main
$ git push origin pr-branch # supondo que seu remote se chame "origin"
O que deixará o grafo de commits nesta situação:
O PR/MR não estará mais com conflito. E por que funciona? Bom, fizemos um
merge da main
na pr-branch
, o que localmente é bem-sucedido já que o Git
foi capaz de fazer um fast-forward do submódulo.
Quando fizermos o push
:
- Se ninguém alterar a
main
nesse meio-tempo,pr-branch
será descendente damain
, o que permite um fast-forward; - Caso alguém tenha alterado a
main
nesse meio-tempo, amain
antiga (que você fez ofetch
) será a melhor ancestral comum entre amain
nova (com a alteração da outra pessoa) epr-branch
. Dessa forma, caso amain
nova não tenha alterado o submódulo em relação àmain
antiga, o submódulo dapr-branch
será escolhido no three-way merge. Isso também valerá caso você opte por não usar um fast-forward no GitHub/GitLab.
Conclusão
Quando fazemos git merge pr-branch
, o Git tenta mesclar os submódulos usando
three-way merge. Se não for possível, tenta um fast-forward para o commit
descendente do submódulo, se houver. GitHub e GitLab não fazem esse
fast-forward, então, precisamos fazer o merge localmente, resolvendo o
conflito do submódulo manualmente ou automaticamente, de forma que permita o
que o GitHub ou GitLab façam o three-way merge sem cair no caso de conflito.