@maxiaochao/pi-toolkit 0.10.23 → 0.11.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 +10 -0
- package/package.json +1 -1
- package/packages/herdr-subagents/CHANGELOG.md +4 -0
- package/packages/herdr-subagents/package.json +1 -1
- package/packages/herdr-subagents/tests/run-tests.mjs +25 -3
- package/packages/worktree/CHANGELOG.md +5 -0
- package/packages/worktree/README.md +6 -7
- package/packages/worktree/TESTING.md +5 -5
- package/packages/worktree/bin/pi-worktree +31 -66
- package/packages/worktree/bin/pi-worktree-completion.bash +4 -4
- package/packages/worktree/docs/demo.gif +0 -0
- package/packages/worktree/docs/screenshot.png +0 -0
- package/packages/worktree/extensions/worktree.ts +30 -30
- package/packages/worktree/package.json +1 -1
- package/packages/worktree/skills/pi-worktree/SKILL.md +3 -6
- package/packages/worktree/tests/extension.test.mjs +2 -2
- package/packages/worktree/tests/run-tests.sh +28 -32
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
|
+
## 2026-10-12 - v0.11.0
|
|
2
|
+
|
|
3
|
+
- bundle worktree v0.4.0: Replace CLI start and prepare with the create-only command
|
|
4
|
+
- Replace `pi-worktree start` and `pi-worktree prepare` with the create-only `pi-worktree create` CLI; `/worktree start` and `enter_worktree` continue to switch the current Pi session.
|
|
5
|
+
|
|
6
|
+
## 2026-10-12 - v0.10.24
|
|
7
|
+
|
|
8
|
+
- bundle herdr-subagents v0.4.10: Stabilize concurrency checks and cancellation synchronization on slower CI runners.
|
|
9
|
+
|
|
1
10
|
## 2026-10-12 - v0.10.23
|
|
2
11
|
|
|
3
12
|
- bundle herdr-subagents v0.4.9: Stabilize concurrency checks on slower CI runners.
|
|
13
|
+
|
|
4
14
|
## 2026-10-12 - v0.10.22
|
|
5
15
|
|
|
6
16
|
- bundle herdr-subagents v0.4.8: make blocking behave identically in serial and parallel runs and let respond continue a named failed task from its saved session
|
package/package.json
CHANGED
|
@@ -671,10 +671,32 @@ try {
|
|
|
671
671
|
check("the parallel run closes only after its last blocked lane is answered", !secondOfBoth.isError && secondOfBoth.details.status === "completed" && secondOfBoth.details.tasks.every((entry) => entry.status === "completed") && bothBlockedHerdr.calls.filter(([name]) => name === "closeTab").length === 1);
|
|
672
672
|
const mixedCancelHerdr = fakeHerdr();
|
|
673
673
|
const mixedCancelPrompt = mixedCancelHerdr.promptAgent.bind(mixedCancelHerdr);
|
|
674
|
-
|
|
674
|
+
let markMixedFailureSettled;
|
|
675
|
+
let markMixedWaitStarted;
|
|
676
|
+
const mixedFailureSettled = new Promise((resolve) => { markMixedFailureSettled = resolve; });
|
|
677
|
+
const mixedWaitStarted = new Promise((resolve) => { markMixedWaitStarted = resolve; });
|
|
678
|
+
mixedCancelHerdr.promptAgent = async (request) => {
|
|
679
|
+
if (request.prompt.includes("WAIT")) {
|
|
680
|
+
markMixedWaitStarted();
|
|
681
|
+
if (!request.signal) throw new Error("WAIT prompt requires an AbortSignal");
|
|
682
|
+
return await new Promise((_resolve, reject) => {
|
|
683
|
+
if (request.signal.aborted) reject(request.signal.reason);
|
|
684
|
+
else request.signal.addEventListener("abort", () => reject(request.signal.reason), { once: true });
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
return mixedCancelPrompt(request);
|
|
688
|
+
};
|
|
675
689
|
const mixedCancelController = new AbortController();
|
|
676
|
-
const mixedCancelPromise = executeHerdrSubagents(
|
|
677
|
-
|
|
690
|
+
const mixedCancelPromise = executeHerdrSubagents(
|
|
691
|
+
{ concurrency: 2, tasks: [{ name: "fail-first", prompt: "FAIL", agent: "explorer" }, { name: "cancel-second", prompt: "WAIT", agent: "reviewer" }] },
|
|
692
|
+
mixedCancelController.signal,
|
|
693
|
+
(update) => {
|
|
694
|
+
if (update.details?.tasks?.some((task) => task.status === "failed")) markMixedFailureSettled();
|
|
695
|
+
},
|
|
696
|
+
{ cwd: root },
|
|
697
|
+
{ agentsDirectory: join(process.cwd(), "agents"), herdr: mixedCancelHerdr },
|
|
698
|
+
);
|
|
699
|
+
await Promise.all([mixedFailureSettled, mixedWaitStarted]);
|
|
678
700
|
mixedCancelController.abort(new Error("Parent Pi session closed."));
|
|
679
701
|
const mixedCancel = await mixedCancelPromise;
|
|
680
702
|
check("failed and cancelled production tasks aggregate to partial", mixedCancel.details.status === "partial" && mixedCancel.details.tasks.some((task) => task.status === "failed") && mixedCancel.details.tasks.some((task) => task.status === "cancelled"));
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2026-10-12 - v0.4.0
|
|
4
|
+
|
|
5
|
+
- Replace CLI start and prepare with the create-only command
|
|
6
|
+
- replace the CLI `start` and `prepare` commands with one idempotent `create` command that never launches Pi
|
|
7
|
+
|
|
3
8
|
## 2026-10-09 - v0.3.2
|
|
4
9
|
|
|
5
10
|
- continue Agent tasks automatically after switching into a worktree
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @maxiaochao/pi-worktree
|
|
2
2
|
|
|
3
|
-
`pi-worktree` CLI、Pi Extension 与配套 skill
|
|
3
|
+
`pi-worktree` CLI、Pi Extension 与配套 skill。CLI 负责管理 worktree;Extension 让用户或 Agent 在当前 Pi 界面中切换,并保留完整对话。
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
|
|
@@ -13,14 +13,14 @@
|
|
|
13
13
|

|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
pi-worktree
|
|
17
|
-
pi-worktree
|
|
16
|
+
pi-worktree create feature
|
|
17
|
+
pi-worktree create --base main --json feature
|
|
18
18
|
pi-worktree info feature
|
|
19
19
|
pi-worktree out
|
|
20
20
|
pi-worktree remove feature
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
`
|
|
23
|
+
`create` 从当前分支(或显式 `--base`)创建或复用 `.worktrees/feature` 并记录 base,不启动 Pi。`--json` 提供相同操作的机器可读结果。
|
|
24
24
|
|
|
25
25
|
在交互式 Pi 中,用户可以保留当前界面和完整对话:
|
|
26
26
|
|
|
@@ -61,8 +61,7 @@ pi update npm:@maxiaochao/pi-worktree # 更新独立子包
|
|
|
61
61
|
## 命令
|
|
62
62
|
|
|
63
63
|
```bash
|
|
64
|
-
pi-worktree
|
|
65
|
-
pi-worktree prepare [--base <ref>] [--json] <name>
|
|
64
|
+
pi-worktree create [--base <ref>] [--json] <name>
|
|
66
65
|
pi-worktree list
|
|
67
66
|
pi-worktree info <name>
|
|
68
67
|
pi-worktree out
|
|
@@ -70,6 +69,6 @@ pi-worktree remove <name>
|
|
|
70
69
|
pi-worktree prune
|
|
71
70
|
```
|
|
72
71
|
|
|
73
|
-
`
|
|
72
|
+
`create` 是幂等接口;`create --json` 供 Extension 和其他宿主使用,返回 `created`、`attached`、`reused` 或 `already-active`,以及绝对路径、分支和 dirty 状态。已有 worktree 会复用;已存在但未挂载的同名分支会挂载;stale 注册、非法名称和路径冲突会显式失败。
|
|
74
73
|
|
|
75
74
|
Agent 用法见 `skills/pi-worktree/SKILL.md`。测试流程、夹具和结果见 [TESTING.md](TESTING.md)。
|
|
@@ -4,19 +4,19 @@
|
|
|
4
4
|
|
|
5
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
|
-
3. 手工验收发布包的 `pi-worktree
|
|
7
|
+
3. 手工验收发布包的 `pi-worktree create` 创建与复用流程。
|
|
8
8
|
4. 确认 README 示例、真实临时 Git 仓库输出 `docs/screenshot.png` 和 `docs/demo.gif` 进入 tarball。
|
|
9
9
|
|
|
10
10
|
## 用例数据
|
|
11
11
|
|
|
12
12
|
- `--help` 输出命令说明。
|
|
13
|
-
- `
|
|
14
|
-
- `
|
|
13
|
+
- `create` 从当前分支创建 `.worktrees/<name>` 并记录 base。
|
|
14
|
+
- `create --json` 覆盖 created、reused、already-active、dirty 和非法名称安全失败。
|
|
15
15
|
- `/worktree start` 把历史 fork 到 cwd 正确的新 session,再调用 `switchSession`。
|
|
16
|
-
- `enter_worktree`
|
|
16
|
+
- `enter_worktree` 串行创建或复用目标,在 `agent_settled` 后触发内部命令,并 fork 当前 session 后调用 `switchSession`。
|
|
17
17
|
- Agent 轮被中止时不触发切换;用户取消 session replacement 时删除派生 session。
|
|
18
18
|
- 自定义 session 目录在 fork 后保持不变。
|
|
19
|
-
-
|
|
19
|
+
- 已删除的 CLI `start` 和 `prepare` 会显式失败。
|
|
20
20
|
- `list` 标记主 checkout 和子 worktree。
|
|
21
21
|
- `info` 报告 clean、branch、base 和 ahead。
|
|
22
22
|
- postinstall 把可执行 CLI 与 completion 复制到隔离 HOME,并保持执行权限。
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# pi-worktree: create
|
|
2
|
+
# pi-worktree: create, inspect, and clean up git worktrees.
|
|
3
3
|
#
|
|
4
4
|
# Usage:
|
|
5
|
-
# pi-worktree
|
|
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
|
|
5
|
+
# pi-worktree create [--base <ref>] [--json] <name> # create/reuse without launching pi
|
|
8
6
|
# pi-worktree -l | --list # list worktrees (main checkout marked "main checkout")
|
|
9
7
|
# pi-worktree -i <name> # show base commit, ahead/behind, unique commits
|
|
10
8
|
# pi-worktree -o | --out # cd back to the main checkout
|
|
@@ -229,13 +227,13 @@ cmd_remove() {
|
|
|
229
227
|
if [[ "$ans" =~ ^[Yy]$ ]]; then
|
|
230
228
|
git branch -D "$branch" && ok "deleted branch: $branch"
|
|
231
229
|
else
|
|
232
|
-
warn "branch '$branch' kept; 'pi-worktree
|
|
230
|
+
warn "branch '$branch' kept; 'pi-worktree create $branch' will reuse it as-is"
|
|
233
231
|
fi
|
|
234
232
|
fi
|
|
235
233
|
fi
|
|
236
234
|
}
|
|
237
235
|
|
|
238
|
-
|
|
236
|
+
create_wt() {
|
|
239
237
|
local name="$1"
|
|
240
238
|
require_repo
|
|
241
239
|
validate_name "$name"
|
|
@@ -247,11 +245,11 @@ prepare_wt() {
|
|
|
247
245
|
current_branch=$(git branch --show-current)
|
|
248
246
|
[[ "$current_branch" == "$name" ]] \
|
|
249
247
|
|| die "already inside linked worktree '$current_branch'; return to the main checkout first"
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if [[ -n "$(git status --porcelain)" ]]; then
|
|
248
|
+
CREATE_STATUS="already-active"
|
|
249
|
+
CREATE_DIR=$(pwd -P)
|
|
250
|
+
CREATE_BRANCH="$current_branch"
|
|
251
|
+
CREATE_DIRTY=false
|
|
252
|
+
if [[ -n "$(git status --porcelain)" ]]; then CREATE_DIRTY=true; fi
|
|
255
253
|
return 0
|
|
256
254
|
fi
|
|
257
255
|
|
|
@@ -275,7 +273,7 @@ prepare_wt() {
|
|
|
275
273
|
|| die "$dir is not checked out on expected branch '$name'"
|
|
276
274
|
ensure_gitignore
|
|
277
275
|
ok "reusing existing worktree $dir"
|
|
278
|
-
|
|
276
|
+
CREATE_STATUS="reused"
|
|
279
277
|
else
|
|
280
278
|
local target_abs registered_path
|
|
281
279
|
target_abs="$dir"
|
|
@@ -292,7 +290,7 @@ prepare_wt() {
|
|
|
292
290
|
git worktree add "$dir" "$name"
|
|
293
291
|
fi
|
|
294
292
|
ok "checked out existing branch '$name'"
|
|
295
|
-
|
|
293
|
+
CREATE_STATUS="attached"
|
|
296
294
|
# record a best-effort base for -i display
|
|
297
295
|
local mb; mb=$(git merge-base main "$name" 2>/dev/null || true)
|
|
298
296
|
[[ -n "$mb" ]] && git config "branch.$name.base" "$(git rev-parse --short "$mb")" \
|
|
@@ -307,71 +305,46 @@ prepare_wt() {
|
|
|
307
305
|
git config "branch.$name.base" "$(git rev-parse --short "$base")"
|
|
308
306
|
git config "branch.$name.baseRef" "$base"
|
|
309
307
|
ok "created branch '$name' from $base @ $(git rev-parse --short "$base")"
|
|
310
|
-
|
|
308
|
+
CREATE_STATUS="created"
|
|
311
309
|
fi
|
|
312
310
|
fi
|
|
313
311
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
if [[ -n "$(git -C "$dir" status --porcelain)" ]]; then
|
|
312
|
+
CREATE_DIR=$(cd "$dir" && pwd -P)
|
|
313
|
+
CREATE_BRANCH=$(git -C "$dir" branch --show-current)
|
|
314
|
+
CREATE_DIRTY=false
|
|
315
|
+
if [[ -n "$(git -C "$dir" status --porcelain)" ]]; then CREATE_DIRTY=true; fi
|
|
318
316
|
}
|
|
319
317
|
|
|
320
|
-
|
|
321
|
-
PI_WORKTREE_STATUS="$
|
|
322
|
-
PI_WORKTREE_PATH="$
|
|
323
|
-
PI_WORKTREE_BRANCH="$
|
|
324
|
-
PI_WORKTREE_DIRTY="$
|
|
318
|
+
print_create_json() {
|
|
319
|
+
PI_WORKTREE_STATUS="$CREATE_STATUS" \
|
|
320
|
+
PI_WORKTREE_PATH="$CREATE_DIR" \
|
|
321
|
+
PI_WORKTREE_BRANCH="$CREATE_BRANCH" \
|
|
322
|
+
PI_WORKTREE_DIRTY="$CREATE_DIRTY" \
|
|
325
323
|
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
324
|
}
|
|
327
325
|
|
|
328
|
-
|
|
326
|
+
cmd_create() {
|
|
329
327
|
BASE_OVERRIDE=""
|
|
330
328
|
local json=0
|
|
331
329
|
while [[ $# -gt 0 ]]; do
|
|
332
330
|
case "$1" in
|
|
333
331
|
--base)
|
|
334
|
-
shift; [[ $# -ge 1 ]] || die "usage: pi-worktree
|
|
332
|
+
shift; [[ $# -ge 1 ]] || die "usage: pi-worktree create [--base <ref>] [--json] <name>"
|
|
335
333
|
BASE_OVERRIDE="$1"; shift ;;
|
|
336
334
|
--json) json=1; QUIET=1; shift ;;
|
|
337
|
-
--*) die "unknown
|
|
335
|
+
--*) die "unknown create option: $1" ;;
|
|
338
336
|
*) break ;;
|
|
339
337
|
esac
|
|
340
338
|
done
|
|
341
|
-
[[ $# -eq 1 ]] || die "usage: pi-worktree
|
|
342
|
-
|
|
339
|
+
[[ $# -eq 1 ]] || die "usage: pi-worktree create [--base <ref>] [--json] <name>"
|
|
340
|
+
create_wt "$1"
|
|
343
341
|
if [[ "$json" == 1 ]]; then
|
|
344
|
-
|
|
342
|
+
print_create_json
|
|
345
343
|
else
|
|
346
|
-
ok "$
|
|
344
|
+
ok "$CREATE_STATUS worktree: $CREATE_DIR ($CREATE_BRANCH)"
|
|
347
345
|
fi
|
|
348
346
|
}
|
|
349
347
|
|
|
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"
|
|
372
|
-
exec pi "$@"
|
|
373
|
-
}
|
|
374
|
-
|
|
375
348
|
case "${1:-}" in
|
|
376
349
|
-l|--list|list|ls) shift; cmd_list "$@" ;;
|
|
377
350
|
-i|--info|info) shift; cmd_info "$@" ;;
|
|
@@ -379,15 +352,7 @@ case "${1:-}" in
|
|
|
379
352
|
-r|--remove|rm|remove) shift; cmd_remove "$@" ;;
|
|
380
353
|
-p|--prune|prune) shift; subcmd_prune ;;
|
|
381
354
|
-h|--help|help) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//' ;;
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
if [[ "${1:-}" == "--base" ]]; then
|
|
386
|
-
shift; [[ $# -ge 2 ]] || die "usage: pi-worktree start --base <ref> <name> [pi args...]"
|
|
387
|
-
BASE_OVERRIDE="$1"; shift
|
|
388
|
-
fi
|
|
389
|
-
[[ $# -ge 1 ]] || die "usage: pi-worktree start <name> [pi args...]"
|
|
390
|
-
name="$1"; shift; cd_wt "$name" "$@" ;;
|
|
391
|
-
"") die "usage: pi-worktree start <name> [pi args...] (or list / info / out / remove / prune)" ;;
|
|
392
|
-
*) die "unknown command: $1 (usage: pi-worktree start <name> [pi args...])" ;;
|
|
355
|
+
create) shift; cmd_create "$@" ;;
|
|
356
|
+
"") die "usage: pi-worktree create <name> (or list / info / out / remove / prune)" ;;
|
|
357
|
+
*) die "unknown command: $1 (usage: pi-worktree create <name>)" ;;
|
|
393
358
|
esac
|
|
@@ -4,7 +4,7 @@ _pi_worktree() {
|
|
|
4
4
|
cur="${COMP_WORDS[COMP_CWORD]}"
|
|
5
5
|
first="${COMP_WORDS[1]}"
|
|
6
6
|
|
|
7
|
-
local SUBS="
|
|
7
|
+
local SUBS="create 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=""
|
|
@@ -17,13 +17,13 @@ _pi_worktree() {
|
|
|
17
17
|
fi
|
|
18
18
|
|
|
19
19
|
if (( COMP_CWORD == 1 )); then
|
|
20
|
-
# first word: subcommands, flags, and existing worktree names
|
|
20
|
+
# first word: subcommands, flags, and existing worktree names
|
|
21
21
|
COMPREPLY=($(compgen -W "$SUBS $wts" -- "$cur"))
|
|
22
22
|
return
|
|
23
23
|
fi
|
|
24
24
|
|
|
25
25
|
case "$first" in
|
|
26
|
-
-i|--info|info|-r|--remove|remove|rm|
|
|
26
|
+
-i|--info|info|-r|--remove|remove|rm|create)
|
|
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
|
|
@@ -33,7 +33,7 @@ _pi_worktree() {
|
|
|
33
33
|
fi
|
|
34
34
|
;;
|
|
35
35
|
*)
|
|
36
|
-
#
|
|
36
|
+
# no command-specific completion
|
|
37
37
|
COMPREPLY=()
|
|
38
38
|
;;
|
|
39
39
|
esac
|
|
Binary file
|
|
Binary file
|
|
@@ -7,14 +7,14 @@ import { Type } from "typebox";
|
|
|
7
7
|
const CLI_PATH = fileURLToPath(new URL("../bin/pi-worktree", import.meta.url));
|
|
8
8
|
const PENDING_SWITCH_ARGUMENT = "__enter_worktree_pending__";
|
|
9
9
|
|
|
10
|
-
export interface
|
|
10
|
+
export interface WorktreeCreateResult {
|
|
11
11
|
status: "created" | "attached" | "reused" | "already-active";
|
|
12
12
|
path: string;
|
|
13
13
|
branch: string;
|
|
14
14
|
dirty: boolean;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
export interface EnterWorktreeResult extends
|
|
17
|
+
export interface EnterWorktreeResult extends WorktreeCreateResult {
|
|
18
18
|
switch: "scheduled" | "already-active";
|
|
19
19
|
}
|
|
20
20
|
|
|
@@ -49,41 +49,41 @@ function parseCommandArgs(input: string): { name: string; base?: string } {
|
|
|
49
49
|
return { name, base };
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
function
|
|
52
|
+
function parseCreateOutput(stdout: string): WorktreeCreateResult {
|
|
53
53
|
const line = stdout.trim().split("\n").filter(Boolean).at(-1);
|
|
54
|
-
if (!line) throw new Error("pi-worktree
|
|
55
|
-
const result = JSON.parse(line) as Partial<
|
|
54
|
+
if (!line) throw new Error("pi-worktree create 没有返回结果");
|
|
55
|
+
const result = JSON.parse(line) as Partial<WorktreeCreateResult>;
|
|
56
56
|
if (
|
|
57
57
|
!["created", "attached", "reused", "already-active"].includes(String(result.status))
|
|
58
58
|
|| typeof result.path !== "string"
|
|
59
59
|
|| typeof result.branch !== "string"
|
|
60
60
|
|| typeof result.dirty !== "boolean"
|
|
61
61
|
) {
|
|
62
|
-
throw new Error("pi-worktree
|
|
62
|
+
throw new Error("pi-worktree create 返回了无效结果");
|
|
63
63
|
}
|
|
64
|
-
return result as
|
|
64
|
+
return result as WorktreeCreateResult;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
async function
|
|
67
|
+
async function createWorktree(
|
|
68
68
|
pi: ExtensionAPI,
|
|
69
69
|
cwd: string,
|
|
70
70
|
name: string,
|
|
71
71
|
base?: string,
|
|
72
72
|
signal?: AbortSignal,
|
|
73
|
-
): Promise<
|
|
74
|
-
const args = ["
|
|
73
|
+
): Promise<WorktreeCreateResult> {
|
|
74
|
+
const args = ["create", "--json"];
|
|
75
75
|
if (base) args.push("--base", base);
|
|
76
76
|
args.push(name);
|
|
77
77
|
const result = await pi.exec(CLI_PATH, args, { cwd, signal });
|
|
78
78
|
if (result.code !== 0) {
|
|
79
|
-
throw new Error(result.stderr.trim() || result.stdout.trim() || `pi-worktree
|
|
79
|
+
throw new Error(result.stderr.trim() || result.stdout.trim() || `pi-worktree create 失败(退出码 ${result.code})`);
|
|
80
80
|
}
|
|
81
81
|
if (signal?.aborted) throw new Error("worktree 创建已取消");
|
|
82
|
-
return
|
|
82
|
+
return parseCreateOutput(result.stdout);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
export default function worktreeExtension(pi: ExtensionAPI) {
|
|
86
|
-
let pendingSwitch:
|
|
86
|
+
let pendingSwitch: WorktreeCreateResult | undefined;
|
|
87
87
|
let pendingDispatchScheduled = false;
|
|
88
88
|
let transitionReserved = false;
|
|
89
89
|
|
|
@@ -108,7 +108,7 @@ export default function worktreeExtension(pi: ExtensionAPI) {
|
|
|
108
108
|
description: "创建或复用 worktree,并在当前界面切换过去",
|
|
109
109
|
handler: async (input, ctx) => {
|
|
110
110
|
const isPendingSwitch = input.trim() === PENDING_SWITCH_ARGUMENT;
|
|
111
|
-
let
|
|
111
|
+
let created: WorktreeCreateResult;
|
|
112
112
|
let targetSessionFile: string;
|
|
113
113
|
try {
|
|
114
114
|
if (!isPendingSwitch && transitionReserved) {
|
|
@@ -127,25 +127,25 @@ export default function worktreeExtension(pi: ExtensionAPI) {
|
|
|
127
127
|
|
|
128
128
|
await ctx.waitForIdle();
|
|
129
129
|
if (requestedSwitch) {
|
|
130
|
-
|
|
130
|
+
created = requestedSwitch;
|
|
131
131
|
} else {
|
|
132
132
|
const { name, base } = parseCommandArgs(input);
|
|
133
|
-
|
|
133
|
+
created = await createWorktree(pi, ctx.cwd, name, base);
|
|
134
134
|
}
|
|
135
|
-
if (resolve(ctx.cwd) === resolve(
|
|
135
|
+
if (resolve(ctx.cwd) === resolve(created.path)) {
|
|
136
136
|
if (isPendingSwitch) {
|
|
137
137
|
pendingSwitch = undefined;
|
|
138
138
|
pendingDispatchScheduled = false;
|
|
139
139
|
}
|
|
140
140
|
transitionReserved = false;
|
|
141
|
-
ctx.ui.notify(`已经位于 worktree:${
|
|
141
|
+
ctx.ui.notify(`已经位于 worktree:${created.path}`, "info");
|
|
142
142
|
return;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
const sessionDir = ctx.sessionManager.usesDefaultSessionDir()
|
|
146
146
|
? undefined
|
|
147
147
|
: ctx.sessionManager.getSessionDir();
|
|
148
|
-
const target = SessionManager.forkFrom(sourceSessionFile,
|
|
148
|
+
const target = SessionManager.forkFrom(sourceSessionFile, created.path, sessionDir);
|
|
149
149
|
targetSessionFile = target.getSessionFile() ?? "";
|
|
150
150
|
if (!targetSessionFile) throw new Error("无法为 worktree 创建持久会话");
|
|
151
151
|
} catch (error) {
|
|
@@ -162,12 +162,12 @@ export default function worktreeExtension(pi: ExtensionAPI) {
|
|
|
162
162
|
try {
|
|
163
163
|
switched = await ctx.switchSession(targetSessionFile, {
|
|
164
164
|
withSession: async (next) => {
|
|
165
|
-
const dirtyNote =
|
|
165
|
+
const dirtyNote = created.dirty ? "(已有未提交改动)" : "";
|
|
166
166
|
await next.sendMessage({
|
|
167
167
|
customType: "worktree-switch",
|
|
168
|
-
content: `✓ 已进入 worktree:${
|
|
168
|
+
content: `✓ 已进入 worktree:${created.path}\nBranch: ${created.branch}${dirtyNote}\n\n在worktree继续任务`,
|
|
169
169
|
display: true,
|
|
170
|
-
details:
|
|
170
|
+
details: created,
|
|
171
171
|
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
172
172
|
},
|
|
173
173
|
});
|
|
@@ -214,28 +214,28 @@ export default function worktreeExtension(pi: ExtensionAPI) {
|
|
|
214
214
|
}
|
|
215
215
|
transitionReserved = true;
|
|
216
216
|
|
|
217
|
-
let
|
|
217
|
+
let created: WorktreeCreateResult;
|
|
218
218
|
try {
|
|
219
|
-
|
|
219
|
+
created = await createWorktree(pi, ctx.cwd, params.name, params.base, signal);
|
|
220
220
|
if (signal.aborted) throw new Error("worktree 创建已取消");
|
|
221
221
|
} catch (error) {
|
|
222
222
|
transitionReserved = false;
|
|
223
223
|
throw error;
|
|
224
224
|
}
|
|
225
|
-
const alreadyActive = resolve(ctx.cwd) === resolve(
|
|
225
|
+
const alreadyActive = resolve(ctx.cwd) === resolve(created.path);
|
|
226
226
|
const result: EnterWorktreeResult = {
|
|
227
227
|
switch: alreadyActive ? "already-active" : "scheduled",
|
|
228
|
-
...
|
|
228
|
+
...created,
|
|
229
229
|
};
|
|
230
230
|
if (alreadyActive) transitionReserved = false;
|
|
231
|
-
else pendingSwitch =
|
|
232
|
-
const dirtyNote =
|
|
231
|
+
else pendingSwitch = created;
|
|
232
|
+
const dirtyNote = created.dirty ? " 目标 worktree 已有未提交改动。" : "";
|
|
233
233
|
return {
|
|
234
234
|
content: [{
|
|
235
235
|
type: "text",
|
|
236
236
|
text: alreadyActive
|
|
237
|
-
? `已经位于 worktree:${
|
|
238
|
-
: `Worktree ${
|
|
237
|
+
? `已经位于 worktree:${created.path}.${dirtyNote}`
|
|
238
|
+
: `Worktree ${created.status}: ${created.path}.${dirtyNote} 当前 Agent run 结束后会自动切换到该目录;不要继续调用仓库工具。`,
|
|
239
239
|
}],
|
|
240
240
|
details: result,
|
|
241
241
|
structuredContent: result,
|
|
@@ -15,9 +15,7 @@ Installed by the package postinstall hook to `~/.local/bin/pi-worktree`, with ba
|
|
|
15
15
|
|---|---|
|
|
16
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
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
|
|
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. |
|
|
20
|
-
| `pi-worktree start --base <ref> <name> [pi args...]` | Same, but branch from `<ref>` instead of the current branch. |
|
|
18
|
+
| `pi-worktree create [--base <ref>] [--json] <name>` | Idempotently create/reuse without launching Pi; `--json` is the machine interface. |
|
|
21
19
|
| `pi-worktree list` | All worktrees; the main checkout is marked `(main checkout)`, stale registrations are flagged. |
|
|
22
20
|
| `pi-worktree info <name>` | Clean/dirty status, branch and HEAD, base commit, ahead/behind `main`, unique commits. |
|
|
23
21
|
| `pi-worktree out` | Open a shell in the main checkout (for merging). |
|
|
@@ -27,7 +25,7 @@ Installed by the package postinstall hook to `~/.local/bin/pi-worktree`, with ba
|
|
|
27
25
|
## Isolating a task
|
|
28
26
|
|
|
29
27
|
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.
|
|
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>`.
|
|
28
|
+
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>`.
|
|
31
29
|
3. **Work** — inside the worktree: commit there, and check `pi-worktree info <name>` before claiming progress. `ahead: N commits since base` is the evidence.
|
|
32
30
|
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.
|
|
33
31
|
5. **Clean up** — `pi-worktree remove <name>`. A merged branch goes without prompting; anything unmerged asks once.
|
|
@@ -36,6 +34,5 @@ Installed by the package postinstall hook to `~/.local/bin/pi-worktree`, with ba
|
|
|
36
34
|
|
|
37
35
|
- 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
36
|
- `/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
|
-
- `
|
|
40
|
-
- `prepare` is idempotent. It reports whether the target was created, attached, reused, or already active, and reports dirty reuse rather than discarding anything.
|
|
37
|
+
- `create` is idempotent and never launches Pi. It reports whether the target was created, attached, reused, or already active, and reports dirty reuse rather than discarding anything.
|
|
41
38
|
- `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>`.
|
|
@@ -140,7 +140,7 @@ test("public /worktree forwards --base to the CLI and switches", async () => {
|
|
|
140
140
|
try {
|
|
141
141
|
await f.handlers["command:worktree"]("start feature --base main", f.ctx);
|
|
142
142
|
assert.match(f.execCalls[0].command, /packages\/worktree\/bin\/pi-worktree$/);
|
|
143
|
-
assert.deepEqual(f.execCalls[0].args, ["
|
|
143
|
+
assert.deepEqual(f.execCalls[0].args, ["create", "--json", "--base", "main", "feature"]);
|
|
144
144
|
assert.equal(f.execCalls[0].options.cwd, f.cwd);
|
|
145
145
|
assert.ok(f.switchedTo());
|
|
146
146
|
} finally {
|
|
@@ -159,7 +159,7 @@ test("aborted agent settlement discards the scheduled switch", async () => {
|
|
|
159
159
|
}
|
|
160
160
|
});
|
|
161
161
|
|
|
162
|
-
test("a user command cannot race an Agent tool that is still
|
|
162
|
+
test("a user command cannot race an Agent tool that is still creating", async () => {
|
|
163
163
|
const f = await fixture({ deferExec: true });
|
|
164
164
|
try {
|
|
165
165
|
const toolResult = f.handlers.tool.execute(
|
|
@@ -5,14 +5,6 @@ PACKAGE_DIR=$(cd "$(dirname "$0")/.." && pwd)
|
|
|
5
5
|
ROOT=$(mktemp -d)
|
|
6
6
|
trap 'rm -rf "$ROOT"' EXIT
|
|
7
7
|
|
|
8
|
-
mkdir -p "$ROOT/bin"
|
|
9
|
-
cat > "$ROOT/bin/pi" <<'EOF'
|
|
10
|
-
#!/usr/bin/env bash
|
|
11
|
-
printf '%s\n' "$PWD" > "${PI_WORKTREE_TEST_OUTPUT}.cwd"
|
|
12
|
-
printf '%s\n' "$@" > "${PI_WORKTREE_TEST_OUTPUT}.args"
|
|
13
|
-
EOF
|
|
14
|
-
chmod +x "$ROOT/bin/pi"
|
|
15
|
-
|
|
16
8
|
mkdir "$ROOT/repo"
|
|
17
9
|
cd "$ROOT/repo"
|
|
18
10
|
git init -q -b main
|
|
@@ -23,15 +15,11 @@ printf 'base\n' > tracked.txt
|
|
|
23
15
|
git add .gitignore tracked.txt
|
|
24
16
|
git commit -qm initial
|
|
25
17
|
|
|
26
|
-
|
|
27
|
-
export PI_WORKTREE_TEST_OUTPUT="$ROOT/pi"
|
|
28
|
-
bash "$PACKAGE_DIR/bin/pi-worktree" start feature --model fake/model
|
|
18
|
+
feature_json=$(bash "$PACKAGE_DIR/bin/pi-worktree" create --json feature)
|
|
29
19
|
|
|
30
20
|
test -d .worktrees/feature
|
|
31
21
|
test "$(git -C .worktrees/feature branch --show-current)" = feature
|
|
32
|
-
|
|
33
|
-
grep -qx -- '--model' "$ROOT/pi.args"
|
|
34
|
-
grep -qx -- 'fake/model' "$ROOT/pi.args"
|
|
22
|
+
node -e 'if (JSON.parse(process.argv[1]).status !== "created") process.exit(1)' "$feature_json"
|
|
35
23
|
|
|
36
24
|
bash "$PACKAGE_DIR/bin/pi-worktree" list > "$ROOT/list.txt"
|
|
37
25
|
grep -q 'main checkout' "$ROOT/list.txt"
|
|
@@ -42,14 +30,14 @@ grep -q 'status: clean' "$ROOT/info.txt"
|
|
|
42
30
|
grep -q 'branch: feature' "$ROOT/info.txt"
|
|
43
31
|
grep -q 'based on:' "$ROOT/info.txt"
|
|
44
32
|
|
|
45
|
-
bash "$PACKAGE_DIR/bin/pi-worktree"
|
|
33
|
+
bash "$PACKAGE_DIR/bin/pi-worktree" create --json feature > "$ROOT/reused.json"
|
|
46
34
|
node -e '
|
|
47
35
|
const fs = require("node:fs");
|
|
48
36
|
const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
|
49
37
|
if (value.status !== "reused" || value.branch !== "feature" || value.dirty !== false) process.exit(1);
|
|
50
38
|
' "$ROOT/reused.json"
|
|
51
39
|
|
|
52
|
-
bash "$PACKAGE_DIR/bin/pi-worktree"
|
|
40
|
+
bash "$PACKAGE_DIR/bin/pi-worktree" create --json prepared > "$ROOT/created.json"
|
|
53
41
|
node -e '
|
|
54
42
|
const fs = require("node:fs");
|
|
55
43
|
const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
|
@@ -57,7 +45,7 @@ node -e '
|
|
|
57
45
|
' "$ROOT/created.json"
|
|
58
46
|
|
|
59
47
|
mkdir -p nested/path
|
|
60
|
-
(cd nested/path && bash "$PACKAGE_DIR/bin/pi-worktree"
|
|
48
|
+
(cd nested/path && bash "$PACKAGE_DIR/bin/pi-worktree" create --json from-subdir) > "$ROOT/subdir.json"
|
|
61
49
|
node -e '
|
|
62
50
|
const fs = require("node:fs");
|
|
63
51
|
const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
|
@@ -65,50 +53,50 @@ node -e '
|
|
|
65
53
|
' "$ROOT/subdir.json" "$ROOT/repo"
|
|
66
54
|
|
|
67
55
|
git branch attachable
|
|
68
|
-
bash "$PACKAGE_DIR/bin/pi-worktree"
|
|
56
|
+
bash "$PACKAGE_DIR/bin/pi-worktree" create --json attachable > "$ROOT/attached.json"
|
|
69
57
|
node -e '
|
|
70
58
|
const fs = require("node:fs");
|
|
71
59
|
const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
|
72
60
|
if (value.status !== "attached" || value.branch !== "attachable") process.exit(1);
|
|
73
61
|
' "$ROOT/attached.json"
|
|
74
62
|
|
|
75
|
-
bash "$PACKAGE_DIR/bin/pi-worktree"
|
|
63
|
+
bash "$PACKAGE_DIR/bin/pi-worktree" create --json stale > /dev/null
|
|
76
64
|
rm -rf .worktrees/stale
|
|
77
|
-
if bash "$PACKAGE_DIR/bin/pi-worktree"
|
|
78
|
-
echo "
|
|
65
|
+
if bash "$PACKAGE_DIR/bin/pi-worktree" create --json stale >/dev/null 2>&1; then
|
|
66
|
+
echo "create silently replaced a stale worktree registration" >&2
|
|
79
67
|
exit 1
|
|
80
68
|
fi
|
|
81
69
|
git worktree prune
|
|
82
70
|
|
|
83
71
|
git worktree add .worktrees/wrong -b another-branch >/dev/null
|
|
84
|
-
if bash "$PACKAGE_DIR/bin/pi-worktree"
|
|
85
|
-
echo "
|
|
72
|
+
if bash "$PACKAGE_DIR/bin/pi-worktree" create --json wrong >/dev/null 2>&1; then
|
|
73
|
+
echo "create reused a path checked out on the wrong branch" >&2
|
|
86
74
|
exit 1
|
|
87
75
|
fi
|
|
88
76
|
|
|
89
77
|
mkdir .worktrees/main
|
|
90
|
-
if bash "$PACKAGE_DIR/bin/pi-worktree"
|
|
91
|
-
echo "
|
|
78
|
+
if bash "$PACKAGE_DIR/bin/pi-worktree" create --json main >/dev/null 2>&1; then
|
|
79
|
+
echo "create reused an ordinary directory as a worktree"
|
|
92
80
|
exit 1
|
|
93
81
|
fi
|
|
94
82
|
|
|
95
83
|
printf 'changed\n' >> .worktrees/prepared/tracked.txt
|
|
96
|
-
bash "$PACKAGE_DIR/bin/pi-worktree"
|
|
84
|
+
bash "$PACKAGE_DIR/bin/pi-worktree" create --json prepared > "$ROOT/dirty.json"
|
|
97
85
|
node -e '
|
|
98
86
|
const fs = require("node:fs");
|
|
99
87
|
const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
|
100
88
|
if (value.status !== "reused" || value.dirty !== true) process.exit(1);
|
|
101
89
|
' "$ROOT/dirty.json"
|
|
102
90
|
|
|
103
|
-
(cd .worktrees/prepared && bash "$PACKAGE_DIR/bin/pi-worktree"
|
|
91
|
+
(cd .worktrees/prepared && bash "$PACKAGE_DIR/bin/pi-worktree" create --json prepared) > "$ROOT/active.json"
|
|
104
92
|
node -e '
|
|
105
93
|
const fs = require("node:fs");
|
|
106
94
|
const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
|
107
95
|
if (value.status !== "already-active" || value.dirty !== true) process.exit(1);
|
|
108
96
|
' "$ROOT/active.json"
|
|
109
97
|
|
|
110
|
-
if bash "$PACKAGE_DIR/bin/pi-worktree"
|
|
111
|
-
echo "
|
|
98
|
+
if bash "$PACKAGE_DIR/bin/pi-worktree" create --json ../escape >/dev/null 2>&1; then
|
|
99
|
+
echo "create accepted an unsafe worktree name" >&2
|
|
112
100
|
exit 1
|
|
113
101
|
fi
|
|
114
102
|
|
|
@@ -122,11 +110,19 @@ mkdir "$ROOT/symlink-repo" "$ROOT/outside-worktrees"
|
|
|
122
110
|
git add tracked.txt
|
|
123
111
|
git commit -qm initial
|
|
124
112
|
ln -s "$ROOT/outside-worktrees" .worktrees
|
|
125
|
-
if bash "$PACKAGE_DIR/bin/pi-worktree"
|
|
126
|
-
echo "
|
|
113
|
+
if bash "$PACKAGE_DIR/bin/pi-worktree" create --json escaped >/dev/null 2>&1; then
|
|
114
|
+
echo "create followed a symlinked .worktrees directory" >&2
|
|
127
115
|
exit 1
|
|
128
116
|
fi
|
|
129
117
|
)
|
|
130
118
|
|
|
131
|
-
bash "$PACKAGE_DIR/bin/pi-worktree" --help | grep -q 'pi-worktree
|
|
119
|
+
bash "$PACKAGE_DIR/bin/pi-worktree" --help | grep -q 'pi-worktree create'
|
|
120
|
+
if bash "$PACKAGE_DIR/bin/pi-worktree" prepare feature >/dev/null 2>&1; then
|
|
121
|
+
echo "removed prepare command is still accepted" >&2
|
|
122
|
+
exit 1
|
|
123
|
+
fi
|
|
124
|
+
if bash "$PACKAGE_DIR/bin/pi-worktree" start feature >/dev/null 2>&1; then
|
|
125
|
+
echo "removed start command is still accepted" >&2
|
|
126
|
+
exit 1
|
|
127
|
+
fi
|
|
132
128
|
printf 'PASS worktree CLI integration cases\n'
|