Git Cherry Pick 的後遺症一文聊過使用 Git 做上線合併可能遇到的困擾。若直接用 master 當開發主軸,開發者平日陸續提交 Commit,等要上線時就必須從交錯的 Commit 中 Cherry-Pick 挑出該次要上的項目拉進上線分支(例如: release)。若所有修改都要上線可改用合併,操作上比 Cherry-Pick 簡便,但做過 Cherry-Pick 後再合併則有 Commit 重複出現的後遺症。文章最後提到一些克服問題的策略,其中關鍵是把 master 的角色升級到準上線層次,要求開發人員另開 Branch 進行修改或擴充,經過測試核可(所以通常還有一條 test 分支),確認要納入正式版本再合併入 master。

這篇文章將實際演練這種「一律拉開發分支,要上線才進 master」的策略,說明注意事項並體會它的好處。在 作者(Auhtor)與提交者(Commmitter)差異實驗玩過一人分飾兩角的把戲,開兩個資料夾,模擬兩個開發者同時開發,並建一個 upstream Bare Repository 當作同步來源,演練二者分別修改、穿插 Commit 並合併進 master 及 release 的情境。這回也會沿用這個方案做實驗,假設的情境是有兩位開發者 Jeffrey 及 darkthread,各拿到一份規格(ReqA 及 ReqB)同時開發,兩人開發及更新進度如下:

  1. Jeffrey 完成 ReqA Commit 1
  2. darkthread 完成 ReqB Commit 1
  3. Jeffrey 完成 ReqA Commit 2
  4. darkthread 完成 ReqB Commit 2
  5. darkthread 上線 ReqB
  6. Jeffrey 上線 ReqA

第一種策略是直接用 master 開發,寫好的東西直接提交到 master,因此 master 的 Commit 順序如下:

由於 master 的 Commit 交錯,ReqB 先上線時 ReqA 的修改還沒有要上,故整併至 release 時只能靠 Cherry-Pick 挑選,Cherry-Pick 不會產生合併線,不易看出該次上線的 Commit 項目有哪些,但可用 Tag 註記(另一種做法是 git commit -m "標示用的空 Commit" --allow-empty)。以下是演練批次檔,請存成 .bat 執行。(若要複製指令手動執行,%% 要改成 %)

rem 請先建好實驗資料夾,並修改以下變數
set lab_folder=D:\GitTest
rem 切換到實驗資料夾,清空前次 Git 測試結果
cd /d %lab_folder% 
if exist %lab_folder%\upstream (rmdir /s /q %lab_folder%\upstream)
if exist %lab_folder%\jeffrey (rmdir /s /q %lab_folder%\jeffrey)
if exist %lab_folder%\darkthread (rmdir /s /q %lab_folder%\darkthread)
rem 建立 upstream Git Repository
mkdir %lab_folder%\upstream && cd %lab_folder%\upstream
git init --bare
rem 模擬 jeffrey 及 darkthread clone 建立本機 Repository 
cd %lab_folder%
git clone ./upstream ./jeffrey
git clone ./upstream ./darkthread
rem jeffrey,建立 master, release 並 push
cd %lab_folder%/jeffrey
git config user.name "Jeffrey"
git config user.email "jeffrey@mail.net"
git commit -m "初始化" --allow-empty
git push
git checkout -b release
git push --set-upstream origin release
git checkout master
rem darkthread,pull 取得 master, release
cd %lab_folder%\darkthread
git config user.name "darkthread"
git config user.email "darkthread@mail.net"
git pull
git fetch origin release
rem jeffrey,修改並 push
cd %lab_folder%\jeffrey
touch reqA-1.txt
git add . && git commit -m "ReqA Commit 1" && git push
ping localhost -n 2 > NUL
rem darkthread,修改並 push
cd %lab_folder%\darkthread
git pull --rebase
touch reqB-1.txt
git add . && git commit -m "ReqB Commit 1" && git push
ping localhost -n 2 > NUL
rem jeffrey,修改並 push
cd %lab_folder%\jeffrey
git pull --rebase
touch reqA-2.txt
git add . && git commit -m "ReqA Commit 2" && git push
ping localhost -n 2 > NUL
rem darkthread,修改並 push
cd %lab_folder%\darkthread
git pull --rebase
touch reqB-2.txt
git add . && git commit -m "ReqB Commit 2" && git push
ping localhost -n 2 > NUL
rem darkthread, 使用 Cherry-Pick 上線到 release
for /f %%i in ('git rev-parse HEAD') do set commit2=%%i
for /f %%i in ('git rev-parse HEAD~2') do set commit1=%%i
git checkout release
git pull --rebase
git cherry-pick %commit1%
git cherry-pick %commit2%
git push
git tag -a "ReqB" -m "ReqB 上線"
git push --tags
rem jeffrey,使用 Cherry-Pick 上線到 release
cd %lab_folder%\jeffrey
git pull --rebase 
for /f %%i in ('git rev-parse HEAD~1') do set commit2=%%i
for /f %%i in ('git rev-parse HEAD~3') do set commit1=%%i
git checkout release
git pull --rebase
git cherry-pick %commit1%
git cherry-pick %commit2%
git push
git tag -a "ReqA" -m "ReqA 上線"
git push --tags
rem 檢視歷史
git log --graph --pretty="%%h (%%aN %%ai) %%s"

