十年 Legacy 專案導入 Git 工作流(一):先診斷,再談分支模型


系列文章:這是「十年 Legacy 專案導入 Git 工作流與 CI/CD」四篇的第一篇。 (一)診斷與分支模型 ← 本篇 · (二)CI 第一階段:零誤報 · (三)SQL Migration 版本化 · (四)部署、回滾與 E2E

中文圈談 CI/CD 的文章,幾乎清一色是綠地專案:新專案、有測試、單一資料庫、團隊從第一天就照規矩來。

而多數人的真實處境是另一種:十年的 PHP 專案、多租戶、零自動化測試、部署靠一支沒人敢動的 rsync 腳本。 這種專案要導入現代工作流,照抄教學會死得很難看。

這個系列記錄一份 12 週的導入規劃。第一篇談最容易被跳過、但決定後面所有事的一步:診斷


第一件事:不要從「該用哪套工作流」開始

導入這類制度時,最常見的開場是「我們要不要改用 Trunk-based?」或「該不該引入 develop 分支?」——然後開三次會,每次都在講同樣的抽象權衡。

正確的開場是掃 repo 拿數字。掃完之後,這個專案的結論是:

這題不是「該選哪套工作流」。我們已經在跑一套接近 GitLab Flow 的做法:feature/issue-N → PR → release/YYYYMMDD → 合回 main,PR 機制運作中。 真正的缺口在自動化與資料庫變更管理。

換句話說,那三場關於分支模型的會議,全部可以不用開。


六個數字

指標數字
CI 設定檔0沒有任何自動檢查
遠端分支709合併完沒人刪
近 90 天「需執行 SQL」的 commit158平均 1.7 天一次
sql/ 目錄的 .sql348另有子目錄 47 支,命名三套
一個 release 分支的壽命2–7等於沒有凍結窗口
單一成員 commit 量第二名的 4–5 倍審查負載極度集中

這六個數字每個都對應一個具體問題:

  • 0 個 CI 設定檔——所有錯誤都得等人看出來。一個少打分號的語法錯誤,可能要到 QA 開頁面看到白屏才發現,而機器 15 秒就能抓到。
  • 709 個遠端分支——大部分早就合併完了。分支列表長到找不到東西,之後寫自動化腳本也得先繞過這堆垃圾。
  • 158 次「需執行 SQL」——平均每 1.7 天就要有人手動連進 N 個租戶資料庫執行腳本。漏掉一家,那家客戶就會在某個功能上看到錯誤或空白,而且通常是客戶先發現
  • 分支命名分兩派——feature/issue-N(團隊派)與 Dev/issue-N(個人前綴派)。之後 CI 要用萬用字元比對分支名、自動清理腳本要判斷哪些能刪,兩套命名就得寫兩套特例。
  • release 節奏太碎——有時兩天一個。QA 根本沒有完整的驗收期,等於「凍結」這件事不存在。
  • 貢獻集中度 4–5 倍——如果 review 不輪值,新流程上線後瓶頸只會從「沒人 review」變成「都在等同一個人」。

診斷帶來的最大價值:風險排序

拿到數字之後,可以做一件開會做不到的事——排序

SQL migration 的風險大於分支策略的風險。 90 天內 158 次「需執行 SQL」,每次都要對 N 個租戶資料庫手動執行,漏一家就是靜默的資料錯誤。 如果 12 週只能做一件事,做 migration 那段。

這個判斷很反直覺。分支策略好談、大家有意見、開會很熱鬧;migration 沒人想碰,但它才是會讓客戶先發現問題的那個。

別把力氣花在討論度最高的事情上。


為什麼不轉 Trunk-based

網路文章會告訴你「現在都流行 Trunk-based,大家直接往主線提交」。那套在這裡會出事,因為它有三個前提,這個專案一個都沒有

Trunk-based 的前提這個專案的現況
高覆蓋率的自動測試沒有 PHPUnit,沒有測試網
Feature flag(先把新功能藏起來)沒有
灰度部署(先開放 5% 使用者)rsync 是全站同步,做不到

再加上醫療系統 + 多租戶:一次錯誤的影響面是「N 家機構同時中」。所以必須要有凍結窗口與集中驗收

反過來說,完整版 Git Flow(多一層 develop 分支)對 8 人團隊又太重。現在少那一層反而是對的,維持四類分支就好。

這個結論的形狀值得記住:不是選一套「最好的」工作流,是確認你有沒有付得起某套工作流的前置成本。


四種分支:先用比喻理解

給新人的說明用了一個雜誌的比喻,我覺得比任何流程圖都好懂:

分支比喻
main已經印出去、客戶手上那一期(動它就是動正式機)
release/YYYYMMDD這一期的排版稿,各篇稿件都收進來,交給校對(QA)看過就送印
feature/issue-N你正在寫的那一篇稿,寫壞了也不影響別人
hotfix/issue-N已經印出去才發現錯字,得單獨補一張勘誤——而且下一期的排版稿也要同步改,不然下期又印錯

