Pull Request 工作流程
Godot 所採用的「PR 工作流程」在許多使用 Git 的專案中都很常見,對資深自由軟體貢獻者來說應該不陌生。其核心觀念是,只有極少數(或甚至沒有)人會直接在 master 分支上提交(commit)。大多數貢獻者會先 fork 專案(即建立一份可自由修改的副本),再透過 GitHub 介面,從自己 fork 的分支發起 pull request 到原始儲存庫(通常稱為 upstream)的某個分支。
所產生的 Pull Request*(PR)會由其他貢獻者審核,可能會被通過、拒絕,或最常見的是被要求修改。經審核通過後,PR 會由核心開發者合併,其 commit 便會成為目標分支(通常是 *master)的一部分。
接下來,我們會透過一個範例來說明典型的工作流程和相關 Git 指令。不過在此之前,先快速介紹一下 Godot 的 Git 儲存庫結構。
Git 原始碼儲存庫
GitHub 上的儲存庫 是一個 Git 原始碼儲存庫,內含議題追蹤(Issue Tracker)與 PR 系統。
備註
如果你要貢獻說明文件,可以在 這裡 找到相關儲存庫。
Git 版本控制系統用於追蹤原始碼的連續修改。若想有效率地貢獻 Godot,強烈 建議學習 Git 指令列的基本操作。雖然有圖形化 Git 工具,但這些工具往往會讓使用者養成不良的 Git 或 PR 習慣,因此我們不推薦這樣做。特別是,對於程式碼貢獻,我們不建議使用 GitHub 的線上編輯器(雖然小幅修正或文件調整還可以),因為它會對每次單一檔案修改產生一個 commit,這樣 PR 的 Git 歷史很快就會變得難以閱讀(尤其經過同儕審查之後)。
也參考
想認識 Git 的理念及日常所需指令,可以參考 Git「Book」的前幾章。你可以在 Git SCM 網站線上閱讀,也可試試 GitHub 的互動教學。
Git 儲存庫的分支結構如下:
master分支是用來開發下一個主要版本的地方。作為開發分支,裡面的內容可能不穩定,不建議用於正式環境。大多數 PR 都應該優先對此分支提出。穩定版分支會以版本命名,例如
3.1、2.1。這些分支用來從master分支回補(backport)修正與改進到目前維護的穩定版(如 3.1.2 或 2.1.6)。通常,最後一個穩定分支會維護到下一版釋出(例如3.0會維護到 Godot 3.1 發佈為止)。如果想對穩定分支提出 PR,請先檢查更動是否也適用於master,若是,請優先對master提出 PR。釋出管理員會再視情況將修正 cherry-pick 到穩定分支。有時會有功能性分支,通常最終會合併回
master分支。
Fork 與 Clone
第一步是在 GitHub 上 fork godotengine/godot 儲存庫。你需要有 GitHub 帳號並登入後,在該儲存庫頁面右上角就能看到「Fork」按鈕:
點擊後稍等片刻,你會被導向你自己 fork 的 Godot 儲存庫,命名空間會以你的 GitHub 使用者名稱開頭:
接下來你可以 clone 你的 fork,也就是把線上儲存庫複製到本地端(在 Git 裡稱為 origin remote)。如果還沒安裝 Git,Windows 或 macOS 請從 Git 網站 下載,Linux 則可用套件管理員安裝。
備註
Windows 請使用 Git Bash 輸入指令,macOS 與 Linux 則可用各自的終端機。
要從 GitHub clone 你的 fork,請用以下指令:
git clone https://github.com/USERNAME/godot
過一會兒,在你的目前工作目錄中會出現 godot 資料夾。請用 cd 指令切換進去:
cd godot
接下來要設定一個指向原始儲存庫的參照:
git remote add upstream https://github.com/godotengine/godot
git fetch upstream
這樣會建立一個名為 upstream 的參照,指向原始的 godotengine/godot 儲存庫。方便之後從 upstream 的 master 分支拉取最新 commit 來更新你的 fork。另外還有一個 origin 參照,指向你自己的 fork(使用者名稱/godot)。
只要你保留本機的 godot 資料夾(可隨意搬動,相關資訊都在 .git 子資料夾),上述步驟只需做一次。
備註
建立分支、拉取、撰寫程式碼、暫存、提交、推送、rebase…… 全都是技術。
這段改編自 Daft Punk Technologic 的歌詞,反映了 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,因為其他貢獻者的 PR 已被合併進去了。
為避免你開發的功能與最新 upstream master 分支衝突,需要從 upstream 拉取(pull)最新內容來更新分支。
git pull --rebase upstream master
加上 --rebase 參數,會讓你的本地 commit 被「套用在」拉下來的 upstream 分支最前面,這是 PR 工作流程中推薦的做法。如此一來,開 PR 時,你的 commit 就會是跟 upstream master 唯一的差異。
進行 rebase 時,如果你的 commit 修改到和 upstream 分支相同的程式碼,可能會發生衝突。這時 Git 會在有衝突的 commit 停下來,要求你解決衝突。你可以用任何文字編輯器解決,然後將更動暫存(stage),再執行 git rebase --continue。如果後續 commit 也有衝突,請重複此步驟,直到 rebase 結束。
如果 rebase 過程讓你慌張(別擔心,大家第一次都會),可以用 git rebase --abort 中止 rebase,這樣會回到執行 git pull --rebase 之前的狀態。
備註
如果沒加 --rebase,會產生一個 merge commit,告訴 Git 如何合併這兩個分支。如果有衝突,這個 merge commit 會一次處理所有衝突。
雖然這是個可行的流程,也是 git pull 的預設行為,但我們的 PR 流程不建議在 PR 裡產生 merge commit。我們只會在將 PR 合併回 upstream 時才使用 merge commit。
這麼做的理念是,PR 應該代表對程式碼最終的變更狀態,我們不需要看到合併前那些中間修正與錯誤。Git 提供了優秀的「重寫歷史」工具,讓我們能讓歷史看起來一開始就正確。這有助於讓更動在合併後很久都能容易審查與理解。
如果你沒用 rebase,已經產生 merge commit,或造成了不理想的歷史紀錄,最好的辦法是對 upstream 分支進行 互動式 rebase。詳細步驟請見 專章。
小訣竅
任何時候想要 重設 本地分支到某個 commit 或分支,可以用 git reset --hard <commit ID> 或 git reset --hard <remote>/<branch>``(例如 ``git reset --hard upstream/master)。
注意,這麼做會移除目前分支上所有已提交的更改。如果不小心丟失 commit,可以用 git reflog 找回想要還原的 commit ID,再用 git reset --hard 回到那個狀態。
進行修改
你可以用自己慣用的開發環境(文字編輯器、IDE 等)來修改 editor/project_manager.cpp 這個檔案。
預設情況下,這些修改處於「未暫存(unstaged)」狀態。暫存區(staging area)就是介於你工作目錄(你編輯的地方)和本地 Git 儲存庫(所有 commit 與 .git 資料夾)之間的中介。要把更改從工作目錄帶進 Git 儲存庫,必須先用 git add 暫存,再用 git commit 提交。
你應該認識幾個常用指令,來檢查目前的修改狀態:無論是暫存前、暫存時,還是已提交之後。
git diff會顯示目前未暫存(unstaged)的更動,也就是工作目錄與暫存區之間的差異。git checkout -- <檔案>可以還原指定檔案的未暫存更改。git add <檔案>會將所列檔案的更改「暫存」(stage)。git diff --staged會顯示已暫存的內容,也就是暫存區與上一次 commit 間的差異。git reset HEAD <檔案>會將所列檔案的內容「取消暫存」(unstage)。git status會顯示目前有哪些已暫存、哪些未暫存的修改。git commit會將已暫存的檔案提交(commit)。預設會開啟文字編輯器(可用GIT_EDITOR環境變數或 Git 設定中的core.editor指定),讓你撰寫 commit 訊息。也可以直接用git commit -m "你的 commit 訊息"。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 與 upstream 都還不知道。
推送(push)更動到遠端
這時候就要用 git push 了。在 Git 裡,所有 commit 都是先在本地完成(不像 Subversion 會直接改遠端)。你需要 push 這些 commit 到遠端分支,讓大家都能看到。語法如下:
git push <remote> <local branch>[:<remote branch>]
如果遠端分支名稱跟本機分支相同,可以省略不寫。在這個例子就是如此,因此我們會這樣做:
git push origin better-project-manager
Git 會要求你輸入帳號密碼。密碼請填入你的 GitHub 個人存取權杖(PAT)。如果還沒有 PAT,或沒有正確權限,需要先建立一個。請參考這個連結:建立個人存取權杖。
帳號驗證成功後,修改內容就會被推送到你的遠端儲存庫。在 GitHub 上查看你的 fork,可以看到剛剛 push 上去的新分支與 commit。
建立 Pull Request
在 GitHub 上瀏覽 fork 的分支時,會看到一句話:「This branch is 2 commits ahead of godotengine:master.」(如果 master 分支沒和 upstream 同步,這個數字可能會更多)。
在那行旁邊會有「Pull request」連結。點下去會打開表單,讓你對 godotengine/godot 上游儲存庫建立 PR。這裡應該會顯示你的兩個 commit,並標示「Able to merge」。如果不是(例如 commit 太多、或有 merge 衝突),請先不要發 PR,這代表有地方出錯。可以到 Godot 貢獻者聊天室 尋求協助 :)
請為 PR 加上明確標題,並在留言區填寫必要細節。有需要可以拖曳截圖、GIF 或壓縮專案檔案,展示你的實作內容。點下「Create a pull request」,就完成了!
修改 Pull Request
在其他貢獻者審查 PR 期間,你常常會需要修改尚未合併的 PR,可能是因為審查者要求,也可能是自己測試時發現有問題。
好消息是,你只要在發 PR 用的分支繼續修改即可。你可以在該分支 commit 新內容,push 到 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,中間不應有修正自己 bug 或樣式問題的中間 commit。大多數情況下,我們希望一個 PR 只包含一個 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、錯字等,這些內容對未來讀 changelog 想追蹤 Godot 原始碼狀態或某檔案變動時,其實沒有意義。
要把這些多餘的 commit 壓進主 commit,就得 重寫歷史。沒錯,我們有這個能力。你或許聽過這麼做不是好習慣,這點在 upstream repo 分支上確實如此,但在你自己的 fork 裡,為了乾淨的 PR,怎麼做都可以 :)
我們會用 互動式 rebase ( git rebase -i )來處理。這個指令以 commit ID 或分支名稱為參數,讓你能修改從該點到你目前分支最新 commit(HEAD)之間的所有 commit。
雖然你可以給 git rebase -i 任意 commit ID 來檢查所有 commit,但最常見、最方便的做法是以 upstream master 分支為基礎做 rebase,指令如下:
git rebase -i upstream/master
備註
在 Git 裡參照分支時要注意區分本地與遠端分支。這裡的 upstream/master (有 /)是指從遠端 upstream 的 master 分支拉下來的本地分支。
互動式 rebase 只能在本地分支操作,所以這裡的 / 很重要。由於 upstream 常常有變動,你本地的 upstream/master 可能會過時,可以用 git fetch upstream master 來更新。git pull --rebase upstream master 會拉到你目前簽出的分支,而 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
編輯器同時會顯示可用的指令說明。簡單來說,「pick」表示直接使用該 commit(不變動),「squash」和「fixup」可以把 commit 合併 到上一個 commit。差別是「fixup」會直接丟掉被合併 commit 的訊息。在例子中,「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 會被合併進第一個,這時用 git log 或 git show 就能看到這兩次更動已經合而為一。
不過!你剛剛已經重寫了歷史,所以本地和遠端分支會出現分歧。例如上例中的 1b4aad7 commit 內容已經變了、hash 也不同。如果你此時嘗試 push,會出現錯誤:
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://[email protected]/akien-mga/godot'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart.
這是正常現象,Git 不會讓你直接覆蓋遠端內容。但這次我們就是要強制這麼做,所以要用 force push:
git push --force origin better-project-manager
完成!Git 會用你本地的分支 取代 遠端分支(請先用 git log 確認內容正確)。這樣 PR 也會自動更新。
將變更 rebase 到其他分支
如果你不小心在錯誤的分支上發了 PR,或因某些原因需要更換目標分支,可能必須篩掉舊分支(如 4.2)與新分支(如 master)之間的許多 commit。這會讓 rebase 變得困難又繁瑣。幸好 Git 有專為這種情境設計的 git rebase --onto 指令。
假設你的 PR 是從 4.2 分支建立,而你想要改從 master 開始,只要依下列步驟操作,應該 就能一次完成:
git rebase -i --onto master 4.2
這會把你分支上「在 4.2 之後」的所有 commit,直接接到 master 上,並忽略 4.2 分支上不屬於 master 的 commit。雖然可能還需要手動修正,但這個指令能省下大量移除 commit 的繁瑣工夫。
跟前面互動式 rebase 一樣,這時你也需要強制 push 分支才能處理這些不同:
git push --force origin better-project-manager
刪除 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 合併或關閉後應該會出現相關按鈕。