導讀
在上一篇文章[前端漫談]一巴掌拍平Git中的各種概念中,描述了 Git 的一些概念,但是太過虛化,描述的都是一些概念和命令。這篇文章結合實際場景,主要描述我在專案實踐中使用 Git
管理專案、團隊協作的一些經驗。包括 1)merge
和 rebase
使用的區別和選擇;2)多人團隊合作開發流程;3)標準化 commit message
;4)commit
精細化管理等。這些都是為專案的健壯發展和程式碼的精細管理所流的淚累積出來的。
0x000 前言
由上一片文章[前端漫談]一巴掌拍平Git中的各種概念中,可以知道,Git
世界就像一個 宇宙,每一個 commit 都是一顆星球,而 commitId
就是星球的座標,branch
是一條條的航線,穿過無數的 星球,tag
是航線上重要的星球,可能是供給站,可能是商業中心,而 HEAD
則是探索號飛船,不斷向前探索。中間可能會有岔道,但是永遠有一個真正的方向等待勇敢的船長。
0x001 merge
還是 rebase
merge
還是 rebase
,這是經久不衰的討論點。但是這裡我不去爭論孰優孰略,我只說我在不同場景的實踐。
1. merge
我通常使用 merge
來將多個分支合併到當前分支,比如要釋出的時候,將多個功能分支合併到帶釋出分支:
已知:feat/A
、feat/B
、feat/C
,是從主分支新建的功能分支,feat/B
和feat/C
都修改了檔案1
。
- 新建待發布分支:
# 從主分支新建分支 pub/191205 $ git checkout -b pub/191205 Switched to a new branch 'pub/191205' 複製程式碼
- 合併
feat/A
到pub/191205
:$ git merge feat/A Updating 53ab8fd..e443dd4 Fast-forward featA | 1 + 1 file changed, 1 insertion(+) create mode 100644 featA 複製程式碼
pub/191205
和 feat/A
都是從主分支新建,所以 pub/191205
指向的 commit
是 feat/A
的祖先,當把 feat/A
合併到pub/191205
的時候,會發生快速合併(Fast-forward)。不會新建一個合併節點(當然也可以通過--no-ff(no-fast-forward)
來強制生成一個節點):
# 檢視 log
$ git log --oneline
e443dd4 (HEAD -> pub/191205, feat/A) feat: a
53ab8fd (master) chore: first commit
複製程式碼
- 合併
feat/B
到pub/191205
$ git merge feat/B # 進入 vim 填寫合併資訊 Merge made by the 'recursive' strategy. 1 | 2 +- featB | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 featB 複製程式碼
feat/B
是從主分支新建的分支,pub/191205
原本指向的也是feat/B
的祖先,但是因為已經和feat/A
合併了,所以pub/191205
不再是feat/B
的祖先。因此,pub/191205
和feat/B
的合併不再是快速合併(Fast-forward),而是Merge made by the 'recursive' strategy.
。會產生一個新的節點:
$ git log --oneline
5d0ee9b (HEAD -> pub/191205) Merge branch 'feat/B' into pub/191205
d7773d6 (feat/B) feat: b
e443dd4 (feat/A) feat: a
53ab8fd (master) chore: first commit
複製程式碼
- 合併
feat/C
到pub/191205
$ git merge feat/C Auto-merging 1 CONFLICT (content): Merge conflict in 1 Automatic merge failed; fix conflicts and then commit the result. 複製程式碼
feat/C
和fix/B
修改了相同檔案,所以產生衝突,因此,會提示解決衝突。這時候檢視狀態,可以發現,處於you have unmerged paths
狀態:
```
$ git status
On branch pub/191205
You have unmerged paths.
(fix conflicts and run "git commit")
(use "git merge --abort" to abort the merge)
Changes to be committed:
new file: featC
Unmerged paths:
(use "git add <file>..." to mark resolution)
both modified: 1
```
複製程式碼
這時候可以執行git merge --abort
放棄繼續合併,恢復合併之前的狀態。也可以解決衝突之後,執行git merge
。這裡選擇解決衝突:
# 解決衝突 $ git commit # 進入 vim 編寫 message [pub/191205 98d63aa] Merge branch 'feat/C' into pub/191205
feat/C
是從主分支新建的分支,pub/191205
原本指向的也是feat/C
的祖先,但是因為已經和feat/A
、feat/B
合併了,所以pub/191205
不再是feat/C
的祖先。因此,pub/191205
和feat/C
的合併不再是快速合併(Fast-forward),會產生一個新的節點:
$ git log --oneline 98d63aa (HEAD -> pub/191205) Merge branch 'feat/C' into pub/191205 5d0ee9b Merge branch 'feat/B' into pub/191205 d7773d6 (feat/B) feat: b 52dd922 (feat/C) feat: c e443dd4 (feat/A) feat: a 53ab8fd (master) chore: first commit
歷史如下:
2. rebase
注:rebase
的功能很強大,這裡先介紹和 merge
相對應的功能。
我通常用它來和主分支同步,比如一個新版本釋出,主分支比我當前的功能分支超前,我使用rebase
將當前分支和主分支“合併(變基)”。
已知:feat/A
、feat/B
是從主分支新建,feat/A
開發完成之後合併到主分支。feat/B
繼續開發,需要將master
的功能合併到當前分支上,使用merge
可以這麼做:
- 切換到 feat/B
$ git switch feat/B Switched to branch 'feat/B' 複製程式碼
- 將 master 合併到 feat/B
$ git merge master # 進入 vim 編寫 message Merge made by the 'recursive' strategy. featA | 1 + 1 file changed, 1 insertion(+) create mode 100644 featA 複製程式碼
- 檢視狀態
$ git log b4f178e (HEAD -> feat/B) Merge branch 'master' into feat/B d7773d6 feat: b e443dd4 (pub/191205, master, feat/A) feat: a 53ab8fd chore: first commit 複製程式碼
因為master
合併了feat/A
,因此不再是feat/B
的祖先節點,不會進行快速合併(Fast-forward),會產生一個新的節點。歷史如下
這麼做是可以,但是我不喜歡這個合併產生的節點,所以我選擇使用rebase
:
- 恢復到合併
feat/B
之前$ git reset e443dd4 --hard HEAD is now at e443dd4 feat: a 複製程式碼
- 使用
rebase
“合併(變基)”master
$ git rebase master git rebase master First, rewinding head to replay your work on top of it... Applying: feat: b 複製程式碼
- 檢視歷史:
$ git log --oneline ef3450c (HEAD -> feat/B) feat: b e443dd4 (pub/191205, master, feat/A) feat: a 53ab8fd chore: first commit 複製程式碼
可以發現沒有新的節點產生,但是rebase
的操作過程並不只是不產生一個合併節點而已,它的中文翻譯是變基
,聽起來很 Gay 的樣子。但它的意思是“改變基礎”。那改變的是什麼基礎呢?就是這個分支checkout
出來的commit
,原本feat/B
是從master
中checkout
出來的,但是使用git rebase master
之後,就會以master
最新的節點作為feat/B
分支的基礎。就像feat/B
上所有的commit
都是基於最新的master
提交的。
歷史如下:
由於rebase
之後,master
始終是feat/B
的祖先節點,因此,之後將feat/B
合併到master
將執行Fast-Farword
,不會產生衝突(如果有衝突,rebase
的時候就需要解決了),也不會產生新節點。
3. merge 還是 rebase
merge
還是rebase
,有人提倡不要使用rebase
,應該rebase
改變了歷史(在上一小節中一直在改變分支的啟始節點),有人提倡使用merge
,保留完整的歷史。
我是這麼做的,在私有的分支上,我始終使用rebase
將主分支的更新合併到私有的分支上(後面還有很多使用rebase
的操作,都是在私有的分支,這裡的私有的分支,指的是隻有自己使用的分支,一旦分享出去,或者有人基於你的分支開發,那就不再是私有),而在將自己的分支合併到其他分支(主分支或者待發布分支),則使用merge
。
- 切換到主分支:
$ git switch mater Switched to branch 'master' 複製程式碼
- 將
feat/B
合併到主分支$ git merge feat/B Updating e443dd4..ef3450c Fast-forward 1 | 2 +- featB | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 featB 複製程式碼
這樣在長時間開發(master
中間釋出過n
多版本)的feat/B
就不會有無數亂七八糟的分支合併。而在master
也不會存在rebase
導致的歷史變更後果。
歷史如下:
準則:不要對在你的倉庫外有副本的分支執行變基。
如果你遵循這條金科玉律,就不會出差錯。 否則,人民群眾會仇恨你,你的朋友和家人也會嘲笑你,唾棄你。-- 3.6 Git 分支 - 變基 - 變基的風險
0x002 多人合作開發
1. 新功能開發
開發方式
新功能開發的時候從主分支新建新分支,所有該功能的開發工作都在這個分支上完成。如果主分支有新的釋出,使用rebase
同步主分支功能:
名稱規範
功能分支的命名方式是feat/${name}_${featName}
,它的構成如下:
- 常量
feat
:表示這是一個功能分支 - 變數
name
:你的名字 - 變數
featName
:功能名字 好處是見名知意,一看就知道是功能分支,是誰負責,是什麼功能
2. bug 修復及其釋出
開發方式
bug
修復大體上和新功能的開發類似,但是bug
修復一般時間短,立馬上線。
bug
修復從主分支新建新分支,所有的bug
修復工作都在這個分支上完成。如果主分支有新的釋出,使用rebase
同步主分支功能(這個步驟其實和新功能開發一樣):
名稱規範
bug
修復分支的命名方式是hotfix/${name_${bugName}}
,它的構成如下:
- 常量
hotfix
:表示這是一個功能分支 - 變數
name
:你的名字 - 變數
bugName
:bug
名字 好處是見名知意,一看就知道是bug
修復分支,是誰負責,是什麼bug
bug 釋出
bug
釋出可以直接推送到待發布版本分支,比如1.1.1
,然後CodeReview
(如果有),然後合併主分支部署上線。
完整過程如下:
2.5 stash
一般我們修復bug
的時候都在開發新功能,也就是在feat/*
上,這時候如何快速進入bug
修復狀態呢?可以儲存當前程式碼,提交commit
,但是這時候會有一些問題,比如,1)當前的程式碼並未完成,並不想提交;2)commit
有鉤子,比如ESLint
,必須修復語法問題才能提交。
這時候就是使用stash
了。stash
可以將當前工作區和暫存區的內容暫時儲存起來,之後再使用。
如下:
- 開發功能中
$ echo "this is a feat" >> feat.txt 複製程式碼
- 收到
bug
通知$ git stash Saved working directory and index state WIP on master: ef3450c feat: b $ git switch master $ git checkout -b hotfix/bugA Switched to a new branch 'hotfix/bugA' 複製程式碼
- 修復
bug
之後$ git switch feat/A $ git stash pop On branch hotfix/bugA Changes to be committed: (use "git restore --staged <file>..." to unstage) new file: feat.txt Dropped refs/stash@{0} (32cf119fc1dcbe7088d1a12e290b868d6707526d) 複製程式碼
stash
命令有一整套完整的增刪改查指令,可以檢視git-pro 7.3 Git 工具 - 儲藏與清理瞭解更多。
3. 新功能釋出
新功能釋出和bug
釋出有些不同,1)可能會有多個功能共同釋出,需要提前合併,避免大沖突;2)可能有bug
修復需要插隊。3)可能需要等待後端釋出,時間長。
因為(2),所有無法像bug
釋出那樣直接推送到版本分支,等待發布。因為在真正釋出之前,是無法知道準確版本的。
因為(1)、(3),所以需要提前合併,所以引入一個“日期分支”的概念,即以日期為分支名,比如pub/191205
。
所以釋出過程如下:
(其實我還畫了一張賊複雜的圖,把自己都噁心了,有空還是畫個動態圖吧(沒空))
0x003 標準化 commit message
標準化 commit message 可以參考阮一峰 - Commit message 和 Change log 編寫指南。(阮一峰真的是寫部落格跨不過去的坎?,寫啥都可以引用他)
- 安裝
commitizen
$ npm install -g commitizen
複製程式碼
- 在專案目錄中初始化
commitizen
$ commitizen init cz-conventional-changelog --save --save-exact
複製程式碼
- 使用
git cz
代替git commit
,以下是我常用的型別:
$ git cz
feat: 一個新功能
fix: 一個 bug 修復
docs: 只改變文件
refactor: 改變程式碼但是不新增或者修復功能,我一般用於優化或者重構
test: 新增測試程式碼
chore: 其他改變
style: 樣式更新
複製程式碼
0x004 commit 精細化管理
首先是為什麼?為什麼要管理commit
,commit
有啥好管理的?
在以前,我覺得git
是用來記錄程式碼操作的,我對程式碼的任何操作都應該被記錄下來,而且就像歷史一樣,是神聖不可侵犯的。通過git
歷史,我必須要可以知道我在某一刻做了什麼,就算我在一個commit
新增了一行程式碼,然後在後一個commit
刪除了它,我也必須可以從log
中看出來。
所以我的git
歷史中充滿了各種無效的commit
,因為有時候真的不知道如何為命名。
但是後來,我就想通了,我使用git
的目標是不是為了記錄,而是為了專案的穩定發展。只要實現了這個目的,手段不是問題,更何況git
只是一個工具,工具是用來用的,不是用來供奉的。讓自己快樂快樂才叫做意義。
所謂的管理commit
,就是對commit
執行增、刪、改、拆操作。會在後面的章節一一列出。而管理的目的,是為了讓每一個commit
都有存在的意義,讓Git
成為專案管理真正的後盾。
後面的例子將同時提供SourceTree
的操作,命令式可以看上一篇文章[前端漫談]一巴掌拍平Git中的各種概念。
1. 排序 commit
場景:完成登陸頁面之後,提交一個commit
,message
是feat(登陸): 完成登陸頁面
。然後進入其他功能的開發,之後又回到登陸頁面的開發。提交記錄如下:
我們有兩個feat(登陸)
或者多個相關的的commit
,但是卻分佈於不同的地方,假設每一個feat(登陸)
只會與前一個feat(登陸)
有檔案修改的交集,那麼我們希望feat(登陸)
相關的功能可以放在一起。如下:
如何實現:
2. 合併 commit
場景:完成登陸頁面之後,提交一個commit
,message
是feat(登陸): 完成登陸頁面
。然後進入其他功能的開發,後來發現登陸有一個文案錯誤,繼續修改,完成之後又提交一個commit
,message
為feat(登陸): 修改文案
。提交記錄如下:
在我看來,feat(登陸): 修改文案
這個commit
的存在是不應該的,比如,1)如果有一天我們需要單獨上“登陸”功能,還有可能被遺漏;2)單獨佔據一個commit
可能只是為了修復一個符號問題,在回溯歷史的時候有不必要的浪費。也就是我希望一個commit
它是獨立的,不依賴後續commit
的存在。
所以我希望將這兩個commit
合併:
操作過程:
3. 更新 commit
更新commit
的場景有兩個:
- 更新
message
- 正好上面有一個不符合標準的
message
: - 我希望改為:
- 操作說明
- 正好上面有一個不符合標準的
- 更新、新增、刪除
- 1)有時候我們會通過修改某個變數來做一些測試,然後提交的時候突然發現忘記改回來;2)忘記或者誤新增檔案;3)忘記刪除檔案。這時候可以通過再建立一個
commit
再改回來,但是誤新增的檔案依舊會在歷史中存在,佔據一定的空間。我們可以根據上面的“合併”方式合併commit
消除影響,也可以一步到位: feat(mine): 個人中心
提交中有一個mime.html
檔案,我希望刪掉bad line
;還有一個mineBad.bad
這麼一個看起來壞壞的檔案,我希望刪除它。- 操作過程(略複雜):
- 1)有時候我們會通過修改某個變數來做一些測試,然後提交的時候突然發現忘記改回來;2)忘記或者誤新增檔案;3)忘記刪除檔案。這時候可以通過再建立一個
4. 增加/分離 commit
-
增加一個
commit
的意義其實不大,在更新commit
的過程中我們選擇的是更正上一次提交
,也就是git commit --amend
,但是如果我們不選擇,而是建立一個提交,其實就是增加一個commit
了。- 我希望在
feat(mine): 完成個人中心
和feat(main): 完成主頁
中間新增一個commit
,可以通過新建一個commit
然後之後通過前面的排序
手段來做到,也可以一步到位:
- 操作過程(和前面差不多,只是不選擇更正上一次提交):
- 我希望在
-
分離
commit
的意義重大,有時候我們希望只釋出一個功能,卻發現這個功能的commit
中包含我們不希望釋出的另一個功能,可能是因為本來要放到兩個commit
的功能誤新增到一個commit
,- 我們有一個
feat(detail): 完成詳情頁
的commit
,卻不小心把other
的功能給包含進去了,這時候我希望只發布detail
頁面,因此,對於commit
的分離是必須的:
- 操作過程
- 我們有一個
5. 刪除 commit
當我們做了一次修改後來發現這個修改沒有必要,就可以刪除這個commit
,但是不推薦,除非真的確認。
-
在
feat(detail): 完成詳情頁面
後面做了一個不需要的提交: -
刪除步驟
7. cherry-pick
有時候,我們需要釋出一個分支中的幾個功能,比如我們在一次統一優化中修復了 5 個 bug,做了 5 個優化,但是其中幾個並沒有通過驗證:
-
refactor/A
分支中有 3 個commit,通過了 2(用 ok 標記) 個 -
fix/A
分支中有 3 個 commit,通過了 2(用 ok 標記) 個
我們只能釋出通過的 bug 修復和優化(標註了 ok 的),而這些修復和優化並不一定在哪個分支,是隨機分佈的:
在這種場景中,雖然可以用分支去處理,但是有點麻煩,這個時候 cherry-pick 是最好的工具。
-
操作過程
0x005 reflog
上面的很多操作都涉及到歷史的操作,用普通的 revert 或者 reset 是無法消除影響的,只有在清楚這些命令的原理和本質的情況下才應該使用這些命令。但是對於這些操作也是有辦法處理的,那就是 reflog
:
在git
中,所有的操作都會被記錄下來,比如切換分支
、合併分支
等,可以使用 reflog
檢視這個記錄,下面是cherry-pick
例子產生的記錄:
$ git reflog
# 執行 cherry-pick,一共 4 個 commit
b185e09 (HEAD -> pub/191206) HEAD@{0}: cherry-pick: feat(A): ok
dd67bf5 HEAD@{1}: cherry-pick: fix(A): ok
1d0237e HEAD@{2}: cherry-pick: feat(A): ok
51f808e HEAD@{3}: cherry-pick: refactor(A): ok
### 從 master 新建分支 pub/191206
a48cdd2 (master) HEAD@{4}: checkout: moving from master to pub/191206
複製程式碼
如果我們撤銷cherry-pick
,可以執行以下命令:
$ git reset --hard HEAD@{4}
HEAD is now at a48cdd2 chore: 專案初始化
複製程式碼
-
就沒啦
-
再次檢視
reflog
,多了一條記錄$ git reflog a48cdd2 (HEAD -> pub/191206, master) HEAD@{0}: reset: moving to HEAD@{4} b185e09 HEAD@{1}: cherry-pick: feat(A): ok dd67bf5 HEAD@{2}: cherry-pick: fix(A): ok 1d0237e HEAD@{3}: cherry-pick: feat(A): ok 51f808e HEAD@{4}: cherry-pick: refactor(A): ok a48cdd2 (HEAD -> pub/191206, master) HEAD@{5}: checkout: moving from master to pub/191206 複製程式碼
-
撤銷
cherry-pick
又後悔啦$ git reset --hard HEAD@{1} HEAD is now at b185e09 feat(A): ok 複製程式碼
-
效果
-
又又後悔啦!!!滾
0x006 總結
-
勿忘初心,砥礪前行。我們一開始使用
git
是為了更好的輔助專案,而不是讓專案更加複雜,如果不使用這些方式可以讓你的專案更加簡單,那就不要用,為了使用git
而使用git
,不如不使用。 -
要理解工具的原理,再去使用,不要盲目。使用上面的命令之前,務必瞭解這些命令或者操作背後發生了什麼。
0x007 後記
-
我一直在尋找一種好的表達方式,從截圖示註、繪圖,到
gif
等,希望可以將文章講的更加透徹。現在看來,可能還是gif
比較好。 -
寫一篇文章真的有點難啊,構思、佈局、實驗、總結,每一步都需要花很大的功夫,但是一篇精心總結的文章,對自己的幫助還是很大的,希望對各位也有幫助把。
0x008 資源
- git pro
- 阮一峰 - Commit message 和 Change log 編寫指南
- [前端漫談]一巴掌拍平Git中的各種概念
- SourceTree:Git 視覺化工具
- ProcessOn:繪圖工具
- 截圖:截圖和標註
- GIF Brewery 3 by Gfycat:GIF 錄製和標註工具
- IDEA:IDE
0x009 帶貨
最近發現一個好玩的庫,作者是個大佬啊--基於 React 的現象級微場景編輯器。