@maxiaochao/pi-toolkit 0.10.24 → 0.11.1
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/evaluations/agents-md/README.md +19 -0
- package/evaluations/agents-md/run.mjs +217 -0
- package/evaluations/agents-md/tests/run-tests.mjs +52 -0
- package/global/AGENTS.md +13 -8
- package/package.json +2 -1
- package/packages/herdr-subagents/ARCHITECTURE.md +407 -0
- package/packages/herdr-subagents/CHANGELOG.md +4 -0
- package/packages/herdr-subagents/README.md +2 -0
- package/packages/herdr-subagents/package.json +2 -1
- 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
|
@@ -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'
|