[前端漫談]Git 在專案中的完全控制實踐

賣梳子的鯉魚發表於2019-12-07

導讀

在上一篇文章[前端漫談]一巴掌拍平Git中的各種概念中,描述了 Git 的一些概念,但是太過虛化,描述的都是一些概念和命令。這篇文章結合實際場景,主要描述我在專案實踐中使用 Git 管理專案、團隊協作的一些經驗。包括 1)mergerebase 使用的區別和選擇;2)多人團隊合作開發流程;3)標準化 commit message;4)commit 精細化管理等。這些都是為專案的健壯發展和程式碼的精細管理所流的淚累積出來的。

0x000 前言

由上一片文章[前端漫談]一巴掌拍平Git中的各種概念中,可以知道,Git 世界就像一個 宇宙,每一個 commit 都是一顆星球,而 commitId 就是星球的座標,branch 是一條條的航線,穿過無數的 星球,tag 是航線上重要的星球,可能是供給站,可能是商業中心,而 HEAD 則是探索號飛船,不斷向前探索。中間可能會有岔道,但是永遠有一個真正的方向等待勇敢的船長。

[前端漫談]Git 在專案中的完全控制實踐

0x001 merge 還是 rebase

merge 還是 rebase,這是經久不衰的討論點。但是這裡我不去爭論孰優孰略,我只說我在不同場景的實踐。

1. merge

我通常使用 merge 來將多個分支合併到當前分支,比如要釋出的時候,將多個功能分支合併到帶釋出分支:

已知:feat/Afeat/Bfeat/C,是從主分支新建的功能分支,feat/Bfeat/C都修改了檔案1

  • 新建待發布分支:
    # 從主分支新建分支 pub/191205
    $ git checkout -b pub/191205
    Switched to a new branch 'pub/191205'
    複製程式碼
  • 合併feat/Apub/191205
    $ git merge feat/A
    Updating 53ab8fd..e443dd4
    Fast-forward
     featA | 1 +
     1 file changed, 1 insertion(+)
     create mode 100644 featA
    複製程式碼

pub/191205feat/A 都是從主分支新建,所以 pub/191205 指向的 commitfeat/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/Bpub/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/191205feat/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/Cpub/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/Cfix/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/Afeat/B合併了,所以pub/191205不再是feat/C的祖先。因此,pub/191205feat/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

歷史如下:

[前端漫談]Git 在專案中的完全控制實踐

2. rebase

注:rebase 的功能很強大,這裡先介紹和 merge 相對應的功能。

我通常用它來和主分支同步,比如一個新版本釋出,主分支比我當前的功能分支超前,我使用rebase將當前分支和主分支“合併(變基)”。

已知:feat/Afeat/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),會產生一個新的節點。歷史如下

[前端漫談]Git 在專案中的完全控制實踐

這麼做是可以,但是我不喜歡這個合併產生的節點,所以我選擇使用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是從mastercheckout出來的,但是使用git rebase master之後,就會以master最新的節點作為feat/B分支的基礎。就像feat/B上所有的commit都是基於最新的master提交的。

歷史如下:

[前端漫談]Git 在專案中的完全控制實踐

由於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導致的歷史變更後果。

歷史如下:

[前端漫談]Git 在專案中的完全控制實踐

準則:不要對在你的倉庫外有副本的分支執行變基

如果你遵循這條金科玉律,就不會出差錯。 否則,人民群眾會仇恨你,你的朋友和家人也會嘲笑你,唾棄你。-- 3.6 Git 分支 - 變基 - 變基的風險

0x002 多人合作開發

1. 新功能開發

開發方式 新功能開發的時候從主分支新建新分支,所有該功能的開發工作都在這個分支上完成。如果主分支有新的釋出,使用rebase同步主分支功能:

[前端漫談]Git 在專案中的完全控制實踐

名稱規範 功能分支的命名方式是feat/${name}_${featName},它的構成如下:

  • 常量feat:表示這是一個功能分支
  • 變數name:你的名字
  • 變數featName:功能名字 好處是見名知意,一看就知道是功能分支,是誰負責,是什麼功能

2. bug 修復及其釋出

開發方式 bug修復大體上和新功能的開發類似,但是bug修復一般時間短,立馬上線。 bug修復從主分支新建新分支,所有的bug修復工作都在這個分支上完成。如果主分支有新的釋出,使用rebase同步主分支功能(這個步驟其實和新功能開發一樣):

[前端漫談]Git 在專案中的完全控制實踐