最後 release 分支的 Commit 如下:

如先前所提, Cherry-Pick 缺點是需要逐一挑選 Commit,程序繁瑣較易出錯,且除了 Tag 外,看不出合併線。而在 master 上 ReqA 跟 ReqB 修改的 Commit 互相穿插,多個需求或修改平行開發時異動記錄比較雜亂。

接著來看另一種分支策略:

  1. 所有開發工作一律另開開發分支(feature/xxx 或 fix/yyyy),測試 OK 確認要納入正式版本才使用合併進 master
    註:實務上多半還需要一條 test 分支,上測試環境前合併進 test 以編譯測試台版本
  2. 開發分支合併進 master 需先 Rebase,並採用 --no-ff 產生明確合併線(俗稱小耳朵)
  3. 針對特定需求或修改拉出的開發分支,合併進 master 即功成身退,可以刪除

這裡說一下開發分支合併回 master 前先對 master 做 Rebase 的用意。

假設 feature/reqA 是從 master 分出來,而 reqA 開發期間有其他開發者已先將 reqB 與 reqC 的修改合併進 master,當 feature/reqA 要合併上線時,可以選擇直接合併,也可以選擇先對 master 做 Rebase 再合併,雖然最後的程式碼狀態相同,但二者的線圖差很多:

圖片上方的線條交錯版是直接合併的結果;下方則是先 Rebase 再合併的線圖,每次上線合併呈現一個獨立小耳朵相鄰排列,而該次合併的所有 Commit 全集中在小耳朵支線。相信不用我說,大家也看得出來哪一種表現方式比較清楚明瞭。但做出美美的線圖需要額外代價,執行 Rebase 因重新產生 Commit,若程式被其他人改過,最麻煩的情況是每個 Commit 都要排除一次更新衝突(直接合併的話只需解決衝突一次)。故有兩種 Rebase 策略,一種平常先無視可能的衝突,合併前再 Rebase 一次解決,風險是累積 N 個 Commit 受影響要排除衝突 N 次;另一種策略是開發分支平時就三不五時對 master 做 Rebase 將 master 的更新整合進來(概念就像在 master 開發時經常 git pull --rebase 取回最新版本),早期發現早期治療(若解決衝突時動到共用邏輯,還必須經過測試驗證),避免 Commit 包含過時版本產生衝突,上線合併前 Rebase 會比較輕鬆(時間壓力加上累積的衝突數量讓人想哭)。關於直接合併與 Rebase 再合併的比較,未來再找機會探討。

如果你選擇開發分支經常對 master 做 Rebase 策略,則有個衍生議題:如果開發分支為特定開發者專用不需跟其他人分享,需不需要 git push 推上伺服器(Github、TFS)?送上伺服器開發分支可視為遠端備份,避免個人電腦故障進度歸零的風險。但如果平時經常對 master 做 Rebase 策略,會出現一個狀況 - 每次 Rebase 後必須改用 Force Push 更新。而 Force Push 是危險動作,原則上對 master、release 等分支要禁止,只允許在開發分支執行,這有賴 Git 伺服器,Github 可依分支名稱規則設定 Force Branch 及刪除保護,TFS 也有類似的分支權限設定,但實際執行時需要額外花心思設計及管理。

以下是實踐「開發分支上線前 Rebase 再合併」策略的演練批次指令,跟前面展示的 ReqA、ReqB Commit 順序相同,但開發者需另開 feature/reqA、feature/reqB,上線前才 Merge 進 master,期間則定期對 master 做 Rebase。合併進 master 前先做 Rebase 並加 --no-ff 參數做出小耳朵。在這種策略下,release 跟 master 幾乎一致,差別在 release 精準反映線上環境版本,會在對線上環境直接做緊急修補時與 master 有出入。

