Xcode 的 project.pbxproj 檔案採用文字格式儲存專案結構,但 Xcode 在新增檔案或修改設定時,不保證項目的插入順序一致。多人協作時,即使修改不同的檔案或 target,也可能因為項目順序差異而產生 merge conflict。這些衝突往往與實際變更無關,純粹是格式問題。

我的解決方案

project.pbxproj 中的各個區塊按固定規則排序,確保相同內容產生相同的檔案結構。配合 git pre-commit hook,每次提交前自動排序,團隊成員的專案檔就能維持一致的順序,大幅降低無意義的衝突。

我開發了一個腳本工具來執行這個任務,也已經在多個專案上跑了好幾年,GitHub repo 放在這裡:https://github.com/chiahsien/sort-Xcode-project-file

雖然目前推崇使用 Swift Package Manager 進行模組化,Xcode 16 也引入了 buildable folders 功能來減少專案檔變更,甚至也有 Tuist 或 Xcode Gen 這類的工具來生成專案檔,但這些新技術主要針對新專案或願意大幅重構的專案。對於已經開發多年、結構複雜的舊專案,貿然改用 SPM 模組化或轉換成 folder references 風險過高。此時,這個排序工具仍是最務實的選擇,能以最小成本解決 merge conflict 問題。

排序範圍

此工具排序以下 array 結構:

  • children — group 內的檔案與 subgroup(目錄排在檔案前面)
  • files — build phase 的檔案列表
  • buildConfigurations — build configuration 列表
  • targets — 專案 target 列表
  • packageProductDependencies — Swift Package product dependency
  • packageReferences — Swift Package reference

安全性

這些 array 是宣告性內容,Xcode 透過 24 字元的十六進位 ID 參照物件,而非依賴位置。排序不影響建置行為或專案結構,僅改變檔案內的呈現順序。

不排序的區塊:PBXFrameworksBuildPhase section 的 framework 連結順序會影響符號解析,工具會偵測到這個區塊並完整保留原始順序。其餘不在上述排序範圍內的 array(例如 buildPhases)則不會被處理,同樣維持原始順序。

寫入方式採用原子操作(透過暫存檔 + os.replace()),即使過程中發生錯誤,也不會留下損壞的 .pbxproj 檔案。

警告:
雖然這個工具已經在多個不同專案執行很長一段時間了,我還是強烈建議在修改之前先做好備份,才不會出現難以挽回的錯誤!

與原版的差異

本版本是 WebKit 專案的 fork,以 Python 3 重寫並新增以下功能:

  • Natural sorting:數字部分按數值比較,file2.m 排在 file10.m 前面,符合人類直覺
  • Case-insensitive 選項:提供 --case-insensitive 參數支援不分大小寫排序,預設仍為 case-sensitive 以保持原始行為
  • 目錄優先排序children array 中目錄排在檔案前面,符合檔案系統慣例
  • 自動去除重複:移除重複的項目 reference
  • 擴充排序範圍:包含所有 children array、files array、targets 列表、packageProductDependenciespackageReferences
  • CI 檢查模式--check 參數可檢查檔案是否已排序,不修改檔案,適合整合到 CI pipeline
  • 遞迴搜尋-r 參數可遞迴搜尋目錄下所有 project.pbxproj 並排序,適合 monorepo
  • Stdin/stdout 支援:使用 - 參數可從 stdin 讀取、寫到 stdout,方便管線操作
  • 原子寫入:透過暫存檔 + os.replace() 確保寫入過程不會損壞原始檔案

使用方法

基本呼叫

python3 sort-Xcode-project-file.py path/to/Project.xcodeproj

腳本會自動找到 project.pbxproj 並就地排序。也可以直接指定 project.pbxproj 檔案路徑。

選項

# 使用 case-insensitive sorting
python3 sort-Xcode-project-file.py --case-insensitive Project.xcodeproj

# CI 檢查模式:exit 0 = 已排序,exit 1 = 未排序(不修改檔案)
python3 sort-Xcode-project-file.py --check Project.xcodeproj

# 遞迴搜尋目錄下所有 project.pbxproj 並排序
python3 sort-Xcode-project-file.py -r .

# 從 stdin 讀取,寫到 stdout
cat project.pbxproj | python3 sort-Xcode-project-file.py - > sorted.pbxproj

# 抑制 warning 訊息
python3 sort-Xcode-project-file.py -w Project.xcodeproj

# 顯示版本號
python3 sort-Xcode-project-file.py --version

# 顯示說明
python3 sort-Xcode-project-file.py --help

Git Pre-commit Hook 整合

在專案根目錄建立 Scripts 目錄,將 sort-Xcode-project-file.py 放進去,然後建立 .git/hooks/pre-commit

#!/bin/sh

echo 'Sorting Xcode project files'

GIT_ROOT=$(git rev-parse --show-toplevel)
sorter="$GIT_ROOT/Scripts/sort-Xcode-project-file.py"

git diff --name-only --cached | grep "project.pbxproj" | while IFS= read -r filePath; do
  fullFilePath="$GIT_ROOT/$filePath"
  python3 "$sorter" "$fullFilePath"
  git add "$fullFilePath"
done

echo 'Done sorting Xcode project files'

記得設定執行權限:

chmod +x .git/hooks/pre-commit

另外可以在 .gitattributes 加上以下設定,進一步減少合併衝突:

*.pbxproj merge=union

注意: merge=union 會讓 Git 自動保留衝突的雙方內容。搭配排序工具使用效果很好,但如果專案檔沒有經過排序,可能會產生無效的結果。請確保團隊成員都有使用這個排序工具。