主题
Lampio 代码规范
本文档管代码风格、架构不变式、文件组织的细节。 例外:技术红线的真相源在
CLAUDE.md§4,不在本文件(§1 只留指针,原因见该节)。头部不再标注"最后更新"——它注定漂移。以
git log -1 -- docs/coding-standards.md为准。
目录
- 技术红线(硬性禁止)
- 架构设计原则
- 前端规范(React + TypeScript)
- 后端规范(Rust)
- Content-Script 规范
- Zustand Store 设计
- 错误处理
- 文件组织
- 提交规范
- 分支管理 & 版本发布
1. 技术红线(硬性禁止)
🔒 红线清单的唯一真相源 =
CLAUDE.md§4。本节不再维护副本。为什么删掉这里的表:本节曾并行维护一份 R1-R8,随时间落后成了错误信息源—— 它的 R5 写着
updated_at=gt.{last_sync_at},而 2026-05-22 起正确写法是server_updated_at=gt.(客户端 ts 会漂移,用它做 watermark 会永久漏数据; 见 CLAUDE.md 红线 #5/#5d 与已归档的plans/archive/sync-server-timestamp-plan.md)。 与此同时 CLAUDE.md §4 已长到 #1-#11(含 5a/5b/5c/5d/5i/6a/6b/6c/6d 共 11 条本节从未有过的), 而 CLAUDE.md 却写着"详细清单见本节"——指针指向了更少且更旧的一份。 两份红线互相矛盾约 3 个月。一份规则只能有一个家。
/arch-check skill 自动扫描其中可机械判定的部分(S1-S19),语义层由 /rb-code-review 覆盖。
2. 架构设计原则
以下三条原则从 Review 流程重构中提炼,适用于所有涉及 Webview 和跨边界通信的功能开发。
原则 1:能用 Tab 就不造特殊实体
需要在 content-area 显示 webview 的功能,必须作为 Tab(useTabsStore)接入。用 tab.type 区分特殊行为,复用 Tab 系统的生命周期管理(创建/销毁/显隐/resize/切换)。
禁止在 App.tsx 中为新功能新增 webview 协调 useEffect。
原则 2:状态归属——谁渲染谁拥有
- 只有一个组件读写 →
useState(local state) - 多个组件读 → 最小共享 Zustand store
- 跨边界传入 → Tauri event → 接收方 local state 或 store
- 禁止同一信息在多处维护 + 手动同步
原则 3:跨边界通信只用两种模式
- React/content-script → Rust:
invoke() - Rust → React:
emit event→listen()(用hooks/useTauriEvent) - 禁止 UI 组件直接调用 Tauri webview 命令(通过 Tab store 方法)
- 禁止 多个组件各自 listen 同一事件各自处理
3. 前端规范(React + TypeScript)
3.1 组件分层
组件层级 / 基础组件库 / 颜色系统 / 间距 / 字号等 UI 细节统一归 docs/ui-standards.md。本节仅列一次性索引。
快速参考:
- Atom:无状态、无业务、纯展示(ui/Button、ui/IconButton、TabButton)
- Molecule:组合 Atom,有自己的本地交互(WordPopup、SectionHeader)
- Organism:面板/工具栏,可能接 store(ReviewPanel、VocabPanel)
- Page:路由级,编排多个 Organism(HomePage)
3.2 颜色系统
禁止在 .tsx/.ts 中写 hex 字面量(#xxxxxx)。只能通过 src/lib/design-tokens.ts 引用 CHART_COLORS / SEMANTIC / CEFR_* / cefrChartColor() 等 token。扩展规则、语义色分支、TOGGLE/BUTTON 变体归属、类名组合顺序详见 docs/ui-standards.md §3-§13。/arch-check S10/S11/S12 负责在提交前扫描违反。
3.3 数据获取的错误处理
没有统一的取数 hook。 组件按需自己写 useEffect + 取数,规矩只有一条: 错误不许直接吞进 console.*,走 logger.error 或显式 try/catch。
tsx
// ❌ 错误被吞掉,线上无从排查
useEffect(() => {
getStatsSummary().then(setStats).catch(console.error);
}, []);
// ✅ 走 logger
useEffect(() => {
getStatsSummary()
.then(setStats)
.catch((e) => logger.error('stats summary 取数失败', e));
}, []);/arch-check S4 扫 .then(setX).catch(console.error) 形态,> 5 处告警。
📌 本节 2026-08-15 订正:原文教「统一使用
src/hooks/useFetchData.ts」并给了useFetchData(...)的示例代码——但那个文件在5f92a5c(2026-05)就已删除, 全仓 0 引用。照原文写会调用一个不存在的 API;更糟的是它把现在实际通行的写法 标成「❌ 旧模式」。同期/arch-checkS4 的措辞已经改对了(不再提 useFetchData), 本文档漏改,于是规则与守门相互矛盾约 3 个月。
3.4 事件监听 Hook
统一使用 src/hooks/useTauriEvent.ts,替代手动 listen + cleanup:
tsx
// ❌ 旧模式
useEffect(() => {
const unlisten = listen('word-saved', () => reload());
return () => { unlisten.then(fn => fn()); };
}, []);
// ✅ 新模式
useTauriEvent('word-saved', () => reload(), [reload]);3.5 组件最大行数
软上限 500 行。超过需要拆分。常见拆分模式:
- 容器组件 + 多个子组件(Panel 拆 Overview + Session)
- 抽取 Hook(
useXxxFiltering,useXxxSelection) - 抽取纯函数到
lib/
3.6 禁止直接 invoke
组件和页面不应直接 import { invoke } from '@tauri-apps/api'。走 src/lib/commands.ts 封装:
tsx
// ❌ 禁止
import { invoke } from '@tauri-apps/api/core';
await invoke('save_word', { word });
// ✅ 正确
import { saveWord } from '../lib/commands';
await saveWord(word);3.7 日志规范
禁止在组件/页面/hooks/stores 中直接使用 console.*。统一走 src/lib/logger.ts:
ts
// ❌ 禁止
console.error('Save failed:', err);
console.warn('No data');
// ✅ 正确
import { logger } from '../lib/logger';
logger.error('Save failed', err);
logger.warn('No data');logger API:
| 方法 | 行为 |
|---|---|
logger.debug(...args) | 仅在 DEV 模式输出 [RB] 前缀的 console.debug |
logger.info(...args) | console.info + [RB] 前缀 |
logger.warn(...args) | console.warn + [RB] 前缀 |
logger.error(msg, err?) | console.error + [RB] 前缀,未来可挂接错误上报 |
唯一例外:commands.ts 中 vocabulary seed 的一次性初始化日志可保留 console.error,但其余一律走 logger。
4. 后端规范(Rust)
4.1 错误处理
新命令必须使用 CommandResult<T>,不再用 Result<T, String>:
rust
// ❌ 旧模式
#[tauri::command]
pub fn foo(db: State<'_, DbConn>) -> Result<i64, String> {
let conn = db.0.lock().map_err(|e| format!("Lock: {}", e))?;
conn.query_row("SELECT ...", [], |r| r.get(0))
.map_err(|e| format!("Query: {}", e))
}
// ✅ 新模式
use crate::commands::error::CommandResult;
#[tauri::command]
pub fn foo(db: State<'_, DbConn>) -> CommandResult<i64> {
let conn = db.0.lock()?; // PoisonError → CommandError::Lock
let n: i64 = conn.query_row("SELECT ...", [], |r| r.get(0))?; // rusqlite → Db
Ok(n)
}CommandError 变体:Db · Lock · Http · Io · Parse · NotConfigured · NotFound · InvalidArgument · Auth · RemoteRejected · Other。
从 String 构造:Err("msg".into()) 会自动映射到 Other。
4.2 模块组织
- 按领域分模块:
commands/sync/{mod,common,push,pull}.rs - 单文件软上限 500 行;超过需要拆
- 辅助函数
pub(super) fn限制在模块内访问 - 共享辅助函数下沉到
commands/common.rs(若跨模块复用)
4.3 SQL 查询
- 用
COLLATE NOCASE,不用LOWER() - 所有软删除表查询必须
WHERE deleted_at IS NULL - 多参数
IN (?1, ?2, ...)用(1..=n).map(|i| format!("?{i}")).collect::<Vec<_>>().join(",")
4.4 模块命名
snake_case.rs。每个命令模块一个文件或一个目录(含 mod.rs)。
4.5 日志规范
| 级别 | 使用场景 | 示例 |
|---|---|---|
log::error! | 不可恢复错误、需要开发者关注的异常 | 数据库连接失败、HTTP 500 |
log::warn! | 可恢复异常、降级路径 | 无 Supabase 配置、字段缺失 |
log::info! | 关键业务事件(sync 完成、auth 切换、migration 执行) | "User signed in"、"Sync completed" |
log::debug! | 调试信息(release 不输出,仅 dev 可见) | SQL 结果行数、中间计算值 |
log::trace! | 极详细追踪(一般不用) | 循环每次迭代 |
禁止记录的敏感字段:
access_token/refresh_token/password/ 含 token 的完整 URL- 邮箱须脱敏:
a***@domain.com(用mask_email()辅助函数) user_id本身不算敏感,可直接记录
格式约定:每条日志以 [模块名] 前缀开头,便于过滤:
rust
log::info!("[auth] User signed in: {}", mask_email(&session.email));
log::warn!("[sync] No Supabase config, skipping push");
log::error!("[recommend] HTTP failed: {}", e);5. Content-Script 规范
5.1 模块化
目录结构:
src-tauri/src/content-script/
├── core/ # IPC, 状态, 常量, 观察器管理
├── features/ # 各功能模块(popup, highlight, reader, ...)
├── index.js # IIFE 入口
├── events.js # 事件分发
└── styles.js # CSS 注入构建脚本 scripts/build-content-script.mjs 按顺序拼接为单个 JS 文件,include_str!() 注入到 webview。
5.2 全局状态
使用命名空间对象 __rb,不创建散落的全局变量:
js
const __rb = {
settings: { ... },
vocab: { ... },
ui: { ... },
cache: { ... },
};5.3 IPC 必须走 __TAURI_INTERNALS__
js
// ❌ 禁止(content webview 中 @tauri-apps/api 不可用)
import { invoke } from '@tauri-apps/api';
// ✅ 正确
window.__TAURI_INTERNALS__.invoke('save_word', { word });5.4 DOM 操作要暂停 Observer
js
// core/observer.js
export function withObserverPaused(fn) {
mutationObserver.disconnect();
try { fn(); } finally { mutationObserver.observe(document.body, OBS_OPTS); }
}6. Zustand Store 设计
6.1 UI state 与 领域数据分离
| Store | 职责 | 示例字段 |
|---|---|---|
usePanelStore | UI 布局(panel 可见性、宽度、overlay 开关) | activePanel, leftPanel, panelWidth, reportOverlayOpen |
useReviewStore | 复习领域数据 | currentCard |
useRssStore | RSS 领域数据 | drilldownFeedId |
useTabsStore | 多标签状态 | tabs, activeTabId |
useAuthStore | 认证 + 同步 | session, isSyncing, lastSyncAt |
禁止在 usePanelStore 中混入领域数据(currentReviewCard 应在 useReviewStore)。
6.2 跨 store 组合在调用点
需要同时更新 UI store + 领域 store 的动作,不要在任一 store 中写组合方法。调用点组合:
tsx
// ❌ 禁止在 store 中耦合
openRss: (feedId) => set({ leftPanel: 'rss', rssDrilldownFeedId: feedId })
// ✅ 调用点组合
onClick={() => {
useRssStore.getState().setDrilldownFeed(feed.id);
usePanelStore.getState().openLeftPanel('rss');
}}7. 错误处理
7.1 前端
- 异步操作必须
.catch();禁止完全忽略错误(.catch(() => {})) - 用户可见的错误通过 UI 呈现(toast、inline message);不要只
console.error() - 取数失败走
logger.error(见 §3.3),不要直接console.error
7.2 后端
- 用
CommandResult<T>(见 §4.1) log::error!/log::info!用于日志;错误返回给前端的是序列化后的字符串(CommandError: Serialize → String)- 同步过程中的错误收集到
errors: Vec<String>不中断整体流程(参考sync::sync_now)
8. 文件组织
8.1 新建文件归属
| 文件类型 | 归属目录 | 命名 |
|---|---|---|
| React 组件 | src/components/ | PascalCase.tsx |
| 页面 | src/pages/ | PascalCase.tsx |
| Zustand store | src/stores/ | useCamelCase.ts |
| 工具函数 | src/lib/ | camelCase.ts |
| React hook | src/hooks/ | useCamelCase.ts |
| 类型定义 | src/types/ | camelCase.ts |
| Rust 命令模块 | src-tauri/src/commands/ | snake_case.rs 或 snake_case/mod.rs |
| 数据库相关 | src-tauri/src/db/ | snake_case.rs |
| 文档 | docs/ | kebab-case.md(英文,不带数字前缀) |
| 跨端交接 | docs/cross-end/ | NN-kebab-case.md(NN = 全局递增序号,见该目录 README) |
| 活跃计划 | docs/plans/ | kebab-case-plan.md |
| 归档计划 | docs/plans/archive/ | 同上 |
文档命名只有两种合法形态,判据是「这份文档有没有位置意义」:
docs/根 +docs/plans/→ 纯 kebab-case,禁数字前缀。 这些文档彼此独立、无阅读顺序, 编号只会制造「05 和 06 是不是要连着读」的假暗示,且插入新文档时被迫重编号。docs/cross-end/→NN-前缀是对的,因为它记录的是时序:交接 → 对端确认 → 回执, 序号即事件顺序,编号本身就是信息。
⚠️
docs/archive/里的00-项目概述.md…09-双端整合实施计划.md是历史形态,不是范本。 那是项目最初的一套规划文档,编号 = 阅读顺序,作为完整连续序列保留有意义 (拆掉前缀会让它断成「00-07 有号 / 08-09 无号」,比现状更糟)。 但新文档不要仿它——2026-08 前09-双端整合实施计划.md一直滞留在docs/根, 与周围 13 份 kebab-case 英文文档格格不入,正是因为旧规范把NN-中文标题.md和kebab-case.md并列却不给判据。该形态现已无活跃实例。守门:
/arch-checkS29。
8.2 临时文件
| 类型 | 目录 | Gitignore |
|---|---|---|
| 日志 | logs/ | ✅ |
| 备份 | backups/<YYYY-MM-DD>/ | ✅ |
| 临时 | temp/ | ✅ |
8.3 不应创建的文件
- 不在根目录创建散落的
.sql、.json、.log docs/下不创建英文文档(统一中文)- 不创建额外的根目录 markdown(只允许
README.md/CLAUDE.md/CHANGELOG.md三份。PROGRESS.md已于 2026-08-14 退役进docs/archive/——它与 CHANGELOG 记的是同一类东西, 双份账本必然其一落后) docs/plans/中完成或放弃的计划必须归档(git mv到archive/)
9. 提交规范
格式:<type>(<scope>): <subject>
| type | 用途 |
|---|---|
feat | 新功能 |
fix | Bug 修复 |
docs | 文档更新 |
refactor | 重构(无功能变更) |
style | 样式/格式调整 |
perf | 性能优化 |
chore | 构建/工具变更 |
示例:
feat(sync): add soft-delete propagation for learning_entriesrefactor(panel): split usePanelStore into layout + domain storesfix(review): hero word waits for highlighting to finish before applying
提交前工作流
1. /arch-check ← 架构不变式(< 30s)
2. /build-check ← 编译检查(cargo check + pnpm build)
3. 手动测试 / tauri dev
4. /rb-code-review ← 代码质量审查
5. /doc-sync-check ← 检查文档是否需要同步
6. git commit ← 提交四个 skill 无硬依赖,可单独执行,但推荐按顺序。
10. 分支管理 & 版本发布
模式 = 主干开发(trunk-based),不用 GitFlow。 个人开发单机维护
develop/release常驻分支是纯负债。默认在main上线性提交,只在真正需要隔离时切短命分支,合入即删。分支是一次性脚手架、不是永久档案——历史留在main上就够了。 (与 CLAUDE.md §8 同源,此处为人类翻阅版;两处若冲突以本节 + CLAUDE.md 较新者为准。)
10.1 何时直接在 main(约 80% 的提交)
小的、自包含、一次坐下能收尾、且能保持 main 绿(编译过 + /build-check Step 6 命中的高危测试过)的改动——bug fix、文档、单文件功能、reseed。直接 commit 到 main。
10.2 何时切短命分支
命中任一即切;合入后 git branch -d 立即删:
| 触发 | 例子 | 完事 |
|---|---|---|
① 大/险重构,中途会让 main 红好几个 commit | 三端表重命名、sync 引擎重写 | 分支上弄绿 → merge → 删 |
| ② 可能丢弃的实验 / spike | 探索性重绘 | 放弃 = 删分支,不污染 main |
| ③ 合入前想过一遍 CI / PR 门 | 临发版前、动迁移链 | PR 触发 ci.yml → 绿 → merge → 删 |
④ 跨多会话大工程,期间要能回 main 发小修 | 少见 | — |
合入统一用 git merge --ff-only(保持线性历史,无多余 merge commit)。
并行分支的合入序:两条分支从同一 main 分叉后,只有先合入的能 fast-forward,第二条必然被 ff-only 拒绝(main 已前进)。后合入者:
git rebase main(此时才真正暴露文本冲突)→ 重跑/build-check→ 再merge --ff-only。不要为此改用 merge commit。
10.3 多会话并行纪律
核心事实:共用同一工作树有三条独立的静默丢失路径,只有物理隔离(独立 worktree)能同时挡住三条。
路径 A · 文件级(同文件并发写):两会话同时改同一文件,文件系统层面是后写者赢、先写者的改动静默消失——git 不参与、也无从发现(它只看到最终落盘那一版)。这不是"提示你冲突",是"悄悄丢一半"。约定"别碰同一文件"挡不住(人的判断力有限)。
路径 B · 工作树级(
reset --hard类破坏):任一会话跑git reset --hard/git checkout ./git stash push,会无差别抹掉整棵树上所有未提交改动,包括别的会话正攥在手里的。 🔴 对路径 B,"文件不相交"提供零保护——它不看谁碰了哪个文件。所以下方任何以文件为粒度的白名单(枢纽文件清单、不相交模块豁免)都永远抓不到它;能挡的只有 worktree 隔离,或"改完立刻提交"。 🔴 显式git add也挡不住:受害者的改动根本没进暂存区,是工作区被清。 事故实录(2026-08-01):并行会话提交完自己的 6 个文件后跑git reset --hard HEAD清残留,抹掉了另一会话未提交的TitleBar.tsx。两会话文件域完全不相交、对方git add纪律也正确,规则按当时写法判定"共用树 OK"——照样丢。reflog 里只留一行reset: moving to HEAD,不主动查发现不了。路径 C · 暂存区级(共享 index 抢跑):
git add与git commit之间存在一个窗口——index 是整棵工作树共享的一份,谁先git commit,谁就把此刻 index 里的全部内容提交走,包括另一会话刚git add进去、还没来得及提交的文件。 🔴 这条不丢代码,丢的是历史的可读性——受害者的改动被塞进一条与之毫无关系的 commit 消息底下。危害是延迟的:当时无感,几周后查「这个组件为什么被删」会翻到一条讲别的事的 commit。 🔴 「改完立刻提交」这条护栏正好覆盖不到它——危险窗口恰恰就在"改完"之后、"提交"之前那几秒;显式git add不但不防,反而是触发条件。 事故实录(2026-08-14):本会话git addT4 的 8 个文件后、git commit之前,并行会话先 commit,把这 8 个文件连同它自己 1 个 docs 文件一起提交成了一条讲「landing 统计实测」的消息(该 commit 里 landing/ 一个文件都没有)。修法 = 未推送时git reset --soft HEAD~1拆成两条,拆完必须git diff <原HEAD> HEAD验树为空证明零改动。 事故实录(2026-08-25,第二次):本会话做完notes.rs拆分后用git commit -a提交,卷走了并行会话尚未提交的.gitattributes+db/migrations.rs(一个 CI 行尾修复),埋进一条讲「notes.rs 拆 5 块」的消息里。同一会话前九次提交都用了 pathspec,这次图省事换成-a就中招——说明这条护栏的价值全在「每次都用」,破一次就等于没有;它防的不是"你不知道该用",是"你知道但偷懒了那一次"。 发现时那两条 commit 已被并行会话推上两个远程,force-push 的代价大于收益,裁定不改写历史、只留本条记录(对方随后补了 CHANGELOG,那份工作的账目已清楚)。这也说明路径 C 的修复窗口很短:未推送时reset --soft拆条很便宜,一旦对方推了就基本只能认。
廉价免疫:共用树时用
git commit <pathspec>(如git commit path/a path/b -m ...)——它绕过 index 直接按路径提交,窗口归零。这是共用树场景下唯一不依赖时序运气的写法。
触发不靠人的记忆(隔离决策由 Claude/harness 承担,不要求用户每次发问)
下面的分级是判定标准,但"每个任务前记得判定"不能压在用户记忆上。触发靠这两条机制:
- 机制①(首选,零记忆·无盲窗):并行工作默认作为 worktree 隔离的子代理启动。用户只说"并行做 X 和 Y",Claude 用
Agent(isolation: "worktree")让每个任务跑在自动创建/自动清理的独立 worktree 里——隔离是"启动方式"自带的属性,用户无需判断、无需碰 git,也没有"两会话同时改同一新文件"的盲窗(物理隔离先于任何编辑)。适用于可委派的实现任务。 - 机制②(Claude 自查,补充机制①覆盖不到的手动多终端场景):Claude 在动手改代码前自己跑
git status+git worktree list。探到并行痕迹(有别的 worktree、或有不是本会话改的未提交文件——包括会话启动时 harness 给的 git status 快照)后分两档处置:- 本任务会碰下方枢纽文件 → 主动开 worktree 并告知用户(挡路径 A + B)。
- 即使不碰枢纽文件、文件域完全不相交 → 仍受路径 B 威胁,必须启用工作树级护栏:每完成一个可编译的小步就提交,不把改动长时间攥在未提交状态。判断责任在 Claude,不在用户。
- 局限:机制②依赖"对方已产生脏树/worktree 信号",两个全新会话同时起手改同一新文件有短暂盲窗——所以能用机制①就优先用①。(更强的 harness 层强制可选:PreToolUse hook 挂 Edit/Write,改 hub 文件时自动查 git 并警告/拦截;连 Claude 记不记得都不依赖,需单独配置。)
按碰撞风险分级判定(同 §"修法范围按影响校准"哲学)。判定看会不会撞枢纽文件:
src/lib/commands.ts(每个 Tauri 命令)、src-tauri/src/lib.rs(每个新命令注册)、src/lib/strings/**(每个 UI 文案)、commands/*/mod.rs、db/migrations.rs、sync/ ——这些几乎每个功能都改,并行功能实现撞它们是大概率而非罕见。
| 并行场景 | 方案 |
|---|---|
| 功能实现 / 会碰上述枢纽文件 / 文件足迹说不清 | 各会话开独立 worktree(自动附带独立分支) |
| 纯文档 / 规划 / 明确不相交的独立模块,且能预先说清各自文件域 | 共用树 + 廉价护栏(见下)——注意:本行只豁免路径 A,路径 B / C 照样打得到你。护栏里的"勤提交"对本行是硬要求不是建议;提交一律用 git commit <pathspec>(防路径 C) |
git 约束:同一分支不能被两个 worktree 同时签出,
EnterWorktree默认建新分支——所以开 worktree 就自动得到独立分支,分支隔离免费附送,不是额外要做的一件事。分支的独立价值在合入环节(§10.2/10.5:merge 单元 + 合完即删),不在并行隔离。
三层保障(worktree 场景,缺第一层后两层没机会介入):
| 层 | 机制 | 挡什么 |
|---|---|---|
| ① worktree 物理隔离 | 各会话独立目录 | 同文件并发写的静默覆盖 |
② git merge | 合入时三方合并 | 文本冲突——强制暴露、绝不静默丢失(不同行自动合并;同行冲突标记逼裁决) |
③ /build-check + CI | 合入后编译/测试 | 语义冲突(改不同文件但逻辑互破,如改函数签名 vs 调旧签名——git 合并无冲突却坏了) |
关键心智:worktree 不保证"不发生冲突",而是保证"冲突一定被看见"。共用工作树把这层保护整个绕过。
worktree 操作 + 成本压缩:
- 开会话即
EnterWorktree(或git worktree add .claude/worktrees/<task> -b <branch>);完成后合入main(git merge --ff-only,后合入者先 rebase,见 §10.2 合入序)→ 跑/build-check→ 删 worktree + 分支。 - worktree 分叉基准 = 本地 HEAD(
.claude/settings.json已设worktree.baseRef: "head")。EnterWorktree 默认从origin/main分叉,但本仓单机开发本地 main 才是真相源、origin 只是备份镜像——本地领先未推时从 origin 分叉会基于旧代码,故显式改为 head。 - 成本缓解让 ROI 站得住:pnpm 共享 store(默认已是)→
node_modules近乎秒装(硬链);共享CARGO_TARGET_DIR→ 依赖只编译一次(省最大头,代价见下)。纯文档/规划任务本就无需 build,成本≈0。- ① 两 worktree 代码差异大时增量缓存互相失效;
- ② cargo 对 target 目录持文件锁,两 worktree 同时 build/check 会串行等锁——并行会话里 cargo 长时间无输出的第一嫌疑就是它,不是卡死;
- ③ 🔴 worktree 删掉之后,主树的下一次 build 可能还带着指向那棵已删树的 build script。
build.rs用env!("CARGO_MANIFEST_DIR")定位.env.local,而那个值是编译 build script 时 烤进二进制的;共享 target 时 target 里那份二进制可能是为另一棵 worktree 编的。那棵树一删, 它就去一个不存在的目录找.env.local→ 主树cargo check报Missing build-time config RB_SUPABASE_URL,而.env.local明明就在src-tauri/下。 ⚠️ 报错完全指不到真因:它说的是「缺配置」,实际是「缺的是另一棵树」,照它的指引去cp .env.example .env.local只会覆盖掉本来好好的文件。解药是touch src-tauri/build.rs逼 cargo 重编 build script。2026-08-27 拆sync/pull时实测踩到。 (同理:worktree 里首次 build 需要把主树的src-tauri/.env.local复制过去——它是 gitignored 的,git worktree add不会带。)
共用树的廉价护栏(低风险场景,或确实无法开 worktree 时):
- 勤提交(对路径 B 是唯一护栏,故为硬要求):未提交的改动 = 暴露在
reset --hard下的资产,攥得越久风险越大。每完成一个可编译的小步就提交,别攒一整轮再一次性交。对方一旦提交,你再改就基于其版本——路径 A 的丢失窗口也随之缩到"两边同时攥着未提交的同一文件"这一小段。 - 显式路径
git add <path>,勿git add -A,避免卷入对方未提交改动。(注意:这条只防"误提交别人的",不防别人清掉你的。) - 禁止无差别破坏工作树:
git reset --hard、git checkout ./git restore .、git stash push(无 pathspec)、git clean -fd—— 它们作用于整棵共用树,会连带抹掉其他会话未提交的改动,且受害方毫无提示。确有需要时:先git status逐条确认没有不是自己改的文件,再带 pathspec 精确作用(git checkout -- <你自己的路径>)。
把单文件拆成模块目录时:git add -A <dir> 不会暂存同名文件的删除
reading.rs → reading/{analysis,view,...}.rs 这类拆分,git add -A src-tauri/src/commands/reading 只暂存那个目录,不含兄弟文件 reading.rs 的删除。于是提交出来的 commit 里两者并存, Rust 报 file for module 'reading' found at both reading.rs and reading/mod.rs —— 该 commit 单独 checkout 编译不过。
🔴 本地毫无感觉:工作树里旧文件确实已删,所以 cargo test 全绿、git status 事后也干净, 只有别人(或 git bisect)checkout 到那一条时才炸。 实录:c57c9fb(reading.rs 拆 5 块)正是这样一条 commit,删除在下一条 95b12d9 才被 commit -a 顺带补上;两条合起来是对的,中间那条对 bisect 有害。
做法:拆完提交前 git status --short 确认有 D <旧文件> 那一行;或把 pathspec 提到 父目录(git add -A src-tauri/src/commands/)。提交后 git show --stat 再核一眼有没有 delete mode。
第二次实录(2026-08-27,sync/pull.rs 1804 行拆成 sync/pull/{mod,vocab,library,prefs}.rs): 这一次按本条走了——用 git rm src-tauri/src/commands/sync/pull.rs 让删除在写新文件之前就进暂存区 (比事后核 git status 更早一步,git add -A <新目录> 那个坑根本没有机会形成), 提交后又 git worktree add /tmp/verify-split HEAD 单独 checkout 跑了一遍 cargo check。
- 禁止切换 HEAD 分支(
git checkout <branch>/switch)——会夺走对方脚下的分支,令其 commit 落错。注意reset --hard HEAD不移动 HEAD 引用,不在本条覆盖范围内,它归上一条。 - 开工前
git status+git worktree list探测:若有自己没碰过的文件正被改动 → 有活跃并行会话,涉枢纽文件则停手改开 worktree;不涉也要把"勤提交"当硬要求执行。 - 怀疑丢过东西时:
git reflog找reset:/checkout:条目定位破坏时点。⚠️ 未提交的改动不在 reflog 里、也不可恢复(除非编辑器本地历史有留),reflog 只能告诉你"何时被谁清的"。
10.4 版本发布 = tag,不是分支
由 /release skill 编排。
- 版本唯一真相源 =
src-tauri/tauri.conf.json的version(决定 app 版本 + tag)。package.json的version是死字段(长期0.0.0),勿据它判断版本。 - 递进:
v0.1.0-dev.N(当前内测)→ 功能冻结 →v1.0.0-rc.N→ 首个真实外部用户安装的版本 =v1.0.0。 - v1.0 发出后
main多一条义务:永远保持可发布。任何险活先走短命分支(触发 ①),别直接怼main。 - 热修按需、不预建 release 分支:v1.0 后线上要热修 → 从对应 tag(如
v1.0.0)切临时release/v1.0.x→ 修 → 打v1.0.1→ cherry-pick 回main→ 删分支。 - v1.0 后语义化:fix → PATCH、加功能 → MINOR、破坏性 data-model 变更 → MAJOR(有迁移链,基本用不到)。
- tag 推
githubremote 触发 Actions 构建(Win + macOS);origin(Gitee)为镜像。详见/releaseskill。
10.5 分支卫生
- merge 后立即删本地 + 远程分支(
git push <remote> --delete <branch>)。 - 定期查僵尸分支:
git rev-list --left-right --count main...<branch>,0 ahead= 已合并可删。 - 定期
git remote prune github/git remote prune origin清理已删远程分支的本地引用。 - 本地
main定期git push github main+git push origin main双推备份——单机开发 = 未推的 commit 无备份。