Corrigindo Commits Antigos Com git history
git history no Git 2.55 reescreve commits antigos com fixup, reword ou split, atualiza branches empilhados e mostra prévias seguras com dry-run.
O comando experimental git history reescreve um único commit antigo, incorporando alterações em stage, substituindo sua mensagem ou dividindo-o em dois, e move todos os branches locais construídos sobre ele, sem nunca iniciar um rebase interativo.
Se você mantém vários branches empilhados uns sobre os outros, talvez reconheça o modo de falha que isso substitui: você percebe um erro de digitação três commits abaixo, inicia um rebase -i, encontra um conflito em um commit que nunca pretendia tocar, e agora está no meio de um rebase com um HEAD detached e uma decisão a tomar. O git history foi criado para evitar que você chegue a esse ponto. Seus subcomandos compartilham uma garantia: a operação ou é concluída, ou é abortada com um erro e nada é alterado.
Este artigo percorre os três subcomandos do Git 2.55 com saídas reais, mostra a flag --dry-run para pré-visualizar uma reescrita antes que qualquer coisa se mova, e cobre os limites: sem históricos com merges, sem operações conflitantes, sem hooks.
Principais Conclusões
- No Git 2.55, o
git historypossui três subcomandos:fixupincorpora alterações em stage a um commit antigo,rewordsubstitui sua mensagem esplito divide em dois. - Toda operação é atômica: se a reescrita produzisse um conflito em qualquer ponto do histórico reproduzido, o comando aborta e nada é alterado.
- Por padrão, todos os branches locais que descendem do commit reescrito são atualizados;
--update-refs=headmove apenas o HEAD atual. --dry-runnão move nada e, em vez disso, imprime as atualizações de refs para que ogit update-refas aplique depois, que é a forma segura de pré-visualizar uma reescrita.- O comando é experimental, não executa hooks e recusa históricos que contenham merges; nesses casos, use
git rebase --rebase-merges.
Por Que o Comando git history Existe?
O git history existe para corrigir um commit antigo sem a cerimônia e o risco de um rebase interativo. O manual oficial o contrasta com o git rebase: menos escolhas e um comando mais fácil de acionar quando a edição é pontual. Ele também carrega um rótulo de experimental, portanto flags e comportamentos ainda podem mudar entre releases.
Duas propriedades tornam o comando digno de aprendizado. Primeiro, atomicidade: qualquer operação que possa terminar em um conflito de merge simplesmente não é oferecida. Essa é uma escolha deliberada, porque o comando trata uma reescrita como uma tacada única, e não como uma sessão pela qual você avança passo a passo. Não há --continue, não há --abort e não há estado inacabado do qual se recuperar. Segundo, alcance: por padrão, ele atualiza todos os branches locais que apontam para descendentes do commit reescrito, que é exatamente o que um fluxo de trabalho com branches empilhados precisa.
O comando chegou em duas etapas. reword e split foram lançados no Git 2.54; o Git 2.55 adicionou o fixup. Isso totaliza três subcomandos a partir da 2.55, a versão que este artigo cobre:
| Subcomando (Git 2.55) | O que faz | Executa em um repositório bare |
|---|---|---|
git history fixup | Incorpora alterações em stage a um commit antigo | Não, pois lê o index |
git history reword | Substitui a mensagem de um commit antigo | Sim |
git history split | Divide um commit em dois | Sim |
Espere que essa lista cresça. O manual em desenvolvimento do Git para o git history já documenta um quarto subcomando, drop, que remove um commit e reproduz seus descendentes sobre o pai dele. Portanto, verifique git history -h na sua própria versão antes de presumir que três é o conjunto completo.
fixup: Incorporar Alterações em Stage a Um Commit Antigo
O git history fixup <commit> toma o que estiver em stage no index e o integra ao commit alvo. Por baixo dos panos, isso é um merge de três vias, tendo como entradas o HEAD, o commit alvo e uma árvore construída a partir das suas alterações em stage. O alvo mantém sua mensagem e autor originais, a menos que você solicite um editor com --reedit-message. Isso é o equivalente moral de um git commit --fixup seguido de git rebase --autosquash, comprimido em um único passo que não pode deixar você encalhado.
Digamos que um carregador de configuração tenha entrado dois commits atrás sem um valor padrão, e um branch de feature esteja empilhado sobre ele:
$ git log --oneline --branches
c41f9e2 (feature/retries) add retry logic
7b2d8a0 (HEAD -> main) add http client
3e59c11 add config loader
$ git add src/config.js
$ git history fixup 3e59c11
$ git log --oneline --branches
f8a01d3 (feature/retries) add retry logic
92c6b7e (HEAD -> main) add http client
5d40e19 add config loader
Todos os três hashes mudaram, e tanto main quanto feature/retries agora apontam para o histórico reescrito. Esse é o padrão --update-refs=branches em ação: todo branch local que descende do alvo se move, não apenas aquele que você tem em checkout. Passe --update-refs=head para mover apenas o HEAD. Isso alcança mais longe do que git rebase --update-refs, que apenas atualiza refs apontando para commits dentro do intervalo sendo rebaseado.
Antes de executar isso em uma pilha que lhe é importante, adicione --dry-run. Nenhuma referência se move. O que você obtém, em vez disso, é uma lista impressa das movimentações que teriam sido feitas, disposta de modo que você possa alimentá-la ao git update-ref depois. O Git ainda escreve os novos objetos de que precisou, e é por isso que reproduzir essa lista posteriormente normalmente funciona bem. É a forma honesta de ver exatamente quais branches uma reescrita vai tocar antes de se comprometer com ela.
Observe a diferença de escopo em relação ao amend: o git commit --amend alcança apenas o HEAD, enquanto o fixup alcança qualquer commit do seu histórico linear. Ele também é o único subcomando que precisa do seu index, razão pela qual não pode ser executado em um repositório bare como os outros dois.
reword: Substituir a Mensagem de Um Commit Antigo
O git history reword <commit> altera uma única coisa no commit alvo: sua mensagem. Todo o resto do commit é preservado, cada descendente é reproduzido em cima, e as refs dos branches acompanham.
$ git history reword 7b2d8a0
Seu editor abre pré-preenchido com a mensagem atual, add http client. Salve uma melhor e a pilha é reconstruída:
$ git log --oneline --branches
3ba90cf (feature/retries) add retry logic
a1d27e8 (HEAD -> main) add http client with timeout handling
5d40e19 add config loader
Como o reword não mexe no index nem na working tree, ele roda tranquilamente em um repositório bare. Você também pode corrigir a mensagem em um branch que não tem em checkout, sem perturbar aquilo que está fazendo no momento.
split: Transformar Um Commit em Dois
O git history split <commit> conduz você hunk por hunk pelo diff que aquele commit introduziu. O que você escolher vai para um commit novo, encaixado abaixo do original como seu novo pai. O original mantém os hunks que você deixou para trás. Escolher todos os hunks, ou nenhum deles, é rejeitado: qualquer das duas opções deixaria um dos dois commits sem nada dentro.
Dado um commit que misturou um rate limiter com código de métricas não relacionado:
$ git history split 91b04c7
Responda y aos hunks de métricas e n aos hunks do limiter. O editor solicita ambas as mensagens de commit, a autoria permanece a do original, e o resultado é um par limpo:
$ git log --oneline
e7d3f21 (HEAD -> main) add rate limiter
b19c8a4 add request metrics
Um pathspec ao final (git history split 91b04c7 -- src/metrics.js) restringe a divisão aos arquivos que você nomear. Qualquer coisa fora dessa lista permanece onde está, no commit original. Como o reword, o split opera puramente sobre o grafo de commits e roda em um repositório bare.
O Que o git history Não Faz?
A seção LIMITATIONS do manual é curta e vale ser levada ao pé da letra. Commits de merge estão fora de escopo: se o histórico que você quer reescrever contiver algum, o manual o direciona ao git rebase --rebase-merges. Qualquer coisa que possa terminar em conflito é recusada, uma restrição que poderia ser relaxada se o Git algum dia ganhar conflitos como cidadãos de primeira classe. Hooks também não são executados hoje, e o manual deixa a porta aberta para que isso mude.
O fixup tem uma situação-limite adicional, tratada por --empty=(drop|keep|abort). Há dois caminhos para um commit vazio aqui. Sua correção em stage pode cancelar completamente o commit alvo, ou um commit posterior pode já carregar a mesma alteração que você acabou de empurrar para um ancestral. drop é o padrão e descarta tais commits enquanto o histórico é reconstruído; keep os mantém no lugar; abort interrompe o comando com um erro em vez de decidir por você. Descartar o commit raiz ainda não é suportado.
Quando Recorrer ao rebase?
O git history edita um commit; o git rebase continua sendo a ferramenta para tudo mais amplo. O rebase é a resposta quando um intervalo inteiro de commits precisa de uma nova base, e o rebase interativo quando você quer trabalhar em vários commits de uma só vez. E se o que você realmente quer é uma ferramenta que conduza estados conflitados através de um rebase para resolução posterior, isso é o modelo de conflitos de primeira classe do jj, algo que o git history deliberadamente não tenta fazer.
Concluindo
Para a edição de histórico mais comum de todas — corrigir um commit que tem branches empilhados sobre ele — o git history substitui um rebase interativo arriscado por uma operação que ou tem sucesso completo, ou se recusa a rodar. Escolha uma pilha real, execute git history fixup <commit> --dry-run sobre ela e leia as atualizações de refs que ela teria feito. Esse experimento de cinco minutos vai lhe dizer se este comando pertence ao seu fluxo de trabalho diário. Apenas mantenha o rótulo de experimental em mente enquanto decide, porque as flags e o comportamento ainda podem mudar de um release para o outro.
Perguntas Frequentes
Qual é a diferença entre git history fixup e git commit --fixup com autosquash?
O git history fixup incorpora as alterações em stage ao commit alvo em um único passo atômico. O git commit --fixup apenas registra um commit fixup! que um git rebase --autosquash posterior deve esmagar, e esse rebase interativo pode parar no meio do caminho por conflitos. O git history fixup também move, por padrão, todo branch local descendente, e aborta sem alterar nada se a reescrita gerasse conflito.
O git history atualiza branches remotos ou commits que eu já enviei com push?
Não. O git history atualiza apenas branches locais; as refs de rastreamento remoto ficam intactas. Como os commits reescritos recebem novos hashes, commits já enviados exigem um force-push (git push --force-with-lease) e coordenação com qualquer pessoa que tenha feito pull do histórico antigo, como em qualquer reescrita. É mais seguro em commits que ainda não foram enviados, ou em branches em que só você trabalha.
Como desfaço uma reescrita feita com git history?
Use o reflog. O git history move as refs dos branches para novos commits, mas os commits originais permanecem no repositório e, em um repositório normal (não bare), cada branch movido registra a atualização em seu reflog. Execute git reflog show com o nome do branch para encontrar o hash anterior à reescrita e então restaure-o com git reset --hard em um branch em checkout, ou com git update-ref caso contrário.
O git history pode excluir um commit por completo?
Não no Git 2.55. Seus três subcomandos (fixup, reword, split) modificam ou dividem um commit, mas não conseguem remover nenhum. Para excluir um commit hoje, use git rebase -i e marque o commit como 'drop', ou git rebase --onto para pulá-lo. Um subcomando drop aparece na documentação em desenvolvimento do Git para o git history, portanto uma opção nativa pode chegar em um release futuro.