rem 請先建好實驗資料夾,並修改以下變數
set lab_folder=D:\GitTest
rem 切換到實驗資料夾,清空前次 Git 測試結果
cd /d %lab_folder% 
if exist %lab_folder%\upstream (rmdir /s /q %lab_folder%\upstream)
if exist %lab_folder%\jeffrey (rmdir /s /q %lab_folder%\jeffrey)
if exist %lab_folder%\darkthread (rmdir /s /q %lab_folder%\darkthread)
rem 建立 upstream Git Repository
mkdir %lab_folder%\upstream && cd %lab_folder%\upstream
git init --bare
rem 模擬 jeffrey 及 darkthread clone 建立本機 Repository 
cd %lab_folder%
git clone ./upstream ./jeffrey
git clone ./upstream ./darkthread
rem jeffrey,建立 master, release 並 push
cd %lab_folder%\jeffrey
git config user.name "Jeffrey"
git config user.email "jeffrey@mail.net"
git commit -m "初始化" --allow-empty
git push
git checkout -b release
git push --set-upstream origin release
git checkout master
rem darkthread,pull 取得 master, release
cd %lab_folder%\darkthread
git config user.name "darkthread"
git config user.email "darkthread@mail.net"
git pull
git fetch origin release
rem jeffrey,修改前先建立開發分支,進行修改
cd %lab_folder%\jeffrey
rem ** Step 1 **
git checkout -b feature/reqA
touch reqA-1.txt
rem ** Step 2 **
git add . && git commit -m "ReqA Commit 1" && git push
ping localhost -n 2 > NUL
rem darkthread,建立開發分支,進行修改
cd %lab_folder%\darkthread
rem ** Step 3 **
git checkout -b feature/reqB
touch reqB-1.txt
rem ** Step 4 **
git add . && git commit -m "ReqB Commit 1" && git push
ping localhost -n 2 > NUL
rem jeffrey,繼續修改
cd %lab_folder%\jeffrey
touch reqA-2.txt
rem ** Step 5 **
git add . && git commit -m "ReqA Commit 2" && git push
ping localhost -n 2 > NUL
rem darkthread,繼續修改
cd %lab_folder%\darkthread
touch reqB-2.txt
rem ** Step 6 **
git add . && git commit -m "ReqB Commit 2" && git push
ping localhost -n 2 > NUL
rem darkthread 合併進 master
git checkout master
rem ** Step 7 **
git merge feature/reqB --no-ff -m "Merge ReqB feature"
git push
rem darkthread 合併至 release
git checkout release && git pull --rebase
git merge master
git push
git tag -a "ReqB" -m "ReqB 上線"
git push --tags
rem jeffrey,更新 master,feature/reqA 對 master Rebase 再合併進 master
cd %lab_folder%\jeffrey
git checkout master
rem ** Step 8 **
git pull --rebase
git checkout feature/reqA
rem ** Step 9 **
git rebase master
git checkout master
rem ** Step 10 **
git merge feature/reqA --no-ff -m "Merge ReqA feature"
git push
rem jeffrey, master 合併至 release
git checkout release && git pull --rebase
git merge master
git push
git tag -a "ReqA" -m "ReqA 上線"
git push --tags
rem 檢視歷史
git log --graph --pretty="%%h (%%aN %%ai) %%s"

因為過程稍複雜,整理兩位開發者的操作歷程方便理解:(對映批次檔中的 ** Step n ** 註記)

  1. Jeffrey 建立 feature/reqA
  2. Jeffrey 完成 ReqA Commit 1 @ feature/reqA
  3. darkthread 建立 feature/reqB
  4. darkthread 完成 ReqB Commit 1 @ feature/reqB
  5. Jeffrey 完成 ReqA Commit 2 @ feature/reqA
  6. darkthread 完成 ReqB Commit 2 @ feature/reqB
  7. darkthread 將 feature/reqB 合併進 master 並 push
  8. Jeffrey pull --rebase 更新 master
  9. Jeffrey feature/reqA 對 master 做 Rebase
  10. Jeffrey 將 feature/reqA 合併進 master

在這種策略下的 master/release 線形如下,優點是每次上線併入的 Commit 集中於一條分支線緊密排列,線條交會處即為合併點清楚分明,程式異動清單的比較基準也很好找。至於代價呢?要多開 Branch,Rebase 排除衝突的成本較高,開發分支 Rebase 後 Push 回遠端程序較複雜。

最後補充一個訣竅,開發分支用完即拋,其 Commit 在併入 master 前都可任意自由調整,Git Rebase 指令超級強大,允許你合併、拆解、取消 Commit,修改訊息,甚至調換 Commit 順序(實務上很少有這種需求就是了),可將 Commit 整理得漂漂亮亮再放進正式版本,是龜毛講究細節追求完美的人夢寐以求的美妙功能(但很吃操作技能)。關於 Rebase 的花式操作,推薦 ihower 這篇錄影示範

Explaining how to use developer branch to make merge job to master easier and clearly.


Comments

Be the first to post a comment

Post a comment