最後那半句是重點,後面會展開。

main

正式機的真相:這個分支上有什麼,客戶就在用什麼。

設為 protected,禁止直接 push、禁止 force push,只接受來自 release/*hotfix/* 的 PR。

release/YYYYMMDD — 規矩①:固定節奏

本次上線的集結地,從 main 切出來。

建議「每週三切、週四 QA、週一上線」或改雙週一次。切了就凍結:之後只進 bugfix,不進新功能。

凍結後還塞新功能,QA 前面驗的就白驗了。

要讓凍結成真,公告必須具體到星期幾與時間:「週三 17:00 之後進來的新功能一律排下一期」。沒有這條,「凍結」就只是說說。

feature/issue-<id>-<topic> — 規矩②:廢除個人前綴

從當前的 release/* 切出來。

廢除 Dev/ 這類個人前綴——PR 本身已經帶作者資訊了,個人前綴只會讓 CI 的分支比對規則、自動清理與保護規則全部得寫特例。

存活時間目標 ≤ 3 天,超過就該拆成兩張 PR。

分支活得越久,你跟主線的距離越遠,最後合併的痛苦是指數成長的。

hotfix/issue-<id>

正式機出包才用。從 main 切,合回 main 之後一定要同時再合進當前的 release/*

否則下次 release 上線時,會把你剛修好的東西蓋回壞掉的版本

這是 release 分支流最經典、也最常犯的坑。

值得展開講一下為什麼這麼容易犯:hotfix 修完、上線、問題解決了,所有回饋都告訴你「這件事做完了」。而漏掉的那一步要兩週後才會以「同一個 bug 又出現了」的形式回來——那時已經沒有人會聯想到是 hotfix 沒回填。

沒有立即回饋的步驟,一定要靠流程或自動化來記,不能靠人記。

規矩③:合併後自動刪分支

在 PR 設定開啟「Delete branch after merge」。

已合併的分支刪掉不會遺失任何 commit——那些 commit 已經在 release 裡了,刪的只是那個指標名字。這句話要對團隊講清楚,否則沒人敢按。


清理 709 個分支:怎麼安全地刪

一次性清理,但順序是先備份 → 產清單 → 人工看過 → 才刪

# 0) 整包備份,出事還原得回來(放安全的地方,別放 repo 內)
git bundle create ../all-refs-$(date +%Y%m%d).bundle --all

# 1) 列出「已經合併進 main、而且 60 天沒動」的分支
git fetch --prune
cutoff=$(date -d '60 days ago' +%s)
git for-each-ref --format='%(refname:short) %(committerdate:unix)' refs/remotes/origin \
| while read -r ref ts; do
    case "$ref" in origin/HEAD|origin/main|origin/release/*) continue;; esac
    [ "$ts" -lt "$cutoff" ] || continue
    git merge-base --is-ancestor "$ref" origin/main && echo "${ref#origin/}"
  done > /tmp/to-delete.txt
wc -l /tmp/to-delete.txt

# 2) 人工掃一遍 /tmp/to-delete.txt,確認沒有還要用的

# 3) 分批刪,一次 20 個
xargs -n 20 git push origin --delete < /tmp/to-delete.txt

關鍵在 --is-ancestor

它保證只刪「內容已經在 main 裡」的分支。沒合併過的分支根本不會出現在清單上,所以這個清理是安全的。

這是我很喜歡的一種腳本設計:安全性不是靠使用者小心,是靠篩選條件本身在數學上就排除了危險項。 你不需要人工判斷每一個分支能不能刪,只需要人工掃一遍確認沒有例外。


PR 審查:門檻要分級,不要一刀切

8 人團隊一律要求 2 人 approve 會直接塞車。這是最常見的過度設計。

情境門檻
一般功能/UI/表單調整1 人 approve
高風險路徑2 人 approve
hotfix → main1 人,但限定人(Tech Lead 或當週值班者)

高風險路徑用 CODEOWNERS 指定 + 分支保護綁定,建議列入:

  • sql/**(任何 migration)
  • 健保申報/總表 XML 產生器
  • 金額計算(自付額/繳款單/薪資)
  • AppSystem/class/DBPDO*.php 等共用底層
  • Docker/**bin/**

共通點是「錯了會影響全部機構或牽涉金錢」。

配套三條:

  • 值班輪值制:每週指定 2 名 reviewer 認領一般 PR,用來打散集中在單一成員身上的審查負載。
  • 首次回應 SLA:4 個工作小時內(先回一句「我看了」也算),超時由值班者接手。
  • PR 大小上限:diff ≤ 400 行(不含自動產生的檔案)。超過請拆——研究與實務都顯示,超過 400 行之後 review 品質會斷崖式下降,看的人只會回一句 LGTM。

第一次幫人 review,看這三件事就好

給新人的指引,我覺得寫得很好:

  1. 對照需求規格:該做的有沒有漏、有沒有做了規格沒要求的東西(多做也是風險)。
  2. 對照專案雷區清單
    • SQL 有沒有用字串內插把變數直接拼進去(最常見的 bug 根因,也是資安漏洞)
    • 機構客製的判斷條件有沒有寫對
    • 編輯版改了,對應的列印版/匯出版/申報版有沒有一起改——這是最常見的「上線後客戶才發現」類型
  3. 有沒有可以照著點的測試步驟:看不懂怎麼驗,就代表 QA 也不會知道怎麼驗。

還有一句給新人的話:

看不懂某段程式在幹嘛,直接問就是正確做法,不要因為看不懂就 approve。 新人問出來的問題,常常正好是原作者沒想清楚的地方。

PR 模板

必填欄位設計成「之後 CI 驗得到」的形狀:

## 需求單
- issue: https://redmine.internal/issues/____

## 變更說明
issue-____ 【模組中文名】做了什麼

## 需執行 SQL
- [ ] 需要 → 檔案:sql/issue-____.sql
- [ ] 不需要

## 影響範圍
- [ ] 全機構
- [ ] 特定機構(mOrgID:____)
- [ ] 特定模組:____

## 測試步驟
1.
2.

## 同步檢查(表單類必填)
- [ ] 編輯版 - [ ] 列印版 - [ ] 匯出版 - [ ] 申報版 - [ ] 不適用

最後那個「同步檢查」區塊是這個專案獨有的——它把「最常見的上線後災難」變成一個 checkbox。


一個 Legacy 專屬地雷:assume-unchanged

這個必須單獨講,因為它會讓所有流程失效。

專案的 AppSystem/ 整棵樹被設了 assume-unchanged。意思是:

你改了那裡面的檔案,git status 也不會顯示git add . 也加不進去—— commit 出去會靜默漏檔,你以為改好了,但推上去的是半套。

處理方式:

# 列出被標記的檔案(開頭是小寫 h 的就是中招的)
git ls-files -v | grep '^h'

# 解除單一檔案的標記
git update-index --no-assume-unchanged AppSystem/module_a/module/module_b/form123.php

# 或者不解除,直接明確加入
git add AppSystem/module_a/module/module_b/form123.php

養成習慣:永遠明確 git add <完整路徑>,不要用 git add .

提交前的自我檢查:git show --stat HEAD 看一下這個 commit 到底收了哪些檔案,跟你以為改的對得起來嗎?

這個坑還有一個重要性質:CI 抓不到它。那是本地 index 的旗標,推上來的 PR 裡看不見。它只能靠本機 pre-commit hook 擋——這件事會在系列第二篇處理。


第一週:完全不改任何人的工作方式

導入計畫的第一週目標是這樣寫的:

做完的樣子:CI 平台確定可用、runner 在跑、分支列表乾淨、新人有一份可讀的 CONTRIBUTING.md這週完全不改任何人的工作方式。

我認為這是整份規劃裡最聰明的一句話。

導入失敗最常見的原因不是技術,是團隊在第一週就感受到成本、卻還沒感受到好處。先做完全不影響日常的準備工作,讓基礎設施就位,等到真的要改習慣時,配套已經在那裡了。

第二週也一樣:PR 模板、分支保護、CODEOWNERS——「這週不需要任何工具,只有設定」

真正會改變日常的 CI,排在第三週之後。

一場 60 分鐘的會,四件事當場定案

  • 0–15 分:看那六個數字(不做簡報,直接看數據)
  • 15–30 分:定分支命名(廢除個人前綴)與 release 節奏(要講到星期幾
  • 30–45 分:定 review 門檻與值班名單(誰跟誰一組、輪幾週)
  • 45–60 分:分工——誰顧 runner、誰寫 CI workflow、誰負責 migration

會後把四項決議寫進 wiki 並貼進 CONTRIBUTING.md

用數據開場而不是用簡報開場,是這場會能在 60 分鐘結束的關鍵。六個數字擺出來,「要不要做」就不會再是議題,只剩「怎麼做」。


小結

這篇沒有任何一行 CI 設定,但它決定了後面三篇的內容:

  1. 先診斷再開藥。 六個數字花不到半天掃出來,卻直接砍掉三場會議,並且指出真正的頭號風險不是大家想討論的那個。
  2. 工作流的選擇是「前置成本付不付得起」,不是「哪套比較先進」。 沒有測試網、沒有 feature flag、沒有灰度部署,就別碰 Trunk-based。
  3. 沒有立即回饋的步驟,要靠流程記,不能靠人記。 hotfix 回填 release 是典型。
  4. 安全性要設計進篩選條件,不是靠人小心。 --is-ancestor 讓危險的分支根本不會進入待刪清單。
  5. 前兩週不要改變任何人的日常。 讓基礎設施先就位。

下一篇進入真正會改變日常的部分:CI 的第一階段,以及為什麼它的設計目標是零誤報而不是抓得多。


相關文章: