把 106 個 commit 合進開了兩週半的分支:44 個衝突我們怎麼分類與解
目錄
長期分支合主線,44 檔衝突真正難的是每一檔都臨場判斷,前後判準不一致。我們這次先分類再解:衝突檔分成四類,每類一條預設原則,例外才逐檔想;解完跑兩段測試、守住主包大小門檻、再上預覽站用測試帳號走一遍。結果 44 檔全解、測試全過,主包比合併前還小,走查還抓到一個 git 和測試都沒發現的漏掛轉址。
為什麼會累積到 44 檔?
MindThread 2.0 是重做介面的分支,10 月 17 日從主線開出來。前十天我們幾乎每天把主線合進來一次。10 月 27 日之後,2.0 這邊忙著補功能,一週沒同步。這一週主線長了 106 個 commit,跟共同祖先比動了 350 個檔;2.0 這邊自上次同步後也新增了 22 個 commit。
11 月 3 日稍早盤點時試合過一次,是 92 個 commit、38 檔衝突;晚上真正動手,變成 106 和 44。幾個小時就多 6 檔,這是第一個教訓:長期分支的同步不能停。
合併是派給一個 Claude Code 工人做的。工單裡先寫好分類方式和每一類的原則,它交回來的報告逐檔列出怎麼解、為什麼。
四類衝突,各一條原則
先用 git diff --name-only --diff-filter=U 列出衝突檔,照路徑分類:
- 共用程式,25 檔(後端 API、共用的 lib、hooks、元件):以主線為底,逐段補回 2.0 的改動。主線這一週修的是線上會員真的遇到的問題,不能丟;2.0 的改動是新功能,例如站內助理小麥、免註冊試寫、總覽三塊,也不能丟。
- 舊版專屬頁面,5 檔:照主線。舊版還在線上,2.0 對這些頁面本來就很少改。
- 新版專屬頁面,3 檔:照 2.0,主線的改動只挑需要的。設定頁只帶進主線新增的「刪除帳號」卡片,放進「一般」分頁;主線改到的團隊區塊 2.0 沒用到,就不帶。
- 設定與資料檔,7 檔(package.json、lock 檔、vercel.json、兩份語系檔、.gitignore、維護文件):兩邊都要。
測試檔 4 個不另成一類,跟著它測的那一側走:測主線後端行為的照主線,測 2.0 預設值的照 2.0,兩邊案例都要的就合寫。
分類先定了每一檔的預設方向,要想的只剩例外。
共用程式最難:要看呼叫端才知道怎麼解
Threads 發圖片的函式是個例子。2.0 在第 6 個參數加了一個測試用的等待設定;同一週主線也在第 6 個位置加了 replyToId,用來把影片說明拆成串文。只看函式本身,兩種排法都合理。但正式的呼叫端是主線寫的,第 6 個參數傳的就是 replyToId,如果照 2.0 的排法,它會落進等待設定的位置。所以簽名改成 …, topicTag?, replyToId?, wait,等待設定移到最後(只有測試會傳它),對應的測試也照新順序改。
其他幾種典型:
- 兩邊各加一個狀態,兩個都留。 讀訂閱方案的 hook,2.0 改成看最後一份資料是哪個使用者的來判斷「讀取中」,主線加了「讀取失敗」旗標,讓方案讀不到時不會被當成免費版擋住。
- 流程合寫要排順序。 移除帳號這件事,2.0 先檢查擁有者、取消 IG 和 FB 的預約;主線改成交給伺服器清乾淨,連帶把孤兒草稿轉走。合起來的順序是:檢查擁有者、取消預約、伺服器移除,預約已取消但伺服器失敗時丟一個專用錯誤碼,最後清快取。
- 一邊刪、一邊改。 2.0 已經刪掉舊的重新授權橫幅,改由統一的提示列處理;主線還在改它。查過沒有任何地方 import,照 2.0 刪掉。
- 同一段兩邊都對,各取一半。 帳號額度通知信,連結留 2.0 的
/settings/plan?addon=1,因為 2.0 沒有 /pricing 頁;收件人改用主線新寫的函式,被停權的帳號不再收信。
設定檔按 key 合,不要按行合
package.json 和語系檔都是 JSON,按行合很容易合出重複的 key 或少一個逗號。我們按 key 做三方合併:
- package.json:主線新的 scripts、overrides、套件版本都帶進來,2.0 的兩段 prebuild 保留,主線的測試接在後面。
- lock 檔:不手改。取主線那份當底,再跑一次
npm install補上 2.0 多的依賴。 - vercel.json:兩條 SPA 改寫的路徑清單取聯集;cron 合起來 40 條,排程頻率不同的那條(自動回覆)用主線的每 5 分鐘。
- 語系檔:主線這一週在每份語系檔各新增 352 個葉節點,沒有任何一個 key 是兩邊改成不同內容的。按 key 合完,一條都不用人工挑。
兩段測試,外加一條「測試有沒有接上」的測試
2.0 的建置前檢查分兩段:第一段是 2.0 自己的測試,第二段我們把主線的 8 支測試和它的 run-tests.mjs @prebuild 名單接在尾端。合併把主線的測試檔帶了進來,但沒接進建置流程的話,它們永遠不會跑。所以另有一支檢查會核對「名單上的測試是不是都接進建置前檢查」,這次也改成認得主線的名單。
有幾支測試的斷言要配合 2.0 的結構調整,例如路由檢查改看舊版那棵路由樹和 2.0 的轉址表、一支檢查同時接受兩種狀態寫法、一份測試資料補上帳號編號。原則是:改的是測試「去哪裡找」,不是「要求什麼」。
主包超標,不准抬門檻
合併後主包是 148.8 KB(gzip),門檻 143.3。多出來的主要是主線帶進來的語系字串(約 7.7 KB),以及更新提示、離線提示、安裝提示這幾個模組。
2.0 的 bundle 測試明文禁止用更新基準的方式把門檻抬高,這條就是為了這種時候寫的。我們改成把頂列的鈴鐺和回饋鈕延遲載入,載到之前放同尺寸的空位,頂列不會跳版。主包降到 135.2 KB,比合併前 2.0 的 139.4 還小。
沒解決的也記下來:首屏總量(gzip)從基準 336 KB 漲到 401.8 KB。這個數字沒有門檻,主要是主線升級套件後,預先載入的 vendor 和 firebase 包變大,還沒查是哪幾個。
預覽站走一遍
測試和 build 都過,不代表會員用起來沒事。我們在 2.0 的預覽站用測試資料庫裡的測試帳號走 7 個點:登入、總覽三塊、發布五步、問小麥一題、免註冊試寫一篇、互動頁、設定頁。發布只開到確認視窗,沒有按送出。console 錯誤 0,手機沒有橫向捲動。
走查抓到一個漏洞:舊網址 /distribute 沒有轉到 2.0 的發布頁。轉址表登記了,但路由殼裡的舊路徑清單漏掛這一條,測試沒抓到。補在第二個 commit。
合併不等於功能搬完
合併前,我們盤出 2.0 跟舊版比還缺 7 項會員看得到的功能。合完之後:1 項自然補上(雲端海巡的三項新設定,因為 2.0 互動頁用的是同一個元件),1 項補上一半(方案讀取失敗不再擋人、全站斷網提示也進來了,但發布頁的送出鈕還沒接斷網判斷),另外 5 項沒有,例如發布頁的手動串文、從 Threads 刪除已發布的貼文。
原因很單純:合併帶進來的是程式碼,元件只掛在舊版頁面,2.0 沒掛,會員就看不到。
衝突分類腳本
合併停在衝突時,在 repo 根目錄跑這支,先看全貌再動手。路徑規則換成你自己 repo 的:
// classify-conflicts.cjs:合併停在衝突時,先把衝突檔分類,每類一條解法原則
// 用法:在衝突中的 repo 根目錄跑 node classify-conflicts.cjs
const { execSync } = require('child_process')
const fs = require('fs')
// 由上往下比對,先中先贏;路徑規則換成你自己 repo 的
const GROUPS = [
{ key: 'config', rule: '兩邊都要:按 key 合併,lock 檔重產', test: /(^|\/)(package(-lock)?\.json|vercel\.json|\.gitignore)$|\/i18n\/[^/]+\.json$|^docs\// },
{ key: 'tests', rule: '跟著它測的那一側走;兩邊的案例都要就合寫', test: /(^|\/)(scripts\/test-[^/]+|[^/]+\.test\.[jt]sx?)$/ },
{ key: 'old-only', rule: '舊版專屬:照主線', test: /^src\/pages\/v1\// },
{ key: 'new-only', rule: '新版專屬:照新分支', test: /^src\/pages\/v2\// },
{ key: 'shared', rule: '共用:以主線為底,逐段補回新分支的改動', test: /./ },
]
const files = execSync('git diff --name-only --diff-filter=U', { encoding: 'utf8' }).split('\n').filter(Boolean)
const out = Object.fromEntries(GROUPS.map((g) => [g.key, []]))
for (const f of files) {
const g = GROUPS.find((x) => x.test.test(f))
const text = fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : ''
const hunks = (text.match(/^<{7} /gm) || []).length // 這檔有幾段衝突
out[g.key].push(`${f}${hunks ? `(${hunks} 段)` : '(刪改衝突)'}`)
}
console.log(`衝突 ${files.length} 檔`)
for (const g of GROUPS) {
if (!out[g.key].length) continue
console.log(`\n## ${g.key}:${out[g.key].length} 檔|${g.rule}`)
for (const line of out[g.key]) console.log(' ' + line)
}
JSON 按 key 三方合併
語系檔、設定檔這類 JSON,用 git index 裡的三個版本(:1 共同祖先、:2 我方、:3 對方)按 key 合併。兩邊都改到同一個 key 的,先取我方並列出來,exit 2 提醒你逐條看:
// json-merge3.cjs:衝突中的 JSON(語系檔、設定檔)按 key 做三方合併
// 用法:node json-merge3.cjs src/i18n/en.json (讀 git index 的 :1 共同祖先、:2 我方、:3 對方)
const fs = require('fs')
const { execSync } = require('child_process')
const file = process.argv[2]
const show = (stage) => execSync(`git show :${stage}:${file}`, { encoding: 'utf8', maxBuffer: 64 << 20 })
const oursRaw = show(2)
const base = JSON.parse(show(1)), ours = JSON.parse(oursRaw), theirs = JSON.parse(show(3))
const isObj = (x) => x && typeof x === 'object' && !Array.isArray(x)
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
const log = { added: [], tookTheirs: [], deleted: [], bothChanged: [] }
function merge(b, o, t, path) {
if (!isObj(o) || !isObj(t)) {
if (same(o, t)) return o
if (same(b, o)) { log.tookTheirs.push(path); return t } // 只有對方改
if (same(b, t)) return o // 只有我方改
log.bothChanged.push(path); return o // 兩邊都改:先取我方,列出來給人看
}
const bb = isObj(b) ? b : {}
const out = {}
for (const k of Object.keys(o)) {
if (k in t) out[k] = merge(bb[k], o[k], t[k], `${path}.${k}`)
else if (k in bb && same(bb[k], o[k])) log.deleted.push(`${path}.${k}`) // 對方刪、我方沒動:刪
else out[k] = o[k]
}
const keys = Object.keys(out)
const tKeys = Object.keys(t)
tKeys.forEach((k, i) => { // 對方新增的 key:插在它原本前一個兄弟後面
if (k in o || k in bb) return
log.added.push(`${path}.${k}`)
let at = i === 0 ? 0 : keys.length
for (let p = i - 1; p >= 0; p--) { const pos = keys.indexOf(tKeys[p]); if (pos >= 0) { at = pos + 1; break } }
keys.splice(at, 0, k); out[k] = t[k]
})
return Object.fromEntries(keys.map((k) => [k, out[k]]))
}
const merged = merge(base, ours, theirs, '')
const indent = (oursRaw.match(/^\{\r?\n( +)"/) || [, ' '])[1].length
const nl = oursRaw.includes('\r\n') ? '\r\n' : '\n'
fs.writeFileSync(file, JSON.stringify(merged, null, indent).replace(/\n/g, nl) + (/\r?\n$/.test(oursRaw) ? nl : ''))
console.log(`${file}:對方新增 ${log.added.length}|取對方改動 ${log.tookTheirs.length}|刪 ${log.deleted.length}|兩邊都改 ${log.bothChanged.length}`)
if (log.bothChanged.length) { console.log('兩邊都改(先取我方,請逐條看):\n ' + log.bothChanged.join('\n ')); process.exitCode = 2 }
這兩支我們寫這篇時,在一個故意做出衝突的暫存 repo 裡跑過:6 個衝突檔分進 5 組;JSON 那支把對方新增、只有對方改、對方刪掉、兩邊都改四種情況都照規則處理,兩邊都改的那個 key 有列出來。合完記得 git add,再跑完整測試。
下次會怎麼做?
- 每天同步。 就算當天沒空解衝突,也先
git merge --no-commit --no-ff試合看數字,再git merge --abort。 - 工單先寫分類原則。 不管是人還是 agent 來合,判準先講好,報告才能逐檔對照。
批次處理大量變更,我們的習慣都是先盤點分類、再動手、改完重新確認,另一個例子是49 個 repo 的 Dependabot 警報一晚盤完。
常見問題
長期分支要多久跟主線同步一次?
越常越好。我們的 2.0 分支前十天幾乎每天合一次主線;10 月 27 日之後停了一週,主線長了 106 個 commit,合併時 44 檔衝突。同一天稍早試合時還是 92 個 commit、38 檔,幾個小時就多了 6 檔。我們打算改成每天同步,就算當天沒空解,也先試合看衝突數。
package-lock.json 衝突要怎麼解?
不要手動逐段改 lock 檔。先把 package.json 按 key 合好,lock 檔取其中一邊當底(我們取主線那份,因為它的依賴變動比較多),再跑一次 npm install,讓它補上另一邊多出來的依賴。最後跑完整測試和 build 確認。
合併後 bundle 超過門檻,可以直接把門檻調高嗎?
我們的測試明文禁止用更新基準的方式抬門檻,這條規則就是為了合併這種時候。我們的主包合併後到 148.8 KB gz,超過 143.3,改成把頂列的鈴鐺和回饋鈕延遲載入,載到之前放同尺寸的空位,主包降到 135.2 KB,比合併前還小。