用单仓库管多个项目这件事我最早是被逼出来的。当时手上一个后台系统前端一个仓库、后端一个仓库、三个共享库各自一个仓库、部署脚本又一个仓库改一个字段名要开五个 PR还得盯着五个仓库的版本号互相对齐谁先合谁后合全靠群里喊。后来干脆合并成一个仓库目录一拉apps/下面全是可交付的应用packages/下面全是共享代码一次提交把接口和调用方一起改完CI 自动判断哪些项目受影响、只跑那几条流水线。这篇文章就把这套「GitHub 单个仓库管理多个项目」的完整思路讲清楚目录怎么切、依赖怎么连、CI 怎么只跑改动的部分、版本和权限怎么隔离开。不管你是刚学会git push的新手还是已经在带团队的老手都能从里面找到能直接抄的配置和能少踩的坑。1. 先想清楚单仓库管多项目到底解决什么问题单仓库管多个项目行业里通常叫 monorepo单一仓库。这个词听起来很时髦但它的本质非常朴素把原本分散在多个代码仓库里的项目放进同一个仓库的不同目录下让它们共享同一套提交历史、同一套工具链、同一套流水线配置。它不是架构升级而是一种协作方式的重新组织。1.1 monorepo 和多仓库的真实取舍多仓库的问题只有真正维护过三五个以上关联仓库的人才有体感。最典型的是「原子性修改」做不到共享库改了一个函数签名你得先在共享库仓库提 PR等它合并、发版、推制品然后到应用仓库升级依赖版本再提第二个 PR。中间任何一个环节卡住两个仓库就处于「代码对不上」的状态。如果团队里还有人在旧分支上开发等他合并回来冲突能让你改一整天。单仓库把这一切压缩成一次提交。共享库和应用代码在同一个工作区里改完直接跑一遍全量测试测试绿了一提交原子性天然成立。Git 的git log也能如实反映「这次改动同时影响哪几个项目」追溯问题的时候一条线拉到底不用在五个仓库里做时间线对齐。但代价必须说清楚不然容易翻车对比维度多仓库单仓库跨项目改动的原子性差需要多次 PR 和版本协调好一次提交搞定依赖版本对齐靠规范和工具约束容易漂移天然统一仓库体积与克隆耗时各自独立单仓小随项目数线性增长权限粒度仓库级天然隔离目录级需要额外配置CI 执行时长每个仓库各跑各的容易变成全量跑必须做影响面分析新人上手成本需要克隆多个仓库一次克隆但有认知负担这张表里最容易被低估的是最后两行。很多人合并完仓库发现克隆变成五分钟、CI 一跑半小时然后就开始骂 monorepo 是伪命题。其实问题不在模式本身而在于没做「影响面分析」和「浅克隆 稀疏检出」这些配套手段——后面章节会逐个给方案。1.2 哪些场景适合哪些场景千万别硬上判断标准其实就一条项目之间的耦合度是否高于团队的隔离需求。适合上的场景很明确前后端共享类型定义、多个服务共享一套工具库和中间件、一个产品拆成主应用加若干插件、公司内部的组件库加若干使用方。这类场景里共享代码的改动频率很高多仓库带来的协调成本远大于单仓库的管理成本。不适合的场景也得说清楚。第一权限隔离要求高的比如外包团队只能看其中一部分代码——GitHub 的私有仓库权限是仓库级的单仓库里你没法让某个人只克隆apps/a而看不到apps/b除非上企业版的高级功能这类需求老老实实拆仓库。第二技术栈完全无关的比如一个 Go 服务和一个小程序塞一起除了占地方没有协同收益。第三团队规模过百、每个子项目都有独立发布节奏和值班表的单仓库的 CI 和权限治理成本会指数上升。我的经验线是十到三十人、共享代码占比超过两成、发布节奏基本一致这三个条件满足两个以上就可以考虑合并。2. 仓库结构怎么设计目录划分的主流打法结构设计是单仓库的地基改起来成本最高所以一开始就要想明白。我见过最糟糕的一种是把所有项目平铺在根目录下project-a/、project-b/、utils/、test/看不出层次也看不出哪些能发布、哪些只是内部依赖。跑一段时间后谁也不敢删东西仓库变成垃圾场。2.1 按可交付物和共享库两类划分我目前用的骨架是「应用与库分离」这是社区里最通用也最好理解的一种repo-root/ ├── apps/ # 可以独立部署、独立发布的项目 │ ├── admin-web/ # 后台前端 │ └── order-service/ # 订单服务 ├── packages/ # 只被内部引用、不单独对外发布的共享包 │ ├── ui-kit/ # 通用组件 │ ├── shared-types/ # 跨端共享的类型定义 │ └── utils/ # 工具函数 ├── tools/ # 构建脚本、代码生成器、发布脚本 ├── docs/ # 架构文档、决策记录 ├── .github/workflows/ # 流水线配置 ├── pnpm-workspace.yaml # 工作区声明 └── package.json # 根依赖与统一脚本这个划分的核心逻辑在于区分「产出物」和「依赖项」。apps/下的每个目录都对应一个能跑起来、能部署的实体它们的依赖关系是单向的只允许apps依赖packages绝不允许packages反过来依赖apps。这条规则听起来简单但在实际项目里救过我很多次——一旦共享库依赖了某个应用构建顺序就会出现循环工具链会直接报错而且这种依赖往往是「顺手 import 一下」造成的不做目录约定根本拦不住。如果你的项目包含不同语言比如 Go 服务加 TypeScript 前端可以再按语言加一层apps/ ├── go/ │ └── order-service/ └── ts/ └── admin-web/或者反过来按业务域先分技术栈放在里面。哪种好取决于你的协作方式如果前端和后端是两个独立小组各管各的按语言分更顺手如果同一个业务线的前后端经常一起改按业务域分能让改动集中在同一个目录里。2.2 命名规范给共享包一个统一前缀共享包一定要统一 scope比如myorg/ui-kit、myorg/shared-types。好处有三个一是package.json里一眼能看出哪些是内部包、哪些是外部依赖排查问题时不用翻目录二是私有制品库比如公司内部自建的 Nexus 或者 Harbor 旁边的 npm 私服配置 scope 级别的源地址内部包走内部源外部包走公共源互不干扰三是发布的时候可以按 scope 批量处理脚本里一个通配符就筛出来了。目录名和包名尽量保持一致。packages/ui-kit对应的包名就是myorg/ui-kit不要出现目录叫ui包名叫components这种错位时间一长没人记得住对应关系。引用路径也尽量用包名而不是相对路径import { Button } from myorg/ui-kit而不是import { Button } from ../../packages/ui-kit/src/Button。用包名走的是解析器的入口配置将来包被拆走或者换实现调用方一行都不用改用相对路径就等于把两个目录焊死了跨包重构的地狱就是这么来的。提示跨包引用必须走包名禁止跨目录相对路径引用../../other-package/...。这条规则建议写进 lint 规则里自动拦截靠人自觉一定会破功。2.3 用工作区把「一个仓库」拆成「多个项目单元」单纯放目录只能叫「文件夹堆放」真正让它变成「多个项目」的是工作区workspace机制。不同生态的写法不一样但思路一致在根目录声明成员范围让包管理工具把它们当成独立的包来解析依赖和建立软链接。Node 生态用 pnpm 的话根目录一个pnpm-workspace.yamlpackages: - apps/* - packages/* - tools/*Java 生态用 Maven 多模块根pom.xml里声明聚合modules moduleapps/order-service/module modulepackages/shared-common/module /modulesGo 用go.workgo 1.22 use ( ./apps/order-service ./packages/shared-lib )Rust 用 Cargo 的[workspace] members思路完全一样。工作区带来的直接好处是内部包之间可以写workspace:*或者模块版本工具链自动指向本地目录而不是去远程仓库拉——改共享库立刻在应用里生效不需要先发一个版本号。这一点是多仓库永远做不到的也是我认为单仓库最大的收益点。工作区还有一个隐形价值统一的脚本入口。根package.json里可以定义build、test、lint通过过滤参数只对某个项目执行pnpm --filter myorg/ui-kit build pnpm --filter ./apps/** testMaven 里对应的是mvn -pl apps/order-service -am test。这套「按名字或路径筛项目」的能力是后面做增量 CI 的基础。3. 分支、标签与依赖把项目之间的边界划清楚目录分开了、工作区配好了接下来要解决的是「多个项目共用一套 Git 历史」带来的副作用分支策略怎么定、版本号怎么标、跨项目依赖用什么方式连接。这三件事处理不好单仓库会从「协作利器」变成「互相拖累」。3.1 分支策略主干开发加短命分支单仓库最忌讳的是长期分支。多仓库时代你可能习惯了每个项目维护一个develop分支合并进单仓库之后如果还这么干会出现非常恶心的局面develop分支上既有 A 项目没发完的代码又有 B 项目刚合并的功能任何一次发布都得小心翼翼挑拣提交。我的做法是接近主干开发main分支永远可发布功能分支从main切出命名带上项目前缀比如feat/order-service/add-refund生命周期控制在两三天内合并走 PR 加自动化检查。合并之后立刻删除分支。这样main的历史始终是一条向前的直线回溯问题的时候git log --oneline apps/order-service就能看到这个项目的全部改动干干净净。如果确实需要长时间维护某个项目的旧版本比如线上还跑着 v1同时要开发 v2那就从 tag 上拉维护分支release/order-service-v1。注意这个分支只允许 cherry-pick 修复提交不允许从main全量合并否则你又会把别的项目代码带进来。3.2 标签与版本号用前缀隔离不同项目单仓库里所有项目共用一套 tag 空间如果都叫v1.0.0几次发布之后你就完全分不清哪个 tag 属于哪个项目了。解决办法是加项目前缀git tag order-service/v1.4.2 git tag order-service/v1.4.3 git tag admin-web/v2.1.0 git push origin --tags前缀的好处是筛选方便。git tag -l order-service/*一列就是这个项目的完整发布历史git describe --tags --match order-service/*能直接算出当前提交距离上次发布有多少个提交。发布脚本里也能用这个模式批量判断「哪些项目产生了新的提交、需要打 tag」这一块在第 5 章会给出具体脚本。推送 tag 的时候记得不要用git push --tags一把梭到所有远程如果仓库配置了多个 remote比如同时有一个内部托管的镜像容易误推。稳妥写法是显式指定git push origin order-service/v1.4.2。3.3 跨项目依赖工作区、子模块、subtree 怎么选这是单仓库讨论里最容易吵起来的话题。三种方案我都用过说说真实体感。工作区workspace首选适用于所有在同仓库内的包。依赖直接指向本地源码改完立刻生效没有同步问题。缺点是要求技术栈支持工作区机制且所有包必须在同一个仓库里。Git 子模块submodule在一个仓库里引用另一个独立仓库的特定提交。它的定位其实和 monorepo 是矛盾的——你既然要单仓库管理就说明不想维护多套版本submodule 又把版本对齐的麻烦带回来了。而且 submodule 的操作心智负担很重克隆要加--recurse-submodules更新要git submodule update --remote很容易出现「主仓库指向的提交已经不存在了」这种问题。我的建议是能不用就不用。Git subtree把外部仓库的代码合并进当前仓库的一个子目录保留提交历史。它对使用者透明克隆时不需要额外参数适合「已经稳定、很少更新」的第三方代码。缺点是同步回上游比较麻烦合并历史会让git log变长。三者对比方案适用场景更新方式主要痛点工作区同仓库内的包相互引用自动改完即生效需要工具链支持跨语言支持不一submodule引用外部独立仓库的固定版本手动 update指向具体提交心智负担重克隆易漏参数subtree引入很少变动的外部代码手动 pull 合并回推上游麻烦历史变长实践里我的组合是仓库内部一律用 workspace外部依赖一律走私有制品库npm 私服或 Maven 私服发布版本包实在需要嵌代码才考虑 subtree。submodule 基本淘汰。4. 自动化配套让 CI 只跑受影响的项目单仓库被吐槽最多的一点就是「改一行代码跑一小时流水线」。这个问题必须解决否则前面所有结构设计的好处都会被 CI 的等待时间吃掉。4.1 路径过滤用改动文件反推受影响项目最直接的方案是路径过滤。GitHub Actions 的paths配置可以做到「只有指定目录变化才触发」name: order-service-ci on: push: branches: [main] paths: - apps/order-service/** - packages/shared-types/** - .github/workflows/order-service-ci.yml注意第三行——把共享包路径也加进去。因为order-service依赖shared-types共享包改了它也得重新构建。但这里有个坑手写路径列表会漏。order-service后来又依赖了utils你忘了加那次改动就静默漏跑了这种 bug 最难查。所以路径列表只是底线方案规模上来之后要换成依赖图分析。稍微进阶一点的做法是用dorny/paths-filter这类动作在一个 job 里算出「哪些目录变了」输出成变量给后面的 job 判断- uses: dorny/paths-filterv3 id: changes with: filters: | order: - apps/order-service/** - packages/shared-types/**然后后续 job 用if: steps.changes.outputs.order true控制。再往上就是用 Nx、Turborepo 这类工具它们能解析包之间的依赖关系自动算出「受影响的项目集合」你不用手写任何路径规则。Turborepo 的--filter...[origin/main]就是干这个的Nx 的nx affected同理。我的建议是项目少于五个的时候手写路径够用超过五个直接上工具别自己造轮子。4.2 缓存和增量构建把等待时间压下来路径过滤解决了「跑哪些项目」缓存解决的是「每个项目跑多快」。单仓库的依赖目录往往很大node_modules或者 Maven 的~/.m2每次重装都是几分钟起步。做法是缓存包管理器的全局存储目录而不是项目里的node_modules- uses: actions/cachev4 with: path: ~/.local/share/pnpm/store key: ${{ runner.os }}-pnpm-${{ hashFiles(**/pnpm-lock.yaml) }} restore-keys: | ${{ runner.os }}-pnpm-用 lockfile 的哈希做 key依赖没变就精确命中依赖变了也能用restore-keys命中一个旧的缓存做增量补齐比全量下载快得多。Maven 侧同理缓存~/.m2/repositorykey 用hashFiles(**/pom.xml)。这里有个坑要注意本地构建共享包时用的是mvn install装进本地仓库CI 上如果跳过了install只做test模块之间的依赖会找不到。正确做法是用-amalso make参数让 Maven 自动把依赖模块一起构建mvn -pl apps/order-service -am test。另一个容易被忽略的是浅克隆。单仓库历史长actions/checkout默认只取最近一次提交这个默认值是对的别为了看历史去设fetch-depth: 0除非你的构建工具真的需要完整历史算版本号。4.3 权限收口CODEOWNERS 和目录级规则单仓库没有仓库级的权限隔离但可以用CODEOWNERS做到「谁改哪个目录必须谁审」apps/order-service/ backend-team apps/admin-web/ frontend-team packages/shared-types/ backend-team frontend-team packages/ui-kit/ frontend-team放在.github/CODEOWNERS配合分支保护规则里的「Require review from Code Owners」就能拦住「前端顺手改了后端代码还没人发现」的情况。共享包目录可以同时指定两个团队因为改它会影响双方必须两边都点头。这套机制只能解决「审核」解决不了「保密」。如果某个项目真的不能让部分人看到代码那还是得拆仓库别指望靠 CODEOWNERS 兜底它只控制合并权限不控制读取权限。5. 完整实操从零搭一个多项目管理仓库前面讲的是思路这一章给一套能直接跑起来的流程。我用 pnpm 加 GitHub Actions 举例其他生态把命令替换掉即可结构思路完全一致。5.1 初始化和目录骨架先建目录。注意.gitkeep是必要的Git 不跟踪空目录没有占位文件的话克隆下来结构会缺。mkdir -p my-monorepo/{apps,packages,tools} cd my-monorepo git init mkdir -p apps/admin-web apps/order-api mkdir -p packages/shared-types packages/ui-kit mkdir -p .github/workflows touch apps/admin-web/.gitkeep apps/order-api/.gitkeep touch packages/shared-types/.gitkeep packages/ui-kit/.gitkeep然后在各个项目目录里初始化自己的包描述文件。每个package.json都必须有独立的name和version因为它们在工作区眼里是独立的包{ name: myorg/shared-types, version: 0.1.0, private: false, main: src/index.ts, types: src/index.ts }应用侧的package.json里引用共享包用workspace:*{ name: myorg/admin-web, version: 0.1.0, private: true, dependencies: { myorg/shared-types: workspace:*, myorg/ui-kit: workspace:* } }workspace:*的含义是「永远用工作区里的本地版本」构建时 pnpm 会自动建立软链接。等发布的时候发布工具会把workspace:*替换成实际版本号这一点不用担心。注意根目录的package.json一定要加private: true。否则在某次误操作里它可能被发布到公共制品库把自己的内部结构全暴露出去这类事故我见过不止一次。5.2 配置工作区和统一脚本根目录创建pnpm-workspace.yaml声明成员范围packages: - apps/* - packages/* - tools/*根package.json里定义统一脚本用--filter按项目筛选{ name: my-monorepo, private: true, scripts: { build: pnpm -r --filter ./packages/** build pnpm -r --filter ./apps/** build, test:changed: pnpm -r --filter [origin/main] test, typecheck: pnpm -r typecheck, release:tag: bash tools/tag-release.sh } }这里[origin/main]是 pnpm 的过滤语法含义是「相对于origin/main有改动的项目」等价于增量执行。发布脚本里也可以用它来判断哪些包需要打 tag。装依赖的时候在根目录执行一次就行pnpm install会把所有工作区成员的依赖装到统一的存储里靠硬链接复用磁盘占用比每个项目单独装小得多。这也是单仓库的一个隐形收益同一个版本的 lodash 全仓库只有一份实体文件。5.3 提交第一个跨项目改动验证一下原子性是不是真的成立。在packages/shared-types/src/index.ts里加一个类型export interface RefundRequest { orderId: string; amount: number; reason: string; }然后在apps/order-api里引用它最后在apps/admin-web里也引用它。三个文件在同一个提交里改完git add packages/shared-types apps/order-api apps/admin-web git commit -m feat(shared-types): add RefundRequest and adopt in api/web如果这是多仓库你得开三个 PR、协调三次合并顺序、中间还要发一次包。单仓库里就是一条提交git show --stat能列出全部影响的文件评审的人一眼看清影响面。这就是我一直推荐单仓库的核心理由。改完跑一遍验证pnpm typecheck pnpm --filter myorg/order-api test注意typecheck是全量的因为类型错误可能在任何被依赖的包里出现测试可以按项目筛。这也是一个经验类型检查全量跑单元测试按影响面跑。类型检查快全量成本低且能防止跨包类型断裂单元测试慢全量跑是浪费。5.4 打标签和发布发布脚本的思路是遍历所有可发布的包比较它当前版本对应的 tag 是否存在不存在就打新 tag。简化版#!/usr/bin/env bash set -euo pipefail for dir in packages/*/; do name$(basename $dir) version$(node -p require(./$dir/package.json).version) tag$name/v$version if git rev-parse $tag /dev/null 21; then echo skip $tag (exists) continue fi git tag $tag echo tagged $tag done这个脚本只处理packages/下的共享包apps/里的是部署物不打发布 tag如果需要改成遍历apps/*/即可。跑之前先确认所有改动都已提交避免 tag 指向一个不完整的提交。跑完git push origin --tags推上去在 GitHub 的 Releases 页面就能按前缀筛出每个项目的发布记录。配套的 CI 里加一条判断只有 tag 推送时才触发发布流水线on: push: tags: - */v*这样日常提交不会误触发发布正式发布时自动跑。发布环节的权限要收紧只有维护者能推 tag普通开发者只能提 PR。6. 常见问题与排查速查表单仓库跑起来之后问题基本集中在几个固定位置。我把遇到过的都整理出来并附上排查方向。6.1 高频问题速查现象常见原因处理方式CI 没跑某个项目但它的行为确实变了路径过滤漏了间接依赖改用nx affected或 Turborepo 依赖图别手写路径克隆特别慢仓库历史太长含大量二进制文件浅克隆--depth 1大文件迁移到 Git LFS 或制品库本地构建找不到共享包没在工作区目录下装依赖回到根目录执行pnpm install别在子目录里单独装Maven 报找不到模块依赖只写了-pl没加-am用mvn -pl 模块 -am test连带构建依赖模块两个项目互相 import 报循环依赖目录分层规则被破坏加 lint 规则禁止packages反向依赖appstag 太多分不清版本用了统一的v1.0.0格式改成项目名/v版本前缀格式某人的提交把别的项目改坏了审核粒度不够配置 CODEOWNERS共享目录要求双团队审核仓库体积越来越大历史里混入了构建产物和依赖目录补全.gitignore已提交的用git filter-repo清理这张表里我实际踩得最多的是前三条。特别是路径过滤漏依赖这条它不会报错只会静默地不跑 CI等线上出问题才发现排查成本极高。所以我在任何项目里看到有人手写paths列表都会提醒一句。6.2 三个我踩过的坑第一个坑是.gitignore没配全构建产物进了仓库。单仓库里很容易出现某个子项目的dist/、target/、node_modules/被误提交的情况因为它们散布在各个目录里根目录的忽略规则如果写得不严很容易漏。我现在每个子项目的独立忽略规则都会配一遍根目录再写一层通配双保险。已经提交进去的用git filter-repo --path-glob */dist/* --invert-paths彻底清理注意这个操作会改写历史团队里所有人都得重新克隆。第二个坑是版本号管理混乱。一开始我用统一的v1.0.0给全仓库打 tag结果两个月后完全分不清哪次发布包含了哪个项目。后来改成前缀格式才好起来。更进一步的做法是用 changesets 这类工具它会根据提交记录自动算出哪些包需要升版本、升多少还能自动生成变更日志包多的团队很值得上。第三个坑是 CI 并行任务太多把免费额度烧完了。单仓库的 CI 如果按项目拆成十几个 job每个 job 都要走一遍 checkout 和装依赖即使有缓存启动开销也是实打实的。我的做法是把「装依赖」抽成一个前置 job把产物打包成 artifact 给后续 job 用测试类 job 合并粒度不要太细比如几个小工具包的测试可以塞进同一个 job 里顺序执行反而比拆开快。还有一个不是坑但值得提的点单仓库里的README要分层。根目录的 README 只讲整体结构和协作规范每个子项目目录下放自己的 README 讲怎么跑、怎么测。我见过把几十个项目文档全堆在根 README 里的打开就是几千行的表格谁也不想看。我个人在实际操作中的体会是单仓库的核心收益从来不是「少开几个仓库」而是把跨项目的改动从「流程问题」变成了「代码问题」——原本要靠沟通、排期、版本号对齐来解决的事情现在一次提交加一条流水线就完事了。但这份收益是有前提的目录分层要清晰、增量 CI 要配好、标签和权限要管住。这三件事只要有一件没做到位单仓库就会从帮手变成负担。所以别急着一次性把十个仓库全合并进来先挑耦合最紧密的两三个试试把 CI 和发布流程跑顺了再逐步往里搬这才是比较稳的推进方式。