@maxiaochao/pi-toolkit 0.10.7 → 0.10.8

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.
Files changed (30) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +1 -1
  3. package/package.json +4 -3
  4. package/packages/herdr-subagents/CHANGELOG.md +7 -0
  5. package/packages/herdr-subagents/README.md +4 -2
  6. package/packages/herdr-subagents/TESTING.md +6 -1
  7. package/packages/herdr-subagents/extensions/herdr-subagents.ts +83 -19
  8. package/packages/herdr-subagents/src/batch-runner.ts +3 -1
  9. package/packages/herdr-subagents/src/dispatcher.ts +8 -1
  10. package/packages/herdr-subagents/tests/extension-contract.ts +198 -2
  11. package/packages/herdr-subagents/tests/run-tests.mjs +25 -0
  12. package/packages/scheduler/CHANGELOG.md +5 -0
  13. package/packages/scheduler/README.md +4 -2
  14. package/packages/scheduler/TESTING.md +1 -1
  15. package/packages/scheduler/docs/demo.gif +0 -0
  16. package/packages/scheduler/docs/screenshot.png +0 -0
  17. package/packages/scheduler/extensions/scheduler.ts +87 -14
  18. package/packages/scheduler/src/scheduler.ts +4 -2
  19. package/packages/scheduler/tests/scheduler-core.mjs +7 -0
  20. package/packages/scheduler/tests/scheduler-extension.mjs +38 -2
  21. package/packages/worktree/CHANGELOG.md +7 -0
  22. package/packages/worktree/README.md +18 -3
  23. package/packages/worktree/TESTING.md +5 -2
  24. package/packages/worktree/bin/pi-worktree +118 -23
  25. package/packages/worktree/bin/pi-worktree-completion.bash +2 -2
  26. package/packages/worktree/extensions/worktree.ts +173 -0
  27. package/packages/worktree/package.json +37 -8
  28. package/packages/worktree/skills/pi-worktree/SKILL.md +11 -6
  29. package/packages/worktree/tests/extension.test.mjs +114 -0
  30. package/packages/worktree/tests/run-tests.sh +86 -0