名稱規範 bug修復分支的命名方式是hotfix/${name_${bugName}},它的構成如下:

  • 常量hotfix:表示這是一個功能分支
  • 變數name:你的名字
  • 變數bugNamebug名字 好處是見名知意,一看就知道是bug修復分支,是誰負責,是什麼bug

bug 釋出 bug釋出可以直接推送到待發布版本分支,比如1.1.1,然後CodeReview(如果有),然後合併主分支部署上線。

完整過程如下:

[前端漫談]Git 在專案中的完全控制實踐

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

所以釋出過程如下:

[前端漫談]Git 在專案中的完全控制實踐

(其實我還畫了一張賊複雜的圖,把自己都噁心了,有空還是畫個動態圖吧(沒空))

[前端漫談]Git 在專案中的完全控制實踐

0x003 標準化 commit message

標準化 commit message 可以參考阮一峰 - Commit message 和 Change log 編寫指南。(阮一峰真的是寫部落格跨不過去的坎?,寫啥都可以引用他)

  1. 安裝commitizen
$ npm install -g commitizen
複製程式碼
  1. 在專案目錄中初始化commitizen
$ commitizen init cz-conventional-changelog --save --save-exact
複製程式碼
  1. 使用git cz代替git commit,以下是我常用的型別:
$ git cz
feat:       一個新功能
fix:        一個 bug 修復
docs:       只改變文件
refactor:   改變程式碼但是不新增或者修復功能,我一般用於優化或者重構
test:       新增測試程式碼
chore:      其他改變
style:      樣式更新
複製程式碼

0x004 commit 精細化管理

首先是為什麼?為什麼要管理commitcommit有啥好管理的?

在以前,我覺得git是用來記錄程式碼操作的,我對程式碼的任何操作都應該被記錄下來,而且就像歷史一樣,是神聖不可侵犯的。通過git歷史,我必須要可以知道我在某一刻做了什麼,就算我在一個commit新增了一行程式碼,然後在後一個commit刪除了它,我也必須可以從log中看出來。

所以我的git歷史中充滿了各種無效的commit,因為有時候真的不知道如何為命名。

但是後來,我就想通了,我使用git的目標是不是為了記錄,而是為了專案的穩定發展。只要實現了這個目的,手段不是問題,更何況git只是一個工具,工具是用來用的,不是用來供奉的。讓自己快樂快樂才叫做意義。

所謂的管理commit,就是對commit執行增、刪、改、拆操作。會在後面的章節一一列出。而管理的目的,是為了讓每一個commit都有存在的意義,讓Git成為專案管理真正的後盾。

後面的例子將同時提供SourceTree的操作,命令式可以看上一篇文章[前端漫談]一巴掌拍平Git中的各種概念

1. 排序 commit

場景:完成登陸頁面之後,提交一個commitmessagefeat(登陸): 完成登陸頁面。然後進入其他功能的開發,之後又回到登陸頁面的開發。提交記錄如下:

[前端漫談]Git 在專案中的完全控制實踐

我們有兩個feat(登陸)或者多個相關的的commit,但是卻分佈於不同的地方,假設每一個feat(登陸)只會與前一個feat(登陸)有檔案修改的交集,那麼我們希望feat(登陸)相關的功能可以放在一起。如下:

[前端漫談]Git 在專案中的完全控制實踐

如何實現:

[前端漫談]Git 在專案中的完全控制實踐

2. 合併 commit

場景:完成登陸頁面之後,提交一個commitmessagefeat(登陸): 完成登陸頁面。然後進入其他功能的開發,後來發現登陸有一個文案錯誤,繼續修改,完成之後又提交一個commitmessagefeat(登陸): 修改文案。提交記錄如下:

[前端漫談]Git 在專案中的完全控制實踐

在我看來,feat(登陸): 修改文案這個commit的存在是不應該的,比如,1)如果有一天我們需要單獨上“登陸”功能,還有可能被遺漏;2)單獨佔據一個commit可能只是為了修復一個符號問題,在回溯歷史的時候有不必要的浪費。也就是我希望一個commit它是獨立的,不依賴後續commit的存在。

所以我希望將這兩個commit合併:

[前端漫談]Git 在專案中的完全控制實踐

操作過程:

[前端漫談]Git 在專案中的完全控制實踐

3. 更新 commit

