
上個月有個同行在群里發(fā)了張截圖一個公共的日志封裝模塊被三個業(yè)務項目各復制了一份半年下來三個副本的函數(shù)簽名全不一樣了。修一個時間格式化的 bug得在三個倉庫里改三遍還漏了一次結(jié)果線上只有一個項目生效。這種場面我見過太多次幾乎每個團隊發(fā)展到一定規(guī)模都會撞上同一個坎——公共代碼到底該怎么在多個 Git 版本庫之間共享。Git 原生給了兩套思路完全不同的解法子模塊submodule和子樹subtree。前者把另一個倉庫當成一個指針釘死在你當前倉庫的某個提交上后者把另一個倉庫的內(nèi)容連同歷史一起揉進你自己的倉庫。兩者都能讓公共代碼只保留一份源頭但在日常協(xié)作里的表現(xiàn)、坑位、維護成本差異極大。這篇就圍繞版本庫之間的依賴這個主題把兩種機制的存儲模型、完整操作流、高危動作和選型邏輯拆開講清楚。不管你是剛開始拆分公共庫還是已經(jīng)被子模塊坑過一次打算換方案應該都能從中找到能直接抄的東西。1. 從復制粘貼到依賴管理中間隔著的到底是什么問題1.1 三個副本的宿命從先用著到誰也說不清先說復制粘貼這條路為什么一定會崩。它的問題不在于懶而在于復制的那一刻就把同源關系給切斷了。假設項目 A 在三月復制了一份工具庫項目 B 在五月復制了一份項目 C 在九月復制了一份。這三次復制的起點其實已經(jīng)是三個不同的版本了只是當時沒人意識到。后面修 bug 時A 的維護者改了dateFormat的時區(qū)處理B 的維護者加了緩存C 的維護者什么都沒動。等到需要統(tǒng)一升級某個底層依賴時你面對的是三段互不相干的歷史沒有任何機制能告訴你這三個文件曾經(jīng)是同一個東西。比這更隱蔽的問題是職責歸屬。復制之后每個項目團隊都會覺得這是我們的代碼于是開始按自己的需求改。公共庫那邊提出一個改進沒人愿意跟進公共庫提了一個不兼容的 API 變更三個項目各自打補丁。一年之后公共庫和三個副本已經(jīng)是四個不同的項目了只是長得有點像。版本庫之間的依賴管理本質(zhì)就是解決兩件事代碼歸屬清晰源頭只有一個和版本可控每個消費者明確知道自己用的是哪個版本。這兩件事聽起來簡單但它們的實現(xiàn)方式是互相拉扯的——你越想鎖定版本升級就越痛苦你越想跟隨最新穩(wěn)定性就越差。1.2 鎖指針與并歷史Git 給出的兩條路線Git 的兩種機制正好對應這兩種取舍。子模塊走的是鎖指針路線。父倉庫里記錄的不是子模塊的文件內(nèi)容而是一個指向子模塊某個具體提交的引用。這個引用在 Git 的索引里表現(xiàn)為一個特殊的條目對象類型是commit權限位是160000。翻譯成人話就是父倉庫只記得這個目錄應該是某某倉庫的第幾個提交至于那個目錄下面有什么文件它一概不管。子樹走的是并歷史路線。它把對方倉庫的內(nèi)容直接復制到你倉庫的一個子目錄里作為一個普通的提交合進來。合完之后那些文件就是你倉庫的一部分了正常 clone 就能拿到?jīng)]有任何額外步驟。代價是倉庫體積會變大歷史會混在一起。這兩條路線的分歧點從第一秒就決定了后面所有的操作方式和坑位分布。理解這一點比記住那幾條命令重要得多。2. submodule 的存儲模型父倉庫里究竟存了些什么2.1 一個 gitlink、一個配置文件、一個獨立對象庫很多人第一次用子模塊會懵因為看不到預期的文件。執(zhí)行完添加命令之后父倉庫里多了三樣東西位置和作用各不相同第一樣是索引中的一個 gitlink 條目。它躺在父倉庫的索引里記錄路徑 → 提交 SHA這條映射。你用git ls-tree能看到它git ls-tree HEAD vendor/lib-shared # 160000 commit 8a3f9c2e1b7d4a5f6e8c9d0b1a2f3e4d5c6b7a8f vendor/lib-shared注意那個160000這是 Git 專門為子模塊保留的文件模式含義是這里不是文件也不是目錄是指向另一個倉庫的引用。第二樣是倉庫根目錄的.gitmodules文件。這個是唯一被父倉庫正常跟蹤的配置文件里面記錄了子模塊的路徑和 URL還可以指定跟蹤哪個分支[submodule vendor/lib-shared] path vendor/lib-shared url https://example.com/lib-shared.git branch main第三樣是父倉庫.git/modules/下的一份獨立對象庫。子模塊的提交對象、分支、遠端配置都存在這里而不是寫在子目錄自己的.git里。子目錄下的.git其實是一個文本文件內(nèi)容就一行gitdir: ../../.git/modules/vendor/lib-shared這個設計的意義在于父倉庫的.git目錄被完整搬走或備份時子模塊的歷史也跟著走。但也正因為這樣刪除子模塊時如果只刪目錄.git/modules/里的殘留會一直掛著后面同名添加會報錯。把這三樣東西記清楚后面的所有問題你都能定位到根上。2.2 從零添加一個子模塊的完整命令流假設現(xiàn)在要把一個公共工具庫掛到項目的vendor/lib-shared下面# 1. 添加子模塊同時會在 .gitmodules 里寫入配置 git submodule add https://example.com/lib-shared.git vendor/lib-shared # 2. 如果需要它跟蹤某個分支而不是默認分支 git config -f .gitmodules submodule.vendor/lib-shared.branch main # 3. 提交這兩處變更.gitmodules 和索引中的 gitlink git add .gitmodules vendor/lib-shared git commit -m chore: 引入公共工具庫作為子模塊這里有個細節(jié)值得說一下git submodule add會自動把子模塊的 HEAD 設置成遠端默認分支的最新提交但它不會自動切換到那個分支上。子模塊在添加完之后處于游離頭指針detached HEAD狀態(tài)這是正常設計——父倉庫鎖定的是一個具體提交而不是一個分支名。如果你不確定git submodule status會在提交 SHA 前面沒有任何前綴表示狀態(tài)正常。如果我是在一個已經(jīng)有內(nèi)容的老項目里加還得注意目標路徑不能已存在且非空否則命令會直接拒絕。要么換個路徑要么先把老內(nèi)容清掉。2.3 克隆方要做的事以及漏做之后看到的現(xiàn)象這是子模塊最容易翻車的地方。默認的git clone不會拉取子模塊的內(nèi)容你 clone 下來看到的是一個空目錄或者一個只有.git文件的空殼。正確的做法有兩種。一次性的是git clone --recurse-submodules https://example.com/project-a.git已經(jīng) clone 完了才想起來那就補一刀git submodule update --init --recursive--init的作用是根據(jù).gitmodules把配置寫進本地配置--recursive是處理子模塊里面還嵌套子模塊的情況。少寫--init時會報錯說子模塊未注冊少寫--recursive時外層好了里層還是空的。還有一種情況你已經(jīng) clone 了也 init 過了但是父倉庫的維護者后來把子模塊的指針推到了一個新提交。這時候git pull只會更新父倉庫的 gitlink子目錄里的文件還是舊的。git submodule status會在這個子模塊前面顯示一個號意思是工作區(qū)的 HEAD 和索引記錄的提交不一致。補一條git submodule update就同步了。我見過最典型的事故是 CI 流水線。構建腳本里寫了git clone后面直接跑單元測試結(jié)果測試引用的那個模塊目錄是空的報一堆 import 錯誤。排查的人花了兩個小時以為是依賴安裝的問題。后來做了一件事在所有項目的 CI 配置里統(tǒng)一加上submodule: true或者在 clone 步驟顯式帶--recurse-submodules這類問題就絕跡了。3. 子模塊的高危動作與日常維護紀律3.1 改了子模塊代碼卻忘記回父倉庫提交指針這個坑的觸發(fā)率我估摸著有七成以上。場景是這樣的你在子模塊目錄里改了一行代碼進去git add、git commit、git push一氣呵成覺得沒問題了。然后回到父倉庫看到git status顯示vendor/lib-shared有變化modified: vendor/lib-shared (new commits)你可能順手就git checkout .給還原了或者壓根沒注意就切分支走人了。結(jié)果就是子模塊上游有了新提交但父倉庫的鎖定指針還停在舊位置。別人 clone 下來拿到的還是舊版本然后來問你你改的東西在哪呢。正確的收尾動作是固定的三步# 在子模塊內(nèi)完成改動并推送 cd vendor/lib-shared git add . git commit -m fix: 修正時區(qū)解析 git push origin HEAD:main # 回到父倉庫把新的指針提交上去 cd ../.. git add vendor/lib-shared git commit -m chore: 同步公共庫至最新提交 git pushgit add vendor/lib-shared這一步加進去的不是文件內(nèi)容而是那個新的提交 SHA。父倉庫這次提交之后所有 clone 的人才知道應該檢出哪個版本。提示提交前養(yǎng)成git submodule status看一眼的習慣帶前綴就說明指針沒同步。3.2 分支切換引發(fā)的指針錯位和游離頭指針父倉庫在不同分支上鎖定的子模塊提交可能不一樣。當你在兩個分支之間來回切時Git 會自動更新 gitlink但不會自動更新工作區(qū)里子模塊的實際內(nèi)容。于是你會看到狀態(tài)一直是子目錄里的代碼和當前分支預期的版本對不上。解決辦法是在切分支時帶上遞歸git checkout feature-x --recurse-submodules # 或者在切換后立刻 git submodule update --recursive第二個容易懵的點是游離頭指針。在子模塊里直接git status會看到一行提示說當前不在任何分支上。這不算錯誤但如果你在子模塊里改了東西又沒有先建分支切走之后提交會丟。我的習慣是進子模塊改代碼前先執(zhí)行git switch -c fix/timezone-parse改完推送再按上一節(jié)的三步走。多花三秒省掉一次深夜找提交。還有一種邊界情況兩個分支對同一個子模塊鎖定的提交差得很遠切過去之后 Git 可能會拒絕切換說會覆蓋未跟蹤的文件。這時候要么先git submodule update把工作區(qū)清干凈要么把改動 stash 掉。強推-f不是好主意因為子模塊里的未提交改動會被直接抹掉找不回來。3.3 批量操作、遞歸配置與嵌套子模塊的處理項目大了之后子模塊可能有五六個手動一個一個 update 效率太低。這時候有兩個工具。第一個是git submodule foreach遍歷所有子模塊執(zhí)行命令# 一次性把所有子模塊切到 main 分支并拉取最新 git submodule foreach git switch main git pull # 查看所有子模塊當前的狀態(tài)和遠端地址 git submodule foreach git remote -v git log -1 --oneline第二個是全局開啟遞歸。Git 支持把一些命令默認變成遞歸模式# 讓 checkout / fetch 等命令默認處理子模塊 git config --global submodule.recurse true # pull 時按需拉取子模塊哪個指針變了拉哪個省時間 git pull --recurse-submoduleson-demandon-demand這個選項值得單獨說。默認的遞歸拉取會把所有子模塊都 fetch 一遍項目里子模塊多了光 fetch 就要幾十秒。on-demand只處理父倉庫指針發(fā)生變化的那些速度快很多。嵌套子模塊的處理原則是所有命令都帶--recursive包括 clone、update、foreach。子模塊里再掛子模塊雖然能用但維護成本直線上升超過兩層我個人建議直接換方案或者把最內(nèi)層改成包管理器分發(fā)。權限方面也有個坑如果子模塊指向的是需要單獨授權的倉庫CI 環(huán)境里的憑據(jù)可能只配了主倉庫。表現(xiàn)是 clone 主倉庫成功update 子模塊時提示認證失敗。這時候要么給 CI 配能訪問所有相關倉庫的憑據(jù)要么把子模塊改成公開可讀的形式。批量刪除子模塊的步驟也順手記在這里這個操作必須按順序來少一步都會留垃圾git submodule deinit -f vendor/lib-shared git rm -f vendor/lib-shared rm -rf .git/modules/vendor/lib-shared4. subtree把對方的提交歷史揉進自己的倉庫4.1 add、pull、push 三段式的操作流程子樹的核心思想很樸素不搞指針直接把對方的文件復制進來作為一次普通提交合入。這意味著你 clone 之后什么都不用做代碼就在那兒。第一次引入git subtree add --prefixlibs/lib-shared \ https://example.com/lib-shared.git main --squash--prefix指定落地目錄--squash表示把上游的整段歷史壓成一個提交塞進來。不加這個參數(shù)會把上游所有提交都并入你的歷史可追溯性更好但倉庫會明顯變大。后續(xù)同步上游更新git subtree pull --prefixlibs/lib-shared \ https://example.com/lib-shared.git main --squash反過來你在本地這個目錄里改了東西想推回上游git subtree push --prefixlibs/lib-shared \ https://example.com/lib-shared.git main這條命令背后做的事比看起來復雜它會從你的整個提交歷史里把所有影響libs/lib-shared這個前綴的提交挑出來重新組織成一段干凈的歷史再推給上游。所以如果倉庫歷史很長subtree push會明顯慢有時候要等十幾秒甚至更久。這是子樹方案最容易被吐槽的點但它也確實解決了雙向同步這個問題。一個實操建議把這三個命令寫成腳本或者 git alias別每次手敲。比如git config alias.spull subtree pull --prefixlibs/lib-shared git config alias.spush subtree push --prefixlibs/lib-shared4.2 split 與 squash 的取舍以及合并沖突怎么處理git subtree split是子樹的另一個核心能力——把一個子目錄的歷史單獨抽出來變成一個獨立分支git subtree split --prefixlibs/lib-shared -b shared-only這條命令產(chǎn)生的分支歷史里只包含影響那個目錄的提交其他提交全部被過濾掉。它的典型用途是把一個項目里的公共部分剝離出去獨立成庫。--squash用不用取決于你是想保留上游的完整提交記錄還是只關心內(nèi)容快照。我的判斷標準是這樣的如果是內(nèi)部團隊維護的公共庫雙方都要雙向提交那不加 squash歷史完整出問題時能查到具體是哪次提交引入的如果是引用的第三方代碼基本只讀那加 squash倉庫干凈體積也可控。沖突處理上子樹跟普通 merge 完全一樣。git subtree pull遇到?jīng)_突會停下來你在工作區(qū)解決git add然后git commit完成合并。有個小細節(jié)沖突標記里的路徑是帶前綴的完整路徑看一眼就知道是哪個文件在打架。解決完不需要額外操作比子模塊那套進去出來再提交指針的流程省心不少。4.3 子樹方案那些不會寫在文檔里的隱性成本子樹看起來很美好但有幾筆賬得提前算。第一筆是倉庫體積。每次subtree pull都會在你的倉庫里增加提交不管你加沒加 squash。一個每天都同步的項目一年下來倉庫能胖出好幾百兆。如果你在意 clone 速度這個成本會慢慢顯現(xiàn)。第二筆是歷史噪音。不加 squash 時上游的每個提交都會出現(xiàn)在你的git log里作者是別人提交信息跟你項目的規(guī)范也對不上。做 blame 分析的時候會被干擾。第三筆是push 的可靠性。subtree push在歷史復雜時容易發(fā)生奇怪的情況比如推送被拒絕但報錯信息看不出原因。最常見的觸發(fā)條件是你本地這個目錄的提交里有合并提交split 出來的歷史無法線性應用。遇到時可以先git subtree split出一個中間分支檢查一下里面的提交是否合理再從那個分支推。第四筆是前后綴的一致性。--prefix一旦定下來就最好別改因為 split 的邏輯完全依賴路徑匹配。改了目錄名等于把歷史斷了后續(xù) pull 會當成一個全新的目錄來合并產(chǎn)生大量重復文件。這四筆賬不是說子樹不好而是說它更適合單向引用為主、偶爾回推的場景。雙向高頻同步子樹的摩擦會更明顯。5. 兩種依賴方式的橫向?qū)φ张c選型判斷5.1 把差異攤成一張表我在團隊里做選型評審時習慣用一張表把所有維度擺出來避免討論跑偏維度子模塊 submodule子樹 subtree存儲方式父倉庫只存提交指針完整內(nèi)容并入庫clone 后的可用性需 extra 步驟才能拿到內(nèi)容直接可用版本鎖定精確鎖定到提交無鎖定同步時才更新倉庫體積幾乎不增加隨同步次數(shù)增長歷史清晰度各自獨立互不干擾混在一起需要 prefix 區(qū)分上游更新拉取update --remote或手動切subtree pull本地改動回推上游進子模塊提交推送即可subtree push歷史復雜時慢沖突處理少見因為不共享文件級歷史常見按普通 merge 處理團隊上手成本較高容易漏步驟較低跟普通目錄沒差別CI 配置必須顯式加遞歸參數(shù)無需額外配置刪除干凈程度需要四步清理刪目錄加提交即可適合場景依賴穩(wěn)定、版本強約束需要頻繁雙向同步這張表里最容易被低估的是clone 后的可用性這一行。它帶來的連鎖反應很大新人入職第一次拉代碼失敗問一圈沒人說得清外部合作方拿到倉庫打不開臨時在別的機器上驗證一個問題還得額外折騰一遍。而版本鎖定這一行是子模塊最大的價值。它意味著父倉庫的每一次提交都精確對應著所有依賴的確定版本。這在需要嚴格復現(xiàn)歷史構建的場景里非常關鍵——比如要給半年前的一個版本打補丁你檢出的那一刻所有依賴的版本都自動還原到當時的模樣。子樹做不到這一點因為內(nèi)容已經(jīng)混進來了你只能靠 commit 時間來猜。5.2 按團隊形態(tài)和發(fā)布節(jié)奏做決定拋開表格我給幾條更實用的判斷標準??磮F隊的 Git 熟練度。如果隊伍里有相當比例的人對 Git 的理解停在add / commit / push三件套子模塊會持續(xù)制造問題。每次切分支、每次拉代碼、每次進子模塊改東西都是一個新的出錯點。子樹在這點上友好得多因為對使用者來說它就是一個普通目錄??匆蕾嚨淖兏l率。公共庫一個月發(fā)一次版子模塊很合適升級時手動 update 一次明確可控。公共庫每天都在改而且多個項目都要跟著改那子樹的同步成本更低。看發(fā)布產(chǎn)物是否需要自包含。如果交付物是一個可以獨立部署的包需要把源碼整體打包給對方子樹更省事因為代碼本來就在倉庫里。子模塊打包時得額外處理或者干脆不走源碼交付??词欠裼袃?nèi)部包管理體系。如果團隊已經(jīng)有一套成熟的包發(fā)布流程私有 registry、版本號語義化、依賴鎖文件那最合適的其實是既不用子模塊也不用子樹直接用包管理器。Git 的這兩種機制是沒有包管理時的替代方案不是更優(yōu)解。這點我踩過坑——曾經(jīng)用一個子模塊當依賴管理結(jié)果版本升級完全靠人記后來上了包倉庫整套流程順暢十倍。6. 從復制粘貼遷移到依賴管理的落地路徑6.1 拆出公共代碼的四個階段存量項目切換到依賴管理最怕的是一步到位。我的經(jīng)驗是分四步走每一步都可回退。第一階段識別與凍結(jié)。把三個項目里重復的那些模塊找出來逐個對比差異列一張差異清單。這一步不寫代碼純分析。差異超過三成的模塊先不動因為合并成本可能高于收益。同時給選中的模塊定一個凍結(jié)時間點之后不再往舊副本里加新功能。第二階段建獨立倉庫。選一個差異最小的副本作為基礎新建倉庫把代碼放進去加上必要的構建配置和文檔。這個倉庫最好是能獨立跑通測試的否則后面每次改動都沒法驗證。第三階段逐個項目接入。先在一個項目里接入用哪個方案看前面的選型判斷。接入之后跑一輪完整回歸確認功能正常。跑通了再動第二個項目。這一步千萬別并行出了問題不好定位是哪個接入引入的。第四階段清理舊副本。所有項目都切換完之后把原來的復制目錄刪掉同時在團隊里公告這個模塊的源頭在某某倉庫改代碼請?zhí)?PR。這一步很多人省了結(jié)果半年后又長出新的副本。整個周期我給個參考三個項目、兩三個模塊的規(guī)模認真做大概兩到三周。想一周搞定通常是在第三階段出問題。6.2 日常維護里那幾條不能松的紀律機制選對了還得靠日常習慣兜底。以下幾條是我在實際項目里反復驗證過的。提交信息帶上依賴變更說明。父倉庫里那種同步子模塊的提交信息里要寫清楚從哪個版本到哪個版本改了哪些內(nèi)容。三個月后有人 bisect 找問題時這條信息能救命。給子模塊指針變更單獨提交。不要把子模塊指針的變更和業(yè)務代碼混在同一個提交里。分開之后回滾業(yè)務代碼不會誤傷依賴版本反之亦然。每次發(fā)版前跑一次全量同步檢查。針對子樹方案發(fā)版前確認本地目錄和上游是不是一致的針對子模塊確認所有指針是不是都已經(jīng)推到遠端。這個檢查可以寫成腳本一行命令輸出所有子模塊的當前提交和遠端最新提交的差異。給 CI 加一條斷言。在流水線最開始加一步檢查子模塊目錄是否非空或者檢查合并后的代碼里是否包含預期的版本標記文件。這條斷言能在三十秒內(nèi)暴露大部分依賴拉取問題比事后排查省太多時間。新成員文檔里寫清楚依賴拉取步驟。不是寫請初始化子模塊而是給出可以直接復制的完整命令。文檔里那段命令我自己每季度會拿出來在一個全新環(huán)境里試一遍確認還能用。如果非要在兩種機制里挑一個當默認我的傾向是能上包管理器就上包管理器不能上就優(yōu)先子樹只有在需要嚴格版本鎖定和依賴足夠穩(wěn)定的場景才用子模塊。這個排序是根據(jù)維護成本排的不是根據(jù)功能強弱排的。子模塊的機制本身很優(yōu)雅但它把復雜度轉(zhuǎn)嫁給了每一個使用倉庫的人而子樹把復雜度集中在了少數(shù)幾次同步操作上——后者在團隊協(xié)作里通常更容易被消化。