Git: merge com submódulos

12 minuto(s) de leitura

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:

Grafo de commits

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:

Objetos blob, commit e tree, e branch

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:

Grafo de commits antes do fast-forward

Depois do fast-forward:

Grafo de commits 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:

Melhores ancestrais comuns

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.

Three-way merge

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:

Submódulos dentro do grafo dos objetos

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:

Fast-forward de submódulos antes do merge

Depois do merge com fast-forward de submódulo, a situação é esta:

Fast-forward de submódulos depois do merge

Caso não seja possível esse fast-forward, o Git indica conflito e o usuário deverá resolver manualmente, como nesta situação:

Conflito de submódulos

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:

Branches apontando para commits de submódulos em que pode ser feito um fast-forward

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:

Merge de main na pr-branch

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:

  1. Se ninguém alterar a main nesse meio-tempo, pr-branch será descendente da main, o que permite um fast-forward;
  2. Caso alguém tenha alterado a main nesse meio-tempo, a main antiga (que você fez o fetch) será a melhor ancestral comum entre a main nova (com a alteração da outra pessoa) e pr-branch. Dessa forma, caso a main nova não tenha alterado o submódulo em relação à main antiga, o submódulo da pr-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.

Atualizado em: