diff --git a/AGENTS.md b/AGENTS.md index f79349d..f9cea55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,10 @@ - 上游来源: - 初始参考快照:`dcd461925db2edf69a43c8135db1180d418afd54`(2026-08-24)。 - 该目录由本仓库直接维护,保留上游 MIT 许可证与来源说明;根组合包直接加载源码并通过本地 `file:` 依赖打包。 +- `pi-worktree/`:完全自维护(无上游)的斜杠命令 git worktree 管理扩展,提供 `/wt`(别名 `/worktree`)的 `new` / `merge` / `switch` / `list` / `remove` / `prune` 子命令,实现「一键拆分」与「一键合并」;不注册模型可调用工具。 +- 状态登记位于 `/pi-worktree/state.json`(目录 `700`、文件 `600`、原子写入),不放 `/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 个记忆工具。 - 上游来源: @@ -61,7 +65,7 @@ - 上游参考一:,快照 `59c95110aacd8131db71e21296765dd80b72fae8`(`v2.9.1`)。 - 上游来源二:,导入快照 `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` 整体重建 `/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` 整体重建 `/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`;命令缺失、项目未初始化或连接失败时不得阻止其他扩展加载。 diff --git a/README.md b/README.md index c076d2a..0f1ed1d 100644 --- a/README.md +++ b/README.md @@ -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;登记状态在 `/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 管理。 diff --git a/package-lock.json b/package-lock.json index a3cb0f8..44fd42a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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 + } + } } } } diff --git a/package.json b/package.json index d2b02c3..2d0b506 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pi-worktree/LICENSE b/pi-worktree/LICENSE new file mode 100644 index 0000000..57a1a7e --- /dev/null +++ b/pi-worktree/LICENSE @@ -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. diff --git a/pi-worktree/README.md b/pi-worktree/README.md new file mode 100644 index 0000000..24f45f4 --- /dev/null +++ b/pi-worktree/README.md @@ -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 [--from ]` | 在 `/<仓库名>/` 创建 worktree,签出新分支 ``(默认 `wt/`),确认后把当前会话切换进 worktree | +| `/wt merge [--squash] [--keep] [--continue] [--abort]` | 在当前 worktree 内执行:确认 → rebase 到来源分支 → 快进合并(或 squash)回来源分支 → 切回来源目录并清理 worktree 与分支。`--keep` 保留 worktree、分支和登记;rebase 冲突保留时可用 `--continue`/`--abort` 续跑或放弃 | +| `/wt switch [\|main]` | 切换到某个登记的 worktree;`main` 切回该 worktree 的来源 checkout。无参数时弹列表 | +| `/wt list` | 列出登记的 worktree:分支、路径、`(缺失)`、`(有未提交改动)`、`+N` 领先数,以及非本扩展管理的其它 git worktree | +| `/wt remove [--force]` | 删除指定 worktree 与分支;有未提交改动或未合并提交时要求 `--force` 并二次确认。不能删除当前所在的 worktree | +| `/wt prune` | 执行 `git worktree prune` 并清除目录已不存在的登记项 | +| `/wt reopen []` | 重新读取来源目录的 `.pi/worktree.json` 并补跑环境初始化(已存在的文件/软链自动跳过);默认 slug 为当前所在 worktree | + +## 流程与安全规则 + +- **拆分**:确认对话框显示新分支、基准 ref 和短 SHA、目标路径;创建并登记后才切换会话。会话切换失败不会回滚已创建的 worktree,可用 `/wt switch ` 重试。 +- **合并**:必须先校验来源 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 显示 `🌳 `,离开后清除。 + +## 会话切换机制 + +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 下的路径可能已不存在。 + +## 状态登记与锁 + +登记文件为 `/pi-worktree/state.json`(目录 `700`、文件 `600`、原子写入)。所有写操作经 `updateState` 在 `.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` 会标注 `(环境初始化失败:<步骤>)`。 + +## 状态与配置 + +- 登记文件:`/pi-worktree/state.json`(`getAgentDir()`,遵循 `PI_CODING_AGENT_DIR`),目录权限 `700`、文件权限 `600`,原子写入;文件损坏或版本未知时命令报错中止,不会被静默覆盖。**不**放在 `/my-pi/` 下,因为该目录每次升级会被整体替换。 +- `settings.json` 的 `piWorktree` 键: + - `root`:worktree 根目录,默认 `~/.pi/worktrees`(只展开开头的 `~`,展开后必须是绝对路径)。worktree 路径为 `/<主 worktree 目录名>/`。 + - `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`。 diff --git a/pi-worktree/index.ts b/pi-worktree/index.ts new file mode 100644 index 0000000..6888149 --- /dev/null +++ b/pi-worktree/index.ts @@ -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"; diff --git a/pi-worktree/package.json b/pi-worktree/package.json new file mode 100644 index 0000000..25a906c --- /dev/null +++ b/pi-worktree/package.json @@ -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" + } +} diff --git a/pi-worktree/src/commands.ts b/pi-worktree/src/commands.ts new file mode 100644 index 0000000..e15f40f --- /dev/null +++ b/pi-worktree/src/commands.ts @@ -0,0 +1,1016 @@ +import type { + ExtensionAPI, + ExtensionCommandContext, + ExtensionContext, +} from "@earendil-works/pi-coding-agent"; +import type { AutocompleteItem } from "@earendil-works/pi-tui"; +import { existsSync } from "node:fs"; +import { mkdir, realpath } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; +import { createGit, type Git } from "./git.ts"; +import { + addEntry, + findByCwd, + findBySlug, + loadState, + removeEntry, + updateState, + type WorktreeEntry, + type WorktreeState, +} from "./state.ts"; +import { switchToCwd } from "./session.ts"; +import { + applySetup, + formatPlan, + loadSetupConfig, + planSetup, + type SetupPlan, +} from "./setup.ts"; +import { readWorktreeSettings, type WorktreeSettings } from "./settings.ts"; +import { toBranchName, toPathSlug, validateSlug } from "./slug.ts"; + +type Ui = ExtensionCommandContext["ui"]; + +const SUBCOMMANDS = [ + { name: "new", description: "新建 worktree 并切换过去" }, + { name: "merge", description: "把当前 worktree 的改动合并回来源分支" }, + { name: "switch", description: "切换到某个 worktree 或回主目录(main)" }, + { name: "list", description: "列出所有登记的 worktree" }, + { name: "remove", description: "删除指定 worktree" }, + { name: "prune", description: "清理已失效的 worktree 登记" }, + { name: "reopen", description: "重跑 worktree 环境初始化(.pi/worktree.json)" }, +] as const; + +const MUTATING = new Set(["new", "merge", "switch", "remove", "reopen"]); + +function shortSha(sha: string): string { + return sha.slice(0, 8); +} + +function truncateLines(text: string, maxLines: number): string { + const lines = text.trimEnd().split("\n"); + if (lines.length <= maxLines) return text.trimEnd(); + return `${lines.slice(0, maxLines).join("\n")}\n…(共 ${lines.length} 行,已截断)`; +} + +function notifyError(ui: Ui, error: unknown): void { + ui.notify(error instanceof Error ? error.message : String(error), "error"); +} + +async function loadStateOrNotify(ui: Ui): Promise { + try { + return await loadState(); + } catch (error) { + notifyError(ui, error); + return undefined; + } +} + +interface ParsedArgs { + positional: string[]; + flags: Map; +} + +const VALUE_FLAGS = new Set(["from"]); + +function parseArgs(tokens: string[]): ParsedArgs { + const positional: string[] = []; + const flags = new Map(); + for (let i = 0; i < tokens.length; i += 1) { + const token = tokens[i]; + if (token.startsWith("--")) { + const name = token.slice(2); + const next = tokens[i + 1]; + if (VALUE_FLAGS.has(name) && next !== undefined && !next.startsWith("--")) { + flags.set(name, next); + i += 1; + } else { + flags.set(name, true); + } + } else { + positional.push(token); + } + } + return { positional, flags }; +} + +function requireUI(ctx: ExtensionCommandContext): boolean { + if (ctx.hasUI) return true; + ctx.ui.notify("需要交互式界面", "error"); + return false; +} + +async function realpathOrUndefined(path: string): Promise { + try { + return await realpath(path); + } catch { + return undefined; + } +} + +async function statusText(cwd: string): Promise { + const entry = await findByCwd(await loadState(), cwd); + return entry ? `🌳 ${entry.slug}` : undefined; +} + +// --------------------------------------------------------------------------- +// /wt new [--from ] +// --------------------------------------------------------------------------- + +async function cmdNew( + ctx: ExtensionCommandContext, + git: Git, + settings: WorktreeSettings, + args: ParsedArgs, +): Promise { + const ui = ctx.ui; + const slug = args.positional[0]; + if (!slug) { + ui.notify("用法:/wt new [--from ]", "error"); + return; + } + const slugError = validateSlug(slug); + if (slugError) { + ui.notify(`无效的 slug:${slugError}`, "error"); + return; + } + const fromFlag = args.flags.get("from"); + if (fromFlag === true) { + ui.notify("--from 需要一个 ref 值", "error"); + return; + } + const fromRef = typeof fromFlag === "string" ? fromFlag : undefined; + + let toplevel: string; + try { + toplevel = await git.getToplevel(ctx.cwd); + } catch (error) { + notifyError(ui, error); + return; + } + const currentBranch = await git.getCurrentBranch(toplevel); + if (!currentBranch && !fromRef) { + ui.notify("当前处于 detached HEAD,请用 --from 指定基准", "error"); + return; + } + const startPoint = fromRef ?? "HEAD"; + let sha: string; + try { + sha = await git.revParse(toplevel, startPoint); + } catch { + ui.notify(`无效的基准 ref:${startPoint}`, "error"); + return; + } + const sourceBranch = currentBranch ?? fromRef ?? "HEAD"; + + const state = await loadStateOrNotify(ui); + if (!state) return; + if (findBySlug(state, slug)) { + ui.notify(`slug '${slug}' 已被登记,换一个名字或先 /wt remove ${slug}`, "error"); + return; + } + const branch = toBranchName(settings.branchPrefix, slug); + if (await git.branchExists(toplevel, branch)) { + ui.notify(`分支 ${branch} 已存在`, "error"); + return; + } + const repoName = basename(await git.getMainWorktreePath(toplevel)); + const worktreePath = join(settings.root, repoName, toPathSlug(slug)); + if (existsSync(worktreePath)) { + ui.notify(`目标路径已存在:${worktreePath}`, "error"); + return; + } + + // Replication config is read from the SOURCE checkout (never the new + // worktree) and planned before the confirm dialog so the user sees + // exactly what will happen. + const entry: WorktreeEntry = { + slug, + path: worktreePath, + branch, + sourcePath: (await realpathOrUndefined(toplevel)) ?? toplevel, + sourceBranch, + sourceSha: sha, + createdAt: new Date().toISOString(), + }; + let plan: SetupPlan | undefined; + let portBase = 3000; + try { + const config = await loadSetupConfig(toplevel); + if (config) { + portBase = config.portBase; + plan = await planSetup(git, config, entry, ctx.isProjectTrusted()); + } + } catch (error) { + notifyError(ui, error); + return; + } + + const setupSection = plan ? `\n\n环境初始化:\n${formatPlan(plan)}` : ""; + const confirmed = await ui.confirm( + "创建 worktree", + `分支:${branch}\n基准:${startPoint}(${shortSha(sha)})\n路径:${worktreePath}\n\n创建后当前会话将切换到该 worktree。${setupSection}`, + ); + if (!confirmed) return; + + try { + await mkdir(dirname(worktreePath), { recursive: true }); + await git.addWorktree(toplevel, worktreePath, branch, startPoint); + } catch (error) { + notifyError(ui, error); + return; + } + + entry.path = (await realpathOrUndefined(worktreePath)) ?? worktreePath; + if (plan) plan.worktreePath = entry.path; + try { + // Mutate a freshly-loaded state under the registry lock; a concurrent + // Pi may have registered the same slug since `state` was read. + await updateState((s) => { + if (findBySlug(s, slug)) throw new Error(`slug ${slug} 已被登记`); + addEntry(s, entry); + }); + } catch (error) { + ui.notify(`worktree 已创建但登记失败:${error instanceof Error ? error.message : String(error)}`, "error"); + return; + } + + if (plan) { + const actionable = + plan.copies.length + plan.links.length + plan.postCreate.length + (plan.envFile ? 1 : 0) > 0; + if (!actionable) { + entry.setup = { status: "skipped", at: new Date().toISOString() }; + } else { + const result = await applySetup(git, plan, entry, portBase, { + onStep: (text) => ui.notify(text, "info"), + }); + entry.setup = { status: result.status, step: result.step, error: result.error, at: new Date().toISOString() }; + if (result.status === "failed") { + ui.notify( + `环境初始化在 ${result.step} 失败:${result.error};修复后可用 /wt reopen ${slug} 重跑`, + "error", + ); + } + } + const setup = entry.setup; + try { + await updateState((s) => { + const e = findBySlug(s, slug); + if (e) e.setup = setup; + }); + } catch (error) { + ui.notify(`环境初始化结果登记失败:${error instanceof Error ? error.message : String(error)}`, "warning"); + } + } + + // The outer catch covers failures before the session teardown, where the + // old ctx is still valid; anything inside withSession runs on the new + // context and must report through c.ui (the old ctx is inactive then). + try { + const switched = await switchToCwd(ctx, worktreePath, async (c) => { + try { + c.ui.setStatus("worktree", `🌳 ${slug}`); + c.ui.notify(`已进入 worktree ${slug}(${worktreePath})`, "info"); + } catch (error) { + c.ui.notify( + `已进入 worktree 但状态提示失败:${error instanceof Error ? error.message : String(error)}`, + "error", + ); + } + }); + if (switched.cancelled) { + ui.notify(`worktree ${slug} 已创建(${worktreePath}),但会话切换被取消;可用 /wt switch ${slug} 进入`, "warning"); + } + } catch (error) { + ui.notify( + `worktree 已创建但会话切换失败(${error instanceof Error ? error.message : String(error)}),运行 /wt switch ${slug} 重试`, + "error", + ); + } +} + +// --------------------------------------------------------------------------- +// /wt merge [--squash] [--keep] [--continue] [--abort] +// --------------------------------------------------------------------------- + +async function cmdMerge( + ctx: ExtensionCommandContext, + git: Git, + args: ParsedArgs, +): Promise { + const ui = ctx.ui; + const squash = args.flags.has("squash"); + const keep = args.flags.has("keep"); + + const state = await loadStateOrNotify(ui); + if (!state) return; + const entry = await findByCwd(state, ctx.cwd); + if (!entry) { + ui.notify("当前不在 pi-worktree 管理的 worktree 内", "error"); + return; + } + + // Validate the recorded source checkout is still usable. + if (!existsSync(entry.sourcePath)) { + ui.notify(`来源目录不存在:${entry.sourcePath}`, "error"); + return; + } + + // An in-progress rebase leaves the worktree's HEAD detached and its tree + // dirty, so this must run before the branch checks and the dirty-commit + // step below. + const inProgress = await git.rebaseInProgress(entry.path); + if (inProgress) { + if (args.flags.has("abort")) { + await git.rebaseAbort(entry.path); + ui.notify("已中止 rebase,worktree 恢复原状", "info"); + return; + } + if (!args.flags.has("continue")) { + ui.notify( + "worktree 有进行中的 rebase:解决冲突后运行 /wt merge --continue,或 /wt merge --abort 放弃", + "warning", + ); + return; + } + const files = await git.unmergedFiles(entry.path); + if (files.length > 0) { + ui.notify( + `仍有未解决的冲突:\n${truncateLines(files.join("\n"), 20)}\n解决并 git add 后再运行 /wt merge --continue`, + "error", + ); + return; + } + const continued = await git.rebaseContinue(entry.path); + if (!continued.ok) { + const again = await git.unmergedFiles(entry.path); + ui.notify( + `冲突文件:\n${truncateLines(again.join("\n"), 20)}\n在 worktree 中解决并 git add 后运行 /wt merge --continue;放弃运行 /wt merge --abort`, + "warning", + ); + return; + } + ui.notify("rebase 已继续完成", "info"); + // Fall through into the normal merge flow. + } else if (args.flags.has("abort") || args.flags.has("continue")) { + ui.notify("当前没有进行中的 rebase", "info"); + return; + } + + try { + const sourceToplevel = await git.getToplevel(entry.sourcePath); + const real = await realpathOrUndefined(sourceToplevel); + if (!real || real !== entry.sourcePath) { + ui.notify(`来源路径 ${entry.sourcePath} 已不是仓库顶层`, "error"); + return; + } + } catch (error) { + notifyError(ui, error); + return; + } + let sourceCurrent: string | null; + try { + sourceCurrent = await git.getCurrentBranch(entry.sourcePath); + } catch (error) { + notifyError(ui, error); + return; + } + if (sourceCurrent !== entry.sourceBranch) { + ui.notify(`源 checkout 已切换分支,请切回 ${entry.sourceBranch} 后重试`, "error"); + return; + } + let worktreeBranch: string | null; + try { + worktreeBranch = await git.getCurrentBranch(entry.path); + } catch (error) { + notifyError(ui, error); + return; + } + if (worktreeBranch !== entry.branch) { + ui.notify(`worktree 当前分支是 ${worktreeBranch ?? "detached"},不是登记的 ${entry.branch}`, "error"); + return; + } + + // Commit pending worktree changes so the rebase has something to carry. + let worktreeDirty = false; + try { + worktreeDirty = await git.isDirty(entry.path); + } catch (error) { + notifyError(ui, error); + return; + } + if (worktreeDirty) { + const commit = await ui.confirm( + "提交未提交改动", + `worktree ${entry.slug} 有未提交的改动。提交全部改动后继续合并?`, + ); + if (!commit) return; + const message = + (await ui.input("提交信息", `wt: ${entry.slug}`))?.trim() || `wt: ${entry.slug}`; + try { + await git.commitAll(entry.path, message); + } catch (error) { + notifyError(ui, error); + return; + } + } + + // Ask before touching anything: rebase rewrites the branch, so the user + // must see the confirm dialog (and the squash prompt) first. + const ahead = await git.aheadCount(entry.path, entry.sourceBranch, entry.branch); + if (ahead === 0) { + ui.notify("没有需要合并的提交", "info"); + return; + } + const stat = truncateLines( + await git.diffStat(entry.path, entry.sourceBranch, entry.branch), + 30, + ); + const mode = squash ? "squash(压缩为单个提交)" : "ff-only(快进合并)"; + const confirmed = await ui.confirm( + "合并 worktree", + `将把 ${entry.branch} 的 ${ahead} 个提交以 ${mode} 方式合并到 ${entry.sourceBranch}。\n` + + `将先 rebase 到 ${entry.sourceBranch} 最新提交,再合并。\n` + + `合并后${keep ? "保留" : "删除"} worktree 与登记${keep ? "" : ",并删除分支"}。\n\n${stat}`, + ); + if (!confirmed) return; + + let squashMessage: string | undefined; + if (squash) { + squashMessage = + (await ui.input("squash 提交信息", `wt: ${entry.slug}`))?.trim() || `wt: ${entry.slug}`; + } + + const stashName = `pi-worktree merge ${entry.slug}`; + let stashed = false; + try { + if (await git.isDirty(entry.sourcePath)) { + stashed = await git.stashPush(entry.sourcePath, stashName); + } + + const rebaseResult = await git.rebase(entry.path, entry.sourceBranch); + if (!rebaseResult.ok) { + const files = await git.unmergedFiles(entry.path); + const choice = await ui.select(`rebase 冲突(${files.length} 个文件)`, [ + "中止 rebase,保持原状", + "保留冲突,稍后手动解决", + ]); + if (!choice || choice.startsWith("中止")) { + await git.rebaseAbort(entry.path); + ui.notify("rebase 冲突,已中止;worktree 保持原状", "error"); + } else { + ui.notify( + `冲突文件:\n${truncateLines(files.join("\n"), 20)}\n在 worktree 中解决并 git add 后运行 /wt merge --continue;放弃运行 /wt merge --abort`, + "warning", + ); + } + return; + } + + const aheadAfter = await git.aheadCount(entry.path, entry.sourceBranch, entry.branch); + if (aheadAfter === 0) { + ui.notify("rebase 后没有需要合并的提交(改动已在来源分支中)", "info"); + return; + } + + if (squash) { + await git.mergeSquash(entry.sourcePath, entry.branch, squashMessage ?? `wt: ${entry.slug}`); + } else { + await git.mergeFfOnly(entry.sourcePath, entry.branch); + } + } catch (error) { + notifyError(ui, error); + return; + } finally { + if (stashed) { + try { + await git.stashPop(entry.sourcePath); + } catch { + ui.notify(`恢复暂存失败,改动仍保留在 stash 中:${stashName}`, "warning"); + } + } + } + + const modeLabel = squash ? "squash" : "ff-only"; + // The outer catch covers failures before the session teardown, where the + // old ctx is still valid; the cleanup inside withSession reports through + // c.ui because the old ctx is inactive after the switch. + try { + const switched = await switchToCwd(ctx, entry.sourcePath, async (c) => { + try { + if (!keep) { + try { + await git.removeWorktree(entry.sourcePath, entry.path, false); + } catch (error) { + // git refuses removal when e.g. ignored build output exists. + if (!(await git.isDirty(entry.path))) { + await git.removeWorktree(entry.sourcePath, entry.path, true); + } else { + throw error; + } + } + await git.deleteBranch(entry.sourcePath, entry.branch, squash); + await updateState((s) => removeEntry(s, entry.slug)); + c.ui.setStatus("worktree", undefined); + c.ui.notify(`已合并 ${entry.branch} 到 ${entry.sourceBranch}(${modeLabel})并清理 worktree`, "info"); + } else { + c.ui.setStatus("worktree", undefined); + c.ui.notify( + `已合并 ${entry.branch} 到 ${entry.sourceBranch}(${modeLabel});worktree ${entry.slug} 已保留`, + "info", + ); + } + } catch (error) { + c.ui.notify( + `已合并到 ${entry.sourceBranch},但清理 worktree 失败:${error instanceof Error ? error.message : String(error)};可稍后运行 /wt remove ${entry.slug}`, + "error", + ); + } + }); + if (switched.cancelled) { + ui.notify(`已合并到 ${entry.sourceBranch},但会话切换被取消,未清理 worktree ${entry.slug}`, "warning"); + } + } catch (error) { + ui.notify( + `已合并到 ${entry.sourceBranch},但会话切换或清理失败:${error instanceof Error ? error.message : String(error)}`, + "error", + ); + } +} + +// --------------------------------------------------------------------------- +// /wt switch [|main] +// --------------------------------------------------------------------------- + +async function cmdSwitch(ctx: ExtensionCommandContext, args: ParsedArgs): Promise { + const ui = ctx.ui; + const state = await loadStateOrNotify(ui); + if (!state) return; + const current = await findByCwd(state, ctx.cwd); + + let target = args.positional[0]; + if (!target) { + const options = state.worktrees.map((entry) => entry.slug); + if (current) options.unshift("main"); + if (options.length === 0) { + ui.notify("没有已登记的 worktree", "info"); + return; + } + const choice = await ui.select("切换到", options); + if (!choice) return; + target = choice; + } + + let targetPath: string; + let targetEntry: WorktreeEntry | undefined; + if (target === "main") { + if (!current) { + ui.notify("当前不在 worktree 内,无法切回 main", "error"); + return; + } + targetPath = current.sourcePath; + } else { + targetEntry = findBySlug(state, target); + if (!targetEntry) { + ui.notify(`未登记的 slug:${target}`, "error"); + return; + } + if (!existsSync(targetEntry.path)) { + ui.notify(`worktree 目录不存在:${targetEntry.path},可先运行 /wt prune 清理登记`, "error"); + return; + } + targetPath = targetEntry.path; + } + + const realCwd = await realpathOrUndefined(ctx.cwd); + const realTarget = await realpathOrUndefined(targetPath); + if (realCwd && realTarget && (realCwd === realTarget || realCwd.startsWith(`${realTarget}/`))) { + ui.notify("已在该目录", "info"); + return; + } + + // Outer catch covers pre-teardown failures (old ctx still valid); the + // withSession body must report through c.ui on the new context. + try { + await switchToCwd(ctx, targetPath, async (c) => { + try { + if (targetEntry) { + c.ui.setStatus("worktree", `🌳 ${targetEntry.slug}`); + c.ui.notify(`已切换到 worktree ${targetEntry.slug}(${targetPath})`, "info"); + } else { + c.ui.setStatus("worktree", undefined); + c.ui.notify(`已切换回主目录(${targetPath})`, "info"); + } + } catch (error) { + c.ui.notify( + `会话已切换但收尾失败:${error instanceof Error ? error.message : String(error)}`, + "error", + ); + } + }); + } catch (error) { + notifyError(ui, error); + } +} + +// --------------------------------------------------------------------------- +// /wt list +// --------------------------------------------------------------------------- + +async function cmdList(ctx: ExtensionCommandContext, git: Git): Promise { + const ui = ctx.ui; + const state = await loadStateOrNotify(ui); + if (!state) return; + + const lines: string[] = []; + if (state.worktrees.length === 0) { + lines.push("(没有已登记的 worktree)"); + } + for (const entry of state.worktrees) { + const notes: string[] = []; + if (!existsSync(entry.path)) { + notes.push("缺失"); + } else { + try { + if (await git.isDirty(entry.path)) notes.push("有未提交改动"); + const ahead = await git.aheadCount(entry.path, entry.sourceBranch, entry.branch); + if (ahead > 0) notes.push(`+${ahead}`); + } catch { + notes.push("状态不可读"); + } + } + if (entry.setup?.status === "failed") { + notes.push(`环境初始化失败:${entry.setup.step ?? "未知步骤"}`); + } + lines.push(`${entry.slug} ${entry.branch} ${entry.path}${notes.length ? ` (${notes.join(",")})` : ""}`); + } + + try { + const toplevel = await git.getToplevel(ctx.cwd); + const mainPath = await git.getMainWorktreePath(toplevel); + const known = new Set(state.worktrees.map((entry) => entry.path)); + const realMain = (await realpathOrUndefined(mainPath)) ?? mainPath; + for (const wt of await git.listWorktrees(toplevel)) { + const real = (await realpathOrUndefined(wt.path)) ?? wt.path; + if (real === realMain) continue; + if (known.has(real) || known.has(wt.path)) continue; + lines.push(`${basename(wt.path)} ${wt.branch ?? "detached"} ${wt.path} (非 pi-worktree 管理)`); + } + } catch { + // Not in a repo or worktree list failed; registry listing still shown. + } + + ui.notify(lines.join("\n"), "info"); +} + +// --------------------------------------------------------------------------- +// /wt remove [--force] +// --------------------------------------------------------------------------- + +async function cmdRemove(ctx: ExtensionCommandContext, git: Git, args: ParsedArgs): Promise { + const ui = ctx.ui; + const force = args.flags.has("force"); + const state = await loadStateOrNotify(ui); + if (!state) return; + + let slug = args.positional[0]; + if (!slug) { + const options = state.worktrees.map((entry) => entry.slug); + if (options.length === 0) { + ui.notify("没有已登记的 worktree", "info"); + return; + } + const choice = await ui.select("删除哪个 worktree", options); + if (!choice) return; + slug = choice; + } + const entry = findBySlug(state, slug); + if (!entry) { + ui.notify(`未登记的 slug:${slug}`, "error"); + return; + } + + const realCwd = await realpathOrUndefined(ctx.cwd); + if (realCwd && (realCwd === entry.path || realCwd.startsWith(`${entry.path}/`))) { + ui.notify("当前位于该 worktree 内,请先 /wt switch main", "error"); + return; + } + + let dirtyFiles = 0; + let ahead = 0; + if (existsSync(entry.path)) { + dirtyFiles = await git + .statusEntries(entry.path) + .then((entries) => entries.length) + .catch(() => 0); + ahead = await git.aheadCount(entry.path, entry.sourceBranch, entry.branch).catch(() => 0); + } + if ((dirtyFiles > 0 || ahead > 0) && !force) { + const parts: string[] = []; + if (dirtyFiles > 0) parts.push(`${dirtyFiles} 个未提交文件`); + if (ahead > 0) parts.push(`${ahead} 个未合并提交`); + ui.notify(`worktree ${slug} 有${parts.join("和")};确认丢弃请加 --force`, "error"); + return; + } + if (force && (dirtyFiles > 0 || ahead > 0)) { + const parts: string[] = []; + if (dirtyFiles > 0) parts.push(`${dirtyFiles} 个未提交文件`); + if (ahead > 0) parts.push(`${ahead} 个未合并提交`); + const confirmed = await ui.confirm("强制删除 worktree", `将丢弃 ${slug} 的${parts.join("和")}。是否继续?`); + if (!confirmed) return; + } + + try { + if (existsSync(entry.path)) { + await git.removeWorktree(entry.sourcePath, entry.path, force); + } else { + // Directory is gone but git may still hold stale worktree metadata + // ("checked out at …"), which would make branch deletion fail. + await git.pruneWorktrees(entry.sourcePath); + } + try { + await git.deleteBranch(entry.sourcePath, entry.branch, force); + } catch (error) { + ui.notify( + `worktree 已删除,但分支 ${entry.branch} 删除失败:${error instanceof Error ? error.message : String(error)}`, + "warning", + ); + } + await updateState((s) => removeEntry(s, slug)); + ui.notify(`已删除 worktree ${slug}`, "info"); + } catch (error) { + notifyError(ui, error); + } +} + +// --------------------------------------------------------------------------- +// /wt prune +// --------------------------------------------------------------------------- + +async function cmdPrune(ctx: ExtensionCommandContext, git: Git): Promise { + const ui = ctx.ui; + try { + const toplevel = await git.getToplevel(ctx.cwd); + await git.pruneWorktrees(toplevel); + } catch (error) { + notifyError(ui, error); + return; + } + let removed: string[]; + try { + removed = await updateState((s) => { + const stale = s.worktrees.filter((entry) => !existsSync(entry.path)); + s.worktrees = s.worktrees.filter((entry) => existsSync(entry.path)); + return stale.map((entry) => entry.slug); + }); + } catch (error) { + notifyError(ui, error); + return; + } + if (removed.length === 0) { + ui.notify("没有需要清理的登记项", "info"); + return; + } + ui.notify(`已清理 ${removed.length} 个失效登记:${removed.join("、")}`, "info"); +} + +// --------------------------------------------------------------------------- +// /wt reopen [] +// --------------------------------------------------------------------------- + +async function cmdReopen(ctx: ExtensionCommandContext, git: Git, args: ParsedArgs): Promise { + const ui = ctx.ui; + const state = await loadStateOrNotify(ui); + if (!state) return; + + let slug = args.positional[0]; + if (!slug) { + slug = (await findByCwd(state, ctx.cwd))?.slug; + if (!slug) { + ui.notify("当前不在 worktree 内,请指定 slug:/wt reopen ", "error"); + return; + } + } + const entry = findBySlug(state, slug); + if (!entry) { + ui.notify(`未登记的 slug:${slug}`, "error"); + return; + } + if (!existsSync(entry.path)) { + ui.notify(`worktree 目录不存在:${entry.path},可先运行 /wt prune 清理登记`, "error"); + return; + } + + let plan: SetupPlan; + let portBase = 3000; + try { + const config = await loadSetupConfig(entry.sourcePath); + if (!config) { + ui.notify(`来源目录没有 .pi/worktree.json:${entry.sourcePath}`, "info"); + return; + } + portBase = config.portBase; + plan = await planSetup(git, config, entry, ctx.isProjectTrusted()); + } catch (error) { + notifyError(ui, error); + return; + } + + const actionable = + plan.copies.length + plan.links.length + plan.postCreate.length + (plan.envFile ? 1 : 0) > 0; + const recordSetup = (status: WorktreeEntry["setup"]) => + updateState((s) => { + const e = findBySlug(s, entry.slug); + if (e) e.setup = status; + }).catch((error: unknown) => + ui.notify( + `环境初始化结果登记失败:${error instanceof Error ? error.message : String(error)}`, + "warning", + ), + ); + + if (!actionable) { + await recordSetup({ status: "skipped", at: new Date().toISOString() }); + ui.notify("没有需要执行的环境初始化项(详见备注)\n" + formatPlan(plan), "info"); + return; + } + + const confirmed = await ui.confirm("重跑环境初始化", `${formatPlan(plan)}\n\n是否继续?`); + if (!confirmed) return; + + const result = await applySetup(git, plan, entry, portBase, { + onStep: (text) => ui.notify(text, "info"), + }); + await recordSetup({ + status: result.status, + step: result.step, + error: result.error, + at: new Date().toISOString(), + }); + if (result.status === "ok") { + ui.notify(`环境初始化完成:${slug}`, "info"); + } else { + ui.notify(`环境初始化在 ${result.step} 失败:${result.error}`, "error"); + } +} + +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- + +async function dispatch( + ctx: ExtensionCommandContext, + git: Git, + settings: WorktreeSettings, + tokens: string[], +): Promise { + const [sub, ...rest] = tokens; + const parsed = parseArgs(rest); + switch (sub) { + case "new": + await cmdNew(ctx, git, settings, parsed); + return; + case "merge": + await cmdMerge(ctx, git, parsed); + return; + case "switch": + await cmdSwitch(ctx, parsed); + return; + case "list": + await cmdList(ctx, git); + return; + case "remove": + await cmdRemove(ctx, git, parsed); + return; + case "prune": + await cmdPrune(ctx, git); + return; + case "reopen": + await cmdReopen(ctx, git, parsed); + return; + default: + ctx.ui.notify(`未知子命令:${sub ?? "(空)"}。可用:${SUBCOMMANDS.map((s) => s.name).join("、")}`, "error"); + } +} + +async function runInteractiveMenu(ctx: ExtensionCommandContext, git: Git, settings: WorktreeSettings): Promise { + const choice = await ctx.ui.select( + "worktree 操作", + SUBCOMMANDS.map((sub) => `${sub.name} — ${sub.description}`), + ); + if (!choice) return; + const sub = choice.split(" ")[0]; + switch (sub) { + case "new": { + const slug = (await ctx.ui.input("worktree 名称(slug)"))?.trim(); + if (!slug) return; + await cmdNew(ctx, git, settings, { positional: [slug], flags: new Map() }); + return; + } + case "merge": + await cmdMerge(ctx, git, { positional: [], flags: new Map() }); + return; + case "switch": + await cmdSwitch(ctx, { positional: [], flags: new Map() }); + return; + case "list": + await cmdList(ctx, git); + return; + case "remove": + await cmdRemove(ctx, git, { positional: [], flags: new Map() }); + return; + case "prune": + await cmdPrune(ctx, git); + return; + case "reopen": + await cmdReopen(ctx, git, { positional: [], flags: new Map() }); + return; + } +} + +export async function argumentCompletions(argumentPrefix: string): Promise { + const trimmed = argumentPrefix.trimStart(); + const tokens = trimmed.split(/\s+/).filter(Boolean); + const completingFirst = !trimmed.includes(" ") || tokens.length === 0; + if (completingFirst) { + const prefix = tokens[0] ?? ""; + const matches = SUBCOMMANDS.filter((sub) => sub.name.startsWith(prefix)); + if (matches.length === 0) return null; + return matches.map((sub) => ({ value: sub.name, label: sub.name, description: sub.description })); + } + const sub = tokens[0]; + // Pi's applyCompletion replaces the whole argument text with item.value, so + // second-token completions must return the full argument line as value. + if (sub === "merge" && tokens.length <= 2) { + const prefix = tokens[1] ?? ""; + if (!prefix.startsWith("-")) return null; + const flags = ["--squash", "--keep", "--continue", "--abort"].filter((f) => f.startsWith(prefix)); + if (flags.length === 0) return null; + return flags.map((flag) => ({ value: `merge ${flag}`, label: flag })); + } + if ((sub === "switch" || sub === "remove" || sub === "reopen") && tokens.length <= 2) { + const prefix = tokens[1] ?? ""; + let state: WorktreeState; + try { + state = await loadState(); + } catch { + return null; + } + const items = state.worktrees + .map((entry) => entry.slug) + .filter((slug) => slug.startsWith(prefix)); + if (sub === "switch" && "main".startsWith(prefix)) items.unshift("main"); + if (items.length === 0) return null; + return items.map((slug) => ({ value: `${sub} ${slug}`, label: slug })); + } + return null; +} + +export function registerWorktreeCommands(pi: ExtensionAPI): void { + const handler = async (args: string, ctx: ExtensionCommandContext): Promise => { + const git = createGit(); + let settings: WorktreeSettings; + try { + settings = readWorktreeSettings(() => pi.getSettings()); + } catch (error) { + notifyError(ctx.ui, error); + return; + } + const tokens = args.split(/\s+/).filter(Boolean); + // Only pre-switch errors reach this boundary: once switchSession has run, + // this ctx is stale — the switch paths report through the new ctx. + try { + if (tokens.length === 0) { + if (!requireUI(ctx)) return; + await runInteractiveMenu(ctx, git, settings); + return; + } + const sub = tokens[0]; + if (MUTATING.has(sub) && !requireUI(ctx)) return; + await dispatch(ctx, git, settings, tokens); + } catch (error) { + notifyError(ctx.ui, error); + } + }; + + const options = { + description: "git worktree 一键拆分 / 合并管理", + getArgumentCompletions: argumentCompletions, + handler, + }; + pi.registerCommand("wt", options); + pi.registerCommand("worktree", options); + + let stateErrorWarned = false; + pi.on("session_start", async (_event, ctx: ExtensionContext) => { + try { + const text = await statusText(ctx.cwd); + ctx.ui.setStatus("worktree", text); + } catch { + if (!stateErrorWarned) { + stateErrorWarned = true; + ctx.ui.notify("pi-worktree 状态文件读取失败,已跳过 worktree 状态显示", "warning"); + } + } + }); +} diff --git a/pi-worktree/src/git.ts b/pi-worktree/src/git.ts new file mode 100644 index 0000000..6fae274 --- /dev/null +++ b/pi-worktree/src/git.ts @@ -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; + +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((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; + getCurrentBranch(cwd: string): Promise; + getHeadSha(cwd: string): Promise; + revParse(cwd: string, ref: string): Promise; + branchExists(cwd: string, name: string): Promise; + listWorktrees(cwd: string): Promise; + getMainWorktreePath(cwd: string): Promise; + isDirty(cwd: string): Promise; + statusEntries(cwd: string): Promise; + isTracked(cwd: string, rel: string): Promise; + isIgnored(cwd: string, rel: string): Promise; + excludePath(cwd: string, rel: string): Promise; + commitAll(cwd: string, message: string): Promise; + aheadCount(cwd: string, base: string, head: string): Promise; + diffStat(cwd: string, base: string, head: string): Promise; + addWorktree(cwd: string, path: string, branch: string, startPoint: string): Promise; + removeWorktree(cwd: string, path: string, force: boolean): Promise; + deleteBranch(cwd: string, name: string, force: boolean): Promise; + pruneWorktrees(cwd: string): Promise; + stashPush(cwd: string, message: string): Promise; + stashPop(cwd: string): Promise; + rebase(cwd: string, onto: string): Promise; + rebaseInProgress(cwd: string): Promise; + rebaseContinue(cwd: string): Promise; + rebaseAbort(cwd: string): Promise; + unmergedFiles(cwd: string): Promise; + mergeFfOnly(cwd: string, branch: string): Promise; + mergeSquash(cwd: string, branch: string, message: string): Promise; + isAncestor(cwd: string, a: string, b: string): Promise; +} + +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 => { + 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 (
/.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; +} diff --git a/pi-worktree/src/session.ts b/pi-worktree/src/session.ts new file mode 100644 index 0000000..97e5175 --- /dev/null +++ b/pi-worktree/src/session.ts @@ -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, +): 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); + }, + }); +} diff --git a/pi-worktree/src/settings.ts b/pi-worktree/src/settings.ts new file mode 100644 index 0000000..24f3fe8 --- /dev/null +++ b/pi-worktree/src/settings.ts @@ -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: 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 }; +} diff --git a/pi-worktree/src/setup.ts b/pi-worktree/src/setup.ts new file mode 100644 index 0000000..df63670 --- /dev/null +++ b/pi-worktree/src/setup.ts @@ -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; + 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; + 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)) { + 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; + 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, + envFile: envFileRel, + portBase, + postCreate, + }; +} + +/** Reads /.pi/worktree.json; undefined when the file is absent. */ +export async function loadSetupConfig(sourcePath: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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" }; +} diff --git a/pi-worktree/src/slug.ts b/pi-worktree/src/slug.ts new file mode 100644 index 0000000..70a6658 --- /dev/null +++ b/pi-worktree/src/slug.ts @@ -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}`; +} diff --git a/pi-worktree/src/state.ts b/pi-worktree/src/state.ts new file mode 100644 index 0000000..3e53183 --- /dev/null +++ b/pi-worktree/src/state.ts @@ -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 { + 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 { + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Load-mutate-save under an exclusive lock file (`.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( + mutate: (state: WorktreeState) => T | Promise, + path: string = stateFilePath(), + options: { timeoutMs?: number; staleMs?: number } = {}, +): Promise { + 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 { + let real: string; + try { + real = await realpath(cwd); + } catch { + return undefined; + } + return state.worktrees.find((entry) => real === entry.path || real.startsWith(`${entry.path}${sep}`)); +} diff --git a/pi-worktree/test/commands.test.ts b/pi-worktree/test/commands.test.ts new file mode 100644 index 0000000..8a44f09 --- /dev/null +++ b/pi-worktree/test/commands.test.ts @@ -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 { + 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" }], + ); +}); diff --git a/pi-worktree/test/git.test.ts b/pi-worktree/test/git.test.ts new file mode 100644 index 0000000..70d4282 --- /dev/null +++ b/pi-worktree/test/git.test.ts @@ -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 /repo as a git repo; sibling dirs under 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 { + 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 { + 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); +}); diff --git a/pi-worktree/test/session.test.ts b/pi-worktree/test/session.test.ts new file mode 100644 index 0000000..3576b38 --- /dev/null +++ b/pi-worktree/test/session.test.ts @@ -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 }) => { + 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 }) => { + await opts.withSession({ sendMessage: async () => sent++ }); + return { cancelled: false }; + }, + }; + await switchToCwd(ctx as never, targetCwd, async () => undefined); + assert.equal(sent, 0); +}); diff --git a/pi-worktree/test/setup.test.ts b/pi-worktree/test/setup.test.ts new file mode 100644 index 0000000..ba99697 --- /dev/null +++ b/pi-worktree/test/setup.test.ts @@ -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 { + 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 { + 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 { + 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"))); +}); diff --git a/pi-worktree/test/slug.test.ts b/pi-worktree/test/slug.test.ts new file mode 100644 index 0000000..50cc40a --- /dev/null +++ b/pi-worktree/test/slug.test.ts @@ -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"); +}); diff --git a/pi-worktree/test/state.test.ts b/pi-worktree/test/state.test.ts new file mode 100644 index 0000000..e745057 --- /dev/null +++ b/pi-worktree/test/state.test.ts @@ -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 { + 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); + } +}); diff --git a/tests/extension-load.test.ts b/tests/extension-load.test.ts index c65db64..dc55615 100644 --- a/tests/extension-load.test.ts +++ b/tests/extension-load.test.ts @@ -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");