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:
| |
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:
| |
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:
| |
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:
| |
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:
| |
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:
| |
| |
Cambia la parola all’inizio di una riga e Git applica l’azione corrispondente quando il rebase parte:
| Comando | Effetto |
|---|---|
pick | Mantiene il commit così com’è |
reword | Mantiene il commit, modifica il messaggio |
squash | Lo unisce al commit precedente, combinando i due messaggi |
fixup | Lo unisce al commit precedente, scartando questo messaggio |
drop | Elimina il commit |
edit | Mette 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:
| |
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:
| |
Impostalo una volta sola invece di digitare il flag ogni volta, per singolo branch o globalmente:
| |
È 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:
| |
--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:
| |
| |
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:
| |
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
| Situazione | Usa |
|---|---|
Portare il tuo branch locale, non ancora pushato, allo stesso livello di main | rebase (o pull --rebase) |
| Ripulire i tuoi commit prima di aprire una pull request | rebase -i |
Integrare un branch di feature completato in main | merge (con o senza --no-ff, secondo la convenzione del tuo team) |
Il branch è condiviso, pushato, o è main stesso | merge — mai rebase |
Devi sapere esattamente quando una feature è arrivata su main | merge --no-ff — il commit di merge lo segna |
| Vuoi una storia lineare senza commit di merge nel log | rebase 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.