Fai il pull dell’ultimo main e Git si blocca con CONFLICT (content): Merge conflict in checkout.js. Risolvi il conflitto e nel log compare un commit chiamato Merge branch 'main' into feature/checkout, appoggiato sopra i tre commit che avevi effettivamente scritto tu. Quattro commit, una sola modifica reale. Un collega che legge quella storia il mese prossimo non riesce più a distinguerli.

git merge e git rebase rispondono alla stessa domanda: come allineo il mio branch alle modifiche fatte altrove. Lasciano tracce molto diverse, e scegliere quello sbagliato su un branch condiviso causa danni reali.

Cosa fanno di diverso merge e rebase al grafo dei commit

Supponi che main abbia ricevuto i commit D, E, F mentre lavoravi su feature, che ha i tuoi commit A, B, C partiti dalla vecchia punta di main:

1
2
3
      A---B---C  feature
     /
D---E---F  main

git merge main, eseguito da feature, crea un nuovo commit con due genitori: la punta di feature e la punta di main. Niente cambia in A, B o C. Stessi hash, stessi genitori, stessa posizione nel grafo:

1
2
3
      A---B---C
     /         \
D---E---F-------M  feature (merge commit)

git rebase main, eseguito anch’esso da feature, fa l’opposto. Lascia main intatto e rigioca A, B e C uno alla volta sopra F, generando un nuovo commit per ciascuno — A’, B’, C’ — con hash e genitori nuovi:

1
2
3
D---E---F  main
         \
          A'---B'---C'  feature

Il risultato sembra esattamente come se avessi diramato da F fin dall’inizio e non ti fossi mai allontanato da main. Il vantaggio è tutto qui: una storia lineare, nessun commit di merge nel log. Il costo è che A, B e C smettono di esistere per Git. Al loro posto ci sono A’, B’ e C’, e chiunque avesse già scaricato A, B o C si ritrova con commit che non corrispondono più ai tuoi.

Cos’è un fast-forward merge e quando Git lo usa al posto del merge commit

Un merge ha bisogno di un commit di merge solo quando entrambi i branch si sono mossi. Se feature è partito da F e main non si è mosso da allora, unire feature a main non ha nulla da conciliare: Git sposta in avanti il puntatore di main fino a C e si ferma. Questo è un fast-forward, e lascia la stessa storia piatta che otterresti con un rebase:

1
2
3
4
5
6
Before:  D---E---F  main
                  \
                   A---B---C  feature

After (fast-forward merge of feature into main):
D---E---F---A---B---C  main

Git crea un commit di merge solo quando entrambe le parti hanno aggiunto commit che l’altra non ha. Alcuni team ne forzano uno comunque, così ogni branch di feature lascia un segno visibile in main nel punto in cui è stato integrato:

1
git merge --no-ff feature/checkout

Come ripulire i commit con il rebase interattivo

Il rebase può anche modificare la tua storia senza coinvolgere altri branch. git rebase -i apre gli ultimi N commit in un editor, una riga per ogni commit:

1
git rebase -i HEAD~3
1
2
3
pick a1b2c3d Add checkout validation
pick e4f5a6b fix typo
pick c7d8e9f WIP: still debugging

Cambia la parola all’inizio di una riga e Git applica l’azione corrispondente quando il rebase parte:

ComandoEffetto
pickMantiene il commit così com’è
rewordMantiene il commit, modifica il messaggio
squashLo unisce al commit precedente, combinando i due messaggi
fixupLo unisce al commit precedente, scartando questo messaggio
dropElimina il commit
editMette in pausa il rebase qui, per modificare il commit a mano

Trasforma quella lista in squash e4f5a6b fix typo e fixup c7d8e9f WIP: still debugging e ottieni un solo commit, Add checkout validation, invece di tre che hanno senso solo nell’ordine in cui li hai scritti. Fallo prima di aprire la pull request, non dopo che qualcuno ha già scaricato il tuo branch — lo squash riscrive l’hash di ogni commit successivo, esattamente come qualsiasi altro rebase.

Se sistemi i commit precedenti man mano che procedi, git commit --fixup <hash> seguito da git rebase -i --autosquash <base> sposta ogni fixup accanto al suo bersaglio e lo marca automaticamente, così non devi riordinare la lista a mano.

Come git pull --rebase mantiene il branch piatto

La stessa scelta si ripresenta ogni volta che fai il pull. Un git pull semplice, su un branch dove hai commit locali e il remoto è andato avanti, fa un fetch seguito da un merge. È così che finisci con un commit Merge branch 'main' into feature/checkout per nessun motivo se non una brutta coincidenza di tempistiche. git pull --rebase scarica gli stessi commit e rigioca i tuoi sopra di essi:

1
git pull --rebase origin main

Se hai modifiche non ancora committate, il rebase si rifiuta di partire. Aggiungi --autostash e Git le mette da parte, fa il rebase, poi le riapplica:

1
git pull --rebase --autostash origin main

Impostalo una volta sola invece di digitare il flag ogni volta, per singolo branch o globalmente:

