Programação Por Equipe DevBras

Capa do artigo: Force push na main sem quebrar o clone da equipe

Force push na main sem quebrar o clone da equipe

Testado no Git 2.47: por que o git pull de um colega traz de volta o histórico apagado, quando o force-with-lease falha e como cada pessoa realinha o clone.

Reescrever o histórico de uma branch compartilhada só termina quando todos os clones foram realinhados. Enquanto alguém tiver o clone antigo, basta um git pull resolvido com merge e um git push comum para os commits removidos voltarem ao remoto, sem nenhum force push e sem nenhum erro na tela.

Para mostrar isso com saída real, montamos no box um repositório remoto e quatro clones (Ana, Bruno, Caio e Dani), com Git 2.47.3 e git filter-repo 2.47.0. O objetivo da reescrita no teste é tirar da história uma pasta midia/ com um vídeo de 3 MB que foi commitado por engano.

Se o motivo da reescrita é uma senha, um token ou uma chave que foi parar no repositório, a ordem é outra: a credencial é trocada antes, porque reescrever não apaga as cópias. Esse caso está no guia segredo no Git se troca antes de limpar. Se o erro é só no último commit e ele ainda não foi enviado, desfazer o último commit resolve sem reescrever nada compartilhado.

O que acontece no clone de quem não foi avisado

Depois da reescrita, o remoto caiu de 3,1 MB para 152 KB. O Caio, que tinha um clone da main antiga sem trabalho local, rodou git pull. No Git atual, o pull não decide sozinho o que fazer com duas histórias divergentes:

$ git pull
 + 9b83239...f5c550e main       -> origin/main  (forced update)
hint: You have divergent branches and need to specify how to reconcile them.
...
fatal: Need to specify how to reconcile divergent branches.

Esse erro é o aviso de que algo foi reescrito. O problema é a reação comum a ele, que é escolher merge. Foi o que o teste fez em seguida:

$ git pull --no-rebase --no-edit
Merge made by the 'ort' strategy.
$ ls midia
video.bin
$ git push --dry-run origin main
   f5c550e..deeecf0  main -> main

O merge juntou as duas histórias: a main do Caio ficou com 5 commits, incluindo os antigos com o vídeo. E como esse merge contém a main nova, o push é um avanço normal (f5c550e..deeecf0, sem o + de envio forçado). A regra de proteção que bloqueia force push não pega esse envio, e o arquivo removido volta ao remoto inteiro.

O realinhamento certo para quem não tem trabalho local é um comando só:

$ git reset --hard origin/main
HEAD is now at f5c550e ajuste no app

A reescrita, num clone espelho

A documentação do git filter-branch abre com um aviso: a ferramenta tem armadilhas de segurança e desempenho que não podem ser corrigidas, e o uso dela não é recomendado; a sugestão é usar uma alternativa como o git filter-repo. Ele é instalado à parte (no teste, com pip install git-filter-repo).

A reescrita roda num clone espelho novo, que tem todas as branches e tags, e não no clone de trabalho de ninguém:

git clone --mirror git@github.com:SUA-ORGANIZACAO/loja-web.git
cd loja-web.git
git filter-repo --path midia/ --invert-paths
git remote add origin git@github.com:SUA-ORGANIZACAO/loja-web.git
git push --force --mirror origin

O filter-repo remove o remote origin de propósito, para evitar um envio por reflexo. No teste ele avisou isso e o git remote -v seguinte saiu vazio:

NOTICE: Removing 'origin' remote; see 'Why is my origin removed?'
        in the manual if you want to push back there.
Parsed 3 commits
New history written in 0.02 seconds; now repacking/cleaning...

O commit que só adicionava o vídeo ficou vazio e foi descartado; os outros ganharam ids novos. O envio do espelho mostrou + 9b83239...f5c550e main -> main (forced update). Num serviço com proteção de branch, o force push precisa ser liberado só durante esse envio e bloqueado logo depois. A documentação do GitHub sobre branches protegidas explica a opção.

Antes de rodar, combine uma janela: data, motivo e prazo para cada pessoa enviar o que tem pendente. Pull requests abertos são mesclados antes ou recriados depois, porque a branch base deles vai deixar de existir no formato antigo.

Quem tem trabalho em andamento

