目录
最近遇到一个很典型、也很难靠某条 Git 命令解决的问题。
这是一个运行了十年的 Angular 项目。过去由 A、B 两个团队共同维护,两个主要业务的代码已经分别放在独立目录中,但仍然共享一部分业务代码和 common 组件。B 团队还把部分代码发布成了 npm 包,不过这个包不只是 UI 组件,里面也包含更多属于 B 领域的业务逻辑。
现在团队已经分开,却还在维护同一个仓库。一个团队要修改代码,经常需要先通知另一个团队,确认影响、等待回复、协调发布时间。代码仓库逐渐变成了一把人工锁。
这次的约束也很明确:不考虑 monorepo,最终要形成两个可以独立开发和发布的项目。
真正的问题不是 Git,而是所有权
从 Git 的角度看,拆分并不困难。
只要 Repo 2 从 Repo 1 的完整历史创建,两个仓库就拥有共同祖先。Repo 2 可以把 Repo 1 配置成 upstream,然后执行 fetch、merge 或 cherry-pick。两个不同的远程仓库并不会阻止 Git 合并提交。
但技术上可以合并,不代表组织上应该长期合并。
Repo 2 开始删除 B 业务代码后,Repo 1 还会继续修改这些代码。此时持续把 Repo 1 整体合并到 Repo 2,会反复遇到 modify-delete 冲突,并试图把已经删除的功能重新带回来。冲突不是偶然事故,而是在提醒我们:两个仓库的产品意图已经不同。
如果拆分以后还需要每天同步整个仓库,实际上并没有完成拆分,只是把原来的沟通成本换成了合并成本。
目录分开,不等于依赖分开
两个主要业务已经位于独立目录,这是很好的起点,但对一个十年 Angular 项目而言,目录边界只能算候选架构边界。
除了普通的 TypeScript import,还要检查:
tsconfig中的 path alias;- 路由懒加载和动态
import(); - Angular dependency injection token;
- 根模块或根环境中的全局 provider;
- HTTP interceptor;
- NgRx、service 或其他共享状态;
environment与运行时配置;- 全局样式、主题、assets 和翻译资源;
- localStorage、IndexedDB 和 service worker scope;
- BFF 接口、数据库、消息事件和第三方 webhook。
拆分的第一份产物不应该是第二个 Git 仓库,而应该是一张真实依赖图。它需要回答:哪些依赖从 A 指向 B,哪些从 B 指向 A,哪些代码被双方同时引用,以及哪些运行时资源虽然没有 import,却仍然共同使用。
不要把所有 Shared 代码看成一类
现有代码至少应该分成四类:
| 代码类型 | 拆分后的处理方式 |
|---|---|
| A 团队业务代码 | 只进入 A 项目,由 A 独立维护 |
| B 团队业务代码 | 只进入 B 项目,由 B 独立维护 |
| 双方使用的业务规则 | 指定唯一 owner,再通过领域包或 API 提供 |
| 纯 common 组件和工具 | 根据稳定性选择复制或独立 npm 包 |
这里最危险的不是 common 按钮或日期格式化工具,而是双方共同修改的业务规则。
共享使用没有问题,共享修改权才是问题。拆分以后,每个共享业务模块都必须得到明确处理:归 A、归 B、复制后分别演进,或者交给唯一 owner,通过版本化契约提供给另一方。
不应该再创建一个没有 owner 的 shared 区域,让两个团队继续在里面自由修改。那只会在新架构中保留旧问题。
npm 包不一定是通用组件库
B 团队已经发布的 npm 包包含 B 领域业务逻辑,所以它不应该因为发布到了 registry,就被称为普通 shared library。
更准确的定位是:这是一个由 B 团队拥有的版本化业务产品,A 团队可能是它的消费者。
如果 A 不需要其中的 B 业务能力,只是复用了几个通用组件,就不应该依赖整个包。可以把纯通用部分拆成更小的包,也可以复制少量组件,让两个项目分别维护。
如果 A 确实需要 B 的业务能力,则需要建立正式的消费关系:
- B 是唯一 owner 和发布方;
- A 只依赖公开 API,不使用深层路径导入内部模块;
- 包使用语义化版本,并提供 changelog;
- 破坏性变更有弃用周期和升级窗口;
- 发布失败、紧急修复和兼容问题有明确负责人;
- 明确 Angular、RxJS、TypeScript 和其他 peer dependency 的兼容范围。
最后一点对 Angular 尤其重要。如果共享包只支持某个 Angular 主版本,一个团队升级框架时,就可能迫使另一个团队同时升级。仓库虽然分开,发布节奏却仍被绑定。
业务逻辑应该放 npm 包还是后端
这取决于业务规则需要什么样的一致性。
无副作用的纯计算规则,需要在浏览器中执行,允许跟随前端版本发布,可以保留在有明确 owner 的领域 npm 包中。
但如果一条规则涉及权限、资金、合规,或者要求两个在线产品在同一时刻得到完全一致的结果,更适合放到后端领域服务或 BFF 后面的稳定 API。前端包只保留类型、校验和客户端适配。
否则,A 项目何时升级 npm 包,会决定新规则何时生效。两个产品可能在很长时间内对同一输入给出不同结果。
两个月过渡期只做选择性同步
Repo 2 应从 Repo 1 的完整 Git 历史创建,并在分割提交上打标签。保留共同历史是为了审计、追溯和必要时选择性带入提交,不是为了永久做全量同步。
过渡期适合进入同步白名单的变化包括:
- 安全修复;
- 线上高优先级缺陷;
- 双方都受影响的遗留共享代码修复;
- 双方提前确认的基础设施变更。
B 的普通业务提交不进入 A 的仓库,A 的新功能也不再同时开发两份。
需要同步时,从源仓库 fetch 指定提交,建立独立的 sync/* 分支,通过 cherry-pick -x 保留来源哈希,再走目标仓库自己的 PR、测试和审核流程。
git fetch upstream
git switch -c sync/security-fix origin/main
git cherry-pick -x <source-commit>
git push -u origin sync/security-fix
同步流程必须有结束日期。否则“临时过渡”很容易变成一个永久的跨仓库维护系统。
共用 BFF 不代表 SSO 自动成立
两个前端继续使用同一个 Node.js BFF,确实可以降低迁移成本,但不能因此假设 SSO 自然成立。
至少需要验证:
- 两个前端域名是否都登记了 callback URL 和 logout URL;
- Cookie 的
Domain、Path、Secure、HttpOnly和SameSite; - OAuth/OIDC 的
state、nonce 和 PKCE; - CORS、CSRF 和 WebSocket origin;
- BFF 是否硬编码了旧前端地址;
- 登录成功以后,服务端是否仍按产品和资源执行授权;
- 日志、限流和审计能否区分请求来自哪个前端。
单点登录不等于共享同一枚应用 Cookie。即使两个前端不能共享 Cookie,也可以分别跳转到同一个身份提供商。身份提供商已有登录会话时,用户通常不需要再次输入密码。
更值得讨论的是 BFF 的所有权。谁能修改和发布它?谁负责兼容两个前端?发生事故时谁响应?如果每次接口变化仍要求两个团队共同排期,仓库拆分就只完成了一半。
不要同时做 Angular 大版本升级
除非当前版本已经造成安全或构建阻塞,否则不建议把 Angular 大版本升级放进仓库拆分的关键路径。
拆分会改变目录、依赖、构建、测试和部署;Angular 升级又会改变编译器、包格式、路由、测试工具和 peer dependency。两组变量叠加以后,出现回归时很难判断问题来自哪里。
更稳妥的做法是先在当前版本建立两个可独立构建、测试、部署和回滚的项目,再由两个团队按照各自节奏升级 Angular。过渡期内,共享包需要明确支持哪些版本,以及支持到什么时候。
一个可执行的拆分顺序
我会把整个过程分为以下几个阶段:
- 冻结新增的跨目录依赖,避免拆分期间继续增加耦合。
- 梳理静态依赖和运行时依赖,为每个 shared 模块指定归宿。
- 从完整历史创建 Repo 2,并记录唯一的分割提交。
- 在当前 Angular 版本上建立两套独立构建、测试、部署和回滚流程。
- B 业务包由 B 单独拥有,A 只消费公开契约。
- 真正的企业共享业务规则由唯一 owner 通过领域包或 API 提供。
- 纯 common 组件根据变化方向决定复制还是独立发布。
- 过渡期只通过可审计 PR 同步必要修复。
- 验证 BFF、SSO、数据迁移、消息事件、监控和回滚。
- 设置最终冻结和对账窗口,关闭常规代码同步。
怎样判断拆分真的完成了
不是看两个仓库是否已经建立,也不是看两个项目能否偶尔构建成功。
真正的验收标准是:
- A 的日常业务修改不需要通知 B;
- B 的日常业务修改不需要通知 A;
- 两个团队可以独立安装依赖、测试、发布和回滚;
- 跨团队变化只通过少量、显式、版本化的契约发生;
- 不再依赖聊天通知和整库同步维持正确性。
这次拆分真正要消除的不是一个 Git remote,而是模糊的共同所有权。
Git 可以帮助两个仓库继续合并,但拆分的终点,应该是绝大多数时候不再需要合并。