Pull Request 工作流程

Godot 使用的所謂「PR 工作流程」對於許多使用 Git 的專案來說很場景,對於有經驗的自由軟體貢獻者來說應該很熟悉。PR 的主要概念是,應該只有少數 (甚至沒有) Commit 直接被推送到 master 分支上。貢獻者應該要先 Fork 專案 (即,建立專案的拷貝,接著貢獻者便能隨意修改),然後使用 GitHub 的界面來請原始儲存庫 (通常稱謂 Upstream - 上游) 從這些 Fork 的分支上 Pull 回來。

產生的 Pull Request (PR) 可以被其他貢獻者審閱,其他貢獻者可能會允許 PR、拒絕 PR、或是要求要對 PR 作出修改。PR 被允許後,便可由核心開發人員進行合併 (Merge),而 PR 的 Commit 則會變成合併目標分支中的一部分 (通常為 master 分支)。

我們接著會以一個範例來解釋這個工作流程與其相關的 Git 指令。但首先,我們來看一下 Godot Git 儲存庫的組織架構。

Git 原始碼儲存庫

GitHub 上的儲存庫 是一個 Git 程式碼儲存庫,並含有內建的 Issue Tracker 與 PR 系統。

備註

若想參與貢獻說明文件,則可在 此處 找到其儲存庫。

Git 版本控制系統是用來追送程式碼之連續編輯的工具。若要有效率的為 Godot 參與貢獻,我們 非常 建議學習基礎的 Git 命令行使用。目前有許多的 Git 圖形界面,但這些軟體通常都會讓使用者養成關於 Git 與 PR 工作流程的壞習慣,所以我們不建議使用這些軟體。特別是,對於程式碼貢獻,我們不建議使用 GitHub 的線上編輯器 (雖然對於小型修正與說明文件更改來說還可以),因為 GitHub 的線上編輯器每改一個檔案就有一個 Commit,這樣建立起來的 PR 的 Git 歷史就會很難閱讀 (特別是在同儕審閱後)。

也參考

有關 Git 的理念與日常工作所需掌握的各個指令,可以從 Git「Book」中的第一部分來學習。可以在 Git SCM <https://git-scm.com/book/zh-tw/v2> 網站中線上閱讀。

Git 儲存庫中的分支以下列方式組織:

  • master 分支用來進行下個主要版本的開發。作為一個開發分支,該分支內的程式是不穩定的,且不適用於正式環境。該分支是 PR 應優先進行的地方。

  • 穩定版分支的名稱使用其版本,如 3.12.1 。這些分支是用來將 Bug 修正與改進從 master 分支移植回去主線穩定版本的 (如 3.1.2 或 2.1.6)。依照經驗法則,最後一個穩定的分支會一直維護到下一個主要版本退出後 (如 3.0 分支一直到 Godot 3.1 推出時都還有維護)。若想製作對應主線穩定版的 PR,請先檢查其改動是否也會與 master 分支有關,若與 master 分支有關,請優先以 master 作為 PR 的目標。釋出管理員會將相關的修正 Cherry-Pick 到穩定分支。

  • 有時候可能會有一些功能性分支,通常這些分支是用來在之後合併到 master 用的。

Fork 與 Clone

第一步是在 GitHub 上 Fork godotengine/godot 儲存庫。要進行 Fork,你必須要先有 GitHub 帳號並登入。在儲存庫 GitHub 頁面的右上角,應該可以看到如下的「Fork」按鈕:

../../_images/github_fork_button.png

點擊該按鈕,稍待片刻後應該會重新導向到你自己 Fork 的 Godot 儲存庫中,並以你的 GitHub 使用者名稱作為命名空間:

../../_images/github_fork_url.png

接著便可 Clone 你的 Fork,即為建立一個線上儲存庫的本機拷貝 (用 Git 的說法來說,線上儲存庫就是 origin remote)。若還未下載 Git,Windows 與 macOS 請從 ``Git 網站 <https://git-scm.com>`_ 下載 Git,如果使用 Linux 則請通過套件管理員進行安裝。

備註

如果使用 Windows,請開啟 Git Bash 來輸入指令。macOS 與 Linux 使用者則可使用各自的終端機。

若要從 GitHub 上 Clone 你的 Fork,請使用下列指令:

$ git clone https://github.com/USERNAME/godot

