天天看點

Git 如何優雅地回退代碼

僅作學術交流,如有侵權,請聯系删文

從接觸程式設計就開始使用 Git 進行代碼管理,先是自己玩 Github,又在工作中使用 Gitlab,雖然使用時間挺長,可是也隻進行一些常用操作,如推拉代碼、送出、合并等,更複雜的操作沒有使用過,看過的教程也逐漸淡忘了,有些對不起 Linus 大神。出來混總是要還的,前些天就遇到了 Git 裡一種十分糟心的場景,并為之前沒有深入了解 Git 指令付出了一下午時間的代價。先介紹一下這種場景,我們一個項目從 N 版本升到 A 版本時引入了另一項目的 jar 包,又陸續釋出了 B、C 版本,但在 C 版本後忽然發現了 A 版本引入的 jar 包有極大的性能問題,B、C 版本都是基于 A 版本釋出的,要修複 jar 包性能問題,等 jar 包再發版還得幾天,可此時線上又有緊急的 Bug 要修,于是就陷入了進退兩難的境地。最後決定先将代碼回退到 A 版本之前,再基于舊版本修複 Bug,也就開始了五個小時的受苦之路。

首先肯定的是 revert,<code>git revert commit_id</code> 能産生一個 與 commit_id 完全相反的送出,即 commit_id 裡是添加, revert 送出裡就是删除。但是使用 git log 檢視了送出記錄後,我就打消了這種想法,因為送出次數太多了,中途還有幾次從其他分支的 merge 操作。”利益于”我們不太幹淨的送出記錄,要完成從 C 版本到 N 版本的 revert,我需要倒序執行 revert 操作幾十次,如果其中順序錯了一次,最終結果可能就是不對的。另外我們知道我們在進行代碼 merge 時,也會把 merge 資訊産生一次新的送出,而 revert 這次 merge commit 時需要指定 m 參數,以指定 <code>mainline</code>這個 mainline 是主線,也是我們要保留代碼的主分支,從 feature 分支往 develop 分支合并,或由 develop 分支合并到 master 的送出還好确定,但 feature 分支互相合并時,我哪知道哪個是主線啊。是以 revert 的文案被廢棄了。 

然後就考慮 <code>reset</code> 了, reset 也能使代碼回到某次送出,但跟 revert 不同的是, reset 是将送出的 HEAD 指針指到某次送出,之後的送出記錄會消失,就像從沒有過這麼一次送出。但由于我們都在 feature 分支開發,我在 feature 分支上将代碼回退到某次送出後,将其合并到 develop 分支時卻被提示報錯。這是因為 feature 分支回退了送出後,在 git 的 workflow 裡,feature 分支是落後于 develop 分支的,而合并向 develop 分支,又需要和 develop 分支保持最新的同步,需要将 develop 分支的資料合并到 feature 分支上,而合并後,原來被 reset 的代碼又回來了。這個時候另一個可選項是在 master 分支上執行 reset,使用 <code>--hard</code> 選項完全抛棄這些舊代碼,reset 後再強制推到遠端。

但是還是有問題,首先,我們的 master 分支在 gitlab 裡是被保護的,不能使用 force push,畢竟風險挺大了,萬一有人 reset 到最開始的送出再強制 push 的話,雖然可以使用 <code>reflog</code> 恢複,但也是一番折騰。另外,reset 畢竟太野蠻,我們還是想能保留送出曆史,以後排查問題也可以參考。

隻好用搜尋引擎繼續搜尋,看到有人提出可以先使用 <code>rebase</code> 把多個送出合并成一個送出,再使用 revert 産生一次反送出,這種方法的思路非常清晰,把 revert 和 rebase 兩個指令搭配得很好,相當于使用 revert 回退的更新版。先說一下 rebase,rebase 是”變基”的意思,這裡的”基”,在我了解是指[多次] commit 形成的 git workflow,使用 rebase,我們可以改變這些曆史送出,修改 commit 資訊,将多個 commit 進行組合。介紹 rebase 的文檔有很多,我們直接來說用它來進行代碼回退的步驟。