更新commit的場景有兩個:

  1. 更新message
    • 正好上面有一個不符合標準的message:
      [前端漫談]Git 在專案中的完全控制實踐
    • 我希望改為:
      [前端漫談]Git 在專案中的完全控制實踐
    • 操作說明
      [前端漫談]Git 在專案中的完全控制實踐
  2. 更新、新增、刪除
    • 1)有時候我們會通過修改某個變數來做一些測試,然後提交的時候突然發現忘記改回來;2)忘記或者誤新增檔案;3)忘記刪除檔案。這時候可以通過再建立一個commit再改回來,但是誤新增的檔案依舊會在歷史中存在,佔據一定的空間。我們可以根據上面的“合併”方式合併commit消除影響,也可以一步到位:
    • feat(mine): 個人中心提交中有一個mime.html檔案,我希望刪掉bad line;還有一個mineBad.bad這麼一個看起來壞壞的檔案,我希望刪除它。
      [前端漫談]Git 在專案中的完全控制實踐
    • 操作過程(略複雜):
      [前端漫談]Git 在專案中的完全控制實踐

4. 增加/分離 commit

  1. 增加一個commit的意義其實不大,在更新commit的過程中我們選擇的是更正上一次提交,也就是git commit --amend,但是如果我們不選擇,而是建立一個提交,其實就是增加一個commit了。

    • 我希望在feat(mine): 完成個人中心feat(main): 完成主頁中間新增一個commit,可以通過新建一個commit然後之後通過前面的排序手段來做到,也可以一步到位:

    [前端漫談]Git 在專案中的完全控制實踐

    • 操作過程(和前面差不多,只是不選擇更正上一次提交):
      [前端漫談]Git 在專案中的完全控制實踐
  2. 分離commit的意義重大,有時候我們希望只釋出一個功能,卻發現這個功能的commit中包含我們不希望釋出的另一個功能,可能是因為本來要放到兩個commit的功能誤新增到一個commit

    • 我們有一個feat(detail): 完成詳情頁commit,卻不小心把other的功能給包含進去了,這時候我希望只發布detail頁面,因此,對於commit的分離是必須的:

    [前端漫談]Git 在專案中的完全控制實踐

    • 操作過程
      [前端漫談]Git 在專案中的完全控制實踐

5. 刪除 commit

當我們做了一次修改後來發現這個修改沒有必要,就可以刪除這個commit,但是不推薦,除非真的確認。

  • feat(detail): 完成詳情頁面後面做了一個不需要的提交:

    [前端漫談]Git 在專案中的完全控制實踐

  • 刪除步驟

    [前端漫談]Git 在專案中的完全控制實踐

7. cherry-pick

有時候,我們需要釋出一個分支中的幾個功能,比如我們在一次統一優化中修復了 5 個 bug,做了 5 個優化,但是其中幾個並沒有通過驗證:

  • refactor/A 分支中有 3 個commit,通過了 2(用 ok 標記) 個

    [前端漫談]Git 在專案中的完全控制實踐

  • fix/A 分支中有 3 個 commit,通過了 2(用 ok 標記) 個

    [前端漫談]Git 在專案中的完全控制實踐

我們只能釋出通過的 bug 修復和優化(標註了 ok 的),而這些修復和優化並不一定在哪個分支,是隨機分佈的:

[前端漫談]Git 在專案中的完全控制實踐

在這種場景中,雖然可以用分支去處理,但是有點麻煩,這個時候 cherry-pick 是最好的工具。

  • 操作過程

    [前端漫談]Git 在專案中的完全控制實踐

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
複製程式碼

[前端漫談]Git 在專案中的完全控制實踐

如果我們撤銷cherry-pick,可以執行以下命令:

$ git reset --hard HEAD@{4}
HEAD is now at a48cdd2 chore: 專案初始化
複製程式碼
  • 就沒啦

    [前端漫談]Git 在專案中的完全控制實踐

  • 再次檢視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
    複製程式碼
  • 效果

    [前端漫談]Git 在專案中的完全控制實踐

  • 又又後悔啦!!!滾

0x006 總結

  1. 勿忘初心,砥礪前行。我們一開始使用git是為了更好的輔助專案,而不是讓專案更加複雜,如果不使用這些方式可以讓你的專案更加簡單,那就不要用,為了使用git而使用git,不如不使用。

  2. 要理解工具的原理,再去使用,不要盲目。使用上面的命令之前,務必瞭解這些命令或者操作背後發生了什麼。

0x007 後記

  • 我一直在尋找一種好的表達方式,從截圖示註、繪圖,到 gif 等,希望可以將文章講的更加透徹。現在看來,可能還是 gif 比較好。

  • 寫一篇文章真的有點難啊,構思、佈局、實驗、總結,每一步都需要花很大的功夫,但是一篇精心總結的文章,對自己的幫助還是很大的,希望對各位也有幫助把。

0x008 資源

0x009 帶貨

最近發現一個好玩的庫,作者是個大佬啊--基於 React 的現象級微場景編輯器

[前端漫談]Git 在專案中的完全控制實踐


相關文章