備註

在我們的例子中,「$」字元用來表示一般的 UNIX Shell 命令提示字元。該字元並不是指令的一部分,不應輸入。

稍等片刻後,應該可以在目前的工作目錄中看到 godot 資料夾。請使用 cd 指令移至該資料夾中:

$ cd godot

我們先從設定所 Fork 的原始儲存庫參照開始:

$ git remote add upstream https://github.com/godotengine/godot
$ git fetch upstream

這個指令會建立一個名為 upstream 的參照,並指向原始的 godotengine/godot 儲存庫。這樣設定對於想從其 master 分支 Pull 新 Commit 來更新你的 Fork 很有用。除了 upstream 參照外,還有另一個名為 origin 的遠端參照,指向你自己的 Fork (使用者名稱/godot)。

只要保持本機的 godot 資料夾 (可隨意移動,相關的後設資料隱藏在 .git 子資料夾中),上述步驟只需要進行一次即可。

備註

建立分支、拉取、撰寫程式碼、暫存、推送、變基……太高科技了。 Branch it, pull it, code it, stage it, commit, push it, rebase it... technologic.

Daft Punk 的 Technologic 中對於這種不良看法表示,Git 初學者通常對 Git 工作流程一般的概念為:用複製貼上來學許多奇怪的指令,然後希望指令能如預期般運作。但只要你保持好奇心,且在遇到問題時能問問搜尋引擎,這就不是什麼不好的學習方式。因此,接下來我們會提供使用 Git 的基本指令。

在接下來的部分,我們會以假設你想為 Godot 專案管理員實作新功能為例子,專案管理員的程式碼會寫在 editor/project_manager.cpp 檔案中。

建立分支

預設情況下,git clone 指令會打開你的 Fork (origin) 的 master 分支。若要開始進行功能開發,我們要先建立一個功能分支:

# Create the branch based on the current branch (master)
$ git branch better-project-manager

# Change the current branch to the new one
$ git checkout better-project-manager

下列指令效果相同:

# Change the current branch to a new named one, based on the current branch
$ git checkout -b better-project-manager

若想回到 master 分支,可以使用:

$ git checkout master

可以使用 git branch 指令來看看目前在哪個分支上:

$ git branch
  2.1
* better-project-manager
  master

由於目前的分支會在建立新分支時作為基礎,所以請確保每次建立新分支前都先回到 master 上。或者,也可以在新分支名稱之後指定自定的基礎分支:

$ git checkout -b my-new-feature master

更新分支

剛開始並不需要更新分支 (也就是剛從上游儲存庫 Fork 完後)。但,之後要進行其他作業時可能會發現 Fork 的 master 分支比上游 master 分支還落後了多個 Commit,因為這段期間內其他貢獻者的 Pull Request 被合併到了 master 內。

為了確保你正在開發的功能不會與目前的上游 master 分支衝突,必須要從上游分支 Pull (拉取) 來更新目前的分支。

$ git pull --rebase upstream master

--rebase 引數可以用來確保在本機 Commit 過的更新被重新套用在 Pull 的分支的 最前端 ,這也是我們在 PR 工作流程裡通常會做的方式。這樣一來,在開啟 Pull Request 時,你的分支與上游 master 分支的差別就只會有你的 Commit。

在 Rebase 時,如果你修改過的程式碼在這段時間內也於上游分支中內有修改過,則會發聲衝突。若發生衝突,則 Git 會停在發生衝突的 Commit,並要求處理衝突。可以在任何文字編輯器中處理衝突,接著將更改預存 (Stage) 起來 (稍後說明),然後執行 git rebase --continue 。如果之後的 Commit 還有衝突的話,則需要重複這個過程,直到 Rebase 操作完成。

如果在 Rebase 時害怕不知道現在正在做什麼的話 (別擔心,我們剛開始都會緊張),可以通過 git rebase --abort 來中止 Rebase。接著會回到執行 git pull --rebase 之前的分支狀態。

備註

如果沒有加上 --rebase 印數,那麼會建立一個 Merge Commit,該 Commit 用來告訴 Git 要如何處理兩個不同的分支。若發生衝突,則所有的衝突都會通過 Merge Commit 來處理。

