History sicher umschreiben
Jedes Umschreiben – commit --amend, reset, rebase, filter-repo – erzeugt neue Commits
und verschiebt einen Branch-Zeiger von den alten. Das ist harmlos, solange niemand anders die alten
Commits hat, und destruktiv, sobald jemand sie hat: Dessen Klon hat immer noch die alte History,
und beim nächsten Push oder Pull kommt sie als Duplikate oder Konflikte zurück. Die erste Frage
ist also nie „wie“, sondern „wer hat diese Commits noch?“
1. Entscheiden: umschreiben oder revertieren
git fetch --prune
git status --short --branch # ahead/behind und Arbeitsverzeichnisstatus
git branch -r --contains <commit> # welche Remote-Branches enthalten ihn bereits
git log --oneline @{u}..HEAD # Commits, die nur lokal existieren
| Wo der Commit lebt | Mach dies |
|---|---|
| Nur lokal, nie gepusht | Umschreiben ohne Einschränkung. |
| In einen Branch gepusht, den nur dieser Benutzer bearbeitet (sein PR-Branch) | Umschreiben, dann mit Lease pushen (Schritt 5). |
Auf einem gemeinsamen oder geschützten Branch: main, master, develop, release/* oder mit anderen Commits darauf aufbauend | Nicht umschreiben. git revert verwenden. |
| Bereits released oder deployed | git revert. Ein Umschreiben kann es nicht rückgängig machen. |
Wenn der Benutzer sagt „Entferne diesen Commit aus main“, möchte er normalerweise seine Auswirkung
loswerden, nicht seinen SHA. git revert <sha> macht das, hält die History ehrlich und benötigt nur einen
normalen Push. Für einen Merge-Commit: git revert -m 1 <merge-sha>. Sage, warum du revert gewählt hast,
und wenn der Benutzer darauf besteht, den Commit aus einem gemeinsamen Branch zu löschen, nenne die
Betroffenen und hole dir vorher ein explizites Ja.
Ein Revert erzeugt Konflikte, wenn spätere Commits dieselben Zeilen bearbeitet haben – genau der Fall,
wenn Commits auf dem fehlerhaften aufgebaut wurden. Löse sie wie jeden Konflikt (siehe
git-conflict-resolution): Entferne die schlechte Änderung, behalte die spätere Arbeit, dann
git revert --continue.
Eine Revert-Falle: Nach dem Revertieren eines Merges bringt ein erneuter Merge desselben Branches nichts zurück, weil Git denkt, diese Commits seien bereits gelandet. Um die Arbeit erneut zu integrieren, revertiere zuerst den Revert.
2. Einen Wiederherstellungspunkt erstellen, bevor etwas angefasst wird
git rev-parse HEAD # notieren
git branch backup/<branch>-pre-rewrite # ein echter Ref, nicht nur das Reflog
Das Reflog zeichnet auch auf, wo der Branch war, aber es ist lokal zu diesem Klon, es läuft ab
(90 Tage für erreichbare Einträge, 30 für unerreichbare standardmäßig), und git gc nach
reflog expire beendet es. Ein benannter Backup-Branch kostet nichts. Lasse ihn so lange liegen,
bis der Benutzer das Ergebnis bestätigt hat.
3. Den am wenigsten destruktiven Befehl wählen
| Ziel | Befehl | Hinweise |
|---|---|---|
| Letzten Commit korrigieren (Nachricht oder vergessene Datei) | git commit --amend | Mit -m "..." oder --no-edit wird kein Editor geöffnet. |
| Commit rückgängig machen, Änderungen bleiben staged | git reset --soft HEAD~1 | |
| Commit rückgängig machen und unstage, Dateien behalten | git reset --mixed HEAD~1 (Standard) | |
| Branch zurücksetzen, lokale Änderungen nicht verlieren | git reset --keep <commit> | Bricht ab, wenn es uncommittete Änderungen überschreiben würde. Bevorzuge dies gegenüber --hard. |
| Auch uncommittete Arbeit verwerfen | git reset --hard | Uncommittete Arbeit ist nicht im Reflog und kann nicht wiederhergestellt werden. Nur nach Bestätigung, dass nichts davon erwünscht ist; wenn unsicher, vorher git stash -u. |
| Änderungen an einer Datei verwerfen | git restore <datei> | |
| Fixups in frühere Commits squashen | git commit --fixup=<sha> dann autosquash | Siehe unten. |
| Commits löschen oder neu anordnen | rebase -i mit bereitgestelltem Todo | Siehe unten. |
Ein gescheiterter Pre-Commit-Hook bedeutet, dass kein Commit erstellt wurde. Wenn du dann git commit --amend
ausführen, um es „erneut zu versuchen“, veränderst du den vorherigen Commit – die neuen Änderungen landen
im falschen. Nach einem Hook-Fehler: Problem beheben, erneut stage und einen normalen neuen Commit erstellen.
Interaktives Rebase ohne interaktiven Editor
git rebase -i öffnet einen Editor, der in einer nicht-interaktiven Shell hängen bleibt. Verlasse dich nicht darauf.
Zwei Wege, die funktionieren:
# Fixup-Commits in ihre Ziele folden – akzeptiere das von Git generierte Todo:
git commit --fixup=<target-sha>
GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash <basis>
# Commits löschen oder neu anordnen: schreibe das Todo selbst.
GIT_SEQUENCE_EDITOR="sed -i '/^pick <short-sha> /d'" git rebase -i <basis>
<basis> ist der Commit vor dem ersten, den du ändern willst. Wenn ein Rebase bei einem
Konflikt stoppt, löse ihn, git add, dann GIT_EDITOR=true git rebase --continue. Wenn etwas
schiefläuft, bringt git rebase --abort dich an den Ausgangspunkt zurück.
4. Überprüfen, dass das Umschreiben das Gewünschte bewirkt hat
Eine Nachricht bearbeiten, neu anordnen oder squash sollte den finalen Tree identisch lassen:
git diff backup/<branch>-pre-rewrite HEAD --stat # leer = gleicher Inhalt
git range-diff backup/<branch>-pre-rewrite...HEAD # Commit-für-Commit-Vergleich
Wenn du beabsichtigt hast, Inhalt zu entfernen, sollte der Diff genau diesen Inhalt und nichts anderes zeigen. Alles Unerwartete bedeutet: vom Backup wiederherstellen und neu machen.
5. Veröffentlichen – nur mit Zustimmung des Benutzers
Das Pushen eines Umschreibens ist der nach außen sichtbare, schwer rückgängig zu machende Schritt. Bestätige zuerst, es sei denn, der Benutzer hat bereits nach dem Push gefragt.
git push --force-with-lease=<branch>:<erwarteter-remote-sha> --force-if-includes origin <branch>
- Nie bare
--force. Es überschreibt alles auf dem Remote, einschließlich eines Teamkollegen-Pushes, den du nicht gesehen hast. - Nenne den erwarteten SHA. Ein einfaches
--force-with-leasevergleicht mit deinem Remote-Tracking-Ref, und eingit fetchim Hintergrund einer IDE aktualisiert diesen Ref leise, sodass der Lease dann nichts schützt. Verwende den SHA aus Schritt 1 (git rev-parse origin/<branch>). --force-if-includes(Git 2.30+) verweigert zusätzlich, wenn der Remote auf etwas verschoben wurde, das dein lokaler Branch nie integriert hat.- Nie
main/master/develop/release/*force-pushen. Wenn eine Hosting-Regel dies blockiert, ist diese Regel korrekt; umgehe sie nicht.
Nach dem Push teile dem Benutzer mit, dass jeder, der den alten Branch ausgecheckt hat, auf den neuen zurücksetzen (reset) und nicht pullen muss, und dass Review-Kommentare in einem offenen PR ihre Anker verlieren können.
Schutzmaßnahmen
- Signierte Commits verlieren ihre Signatur beim Umschreiben; mit
-Sneu signieren oder den Benutzer informieren. - Schreibe keine Commits um, die von einer anderen Person autor sind ohne zu fragen, selbst in einem Feature-Branch.
- Gestapelte Branches oberhalb des umgeschriebenen sind nun veraltet. Verwende
git-stacked-pr-rebase, um sie neu zu stapeln, statt sie einzeln zu rebasen. - Ein committedes Secret wird durch ein Umschreiben nicht behoben. Rotiere zuerst den Credential; gehe davon aus, dass es kompromittiert ist.
- Berichte klar: Was der alte Tip war, was der neue ist, wo der Backup-Ref liegt und ob etwas ungepusht ist. Wenn Revert die richtige Wahl war, sage, dass du das getan hast.