LobeHub PR 技能详解:canary 分支策略与跨层功能拆分(Stacked PR)实战
本文基于 LobeHub 仓库中的 PR 技能文档 .agents/skills/pr/SKILL.md,系统讲解该项目「一个分支一个 PR」的标准提交流程与「跨层功能拆分为有序 Stack PR」的分层合并策略。读完你可以掌握:为什么 LobeHub 要求所有 PR 目标 canary 而非 main、如何用一组并行 git 命令安全地收集上下文并创建 PR,以及如何把一个横跨数据库、共享包、服务端 TRPC、桌面端/CLI 与 UI 的功能分支,用可复现的 git 操作步骤安全地拆成「底层先合、上层后合」的两个 PR。
技能定位与触发方式
该文档是 LobeHub 内置的一个 Agent Skill,用于自动化处理「为当前分支创建 PR」这类请求。从 frontmatter 可以看到:
- 技能名:
pr user-invocable: true:可由用户直接调用。其描述覆盖了create pr、submit pr、open a PR、pull request、split this PR、stacked PR、backend should merge first、提 PR、拆 PR、后端先合、分层合并等触发词——中英文关键词都纳入了触发范围。- 配套策略文件 .agents/skills/pr/agents/openai.yaml 声明了
policy: allow_implicit_invocation: false,即该技能只允许显式调用,不会被其他技能隐式触发——这在「改分支、强推远端」这类有副作用的操作上是一个重要的安全边界。
技能整体分为两大部分:前 6 步的标准单 PR 流程(适用于绝大多数场景),以及后半部分的 Stacked PRs 跨层拆分流程(适用于客户端依赖服务端新契约的跨层功能)。
分支策略:canary 是开发主干,main 是发布分支
文档在开头就确立了两条硬性规则:
- 目标分支:
canary(开发分支,对应云生产环境) main是发布分支——永远不要直接对main提 PR
这一策略并非孤立存在,仓库的主 Agent 规范 AGENTS.md 中「Git Workflow」一节给出了同样的约定:
canaryis the development branch (cloud production);mainis the release branch (periodically cherry-picks from canary). New branches should be created fromcanary; PRs should targetcanary. Use rebase forgit pull. Commit messages: prefix with gitmoji. Branch format:<type>/<feature-name>.
也就是说 LobeHub 的分支模型是:日常开发全部流入 canary,main 周期性从 canary 同步(cherry-pick)。仓库中还存在一条专门维护这一模型的 CI 工作流 .github/workflows/sync-main-to-canary.yaml:当 main 收到 push 时,机器人 lobehubbot 会自动以 sync/main-to-canary-* 为 head 分支、canary 为 base 创建同步 PR,并复用已存在的同名 open PR 避免重复。理解这一点很重要——它解释了为什么技能流程把 canary 当作「trunk」来处理所有比对(如 git log --oneline origin/canary..HEAD)与 PR 目标选择。
标准流程第一步:并行收集上下文
技能要求并行执行以下 6 条命令,一次性摸清当前分支状态:
git branch --show-current # 当前分支名
git status --short # 未提交改动
git rev-parse --abbrev-ref @{u} 2>/dev/null # 远端跟踪(upstream)状态
git log --oneline origin/canary..HEAD # 尚未进入 canary 的提交
gh pr list --head "$(git branch --show-current)" --json number,title,state,url # 该分支是否已有 PR
git diff --stat --stat-count=20 origin/canary..HEAD # 变更概览(限制 20 行防刷屏)
这几条命令覆盖了后续决策需要的全部信息:分支名决定推送方式,upstream 状态决定是否需要 git push -u,origin/canary..HEAD 的提交与 diff 决定「是否有东西可提 PR」以及 PR 描述怎么写,而 gh pr list 则是防重复创建的关键检查——文档在 Notes 中明确要求:如果该分支已存在 PR,应告知用户而不是创建重复 PR。
在默认分支上有未提交改动时:先建分支再提 PR
当当前分支是 canary 或 main 且存在未提交改动时,文档给出了一条标准处理链:
git diff分析改动内容;- 从改动推断分支名,格式遵循
<type>/<short-description>(例如fix/i18n-cjk-spacing,与 AGENTS.md 中<type>/<feature-name>的分支格式一致); git checkout -b <branch-name>建分支并切换;git add <files>显式暂存相关文件——文档特意强调优先使用显式文件路径而非git add .,避免把无关改动带进 PR;- 用符合 gitmoji 规范的提交信息提交(对应 commitlint.config.mjs 基于
@lobehub/lint的提交校验); - 继续后续步骤。
边界情况也有明确约定:如果当前在 canary/main 上既无未提交改动、也没有未推送提交,直接中止——没有可创建 PR 的内容。
推送、关联 Issue 与创建 PR
- 推送:无 upstream 时用
git push -u origin $(git branch --show-current);有 upstream 时用git push origin $(git branch --show-current)。两条命令都带显式分支名——这一点在后文「Gotchas」中会被再次强调,是防止误推canary的第一道防线。 - 搜索关联 Issue:
gh issue list --search "<keywords>" --state all --limit 10。文档提醒只链接 scope 匹配的 issue,避免挂到大而全的 umbrella issue 上;没有匹配则跳过。 - 创建 PR:
gh pr create --base canary,要求:- 标题格式:
<gitmoji> <type>(<scope>): <description>; - 正文基于 PR 模板 .github/PULL_REQUEST_TEMPLATE.md 填写并勾选相应复选框;
- 用 magic keywords(
Fixes #123、Closes #123)关联 GitHub issue;适用时同时关联 Linear issue(Fixes LOBE-xxx); - 正文使用 HEREDOC 传入以保留 Markdown 格式。
- 标题格式:
仓库中的 .github/PULL_REQUEST_TEMPLATE.md 实际结构印证了模板要求:UI 改动需要 Before/After 截图表格、「Test」小节含三个复选框(Tested locally / Added/updated tests / No tests needed),以及「Related Issue」小节(Fixes #xxx, Closes #xxx, Related to #xxx)。技能文档要求填写的四大要素——Change Type、Related Issue、Description of Change、How to Test——正是对这个模板各小节的映射。
最后一步 gh pr view --web 在浏览器中打开 PR 页面供人工确认。另有一条全局约定:所有 PR 内容必须使用英文。
Stacked PRs:当功能横跨多个层时如何拆分
这是文档最具价值的部分。文档描述的典型跨层链路是:packages/database 的 schema/model → 某个共享 packages/* 库 → 服务端 TRPC 路由 → apps/desktop 与 apps/cli 的调用方 → src/features 下的 UI。仓库结构可以佐证这条链路的真实性:packages/database/src 下包含 schemas、models、repositories、core 等目录,packages/trpc 提供 TRPC 路由基础设施,apps/desktop 与 apps/cli 则是两个主要客户端。
为什么一个 PR 合不安全
文档给出的核心论据是:客户端调用的端点在同一个 PR 合入之前不存在于 trunk 上。此时单 PR 会出问题:
- 部分合入、回滚、独立 review 都会破坏一致性;
- 评审者无法单独验证任何一层。
因此要把功能拆成有序 PR,底层先合。
排序规则:调用方必须等被调用方先上 trunk
A PR may only merge after every layer it calls is already on the trunk.
即:服务端契约(新 TRPC procedure、返回结构变化、新表/新模型)先合;调用方(desktop、CLI、UI)后合。当难以判断时,用一个问题打破平局:「如果这个 PR 现在单独合入 canary,它能构建并正常工作吗?」 若不能,它就该排在后面的 PR 里。
文件归属判断:三个不直觉的规则
- 适配契约变化的前端代码要跟着服务端 PR 走。 例如放宽 TRPC 返回结构(
listDevices返回值变为platform: string | null),消费该结构的组件必须在同一个 PR 里同步修改,否则服务端 PR 单独合入就会让构建挂掉。原则是:契约与其在仓库内的消费方一起交付。 - 新的共享包跟着它的消费方走,而不是默认归服务端——除非服务端也 import 它。一个只被 desktop/CLI 引用的包,应该放在客户端 PR 里,不要在下层 PR 中拖着无用的包。(注意:技能文档以
@lobechat/*指代共享包;从当前仓库的 apps/cli/package.json 等文件看,工作区包实际使用@lobehub/*作用域,例如@lobehub/cli。) - 工作区依赖声明(
package.json中的workspace:*、pnpm-workspace.yaml 条目)随 import 它的代码走。当前仓库的 pnpm-workspace.yaml 声明了packages/**、e2e、apps/server、apps/share、apps/workbench、apps/desktop/src/main等工作区包,拆分时这些声明应随对应包代码一起进入正确的 PR。
git 操作配方:把一个完整分支拆成 Stack
起点假设:一个分支 feat/x 上有单个包含全部改动的提交 <FULL>,且已推送到远端(远端这份副本本身就是安全网之一)。
# 1. 安全网 —— 改写任何东西之前,确保完整工作不可丢失
git branch backup/x-full <FULL> # 指向完整提交的本地 ref
git branch feat/x-clients <FULL> # 上层分支从完整提交起步
# 2. 把下层分支重写为只含下层文件
git checkout feat/x # 这个分支将成为 SERVER PR
git reset --hard origin/canary
git checkout <FULL> -- <server/db files…> # 只暂存这些路径
git commit -m "✨ feat(...): <server half>"
git push --force-with-lease origin feat/x # 永远不用 --force;永远不推 canary
# 3. 在刚重写的下层 HEAD 之上构建上层分支
git checkout feat/x-clients
git reset --hard feat/x # base = 刚重写的 server HEAD
git checkout backup/x-full -- <client/ui files…> # 只取剩余路径
git commit -m "✨ feat(...): <client half>"
git push -u origin feat/x-clients
核心技巧是 git checkout <FULL> -- <paths>:从完整提交中按路径挑选文件到暂存区,从而在干净的 origin/canary 基线上只重建下层改动;上层分支则 reset --hard 到下层的重写结果之上,形成物理上的依赖。
随后创建上层 PR 时,base 指向下层分支而非 trunk:
gh pr create --base feat/x --head feat/x-clients --title "…" --body "…"
--base feat/x 带来两个效果:diff 只包含客户端文件(不会泄漏服务端文件),并且从机制上杜绝了客户端先于服务端合并的可能。服务端 PR 合入 canary 后,把客户端 PR 的 base 重新指向 canary——GitHub 通常在 base 分支合并时会自动 retarget,文档建议同时在 PR 正文中说明这一点,由人工确认。
验证依赖关系确实成立
拆分的意义在于上层确实需要下层。文档要求证明这一点:在叠加上层分支上对调用方做类型检查,确认下层引入的符号能解析:
cd apps/cli && bun run type-check 2>&1 | grep -iE "connect\.ts|device\.register"
# 与你的改动相关的输出为空 = 栈式 base 提供了 device.register ✓
其中 apps/cli/package.json 定义的 type-check 脚本为 tsc --noEmit。文档还提示:过滤到你实际改动的文件——这个仓库的独立类型检查会输出一些与你无关的环境噪音(如 __ELECTRON__、@/types/llm、未构建的 @lobechat/types),不要把这些当成拆分失败。
PR 与 Linear 的记账规则
- 每个 PR 只关闭自己所属层的 issue:服务端 PR 写
Closes LOBE-<server>,客户端 PR 写Closes LOBE-<pkg> / <desktop> / <cli>;不要让一个 PR 的正文去认领另一层的 issue。 - 两个 PR 都标记为
Part of LOBE-<parent>。 - 创建 PR 时把各自关闭的子 issue 移到 In Review(而非 Done),并补 completion 评论。这一点与配套技能 .agents/skills/linear/SKILL.md 的约定一致:该技能明确「In Review 是 PR 创建后的状态,Done 留给 PR 合并之后」,且要求
Fixes/Closes/Resolves LOBE-xxxmagic keywords 与 issue 上的完成评论成对完成——只写 magic keyword 而不留评论,会让 Linear 上的人看不到任何上下文。
Gotchas:六个踩坑点
- 永远不要推
canary。 用git checkout -b feat/x origin/canary切出的分支会跟踪origin/canary,此时裸git push会推往 canary。务必始终使用显式分支名:git push origin feat/x。 - 重写下层分支用
--force-with-lease,不用--force——如果远端在你脚下移动了(他人推送),lease 检查会中止操作。 reset --hard之前先备份。 配方第 1 步的backup/x-full加上已推送的远端分支,意味着完整提交在被改写前至少被 ≥3 个 ref 引用。可用git branch --contains <FULL>验证。- 锁文件问题:文档指出这个 monorepo 不提交根级
pnpm-lock.yaml,因此新增workspace:*依赖不产生 lockfile 变更。当前仓库确实没有根级 lockfile,且 pnpm-workspace.yaml 显式声明了lockfile: false。反过来,如果在一个确实提交 lockfile 的仓库中使用这套配方,拆分后需要在每个分支上分别重新生成 lockfile。 - 不要过度拆分。 两个 PR(契约 / 调用方)通常已经足够;只读取现有端点的 UI 页面可以成为后续的独立 PR,但不要为了拆分而拆分,把同一层拆散到多个 PR 里。
小结
LobeHub 的 PR 技能把「提 PR」这件事工程化成了两个层级:单 PR 场景下,以 canary 为 trunk、以显式分支名推送、以模板与 magic keywords 收尾的六步标准流程;跨层场景下,以「单独合入能否构建并正常工作」为判据的排序规则、以 git checkout <FULL> -- <paths> 为核心的无损拆分配方,以及用类型检查证明依赖关系真实存在的验证手段。整套流程的安全设计——三重 ref 备份、--force-with-lease、--base 指向下层分支、显式分支名——都围绕同一个目标:让分层合并既保持正确的依赖顺序,又不丢失任何一行已经写完的代码。
相关延伸阅读:AGENTS.md(完整 Git 工作流与质量检查约定)、.agents/skills/linear/SKILL.md(Linear issue 状态与完成评论规范)、.github/PULL_REQUEST_TEMPLATE.md(PR 模板原文)。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0625
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00