feat(pi-worktree): 新增斜杠命令 git worktree 管理扩展

提供 /wt(别名 /worktree)一键拆分/合并:
- /wt new 建 worktree 后经 SessionManager.forkFrom + switchSession
  迁移会话 cwd,历史上下文保留;合并/切换同理
- /wt merge 先确认再 rebase 到来源分支、默认 --ff-only(可选
  --squash),来源改动用 stash 兜底,冲突可中止或保留后用
  /wt merge --continue / --abort 处理,永不 push
- 来源 checkout 的 .pi/worktree.json 声明环境复刻(copy/link/env/
  postCreate),确认框先展示计划再执行;postCreate 仅在项目已信任
  时以 argv 形式 execFile,失败可 /wt reopen 增量重跑
- 登记文件 <agentDir>/pi-worktree/state.json 原子写入并加锁
  串行化;会话切换后向模型注入 cwd 变更提示;未被 .gitignore
  覆盖的软链/环境文件写入 .git/info/exclude 防止误提交
- 仅注册用户显式触发的斜杠命令,不暴露模型可调用工具

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
云服务部-叶林立
2026-10-19 17:45:41 -07:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent f237025cf9
commit 97c2e75dfe
22 changed files with 3195 additions and 1 deletions
+6 -1
View File
@@ -44,6 +44,10 @@
- 上游来源:<https://github.com/earendil-works/pi/tree/main/packages/coding-agent/examples/extensions>
- 初始参考快照:`dcd461925db2edf69a43c8135db1180d418afd54`(2026-08-24)。
- 该目录由本仓库直接维护,保留上游 MIT 许可证与来源说明;根组合包直接加载源码并通过本地 `file:` 依赖打包。
- `pi-worktree/`:完全自维护(无上游)的斜杠命令 git worktree 管理扩展,提供 `/wt`(别名 `/worktree`)的 `new` / `merge` / `switch` / `list` / `remove` / `prune` 子命令,实现「一键拆分」与「一键合并」;不注册模型可调用工具。
- 状态登记位于 `<agentDir>/pi-worktree/state.json`(目录 `700`、文件 `600`、原子写入),不放 `<agentDir>/my-pi/` 下;Pi 不能修改进程 cwd,会话切换通过 `SessionManager.forkFrom`(或为空会话写入最小 header)生成 cwd 指向目标目录的新会话文件再 `ctx.switchSession` 重建运行时,切换后的收尾只能在新 `ctx` 的 `withSession` 回调中执行。
- 合并流程固定为先确认、再 rebase 到来源分支、最后默认 `--ff-only` 合并(`--squash` 可选压缩),冲突时可选择中止或保留后用 `/wt merge --continue` / `--abort` 处理,来源改动用 stash 兜底,永远不会 push;登记文件写入走 `state.json.lock` 锁文件串行化;worktree 根目录与分支前缀由 `settings.json.piWorktree` 的 `root` / `branchPrefix` 配置。
- 来源 checkout 的 `.pi/worktree.json`(`version: 1`)声明环境复刻:`copy` 只复制未跟踪文件、`link` 只软链 gitignore 目标、`env` 渲染为 `600` 权限环境文件、`postCreate` 仅在 `ctx.isProjectTrusted()` 时以 argv 形式 `execFile`(绝不走 shell),任一失败即停并可用 `/wt reopen` 增量重跑;未被 `.gitignore` 覆盖的软链/环境文件写入 `.git/info/exclude`(仓库各 worktree 共享),保证 `/wt merge` 不会提交它们。
- `pi-lsp@0.1.7`:提供声明式 LSP 接入;组合包内置 `typescript-language-server@5.3.0` + `typescript@6.0.3`,并配置机器级 `kotlin-lsp --stdio` 与 `jdtls`。
- `pi-hermes-memory/`:从官方 `pi-hermes-memory` 仓库导入并由本仓库直接维护的 Hermes 风格持久记忆扩展;提供全局/项目/用户/失败四类记忆、SQLite FTS5 trigram 会话与记忆检索、`SKILL.md` 过程记忆、correction 检测、自动 consolidation、`STANDING.md` 常驻指令和 6 个记忆工具。
- 上游来源:<https://github.com/chandra447/pi-hermes-memory>
@@ -61,7 +65,7 @@
- 上游参考一:<https://github.com/jjuraszek/pi-condense>,快照 `59c95110aacd8131db71e21296765dd80b72fae8`(`v2.9.1`)。
- 上游来源二:<https://github.com/ogulcancelik/pi-extensions/tree/main/packages/pi-codex-compaction>,导入快照 `c4d6ca8fb484ba51684296ba8b4715f8c7b3ac91`(`0.1.4`)。
- `pi-compaction` 采用全新的 `pi-compaction` version 1 Session 记录和 `settings.json.piCompaction` 配置,不读取或迁移旧 `context-prune-*`、`contextPrune` 或 `openai-codex-native-compaction` 状态;切换后应使用新 Session。两份上游 MIT 许可证及来源差异保留在包内。
- 根包还固定安装 `@tavily/pi-extension@0.1.2`、`@keenable/pi-search@0.1.2`、`pi-hashline-edit@0.8.3`、`typescript-language-server@5.3.0` 和 `typescript@6.0.3`;`@gotgenes/pi-permission-system`、`@smoose/pi-notify`、`pi-ask-user`、`pi-chrome`、`pi-compaction`、`pi-hermes-memory`、`pi-minimal-footer`、`pi-ssh` 与 `pi-tool-search` 分别指向仓库内对应目录的本地 `file:` 依赖;`pi-subagents/` 仅保留源码,不作为根运行时依赖。
- 根包还固定安装 `@tavily/pi-extension@0.1.2`、`@keenable/pi-search@0.1.2`、`pi-hashline-edit@0.8.3`、`typescript-language-server@5.3.0` 和 `typescript@6.0.3`;`@gotgenes/pi-permission-system`、`@smoose/pi-notify`、`pi-ask-user`、`pi-chrome`、`pi-compaction`、`pi-hermes-memory`、`pi-minimal-footer`、`pi-ssh`、`pi-tool-search` 与 `pi-worktree` 分别指向仓库内对应目录的本地 `file:` 依赖;`pi-subagents/` 仅保留源码,不作为根运行时依赖。
- `install.sh`:先安装根组合包,再通过 `scripts/rebuild-bundle-config.mjs` 整体重建 `<agentDir>/my-pi/` 受管配置目录,然后交互检查 Kitty/Solarized Dark、Oh My Zsh/Powerlevel10k/Zsh 插件、CodeGraph、Kotlin LSP、Java 21+ 和 JDT LS;缺失项目只在用户明确选择 `Y` 后安装或配置。Powerlevel10k 默认配置来自仓库内置的 `config/p10k.zsh`(当前 Rainbow/ASCII 单行紧凑主题)。
- `update.sh`:先通过 `pi update` 升级根组合包,再只升级当前已安装的终端环境和机器级依赖;未安装项直接跳过。不得在 `pi update` 前预清理安装缓存;`pi update` 失败时 fail-fast 停止——Pi 1.3.0 会在下一次 `pi update` 时依据其 `.pi-update-incomplete` 标记自行重跑 `git clean -fdx` 与依赖安装完成自愈,本脚本不做人工恢复。升级成功后调用可定位的更新后组合包 Node helper `scripts/rebuild-bundle-config.mjs` 整体重建 `<agentDir>/my-pi/`:先按 `config/bundle-manifest.json` 校验全部来源与目标,在 agent 目录内构建临时树,再删除旧 `my-pi/`(符号链接只删链接不跟随)并原子替换,不备份、不迁移、不合并不属于组合包的内容。无法从 `PI_PACKAGE_SOURCE` 定位更新后 helper 时必须报告失败,不能回退到可能陈旧的脚本目录 helper。其他组件升级前先查询并比较本地与远端版本,只有版本不同时才下载或替换;Powerlevel10k 配置优先从 `pi update` 后的已安装组合包读取,并与 `.zshrc` 受管块一起按内容比较后增量同步。
- `uninstall.sh`:先移除根组合包,其他共享终端环境和机器工具不卸载。不得删除 `.pi/agent/pi-hermes-memory/`、`projects-memory/`、`.hippo/` 或任何用户记忆数据。
@@ -82,6 +86,7 @@
- `pi-chrome` 在未授权时不注册 Chrome 工具;显式授权后出现的 21 个 `chrome_*` 工具必须由 Tool Search 分入 `chrome-navigation`、`chrome-interaction`、`chrome-debugging`,不得落入 unknown-tools 模型增强路径。授权撤销或过期时必须停用全部 21 个工具,包括 `chrome_find` / `chrome_inspect`;Tool Search 重新激活 schema 不能替代每次执行时的授权校验。
- 标准组合包工具全部命中预置目录时 Tool Search 不得调用模型、创建用户缓存或发送隐藏 schema。只有存在未识别第三方工具时才允许惰性模型增强;生成结果必须保留全部预置 assignment,模型元数据不得替代真实名称、参数或 schema。缓存必须位于 agent 目录且权限 `600`;模型失败必须继续使用预置目录 + 确定性未知工具分组。`groupOverrides` 优先级高于预置分组。
- Tool Search 必须直接使用当前 `@earendil-works/pi-coding-agent`、`typebox`、`ModelRegistry.complete()` 和 `setActiveTools()`;首次纯增加载使用当前 Pi 的增量结果传播,LRU 替换允许走宿主安全 fallback。不得安装或映射旧 `@mariozechner` runtime,不得恢复 provider payload 改写、代理分发或隐藏 `sendMessage` steer/retry 兼容循环。Tool Search 是唯一可调用 `getActiveTools()` / `setActiveTools()` 管理工具显隐的组合包扩展;`pi-permission-system` 只能在调用时裁决,不能读写 active tool 列表。
- `pi-worktree` 只注册 `/wt` / `/worktree` 斜杠命令,不暴露任何模型可调用工具;其 git 操作由用户显式触发,经 `execFile` argv 直接调用(带超时、不经 shell),按设计绕过 Bash 工具权限策略,不新增权限审批层。
- `.pi-debug/` 是 `/dump-system-prompt` 生成的本地诊断目录,不提交到仓库,也不作为组合包运行时配置源。
- CodeGraph MCP 扩展只配置 Pi 到外部 `codegraph` 命令的连接。根包安装本身不安装 CodeGraph;便捷脚本仅在组合包安装完成且用户明确选择 `Y` 后调用官方安装器。安装、升级和普通启动流程不得执行 `codegraph init`;只有用户在目标项目显式调用 `/plugin_init` 并确认后,命令才可以 `.codegraph/codegraph.db` 为权威标志初始化缺失状态。命令必须先预检 CLI,失败不得热重载,也不得隐瞒此前已完成的部分初始化;不得改动 CodeGraph 更新或遥测设置。
- CodeGraph MCP 使用 `keep-alive` 并只直接暴露 `codegraph_explore`;命令缺失、项目未初始化或连接失败时不得阻止其他扩展加载。
+1
View File
@@ -15,6 +15,7 @@
- 本仓库维护的 `pi-notify`:基于 `@smoose/pi-notify@0.1.1`,使用 Kitty OSC 99 在 Pi 完全 settled 或权限系统即将请求人工确认时通知,并支持点击精确聚焦来源 Kitty 窗口。
- 本仓库维护的 `pi-compaction`:以增量投影统一确定性工具结果压缩、后台语义摘要、effective token 计量、`context_tree_query`、portable checkpoint 与 Codex native compaction。
- 本仓库维护的 `pi-ask-user`:提供模型主动调用的 `ask_user_question`;TUI 中保留单题、多题、选择、文本、自定义回答与提交前复核,Paseo 等兼容 RPC 客户端通过标准 Pi extension UI 逐题交互。
- 本仓库自维护的 `pi-worktree`:纯斜杠命令(`/wt` / `/worktree`)git worktree 管理,`/wt new` 一键拆分、`/wt merge` 一键合并回来源分支(默认 ff-only,可选 `--squash`),通过 `SessionManager.forkFrom` + `switchSession` 切换会话 cwd;登记状态在 `<agentDir>/pi-worktree/state.json`,不注册模型工具、不 push。
- 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,默认以最多 5 个动态组、28 个动态工具的 LRU 策略加载原始完整 schema,并支持原子批量激活。
- 本仓库维护的 `pi-chrome`:从上游 `0.15.46` 源码导入,通过显式会话授权接入用户当前已登录的 Chrome Profile;大体积页面快照写入 Session 临时文件并交给 Context Mode 隔离分析,21 个 `chrome_*` 工具仍由 Tool Search 按工作流加载。
- 本仓库维护的 `pi-permission-system`:只读取一份全局普通策略与一份独立 Hard Guards,按 pattern 具体度裁决 allow/ask/deny,并整合 Codex Guardian 风格 AutoReview 与人工 `defer` 兜底;自动评审模型完全由配置文件指定,未配置时提示并转人工审批;工具显隐仍只由 Tool Search 管理。
+28
View File
@@ -25,6 +25,7 @@
"pi-mcp-adapter": "2.26.0",
"pi-ssh": "file:./pi-ssh",
"pi-tool-search": "file:./pi-tool-search",
"pi-worktree": "file:./pi-worktree",
"ssh2": "1.17.0",
"typescript": "6.0.3",
"typescript-language-server": "5.3.0",
@@ -7324,6 +7325,10 @@
"resolved": "pi-tool-search",
"link": true
},
"node_modules/pi-worktree": {
"resolved": "pi-worktree",
"link": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -8941,6 +8946,29 @@
"optional": true
}
}
},
"pi-worktree": {
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-tui": "*",
"typebox": "*"
},
"peerDependenciesMeta": {
"@earendil-works/pi-coding-agent": {
"optional": true
},
"@earendil-works/pi-tui": {
"optional": true
},
"typebox": {
"optional": true
}
}
}
}
}
+3
View File
@@ -26,6 +26,7 @@
"pi-tool-search/CHANGELOG.md",
"pi-tool-search/LICENSE",
"pi-tool-search/UPSTREAM.md",
"pi-worktree",
"pi-permission-system/LICENSES",
"pi-permission-system/docs/auto-review",
"pi-permission-system/src",
@@ -65,6 +66,7 @@
"./extensions/permission-system.ts",
"./pi-ssh/index.ts",
"./pi-ask-user/index.ts",
"./pi-worktree/index.ts",
"./pi-chrome/extensions/chrome-profile-bridge/index.ts",
"./extensions/system-prompt-cover.ts",
"./extensions/tool-routing.ts",
@@ -93,6 +95,7 @@
"pi-mcp-adapter": "2.26.0",
"pi-ssh": "file:./pi-ssh",
"pi-tool-search": "file:./pi-tool-search",
"pi-worktree": "file:./pi-worktree",
"ssh2": "1.17.0",
"typescript": "6.0.3",
"typescript-language-server": "5.3.0",
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 sia
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+80
View File
@@ -0,0 +1,80 @@
# pi-worktree
本仓库自维护的 Pi 扩展:只用斜杠命令管理 git worktree,提供「一键拆分」(`/wt new`)与「一键合并」(`/wt merge`)。不注册任何模型可调用的工具。
所有 git 操作都通过 `node:child_process` 的 `execFile` 以 argv 形式调用(不经过 shell),普通命令 30 秒超时,`worktree add` / `remove` 为 120 秒。因为这些命令由用户显式触发,它们不经过 Bash 工具的权限策略。
## 命令
`/wt` 与别名 `/worktree` 等价;不带参数时弹出子命令菜单。
| 命令 | 说明 |
| --- | --- |
| `/wt new <slug> [--from <ref>]` | 在 `<root>/<仓库名>/<slug>` 创建 worktree,签出新分支 `<branchPrefix><slug>`(默认 `wt/<slug>`),确认后把当前会话切换进 worktree |
| `/wt merge [--squash] [--keep] [--continue] [--abort]` | 在当前 worktree 内执行:确认 → rebase 到来源分支 → 快进合并(或 squash)回来源分支 → 切回来源目录并清理 worktree 与分支。`--keep` 保留 worktree、分支和登记;rebase 冲突保留时可用 `--continue`/`--abort` 续跑或放弃 |
| `/wt switch [<slug>\|main]` | 切换到某个登记的 worktree;`main` 切回该 worktree 的来源 checkout。无参数时弹列表 |
| `/wt list` | 列出登记的 worktree:分支、路径、`(缺失)`、`(有未提交改动)`、`+N` 领先数,以及非本扩展管理的其它 git worktree |
| `/wt remove <slug> [--force]` | 删除指定 worktree 与分支;有未提交改动或未合并提交时要求 `--force` 并二次确认。不能删除当前所在的 worktree |
| `/wt prune` | 执行 `git worktree prune` 并清除目录已不存在的登记项 |
| `/wt reopen [<slug>]` | 重新读取来源目录的 `.pi/worktree.json` 并补跑环境初始化(已存在的文件/软链自动跳过);默认 slug 为当前所在 worktree |
## 流程与安全规则
- **拆分**:确认对话框显示新分支、基准 ref 和短 SHA、目标路径;创建并登记后才切换会话。会话切换失败不会回滚已创建的 worktree,可用 `/wt switch <slug>` 重试。
- **合并**:必须先校验来源 checkout 仍在登记的分支上;worktree 未提交改动先经确认后 `add -A` + commit;随后展示 diffstat 并要求确认——rebase 改写分支的动作只发生在确认之后。确认后若来源目录有未提交改动先 `stash push`,再 rebase 到来源分支并合并,无论成败都在 finally 中 `stash pop`(pop 失败时提示 stash 名称)。rebase 冲突时弹出选择:「中止 rebase,保持原状」或「保留冲突,稍后手动解决」;保留后在 worktree 中解决冲突并 `git add`,再运行 `/wt merge --continue` 续走合并流程,或用 `/wt merge --abort` 放弃。默认只允许快进合并,`--squash` 压缩为单个提交;**永远不会 push**。
- **删除**:拒绝删除当前所在的 worktree;有未提交改动或领先提交时必须 `--force` 并明确列出将丢弃的内容。
- **状态栏**:进入登记的 worktree 后 footer 显示 `🌳 <slug>`,离开后清除。
## 会话切换机制
Pi 不支持修改进程 cwd。`/wt new` / `merge` / `switch` 通过 `SessionManager.forkFrom`(或为空会话写入最小 session 文件)生成 header.cwd 指向目标目录的新会话文件,再调用 `ctx.switchSession` 让 Pi 以新 cwd 重建运行时;切换后的收尾工作在 `withSession` 回调里用新 `ctx` 完成。
切换成功后,扩展会先往新会话追加一条 `[pi-worktree] 工作目录已从 … 切换到 …` 的模型可见提示(`customType: pi-worktree`、`display: true`,空闲时直接写入 session 文件,不触发新轮次),告知此前旧 cwd 下的路径可能已不存在。
## 状态登记与锁
登记文件为 `<agentDir>/pi-worktree/state.json`(目录 `700`、文件 `600`、原子写入)。所有写操作经 `updateState` 在 `<path>.lock` 锁文件内「读取-修改-保存」,防止多个 Pi 进程互相覆盖登记;锁文件陈旧 30 秒会被接管,正常操作下最长等待 5 秒。读取(list、补全、findByCwd)不加锁。
## 环境复刻:`.pi/worktree.json`
在**来源 checkout**(读取来源是 `entry.sourcePath`,不是新 worktree)提交 `.pi/worktree.json` 后,`/wt new` 创建 worktree 时会按顺序执行 复制 → 软链 → 环境文件 → postCreate 四个阶段,确认对话框会预先列出完整计划:
```jsonc
{
"version": 1, // 必填
"copy": [".env.local", ".env.*.local", "config/local.*"],
"link": ["node_modules", ".gradle"],
"env": { "PORT": "{{port}}", "DB_NAME": "app_{{slug}}" },
"envFile": ".env.worktree", // 默认 .env.worktree
"portBase": 3000, // 默认 3000
"postCreate": [
{ "command": "npm", "args": ["ci"], "timeoutMs": 600000 }
]
}
```
规则:
- **复制**:只复制未被 git 跟踪的文件(已跟踪文件应通过提交共享);`*` / `?` 通配符只允许出现在路径最后一段,不支持 `**`。目录暂不复制。
- **软链**:目标必须被 gitignore 忽略,否则拒绝软链以避免改动写回来源目录。
- **环境文件**:`env` 渲染为 `KEY=value` 逐行写入 `envFile`(权限 `600`);模板变量:`{{slug}}`、`{{pathSlug}}`、`{{branch}}`、`{{path}}`、`{{sourcePath}}`、`{{sourceBranch}}`、`{{port}}`,其中 `port = portBase + fnv1a32(slug) % 1000`。
- **info/exclude 兜底**:未被 `.gitignore` 覆盖的软链(如 `node_modules/` 尾斜杠规则不匹配符号链接)和环境文件会写入 `.git/info/exclude`(仓库内所有 worktree 共享同一 common dir),确保 `/wt merge` 的 `commitAll` 不会把它们提交进来源分支。
- **postCreate**:仅当项目受 Pi 信任(`ctx.isProjectTrusted()`)时执行,未受信任的项目只列出步骤并跳过;步骤以 argv 形式调用 `execFile`(不经 shell),并向步骤注入 `PI_WT_PATH` / `PI_WT_SOURCE_PATH` / `PI_WT_BRANCH` / `PI_WT_SOURCE_BRANCH` / `PI_WT_SLUG` / `PI_WT_PORT` 环境变量;任一步失败即停止。
- **永不覆盖**:所有产物(复制、软链、环境文件)在目标已存在时跳过,因此 `/wt reopen` 可以安全地增量补跑;初始化结果记录在登记项的 `setup` 字段,`/wt list` 会标注 `(环境初始化失败:<步骤>)`。
## 状态与配置
- 登记文件:`<agentDir>/pi-worktree/state.json`(`getAgentDir()`,遵循 `PI_CODING_AGENT_DIR`),目录权限 `700`、文件权限 `600`,原子写入;文件损坏或版本未知时命令报错中止,不会被静默覆盖。**不**放在 `<agentDir>/my-pi/` 下,因为该目录每次升级会被整体替换。
- `settings.json` 的 `piWorktree` 键:
- `root`:worktree 根目录,默认 `~/.pi/worktrees`(只展开开头的 `~`,展开后必须是绝对路径)。worktree 路径为 `<root>/<主 worktree 目录名>/<slug>`。
- `branchPrefix`:新分支前缀,默认 `wt/`。
- slug 规则:`A-Za-z0-9._-` 与 `/` 分层,最长 64 字符,不能以 `-` 开头、不能含 `..`、不能以 `.lock` 结尾,保留名 `main` / `HEAD` 不可用。
## 测试
```bash
npm test # node --test test/*.test.ts
npm run check
```
测试在临时目录里创建真实 git 仓库;状态与会话测试使用隔离的 `PI_CODING_AGENT_DIR`。
+36
View File
@@ -0,0 +1,36 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { registerWorktreeCommands } from "./src/commands.ts";
export default function worktreeExtension(pi: ExtensionAPI): void {
registerWorktreeCommands(pi);
}
export { validateSlug, toPathSlug, toBranchName } from "./src/slug.ts";
export { argumentCompletions } from "./src/commands.ts";
export { prepareSessionForCwd, switchToCwd } from "./src/session.ts";
export {
loadState,
saveState,
addEntry,
removeEntry,
findBySlug,
findByCwd,
stateFilePath,
} from "./src/state.ts";
export type { WorktreeEntry, WorktreeState } from "./src/state.ts";
export { createGit, defaultGitRunner, GitError } from "./src/git.ts";
export type { Git, GitRunner, GitResult, WorktreeInfo, RebaseResult } from "./src/git.ts";
export { readWorktreeSettings } from "./src/settings.ts";
export type { WorktreeSettings } from "./src/settings.ts";
export {
loadSetupConfig,
parseSetupConfig,
expandPattern,
planSetup,
applySetup,
formatPlan,
fnv1a32,
worktreePort,
} from "./src/setup.ts";
export type { SetupConfig, SetupStep, SetupPlan, ApplyResult } from "./src/setup.ts";
+46
View File
@@ -0,0 +1,46 @@
{
"name": "pi-worktree",
"version": "0.1.0",
"description": "Slash-command git worktree manager for Pi: one-click split and merge.",
"private": true,
"type": "module",
"license": "MIT",
"main": "./index.ts",
"exports": {
".": "./index.ts"
},
"files": [
"index.ts",
"src",
"README.md",
"LICENSE"
],
"pi": {
"extensions": [
"./index.ts"
]
},
"scripts": {
"test": "node --test test/*.test.ts",
"check": "node --experimental-strip-types --check index.ts && node --test test/*.test.ts"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-tui": "*",
"typebox": "*"
},
"peerDependenciesMeta": {
"@earendil-works/pi-coding-agent": {
"optional": true
},
"@earendil-works/pi-tui": {
"optional": true
},
"typebox": {
"optional": true
}
},
"engines": {
"node": ">=22.19.0"
}
}
File diff suppressed because it is too large Load Diff
+279
View File
@@ -0,0 +1,279 @@
import { execFile } from "node:child_process";
import { existsSync } from "node:fs";
import { appendFile, mkdir, readFile } from "node:fs/promises";
import { dirname, isAbsolute, resolve } from "node:path";
export interface GitResult {
stdout: string;
stderr: string;
code: number;
}
export type GitRunner = (args: string[], cwd: string, timeoutMs?: number) => Promise<GitResult>;
const DEFAULT_TIMEOUT_MS = 30_000;
const WORKTREE_TIMEOUT_MS = 120_000;
const MAX_BUFFER = 10 * 1024 * 1024;
export class GitError extends Error {
readonly args: string[];
readonly cwd: string;
readonly result: GitResult;
constructor(args: string[], cwd: string, result: GitResult) {
const detail = (result.stderr.trim() || result.stdout.trim()).replace(/\s+/gu, " ");
super(`git ${args.join(" ")} 失败(退出码 ${result.code})${detail ? `:${detail.slice(0, 400)}` : ""}`);
this.name = "GitError";
this.args = args;
this.cwd = cwd;
this.result = result;
}
}
export const defaultGitRunner: GitRunner = (args, cwd, timeoutMs = DEFAULT_TIMEOUT_MS) =>
new Promise<GitResult>((resolve, reject) => {
execFile(
"git",
args,
{ cwd, timeout: timeoutMs, maxBuffer: MAX_BUFFER, encoding: "utf8" },
(error, stdout, stderr) => {
if (error && typeof error.code !== "number") {
reject(new Error(`git ${args.join(" ")} 执行失败:${error.message}`));
return;
}
resolve({ stdout: stdout ?? "", stderr: stderr ?? "", code: typeof error?.code === "number" ? error.code : 0 });
},
);
});
export interface WorktreeInfo {
path: string;
head: string;
branch?: string;
detached: boolean;
locked: boolean;
prunable: boolean;
}
export interface RebaseResult {
ok: boolean;
conflict: boolean;
}
export interface Git {
getToplevel(cwd: string): Promise<string>;
getCurrentBranch(cwd: string): Promise<string | null>;
getHeadSha(cwd: string): Promise<string>;
revParse(cwd: string, ref: string): Promise<string>;
branchExists(cwd: string, name: string): Promise<boolean>;
listWorktrees(cwd: string): Promise<WorktreeInfo[]>;
getMainWorktreePath(cwd: string): Promise<string>;
isDirty(cwd: string): Promise<boolean>;
statusEntries(cwd: string): Promise<string[]>;
isTracked(cwd: string, rel: string): Promise<boolean>;
isIgnored(cwd: string, rel: string): Promise<boolean>;
excludePath(cwd: string, rel: string): Promise<void>;
commitAll(cwd: string, message: string): Promise<void>;
aheadCount(cwd: string, base: string, head: string): Promise<number>;
diffStat(cwd: string, base: string, head: string): Promise<string>;
addWorktree(cwd: string, path: string, branch: string, startPoint: string): Promise<void>;
removeWorktree(cwd: string, path: string, force: boolean): Promise<void>;
deleteBranch(cwd: string, name: string, force: boolean): Promise<void>;
pruneWorktrees(cwd: string): Promise<void>;
stashPush(cwd: string, message: string): Promise<boolean>;
stashPop(cwd: string): Promise<void>;
rebase(cwd: string, onto: string): Promise<RebaseResult>;
rebaseInProgress(cwd: string): Promise<boolean>;
rebaseContinue(cwd: string): Promise<RebaseResult>;
rebaseAbort(cwd: string): Promise<void>;
unmergedFiles(cwd: string): Promise<string[]>;
mergeFfOnly(cwd: string, branch: string): Promise<void>;
mergeSquash(cwd: string, branch: string, message: string): Promise<void>;
isAncestor(cwd: string, a: string, b: string): Promise<boolean>;
}
function parseWorktreeList(stdout: string): WorktreeInfo[] {
const entries: WorktreeInfo[] = [];
let current: WorktreeInfo | undefined;
for (const line of stdout.split("\n")) {
if (line.startsWith("worktree ")) {
current = { path: line.slice("worktree ".length), head: "", detached: false, locked: false, prunable: false };
entries.push(current);
} else if (!current) {
continue;
} else if (line.startsWith("HEAD ")) {
current.head = line.slice("HEAD ".length);
} else if (line.startsWith("branch ")) {
const ref = line.slice("branch ".length);
current.branch = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref;
} else if (line === "detached") {
current.detached = true;
} else if (line === "locked" || line.startsWith("locked ")) {
current.locked = true;
} else if (line === "prunable" || line.startsWith("prunable ")) {
current.prunable = true;
}
}
return entries;
}
export function createGit(runner: GitRunner = defaultGitRunner): Git {
const run = async (args: string[], cwd: string, timeoutMs?: number): Promise<GitResult> => {
const result = await runner(args, cwd, timeoutMs);
if (result.code !== 0) throw new GitError(args, cwd, result);
return result;
};
const api: Git = {
async getToplevel(cwd) {
return (await run(["rev-parse", "--show-toplevel"], cwd)).stdout.trim();
},
async getCurrentBranch(cwd) {
const branch = (await run(["rev-parse", "--abbrev-ref", "HEAD"], cwd)).stdout.trim();
return branch === "HEAD" ? null : branch;
},
async getHeadSha(cwd) {
return (await run(["rev-parse", "HEAD"], cwd)).stdout.trim();
},
async revParse(cwd, ref) {
return (await run(["rev-parse", "--verify", `${ref}^{commit}`], cwd)).stdout.trim();
},
async branchExists(cwd, name) {
const result = await runner(["show-ref", "--verify", "--quiet", `refs/heads/${name}`], cwd);
if (result.code === 0) return true;
if (result.code === 1) return false;
throw new GitError(["show-ref", "--verify", "--quiet", `refs/heads/${name}`], cwd, result);
},
async listWorktrees(cwd) {
return parseWorktreeList((await run(["worktree", "list", "--porcelain"], cwd)).stdout);
},
async getMainWorktreePath(cwd) {
const entries = parseWorktreeList((await run(["worktree", "list", "--porcelain"], cwd)).stdout);
const first = entries[0];
if (!first) throw new Error(`无法确定主 worktree:git worktree list 在 ${cwd} 中没有输出`);
return first.path;
},
async isDirty(cwd) {
return (await run(["status", "--porcelain", "--untracked-files=all"], cwd)).stdout.trim().length > 0;
},
async statusEntries(cwd) {
const stdout = (await run(["status", "--porcelain", "--untracked-files=all"], cwd)).stdout;
return stdout.split("\n").filter((line) => line.trim().length > 0);
},
async isTracked(cwd, rel) {
const result = await runner(["ls-files", "--error-unmatch", "--", rel], cwd);
if (result.code === 0) return true;
if (result.code === 1) return false;
throw new GitError(["ls-files", "--error-unmatch", "--", rel], cwd, result);
},
async isIgnored(cwd, rel) {
const result = await runner(["check-ignore", "-q", "--", rel], cwd);
if (result.code === 0) return true;
if (result.code === 1) return false;
throw new GitError(["check-ignore", "-q", "--", rel], cwd, result);
},
async excludePath(cwd, rel) {
// git rev-parse --git-path resolves to the shared common dir for
// linked worktrees (<main>/.git/info/exclude), which is intended:
// one exclusion covers every worktree of the repo.
const gitPath = (await run(["rev-parse", "--git-path", "info/exclude"], cwd)).stdout.trim();
const path = isAbsolute(gitPath) ? gitPath : resolve(cwd, gitPath);
await mkdir(dirname(path), { recursive: true });
let existing = "";
try {
existing = await readFile(path, "utf8");
} catch (error) {
if (!(typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT")) {
throw error;
}
}
const line = `/${rel}`;
if (!existing.split("\n").includes(line)) {
const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
await appendFile(path, `${prefix}${line}\n`);
}
},
async commitAll(cwd, message) {
await run(["add", "-A"], cwd);
await run(["commit", "-m", message], cwd);
},
async aheadCount(cwd, base, head) {
const output = (await run(["rev-list", "--count", `${base}..${head}`], cwd)).stdout.trim();
const count = Number.parseInt(output, 10);
return Number.isNaN(count) ? 0 : count;
},
async diffStat(cwd, base, head) {
return (await run(["diff", "--stat", `${base}...${head}`], cwd)).stdout;
},
async addWorktree(cwd, path, branch, startPoint) {
await run(["worktree", "add", "-b", branch, path, startPoint], cwd, WORKTREE_TIMEOUT_MS);
},
async removeWorktree(cwd, path, force) {
const args = ["worktree", "remove"];
if (force) args.push("--force");
args.push(path);
await run(args, cwd, WORKTREE_TIMEOUT_MS);
},
async deleteBranch(cwd, name, force) {
await run(["branch", force ? "-D" : "-d", name], cwd);
},
async pruneWorktrees(cwd) {
await run(["worktree", "prune"], cwd);
},
async stashPush(cwd, message) {
// --include-untracked so a dirty source checkout stashes untracked
// files too; detect success via stash list delta instead of the
// locale-dependent "No local changes" message.
const before = (await run(["stash", "list"], cwd)).stdout.split("\n").filter(Boolean).length;
await run(["stash", "push", "--include-untracked", "-m", message], cwd);
const after = (await run(["stash", "list"], cwd)).stdout.split("\n").filter(Boolean).length;
return after > before;
},
async stashPop(cwd) {
await run(["stash", "pop"], cwd);
},
async rebase(cwd, onto) {
const result = await runner(["rebase", onto], cwd);
if (result.code === 0) return { ok: true, conflict: false };
// Never auto-abort: a real conflict stays for the user to resolve.
// A failure with no rebase in progress (dirty tree, bad ref) throws.
if (await api.rebaseInProgress(cwd)) return { ok: false, conflict: true };
throw new GitError(["rebase", onto], cwd, result);
},
async rebaseInProgress(cwd) {
for (const name of ["rebase-merge", "rebase-apply"]) {
const gitPath = (await run(["rev-parse", "--git-path", name], cwd)).stdout.trim();
const path = isAbsolute(gitPath) ? gitPath : resolve(cwd, gitPath);
if (existsSync(path)) return true;
}
return false;
},
async rebaseContinue(cwd) {
const result = await runner(["-c", "core.editor=true", "rebase", "--continue"], cwd);
if (result.code === 0) return { ok: true, conflict: false };
if (await api.rebaseInProgress(cwd)) return { ok: false, conflict: true };
throw new GitError(["rebase", "--continue"], cwd, result);
},
async rebaseAbort(cwd) {
await run(["rebase", "--abort"], cwd);
},
async unmergedFiles(cwd) {
const stdout = (await run(["diff", "--name-only", "--diff-filter=U"], cwd)).stdout;
return stdout.split("\n").filter((line) => line.trim().length > 0);
},
async mergeFfOnly(cwd, branch) {
await run(["merge", "--ff-only", branch], cwd);
},
async mergeSquash(cwd, branch, message) {
await run(["merge", "--squash", branch], cwd);
await run(["commit", "-m", message], cwd);
},
async isAncestor(cwd, a, b) {
const result = await runner(["merge-base", "--is-ancestor", a, b], cwd);
if (result.code === 0) return true;
if (result.code === 1) return false;
throw new GitError(["merge-base", "--is-ancestor", a, b], cwd, result);
},
};
return api;
}
+74
View File
@@ -0,0 +1,74 @@
import { CURRENT_SESSION_VERSION, SessionManager } from "@earendil-works/pi-coding-agent";
import type { ExtensionCommandContext, ReplacedSessionContext } from "@earendil-works/pi-coding-agent";
import { randomUUID } from "node:crypto";
import { existsSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
export interface SessionFileSource {
getSessionFile(): string | undefined;
}
/**
* Pi cannot chdir: switchSession() rebuilds the runtime with the session
* header's cwd. To move the current conversation into another directory we
* therefore create a fresh session file whose header points at targetCwd.
*
* When the current session already has a persisted file we fork it (full
* history, new id, header rewritten to targetCwd). For empty or --no-session
* sessions no file exists yet, so we write a minimal header-only file
* ourselves in the target's default session directory.
*/
export function prepareSessionForCwd(
ctx: { sessionManager: SessionFileSource },
targetCwd: string,
): string {
const currentFile = ctx.sessionManager.getSessionFile();
if (currentFile && existsSync(currentFile) && statSync(currentFile).size > 0) {
const forked = SessionManager.forkFrom(currentFile, targetCwd);
const forkedFile = forked.getSessionFile();
if (!forkedFile) throw new Error("fork 会话失败:未能生成新的会话文件");
return forkedFile;
}
const sessionDir = SessionManager.create(targetCwd).getSessionDir();
const id = randomUUID();
const fileTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
const filePath = join(sessionDir, `${fileTimestamp}_${id}.jsonl`);
const header = {
type: "session",
version: CURRENT_SESSION_VERSION,
id,
timestamp: new Date().toISOString(),
cwd: targetCwd,
};
writeFileSync(filePath, `${JSON.stringify(header)}\n`, { flag: "wx" });
return filePath;
}
/**
* Move the current session into targetCwd. The old ctx is invalid after the
* switch; all post-switch work must run inside withSession on the fresh
* context.
*/
export async function switchToCwd(
ctx: ExtensionCommandContext,
targetCwd: string,
withSession: (ctx: ReplacedSessionContext) => Promise<void>,
): Promise<{ cancelled: boolean }> {
await ctx.waitForIdle();
const sessionFile = prepareSessionForCwd(ctx, targetCwd);
const fromCwd = ctx.cwd;
return ctx.switchSession(sessionFile, {
withSession: async (c) => {
// Tell the model the cwd changed: appended to the session file when
// idle (no deliverAs). Files under the old cwd may no longer exist.
if (fromCwd !== targetCwd) {
await c.sendMessage({
customType: "pi-worktree",
content: `[pi-worktree] 工作目录已从 ${fromCwd} 切换到 ${targetCwd}。此前对话中 ${fromCwd} 下的路径不再是当前工作目录(可能已被删除),后续文件与命令操作请使用 ${targetCwd} 下的对应路径。`,
display: true,
});
}
await withSession(c);
},
});
}
+29
View File
@@ -0,0 +1,29 @@
import { homedir } from "node:os";
import { isAbsolute, join } from "node:path";
export interface WorktreeSettings {
/** Absolute directory under which worktrees are created: <root>/<repoName>/<pathSlug>. */
root: string;
branchPrefix: string;
}
const DEFAULT_ROOT = "~/.pi/worktrees";
const DEFAULT_BRANCH_PREFIX = "wt/";
function expandRoot(value: string): string {
if (value === "~") return homedir();
if (value.startsWith("~/")) return join(homedir(), value.slice(2));
return value;
}
export function readWorktreeSettings(getSettings: () => unknown): WorktreeSettings {
const raw = (getSettings() as { piWorktree?: { root?: unknown; branchPrefix?: unknown } } | undefined)?.piWorktree;
const rootRaw = typeof raw?.root === "string" && raw.root.trim() ? raw.root.trim() : DEFAULT_ROOT;
const root = expandRoot(rootRaw);
if (!isAbsolute(root)) {
throw new Error(`piWorktree.root 必须是绝对路径或以 ~ 开头:${rootRaw}`);
}
const branchPrefix =
typeof raw?.branchPrefix === "string" && raw.branchPrefix ? raw.branchPrefix : DEFAULT_BRANCH_PREFIX;
return { root, branchPrefix };
}
+457
View File
@@ -0,0 +1,457 @@
import { execFile } from "node:child_process";
import { constants } from "node:fs";
import { chmod, copyFile, mkdir, readFile, readdir, stat, symlink, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { dirname, isAbsolute, join } from "node:path";
import type { Git } from "./git.ts";
import type { WorktreeEntry } from "./state.ts";
import { toPathSlug } from "./slug.ts";
export interface SetupStep {
command: string;
args: string[];
timeoutMs: number;
}
export interface SetupConfig {
version: 1;
copy: string[];
link: string[];
env: Record<string, string>;
envFile: string;
portBase: number;
postCreate: SetupStep[];
}
export interface CopyItem {
rel: string;
from: string;
to: string;
}
export interface LinkItem {
rel: string;
target: string;
linkPath: string;
}
export interface SetupPlan {
worktreePath: string;
copies: CopyItem[];
links: LinkItem[];
envFile?: { rel: string; content: string };
postCreate: SetupStep[];
postCreateAllowed: boolean;
notes: string[];
}
export interface ApplyResult {
status: "ok" | "failed";
step?: string;
error?: string;
}
const ALLOWED_KEYS = new Set(["version", "copy", "link", "env", "envFile", "portBase", "postCreate"]);
const DEFAULT_ENV_FILE = ".env.worktree";
const DEFAULT_PORT_BASE = 3000;
const DEFAULT_STEP_TIMEOUT_MS = 600_000;
const EXEC_MAX_BUFFER = 10 * 1024 * 1024;
function configError(path: string, detail: string): Error {
return new Error(`无效的 .pi/worktree.json(${path}):${detail}`);
}
function validatePattern(path: string, value: unknown, field: string): string {
if (typeof value !== "string" || !value.trim()) {
throw configError(path, `${field} 条目必须是非空字符串`);
}
const pattern = value.trim();
if (isAbsolute(pattern)) throw configError(path, `${field} 条目必须是相对路径:${pattern}`);
const segments = pattern.split("/");
if (segments.some((s) => s === "" || s === "." || s === "..")) {
throw configError(path, `${field} 条目不能含空段或 '..':${pattern}`);
}
if (pattern.includes("**")) throw configError(path, `${field} 不支持 '**' 通配符:${pattern}`);
// Wildcards are only allowed in the last segment; the directory part must be literal.
for (const segment of segments.slice(0, -1)) {
if (segment.includes("*") || segment.includes("?")) {
throw configError(path, `${field} 的通配符只能出现在最后一段:${pattern}`);
}
}
return pattern;
}
export function parseSetupConfig(raw: string, path: string): SetupConfig {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw configError(path, "不是合法 JSON");
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw configError(path, "顶层必须是对象");
}
const obj = parsed as Record<string, unknown>;
for (const key of Object.keys(obj)) {
if (!ALLOWED_KEYS.has(key)) throw configError(path, `未知键:${key}`);
}
if (obj.version !== 1) throw configError(path, "version 必须为 1");
const copy = obj.copy === undefined ? [] : obj.copy;
const link = obj.link === undefined ? [] : obj.link;
for (const [field, list] of [["copy", copy], ["link", link]] as const) {
if (!Array.isArray(list)) throw configError(path, `${field} 必须是数组`);
}
const env = obj.env === undefined ? {} : obj.env;
if (typeof env !== "object" || env === null || Array.isArray(env)) {
throw configError(path, "env 必须是对象");
}
for (const [key, value] of Object.entries(env as Record<string, unknown>)) {
if (typeof value !== "string") throw configError(path, `env.${key} 必须是字符串`);
}
const envFile = obj.envFile === undefined ? DEFAULT_ENV_FILE : obj.envFile;
if (typeof envFile !== "string" || !envFile.trim()) {
throw configError(path, "envFile 必须是非空字符串");
}
const envFileRel = envFile.trim();
if (isAbsolute(envFileRel) || envFileRel.split("/").some((s) => s === "" || s === ".." || s === ".")) {
throw configError(path, `envFile 必须是仓库内相对路径:${envFileRel}`);
}
if (envFileRel.includes("*") || envFileRel.includes("?")) {
throw configError(path, `envFile 不能含通配符:${envFileRel}`);
}
const portBase = obj.portBase === undefined ? DEFAULT_PORT_BASE : obj.portBase;
if (typeof portBase !== "number" || !Number.isFinite(portBase) || portBase <= 0) {
throw configError(path, "portBase 必须是正数");
}
const rawSteps = obj.postCreate === undefined ? [] : obj.postCreate;
if (!Array.isArray(rawSteps)) throw configError(path, "postCreate 必须是数组");
const postCreate: SetupStep[] = rawSteps.map((rawStep, index) => {
if (typeof rawStep !== "object" || rawStep === null || Array.isArray(rawStep)) {
throw configError(path, `postCreate[${index}] 必须是对象`);
}
const step = rawStep as Record<string, unknown>;
if (typeof step.command !== "string" || !step.command.trim()) {
throw configError(path, `postCreate[${index}].command 必须是非空字符串`);
}
const args = step.args === undefined ? [] : step.args;
if (!Array.isArray(args) || args.some((a) => typeof a !== "string")) {
throw configError(path, `postCreate[${index}].args 必须是字符串数组`);
}
const timeoutMs = step.timeoutMs === undefined ? DEFAULT_STEP_TIMEOUT_MS : step.timeoutMs;
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw configError(path, `postCreate[${index}].timeoutMs 必须是正数`);
}
return { command: step.command.trim(), args: [...args] as string[], timeoutMs };
});
return {
version: 1,
copy: copy.map((p) => validatePattern(path, p, "copy")),
link: link.map((p) => validatePattern(path, p, "link")),
env: env as Record<string, string>,
envFile: envFileRel,
portBase,
postCreate,
};
}
/** Reads <sourcePath>/.pi/worktree.json; undefined when the file is absent. */
export async function loadSetupConfig(sourcePath: string): Promise<SetupConfig | undefined> {
const path = join(sourcePath, ".pi", "worktree.json");
let raw: string;
try {
raw = await readFile(path, "utf8");
} catch (error) {
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
return undefined;
}
throw error;
}
return parseSetupConfig(raw, path);
}
function escapeRegex(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function wildcardToRegex(segment: string): RegExp {
const body = segment
.split("")
.map((ch) => (ch === "*" ? "[^/]*" : ch === "?" ? "[^/]" : escapeRegex(ch)))
.join("");
return new RegExp(`^${body}$`);
}
/** Expands a config pattern into existing paths relative to baseDir. */
export async function expandPattern(baseDir: string, pattern: string): Promise<string[]> {
const segments = pattern.split("/");
const last = segments[segments.length - 1];
const dirPart = segments.slice(0, -1).join("/");
if (!last.includes("*") && !last.includes("?")) {
return existsSync(join(baseDir, pattern)) ? [pattern] : [];
}
const dirAbs = join(baseDir, dirPart);
let names: string[];
try {
names = await readdir(dirAbs);
} catch {
return [];
}
const regex = wildcardToRegex(last);
return names
.filter((name) => regex.test(name))
.sort()
.map((name) => (dirPart ? `${dirPart}/${name}` : name));
}
export function fnv1a32(text: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < text.length; i += 1) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
export function worktreePort(portBase: number, slug: string): number {
return portBase + (fnv1a32(slug) % 1000);
}
const TEMPLATE_PATTERN = /\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}/g;
function templateVars(entry: WorktreeEntry, portBase: number): Record<string, string> {
return {
slug: entry.slug,
pathSlug: toPathSlug(entry.slug),
branch: entry.branch,
path: entry.path,
sourcePath: entry.sourcePath,
sourceBranch: entry.sourceBranch,
port: String(worktreePort(portBase, entry.slug)),
};
}
function renderTemplate(template: string, vars: Record<string, string>): string {
return template.replace(TEMPLATE_PATTERN, (_match, name: string) => {
const value = vars[name];
if (value === undefined) throw new Error(`未知模板变量:{{${name}}}`);
return value;
});
}
/**
* Computes the replication plan without mutating anything. postCreate steps
* are only included when the caller reports the project as trusted.
*/
export async function planSetup(
git: Git,
config: SetupConfig,
entry: WorktreeEntry,
postCreateAllowed: boolean,
): Promise<SetupPlan> {
const plan: SetupPlan = {
worktreePath: entry.path,
copies: [],
links: [],
postCreate: [],
postCreateAllowed,
notes: [],
};
for (const pattern of config.copy) {
for (const rel of await expandPattern(entry.sourcePath, pattern)) {
if (await git.isTracked(entry.sourcePath, rel)) {
plan.notes.push(`复制 ${rel}:已被 git 跟踪,跳过(应通过提交共享)`);
continue;
}
const from = join(entry.sourcePath, rel);
const to = join(entry.path, rel);
if (existsSync(to)) {
plan.notes.push(`复制 ${rel}:目标已存在,跳过`);
continue;
}
if ((await stat(from)).isDirectory()) {
plan.notes.push(`复制 ${rel}:暂不支持复制目录,跳过`);
continue;
}
plan.copies.push({ rel, from, to });
}
}
for (const pattern of config.link) {
for (const rel of await expandPattern(entry.sourcePath, pattern)) {
const target = join(entry.sourcePath, rel);
const linkPath = join(entry.path, rel);
if (!(await git.isIgnored(entry.sourcePath, rel))) {
plan.notes.push(`软链 ${rel}:未被 gitignore 忽略,拒绝软链以免改动写回来源目录`);
continue;
}
if (existsSync(linkPath)) {
plan.notes.push(`软链 ${rel}:目标已存在,跳过`);
continue;
}
plan.links.push({ rel, target, linkPath });
}
}
const envKeys = Object.keys(config.env);
if (envKeys.length > 0) {
const vars = templateVars(entry, config.portBase);
const lines = envKeys.map((key) => `${key}=${renderTemplate(config.env[key], vars)}`);
const content = `${lines.join("\n")}\n`;
const dest = join(entry.path, config.envFile);
if (existsSync(dest)) {
plan.notes.push(`环境文件 ${config.envFile}:worktree 中已存在,跳过`);
} else if (await git.isTracked(entry.sourcePath, config.envFile)) {
plan.notes.push(`环境文件 ${config.envFile}:已在来源被 git 跟踪,跳过`);
} else {
plan.envFile = { rel: config.envFile, content };
}
}
if (config.postCreate.length > 0) {
if (postCreateAllowed) {
plan.postCreate = config.postCreate;
} else {
plan.notes.push("项目未受信任,已跳过 postCreate");
}
}
return plan;
}
export function formatPlan(plan: SetupPlan): string {
const sections: string[] = [];
const cap = (lines: string[]): string[] =>
lines.length <= 10 ? lines : [...lines.slice(0, 10), `…(共 ${lines.length} 项)`];
if (plan.copies.length > 0) {
sections.push(`复制:\n${cap(plan.copies.map((c) => ` ${c.rel}`)).join("\n")}`);
}
const excludeNote = "(未被 gitignore 忽略的软链/环境文件会写入 .git/info/exclude,不会被 /wt merge 提交)";
if (plan.links.length > 0) {
sections.push(
`软链:\n${cap(plan.links.map((l) => ` ${l.rel} → ${l.target}`)).join("\n")}\n ${excludeNote}`,
);
}
if (plan.envFile) {
const keys = plan.envFile.content
.split("\n")
.filter(Boolean)
.map((line) => line.split("=")[0]);
const suffix = plan.links.length === 0 ? `\n ${excludeNote}` : "";
sections.push(`环境文件:\n ${plan.envFile.rel}(${keys.join("、")})${suffix}`);
}
if (plan.postCreate.length > 0) {
sections.push(
`postCreate:\n${cap(plan.postCreate.map((s) => ` ${s.command} ${s.args.join(" ")}`.trim())).join("\n")}`,
);
}
if (plan.notes.length > 0) {
sections.push(`备注:\n${cap(plan.notes.map((n) => ` ${n}`)).join("\n")}`);
}
return sections.join("\n");
}
function runStep(step: SetupStep, plan: SetupPlan, entry: WorktreeEntry, portBase: number): Promise<void> {
return new Promise((resolve, reject) => {
execFile(
step.command,
step.args,
{
cwd: plan.worktreePath,
timeout: step.timeoutMs,
maxBuffer: EXEC_MAX_BUFFER,
env: {
...process.env,
PI_WT_PATH: entry.path,
PI_WT_SOURCE_PATH: entry.sourcePath,
PI_WT_BRANCH: entry.branch,
PI_WT_SOURCE_BRANCH: entry.sourceBranch,
PI_WT_SLUG: entry.slug,
PI_WT_PORT: String(worktreePort(portBase, entry.slug)),
},
},
(error, stdout, stderr) => {
if (!error) {
resolve();
return;
}
const detail = (stderr || stdout || error.message).trim().replace(/\s+/gu, " ").slice(0, 400);
reject(new Error(detail || `退出码 ${error.code ?? "?"}`));
},
);
});
}
/** Applies a plan in order: copy → link → env → postCreate. Stops at first failure. */
export async function applySetup(
git: Git,
plan: SetupPlan,
entry: WorktreeEntry,
portBase: number,
hooks: { onStep(text: string): void },
): Promise<ApplyResult> {
const fail = (step: string, error: unknown): ApplyResult => ({
status: "failed",
step,
error: error instanceof Error ? error.message : String(error),
});
for (const item of plan.copies) {
try {
await mkdir(dirname(item.to), { recursive: true });
await copyFile(item.from, item.to, constants.COPYFILE_EXCL);
const mode = (await stat(item.from)).mode & 0o777;
await chmod(item.to, mode);
} catch (error) {
return fail(`copy ${item.rel}`, error);
}
}
for (const item of plan.links) {
try {
await mkdir(dirname(item.linkPath), { recursive: true });
await symlink(item.target, item.linkPath);
// A trailing-slash gitignore rule does not match a symlink (git sees
// a file, not a dir), so fall back to info/exclude to keep /wt merge
// from committing it.
if (!(await git.isIgnored(entry.path, item.rel))) {
await git.excludePath(entry.path, item.rel);
hooks.onStep(`${item.rel} 未被 gitignore 忽略,已写入 .git/info/exclude`);
}
} catch (error) {
return fail(`link ${item.rel}`, error);
}
}
if (plan.envFile) {
try {
const dest = join(plan.worktreePath, plan.envFile.rel);
await mkdir(dirname(dest), { recursive: true });
await writeFile(dest, plan.envFile.content, { flag: "wx", mode: 0o600 });
if (!(await git.isIgnored(entry.path, plan.envFile.rel))) {
await git.excludePath(entry.path, plan.envFile.rel);
hooks.onStep(`${plan.envFile.rel} 未被 gitignore 忽略,已写入 .git/info/exclude`);
}
} catch (error) {
return fail(`env ${plan.envFile.rel}`, error);
}
}
for (const step of plan.postCreate) {
const label = `${step.command} ${step.args.join(" ")}`.trim();
hooks.onStep(`正在运行 ${label}`);
try {
await runStep(step, plan, entry, portBase);
} catch (error) {
return fail(label, error);
}
}
return { status: "ok" };
}
+26
View File
@@ -0,0 +1,26 @@
const SLUG_PATTERN = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
const MAX_SLUG_LENGTH = 64;
const RESERVED_SLUGS = new Set(["main", "HEAD"]);
/** Returns a Chinese error message when the slug is invalid, otherwise null. */
export function validateSlug(slug: string): string | null {
if (!slug) return "slug 不能为空";
if (slug.length > MAX_SLUG_LENGTH) return `slug 过长(最多 ${MAX_SLUG_LENGTH} 个字符)`;
if (!SLUG_PATTERN.test(slug)) return "slug 只能包含字母、数字、'.'、'_'、'-',可用 '/' 分层";
if (slug.startsWith("-")) return "slug 不能以 '-' 开头";
if (slug.includes("..")) return "slug 不能包含连续的 '..'";
if (slug.endsWith(".lock")) return "slug 不能以 '.lock' 结尾";
if (slug.split("/").some((segment) => segment === "." || segment === "..")) {
return "slug 的路径段不能是 '.' 或 '..'";
}
if (RESERVED_SLUGS.has(slug)) return `'${slug}' 是保留名称`;
return null;
}
export function toPathSlug(slug: string): string {
return slug.replace(/\//g, "-");
}
export function toBranchName(branchPrefix: string, slug: string): string {
return `${branchPrefix}${slug}`;
}
+170
View File
@@ -0,0 +1,170 @@
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import { randomUUID } from "node:crypto";
import { chmod, mkdir, open, readFile, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
import { dirname, join, sep } from "node:path";
export interface WorktreeEntry {
slug: string;
/** Absolute realpath of the worktree directory. */
path: string;
/** Branch checked out inside the worktree. */
branch: string;
/** Absolute realpath of the checkout the worktree was created from. */
sourcePath: string;
/** Branch checked out at sourcePath when the worktree was created. */
sourceBranch: string;
/** Commit the worktree branch started from. */
sourceSha: string;
createdAt: string;
/** Result of the last .pi/worktree.json environment replication run. */
setup?: {
status: "ok" | "failed" | "skipped";
step?: string;
error?: string;
at: string;
};
}
export interface WorktreeState {
version: 1;
worktrees: WorktreeEntry[];
}
export function stateFilePath(): string {
return join(getAgentDir(), "pi-worktree", "state.json");
}
function emptyState(): WorktreeState {
return { version: 1, worktrees: [] };
}
function isWorktreeState(value: unknown): value is WorktreeState {
if (typeof value !== "object" || value === null) return false;
const candidate = value as { version?: unknown; worktrees?: unknown };
return candidate.version === 1 && Array.isArray(candidate.worktrees);
}
export async function loadState(path: string = stateFilePath()): Promise<WorktreeState> {
let raw: string;
try {
raw = await readFile(path, "utf8");
} catch (error) {
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
return emptyState();
}
throw error;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error(`pi-worktree 状态文件损坏(不是合法 JSON):${path}`);
}
if (!isWorktreeState(parsed)) {
throw new Error(`pi-worktree 状态文件版本不受支持或结构无效:${path}`);
}
return parsed;
}
export async function saveState(state: WorktreeState, path: string = stateFilePath()): Promise<void> {
const dir = dirname(path);
await mkdir(dir, { recursive: true, mode: 0o700 });
await chmod(dir, 0o700);
const tmp = `${path}.${randomUUID()}.tmp`;
await writeFile(tmp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
await chmod(tmp, 0o600);
await rename(tmp, path);
}
export function addEntry(state: WorktreeState, entry: WorktreeEntry): void {
state.worktrees.push(entry);
}
export function removeEntry(state: WorktreeState, slug: string): boolean {
const before = state.worktrees.length;
state.worktrees = state.worktrees.filter((entry) => entry.slug !== slug);
return state.worktrees.length !== before;
}
export function findBySlug(state: WorktreeState, slug: string): WorktreeEntry | undefined {
return state.worktrees.find((entry) => entry.slug === slug);
}
function isErrno(error: unknown, code: string): boolean {
return typeof error === "object" && error !== null && "code" in error && error.code === code;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Load-mutate-save under an exclusive lock file (`<path>.lock`), so
* concurrent Pi processes cannot lose each other's registry writes.
* The mutator must be short — never run git or postCreate inside it.
* `timeoutMs`/`staleMs` exist for tests.
*/
export async function updateState<T>(
mutate: (state: WorktreeState) => T | Promise<T>,
path: string = stateFilePath(),
options: { timeoutMs?: number; staleMs?: number } = {},
): Promise<T> {
const timeoutMs = options.timeoutMs ?? 5000;
const staleMs = options.staleMs ?? 30_000;
const lock = `${path}.lock`;
const dir = dirname(path);
const deadline = Date.now() + timeoutMs;
for (;;) {
try {
await mkdir(dir, { recursive: true, mode: 0o700 });
const fd = await open(lock, "wx", 0o600);
try {
await fd.writeFile(`${process.pid} ${new Date().toISOString()}`);
} finally {
await fd.close();
}
break;
} catch (error) {
if (!isErrno(error, "EEXIST")) throw error;
try {
const info = await stat(lock);
if (Date.now() - info.mtimeMs > staleMs) {
await unlink(lock).catch((e) => {
if (!isErrno(e, "ENOENT")) throw e;
});
continue;
}
} catch (error2) {
if (isErrno(error2, "ENOENT")) continue;
throw error2;
}
if (Date.now() >= deadline) {
throw new Error(`pi-worktree 状态文件被占用(锁:${lock}),请稍后重试`);
}
await sleep(50);
}
}
try {
const state = await loadState(path);
const result = await mutate(state);
await saveState(state, path);
return result;
} finally {
await unlink(lock).catch((error) => {
if (!isErrno(error, "ENOENT")) throw error;
});
}
}
/** Matches when cwd's realpath equals an entry path or sits inside it. */
export async function findByCwd(state: WorktreeState, cwd: string): Promise<WorktreeEntry | undefined> {
let real: string;
try {
real = await realpath(cwd);
} catch {
return undefined;
}
return state.worktrees.find((entry) => real === entry.path || real.startsWith(`${entry.path}${sep}`));
}
+64
View File
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { argumentCompletions } from "../index.ts";
import { addEntry, updateState, type WorktreeEntry } from "../src/state.ts";
function entry(overrides: Partial<WorktreeEntry> = {}): WorktreeEntry {
return {
slug: "demo",
path: "/nonexistent/demo",
branch: "wt/demo",
sourcePath: "/nonexistent/source",
sourceBranch: "main",
sourceSha: "0".repeat(40),
createdAt: new Date().toISOString(),
...overrides,
};
}
test("first-token completion returns subcommand names", async () => {
const items = await argumentCompletions("mer");
assert.ok(items?.some((item) => item.value === "merge"));
});
test("merge flag completions expand to the full argument line", async () => {
const items = await argumentCompletions("merge --c");
assert.deepEqual(
items?.map((item) => ({ value: item.value, label: item.label })),
[{ value: "merge --continue", label: "--continue" }],
);
});
test("bare -- after merge offers all four flags as full lines", async () => {
const items = await argumentCompletions("merge --");
assert.equal(items?.length, 4);
for (const item of items ?? []) {
assert.ok(item.value.startsWith("merge --"), item.value);
}
});
test("slug completions keep the subcommand in the replacement value", async (t) => {
const agentDir = await mkdtemp(join(tmpdir(), "pi-worktree-agent-"));
const previous = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = agentDir;
t.after(() => {
if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previous;
});
await updateState((s) => addEntry(s, entry()));
const switchItems = await argumentCompletions("switch ");
const values = switchItems?.map((item) => item.value) ?? [];
assert.ok(values.includes("switch main"), JSON.stringify(values));
assert.ok(values.includes("switch demo"), JSON.stringify(values));
const removeItems = await argumentCompletions("remove d");
assert.deepEqual(
removeItems?.map((item) => ({ value: item.value, label: item.label })),
[{ value: "remove demo", label: "demo" }],
);
});
+175
View File
@@ -0,0 +1,175 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, realpath, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { createGit, GitError } from "../src/git.ts";
const git = createGit();
const GIT_ENV = {
...process.env,
GIT_AUTHOR_NAME: "pi-worktree-test",
GIT_AUTHOR_EMAIL: "test@example.com",
GIT_COMMITTER_NAME: "pi-worktree-test",
GIT_COMMITTER_EMAIL: "test@example.com",
};
function raw(args: string[], cwd: string): string {
return execFileSync("git", args, { cwd, env: GIT_ENV, encoding: "utf8" });
}
/** Creates <tmp>/repo as a git repo; sibling dirs under <tmp> host worktrees. */
async function makeRepo(): Promise<{ parent: string; repo: string }> {
const parent = await mkdtemp(join(tmpdir(), "pi-worktree-git-"));
const repo = join(parent, "repo");
await mkdir(repo);
raw(["init", "-b", "main"], repo);
raw(["config", "user.name", "pi-worktree-test"], repo);
raw(["config", "user.email", "test@example.com"], repo);
await writeFile(join(repo, "file.txt"), "base\n", "utf8");
raw(["add", "-A"], repo);
raw(["commit", "-m", "init"], repo);
return { parent, repo };
}
async function commitFile(dir: string, name: string, content: string, message: string): Promise<void> {
await writeFile(join(dir, name), content, "utf8");
raw(["add", "-A"], dir);
raw(["commit", "-m", message], dir);
}
async function worktreeOf(parent: string, repo: string, name: string): Promise<string> {
const path = join(parent, name);
await git.addWorktree(repo, path, `wt/${name}`, "HEAD");
return path;
}
test("addWorktree creates the directory and branch", async () => {
const { parent, repo } = await makeRepo();
const wt = await worktreeOf(parent, repo, "feat-a");
assert.ok(existsSync(wt));
assert.equal(await git.getCurrentBranch(wt), "wt/feat-a");
assert.ok(await git.branchExists(repo, "wt/feat-a"));
});
test("ff-only merge brings source HEAD to the worktree HEAD", async () => {
const { parent, repo } = await makeRepo();
const wt = await worktreeOf(parent, repo, "feat-b");
await commitFile(wt, "new.txt", "work\n", "wt commit");
const rebaseResult = await git.rebase(wt, "main");
assert.deepEqual(rebaseResult, { ok: true, conflict: false });
assert.equal(await git.aheadCount(wt, "main", "wt/feat-b"), 1);
await git.mergeFfOnly(repo, "wt/feat-b");
assert.equal(await git.getHeadSha(repo), await git.getHeadSha(wt));
});
test("rebase conflict stays in progress, lists unmerged files, and aborts cleanly", async () => {
const { parent, repo } = await makeRepo();
const wt = await worktreeOf(parent, repo, "feat-c");
await commitFile(wt, "file.txt", "worktree version\n", "wt edit");
await commitFile(repo, "file.txt", "source version\n", "source edit");
const before = await git.getHeadSha(wt);
const result = await git.rebase(wt, "main");
assert.deepEqual(result, { ok: false, conflict: true });
assert.equal(await git.rebaseInProgress(wt), true);
assert.deepEqual(await git.unmergedFiles(wt), ["file.txt"]);
await git.rebaseAbort(wt);
assert.equal(await git.rebaseInProgress(wt), false);
assert.equal(await git.getHeadSha(wt), before);
assert.ok(!(await git.isDirty(wt)));
});
test("rebaseContinue finishes once conflicts are resolved and added", async () => {
const { parent, repo } = await makeRepo();
const wt = await worktreeOf(parent, repo, "feat-c2");
await commitFile(wt, "file.txt", "worktree version\n", "wt edit");
await commitFile(repo, "file.txt", "source version\n", "source edit");
const result = await git.rebase(wt, "main");
assert.deepEqual(result, { ok: false, conflict: true });
assert.equal(await git.rebaseInProgress(wt), true);
await writeFile(join(wt, "file.txt"), "resolved\n", "utf8");
raw(["add", "file.txt"], wt);
const continued = await git.rebaseContinue(wt);
assert.deepEqual(continued, { ok: true, conflict: false });
assert.equal(await git.rebaseInProgress(wt), false);
assert.equal(await git.isAncestor(repo, "main", "wt/feat-c2"), true);
});
test("mergeSquash produces exactly one new commit on the source", async () => {
const { parent, repo } = await makeRepo();
const wt = await worktreeOf(parent, repo, "feat-d");
await commitFile(wt, "a.txt", "1\n", "c1");
await commitFile(wt, "b.txt", "2\n", "c2");
const before = await git.getHeadSha(repo);
await git.rebase(wt, "main");
await git.mergeSquash(repo, "wt/feat-d", "squashed");
const after = await git.getHeadSha(repo);
assert.notEqual(before, after);
assert.equal(raw(["rev-list", "--count", `${before}..${after}`], repo).trim(), "1");
assert.equal(await readFile(join(repo, "a.txt"), "utf8"), "1\n");
});
test("stashPush reports whether anything was stashed and stashPop restores it", async () => {
const { repo } = await makeRepo();
assert.equal(await git.stashPush(repo, "nothing"), false);
await writeFile(join(repo, "dirty.txt"), "dirty\n", "utf8");
assert.equal(await git.stashPush(repo, "pi-worktree test"), true);
assert.equal(existsSync(join(repo, "dirty.txt")), false);
await git.stashPop(repo);
assert.equal(await readFile(join(repo, "dirty.txt"), "utf8"), "dirty\n");
});
test("rebase onto a nonexistent ref throws the original rebase error", async () => {
const { parent, repo } = await makeRepo();
const wt = await worktreeOf(parent, repo, "feat-e");
await assert.rejects(
() => git.rebase(wt, "no-such-ref"),
(error: unknown) => {
assert.ok(error instanceof GitError, `expected GitError, got ${String(error)}`);
assert.equal(error.args[0], "rebase");
assert.ok(!error.message.includes("--abort"), error.message);
return true;
},
);
assert.equal(await git.rebaseInProgress(wt), false);
});
test("removeWorktree with force deletes the directory while the branch stays", async () => {
const { parent, repo } = await makeRepo();
const wt = await worktreeOf(parent, repo, "feat-f");
await writeFile(join(wt, "junk.bin"), "x", "utf8");
await git.removeWorktree(repo, wt, true);
assert.equal(existsSync(wt), false);
assert.ok(await git.branchExists(repo, "wt/feat-f"));
});
test("listWorktrees parses main, linked, and detached entries", async () => {
const { parent, repo } = await makeRepo();
const wt = await worktreeOf(parent, repo, "feat-g");
const detachedPath = join(parent, "detached-wt");
raw(["worktree", "add", "--detach", detachedPath, "HEAD"], repo);
const entries = await git.listWorktrees(repo);
assert.equal(entries.length, 3);
const byPath = new Map(
await Promise.all(
entries.map(async (e) => [(await realpath(e.path).catch(() => e.path)), e] as const),
),
);
const main = entries[0];
assert.equal(main?.branch, "main");
assert.equal(byPath.get(await realpath(wt))?.branch, "wt/feat-g");
assert.equal(byPath.get(await realpath(detachedPath))?.detached, true);
assert.equal(await git.getMainWorktreePath(repo), main?.path);
});
+147
View File
@@ -0,0 +1,147 @@
import assert from "node:assert/strict";
import { CURRENT_SESSION_VERSION, SessionManager } from "@earendil-works/pi-coding-agent";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import test from "node:test";
import { prepareSessionForCwd, switchToCwd } from "../src/session.ts";
interface FakeContext {
sessionManager: { getSessionFile(): string | undefined };
}
function fakeCtx(getSessionFile: () => string | undefined): FakeContext {
return { sessionManager: { getSessionFile } };
}
test("prepareSessionForCwd forks an existing session file into the target cwd", async (t) => {
const agentDir = await mkdtemp(join(tmpdir(), "pi-worktree-agent-"));
const previous = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = agentDir;
t.after(() => {
if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previous;
});
const root = await mkdtemp(join(tmpdir(), "pi-worktree-session-"));
const sourceCwd = join(root, "source");
const targetCwd = join(root, "target");
await mkdir(sourceCwd, { recursive: true });
await mkdir(targetCwd, { recursive: true });
// Hand-written session file: header plus one user message entry.
const sessionDir = join(root, "sessions");
await mkdir(sessionDir, { recursive: true });
const file = join(sessionDir, "source-session.jsonl");
const header = {
type: "session",
version: CURRENT_SESSION_VERSION,
id: "11111111-1111-4111-8111-111111111111",
timestamp: new Date().toISOString(),
cwd: sourceCwd,
};
const message = {
type: "message",
id: "22222222-2222-4222-8222-222222222222",
parentId: null,
timestamp: new Date().toISOString(),
message: {
role: "user",
content: [{ type: "text", text: "hello" }],
timestamp: Date.now(),
},
};
await writeFile(file, `${JSON.stringify(header)}\n${JSON.stringify(message)}\n`, "utf8");
const newFile = prepareSessionForCwd(fakeCtx(() => file), targetCwd);
assert.ok(existsSync(newFile));
assert.notEqual(newFile, file);
const opened = SessionManager.open(newFile);
assert.equal(opened.getCwd(), resolve(targetCwd));
const entries = opened.getEntries();
assert.ok(entries.some((e) => e.type === "message"), "forked session keeps the history");
});
test("prepareSessionForCwd writes a minimal session file when none exists yet", async (t) => {
const agentDir = await mkdtemp(join(tmpdir(), "pi-worktree-agent-"));
const previous = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = agentDir;
t.after(() => {
if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previous;
});
const targetCwd = await mkdtemp(join(tmpdir(), "pi-worktree-target-"));
const newFile = prepareSessionForCwd(fakeCtx(() => undefined), targetCwd);
assert.ok(existsSync(newFile));
assert.equal(SessionManager.open(newFile).getCwd(), resolve(targetCwd));
});
test("switchToCwd appends a cwd-change notice before running the callback", async (t) => {
const agentDir = await mkdtemp(join(tmpdir(), "pi-worktree-agent-"));
const previous = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = agentDir;
t.after(() => {
if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previous;
});
const targetCwd = await mkdtemp(join(tmpdir(), "pi-worktree-target-"));
const order: string[] = [];
const sent: { message: unknown; options: unknown }[] = [];
const replacement = {
sendMessage: async (message: unknown, options: unknown) => {
order.push("sendMessage");
sent.push({ message, options });
},
};
const ctx = {
cwd: "/a",
waitForIdle: async () => undefined,
sessionManager: { getSessionFile: () => undefined },
switchSession: async (_file: string, opts: { withSession: (c: unknown) => Promise<void> }) => {
await opts.withSession(replacement);
return { cancelled: false };
},
};
const switched = await switchToCwd(ctx as never, targetCwd, async () => {
order.push("withSession");
});
assert.equal(switched.cancelled, false);
assert.deepEqual(order, ["sendMessage", "withSession"]);
assert.equal(sent.length, 1);
const { message, options } = sent[0] as { message: { customType?: string; content?: string; display?: boolean }; options: unknown };
assert.equal(message.customType, "pi-worktree");
assert.equal(message.display, true);
assert.ok(message.content?.includes("/a"), "notice names the previous cwd");
assert.ok(message.content?.includes(targetCwd), "notice names the new cwd");
assert.equal(options, undefined);
});
test("switchToCwd skips the notice when the cwd did not change", async (t) => {
const agentDir = await mkdtemp(join(tmpdir(), "pi-worktree-agent-"));
const previous = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = agentDir;
t.after(() => {
if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previous;
});
const targetCwd = await mkdtemp(join(tmpdir(), "pi-worktree-target-"));
let sent = 0;
const ctx = {
cwd: targetCwd,
waitForIdle: async () => undefined,
sessionManager: { getSessionFile: () => undefined },
switchSession: async (_file: string, opts: { withSession: (c: unknown) => Promise<void> }) => {
await opts.withSession({ sendMessage: async () => sent++ });
return { cancelled: false };
},
};
await switchToCwd(ctx as never, targetCwd, async () => undefined);
assert.equal(sent, 0);
});
+358
View File
@@ -0,0 +1,358 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { chmod, mkdir, mkdtemp, readFile, readlink, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { createGit } from "../src/git.ts";
import type { WorktreeEntry } from "../src/state.ts";
import {
applySetup,
expandPattern,
formatPlan,
fnv1a32,
loadSetupConfig,
parseSetupConfig,
planSetup,
worktreePort,
} from "../src/setup.ts";
const git = createGit();
const GIT_ENV = {
...process.env,
GIT_AUTHOR_NAME: "pi-worktree-test",
GIT_AUTHOR_EMAIL: "test@example.com",
GIT_COMMITTER_NAME: "pi-worktree-test",
GIT_COMMITTER_EMAIL: "test@example.com",
};
function raw(args: string[], cwd: string): string {
return execFileSync("git", args, { cwd, env: GIT_ENV, encoding: "utf8" });
}
async function makeRepo(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "pi-worktree-setup-"));
raw(["init", "-b", "main"], dir);
raw(["config", "user.name", "pi-worktree-test"], dir);
raw(["config", "user.email", "test@example.com"], dir);
await writeFile(join(dir, "file.txt"), "base\n", "utf8");
raw(["add", "-A"], dir);
raw(["commit", "-m", "init"], dir);
return dir;
}
function makeEntry(sourcePath: string, worktreePath: string): WorktreeEntry {
return {
slug: "feat-x",
path: worktreePath,
branch: "wt/feat-x",
sourcePath,
sourceBranch: "main",
sourceSha: "0".repeat(40),
createdAt: new Date().toISOString(),
};
}
async function writeConfig(dir: string, config: object): Promise<void> {
await mkdir(join(dir, ".pi"), { recursive: true });
await writeFile(join(dir, ".pi", "worktree.json"), JSON.stringify(config), "utf8");
}
test("loadSetupConfig: absent file yields undefined", async () => {
const repo = await makeRepo();
assert.equal(await loadSetupConfig(repo), undefined);
});
test("config validation rejects bad input with clear errors", async () => {
const p = "/x/.pi/worktree.json";
assert.throws(() => parseSetupConfig("{oops", p), /不是合法 JSON/u);
assert.throws(() => parseSetupConfig(JSON.stringify({ version: 1, nope: 1 }), p), /未知键/u);
assert.throws(() => parseSetupConfig(JSON.stringify({ version: 2 }), p), /version/u);
assert.throws(() => parseSetupConfig(JSON.stringify({ version: 1, copy: ["a/**/b"] }), p), /'\*\*'/u);
assert.throws(
() => parseSetupConfig(JSON.stringify({ version: 1, copy: ["dir*/file"] }), p),
/最后一段/u,
);
assert.throws(() => parseSetupConfig(JSON.stringify({ version: 1, copy: ["/abs"] }), p), /相对路径/u);
assert.throws(() => parseSetupConfig(JSON.stringify({ version: 1, copy: ["a/../b"] }), p), /\.\./u);
assert.throws(
() => parseSetupConfig(JSON.stringify({ version: 1, env: { A: 1 } }), p),
/env\.A 必须是字符串/u,
);
assert.throws(
() => parseSetupConfig(JSON.stringify({ version: 1, postCreate: [{ args: [] }] }), p),
/command/u,
);
assert.throws(
() =>
parseSetupConfig(
JSON.stringify({ version: 1, postCreate: [{ command: "x", args: [1] }] }),
p,
),
/args/u,
);
assert.throws(
() =>
parseSetupConfig(
JSON.stringify({ version: 1, postCreate: [{ command: "x", timeoutMs: -1 }] }),
p,
),
/timeoutMs/u,
);
});
test("expandPattern matches wildcards only in the last segment", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-worktree-expand-"));
await writeFile(join(dir, ".env.local"), "a", "utf8");
await writeFile(join(dir, ".env.dev.local"), "b", "utf8");
await writeFile(join(dir, ".env.prod.local"), "c", "utf8");
await mkdir(join(dir, "sub"));
await writeFile(join(dir, "sub", "a1.txt"), "x", "utf8");
await writeFile(join(dir, "sub", "a22.txt"), "y", "utf8");
assert.deepEqual(await expandPattern(dir, ".env.*.local"), [".env.dev.local", ".env.prod.local"]);
assert.deepEqual(await expandPattern(dir, "sub/a?.txt"), ["sub/a1.txt"]);
assert.deepEqual(await expandPattern(dir, "file.txt"), []);
await writeFile(join(dir, "file.txt"), "z", "utf8");
assert.deepEqual(await expandPattern(dir, "file.txt"), ["file.txt"]);
assert.deepEqual(await expandPattern(dir, "missing/*.txt"), []);
});
test("planSetup skips tracked copies and non-ignored links", async () => {
const repo = await makeRepo();
const wt = join(repo, "..", "wt-out");
await mkdir(wt, { recursive: true });
await writeFile(join(repo, ".env.local"), "SECRET=1\n", "utf8");
await writeFile(join(repo, "tracked.txt"), "t\n", "utf8");
raw(["add", "tracked.txt"], repo);
raw(["commit", "-m", "track"], repo);
await mkdir(join(repo, "node_modules"));
await writeFile(join(repo, "node_modules", "pkg.js"), "m", "utf8");
await mkdir(join(repo, "notignored"));
await writeFile(join(repo, "notignored", "f"), "n", "utf8");
await writeFile(join(repo, ".gitignore"), "node_modules/\n.env.local\n.env.worktree\n", "utf8");
// .gitignore itself untracked is fine for check-ignore.
const entry = makeEntry(repo, wt);
const config = parseSetupConfig(
JSON.stringify({
version: 1,
copy: [".env.local", "tracked.txt", "missing.txt"],
link: ["node_modules", "notignored"],
env: { PORT: "{{port}}", DB_NAME: "app_{{slug}}" },
}),
"/cfg",
);
const plan = await planSetup(git, config, entry, true);
assert.deepEqual(plan.copies.map((c) => c.rel), [".env.local"]);
assert.deepEqual(plan.links.map((l) => l.rel), ["node_modules"]);
assert.ok(plan.notes.some((n) => n.includes("tracked.txt") && n.includes("跟踪")));
assert.ok(plan.notes.some((n) => n.includes("notignored") && n.includes("gitignore")));
assert.equal(plan.envFile?.rel, ".env.worktree");
const expectedPort = 3000 + (fnv1a32("feat-x") % 1000);
assert.ok(plan.envFile?.content.includes(`PORT=${expectedPort}`));
assert.ok(plan.envFile?.content.includes("DB_NAME=app_feat-x"));
// Deterministic across calls.
const again = await planSetup(git, config, entry, true);
assert.equal(again.envFile?.content, plan.envFile.content);
assert.equal(worktreePort(3000, "feat-x"), expectedPort);
});
test("planSetup rejects unknown template variables", async () => {
const repo = await makeRepo();
const config = parseSetupConfig(
JSON.stringify({ version: 1, env: { A: "{{bogus}}" } }),
"/cfg",
);
await assert.rejects(() => planSetup(git, config, makeEntry(repo, "/tmp/wt-nowhere"), true), /未知模板变量/u);
});
test("planSetup skips postCreate when the project is untrusted", async () => {
const repo = await makeRepo();
const config = parseSetupConfig(
JSON.stringify({ version: 1, postCreate: [{ command: "echo", args: ["hi"] }] }),
"/cfg",
);
const plan = await planSetup(git, config, makeEntry(repo, "/tmp/wt-nowhere"), false);
assert.equal(plan.postCreate.length, 0);
assert.ok(plan.notes.some((n) => n.includes("未受信任")));
assert.ok(formatPlan(plan).includes("未受信任"));
});
test("applySetup copies files with modes, links dirs, writes env, runs steps", async () => {
const repo = await makeRepo();
// Commit .gitignore (no trailing slash) so the real worktree inherits it
// and the artifacts are already ignored — no info/exclude writes here.
await writeFile(join(repo, ".gitignore"), "node_modules\n.env.worktree\n", "utf8");
raw(["add", ".gitignore"], repo);
raw(["commit", "-m", "gitignore"], repo);
const wt = await makeRealWorktree(repo, "wt-apply");
await writeFile(join(repo, ".env.local"), "SECRET=1\n", { mode: 0o600 });
await chmod(join(repo, ".env.local"), 0o600);
await mkdir(join(repo, "node_modules"));
const entry = makeEntry(repo, wt);
const config = parseSetupConfig(
JSON.stringify({
version: 1,
copy: [".env.local"],
link: ["node_modules"],
env: { HELLO: "world", SLUG: "{{slug}}" },
postCreate: [
{
command: process.execPath,
args: [
"-e",
"require('fs').writeFileSync('marker.txt', process.env.PI_WT_SLUG + ':' + process.env.PI_WT_PORT)",
],
},
{ command: process.execPath, args: ["-e", "require('fs').writeFileSync('second.txt','ok')"] },
],
}),
"/cfg",
);
const plan = await planSetup(git, config, entry, true);
const steps: string[] = [];
const result = await applySetup(git, plan, entry, config.portBase, { onStep: (t) => steps.push(t) });
assert.deepEqual(result, { status: "ok" });
assert.equal(await readFile(join(wt, ".env.local"), "utf8"), "SECRET=1\n");
assert.equal((await stat(join(wt, ".env.local"))).mode & 0o777, 0o600);
assert.equal(await readlink(join(wt, "node_modules")), join(repo, "node_modules"));
const envContent = await readFile(join(wt, ".env.worktree"), "utf8");
assert.ok(envContent.includes("HELLO=world"));
assert.ok(envContent.includes("SLUG=feat-x"));
assert.equal((await stat(join(wt, ".env.worktree"))).mode & 0o777, 0o600);
const marker = await readFile(join(wt, "marker.txt"), "utf8");
const [slug, port] = marker.split(":");
assert.equal(slug, "feat-x");
assert.equal(Number(port), worktreePort(3000, "feat-x"));
assert.ok(existsSync(join(wt, "second.txt")));
assert.deepEqual(
steps.filter((s) => s.startsWith("正在运行")).length,
2,
);
assert.ok(steps[0]?.startsWith("正在运行"));
// A second plan+apply skips existing artifacts (wx/exclusive semantics).
const plan2 = await planSetup(git, config, entry, true);
assert.equal(plan2.copies.length, 0);
assert.equal(plan2.links.length, 0);
assert.equal(plan2.envFile, undefined);
});
test("applySetup stops at the first failing postCreate step", async () => {
const repo = await makeRepo();
const wt = await mkdtemp(join(tmpdir(), "pi-worktree-apply-fail-"));
const entry = makeEntry(repo, wt);
const config = parseSetupConfig(
JSON.stringify({
version: 1,
postCreate: [
{ command: process.execPath, args: ["-e", "process.exit(3)"] },
{ command: process.execPath, args: ["-e", "require('fs').writeFileSync('never.txt','1')"] },
],
}),
"/cfg",
);
const plan = await planSetup(git, config, entry, true);
const result = await applySetup(git, plan, entry, config.portBase, { onStep: () => undefined });
assert.equal(result.status, "failed");
assert.ok(result.step?.includes("process.exit(3)"));
assert.equal(existsSync(join(wt, "never.txt")), false);
});
async function makeRealWorktree(repo: string, name: string): Promise<string> {
const parent = await mkdtemp(join(tmpdir(), "pi-worktree-wt-"));
const wt = join(parent, name);
raw(["worktree", "add", "-b", `wt/${name}`, wt, "main"], repo);
return wt;
}
function excludeFile(repo: string): string {
const p = raw(["rev-parse", "--git-path", "info/exclude"], repo).trim();
return p.startsWith("/") ? p : join(repo, p);
}
test("applySetup excludes a symlink that a trailing-slash gitignore rule does not cover", async () => {
const repo = await makeRepo();
// Commit the trailing-slash rule so the worktree has it; it still does not
// match a symlink, so the exclusion path must fire.
await writeFile(join(repo, ".gitignore"), "node_modules/\n", "utf8");
raw(["add", ".gitignore"], repo);
raw(["commit", "-m", "gitignore"], repo);
const wt = await makeRealWorktree(repo, "wt-trailing");
await mkdir(join(repo, "node_modules"));
// Trailing-slash rule does not match the symlink inside the worktree.
const entry = makeEntry(repo, wt);
const config = parseSetupConfig(JSON.stringify({ version: 1, link: ["node_modules"] }), "/cfg");
const plan = await planSetup(git, config, entry, true);
const steps: string[] = [];
const result = await applySetup(git, plan, entry, config.portBase, {
onStep: (t) => steps.push(t),
});
assert.deepEqual(result, { status: "ok" });
raw(["check-ignore", "-q", "node_modules"], wt);
const status = raw(["status", "--porcelain", "--untracked-files=all"], wt);
assert.ok(!status.includes("node_modules"), status);
const exclude = await readFile(excludeFile(repo), "utf8");
assert.ok(exclude.split("\n").includes("/node_modules"), exclude);
assert.ok(steps.some((s) => s.includes("info/exclude")));
});
test("applySetup excludes a generated env file that is not gitignored", async () => {
const repo = await makeRepo();
const wt = await makeRealWorktree(repo, "wt-envfile");
const entry = makeEntry(repo, wt);
const config = parseSetupConfig(JSON.stringify({ version: 1, env: { A: "1" } }), "/cfg");
const plan = await planSetup(git, config, entry, true);
const result = await applySetup(git, plan, entry, config.portBase, { onStep: () => undefined });
assert.deepEqual(result, { status: "ok" });
raw(["check-ignore", "-q", ".env.worktree"], wt);
const status = raw(["status", "--porcelain", "--untracked-files=all"], wt);
assert.ok(!status.includes(".env.worktree"), status);
});
test("applySetup leaves info/exclude alone when artifacts are already ignored", async () => {
const repo = await makeRepo();
await writeFile(join(repo, ".gitignore"), "node_modules\n.env.worktree\n", "utf8");
raw(["add", ".gitignore"], repo);
raw(["commit", "-m", "gitignore"], repo);
const wt = await makeRealWorktree(repo, "wt-ignored");
await mkdir(join(repo, "node_modules"));
const entry = makeEntry(repo, wt);
const config = parseSetupConfig(
JSON.stringify({ version: 1, link: ["node_modules"], env: { A: "1" } }),
"/cfg",
);
const plan = await planSetup(git, config, entry, true);
const steps: string[] = [];
const result = await applySetup(git, plan, entry, config.portBase, {
onStep: (t) => steps.push(t),
});
assert.deepEqual(result, { status: "ok" });
let exclude = "";
try {
exclude = await readFile(excludeFile(repo), "utf8");
} catch {
exclude = "";
}
assert.ok(!exclude.split("\n").includes("/node_modules"), exclude);
assert.ok(!exclude.split("\n").includes("/.env.worktree"), exclude);
assert.ok(!steps.some((s) => s.includes("info/exclude")));
});
+22
View File
@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import test from "node:test";
import { toBranchName, toPathSlug, validateSlug } from "../src/slug.ts";
test("accepts valid slugs", () => {
for (const slug of ["feat/login", "fix-1", "simple", "a_b.c", "deep/nested/name"]) {
assert.equal(validateSlug(slug), null, slug);
}
});
test("rejects invalid slugs", () => {
for (const slug of ["..", "a..b", "-x", "x.lock", "a".repeat(65), "main", "HEAD", "", "a b", "a//b", "/a", "a/"]) {
assert.notEqual(validateSlug(slug), null, slug);
}
});
test("maps slug to path slug and branch name", () => {
assert.equal(toPathSlug("feat/login"), "feat-login");
assert.equal(toPathSlug("fix-1"), "fix-1");
assert.equal(toBranchName("wt/", "feat/login"), "wt/feat/login");
});
+151
View File
@@ -0,0 +1,151 @@
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, realpath, stat, symlink, unlink, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import test from "node:test";
import {
addEntry,
findByCwd,
findBySlug,
loadState,
removeEntry,
saveState,
stateFilePath,
updateState,
type WorktreeEntry,
} from "../src/state.ts";
function entry(overrides: Partial<WorktreeEntry> = {}): WorktreeEntry {
return {
slug: "feat",
path: "/nonexistent/path",
branch: "wt/feat",
sourcePath: "/nonexistent/source",
sourceBranch: "main",
sourceSha: "0".repeat(40),
createdAt: new Date().toISOString(),
...overrides,
};
}
test("state roundtrip with owner-only permissions", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-worktree-state-"));
const path = join(dir, "pi-worktree", "state.json");
const state = await loadState(path);
assert.deepEqual(state, { version: 1, worktrees: [] });
addEntry(state, entry());
await saveState(state, path);
const loaded = await loadState(path);
assert.equal(loaded.worktrees.length, 1);
assert.equal(loaded.worktrees[0]?.slug, "feat");
assert.equal((await stat(path)).mode & 0o777, 0o600);
assert.equal((await stat(dirname(path))).mode & 0o777, 0o700);
removeEntry(loaded, "feat");
assert.equal(loaded.worktrees.length, 0);
assert.equal(findBySlug(loaded, "feat"), undefined);
});
test("corrupt or unknown-version state throws instead of overwriting", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-worktree-state-"));
const bad = join(dir, "bad.json");
await writeFile(bad, "not json{", "utf8");
await assert.rejects(() => loadState(bad), /损坏/u);
const wrongVersion = join(dir, "v2.json");
await writeFile(wrongVersion, JSON.stringify({ version: 2, worktrees: [] }), "utf8");
await assert.rejects(() => loadState(wrongVersion), /版本不受支持|结构无效/u);
});
test("findByCwd matches nested subdir via realpath through a symlink", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-worktree-state-"));
const target = join(root, "wt-dir");
await mkdir(join(target, "sub", "deep"), { recursive: true });
const link = join(root, "link");
await symlink(target, link);
const state = { version: 1 as const, worktrees: [entry({ path: await realpath(target) })] };
const found = await findByCwd(state, join(link, "sub", "deep"));
assert.equal(found?.slug, "feat");
assert.equal(await findByCwd(state, root), undefined);
});
test("stateFilePath lives under the agent dir, not my-pi", () => {
const previous = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = join(tmpdir(), "pi-worktree-agentdir-check");
try {
const path = stateFilePath();
assert.equal(path, join(process.env.PI_CODING_AGENT_DIR, "pi-worktree", "state.json"));
assert.ok(!path.includes("my-pi"));
} finally {
if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previous;
}
});
test("updateState serializes concurrent mutations without losing entries", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-worktree-state-"));
const path = join(dir, "state.json");
await Promise.all(
Array.from({ length: 20 }, (_, i) =>
updateState(
(s) => addEntry(s, entry({ slug: `feat-${i}`, branch: `wt/feat-${i}`, path: `/p/${i}` })),
path,
),
),
);
const loaded = await loadState(path);
assert.equal(loaded.worktrees.length, 20);
assert.equal(new Set(loaded.worktrees.map((e) => e.slug)).size, 20);
assert.equal(existsSync(`${path}.lock`), false);
});
test("updateState breaks a stale lock older than the stale threshold", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-worktree-state-"));
const path = join(dir, "state.json");
const lock = `${path}.lock`;
await mkdir(dir, { recursive: true });
await writeFile(lock, "1 stale", "utf8");
const old = new Date(Date.now() - 60_000);
await utimes(lock, old, old);
await updateState((s) => addEntry(s, entry()), path);
assert.equal((await loadState(path)).worktrees.length, 1);
assert.equal(existsSync(lock), false);
});
test("updateState does not save when the mutator throws and releases the lock", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-worktree-state-"));
const path = join(dir, "state.json");
await updateState((s) => addEntry(s, entry()), path);
await assert.rejects(
updateState(() => {
throw new Error("boom");
}, path),
/boom/,
);
const loaded = await loadState(path);
assert.equal(loaded.worktrees.length, 1);
assert.equal(existsSync(`${path}.lock`), false);
});
test("updateState rejects when a fresh lock is held past the timeout", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-worktree-state-"));
const path = join(dir, "state.json");
const lock = `${path}.lock`;
await mkdir(dir, { recursive: true });
await writeFile(lock, "1 fresh", "utf8");
try {
await assert.rejects(
updateState(() => undefined, path, { timeoutMs: 300, staleMs: 60_000 }),
/状态文件被占用/,
);
} finally {
await unlink(lock).catch(() => undefined);
}
});
+6
View File
@@ -72,6 +72,12 @@ test("all package extensions load together without global registration conflicts
assert.ok(packageJson.pi.extensions.includes("./pi-ask-user/index.ts"), "the bundle must load pi-ask-user");
assert.equal(packageJson.dependencies["pi-ask-user"], "file:./pi-ask-user");
assert.ok(packageJson.files.includes("pi-ask-user"), "the packed bundle must include pi-ask-user source");
assert.ok(
packageJson.pi.extensions.includes("./pi-worktree/index.ts"),
"the bundle must load the locally maintained worktree commands",
);
assert.equal(packageJson.dependencies["pi-worktree"], "file:./pi-worktree");
assert.ok(packageJson.files.includes("pi-worktree"), "the packed bundle must include pi-worktree source");
const chromeExtension = "./pi-chrome/extensions/chrome-profile-bridge/index.ts";
assert.equal(packageJson.dependencies["pi-chrome"], "file:./pi-chrome", "the browser bridge must use the locally maintained source");
assert.ok(packageJson.files.includes("pi-chrome"), "the packed bundle must include the locally maintained pi-chrome source");