雖然 Merge Commit 也是有效的工作流程,而且是 git pull 預設的行為,但在我們的 PR 工作流程內,我們不贊同使用 Merge Commit。我們只會在將 PR 合併回上游分支時使用 Merge Commit。

這麼做的理念是,PR 應該用來代表對程式碼修改的最終狀態,而我們並不在乎到合併之前這段期間所產生的錯誤與修正。Git 提供了讓我們「重寫歷史」的好工具,通過這個工具可以讓我們把東西做得看起來好像一步到位,而我們很高興能通過這個工具來讓更改更容易被審閱,且即使在合併一段時間後仍容易理解。

如果沒有使用 rebase 且已經建立 Merge Commit 的話,或是造成了一些非期望的歷史記錄,則最好的選擇是在上游分支使用 Interactive Rebase (互動式變基)。相關步驟請參考 Rebase 一節

小訣竅

任何時候,如果想將本機分支 重設 (Reset) 到特定的 Commit 或分支上,可以通過 git reset --hard <Commit ID>git reset --hard <Remote>/<分支> (如 git reset --hard upstream/master) 來實現。

但請注意,這樣也可能會讓任何已經 Commit 到該分支上的改動被移除。如果不小心遺失 Commit,可以使用 git reflog 指令來找到所要恢復到之前狀態的 Commit ID,然後使用該 ID 作為 git reset --hard 的引數來回到該狀態。

做出更改

你可以通過平常使用的開發環境 (如文字編輯器、IDE…等) 來更改範例的 editor/project_manager.cpp 檔案。

預設情況下,做出的改動都是 未預存 (Unstaged) 的。預存區 (Staging Area) 是一層介於目前工作目錄 (也就是你進行修改的地方) 以及本機 Git 儲存庫 (所有 Commit 與後設資料都在 .git 資料夾內) 的區域。若要將目前工作資料夾中的更改帶到 Git 儲存庫中,則需要通過 git add 指令來 預存 (Stage) 這些改動,然後通過 git commit 指令來進行 Commit。

有許多可以用來檢查目前工作狀態的指令,不管是在預存前、預存時、或是已經 Commit 後。

  • git diff 會顯示目前尚未預存的更改,即,目前工作目錄與暫存區的差異。

  • git checkout -- <檔案> 可以取消改動指定檔案未預存的更改。

  • git add <檔案> 可以將列出的檔案 預存

  • git diff --staged 會顯示目前已預存的改動,即,預存區與上一個 Commit 間的差異。

  • git reset HEAD <檔案>取消預存 列出的檔案。

  • git status 會顯示目前已預存與未預存的更改。

  • git commit 會 Commit 已預存的檔案。該指令會開啟文字編輯器 (可以使用 GIT_EDITOR 環境變數或是 Git 組態設定的 core.editor 來定義要使用的編輯器) 來讓你編寫 Commit Log。可以使用 git commit -m "Cool commit log" 來直接撰寫 Commit Log。

  • git commit --amend 可以將目前暫存的改動 (以 git add 新增的) 修正到上一個 Commit 中。當想修正上一個 Commit 中的錯誤,這個指令是最好的選項 (Bug、錯字、樣式問題…等)。

  • git log 顯示目前分支的最新 Commit。若有進行本機 Commit,則應該會顯示在最上面。

  • git show 會顯示最新 Commit 的更改。也可以指定一個 Commit 雜湊來檢視該 Commit 的更改。

要記的東西很多!別擔心,只要下次有進行更改的時候再來看一下這張表就好了,做中學。

在這裡的範例中的 Shell 歷史應該長這樣:

# It's nice to know where you're starting from
$ git log

# Do changes to the project manager with the nano text editor
$ nano editor/project_manager.cpp

# Find an unrelated bug in Control and fix it
$ nano scene/gui/control.cpp

# Review changes
$ git status
$ git diff

# We'll do two commits for our unrelated changes,
# starting by the Control changes necessary for the PM enhancements
$ git add scene/gui/control.cpp
$ git commit -m "Fix handling of margins in Control"

# Check we did good
$ git log
$ git show
$ git status

# Make our second commit
$ git add editor/project_manager.cpp
$ git commit -m "Add a pretty banner to the project manager"
$ git log

這樣一來,我們應該在 better-project-manager 中建立了兩個新的 Commit,這兩個 Commit 不會出現在 master 分支上。雖然目前這些 Commit 還只存在本機裡,對於遠端 Fork 與上游 Repo 都還不知道。