@@ -0,0 +1,173 @@
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
+
9
+ export const WORKTREE_SWITCH_REQUEST = "pi-worktree/switch-request";
10
+
11
+ export interface WorktreePrepareResult {
12
+ status: "created" | "attached" | "reused" | "already-active";
13
+ path: string;
14
+ branch: string;
15
+ dirty: boolean;
16
+ }
17
+
18
+ export interface WorktreeSwitchRequest extends WorktreePrepareResult {
19
+ kind: typeof WORKTREE_SWITCH_REQUEST;
20
+ version: 1;
21
+ action: "fork-and-switch";
22
+ sessionFile: string;
23
+ }
24
+
25
+ const SwitchRequestSchema = Type.Object({
26
+ kind: Type.Literal(WORKTREE_SWITCH_REQUEST),
27
+ version: Type.Literal(1),
28
+ action: Type.Literal("fork-and-switch"),
29
+ sessionFile: Type.String(),
30
+ status: Type.Union([
31
+ Type.Literal("created"),
32
+ Type.Literal("attached"),
33
+ Type.Literal("reused"),
34
+ Type.Literal("already-active"),
35
+ ]),
36
+ path: Type.String(),
37
+ branch: Type.String(),
38
+ dirty: Type.Boolean(),
39
+ });
40
+
41
+ function parseCommandArgs(input: string): { name: string; base?: string } {
42
+ const tokens = input.trim().split(/\s+/).filter(Boolean);
43
+ if (tokens[0] !== "start" || tokens.length < 2) {
44
+ throw new Error("用法:/worktree start <name> [--base <ref>]");
45
+ }
46
+
47
+ const name = tokens[1];
48
+ let base: string | undefined;
49
+ for (let index = 2; index < tokens.length; index += 1) {
50
+ if (tokens[index] !== "--base" || !tokens[index + 1] || base !== undefined) {
51
+ throw new Error("用法:/worktree start <name> [--base <ref>]");
52
+ }
53
+ base = tokens[index + 1];
54
+ index += 1;
55
+ }
56
+ return { name, base };
57
+ }
58
+
59
+ function parsePrepareOutput(stdout: string): WorktreePrepareResult {
60
+ const line = stdout.trim().split("\n").filter(Boolean).at(-1);
61
+ if (!line) throw new Error("pi-worktree prepare 没有返回结果");
62
+ const result = JSON.parse(line) as Partial<WorktreePrepareResult>;
63
+ if (
64
+ !["created", "attached", "reused", "already-active"].includes(String(result.status))
65
+ || typeof result.path !== "string"
66
+ || typeof result.branch !== "string"
67
+ || typeof result.dirty !== "boolean"
68
+ ) {
69
+ throw new Error("pi-worktree prepare 返回了无效结果");
70
+ }
71
+ return result as WorktreePrepareResult;
72
+ }
73
+
74
+ async function prepareWorktree(
75
+ pi: ExtensionAPI,
76
+ cwd: string,
77
+ name: string,
78
+ base?: string,
79
+ ): Promise<WorktreePrepareResult> {
80
+ const args = ["prepare", "--json"];
81
+ if (base) args.push("--base", base);
82
+ args.push(name);
83
+ const result = await pi.exec(CLI_PATH, args, { cwd });
84
+ if (result.code !== 0) {
85
+ throw new Error(result.stderr.trim() || result.stdout.trim() || `pi-worktree prepare 失败(退出码 ${result.code})`);
86
+ }
87
+ return parsePrepareOutput(result.stdout);
88
+ }
89
+
90
+ export default function worktreeExtension(pi: ExtensionAPI) {
91
+ pi.registerCommand("worktree", {
92
+ description: "创建或复用 worktree,并在当前界面切换过去",
93
+ handler: async (input, ctx) => {
94
+ let prepared: WorktreePrepareResult;
95
+ let targetSessionFile: string;
96
+ try {
97
+ const { name, base } = parseCommandArgs(input);
98
+ const sourceSessionFile = ctx.sessionManager.getSessionFile();
99
+ if (!sourceSessionFile) {
100
+ throw new Error("当前是临时会话;请启用 session 持久化后再切换 worktree");
101
+ }
102
+
103
+ await ctx.waitForIdle();
104
+ prepared = await prepareWorktree(pi, ctx.cwd, name, base);
105
+ if (resolve(ctx.cwd) === resolve(prepared.path)) {
106
+ ctx.ui.notify(`已经位于 worktree:${prepared.path}`, "info");
107
+ return;
108
+ }
109
+
110
+ const target = SessionManager.forkFrom(sourceSessionFile, prepared.path);
111
+ targetSessionFile = target.getSessionFile() ?? "";
112
+ if (!targetSessionFile) throw new Error("无法为 worktree 创建持久会话");
113
+ } catch (error) {
114
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
115
+ return;
116
+ }
117
+
118
+ // Do not catch replacement errors here: switchSession may already have
119
+ // invalidated this command context, so Pi must report such failures.
120
+ const switched = await ctx.switchSession(targetSessionFile, {
121
+ withSession: async (next) => {
122
+ const dirtyNote = prepared.dirty ? "(已有未提交改动)" : "";
123
+ next.ui.notify(`已切换到 ${prepared.branch}:${prepared.path}${dirtyNote}`, "success");
124
+ },
125
+ });
126
+ if (switched.cancelled) {
127
+ try {
128
+ unlinkSync(targetSessionFile);
129
+ } catch (error) {
130
+ ctx.ui.notify(`worktree 会话切换已取消,但无法清理派生会话:${error instanceof Error ? error.message : String(error)}`, "warning");
131
+ return;
132
+ }
133
+ ctx.ui.notify("worktree 会话切换已取消", "warning");
134
+ }
135
+ },
136
+ });
137
+
138
+ pi.registerTool({
139
+ name: "enter_worktree",
140
+ label: "Enter Worktree",
141
+ exposure: "model-only",
142
+ description:
143
+ "Create or reuse an isolated git worktree and request that the host Harness move this current conversation into it. Call this tool alone, then stop; the Harness performs the session switch after the tool completes.",
144
+ parameters: Type.Object({
145
+ name: Type.String({ description: "Single-segment worktree and branch name" }),
146
+ base: Type.Optional(Type.String({ description: "Optional git ref used only when creating a new branch" })),
147
+ }),
148
+ outputSchema: SwitchRequestSchema,
149
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
150
+ const sourceSessionFile = ctx.sessionManager.getSessionFile();
151
+ if (!sourceSessionFile) throw new Error("当前是临时会话,Harness 无法续接到 worktree");
152
+
153
+ const prepared = await prepareWorktree(pi, ctx.cwd, params.name, params.base);
154
+ const request: WorktreeSwitchRequest = {
155
+ kind: WORKTREE_SWITCH_REQUEST,
156
+ version: 1,
157
+ action: "fork-and-switch",
158
+ sessionFile: sourceSessionFile,
159
+ ...prepared,
160
+ };
161
+ const dirtyNote = prepared.dirty ? " 目标 worktree 已有未提交改动。" : "";
162
+ return {
163
+ content: [{
164
+ type: "text",
165
+ text: `Worktree ${prepared.status}: ${prepared.path}.${dirtyNote} 已请求 Harness 将当前会话切换到该目录;不要继续调用仓库工具。`,
166
+ }],
167
+ details: request,
168
+ structuredContent: request,
169
+ terminate: true,
170
+ };
171
+ },
172
+ });
173
+ }
@@ -1,20 +1,49 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-worktree",
3
- "version": "0.1.0",
4
- "description": "Git worktree CLI and Pi skill for isolated coding tasks.",
3
+ "version": "0.2.0",
4
+ "description": "Git worktree CLI, Pi extension, and skill for isolated coding tasks.",
5
5
  "type": "module",
