Git合併衝突分支策略MindThread工程流程

把 106 個 commit 合進開了兩週半的分支:44 個衝突我們怎麼分類與解

· · 19 分鐘閱讀
目錄
  1. 為什麼會累積到 44 檔?
  2. 四類衝突,各一條原則
  3. 共用程式最難:要看呼叫端才知道怎麼解
  4. 設定檔按 key 合,不要按行合
  5. 兩段測試,外加一條「測試有沒有接上」的測試
  6. 主包超標,不准抬門檻
  7. 預覽站走一遍
  8. 合併不等於功能搬完
  9. 衝突分類腳本
  10. JSON 按 key 三方合併
  11. 下次會怎麼做?

長期分支合主線,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 列出衝突檔,照路徑分類:

  1. 共用程式,25 檔(後端 API、共用的 lib、hooks、元件):以主線為底,逐段補回 2.0 的改動。主線這一週修的是線上會員真的遇到的問題,不能丟;2.0 的改動是新功能,例如站內助理小麥、免註冊試寫、總覽三塊,也不能丟。
  2. 舊版專屬頁面,5 檔:照主線。舊版還在線上,2.0 對這些頁面本來就很少改。
  3. 新版專屬頁面,3 檔:照 2.0,主線的改動只挑需要的。設定頁只帶進主線新增的「刪除帳號」卡片,放進「一般」分頁;主線改到的團隊區塊 2.0 沒用到,就不帶。
  4. 設定與資料檔,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,比合併前還小。

每週 AI 自動化實戰筆記

不廢話,只有能直接用的東西。Prompt 模板、自動化 SOP、技術拆解。

加入一人公司實驗室

免費資源包、每日建造日誌、可以對話的 AI Agent。一群用 AI 武裝自己的獨立開發者社群。

想自己動手試試?

UltraProbe 免費、免註冊,掃一次網站就知道 Google 與 AI 找不找得到你。