Olá. Hoje eu vou falar de algo que me assombrou por muitos anos, e só agora, depois de muito tempo, eu consegui entender, e do entendimento, conseguir a solução. Um problema clássico, que são os repositórios fork no GitHub estarem eternamente dessincronizados, mesmo após merges de pull requests.
O GitHub é uma hospedagem git
muito boa no geral, porém a funcionalidade de squash (esmagar) commits no momento do merge causa problemas que podem ser apavorantes às pessoas com pouco ou nenhum domínio da ferramenta git
.
Em resumo, utilizar squash merges em conjunto com repositórios fork, deixa os repositórios fork eternamente divergentes, e a divergência só cresce com o tempo. E isso por sua vez decorre do fato que, ao contrário da intuição, os merge commits não unificam a história de dois repositórios separados, mas apenas documentam em um repositório que um commit tem duas dependências locais, ao invés de uma.
Vamos ver isso no caso típico. A história normalmente começa a partir de um repositório oficial ou upstream hospedado no GitHub. A partir desse repositório é criado um segundo repositório fork. E por fim, para trabalhar diretamente com os arquivos, desse segundo repositório é criado um terceiro repositório local, através do comando git clone
.
Apesar de em um primeiro momento os três repositórios estarem idênticos, eles podem (e provavelmente vão) divergir livremente. O máximo de relação que existe entre os repositórios é que eles tem vários commits individuais em comum, e que, para além disso, há uma marcação no repositório fork (B) que indica que ele pode falar com o upstream (A), e uma marcação no repositório local (C) que indica que ele pode falar com o repositório fork. Mas repare que não há links nas direções opostas. Ou seja, C pode falar com B, e B pode falar com A, mas nada além disso.
Agora vamos ver como é o dia a dia. Depois de criados os repositórios fork e local, são feitos variados commits no repositório local, até o ponto que a alteração é considerada pronta e pode ser enviada. Porém o processo de envio é feito em fases. Primeiro os repositórios local e fork são sincronizados via um git pull
, de C para B. Mas para enviar a alteração de B para A, não é utilizado o protocolo git, mas sim as telas do GitHub, e é nesse momento que é possível fazer um squashed commit.
Um commit esmagado é implementado internamente como:
- Um novo commit normal em A;
- Um novo commit merge em B;
- Não tem efeitos imediatos em C.
Por exemplo, vamos supor que foram criados os commits 1
, 2
e 3
no repositório local. Após o push, eles passam a existir também no repositório fork, e C e B ficam iguais. Depois, um pull request é aberto de B para A, que depois de um tempo é aceito. No momento que é aceito, é criado um novo commit 4
, apenas no repositório upstream e que não se parece com nenhum outro commit anterior, e é criado um merge commit 5
, apenas no fork, que também não se parece com nenhum commit do repositório oficial. Nesse ponto, A, B e C estão diferentes entre si.
Repositório | Commits |
upstream | 0-5 |
fork | 0-1-2-3-4 |
local | 0-1-2-3 |
Note que o commit 5 só existe no upstream, que o commit 4 só existe no fork. Isso torna os repositórios upstream e fork incompatíveis. Os repositórios fork e local continuam compatíveis porque o commit 4 existe a mais “ordem certa”, e pode ser tranquilamente copiado de B para C. Mas o commit 5 é uma mesclagem dos commits 1 a 3, e por isso tentar copiar entre A para B/C, ou vice versa, as alterações vão se sobrepor, causando um conflito.
É por isso que repositórios forks no GitHub ficam eternamente dessincronizados, mostrando mensagens do tipo This branch is N commits ahead of proj/repo:branch
. E o único jeito de resolver isso seria através de alterações destrutivas no repositório fork e local. É preciso destruir os commits 1, 2, 3 e 5, para assim “abrir espaço” para a criação do commit 4 do repositório oficial.
As interfaces do GitHub não implementam essas ações destrutivas, de forma que só sobra fazê-las por fora do GitHub (que será explicado abaixo).
Mas sabendo que serão necessárias ações destrutivas, daí dois conselhos imediatos. Primeiro, faça um backup do seu repositório local antes de mais nada, e assim ao menos será possível voltar à situação original se alguma coisa der errado no meio do caminho (o que é bastante comum de acontecer) Segundo, que em alguns casos, é possível diminuir um tanto o tamanho da destruição.
O grande problema é que a decisão de fazer um merge normal ou um merge squashed está nas mãos do controlador do repositório upstream, e você não tenha como prever que tipo de merge ele vai fazer. No final, você pode ter de lidar com uma situação de destruição maior. Porém, se você souber de antemão que o merge será squashed, você pode diminuir os seus problemas fazendo branches “rasos”. Em outras palavras, evitando fazer um monte de commits pontuais nos seus repositórios, que no final serão todos perdidos de qualquer jeito. Esse não é um conselho muito bom, mas diminui bastante as chances do git
falhar fazendo um rebase
automaticamente.
Rebase automático menos destrutivo
Depois do backup feito, hora de tentar resolver e sincronizar seus repositórios de uma maneira automatizada. A primeira coisa que vamos fazer é ensinar o seu repositório local a falar diretamente com o repositório upstream. Lembrar que o repositório local nasce sabendo falar apenas com o fork.
git remote add upstream git@github.com:user/repo.git
git remote set-url --push upstream /dev/null
git fetch upstream
O primeiro comando permite a C conversar com A, o segundo comando bloqueia a comunicação de escrita (e assim você pode trabalhar com menos medo), e o terceiro comando anexa os dados do repositório A na sua cópia C.
Para ver como ficou, você pode rodar um git remote -v show
. O resultado deve ser algo parecido com isso:
origin git@github.com:user/repo.git (fetch)
origin git@github.com:user/repo.git (push)
upstream git@github.com:upstream/repo.git (fetch)
upstream /dev/null (push)
Se você já teve a infelicidade de executar um git rebase
interativo, já descobriu que é um comando feito por masoquistas, para masoquistas, com amor. Mas aqui queremos um processo automatizado, tudo ou nada. Será um processo destrutivo, e talvez por isso você queira entender exatamente o irá acontecer. Os comandos git log
servem para isso, e podem ser comentados ou removidos.
Os comandos abaixo funcionam supondo que vamos fazer a reescrita destrutiva em um branch chamado master
que existe em todos os repositórios. Em teoria seria possível fazer os comandos misturando branchs diferentes de repositórios diferentes, mas fica o alerta que isso fica bem confuso, bem rápido.
Hora da verdade:
git log -500 --format="%h %s" > ../log1.txt
git switch master
git rebase -s ours upstream/master
git log -500 --format="%h %s" > ../log2.txt
Se tudo der certo, você vaii um monte de mensagens, mais ou menos assim:
dropping hash message -- patch contents already upstream
dropping hash message -- patch contents already upstream
dropping hash message -- patch contents already upstream
Successfully rebased and updated refs/heads/master.
As mensagens do tipo dropping -- patch contents already upstream
indicam que o git
conseguiu encontrar as suas alterações pontuais no upstream, e por isso jogou fora vários commits individuais. E isso é o que queríamos.
Eu sugiro que aproveite esse momento para olhar os arquivos ../log1.txt
e ../log2.txt
, para ver como estava e como ficou o seu repositório local. Usando a numeração utilizada acima, deve ter ficado mais ou menos assim, no topo do começo do arquivo ../log1.txt
:
hash5 Merge branch 'php:master' into master
hash3 Msg commit local 3
hash2 Msg commit local 2
hash1 Msg commit local 1
hash0 Mensagem de um commit em comum.
E assim o começo do arquivo ../log2.txt
:
hash4 Mensagem commit esmagado
hash0 Mensagem de um commit em comum.
Ou seja, agora o ramo master
do repositório local está idêntico ao do repositório oficial A. Além disso, os demais branchs devem ter sido preservados. Ainda assim, uma verdadeira reescrita destrutiva no repositório local, que ainda precisa ser enviado ao repositório fork B.
Porém, caso não tenha recebido a mensagem Successfully rebased
, não tema. Execute um git rebase --abort
, e tudo voltará como era antes. Porém isso significa que o procedimento menos destrutivo falhou, e não adianta continuar.
Bom.. supondo que deu certo, a reescrita destrutiva foi feita localmente. Nesse momento, A e C estão iguais, mas B continua diferente, e para além disso, continua mostrando a mensagem de This branch is N commits ahead of proj/repo:branch
, que era o problema original.
Nesse momento, você pode ficar tentado em evitar uma reescrita destrutiva no seu fork, fazendo um simples git push
. Pode tentar, mas não vai funcionar.
E não vai funcionar porque no repositório fork, o último hash de master
ainda é o hash5
, o merge commit criado pelo GitHub, que torna esse repositório B incompatível com a história do repositório A, que nunca nem viu esse merge. Será preciso sim fazer uma reescrita destrutiva em B. E foi para isso que modificamos o repositório local C.
Observe que o repositório local C contém, nesse momento:
- um branch
master
compatível com o do repositório A; - todos os seus outros branchs, que não existem no repositório A.
Ou seja, tem tudo o que você quer. Basta então sobrescrever o repositório B com os dados do repositório C, com o seguinte comando:
git push --force origin +master
Feito isso, é só navegar até a página do seu repositório no GitHub, e verá que a mensagem de This branch is N commits ahead
finalmente desapareceu.
Rebase automático mais destrutivo
Eu expliquei acima o rebase menos destrutivo, mas o que seria então orebase mais destrutivo?
O jeito mais destrutivo, e também o mais rápido, de resolver a mensagem This branch is N commits ahead
seria usar a sequência de comandos abaixo, que sempre funciona, mas que ainda assim eu não recomendo.
git fetch upstream // Anexa dados do upstream no repositório local
git checkout master // Garante que está no local/master
git reset --hard upstream/master // Descarta dados locais
git push --force // Descarta dados no repositório
Os descartes de dados comentados linha a linha são irreversíveis. Jogou fora, é para sempre.
E por isso mesmo que não recomendo o seu uso, a não ser no caso mais desesperador, quando você já está decidido a apagar repositórios inteiros, local e fork, para resolver o problema. Que é mais ou menos isso que os comandos acima fazem.
Epílogo
E aí ajudou? Resolveu? Depois de ler, entendeu mais, ou piorou o entendimento?