將更改推送 (Push) 到遠端

這裡就是 git push 登場的時機了。在 Git 中,Commit 都是在本機儲存庫上完成的 (與 Subversion 不同,SVN 的 Commit 會直接向遠端儲存庫進行修改)。我們會需要將新的 Commit Push 到遠端分支上來將這些 Commit 分享給全世界。語法如下:

$ git push <remote> <local branch>[:<remote branch>]

如果想使用與本機分支相同名稱的遠端分支的話,則可以省略遠端分支的那個部分。而本例中就是這樣,所以可以這樣寫:

$ git push origin better-project-manager

Git 接著會詢問你帳號密碼,並將修改傳送到遠端。在 GitHub 的 Fork 頁面上確認一下,就可以看到有剛才新增 Commit 的分支。

開啟 PR

在 GitHub 上打開 Fork 的分支時,應該會看到一行字寫 「This branch is 2 commits ahead of godotengine:master」 (如果你的 master 分支沒有與上游 master 分支同步的話,數字可能還更大)。

../../_images/github_fork_make_pr.png

在這行文字旁邊,應該有一個「Pull request」連結。點擊該連結來打開在 godotengine/godot 上游儲存庫中建立 PR 的表單。這個頁面應該會顯示你建立的兩個 Commit,並顯示「Able to merge」(可以合併)。如果沒顯示 Able to Merge 的話 (如有更多 Commit 或有 Merge Conflict 的情況),則請先不要建立 PR,這表示有什麼地方出錯了。請先到 IRC 上尋求幫助 :)

請為 PR 使用完整的標題,並將所有必要的資訊都寫在 Comment 區域。有必要的話也可以拖放截圖、GIF 或專案壓縮檔來提供你實作內容的展示。點擊「Create a pull request」,就這樣!

更改 PR

在經由其他貢獻者審閱的期間,可能會常常修改這個還沒被合併的 PR。可能是因為其他貢獻者要求修改,或是你自己在測試的時候發現問題。

好消息是,可以直接在你建立 PR 的分支上進行修改。如,你可以在該分支上建立新 Commit、推送到 Fork 上,然後 PR 就會自動更新:

# Check out your branch again if you had changed in the meantime
$ git checkout better-project-manager

# Fix a mistake
$ nano editor/project_manager.cpp
$ git add editor/project_manager.cpp
$ git commit -m "Fix a typo in the banner's title"
$ git push origin better-project-manager

但是,請注意我們的 PR 工作流程。我們偏好的 Commit 是將整個程式碼從一個功能狀態變成另一個功能狀態的 Commit,而不應有用來修正自己程式碼 Bug、修正樣式問題這樣的中介 Commit。在大多數情況下,我們都偏好一個 PR 一個 Commit (除非有什麼將更改分開 Commit 的好理由)。因此,請避免建立新 Commit,考慮使用 git commit --amend 來將這些修正合併到前面的 Commit 中。上述的例子會變成這樣:

# Check out your branch again if you had changed in the meantime
$ git checkout better-project-manager

# Fix a mistake
$ nano editor/project_manager.cpp
$ git add editor/project_manager.cpp
# --amend will change the previous commit, so you will have the opportunity
# to edit its commit message if relevant.
$ git commit --amend
# As we modified the last commit, it no longer matches the one from your
# remote branch, so we need to force push to overwrite that branch.
$ git push --force origin better-project-manager

互動性 Rebase

如果沒有嚴格按照上面的步驟來將更改 合併 (Amend) 到前面的 Commit 中而建立了修正性的 Commit,或是沒有按照我們的工作流程與 Git 使用說明來建立更改,則審閱者可能會要求你 Rebase 你的分支,並將其中的一些或全部 Commit Squash (壓縮) 為一個。

當然,我們會依據審閱的結果來建立用於修正原始 Commit 中的 Bug、錯字…等,而這些修正對於未來閱讀修改記錄的讀者來說是不相關的,因為這些讀者只想知道 Godot 的原始碼庫發生了哪些變化,或是某個檔案在什麼時候做了什麼修改。