1
2
git config branch.feature/checkout.rebase true
git config --global pull.rebase true

È sicuro perché riscrive solo i commit che esistono soltanto sul tuo branch locale. Nel momento in cui questo smette di essere vero, il rebase smette di essere sicuro.

La regola d’oro: non fare mai rebase di commit che qualcun altro ha già scaricato

Il rebase riscrive gli hash dei commit. Se fai il rebase di un branch i cui vecchi commit qualcun altro ha già in mano — li ha scaricati dal tuo branch, oppure è main, o comunque qualcosa con più di un collaboratore — la sua storia e la tua ora sono in disaccordo su cosa sia quel branch. Git non ha modo di sapere che il tuo A e il suo A’ sono la stessa modifica logica. Vede due commit non collegati tra loro, e il prossimo merge tra le due copie duplica tutto ciò che è divergente.

La regola che evita questo problema: fai il rebase solo di branch su cui nessun altro ha già costruito qualcosa. Il tuo branch di feature personale, prima di aver dato a qualcuno un hash su cui basarsi, è terreno libero. Un branch che hai già pushato e che i tuoi colleghi hanno già preso come base, main, qualsiasi branch condiviso di lunga durata: quelli vanno uniti con merge, mai riscritti con rebase.

Fare il rebase di un branch già pushato richiede un push forzato perché il remoto accetti la storia riscritta:

1
git push --force-with-lease origin feature/checkout

--force-with-lease rifiuta il push se il remoto ha commit che tu non hai ancora scaricato — esattamente la situazione in cui qualcuno ha costruito sulla vecchia storia e un --force semplice scarterebbe silenziosamente il suo lavoro. Mai il --force nudo, e solo su un branch di cui sei certo che sia tuo e basta.

Come recuperare da un rebase andato male con git reflog

Un rebase fatto sulla base sbagliata, o un rebase interattivo in cui hai eliminato il commit sbagliato, sembra irrecuperabile: i commit che avevi un minuto fa non compaiono più in git log. Non sono spariti. git reflog registra ogni posizione in cui HEAD ha puntato sulla tua macchina, rebase compresi:

1
git reflog
1
2
3
4
a1b2c3d HEAD@{0}: rebase (finish): returning to refs/heads/feature/checkout
9f0e1d2 HEAD@{1}: rebase (pick): Add checkout validation
7c8b9a0 HEAD@{2}: rebase (start): checkout main
c4d5e6f HEAD@{3}: commit: WIP: still debugging

HEAD@{3} è dove puntava il tuo branch subito prima che il rebase iniziasse. Fai il reset a quel punto e torni esattamente allo stato precedente, commit eliminati compresi:

1
git reset --hard HEAD@{3}

Le voci del reflog scadono dopo 90 giorni di default, o 30 giorni per i commit a cui nessun altro riferimento punta. È una rete di sicurezza per l’ultima ora andata male, non uno storage permanente. Ed è anche solo locale: non viene mai pushato, e non può annullare una riscrittura che qualcun altro ha già scaricato.

Lo stesso istinto — tornare a uno stato noto e funzionante dopo una modifica andata male — vale anche fuori dalla storia di Git. Un Deployment Kubernetes mantiene la propria storia dei rollout per lo stesso motivo, e kubectl rollout undo fa per un deploy sbagliato quello che git reset --hard su una voce del reflog fa per un rebase sbagliato.

Git rebase vs merge: quale usare e quando

SituazioneUsa
Portare il tuo branch locale, non ancora pushato, allo stesso livello di mainrebase (o pull --rebase)
Ripulire i tuoi commit prima di aprire una pull requestrebase -i
Integrare un branch di feature completato in mainmerge (con o senza --no-ff, secondo la convenzione del tuo team)
Il branch è condiviso, pushato, o è main stessomerge — mai rebase
Devi sapere esattamente quando una feature è arrivata su mainmerge --no-ff — il commit di merge lo segna
Vuoi una storia lineare senza commit di merge nel logrebase dal tuo lato, prima di aprire la pull request

La decisione riguarda davvero chi altro ha già visto quei commit. Solo tu? Rebase, e mantieni la storia pulita. Almeno un’altra persona? Merge. Un log un po’ più disordinato non ti costa nulla; una storia condivisa riscritta costa a chiunque l’abbia scaricata un pomeriggio intero.

Se il tuo team non ha ancora una convenzione, parti da qui: pull.rebase = true in locale, rebase -i per sistemare i tuoi commit prima di aprire la pull request, poi un merge semplice per portarli su main. Ottieni una storia pulita per ogni branch senza mai riscrivere un commit da cui dipende qualcun altro, CI compresa. Un workflow come Costruire e Pubblicare un’Immagine Docker con GitHub Actions etichetta ogni build con lo SHA del commit, e quegli SHA devono continuare a esistere dopo che la pipeline li ha usati. Imposta git config --global pull.rebase true oggi stesso: da solo elimina la maggior parte dei commit di merge accidentali dal tuo log.

Articoli correlati