安全地重寫歷史記錄
每一次重寫——commit --amend、reset、rebase、filter-repo——都會產生新的提交,並將分支指標從舊的提交上移開。在沒有其他人擁有舊提交的情況下,這是無害的;但一旦有人擁有,就會造成破壞:他們的複本仍保有舊的歷史記錄,下次他們推送或拉取時,會以重複或衝突的形式再次出現。因此,第一個問題永遠不是「如何做」,而是「誰還有這些提交?」
1. 決定:重寫還是還原
git fetch --prune
git status --short --branch # ahead/behind 狀態,以及工作目錄是否髒污
git branch -r --contains <commit> # 哪些遠端分支已經擁有它
git log --oneline @{u}..HEAD # 僅存在於本機的提交
| 提交所在位置 | 執行此操作 |
|---|---|
| 僅限本機,從未推送過 | 自由重寫。 |
| 已推送到只有此使用者工作的分支(他們的 PR 分支) | 重寫,然後使用 lease 推送(步驟 5)。 |
在共用或受保護的分支上:main、master、develop、release/*,或任何上面有其他人提交的分支 | 請勿重寫。 請使用 git revert。 |
| 已發佈或部署 | git revert。重寫無法將其取消部署。 |
當使用者說「從 main 分支中移除那個提交」時,他們通常希望的是移除其影響,而不是它的 SHA。git revert <sha> 能做到這一點,保持歷史記錄的真實性,並且只需要一個普通的推送。對於合併提交,請使用 git revert -m 1 <merge-sha>。說明您為何選擇還原,如果使用者堅持要從共享分支中刪除它,請指出會影響到誰,並先獲得明確的同意。
當後續的提交編輯了相同的行時,還原會發生衝突——這正是提交建立在有問題的提交之上的情況。像處理任何衝突一樣解決它(參閱 git-conflict-resolution):移除錯誤的變更,保留後續的工作,然後執行 git revert --continue。
一個還原陷阱:在還原了合併之後,再次合併同一個分支不會帶回任何東西,因為 Git 認為那些提交已經進來了。若要重新套用這些工作,請先還原該還原。
2. 在接觸任何東西之前先建立復原點
git rev-parse HEAD # 記下它
git branch backup/<branch>-pre-rewrite # 一個真正的參考,而不僅僅是 reflog
Reflog 也會記錄分支所在的位置,但它僅限於此複本,它會過期(可達項目預設 90 天,不可達項目預設 30 天),並且 reflog expire 後的 git gc 會終結它。一個命名的備份分支幾乎不花費任何成本。在使用者確認結果之前,請保留它。
3. 選擇破壞性最小的指令
| 目標 | 指令 | 備註 |
|---|---|---|
| 修正最後一個提交的訊息或新增遺漏的檔案 | git commit --amend | 使用 -m "..." 或 --no-edit 以避免開啟編輯器。 |
| 取消提交但保留暫存中的變更 | git reset --soft HEAD~1 | |
| 取消提交並取消暫存,保留檔案 | git reset --mixed HEAD~1(預設值) | |
| 將分支向後移動,拒絕遺失本機編輯內容 | git reset --keep <commit> | 如果會覆蓋未提交的變更則中止。優先於 --hard 使用此選項。 |
| 同時丟棄未提交的工作 | git reset --hard | 未提交的工作不在 reflog 中,無法復原。 僅在確認那裡沒有任何想要的東西之後才使用;如果不確定,請先使用 git stash -u。 |
| 丟棄單一檔案的變更 | git restore <file> | |
| 將修正壓縮或折疊到較早的提交中 | git commit --fixup=<sha> 然後 autosquash | 見下文。 |
| 刪除或重新排序提交 | rebase -i 並提供預先寫好的 todo | 見下文。 |
失敗的 pre-commit hook 表示沒有提交被建立。 執行 git commit --amend 來「重試」會修改前一個提交,將新變更折疊到錯誤的提交中。Hook 失敗後,請修正問題,重新暫存,然後進行一個正常的新提交。
無需互動式編輯器的互動式 rebase
git rebase -i 會開啟一個編輯器,這會在非互動式 shell 中停滯。請不要依賴它。有兩種可行的方法:
# 將修正提交折疊到它們的目標中 - 接受 Git 產生的 todo:
git commit --fixup=<target-sha>
GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash <base>
# 刪除或重新排序:自己寫 todo。
GIT_SEQUENCE_EDITOR="sed -i '/^pick <short-sha> /d'" git rebase -i <base>
<base> 是您要變更的第一個提交之前的提交。如果 rebase 因衝突而停止,請解決衝突,執行 git add,然後執行 GIT_EDITOR=true git rebase --continue。如果出錯,執行 git rebase --abort 會回到開始的地方。
4. 驗證重寫是否如您所想的那樣變更了內容
訊息編輯、重新排序或壓縮應使最終的樹狀結構保持一致:
git diff backup/<branch>-pre-rewrite HEAD --stat # 空白 = 內容相同
git range-diff backup/<branch>-pre-rewrite...HEAD # 逐個提交比較
如果您本來就打算刪除內容,diff 應僅顯示該內容,別無其他。任何意外內容都表示需要從備份還原並重做。
5. 發佈——僅在使用者同意後
推送重寫是面向外部的、難以逆轉的步驟。先確認,除非使用者已經要求推送。
git push --force-with-lease=<branch>:<expected-remote-sha> --force-if-includes origin <branch>
- 永遠不要使用單純的
--force。 它會覆蓋遠端上的任何內容,包括您尚未看到的團隊成員的推送。 - 指定預期的 SHA。 單純的
--force-with-lease會根據您的遠端追蹤參考進行比較,而 IDE 的背景git fetch會悄悄地更新該參考,因此 lease 隨後無法提供任何保護。請使用您在步驟 1 中看到的 SHA(git rev-parse origin/<branch>)。 --force-if-includes(Git 2.30+)會在遠端移動到您的本機分支從未整合過的內容時,額外拒絕推送。- 永遠不要強制推送
main/master/develop/release/*。 如果代管平台的規則阻止了它,該規則是正確的;請不要規避它。
推送後,告訴使用者任何已簽出舊分支的人都必須重置到新分支,而不是拉取,並且開啟中的 PR 的審查評論可能會失去它們的錨點。
防護措施
- 已簽署的提交在重寫時會失去其簽章;使用
-S重新簽署,或告知使用者。 - 未經詢問,請勿重寫由他人撰寫的提交,即使是在功能分支上。
- 位於被重寫分支之上的堆疊分支現在已過時。 使用
git-stacked-pr-rebase來重新堆疊它們,而不是手動逐一 rebase。 - 已提交的祕密不會因重寫而修復。 首先輪換憑證;假設它已洩露。
- 清楚地報告:舊的頂點是什麼,新的頂點是什麼,備份參考在哪裡,以及是否有任何東西尚未推送。如果還原是正確的選擇,請說明那就是您所做的。