如果要將這些不相關的 Commit 合併為一個,則我們需要 重寫歷史 。沒錯,我們有修改歷史的能力。你可能在其他地方讀過,這是個不好的實踐方法 (Bade Practive)。對於上游儲存庫的分支來說,更改歷史確實是個壞習慣,但對於你的 Fork 來說,你可以做任何想事,而對於要產生乾淨的 PR 來說,做什麼都可以 :)

我們會使用 互動性 Rebase git rebase -I 來修改歷史。這個指令接受一個 Commit ID 或分支名稱作為參數,並讓你能修改從該 Commit 或分支起只目前工作分支上最新 Commit (所謂的 HEAD) 間的所有 Commit。

雖然可以將任何的 Commit ID 傳入 git rebase -I 並審閱期間所有的東西,但最常用與最便利的工作流程就是從 上游 ``master`` 分支 開始 Rebase,可以通過下列指令:

$ git rebase -i upstream/master

備註

由於遠端分支與本機分支是不同的,因此要在 Git 中參照分支需要點技巧。此處的 upstream/master (中間有 /) 代表的是從遠端 upstreammaster 分支拉取回來的本機分支。

互動性 Rebase 只能在本機分支上進行,因此此處的 / 很重要。由於上游遠端常常更改,而你本機的 upstream/master 分支可能會比較舊,因此需要通過 git fetch upstream master 來進行更新。與 git pull --rebase upstream master 比較起來,git pull --rebase 會更新目前簽出 (Checkout) 的分支,而 fetch 則只會更新 upstream/master 參照 (而該參照與本機的 master 分支不同……沒錯,這些很混亂,但你會滿滿越來越熟悉)。

上述指令會開啟文字編輯器 (預設為 vi,請參考 Git docs 瞭解如何設定為你常用的編輯器),內容為類似如下:

pick 1b4aad7 Add a pretty banner to the project manager
pick e07077e Fix a typo in the banner's title

編輯器中也會顯示出如何操作這些 Commit 的說明。詳細來說,在這個檔案內應該有告訴你「pick」代表使用該 Commit (不做任何事),而「squash」與「fixup」則代表將該 Commit 合併 (Meld) 進其母 Commit。「Squash」與「Fixup」間的不同就是「Fixup」會移除被合併 Commit 的 Commit Log。在我們的例子中,我們並不打算保留「Fix a typo」的 Commit Log,因此我們可以使用:

pick 1b4aad7 Add a pretty banner to the project manager
fixup e07077e Fix a typo in the banner's title

保存並退出編輯器後,會進行 Rebase。第二個 Commit 會被合併到第一個 Commit 中,而使用 git loggit show 確認便可得知現在只有一個 Commit,其中包含了之前兩個 Commit 的所有更改。

但是!因為我們重寫了歷史,而現在本機與遠端的分支有出入。當然,在上述例子中,Commit 1b4aad7 發生了變化,因此我們有了新的 Commit Hash。此時如果試著將更改推送到遠端分支上,會出現錯誤:

$ git push origin better-project-manager
To https://github.com/akien-mga/godot
 ! [rejected]        better-project-manager -> better-project-manager (non-fast-forward)
error: failed to push some refs to 'https://akien-mga@github.com/akien-mga/godot'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart.

這是很正常的,因為 Git 不讓你將複寫遠端內容的更改推送過去。但在這裡,複寫遠端內容就是我們要做的,因此我們要來 強制 (Force) 推送:

$ git push --force origin better-project-manager

就這樣!Git 接著會將遠端分支以本機上的東西 取代 掉 (因此請先通過 git log 確認你想要強制取代)。同時,也會更新相應的 PR。

刪除 Git 分支

當 PR 被合併了之後,還有一件事要做:刪除用於該 PR 的 Git 分支。如果不刪除的話也不會怎麼樣,但刪除分支是個好習慣。我們會需要刪除兩次,一次是刪除本機的分支,而另一次是刪除 GitHub 上的遠端分支。

要在本機上刪除我們的 Better Project Manager 分支,可以使用這個指令:

$ git branch -d better-project-manager

或者,如果該分支尚未被合併而確定要刪除的話,則應將 -d 改為 -D

接著,通過下列指令刪除 GitHub 上的遠端分支:

$ git push origin -d better-project-manager

也可以從 GitHub PR 中刪除遠端分支,當該 PR 合併或關閉後,頁面上應該會有一個按鈕。