O Bruno tinha uma branch minha-feature criada da main antiga, com um commit dele. Ele não pode fazer merge da main nova nem simplesmente rebasear sobre ela (o rebase tentaria reaplicar os commits antigos). O caminho é levar só os commits dele, com --onto, que é o que a documentação do git rebase descreve na seção sobre recuperar de um rebase feito no repositório de cima:

git fetch origin
git branch backup-main-antiga main
git rebase --onto origin/main main minha-feature
git switch main
git reset --hard origin/main

A ordem importa: o rebase roda enquanto a main local ainda marca onde a branch nasceu. No teste, o resultado foi:

$ git rebase --onto origin/main main minha-feature
Successfully rebased and updated refs/heads/minha-feature.
$ git log --oneline --all --not backup-main-antiga
64b1ead feature do bruno
f5c550e ajuste no app
$ ls
app.txt
feature.txt

A branch do Bruno ficou com o commit dele em cima da main nova, sem a pasta midia/. A backup-main-antiga fica só no clone dele, para consulta. Se ele precisar de algo de lá, copia o arquivo, nunca o commit, e não envia essa branch.

force-with-lease não basta quando o editor faz fetch

Para envios forçados do dia a dia (um --amend numa branch compartilhada, por exemplo), o --force-with-lease recusa o push se o remoto mudou desde a última vez que você o viu. No teste, a Dani enviou um commit e depois o Caio tentou forçar a main dele:

$ git push --force-with-lease origin main
 ! [rejected]        main -> main (stale info)

A proteção depende do que o seu clone "viu", e um git fetch atualiza isso. Muitos editores rodam fetch sozinhos em segundo plano. Depois de um fetch, sem integrar nada, o mesmo comando passou e apagou o trabalho da Dani do remoto:

$ git push --force-with-lease origin main
 + 663b80c...6d3c330 main -> main (forced update)
o commit da dani ainda está na main remota? 0

A documentação do git push descreve esse risco e a opção que fecha a brecha, --force-if-includes, que só deixa forçar se a ponta do remoto já foi integrada ao seu branch local. No teste, ela recusou o mesmo envio:

$ git push --force-with-lease --force-if-includes origin main
 ! [rejected]        main -> main (remote ref updated since checkout)

Para quem força push com frequência, vale deixar git config --global push.useForceIfIncludes true.

O que fica fora do alcance da reescrita

A página do GitHub sobre remover dados sensíveis de um repositório lista onde os commits antigos continuam acessíveis depois do force push: clones e forks, visualizações em cache pelo hash do commit e pull requests que se referem a eles. O GitHub não apaga clones de outras pessoas; a página orienta mandar a cada uma as instruções de limpeza e diz que a diferença exibida em pull requests fechados também se perde.

Na prática, entram na lista de quem precisa realinhar ou reclonar: colegas, servidores de integração contínua com cache do repositório, máquinas de deploy que fazem git pull e qualquer fork. Um servidor de build que faz pull com merge configurado é o Caio do teste, sem ninguém olhando.

Zerar todo o histórico

Quando a decisão é ficar só com o estado atual, sem nenhum commit anterior, uma branch órfã faz isso num clone atualizado:

git switch --orphan nova-main
git add -A
git commit -m "Estado inicial do repositório"
git branch -D main
git branch -m main
git push --force-with-lease --force-if-includes origin main

O custo é perder o git blame, o motivo de cada mudança e a possibilidade de voltar a uma versão. Para tirar arquivos pesados ou indevidos, o filter-repo com --path resolve sem apagar o resto. Os passos de realinhamento da equipe são os mesmos nos dois casos.

O roteiro do teste

O script cria tudo numa pasta temporária, sem configuração global do Git:

git init -q --bare -b main loja-web.git
# Ana: app.txt, depois midia/video.bin (3 MB aleatórios), depois um ajuste; push
# Bruno e Caio clonam; Bruno cria minha-feature com um commit
git clone -q --mirror loja-web.git espelho
cd espelho
git filter-repo --path midia/ --invert-paths
git remote add origin ../loja-web.git
git push --force --mirror origin
# no remoto: git gc --prune=now (3,1 MB para 152 KB)
# Caio: git pull; git pull --no-rebase; git push --dry-run; git reset --hard origin/main
# Bruno: git rebase --onto origin/main main minha-feature
# Dani envia um commit; Caio tenta --force-with-lease antes e depois de um fetch


Avalie este artigo

Referência: https://stackoverflow.com/questions/13716658/how-to-delete-all-commit-history-in-github