【免费下载链接】thinkrail
Vibe code with pi in a lightweight, real IDE that customises itself around the way you work — The Vibe You Need
本文以 ThinkRail 官方博客《ThinkRail Workspaces》为主线,讲清一个核心问题:在 IDE 里让 AI Agent 直接改代码时,如何避免它动到你正在编辑的工作区。文章会覆盖隔离 workspace 的创建流程、目录结构与命名规则、背后的git worktree调用链、多任务并行策略、合并与开 PR 的收尾方式,以及删除回收时的安全边界,并结合仓库源码给出可验证的实现细节。
起点:项目文件夹与隔离 workspace 是两种不同的风险模型
在 ThinkRail 中启动一个项目时,你有两个选择:直接工作在项目文件夹,或者创建一个新的 workspace(隔离工作区)。
直接工作在项目文件夹意味着所有改动会立刻作用于当前 git 分支。这条路最直接,但也最不安全,原因在原文中讲得很明确:
- 未保存的改动可能被 AI Agent 移除。Agent 在同一个工作目录里执行文件操作与命令,你尚未提交、甚至尚未保存的内容与它共享同一个物理目录。
- 已保存(已提交)的改动仍可能被覆盖,只要 Agent 拥有执行某些 git 操作的权限。
- 多 Agent 并行时会互相妨碍。即便你在项目文件夹里同时开多个 Agent,它们仍然共享同一个工作区和同一个分支。
workspace 正是同时针对这三个问题的解法。它把 Agent 的工作目录搬到一个独立的git worktree中,配一条独立分支,从而让"你的检出"和"Agent 的检出"在文件系统层面就是两份东西。
| 维度 | 项目文件夹(Default workspace) | 隔离 workspace |
|---|---|---|
| 工作目录 | 项目根目录本身(git 主工作树) | ~/.thinkrail/worktrees/<project-slug>/<branch> |
| 分支 | 当前检出的分支 | 从你选定的基线切出的新分支 |
| 改动影响范围 | 立刻影响当前分支与你正在编辑的文件 | 只存在于独立 worktree 与独立分支 |
| 并行能力 | 多 Agent 共享同一目录,易冲突 | 每个 workspace 独立目录,可并发 |
| 能否删除 | 不能(非可删除、非可改名) | 可以,删除时只回收 worktree |
| 适用场景 | 快速试验、单人小改动 | 让 Agent 承担完整任务、多任务并行 |
关键规则在 packages/contracts/src/domain.ts 的Workspace类型中就有体现:记录里同时存在branch(git 分支)与worktreePath(实际工作目录),并用可选的kind: "default" | "external"标记两类"用户自有"的例外。
创建隔离 workspace:Start building 的完整流程
原文描述的入口是点击"Start building"按钮。在仓库实现中可以对应到两个层次:
- 入口卡片层:Welcome 面板把两种模式做成可见的并列选择——"Start building"(隔离 worktree)与 "Work in project folder"(Default workspace),见 apps/web/src/panels/SPEC.md。
- 对话框层:
NewWorkspaceDialog用一个target: "worktree" | "default"的切换控件承载两种目标,标签分别为 "Isolated workspace" 与 "Project folder",文案定义在 packages/locales/en.json。
在新建窗口中你需要做三件事:
- 选择 workspace 的 git 分支。对话框中的
BranchPicker会列出本地与远端分支,并在你选中远端分支时在后台预取它(git.prefetch),从而把网络等待挪出创建的关键路径,见 apps/web/src/panels/NewWorkspaceDialog.tsx。 - 填写描述。这段描述承担双重职责:既是人类可读的 workspace 名称,也是发给 AI Agent 的初始 prompt。
- 确认创建。创建成功后,界面立刻为该 workspace 开一个新 chat 并启动 Agent,Agent 会马上开始处理这个任务。
原文特别提示:Agent 之后可能会为了简洁而重命名 workspace。这一点在源码里是有明确设计的:renameWorkspace会写入规范化的显示名并打上renamed: true,而set_title这类自动命名只在目标尚未命名时生效,因此任何手动名称始终优先,第一个名字即为最终名字(见 architecture.md)。
分支名与显示名是两套标识
很多 worktree 工具会把目录名和分支名绑死,ThinkRail 则显式解耦:
- 显示名
name:保留大小写与标点,最长 60 字符,由toDisplayName处理。 - 分支名
branch:由显示名转成 ASCII kebab slug(最长 60 字符),并通过branchSlug清洗;若清洗后为空则回退为workspace。 - 自动命名:当你没有提供名字时,使用
workspace-1、workspace-2…… 作为分支名与显示名。
重名处理同时检查git ref 与 worktree 目录两处,命中时追加-2、-3等后缀,见 packages/server/src/workspaces/workspaces.ts。这一设计解决了原文没有展开、但在实际使用中很常见的两个坑:归档后分支仍然存在,以及改名后旧目录仍被占用。
隔离的实现:一次git worktree add背后的调用链
原文给出的关键事实是:ThinkRail 使用git worktree功能来创建和管理隔离环境,更新后的代码位于~/.thinkrail下的独立 worktree 目录,例如~/.thinkrail/worktrees/<project-name>/workspace-1。
服务端实现集中在 packages/server/src/workspaces/workspaces.ts 的createWorkspace(projectId, name?, baseRef?),可以拆成六个阶段:
1)解析基线与分支名。指定baseRef就用它;否则读取仓库当前的rev-parse --abbrev-ref HEAD。任何进入 git 参数的 ref 都会先经过assertSafeRef校验,防止来自不可信仓库的"选项形状"分支名被当作命令行参数。
2)远端引用缺失时的兜底 fetch。只有当基线是远端跟踪分支、且本地不存在该 ref 时才联网抓取,并且以 ref 是否存在作为判定依据,而不是 fetch 的退出码——裁剪过的 refspec 可以让 fetch 成功返回却不产生目标 ref。失败时抛出的错误区分两种情况:
Could not fetch <base>: <git 的 stderr> Could not fetch <base>: <remote> does not map it into refs/remotes — check remote.<name>.fetch3)计算 worktree 路径。目录规则是dataDir()/worktrees/<project.slug>/<branch>,精确对应原文的~/.thinkrail/worktrees/<project-name>/workspace-1。
4)执行 worktree 创建。实际命令形如:
git worktree add ~/.thinkrail/worktrees/<project-slug>/<branch> \ -b <branch> --no-track --end-of-options <full-base-ref>三个细节值得单独说明:
--no-track:避免新分支把远端跟踪基线设成自己的 upstream。否则 workspace 终端里的git push/git pull会指向基线分支而不是你的新分支;按仓库设计,upstream 应由用户在首次推送时自己设置。- 完整 ref 名:探针与
worktree add使用同一个全名(如refs/remotes/origin/main),而不是origin/main这种简写,防止本地恰好存在同名分支时被 git 二次消歧成错误对象。 --end-of-options与--:把 ref 与路径同选项分隔开,属于全仓库统一的防御式调用风格。
5)写入记录并初始化目录。生成的Workspace记录包含id、projectId、name、branch、worktreePath、baseBranch、initialTerminalPending: true、lastActiveAt,若用户提供了名字则追加renamed: true。随后调用ensureWorkspaceScratchDir,最后持久化并发出created生命周期事件。
6)首个终端的持久握手。initialTerminalPending是一个"预留标记":host 幂等地为 workspace 预留一个确定性的终端标签页,成功后调用completeInitialTerminalReservation清除标记。这样即使预留失败,重启或后续创建流程也能补偿,见 packages/server/src/host/initialTerminal.ts。
数据目录可以被覆盖
dataDir()的实现是resolve(process.env.THINKRAIL_DATA_DIR ?? join(homedir(), ".thinkrail")),见 packages/server/src/persistence/persistence.ts。也就是说:
- 默认所有托管 worktree 都在
~/.thinkrail/worktrees下; - 设置
THINKRAIL_DATA_DIR可以把它整体搬走(开发与端到端测试就依赖这一点做隔离); - 该路径始终被解析为绝对路径——因为相对路径会在不同调用点相对不同 cwd 解析,导致托管 worktree 落到两个地方。
隔离带来的副作用:worktree 内的.thinkrail/context
新建的 worktree 里会多出一个目录,路径约定来自 packages/shared/src/paths.ts:
.thinkrail/context/ # 临时文档与工作文件的存放处 .thinkrail/context/todos/ # 待办数据ensureWorkspaceScratchDir会创建这些目录,并写入一个内容为*的.gitignore,使整个目录对 git 零足迹——Agent 在这里放任务规格、草稿、PR 正文等临时文件,既不会污染提交,又能被 spec 相关工具扫描到。原文没有点名这个目录,但它正是"Agent 在隔离 workspace 内自由写文件却不影响提交"的重要配套。
实现里还有三处防御,值得在做安全评估时留意,见 packages/server/src/workspaces/workspaces.ts:
- workspace 根目录不存在时直接抛错,不会把一个被外部删除的 worktree 悄悄复活成空的非 git 目录;
- 逐级用
lstat检查路径分量,拒绝符号链接,避免恶意检出把写入重定向到工作区之外; .gitignore以wx(独占创建)写入,且仅在文件不存在时写——因为这条路径也可能落在用户自己的仓库里,已有文件属于用户。
同时推进多个任务:为什么要从同一分支起多个 workspace
原文的场景非常具体:当一个 Agent 在处理复杂任务时,你可以从同一分支再创建一个 workspace,把另一个任务交给第二个 Agent。例如一个 Agent 实现不改变应用行为的改动,另一个 Agent 更新项目规格文档——这两个任务互不干扰。
能这样做的根本原因,是每次createWorkspace都会拿到独立的目录 + 独立的分支:
- 目录不同,所以文件写入不冲突,Agent A 的
npm install、构建产物、临时文件不会影响 Agent B; - 分支不同,所以提交历史不交叉,
git status、diff 统计、代码评审都按 workspace 维度计算; - 因此不需要等第一个任务完成,两者可以并发执行。
配合"静置货架"(settled shelf)机制,长时间不动的 workspace 会自动从活跃列表沉到每个项目的货架上,当你重新进入时又会回来(见 README.md)。这让"开很多 workspace 并行"不会把左侧导航撑爆。
需要强调的是:并行是目录级隔离,不是仓库级隔离。多个 worktree 共享同一份.git对象库与 ref 命名空间,所以分支名冲突、同一分支被多处检出的限制依然由 git 本身决定——这也是uniqueBranch要在创建阶段就避开 ref 冲突的原因。
收尾:合并、推送与开 PR
任务完成后,改动需要回到父分支。原文给出了两条路径:
如果 workspace 是从主分支切出来的,可以让 AI Agent 直接创建 Pull Request——"there is a skill for that",指的就是随包分发的shipping-a-pr工作流技能,见 packages/pi-thinkrail-workflow/skills/shipping-a-pr/SKILL.md。该技能把 PR 生命周期拆成六个阶段,并按提问类型路由到对应文档:
| 诉求 | 阶段文档 | 默认模式 |
|---|---|---|
| 工作已完成,创建 PR | creating.md | snapshot |
| 让 PR 与基线同步 / 解决冲突 | syncing.md | snapshot |
| 更新 PR 标题或描述 | body.md | snapshot |
| 补充或刷新 PR 截图 | screenshots.md | snapshot |
| 一次性汇报检查状态 / 合并状态 | checks.md | snapshot |
| 监控 CI、定位并修复失败检查 | checks.md | wait |
| 处理评审意见 | review-comments.md | snapshot |
它还有两条与 workspace 强相关的约定:
- 工作文件位置:
pr-body.md与pr-shots/都放在 workspace 内被 git 忽略的.thinkrail/context/下,PR 正文始终通过--body-file传入而不是内联; - snapshot 与 wait 两种完成模式:默认只取一次新鲜的检查与合并状态快照就停止;只有明确要求"等到绿"或"监控 CI"才进入轮询模式,避免一次普通推送把流程升级为无休止的等待。
如果 workspace 是从特性分支切出来的,可以让 Agent 把改动推送到该分支,或者你自己在终端里执行。Workspace.baseBranch记录的是创建来源(不会再变),而 diff 到底和谁比较由独立的diffBase决定,见 packages/server/src/git/diffScope.ts:
export function diffBaseRef(ws: Pick<Workspace, "baseBranch" | "diffBase">): string { return ws.diffBase ?? ws.baseBranch; }这个区分很关键:把"出处"和"比较基准"合成一个字段,会导致重新指向比较基准后,界面上"来自某分支"的说明开始说谎。默认的 diff 采用 merge-base 语义,所以基线分支上的新提交不会虚增你的改动行数;workspace 列表里的+/-徽标与 Changes 面板用的是同一套范围计算。
删除与回收:先摘记录,再后台清理
删除 workspace 并不是一次同步的rm -rf。从源码看,它是快慢分离的两段式:
export function forgetWorkspace(id: string): Workspace | null { const all = loadWorkspaces(); const ws = all.find((w) => w.id === id); if (!ws) return null; if (ws.kind === "default") throw new Error("The Default workspace cannot be removed"); saveWorkspaces(all.filter((w) => w.id !== id)); observedActivityAt.delete(id); observedHeadSha.delete(id); emit({ kind: "removed", projectId: ws.projectId, id: ws.id }); return ws; } export function reclaimWorktree(ws: Workspace): void { if (ws.kind === "default" || ws.kind === "external") return; const project = loadProjects().find((p) => p.id === ws.projectId); if (!project) return; if (resolve(ws.worktreePath) === resolve(project.path)) return; const removed = git(project.path, ["worktree", "remove", "--force", ws.worktreePath]); if (!removed.ok) { rmSync(ws.worktreePath, { recursive: true, force: true }); git(project.path, ["worktree", "prune"]); } }forgetWorkspace只删持久化记录,因此从workspace.list里立刻消失,UI 响应很快;reclaimWorktree才是慢的那一半:git worktree remove --force,失败时退化为rmSync+git worktree prune。它保留分支,所以你的提交不会随 workspace 一起消失。
host 侧的workspace.remove处理器把这条链路串起来:先同步摘掉记录,再依次清理 spec 索引、评审缓存、变更记录、轮次记录、文件监听和终端,最后把archiveTeardown(清理会话、落定变更产物、回收 worktree)丢到后台执行,见 packages/server/src/host/handlers.ts 与 #L614-L627。
因为分支被保留、目录可能仍被占用,创建时的分支去重必须同时考虑两者,端到端测试专门覆盖了这个回归场景:"creates, removes, and re-creates worktree workspaces (no branch collision)",见 e2e/workspaces.spec.ts。
两类"用户自有"工作区:Default 与已存在的 worktree
原文的对比只提了"项目文件夹"和"新 workspace",实现上还有第三个入口:附着(attach)一个已存在的 worktree。三者都由kind区分:
1)托管 workspace(无kind字段):由createWorkspace创建,位于数据目录下,可改名、可删除、可回收。
2)Default workspace(kind: "default"):每个项目恰好一个,worktreePath就是项目目录本身(git 的主工作树)。它由listWorkspaces惰性补齐,返回值固定排在最前。两条强制约束写在服务端而不是仅靠 UI 隐藏:
forgetWorkspace对kind: "default"抛错——否则归档的rm -rf兜底会把用户的项目目录交给删除逻辑;renameWorkspace对kind: "default"抛错——否则会执行git branch -m改掉用户真实分支。
该记录还带renamed: true,作为双保险让自动命名永远不会碰它。同时它的branch/baseBranch会按"文件夹真相"在列表时刷新,所以你在终端里git switch之后,侧栏与顶栏会收敛到新分支,而不是停留在旧值。
3)外部 worktree(kind: "external"):用户在项目菜单中显式附着的既有 worktree,记录在它原来的位置,ThinkRail 只使用其 cwd,从不创建、改名或回收它。候选列表由git worktree list --porcelain -z异步解析,并过滤掉:项目目录本身、prunable的注册项、以及已经被其它项目或 workspace 表示的路径。附着时的校验很严格:
- 路径必须确实是本项目注册的 worktree;
- 处于detached HEAD的 worktree 会被拒绝,提示先建分支;
- 若该路径已被另一个 ThinkRail 项目占用,则报错。
相关实现见 packages/server/src/workspaces/workspaces.ts,行为在 e2e/workspaces.spec.ts 中有两个对应测试:"opens and safely forgets an existing user-owned worktree" 与 "an attached worktree cannot also be opened as a project"。前者在附着前后逐字节比对git status --porcelain=v1 -z、当前分支、HEAD 与 worktree 注册表,用来证明"附着不会动你的检出",包括已暂存、未暂存与未跟踪文件都被保留。
实践建议与已知边界
结合原文与实现,可以给出几条可操作的建议:
- 让 Agent 在托管 workspace 里干重活。它带来的最大收益不是"多一个分支",而是"你正在编辑的目录不会被它写"。
- 按任务而不是按文件切分并行 workspace。原文的例子(一个改行为、一个改规格)成立的前提是两者改动的文件集基本不重叠;共享
.git意味着它们仍会争用分支名与 ref。 - 给 workspace 一个像样的描述。它同时是显示名与初始 prompt,描述含糊会让 Agent 的首轮推理也含糊;但也不用纠结最终命名,Agent 之后可以改名。
- 不要指望删除 workspace 会删掉分支。
reclaimWorktree只移除 worktree,分支保留;要真正清理分支需要自己执行git branch -d。 - 远端分支基线可能需要网络。首次从远端分支创建时若本地没有对应跟踪 ref,会走一次兜底 fetch;离线或远端无响应时会明确报错,而不是静默切到别的提交。
- 注意
--no-track的语义。新 workspace 分支默认没有 upstream,首次git push需要显式-u,这是刻意设计而非缺陷。 - 改动范围与基线要分开看。想知道"这个 workspace 相对基线改了多少",看
diffBase(默认等于创建时的baseBranch)驱动的 diff 统计;想知道"它从哪儿来",看不会被改写的baseBranch。
延伸阅读:从哪些文件继续深入
- 创建、附着、改名、回收的完整契约:packages/server/src/workspaces/SPEC.md
- 创建工作区与 Default 补齐的实现:packages/server/src/workspaces/workspaces.ts
- 数据目录与持久化:packages/server/src/persistence/persistence.ts
- WebSocket 方法入口与后台回收:packages/server/src/host/handlers.ts
- 新建对话框与分支预取:apps/web/src/panels/NewWorkspaceDialog.tsx
- 既有 worktree 附着对话框:apps/web/src/panels/ExistingWorktreeDialog.tsx
- 架构决策"Workspaces are git worktrees":architecture.md
- diff 范围与基线解析:packages/server/src/git/diffScope.ts
- 端到端行为验证:e2e/workspaces.spec.ts
- 开 PR 的工作流技能:packages/pi-thinkrail-workflow/skills/shipping-a-pr/SKILL.md
如果你只想记住一句话:ThinkRail 的 workspace 不是"另一个窗口",而是一次真实的git worktree add——独立目录、独立分支、独立 diff,改动只有在你明确要求时才回到父分支。理解了这一点,隔离、并行与收尾的每个行为都能在源码里找到对应的那一行。
【免费下载链接】thinkrail
Vibe code with pi in a lightweight, real IDE that customises itself around the way you work — The Vibe You Need
相关推荐
ThinkRail 工作区隔离实战:git worktree 让多个 AI Agent 并行不冲突
ThinkRail 工作区隔离实战:git worktree 让多个 AI Agent 并行不冲突 ThinkRail 是一款围绕 pi 编码 Agent 打造
OpenCrabs 多 Agent 编排实战:用 spawn_agent 并行分发任务(git worktree 隔离原理一次讲清)
OpenCrabs 多 Agent 编排实战:用 spawn_agent 并行分发任务(git worktree 隔离原理一次讲清) OpenCrabs 是一个
ThinkRail workspaces 模块深度解析:用 git worktree 构建可隔离、可归档的开发锚点
ThinkRail workspaces 模块深度解析:用 git worktree 构建可隔离、可归档的开发锚点 在 ThinkRail 中,"工作区(wor
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考