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
Referência: https://stackoverflow.com/questions/13716658/how-to-delete-all-commit-history-in-github