@maxiaochao/pi-worktree 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +18 -3
- package/TESTING.md +7 -2
- package/bin/pi-worktree +118 -23
- package/bin/pi-worktree-completion.bash +2 -2
- package/extensions/worktree.ts +241 -0
- package/package.json +37 -8
- package/skills/pi-worktree/SKILL.md +11 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2026-10-09 - v0.3.0
|
|
4
|
+
|
|
5
|
+
- automatically move Agent conversations into prepared worktrees without external Harness support
|
|
6
|
+
- 修复 `enter_worktree` 只创建目录却不切换的问题;现在 Extension 会在 `agent_settled` 后自动 fork 并切换当前会话,不再依赖外部 Harness 消费协议。
|
|
7
|
+
- 移除 v0.2.0 的 `pi-worktree/switch-request` 常量及 `kind/version/action/sessionFile` 输出字段;自定义 Harness 若曾消费该协议,需要改用 Extension 内置自动切换。
|
|
8
|
+
|
|
9
|
+
## 2026-10-09 - v0.2.0
|
|
10
|
+
|
|
11
|
+
- add current-session worktree switching for users, Agents, and SDK Harnesses
|
|
12
|
+
- 新增 `/worktree start` 用户命令,在当前 Pi 界面中把持久会话 fork 并切换到 worktree。
|
|
13
|
+
- 新增 `enter_worktree` Agent Tool 和 `pi-worktree/switch-request` Harness 协议。
|
|
14
|
+
- 新增幂等的 `pi-worktree prepare --json` 机器接口,安全处理创建、挂载、复用、dirty 和 stale 状态。
|
|
15
|
+
|
|
3
16
|
## 2026-09-30 - v0.1.0
|
|
4
17
|
|
|
5
18
|
- 将 pi-worktree CLI、bash completion 和 Pi skill 合并为可独立安装的子包。
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @maxiaochao/pi-worktree
|
|
2
2
|
|
|
3
|
-
`pi-worktree` CLI 与配套 Pi
|
|
3
|
+
`pi-worktree` CLI、Pi Extension 与配套 skill。它既能启动独立 Pi,也能让用户或 Agent 在当前 Pi 界面中切换 worktree,并保留完整对话。
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
|
|
@@ -14,16 +14,28 @@
|
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
16
|
pi-worktree start feature -p "implement the feature"
|
|
17
|
+
pi-worktree prepare --json feature
|
|
17
18
|
pi-worktree info feature
|
|
18
19
|
pi-worktree out
|
|
19
20
|
pi-worktree remove feature
|
|
20
21
|
```
|
|
21
22
|
|
|
22
|
-
`start` 从当前分支创建或复用 `.worktrees/feature`,记录 base,并在该目录启动 Pi。
|
|
23
|
+
`start` 从当前分支创建或复用 `.worktrees/feature`,记录 base,并在该目录启动 Pi。`prepare` 复用同一创建逻辑,但只返回目标,不启动嵌套 Pi。
|
|
24
|
+
|
|
25
|
+
在交互式 Pi 中,用户可以保留当前界面和完整对话:
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
/worktree start feature
|
|
29
|
+
/worktree start feature --base main
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
命令会把当前持久会话 fork 到目标 worktree,再通过 Pi 的公开 session replacement API 切换。临时的 `--no-session` 会话无法使用此入口。
|
|
33
|
+
|
|
34
|
+
Agent 使用 Extension 注册的 `enter_worktree` tool。它创建或复用目标、终止当前工具轮,并在 `agent_settled` 后自动触发内部 `/worktree` 命令。Extension 随后执行 `SessionManager.forkFrom()` 和 `switchSession()`;目标 cwd 会写入新 session header,当前界面继续显示完整对话,重启后也仍然有效。整个流程由 Extension 完成,不需要外部 Harness 适配。
|
|
23
35
|
|
|
24
36
|
## 安装
|
|
25
37
|
|
|
26
|
-
完整工具包已经包含 CLI 和 skill:
|
|
38
|
+
完整工具包已经包含 CLI、Extension 和 skill:
|
|
27
39
|
|
|
28
40
|
```bash
|
|
29
41
|
pi install npm:@maxiaochao/pi-toolkit
|
|
@@ -50,6 +62,7 @@ pi update npm:@maxiaochao/pi-worktree # 更新独立子包
|
|
|
50
62
|
|
|
51
63
|
```bash
|
|
52
64
|
pi-worktree start <name> [pi args...]
|
|
65
|
+
pi-worktree prepare [--base <ref>] [--json] <name>
|
|
53
66
|
pi-worktree list
|
|
54
67
|
pi-worktree info <name>
|
|
55
68
|
pi-worktree out
|
|
@@ -57,4 +70,6 @@ pi-worktree remove <name>
|
|
|
57
70
|
pi-worktree prune
|
|
58
71
|
```
|
|
59
72
|
|
|
73
|
+
`prepare --json` 是 Extension 和其他宿主使用的幂等机器接口,返回 `created`、`attached`、`reused` 或 `already-active`,以及绝对路径、分支和 dirty 状态。已有 worktree 会复用;已存在但未挂载的同名分支会挂载;stale 注册、非法名称和路径冲突会显式失败。
|
|
74
|
+
|
|
60
75
|
Agent 用法见 `skills/pi-worktree/SKILL.md`。测试流程、夹具和结果见 [TESTING.md](TESTING.md)。
|
package/TESTING.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## 流程
|
|
4
4
|
|
|
5
|
-
1. `npm --prefix packages/worktree test`:在临时 Git
|
|
5
|
+
1. `npm --prefix packages/worktree test`:在临时 Git 仓库中运行 CLI,并验证 Extension 注册、延迟命令桥接和 session 切换生命周期。
|
|
6
6
|
2. `npm pack ./packages/worktree --dry-run`:确认 CLI、completion、skill 和安装脚本进入 tarball。
|
|
7
7
|
3. 手工验收发布包的 `pi-worktree start` 交互流程。
|
|
8
8
|
4. 确认 README 示例、真实临时 Git 仓库输出 `docs/screenshot.png` 和 `docs/demo.gif` 进入 tarball。
|
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
|
|
12
12
|
- `--help` 输出命令说明。
|
|
13
13
|
- `start` 从当前分支创建 `.worktrees/<name>` 并记录 base。
|
|
14
|
+
- `prepare --json` 覆盖 created、reused、already-active、dirty 和非法名称安全失败。
|
|
15
|
+
- `/worktree start` 把历史 fork 到 cwd 正确的新 session,再调用 `switchSession`。
|
|
16
|
+
- `enter_worktree` 串行准备目标,在 `agent_settled` 后触发内部命令,并 fork 当前 session 后调用 `switchSession`。
|
|
17
|
+
- Agent 轮被中止时不触发切换;用户取消 session replacement 时删除派生 session。
|
|
18
|
+
- 自定义 session 目录在 fork 后保持不变。
|
|
14
19
|
- 假 Pi 收到正确工作目录和参数。
|
|
15
20
|
- `list` 标记主 checkout 和子 worktree。
|
|
16
21
|
- `info` 报告 clean、branch、base 和 ahead。
|
|
@@ -18,4 +23,4 @@
|
|
|
18
23
|
|
|
19
24
|
## 最近验证结果
|
|
20
25
|
|
|
21
|
-
|
|
26
|
+
验收要求全部 Node/shell 断言通过,Extension 能被 Pi 单独加载,且 `npm pack --dry-run` 包含 `bin/`、`extensions/`、`skills/` 和 `scripts/install.mjs`。
|
package/bin/pi-worktree
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
# Usage:
|
|
5
5
|
# pi-worktree start <name> [pi args...] # enter worktree <name> and start pi
|
|
6
6
|
# pi-worktree start --base <ref> <name> [pi args...] # branch from <ref> instead of current branch
|
|
7
|
+
# pi-worktree prepare [--base <ref>] [--json] <name> # create/reuse without launching pi
|
|
7
8
|
# pi-worktree -l | --list # list worktrees (main checkout marked "main checkout")
|
|
8
9
|
# pi-worktree -i <name> # show base commit, ahead/behind, unique commits
|
|
9
10
|
# pi-worktree -o | --out # cd back to the main checkout
|
|
@@ -18,7 +19,7 @@ else
|
|
|
18
19
|
C_BOLD='' C_DIM='' C_RED='' C_GREEN='' C_YELLOW='' C_CYAN='' C_RESET=''
|
|
19
20
|
fi
|
|
20
21
|
|
|
21
|
-
ok() { printf '%spi-worktree:%s %s\n' "$C_GREEN" "$C_RESET" "$*"; }
|
|
22
|
+
ok() { [[ "${QUIET:-0}" == 1 ]] || printf '%spi-worktree:%s %s\n' "$C_GREEN" "$C_RESET" "$*"; }
|
|
22
23
|
warn() { printf '%s⚠️ %s%s\n' "$C_YELLOW" "$C_RESET" "$*"; }
|
|
23
24
|
die() { printf '%spi-worktree: %s%s\n' "$C_RED" "$C_RESET" "$*" >&2; exit 1; }
|
|
24
25
|
|
|
@@ -40,6 +41,12 @@ in_linked_worktree() {
|
|
|
40
41
|
|
|
41
42
|
wt_dir() { echo ".worktrees/$1"; }
|
|
42
43
|
|
|
44
|
+
validate_name() {
|
|
45
|
+
local name="$1"
|
|
46
|
+
[[ "$name" != */* ]] || die "worktree name must not contain '/': $name"
|
|
47
|
+
git check-ref-format --branch "$name" >/dev/null 2>&1 || die "invalid worktree/branch name: $name"
|
|
48
|
+
}
|
|
49
|
+
|
|
43
50
|
# Best available base ref: current branch > HEAD (worktrees should branch
|
|
44
51
|
# from where you actually are; a stale origin/main must not win)
|
|
45
52
|
pick_base() {
|
|
@@ -63,12 +70,14 @@ resolve_base() {
|
|
|
63
70
|
}
|
|
64
71
|
|
|
65
72
|
ensure_gitignore() {
|
|
73
|
+
local root
|
|
74
|
+
root=$(main_checkout)
|
|
66
75
|
# The trailing slash is load-bearing: `.worktrees/` is a directory-only
|
|
67
76
|
# pattern, and git check-ignore cannot match it against a path that does not
|
|
68
77
|
# exist yet — so `.worktrees` would look unignored on every first run and the
|
|
69
78
|
# rule would be appended again.
|
|
70
|
-
if ! git check-ignore -q .worktrees/ 2>/dev/null; then
|
|
71
|
-
printf '.worktrees/\n' >>
|
|
79
|
+
if ! git -C "$root" check-ignore -q .worktrees/ 2>/dev/null; then
|
|
80
|
+
printf '.worktrees/\n' >> "$root/.gitignore"
|
|
72
81
|
ok "added .worktrees/ to .gitignore"
|
|
73
82
|
fi
|
|
74
83
|
}
|
|
@@ -226,55 +235,140 @@ cmd_remove() {
|
|
|
226
235
|
fi
|
|
227
236
|
}
|
|
228
237
|
|
|
229
|
-
|
|
230
|
-
local name="$1"
|
|
238
|
+
prepare_wt() {
|
|
239
|
+
local name="$1"
|
|
231
240
|
require_repo
|
|
241
|
+
validate_name "$name"
|
|
232
242
|
|
|
233
|
-
#
|
|
243
|
+
# A linked checkout cannot safely create another `.worktrees/` tree relative
|
|
244
|
+
# to itself. Re-entering the active branch is nevertheless idempotent.
|
|
234
245
|
if in_linked_worktree; then
|
|
235
|
-
|
|
236
|
-
|
|
246
|
+
local current_branch
|
|
247
|
+
current_branch=$(git branch --show-current)
|
|
248
|
+
[[ "$current_branch" == "$name" ]] \
|
|
249
|
+
|| die "already inside linked worktree '$current_branch'; return to the main checkout first"
|
|
250
|
+
PREPARE_STATUS="already-active"
|
|
251
|
+
PREPARE_DIR=$(pwd -P)
|
|
252
|
+
PREPARE_BRANCH="$current_branch"
|
|
253
|
+
PREPARE_DIRTY=false
|
|
254
|
+
if [[ -n "$(git status --porcelain)" ]]; then PREPARE_DIRTY=true; fi
|
|
255
|
+
return 0
|
|
237
256
|
fi
|
|
238
257
|
|
|
239
|
-
local
|
|
258
|
+
local root worktrees_dir dir
|
|
259
|
+
root=$(main_checkout)
|
|
260
|
+
worktrees_dir="$root/.worktrees"
|
|
261
|
+
dir="$worktrees_dir/$name"
|
|
262
|
+
[[ ! -L "$worktrees_dir" ]] || die "$worktrees_dir must be a real directory, not a symlink"
|
|
263
|
+
[[ ! -L "$dir" ]] || die "$dir must not be a symlink"
|
|
240
264
|
|
|
241
265
|
if [[ -d "$dir" ]]; then
|
|
242
266
|
# Reuse: must be a registered worktree for this repo.
|
|
243
267
|
# Skip the dirty-workspace prompt — the worktree already exists, nothing new to create.
|
|
268
|
+
local registered_path
|
|
269
|
+
registered_path=$(git worktree list --porcelain | sed -n 's/^worktree //p' | grep -Fx "$dir" || true)
|
|
270
|
+
[[ -n "$registered_path" ]] || die "$dir exists but is not a registered git worktree"
|
|
244
271
|
git -C "$dir" rev-parse --git-dir >/dev/null 2>&1 || die "$dir exists but is not a git worktree"
|
|
272
|
+
[[ "$(git -C "$dir" rev-parse --path-format=absolute --git-common-dir)" == "$(git rev-parse --path-format=absolute --git-common-dir)" ]] \
|
|
273
|
+
|| die "$dir belongs to a different git repository"
|
|
274
|
+
[[ "$(git -C "$dir" branch --show-current)" == "$name" ]] \
|
|
275
|
+
|| die "$dir is not checked out on expected branch '$name'"
|
|
245
276
|
ensure_gitignore
|
|
246
277
|
ok "reusing existing worktree $dir"
|
|
278
|
+
PREPARE_STATUS="reused"
|
|
247
279
|
else
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
[[ "$ans" =~ ^[Yy]$ ]] || die "aborted"
|
|
254
|
-
fi
|
|
255
|
-
|
|
256
|
-
# After the check above, so the tool's own .gitignore edit is not reported
|
|
257
|
-
# as (and does not prompt about) the user's uncommitted work.
|
|
280
|
+
local target_abs registered_path
|
|
281
|
+
target_abs="$dir"
|
|
282
|
+
registered_path=$(git worktree list --porcelain | sed -n 's/^worktree //p' | grep -Fx "$target_abs" || true)
|
|
283
|
+
[[ -z "$registered_path" ]] \
|
|
284
|
+
|| die "stale worktree registration for $dir; run 'pi-worktree prune' first"
|
|
258
285
|
ensure_gitignore
|
|
259
|
-
mkdir -p
|
|
286
|
+
mkdir -p "$worktrees_dir"
|
|
260
287
|
# Reuse branch if it exists, else create it
|
|
261
288
|
if git show-ref --verify --quiet "refs/heads/$name"; then
|
|
262
|
-
|
|
289
|
+
if [[ "${QUIET:-0}" == 1 ]]; then
|
|
290
|
+
git worktree add "$dir" "$name" >/dev/null
|
|
291
|
+
else
|
|
292
|
+
git worktree add "$dir" "$name"
|
|
293
|
+
fi
|
|
263
294
|
ok "checked out existing branch '$name'"
|
|
295
|
+
PREPARE_STATUS="attached"
|
|
264
296
|
# record a best-effort base for -i display
|
|
265
297
|
local mb; mb=$(git merge-base main "$name" 2>/dev/null || true)
|
|
266
298
|
[[ -n "$mb" ]] && git config "branch.$name.base" "$(git rev-parse --short "$mb")" \
|
|
267
299
|
&& git config "branch.$name.baseRef" main
|
|
268
300
|
else
|
|
269
301
|
local base; base=$(resolve_base)
|
|
270
|
-
|
|
302
|
+
if [[ "${QUIET:-0}" == 1 ]]; then
|
|
303
|
+
git worktree add "$dir" -b "$name" "$base" >/dev/null
|
|
304
|
+
else
|
|
305
|
+
git worktree add "$dir" -b "$name" "$base"
|
|
306
|
+
fi
|
|
271
307
|
git config "branch.$name.base" "$(git rev-parse --short "$base")"
|
|
272
308
|
git config "branch.$name.baseRef" "$base"
|
|
273
309
|
ok "created branch '$name' from $base @ $(git rev-parse --short "$base")"
|
|
310
|
+
PREPARE_STATUS="created"
|
|
274
311
|
fi
|
|
275
312
|
fi
|
|
276
313
|
|
|
277
|
-
cd "$dir"
|
|
314
|
+
PREPARE_DIR=$(cd "$dir" && pwd -P)
|
|
315
|
+
PREPARE_BRANCH=$(git -C "$dir" branch --show-current)
|
|
316
|
+
PREPARE_DIRTY=false
|
|
317
|
+
if [[ -n "$(git -C "$dir" status --porcelain)" ]]; then PREPARE_DIRTY=true; fi
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
print_prepare_json() {
|
|
321
|
+
PI_WORKTREE_STATUS="$PREPARE_STATUS" \
|
|
322
|
+
PI_WORKTREE_PATH="$PREPARE_DIR" \
|
|
323
|
+
PI_WORKTREE_BRANCH="$PREPARE_BRANCH" \
|
|
324
|
+
PI_WORKTREE_DIRTY="$PREPARE_DIRTY" \
|
|
325
|
+
node -e 'process.stdout.write(JSON.stringify({status:process.env.PI_WORKTREE_STATUS,path:process.env.PI_WORKTREE_PATH,branch:process.env.PI_WORKTREE_BRANCH,dirty:process.env.PI_WORKTREE_DIRTY==="true"})+"\n")'
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
cmd_prepare() {
|
|
329
|
+
BASE_OVERRIDE=""
|
|
330
|
+
local json=0
|
|
331
|
+
while [[ $# -gt 0 ]]; do
|
|
332
|
+
case "$1" in
|
|
333
|
+
--base)
|
|
334
|
+
shift; [[ $# -ge 1 ]] || die "usage: pi-worktree prepare [--base <ref>] [--json] <name>"
|
|
335
|
+
BASE_OVERRIDE="$1"; shift ;;
|
|
336
|
+
--json) json=1; QUIET=1; shift ;;
|
|
337
|
+
--*) die "unknown prepare option: $1" ;;
|
|
338
|
+
*) break ;;
|
|
339
|
+
esac
|
|
340
|
+
done
|
|
341
|
+
[[ $# -eq 1 ]] || die "usage: pi-worktree prepare [--base <ref>] [--json] <name>"
|
|
342
|
+
prepare_wt "$1"
|
|
343
|
+
if [[ "$json" == 1 ]]; then
|
|
344
|
+
print_prepare_json
|
|
345
|
+
else
|
|
346
|
+
ok "$PREPARE_STATUS worktree: $PREPARE_DIR ($PREPARE_BRANCH)"
|
|
347
|
+
fi
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
cd_wt() {
|
|
351
|
+
local name="$1"; shift
|
|
352
|
+
require_repo
|
|
353
|
+
|
|
354
|
+
# Already isolated? Preserve the original start behavior and launch Pi here.
|
|
355
|
+
if in_linked_worktree; then
|
|
356
|
+
ok "already in an isolated worktree ($(pwd)), launching pi here"
|
|
357
|
+
exec pi "$@"
|
|
358
|
+
fi
|
|
359
|
+
|
|
360
|
+
# Dirty workspace: uncommitted changes do NOT follow into the new worktree.
|
|
361
|
+
local dir; dir="$(main_checkout)/.worktrees/$name"
|
|
362
|
+
if [[ ! -d "$dir" ]] && { ! git diff --quiet || ! git diff --cached --quiet || [[ -n "$(git ls-files --others --exclude-standard)" ]]; }; then
|
|
363
|
+
warn "Uncommitted changes stay in THIS directory; they will NOT appear in the worktree:"
|
|
364
|
+
git status --short | head -20
|
|
365
|
+
read -rp "Continue? [y/N] " ans
|
|
366
|
+
[[ "$ans" =~ ^[Yy]$ ]] || die "aborted"
|
|
367
|
+
fi
|
|
368
|
+
|
|
369
|
+
prepare_wt "$name"
|
|
370
|
+
|
|
371
|
+
cd "$PREPARE_DIR"
|
|
278
372
|
exec pi "$@"
|
|
279
373
|
}
|
|
280
374
|
|
|
@@ -285,6 +379,7 @@ case "${1:-}" in
|
|
|
285
379
|
-r|--remove|rm|remove) shift; cmd_remove "$@" ;;
|
|
286
380
|
-p|--prune|prune) shift; subcmd_prune ;;
|
|
287
381
|
-h|--help|help) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//' ;;
|
|
382
|
+
prepare) shift; cmd_prepare "$@" ;;
|
|
288
383
|
start) shift
|
|
289
384
|
BASE_OVERRIDE=""
|
|
290
385
|
if [[ "${1:-}" == "--base" ]]; then
|
|
@@ -4,7 +4,7 @@ _pi_worktree() {
|
|
|
4
4
|
cur="${COMP_WORDS[COMP_CWORD]}"
|
|
5
5
|
first="${COMP_WORDS[1]}"
|
|
6
6
|
|
|
7
|
-
local SUBS="start list ls info out remove rm prune -l --list -i --info -o --out -r --remove -p --prune -h --help"
|
|
7
|
+
local SUBS="start prepare list ls info out remove rm prune -l --list -i --info -o --out -r --remove -p --prune -h --help"
|
|
8
8
|
|
|
9
9
|
# existing worktree names (only linked worktrees, i.e. under .worktrees/)
|
|
10
10
|
local wts=""
|
|
@@ -23,7 +23,7 @@ _pi_worktree() {
|
|
|
23
23
|
fi
|
|
24
24
|
|
|
25
25
|
case "$first" in
|
|
26
|
-
-i|--info|info|-r|--remove|remove|rm|start)
|
|
26
|
+
-i|--info|info|-r|--remove|remove|rm|start|prepare)
|
|
27
27
|
# only the second word of info/remove completes worktree names;
|
|
28
28
|
# later words offer nothing (info/remove take no further args)
|
|
29
29
|
if (( COMP_CWORD == 2 )); then
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { unlinkSync } from "node:fs";
|
|
4
|
+
import { SessionManager, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
|
|
7
|
+
const CLI_PATH = fileURLToPath(new URL("../bin/pi-worktree", import.meta.url));
|
|
8
|
+
const PENDING_SWITCH_ARGUMENT = "__enter_worktree_pending__";
|
|
9
|
+
|
|
10
|
+
export interface WorktreePrepareResult {
|
|
11
|
+
status: "created" | "attached" | "reused" | "already-active";
|
|
12
|
+
path: string;
|
|
13
|
+
branch: string;
|
|
14
|
+
dirty: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface EnterWorktreeResult extends WorktreePrepareResult {
|
|
18
|
+
switch: "scheduled" | "already-active";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const EnterWorktreeResultSchema = Type.Object({
|
|
22
|
+
switch: Type.Union([Type.Literal("scheduled"), Type.Literal("already-active")]),
|
|
23
|
+
status: Type.Union([
|
|
24
|
+
Type.Literal("created"),
|
|
25
|
+
Type.Literal("attached"),
|
|
26
|
+
Type.Literal("reused"),
|
|
27
|
+
Type.Literal("already-active"),
|
|
28
|
+
]),
|
|
29
|
+
path: Type.String(),
|
|
30
|
+
branch: Type.String(),
|
|
31
|
+
dirty: Type.Boolean(),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function parseCommandArgs(input: string): { name: string; base?: string } {
|
|
35
|
+
const tokens = input.trim().split(/\s+/).filter(Boolean);
|
|
36
|
+
if (tokens[0] !== "start" || tokens.length < 2) {
|
|
37
|
+
throw new Error("用法:/worktree start <name> [--base <ref>]");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const name = tokens[1];
|
|
41
|
+
let base: string | undefined;
|
|
42
|
+
for (let index = 2; index < tokens.length; index += 1) {
|
|
43
|
+
if (tokens[index] !== "--base" || !tokens[index + 1] || base !== undefined) {
|
|
44
|
+
throw new Error("用法:/worktree start <name> [--base <ref>]");
|
|
45
|
+
}
|
|
46
|
+
base = tokens[index + 1];
|
|
47
|
+
index += 1;
|
|
48
|
+
}
|
|
49
|
+
return { name, base };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parsePrepareOutput(stdout: string): WorktreePrepareResult {
|
|
53
|
+
const line = stdout.trim().split("\n").filter(Boolean).at(-1);
|
|
54
|
+
if (!line) throw new Error("pi-worktree prepare 没有返回结果");
|
|
55
|
+
const result = JSON.parse(line) as Partial<WorktreePrepareResult>;
|
|
56
|
+
if (
|
|
57
|
+
!["created", "attached", "reused", "already-active"].includes(String(result.status))
|
|
58
|
+
|| typeof result.path !== "string"
|
|
59
|
+
|| typeof result.branch !== "string"
|
|
60
|
+
|| typeof result.dirty !== "boolean"
|
|
61
|
+
) {
|
|
62
|
+
throw new Error("pi-worktree prepare 返回了无效结果");
|
|
63
|
+
}
|
|
64
|
+
return result as WorktreePrepareResult;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function prepareWorktree(
|
|
68
|
+
pi: ExtensionAPI,
|
|
69
|
+
cwd: string,
|
|
70
|
+
name: string,
|
|
71
|
+
base?: string,
|
|
72
|
+
signal?: AbortSignal,
|
|
73
|
+
): Promise<WorktreePrepareResult> {
|
|
74
|
+
const args = ["prepare", "--json"];
|
|
75
|
+
if (base) args.push("--base", base);
|
|
76
|
+
args.push(name);
|
|
77
|
+
const result = await pi.exec(CLI_PATH, args, { cwd, signal });
|
|
78
|
+
if (result.code !== 0) {
|
|
79
|
+
throw new Error(result.stderr.trim() || result.stdout.trim() || `pi-worktree prepare 失败(退出码 ${result.code})`);
|
|
80
|
+
}
|
|
81
|
+
if (signal?.aborted) throw new Error("worktree 创建已取消");
|
|
82
|
+
return parsePrepareOutput(result.stdout);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export default function worktreeExtension(pi: ExtensionAPI) {
|
|
86
|
+
let pendingSwitch: WorktreePrepareResult | undefined;
|
|
87
|
+
let pendingDispatchScheduled = false;
|
|
88
|
+
let transitionReserved = false;
|
|
89
|
+
|
|
90
|
+
pi.on("agent_settled", event => {
|
|
91
|
+
if (!pendingSwitch || pendingDispatchScheduled) return;
|
|
92
|
+
if (event.aborted) {
|
|
93
|
+
pendingSwitch = undefined;
|
|
94
|
+
pendingDispatchScheduled = false;
|
|
95
|
+
transitionReserved = false;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
pendingDispatchScheduled = true;
|
|
99
|
+
// AgentSession defers prompts submitted from agent_settled until the event
|
|
100
|
+
// finishes. Command expansion then supplies a safe command context with
|
|
101
|
+
// switchSession(), after the tool result is persisted and the run is idle.
|
|
102
|
+
pi.sendUserMessage(`/worktree ${PENDING_SWITCH_ARGUMENT}`, {
|
|
103
|
+
expandPromptTemplates: true,
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
pi.registerCommand("worktree", {
|
|
108
|
+
description: "创建或复用 worktree,并在当前界面切换过去",
|
|
109
|
+
handler: async (input, ctx) => {
|
|
110
|
+
const isPendingSwitch = input.trim() === PENDING_SWITCH_ARGUMENT;
|
|
111
|
+
let prepared: WorktreePrepareResult;
|
|
112
|
+
let targetSessionFile: string;
|
|
113
|
+
try {
|
|
114
|
+
if (!isPendingSwitch && transitionReserved) {
|
|
115
|
+
ctx.ui.notify("Agent 正在切换 worktree,请等待切换完成后再执行 /worktree。", "warning");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (!isPendingSwitch) transitionReserved = true;
|
|
119
|
+
const requestedSwitch = isPendingSwitch ? pendingSwitch : undefined;
|
|
120
|
+
if (isPendingSwitch && !requestedSwitch) {
|
|
121
|
+
throw new Error("没有待处理的 Agent worktree 切换");
|
|
122
|
+
}
|
|
123
|
+
const sourceSessionFile = ctx.sessionManager.getSessionFile();
|
|
124
|
+
if (!sourceSessionFile) {
|
|
125
|
+
throw new Error("当前是临时会话;请启用 session 持久化后再切换 worktree");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
await ctx.waitForIdle();
|
|
129
|
+
if (requestedSwitch) {
|
|
130
|
+
prepared = requestedSwitch;
|
|
131
|
+
} else {
|
|
132
|
+
const { name, base } = parseCommandArgs(input);
|
|
133
|
+
prepared = await prepareWorktree(pi, ctx.cwd, name, base);
|
|
134
|
+
}
|
|
135
|
+
if (resolve(ctx.cwd) === resolve(prepared.path)) {
|
|
136
|
+
if (isPendingSwitch) {
|
|
137
|
+
pendingSwitch = undefined;
|
|
138
|
+
pendingDispatchScheduled = false;
|
|
139
|
+
}
|
|
140
|
+
transitionReserved = false;
|
|
141
|
+
ctx.ui.notify(`已经位于 worktree:${prepared.path}`, "info");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const sessionDir = ctx.sessionManager.usesDefaultSessionDir()
|
|
146
|
+
? undefined
|
|
147
|
+
: ctx.sessionManager.getSessionDir();
|
|
148
|
+
const target = SessionManager.forkFrom(sourceSessionFile, prepared.path, sessionDir);
|
|
149
|
+
targetSessionFile = target.getSessionFile() ?? "";
|
|
150
|
+
if (!targetSessionFile) throw new Error("无法为 worktree 创建持久会话");
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (isPendingSwitch) pendingSwitch = undefined;
|
|
153
|
+
pendingDispatchScheduled = false;
|
|
154
|
+
transitionReserved = false;
|
|
155
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Do not catch replacement errors here: switchSession may already have
|
|
160
|
+
// invalidated this command context, so Pi must report such failures.
|
|
161
|
+
let switched: { cancelled: boolean };
|
|
162
|
+
try {
|
|
163
|
+
switched = await ctx.switchSession(targetSessionFile, {
|
|
164
|
+
withSession: async (next) => {
|
|
165
|
+
const dirtyNote = prepared.dirty ? "(已有未提交改动)" : "";
|
|
166
|
+
next.ui.notify(`已切换到 ${prepared.branch}:${prepared.path}${dirtyNote}`, "info");
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
} catch (error) {
|
|
170
|
+
pendingSwitch = undefined;
|
|
171
|
+
pendingDispatchScheduled = false;
|
|
172
|
+
transitionReserved = false;
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
pendingSwitch = undefined;
|
|
176
|
+
pendingDispatchScheduled = false;
|
|
177
|
+
transitionReserved = false;
|
|
178
|
+
if (switched.cancelled) {
|
|
179
|
+
try {
|
|
180
|
+
unlinkSync(targetSessionFile);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
ctx.ui.notify(`worktree 会话切换已取消,但无法清理派生会话:${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
ctx.ui.notify("worktree 会话切换已取消", "warning");
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
pi.registerTool({
|
|
191
|
+
name: "enter_worktree",
|
|
192
|
+
label: "Enter Worktree",
|
|
193
|
+
exposure: "model-only",
|
|
194
|
+
executionMode: "sequential",
|
|
195
|
+
description:
|
|
196
|
+
"Create or reuse an isolated git worktree, then automatically move this current conversation into it after the current Agent run settles. Call this tool alone and do not call repository tools afterward.",
|
|
197
|
+
parameters: Type.Object({
|
|
198
|
+
name: Type.String({ description: "Single-segment worktree and branch name" }),
|
|
199
|
+
base: Type.Optional(Type.String({ description: "Optional git ref used only when creating a new branch" })),
|
|
200
|
+
}),
|
|
201
|
+
outputSchema: EnterWorktreeResultSchema,
|
|
202
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
203
|
+
if (!ctx.sessionManager.getSessionFile()) {
|
|
204
|
+
throw new Error("当前是临时会话,无法续接到 worktree");
|
|
205
|
+
}
|
|
206
|
+
if (transitionReserved) {
|
|
207
|
+
const target = pendingSwitch ? `:${pendingSwitch.path}` : "";
|
|
208
|
+
throw new Error(`已有 worktree 切换正在处理${target}`);
|
|
209
|
+
}
|
|
210
|
+
transitionReserved = true;
|
|
211
|
+
|
|
212
|
+
let prepared: WorktreePrepareResult;
|
|
213
|
+
try {
|
|
214
|
+
prepared = await prepareWorktree(pi, ctx.cwd, params.name, params.base, signal);
|
|
215
|
+
if (signal.aborted) throw new Error("worktree 创建已取消");
|
|
216
|
+
} catch (error) {
|
|
217
|
+
transitionReserved = false;
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
const alreadyActive = resolve(ctx.cwd) === resolve(prepared.path);
|
|
221
|
+
const result: EnterWorktreeResult = {
|
|
222
|
+
switch: alreadyActive ? "already-active" : "scheduled",
|
|
223
|
+
...prepared,
|
|
224
|
+
};
|
|
225
|
+
if (alreadyActive) transitionReserved = false;
|
|
226
|
+
else pendingSwitch = prepared;
|
|
227
|
+
const dirtyNote = prepared.dirty ? " 目标 worktree 已有未提交改动。" : "";
|
|
228
|
+
return {
|
|
229
|
+
content: [{
|
|
230
|
+
type: "text",
|
|
231
|
+
text: alreadyActive
|
|
232
|
+
? `已经位于 worktree:${prepared.path}.${dirtyNote}`
|
|
233
|
+
: `Worktree ${prepared.status}: ${prepared.path}.${dirtyNote} 当前 Agent run 结束后会自动切换到该目录;不要继续调用仓库工具。`,
|
|
234
|
+
}],
|
|
235
|
+
details: result,
|
|
236
|
+
structuredContent: result,
|
|
237
|
+
terminate: true,
|
|
238
|
+
};
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
}
|
package/package.json
CHANGED
|
@@ -1,20 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maxiaochao/pi-worktree",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Git worktree CLI and
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Git worktree CLI, Pi extension, and skill for isolated coding tasks.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"keywords": [
|
|
7
|
-
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi-skill",
|
|
9
|
+
"git",
|
|
10
|
+
"worktree"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"extensions",
|
|
15
|
+
"skills",
|
|
16
|
+
"scripts",
|
|
17
|
+
"docs",
|
|
18
|
+
"README.md",
|
|
19
|
+
"TESTING.md",
|
|
20
|
+
"CHANGELOG.md"
|
|
21
|
+
],
|
|
8
22
|
"repository": {
|
|
9
23
|
"type": "git",
|
|
10
24
|
"url": "git+https://github.com/chaochaoxiaochao/pi-toolkit.git",
|
|
11
25
|
"directory": "packages/worktree"
|
|
12
26
|
},
|
|
13
|
-
"bin": {
|
|
14
|
-
|
|
27
|
+
"bin": {
|
|
28
|
+
"pi-worktree": "bin/pi-worktree"
|
|
29
|
+
},
|
|
30
|
+
"pi": {
|
|
31
|
+
"extensions": [
|
|
32
|
+
"./extensions/worktree.ts"
|
|
33
|
+
],
|
|
34
|
+
"skills": [
|
|
35
|
+
"./skills"
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
40
|
+
"typebox": "*"
|
|
41
|
+
},
|
|
15
42
|
"scripts": {
|
|
16
|
-
"test": "node --test tests/install.test.mjs && bash tests/run-tests.sh",
|
|
43
|
+
"test": "node --test tests/install.test.mjs && node --experimental-strip-types --test tests/extension.test.mjs && bash tests/run-tests.sh",
|
|
17
44
|
"postinstall": "node scripts/install.mjs"
|
|
18
45
|
},
|
|
19
|
-
"publishConfig": {
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
}
|
|
20
49
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pi-worktree
|
|
3
|
-
description: Create, inspect, and clean up git worktrees
|
|
3
|
+
description: Create, enter, inspect, and clean up git worktrees through the enter_worktree tool, /worktree command, or pi-worktree CLI. Use when a task warrants isolation in its own worktree, or when the user asks to handle something the worktree way.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# pi-worktree
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Worktrees live in `.worktrees/<name>` (added to `.gitignore`), each with a branch of the same name, branched from the current branch (or `HEAD` when detached). The base commit is recorded in `branch.<name>.base` / `branch.<name>.baseRef` so `info` can report where the branch came from.
|
|
9
9
|
|
|
10
10
|
Installed by the package postinstall hook to `~/.local/bin/pi-worktree`, with bash completion for subcommands and existing worktree names.
|
|
11
11
|
|
|
@@ -13,6 +13,9 @@ Installed by the package postinstall hook to `~/.local/bin/pi-worktree`, with ba
|
|
|
13
13
|
|
|
14
14
|
| Command | Effect |
|
|
15
15
|
|---|---|
|
|
16
|
+
| `enter_worktree({ name, base? })` | Agent entrypoint: create/reuse a worktree and automatically move this conversation there after the run settles. Call it alone and stop after it returns. |
|
|
17
|
+
| `/worktree start <name> [--base <ref>]` | User entrypoint: fork the current persistent session into the worktree and switch the current Pi UI. |
|
|
18
|
+
| `pi-worktree prepare [--base <ref>] [--json] <name>` | Create/reuse without launching Pi; `--json` is the machine interface. |
|
|
16
19
|
| `pi-worktree start <name> [pi args...]` | Create or reuse `.worktrees/<name>`, then launch Pi inside it. Already inside a linked worktree, it just launches Pi there. |
|
|
17
20
|
| `pi-worktree start --base <ref> <name> [pi args...]` | Same, but branch from `<ref>` instead of the current branch. |
|
|
18
21
|
| `pi-worktree list` | All worktrees; the main checkout is marked `(main checkout)`, stale registrations are flagged. |
|
|
@@ -24,13 +27,15 @@ Installed by the package postinstall hook to `~/.local/bin/pi-worktree`, with ba
|
|
|
24
27
|
## Isolating a task
|
|
25
28
|
|
|
26
29
|
1. **Survey** — `pi-worktree list`, plus `pi-worktree info <name>` when the name you want is already taken. Reuse is free; a removed worktree is gone for good, because `remove` deletes its branch.
|
|
27
|
-
2. **
|
|
30
|
+
2. **Enter** — as an Agent, call `enter_worktree` once and stop; the Extension switches automatically after settlement. A user can instead run `/worktree start <name>`. Use the CLI `start` only when a separate Pi process is wanted.
|
|
28
31
|
3. **Work** — inside the worktree: commit there, and check `pi-worktree info <name>` before claiming progress. `ahead: N commits since base` is the evidence.
|
|
29
32
|
4. **Land** — `pi-worktree out`, then `git merge <name>` in the main checkout. Done when the worktree's unique commits are in the target branch.
|
|
30
33
|
5. **Clean up** — `pi-worktree remove <name>`. A merged branch goes without prompting; anything unmerged asks once.
|
|
31
34
|
|
|
32
|
-
##
|
|
35
|
+
## Entry-point rules
|
|
33
36
|
|
|
34
|
-
- `
|
|
37
|
+
- Prefer `enter_worktree` when it is available. After the tool returns, do not read or modify repository files; the Extension waits for `agent_settled`, then forks the session into the target cwd and switches automatically.
|
|
38
|
+
- `/worktree` is user-facing and cannot be invoked by the model. It requires a persisted session because it forks history into a session whose header owns the worktree cwd.
|
|
39
|
+
- `pi-worktree start` **execs a new `pi`** in the worktree, and `out` execs a shell. Use this only for an intentionally separate process/session.
|
|
40
|
+
- `prepare` is idempotent. It reports whether the target was created, attached, reused, or already active, and reports dirty reuse rather than discarding anything.
|
|
35
41
|
- `remove` asks before discarding dirty files or unmerged commits, and a non-interactive stdin (EOF) aborts safely. Answer explicitly when the removal is intended: `printf 'y\ny\n' | pi-worktree remove <name>`.
|
|
36
|
-
- Your own working directory cannot change. "Isolate this in a worktree" therefore means launching a nested headless Pi in the worktree, or telling the user to run `pi-worktree start <name>`.
|