Skip to content

refactor(git): 考慮移除 git2 相依,將最後 12 處呼叫點改走 git CLI #308

Description

@yw-chan

問題

src-tauri/src/modules/git/mod.rs 其實早就跑在系統的 git binary 上:非測試碼有約 96 處呼叫點透過 run_git / git_commandmod.rs:1061-1107)。只剩 12 處還在用 git2。它已經是一個殘留的第二套 git 後端,而我們為它付兩種代價:

  1. 一個原生 C 相依。 git2 0.19 會帶入 libgit2-sys 0.17.0+1.8.1,每次 clean build 都要編譯內附的 libgit2。它在 windows-build.yml:6.claude/skills/windows-tauri/SKILL.md:99 都被點名為讓 Windows bundle 無法跨編譯的原因之一,而且已經為它加過 default-features = false 的 workaround(版本0.3.0無法在MacOS上開啟 #262:動態連結到建置機的 Homebrew OpenSSL,導致啟動即崩潰)。
  2. 同一個功能有兩套行為模型。 這個分歧在程式碼裡已經留下記錄:mod.rs:851-857 把 graph log 從 libgit2 revwalk 改回 CLI,因為 libgit2 必須 eager 走完整份可達歷史(O(repo size))才能吐出第一筆。而 run_git 自己的註解(mod.rs:1079-1080)就寫著走 CLI 是為了「沿用使用者設定的 credentials/helpers」。

關於效能

這個改動的訴求不是效能,請不要當成效能改善來評估。 誠實的預期是:那 6 處 O(1) 的 ref 讀取會變慢(從 in-process 讀幾個檔案變成一次 process spawn,Windows 上約十幾到數十 ms —— mod.rs:1064-1069 記錄過沒有 CREATE_NO_WINDOW 時每次 spawn 約 +100ms,有旗標後小很多但不是零)。這些全都在使用者點擊觸發的路徑上,實際上感受不到。唯一可能變快的是 statusworktree_dirty_count 這兩處走檔案系統的操作,因為真 git 會使用 libgit2 不用的 index extensions(untracked cache、fsmonitor)—— 但這只在大型 repo 成立,小 repo 上 spawn 成本會蓋過收益,必須實測、不要假設

提議的變更

把這 12 處轉成 CLI,然後刪掉相依。不動任何 IPC 介面、也不動前端:GitStatusWorktreeInfoBranchInfoFileStatus 的結構完全不變。

函式 目前的 git2 用法 建議的 CLI
resolve_repo:177 Repository::discover + workdir rev-parse --show-toplevel
status:183 statuses() + index_status/worktree_status:143:160 status --porcelain=v2 -z -uall --branch —— 一次 spawn 拿完# branch.head 欄位一併給出分支名(未出生分支回 (detached) 以外的正確值),不必另外叫 rev-parse
head_shorthand:242 head(),fallback find_reference("HEAD") 不需要獨立命令 —— 它是被 statusworktree_info 呼叫的內部函式,兩邊都改成在自己那一次呼叫裡順便取得分支名(見上下兩列)
worktree_info:259 discoveris_worktree、往上找 git-dir 祖先(:279-290 rev-parse --show-toplevel --git-common-dir --git-dir --abbrev-ref HEAD —— 一次 spawn 拿完rev-parse 會依序輸出各項)。git-dir ≠ common-dir 即為 linked worktree,common-dir 的 parent 就是主工作樹
worktree_add 的分支預檢(:501 find_branch(_, Local) show-ref --verify --quiet refs/heads/<branch>
worktree_dirty_count:710 statuses().len() status --porcelain -z -uall,計算筆數
stage:766 index.add_path 失敗再 remove_path add -- :(literal)<path>(同時涵蓋刪除的情況)
unstage:778 reset_default,未出生時改動 index reset -q HEAD -- <path>;HEAD 未出生時用 rm --cached -- <path>
commit:797 write_tree + repo.commit + Signature commit -m <message>
branches:1345 branches(Local) / branches(Remote) for-each-ref --format=%(refname:short)%00%(HEAD) refs/heads refs/remotes(保留 :1377 現有的 */HEAD 跳過邏輯)
pr::remote_origin_urlpr/mod.rs:104 discover + find_remote("origin").url() remote get-url origin
測試(:2680:2748 Repository::init + config.set_str 其他測試已在用的 run_git(["init","-b","main"]) + run_git(["config", ...]) fixture

實作要求:一個原本 in-process 的操作,轉換後不得變成多次 spawn。 天真的逐一對應會讓 status 從 1 次變 2 次(因為 head_shorthand 是它呼叫的內部函式)。上表已經把每個操作都收斂成單一命令,請維持。

請重用既有機制、不要新增 helper:NUL 分隔的 status 解析在 copy_local_files:645status --porcelain -z --ignored)已經做過;:(literal) pathspec 在 restore_file:1168)已經在用;ensure_not_flag:1114)負責擋 argv flag smuggling;git_command 已經設好 CREATE_NO_WINDOW,所以新增的 spawn 在 Windows 上不會閃視窗。