6
- "keywords": ["pi-package", "pi-skill", "git", "worktree"],
7
- "files": ["bin", "skills", "scripts", "docs", "README.md", "TESTING.md", "CHANGELOG.md"],
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": { "pi-worktree": "bin/pi-worktree" },
14
- "pi": { "skills": ["./skills"] },
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": { "access": "public" }
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 with the pi-worktree CLI (start/list/info/remove/prune), which also launches Pi inside the worktree. Use when a task warrants isolation in its own worktree, or when the user asks to handle something the worktree way.
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
- `pi-worktree` wraps git worktree creation and Pi launch into one command. Worktrees live in `.worktrees/<name>` (added to `.gitignore`), each with a branch of the same name, branched from the branch you are on now (or `HEAD` when detached). The base commit is recorded in `branch.<name>.base` / `branch.<name>.baseRef` so `info` can always report where the branch came from.
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 ask a compatible Harness to move this conversation there. 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. **Start** — `pi-worktree start <name>` (with `--base <ref>` when the work must not start from the current branch). Done when `list` shows the new worktree on its own branch.
30
+ 2. **Enter** — as an Agent, call `enter_worktree` once and stop so the Harness can switch 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
- ## Driving it from an agent
35
+ ## Entry-point rules
33
36
 
34
- - `start` **execs `pi`** in the worktree, and `out` execs a shell. With no trailing Pi args both want an interactive terminal, so either run `start <name> -p "<task>"` for a headless nested run, or hand the bare `start <name>` to the user to run themselves.
37
+ - Prefer `enter_worktree` when it is available. Its `fork-and-switch` request requires Harness support: after `agent_settled`, the host forks the source session into the target cwd and switches to that persisted session. After the tool returns, do not read or modify repository files until the host has switched cwd.
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>`.
@@ -0,0 +1,114 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+
7
+ const root = await mkdtemp(join(tmpdir(), "pi-worktree-extension-"));
8
+ process.env.PI_CODING_AGENT_DIR = join(root, "agent");
9
+ const { default: register, WORKTREE_SWITCH_REQUEST } = await import("../extensions/worktree.ts");
10
+
11
+ test.after(async () => {
12
+ await rm(root, { recursive: true, force: true });
13
+ });
14
+
15
+ function setup(prepared) {
16
+ const commands = new Map();
17
+ const tools = new Map();
18
+ const pi = {
19
+ registerCommand(name, definition) { commands.set(name, definition); },
20
+ registerTool(definition) { tools.set(definition.name, definition); },
21
+ async exec(command, args, options) {
22
+ assert.match(command, /packages\/worktree\/bin\/pi-worktree$/);
23
+ assert.deepEqual(args, ["prepare", "--json", "--base", "main", "feature"]);
24
+ assert.equal(options.cwd, prepared.cwd);
25
+ return { code: 0, stdout: `${JSON.stringify(prepared.result)}\n`, stderr: "" };
26
+ },
27
+ };
28
+ register(pi);
29
+ return { commands, tools };
30
+ }
31
+
32
+ test("registers /worktree and switches a forked persistent session", async () => {
33
+ const cwd = join(root, "main");
34
+ const target = join(root, "main", ".worktrees", "feature");
35
+ await mkdir(target, { recursive: true });
36
+ const sourceSession = join(root, "source.jsonl");
37
+ await writeFile(sourceSession, `${JSON.stringify({ type: "session", version: 3, id: "source", timestamp: new Date().toISOString(), cwd })}\n`);
38
+ const prepared = { cwd, result: { status: "created", path: target, branch: "feature", dirty: false } };
39
+ const { commands } = setup(prepared);
40
+ const notifications = [];
41
+ let switchedFile;
42
+ await commands.get("worktree").handler("start feature --base main", {
43
+ cwd,
44
+ sessionManager: { getSessionFile: () => sourceSession },
45
+ waitForIdle: async () => {},
46
+ ui: { notify: (...args) => notifications.push(args) },
47
+ async switchSession(file, options) {
48
+ switchedFile = file;
49
+ await options.withSession({ ui: { notify: (...args) => notifications.push(args) } });
50
+ return { cancelled: false };
51
+ },
52
+ });
53
+
54
+ const header = JSON.parse((await readFile(switchedFile, "utf8")).split("\n")[0]);
55
+ assert.equal(header.cwd, target);
56
+ assert.equal(header.parentSession, sourceSession);
57
+ assert.deepEqual(notifications.at(-1), [`已切换到 feature:${target}`, "success"]);
58
+ });
59
+
60
+ test("removes the forked session when /worktree switching is cancelled", async () => {
61
+ const cwd = join(root, "cancel-main");
62
+ const target = join(cwd, ".worktrees", "feature");
63
+ await mkdir(target, { recursive: true });
64
+ const sourceSession = join(root, "cancel-source.jsonl");
65
+ await writeFile(sourceSession, `${JSON.stringify({ type: "session", version: 3, id: "cancel-source", timestamp: new Date().toISOString(), cwd })}\n`);
66
+ const prepared = { cwd, result: { status: "created", path: target, branch: "feature", dirty: false } };
67
+ const { commands } = setup(prepared);
68
+ const notifications = [];
69
+ let forkedFile;
70
+ await commands.get("worktree").handler("start feature --base main", {
71
+ cwd,
72
+ sessionManager: { getSessionFile: () => sourceSession },
73
+ waitForIdle: async () => {},
74
+ ui: { notify: (...args) => notifications.push(args) },
75
+ async switchSession(file) {
76
+ forkedFile = file;
77
+ return { cancelled: true };
78
+ },
79
+ });
80
+
81
+ await assert.rejects(readFile(forkedFile, "utf8"), { code: "ENOENT" });
82
+ assert.deepEqual(notifications.at(-1), ["worktree 会话切换已取消", "warning"]);
83
+ });
84
+
85
+ test("registers enter_worktree with a durable Harness switch request", async () => {
86
+ const cwd = join(root, "main");
87
+ const target = join(cwd, ".worktrees", "feature");
88
+ const sourceSession = join(root, "tool-source.jsonl");
89
+ const prepared = { cwd, result: { status: "reused", path: target, branch: "feature", dirty: true } };
90
+ const { tools } = setup(prepared);
91
+ const tool = tools.get("enter_worktree");
92
+ assert.equal(tool.exposure, "model-only");
93
+ assert.ok(tool.outputSchema);
94
+ const result = await tool.execute(
95
+ "call-1",
96
+ { name: "feature", base: "main" },
97
+ new AbortController().signal,
98
+ undefined,
99
+ { cwd, sessionManager: { getSessionFile: () => sourceSession } },
100
+ );
101
+
102
+ assert.equal(result.terminate, true);
103
+ assert.deepEqual(result.details, {
104
+ kind: WORKTREE_SWITCH_REQUEST,
105
+ version: 1,
106
+ action: "fork-and-switch",
107
+ sessionFile: sourceSession,
108
+ status: "reused",
109
+ path: target,
110
+ branch: "feature",
111
+ dirty: true,
112
+ });
113
+ assert.deepEqual(result.structuredContent, result.details);
114
+ });
@@ -42,5 +42,91 @@ grep -q 'status: clean' "$ROOT/info.txt"
42
42
  grep -q 'branch: feature' "$ROOT/info.txt"
43
43
  grep -q 'based on:' "$ROOT/info.txt"
44
44
 
45
+ bash "$PACKAGE_DIR/bin/pi-worktree" prepare --json feature > "$ROOT/reused.json"
46
+ node -e '
47
+ const fs = require("node:fs");
48
+ const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
49
+ if (value.status !== "reused" || value.branch !== "feature" || value.dirty !== false) process.exit(1);
50
+ ' "$ROOT/reused.json"
51
+
52
+ bash "$PACKAGE_DIR/bin/pi-worktree" prepare --json prepared > "$ROOT/created.json"
53
+ node -e '
54
+ const fs = require("node:fs");
55
+ const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
56
+ if (value.status !== "created" || value.branch !== "prepared" || !value.path.endsWith("/.worktrees/prepared")) process.exit(1);
57
+ ' "$ROOT/created.json"
58
+
59
+ mkdir -p nested/path
60
+ (cd nested/path && bash "$PACKAGE_DIR/bin/pi-worktree" prepare --json from-subdir) > "$ROOT/subdir.json"
61
+ node -e '
62
+ const fs = require("node:fs");
63
+ const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
64
+ if (value.path !== process.argv[2] + "/.worktrees/from-subdir") process.exit(1);
65
+ ' "$ROOT/subdir.json" "$ROOT/repo"
66
+
67
+ git branch attachable
68
+ bash "$PACKAGE_DIR/bin/pi-worktree" prepare --json attachable > "$ROOT/attached.json"
69
+ node -e '
70
+ const fs = require("node:fs");
71
+ const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
72
+ if (value.status !== "attached" || value.branch !== "attachable") process.exit(1);
73
+ ' "$ROOT/attached.json"
74
+
75
+ bash "$PACKAGE_DIR/bin/pi-worktree" prepare --json stale > /dev/null
76
+ rm -rf .worktrees/stale
77
+ if bash "$PACKAGE_DIR/bin/pi-worktree" prepare --json stale >/dev/null 2>&1; then
78
+ echo "prepare silently replaced a stale worktree registration" >&2
79
+ exit 1
80
+ fi
81
+ git worktree prune
82
+
83
+ git worktree add .worktrees/wrong -b another-branch >/dev/null
84
+ if bash "$PACKAGE_DIR/bin/pi-worktree" prepare --json wrong >/dev/null 2>&1; then
85
+ echo "prepare reused a path checked out on the wrong branch" >&2
86
+ exit 1
87
+ fi
88
+
89
+ mkdir .worktrees/main
90
+ if bash "$PACKAGE_DIR/bin/pi-worktree" prepare --json main >/dev/null 2>&1; then
91
+ echo "prepare reused an ordinary directory as a worktree"
92
+ exit 1
93
+ fi
94
+
95
+ printf 'changed\n' >> .worktrees/prepared/tracked.txt
96
+ bash "$PACKAGE_DIR/bin/pi-worktree" prepare --json prepared > "$ROOT/dirty.json"
97
+ node -e '
98
+ const fs = require("node:fs");
99
+ const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
100
+ if (value.status !== "reused" || value.dirty !== true) process.exit(1);
101
+ ' "$ROOT/dirty.json"
102
+
103
+ (cd .worktrees/prepared && bash "$PACKAGE_DIR/bin/pi-worktree" prepare --json prepared) > "$ROOT/active.json"
104
+ node -e '
105
+ const fs = require("node:fs");
106
+ const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
107
+ if (value.status !== "already-active" || value.dirty !== true) process.exit(1);
108
+ ' "$ROOT/active.json"
109
+
110
+ if bash "$PACKAGE_DIR/bin/pi-worktree" prepare --json ../escape >/dev/null 2>&1; then
111
+ echo "prepare accepted an unsafe worktree name" >&2
112
+ exit 1
113
+ fi
114
+
115
+ mkdir "$ROOT/symlink-repo" "$ROOT/outside-worktrees"
116
+ (
117
+ cd "$ROOT/symlink-repo"
118
+ git init -q -b main
119
+ git config user.email test@example.com
120
+ git config user.name Test
121
+ printf 'base\n' > tracked.txt
122
+ git add tracked.txt
123
+ git commit -qm initial
124
+ ln -s "$ROOT/outside-worktrees" .worktrees
125
+ if bash "$PACKAGE_DIR/bin/pi-worktree" prepare --json escaped >/dev/null 2>&1; then
126
+ echo "prepare followed a symlinked .worktrees directory" >&2
127
+ exit 1
128
+ fi
129
+ )
130
+
45
131
  bash "$PACKAGE_DIR/bin/pi-worktree" --help | grep -q 'pi-worktree start'
46
132
  printf 'PASS worktree CLI integration cases\n'