首先,切出一個新分支 F,使用 git log 查詢一下<code>要回退到</code>的 commit 版本 N。

使用指令 <code>git rebase -i N</code>, -i 指定互動模式後,會打開 git rebase 編輯界面,形如:

這些 commit 自舊到新由上而下排列,我們隻需要在 commit_id 前添加操作指令即可。

在合并 commit 這個需求裡,我們可以選擇 <code>pick(p)</code> 最舊的 commit1,然後在後續的 commit_id 前添加 <code>squash(s)</code> 指令,将這些 commits 都合并到最舊的 commit1 上。

儲存 rebase 結果後,再編輯 commit 資訊,使這次 rebase 失效,git 會将之前的這些 commit 都删除,并将其更改合并為一個新的 commit5

如果出錯了,也可以使用 <code>git rebase --abort/--continue/--edit-todo</code><code> </code>對之前的編輯進行撤銷、繼續編輯。

這個時候,主分支上的送出記錄是 <code>older, commit1, commit2, commit3, commit4</code>而 F 分支上的送出記錄是 <code>older, commit5</code>,由于 F 分支的祖先節點是 older,明顯落後于主分支的 commit4,将 F 分支向主分支合并是不允許的

是以我們需要執行 <code>git merge master</code> 将主分支向 F 分支合并,合并後 git 會發現 commit1 到 commit4 送出的内容和 F 分支上 commit5 的修改内容是完全相同的,會自動進行合并,内容不變,但多了一個 commit5。

再在 F 分支上對 commit5 進行一次 revert 反送出,就實作了把 commit1 到 commit4 的送出全部回退。

這種方法的取巧之處在于巧妙地利用了 rebase 操作曆史送出的功能和 git 識别修改相同自動合并的特性,操作雖然複雜,但曆史送出保留得還算完整。rebase 這種修改曆史送出的功能非常實用,能夠很好地解決我們遇到的一個小功能送出了好多次才好使,而把 git 曆史弄得亂七八糟的問題,隻需要注意避免在多人同時開發的分支使用就行了。遺憾的是,當天我并沒有了解到 rebase 的這種思想,又由于試了幾個方法都不行太過于慌亂,在 rebase 完成後,向主分支合并被拒之後對這些方式的可行性産生了懷疑,又加上有同僚提出聽起來更可行的方式,就中斷了操作。 

這種更可行的方式就是對檔案操作,然後讓 git 來識别變更,具體是:

從主分支上切出一個跟主分支完全相同的分支 F。

從檔案管理系統複制項目檔案夾為 bak,在 bak 内使用 <code>git checkout N</code> 将代碼切到想要的曆史送出,這時候 git 會将 bak 内的檔案恢複到 N 狀态。

在從檔案管理系統内,将 bak 檔案夾下 <code>除了 .git</code> 檔案夾下的所有内容複制粘貼到原項目目錄下。git 會純從檔案級别識别到變更,然後更新工作區。

在原項目目錄下執行 <code>add 和 commit</code>,完成反送出。

這種方式的巧妙之處在于利用 git 本身對檔案的識别,不牽涉到對 workflow 操作。

最後終于靠着檔案操作方式成功完成了代碼回退,事後想來真是一把心酸淚。為了讓我的五個小時不白費,複盤一下當時的場景,學習并總結一下四種代碼回退的方式:

revert 适合需要回退的曆史送出不多,且無合并沖突的情景。

如果你可以向 master 強推代碼,且想讓 git log 裡不再出現被回退代碼的痕迹,可以使用 <code>git reset --hard + git push --force</code><code> </code>的方式。

如果你有些 geek,追求用”正規而正統”的方式來回退代碼,rebase + revert 滿足你的需求。

如果你不在乎是否優雅,想用最簡單,最直接的方式,檔案操作正合适。

git 真的是非常牛逼的代碼管理工具,入手簡單,三五個指令組合起來就足夠完成工作需求,又對 geeker 們非常友好,你想要的騷操作它都支援,學無止境啊。

Git 如何優雅地回退代碼