mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
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:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
f237025cf9
commit
97c2e75dfe
@@ -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`;命令缺失、项目未初始化或连接失败时不得阻止其他扩展加载。
|
||||
|
||||
@@ -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 管理。
|
||||
|
||||
Generated
+28
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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`。
|
||||
@@ -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";
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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}`));
|
||||
}
|
||||
@@ -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" }],
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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")));
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user