建議拆成兩個 PR:先做唯讀的那幾處(resolve_repohead_shorthandworktree_infobranchesworktree_dirty_countstatusremote_origin_url、測試),再做寫入的三處(stage / unstage / commit)—— 下面那些行為決策都落在後者。

動工前需要先決定的行為變更

以下是真正的語意差異,不是機械替換:

  1. Commit 身分的 fallback。 目前 commituser.name/user.email 未設定時,會 fallback 到硬編碼的 TempoTerm <tempoterm@localhost> 簽名(:806-809);git commit 則是直接報錯。選項:偵測到設定缺失時注入 -c user.name=… -c user.email=… 以維持現狀,或是讓 git 的錯誤浮到使用者面前(後者可能更正確 —— 現在的 fallback 會默默用一個假身分寫下 commit)。
  2. hooks 與簽章會開始生效。 git commit 會跑 pre-commit / commit-msg,也會遵守 commit.gpgsign;libgit2 兩者都不做。對開發者工具來說這幾乎肯定是使用者預期的行為,但它是 user-visible 的變更,而且把使用者的任意程式放進了 commit 路徑。建議考慮設上限 —— wait-timeout 已經是相依,當初就是為了 gh spawn 的同樣理由加的。
  3. git add 會走 .gitattributes 的 clean filter 與 Git LFSindex.add_path 不會。同樣是改善,同樣是變更。
  4. 未追蹤檔的遞迴行為必須保持。 recurse_untracked_dirs(true):191:715)對應的是 -uall不是 git 預設的 normal。漏掉 -uall 會讓未追蹤目錄塌成單一筆,同時改掉檔案清單與 dirty count。
  5. 狀態碼映射。 index_status/worktree_status:143-174)回傳固定優先序的單一字元,且 WT_NEW?。從 porcelain 的 XY 碼映回來時必須完全一致,包含 rename(R)與 ??? 的轉換,否則版本控制面板的圖示會跑掉。

不在範圍內

  • 不是改用 gix 已評估並排除:push、完整的 merge 流程、rebasecherry-pick/revert,以及 linked worktree 的 add/remove/prune 在上游都還沒實作(crate-status.md),因此 gix 無法取代 worktree 管理、push、merge/rebase 這些功能所依賴的 CLI 層。而若只把這 12 處 git2 換成 gix,跨編譯的阻礙依然存在(font-kitportable-ptyrusqlite 都還在),卻要多帶約 40 個 crate 與一組 0.x 的 API 要跟版。
  • 不動任何 #[tauri::command] 介面,也不動前端。

驗收標準

  • src-tauri/Cargo.tomlCargo.lock 裡不再有 git2;lockfile 裡不再有 libgit2-sys
  • grep -r git2 src-tauri/src 沒有結果;README.md:78(以及 zh-Hans / zh-Hant 兩份)、windows-build.yml:6.claude/skills/windows-tauri/SKILL.md:99 裡的 git2 描述都已更新。
  • PR 內記錄前後的 clean build 時間與 release binary 大小。
  • PR 內記錄 statusworktree_dirty_count 的前後耗時,各在兩種 repo 上量:一個小 repo,以及一個裝好 node_modules 的大 repo。這是唯一可能出現效能回歸的地方,不量就不知道。
  • 每個轉換後的操作都只 spawn 一次 git(見上面的實作要求)。

測試計畫

  • cargo test —— 擴充 git/mod.rs 的測試以涵蓋:未出生分支(status 與 head_shorthand)、含空白與非 ASCII 字元的路徑、HEAD 未出生時的 unstageuser.name/user.email 未設定時的 commit、被 rename 的檔案、含巢狀檔案的未追蹤目錄(驗證 -uall)。
  • cargo clippy -- -D warnings;PR 上跑 windows-check.yml,並手動 dispatch 一次 windows-build.yml 確認綠燈。
  • npx pnpm exec tsc --noEmitnpx pnpm exec vitest runApp.test.tsx / App.windows.test.tsx 的 mock 有提到 git2,要一起檢查)。
  • 手動驗證:版本控制面板的 stage / unstage / commit / diff;worktree 管理器每列的 dirty 圓點;分支清單的目前分支標記;在有 origin 的 repo 上做 PR 查詢。
  • CHANGELOG-NEXT.md:若行為維持完全一致則不需要條目;若 hooks / 簽章 / LFS 開始生效(第 2、3 點),那是 user-visible 的變更 → 在正體中文與 English 兩區的 feat 下各補一條。

#309 的關係

互相獨立,可任意順序進行。 #309(事件驅動的 git 狀態刷新)決定的是「何時」跑 git,本 issue 決定的是「用什麼」跑。唯一交集是上表的 worktree_info:改成 rev-parse --show-toplevel --git-common-dir --git-dir --abbrev-ref HEAD 之後,取到的 git-dir / common-dir 區分正是 #309 的 watcher 監看集合所需要的資訊 —— 抽成共用 helper 兩邊都能用。

以上由 ai 整理提供

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions