pi-thread-engine 0.4.7 → 0.4.9

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.
@@ -1,263 +1,309 @@
1
- /**
2
- * Worktree management for pi-thread-engine
3
- *
4
- * Mirrors Grok CLI's `isolation: "worktree"` pattern.
5
- * Each worktree gets its own branch + working directory so parallel
6
- * agents never conflict on file edits.
7
- *
8
- * Worktrees stored under `<repo>/.git/worktrees-pi/` for easy cleanup.
9
- */
10
-
11
- import { execSync, exec } from "child_process";
12
- import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync } from "fs";
13
- import { join, resolve } from "path";
14
- import { homedir } from "os";
15
-
16
- export interface WorktreeInfo {
17
- path: string;
18
- branch: string;
19
- threadId: string;
20
- createdAt: number;
21
- ahead: number;
22
- behind: number;
23
- dirty: boolean;
24
- }
25
-
26
- const WORKTREE_DIR = ".git/worktrees-pi";
27
- let worktreeRegistry: WorktreeInfo[] = [];
28
-
29
- function loadRegistry(repoPath: string): WorktreeInfo[] {
30
- const regPath = join(repoPath, WORKTREE_DIR, "registry.json");
31
- if (existsSync(regPath)) {
32
- try {
33
- return JSON.parse(readFileSync(regPath, "utf8"));
34
- } catch { /* corrupt, start fresh */ }
35
- }
36
- return [];
37
- }
38
-
39
- function saveRegistry(repoPath: string, reg: WorktreeInfo[]) {
40
- const regDir = join(repoPath, WORKTREE_DIR);
41
- if (!existsSync(regDir)) mkdirSync(regDir, { recursive: true });
42
- writeFileSync(join(regDir, "registry.json"), JSON.stringify(reg, null, 2), "utf8");
43
- }
44
-
45
- /** Resolve the git repo root from a path */
46
- export function findRepoRoot(cwd: string): string | null {
47
- try {
48
- return execSync("git rev-parse --show-toplevel", { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
49
- } catch {
50
- return null;
51
- }
52
- }
53
-
54
- /** Check if we're in a git repo */
55
- export function isGitRepo(cwd: string): boolean {
56
- return findRepoRoot(cwd) !== null;
57
- }
58
-
59
- /** Generate a unique branch name for a thread */
60
- export function branchName(threadId: string): string {
61
- const safe = threadId.replace(/[^a-zA-Z0-9-]/g, "-");
62
- return `pi-thread/${safe}`;
63
- }
64
-
65
- /** Worktree storage path for a thread */
66
- export function worktreePath(repoPath: string, threadId: string): string {
67
- return join(repoPath, WORKTREE_DIR, threadId);
68
- }
69
-
70
- /**
71
- * Create a worktree for a thread.
72
- * Creates a new branch off the current HEAD, checks it out in an isolated dir.
73
- */
74
- export function createWorktree(cwd: string, threadId: string, baseBranch?: string): WorktreeInfo {
75
- const repoPath = findRepoRoot(cwd);
76
- if (!repoPath) throw new Error("Not in a git repository");
77
-
78
- const branch = branchName(threadId);
79
- const wtPath = worktreePath(repoPath, threadId);
80
-
81
- // Create the branch if it doesn't exist
82
- try {
83
- execSync(`git branch ${branch} ${baseBranch ?? "HEAD"}`, { cwd: repoPath, encoding: "utf8", stdio: "pipe" });
84
- } catch {
85
- // Branch may already exist from a previous run — that's fine
86
- }
87
-
88
- // Create worktree
89
- execSync(`git worktree add ${wtPath} ${branch}`, { cwd: repoPath, encoding: "utf8", stdio: "pipe" });
90
-
91
- const info: WorktreeInfo = {
92
- path: wtPath,
93
- branch,
94
- threadId,
95
- createdAt: Date.now(),
96
- ahead: 0,
97
- behind: 0,
98
- dirty: false,
99
- };
100
-
101
- const reg = loadRegistry(repoPath);
102
- // Remove any stale entry for this thread
103
- const filtered = reg.filter((w) => w.threadId !== threadId);
104
- filtered.push(info);
105
- saveRegistry(repoPath, filtered);
106
- worktreeRegistry = filtered;
107
-
108
- return info;
109
- }
110
-
111
- /**
112
- * Remove a worktree and its branch
113
- */
114
- export function removeWorktree(cwd: string, threadId: string): boolean {
115
- const repoPath = findRepoRoot(cwd);
116
- if (!repoPath) return false;
117
-
118
- const branch = branchName(threadId);
119
- const wtPath = worktreePath(repoPath, threadId);
120
-
121
- let success = true;
122
-
123
- // Remove worktree
124
- try {
125
- execSync(`git worktree remove ${wtPath}`, { cwd: repoPath, encoding: "utf8", stdio: "pipe" });
126
- } catch {
127
- // Force remove if locked
128
- try {
129
- execSync(`git worktree remove --force ${wtPath}`, { cwd: repoPath, encoding: "utf8", stdio: "pipe" });
130
- } catch {
131
- success = false;
132
- }
133
- }
134
-
135
- // Delete branch
136
- try {
137
- execSync(`git branch -D ${branch}`, { cwd: repoPath, encoding: "utf8", stdio: "pipe" });
138
- } catch {
139
- // Branch may not exist
140
- }
141
-
142
- // Clean up directory
143
- try {
144
- rmSync(wtPath, { recursive: true, force: true });
145
- } catch { /* best effort */ }
146
-
147
- // Update registry
148
- const reg = loadRegistry(repoPath);
149
- const filtered = reg.filter((w) => w.threadId !== threadId);
150
- saveRegistry(repoPath, filtered);
151
- worktreeRegistry = filtered;
152
-
153
- return success;
154
- }
155
-
156
- /**
157
- * Get divergence stats for a worktree branch
158
- */
159
- function getDivergence(repoPath: string, branch: string): { ahead: number; behind: number } {
160
- try {
161
- const ahead = parseInt(
162
- execSync(`git rev-list --count HEAD..origin/${branch}`, { cwd: repoPath, encoding: "utf8", stdio: "pipe" }).trim() || "0",
163
- 10
164
- );
165
- const behind = parseInt(
166
- execSync(`git rev-list --count origin/${branch}..HEAD`, { cwd: repoPath, encoding: "utf8", stdio: "pipe" }).trim() || "0",
167
- 10
168
- );
169
- return { ahead, behind };
170
- } catch {
171
- return { ahead: 0, behind: 0 };
172
- }
173
- }
174
-
175
- /**
176
- * Check if a worktree has uncommitted changes
177
- */
178
- function isDirty(path: string): boolean {
179
- try {
180
- const status = execSync("git status --porcelain", { cwd: path, encoding: "utf8", stdio: "pipe" }).trim();
181
- return status.length > 0;
182
- } catch {
183
- return false;
184
- }
185
- }
186
-
187
- /**
188
- * List all worktrees with their divergence stats
189
- */
190
- export function listWorktrees(cwd: string): WorktreeInfo[] {
191
- const repoPath = findRepoRoot(cwd);
192
- if (!repoPath) return [];
193
-
194
- const reg = loadRegistry(repoPath);
195
-
196
- // Refresh stats
197
- return reg.map((w) => {
198
- const div = getDivergence(repoPath, w.branch);
199
- const dirty = existsSync(w.path) ? isDirty(w.path) : false;
200
- return { ...w, ...div, dirty };
201
- });
202
- }
203
-
204
- /**
205
- * Collect worktree results — reads the output file from a finished thread
206
- */
207
- export function collectWorktreeResult(threadId: string, worktree: WorktreeInfo): string | null {
208
- const resultFile = join(worktree.path, ".pi-thread-result.json");
209
- if (existsSync(resultFile)) {
210
- try {
211
- return readFileSync(resultFile, "utf8");
212
- } catch {
213
- return null;
214
- }
215
- }
216
- return null;
217
- }
218
-
219
- /**
220
- * Clean up ALL worktrees for this repo (emergency / force cleanup)
221
- */
222
- export function cleanupAll(cwd: string): { removed: number; failed: number } {
223
- const repoPath = findRepoRoot(cwd);
224
- if (!repoPath) return { removed: 0, failed: 0 };
225
-
226
- const reg = loadRegistry(repoPath);
227
- let removed = 0;
228
- let failed = 0;
229
-
230
- for (const w of reg) {
231
- if (removeWorktree(cwd, w.threadId)) removed++;
232
- else failed++;
233
- }
234
-
235
- saveRegistry(repoPath, []);
236
- worktreeRegistry = [];
237
-
238
- return { removed, failed };
239
- }
240
-
241
- /**
242
- * Push worktree changes back to the main repo
243
- */
244
- export function pushWorktreeChanges(cwd: string, threadId: string, message?: string): boolean {
245
- const repoPath = findRepoRoot(cwd);
246
- if (!repoPath) return false;
247
-
248
- const wtPath = worktreePath(repoPath, threadId);
249
- if (!existsSync(wtPath)) return false;
250
-
251
- try {
252
- // Commit any uncommitted changes
253
- const status = execSync("git status --porcelain", { cwd: wtPath, encoding: "utf8", stdio: "pipe" }).trim();
254
- if (status) {
255
- execSync(`git add -A`, { cwd: wtPath, encoding: "utf8", stdio: "pipe" });
256
- const defaultMsg = `[pi-threads] ${threadId} worktree changes`;
257
- execSync(`git commit -m "${message ?? defaultMsg}"`, { cwd: wtPath, encoding: "utf8", stdio: "pipe" });
258
- }
259
- return true;
260
- } catch {
261
- return false;
262
- }
263
- }
1
+ /**
2
+ * Worktree management for pi-thread-engine
3
+ *
4
+ * Mirrors Grok CLI's `isolation: "worktree"` pattern.
5
+ * Each worktree gets its own branch + working directory so parallel
6
+ * agents never conflict on file edits.
7
+ *
8
+ * Worktrees stored under `<repo>/.git/worktrees-pi/` for easy cleanup.
9
+ */
10
+
11
+ import { execFileSync } from "child_process";
12
+ import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync } from "fs";
13
+ import { join, resolve, relative, sep, isAbsolute } from "path";
14
+
15
+ export interface WorktreeInfo {
16
+ path: string;
17
+ branch: string;
18
+ threadId: string;
19
+ createdAt: number;
20
+ ahead: number;
21
+ behind: number;
22
+ dirty: boolean;
23
+ }
24
+
25
+ const WORKTREE_DIR = ".git/worktrees-pi";
26
+ let worktreeRegistry: WorktreeInfo[] = [];
27
+
28
+ function git(args: string[], cwd: string): string {
29
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
30
+ }
31
+
32
+ function shortHash(input: string): string {
33
+ let hash = 0x811c9dc5;
34
+ for (let i = 0; i < input.length; i++) {
35
+ hash ^= input.charCodeAt(i);
36
+ hash = Math.imul(hash, 0x01000193);
37
+ }
38
+ return (hash >>> 0).toString(36);
39
+ }
40
+
41
+ function sanitizeThreadId(threadId: string): string {
42
+ const safe = threadId.replace(/[^a-zA-Z0-9-]/g, "-").replace(/^-+|-+$/g, "");
43
+ if (!safe) throw new Error("Invalid threadId");
44
+ const suffix = shortHash(threadId);
45
+ return safe.length > 48 ? `${safe.slice(0, 48)}-${suffix}` : `${safe}-${suffix}`;
46
+ }
47
+
48
+ function isContained(parent: string, child: string): boolean {
49
+ const rel = relative(resolve(parent), resolve(child));
50
+ return rel === "" || (!rel.startsWith("..") && !rel.includes(`..${sep}`) && !isAbsolute(rel));
51
+ }
52
+
53
+ function assertContainedWorktreePath(repoPath: string, wtPath: string): string {
54
+ const root = resolve(repoPath, WORKTREE_DIR);
55
+ const resolved = resolve(wtPath);
56
+ if (!isContained(root, resolved)) throw new Error("Worktree path escapes worktree directory");
57
+ return resolved;
58
+ }
59
+
60
+ function validateBranchRef(ref: string, cwd: string, label: string): string {
61
+ if (!ref || ref.startsWith("-") || ref.includes("\0")) throw new Error(`Invalid ${label}`);
62
+ try {
63
+ git(["check-ref-format", "--branch", ref], cwd);
64
+ return ref;
65
+ } catch {
66
+ throw new Error(`Invalid ${label}`);
67
+ }
68
+ }
69
+
70
+ function validateStartPoint(ref: string, cwd: string, label: string): string {
71
+ if (!ref || ref.startsWith("-") || /[\0\r\n]/.test(ref)) throw new Error(`Invalid ${label}`);
72
+ try {
73
+ git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], cwd);
74
+ return ref;
75
+ } catch {
76
+ throw new Error(`Invalid ${label}`);
77
+ }
78
+ }
79
+
80
+ function loadRegistry(repoPath: string): WorktreeInfo[] {
81
+ const regPath = join(repoPath, WORKTREE_DIR, "registry.json");
82
+ if (existsSync(regPath)) {
83
+ try {
84
+ return JSON.parse(readFileSync(regPath, "utf8"));
85
+ } catch { /* corrupt, start fresh */ }
86
+ }
87
+ return [];
88
+ }
89
+
90
+ function saveRegistry(repoPath: string, reg: WorktreeInfo[]) {
91
+ const regDir = join(repoPath, WORKTREE_DIR);
92
+ if (!existsSync(regDir)) mkdirSync(regDir, { recursive: true });
93
+ writeFileSync(join(regDir, "registry.json"), JSON.stringify(reg, null, 2), "utf8");
94
+ }
95
+
96
+ /** Resolve the git repo root from a path */
97
+ export function findRepoRoot(cwd: string): string | null {
98
+ try {
99
+ return git(["rev-parse", "--show-toplevel"], cwd);
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ /** Check if we're in a git repo */
106
+ export function isGitRepo(cwd: string): boolean {
107
+ return findRepoRoot(cwd) !== null;
108
+ }
109
+
110
+ /** Generate a unique branch name for a thread */
111
+ export function branchName(threadId: string): string {
112
+ return `pi-thread/${sanitizeThreadId(threadId)}`;
113
+ }
114
+
115
+ /** Worktree storage path for a thread */
116
+ export function worktreePath(repoPath: string, threadId: string): string {
117
+ return assertContainedWorktreePath(repoPath, join(repoPath, WORKTREE_DIR, sanitizeThreadId(threadId)));
118
+ }
119
+
120
+ /**
121
+ * Create a worktree for a thread.
122
+ * Creates a new branch off the current HEAD, checks it out in an isolated dir.
123
+ */
124
+ export function createWorktree(cwd: string, threadId: string, baseBranch?: string): WorktreeInfo {
125
+ const repoPath = findRepoRoot(cwd);
126
+ if (!repoPath) throw new Error("Not in a git repository");
127
+
128
+ const branch = validateBranchRef(branchName(threadId), repoPath, "branch ref");
129
+ const base = validateStartPoint(baseBranch ?? "HEAD", repoPath, "base branch ref");
130
+ const wtPath = worktreePath(repoPath, threadId);
131
+
132
+ // Create the branch if it doesn't exist
133
+ try {
134
+ git(["branch", branch, base], repoPath);
135
+ } catch {
136
+ // Branch may already exist from a previous run — that's fine
137
+ }
138
+
139
+ // Create worktree
140
+ git(["worktree", "add", wtPath, branch], repoPath);
141
+
142
+ const info: WorktreeInfo = {
143
+ path: wtPath,
144
+ branch,
145
+ threadId,
146
+ createdAt: Date.now(),
147
+ ahead: 0,
148
+ behind: 0,
149
+ dirty: false,
150
+ };
151
+
152
+ const reg = loadRegistry(repoPath);
153
+ // Remove any stale entry for this thread
154
+ const filtered = reg.filter((w) => w.threadId !== threadId);
155
+ filtered.push(info);
156
+ saveRegistry(repoPath, filtered);
157
+ worktreeRegistry = filtered;
158
+
159
+ return info;
160
+ }
161
+
162
+ /**
163
+ * Remove a worktree and its branch
164
+ */
165
+ export function removeWorktree(cwd: string, threadId: string): boolean {
166
+ const repoPath = findRepoRoot(cwd);
167
+ if (!repoPath) return false;
168
+
169
+ const branch = validateBranchRef(branchName(threadId), repoPath, "branch ref");
170
+ const wtPath = worktreePath(repoPath, threadId);
171
+
172
+ let success = true;
173
+
174
+ // Remove worktree
175
+ try {
176
+ git(["worktree", "remove", wtPath], repoPath);
177
+ } catch {
178
+ // Force remove if locked
179
+ try {
180
+ git(["worktree", "remove", "--force", wtPath], repoPath);
181
+ } catch {
182
+ success = false;
183
+ }
184
+ }
185
+
186
+ // Delete branch
187
+ try {
188
+ git(["branch", "-D", branch], repoPath);
189
+ } catch {
190
+ // Branch may not exist
191
+ }
192
+
193
+ // Clean up directory after re-checking containment
194
+ try {
195
+ rmSync(assertContainedWorktreePath(repoPath, wtPath), { recursive: true, force: true });
196
+ } catch { /* best effort */ }
197
+
198
+ // Update registry
199
+ const reg = loadRegistry(repoPath);
200
+ const filtered = reg.filter((w) => w.threadId !== threadId);
201
+ saveRegistry(repoPath, filtered);
202
+ worktreeRegistry = filtered;
203
+
204
+ return success;
205
+ }
206
+
207
+ /**
208
+ * Get divergence stats for a worktree branch
209
+ */
210
+ function getDivergence(repoPath: string, branch: string): { ahead: number; behind: number } {
211
+ try {
212
+ const ref = validateBranchRef(branch, repoPath, "branch ref");
213
+ const ahead = parseInt(git(["rev-list", "--count", `HEAD..origin/${ref}`], repoPath) || "0", 10);
214
+ const behind = parseInt(git(["rev-list", "--count", `origin/${ref}..HEAD`], repoPath) || "0", 10);
215
+ return { ahead, behind };
216
+ } catch {
217
+ return { ahead: 0, behind: 0 };
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Check if a worktree has uncommitted changes
223
+ */
224
+ function isDirty(path: string): boolean {
225
+ try {
226
+ const status = git(["status", "--porcelain"], path);
227
+ return status.length > 0;
228
+ } catch {
229
+ return false;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * List all worktrees with their divergence stats
235
+ */
236
+ export function listWorktrees(cwd: string): WorktreeInfo[] {
237
+ const repoPath = findRepoRoot(cwd);
238
+ if (!repoPath) return [];
239
+
240
+ const reg = loadRegistry(repoPath);
241
+
242
+ // Refresh stats
243
+ return reg.map((w) => {
244
+ const div = getDivergence(repoPath, w.branch);
245
+ const dirty = existsSync(w.path) ? isDirty(w.path) : false;
246
+ return { ...w, ...div, dirty };
247
+ });
248
+ }
249
+
250
+ /**
251
+ * Collect worktree results — reads the output file from a finished thread
252
+ */
253
+ export function collectWorktreeResult(threadId: string, worktree: WorktreeInfo): string | null {
254
+ const resultFile = join(worktree.path, ".pi-thread-result.json");
255
+ if (existsSync(resultFile)) {
256
+ try {
257
+ return readFileSync(resultFile, "utf8");
258
+ } catch {
259
+ return null;
260
+ }
261
+ }
262
+ return null;
263
+ }
264
+
265
+ /**
266
+ * Clean up ALL worktrees for this repo (emergency / force cleanup)
267
+ */
268
+ export function cleanupAll(cwd: string): { removed: number; failed: number } {
269
+ const repoPath = findRepoRoot(cwd);
270
+ if (!repoPath) return { removed: 0, failed: 0 };
271
+
272
+ const reg = loadRegistry(repoPath);
273
+ let removed = 0;
274
+ let failed = 0;
275
+
276
+ for (const w of reg) {
277
+ if (removeWorktree(cwd, w.threadId)) removed++;
278
+ else failed++;
279
+ }
280
+
281
+ saveRegistry(repoPath, []);
282
+ worktreeRegistry = [];
283
+
284
+ return { removed, failed };
285
+ }
286
+
287
+ /**
288
+ * Push worktree changes back to the main repo
289
+ */
290
+ export function pushWorktreeChanges(cwd: string, threadId: string, message?: string): boolean {
291
+ const repoPath = findRepoRoot(cwd);
292
+ if (!repoPath) return false;
293
+
294
+ const wtPath = worktreePath(repoPath, threadId);
295
+ if (!existsSync(wtPath)) return false;
296
+
297
+ try {
298
+ // Commit any uncommitted changes
299
+ const status = git(["status", "--porcelain"], wtPath);
300
+ if (status) {
301
+ git(["add", "-A"], wtPath);
302
+ const defaultMsg = `[pi-threads] ${threadId} worktree changes`;
303
+ git(["commit", "-m", message ?? defaultMsg], wtPath);
304
+ }
305
+ return true;
306
+ } catch {
307
+ return false;
308
+ }
309
+ }
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { helloWorld, DEFAULT_GREETING } from "./hello-world.js";
3
+
4
+ describe("helloWorld", () => {
5
+ it("returns 'Hello, World!' when called with no arguments", () => {
6
+ expect(helloWorld()).toBe("Hello, World!");
7
+ });
8
+
9
+ it("returns 'Hello, World!' when called with undefined", () => {
10
+ expect(helloWorld(undefined)).toBe("Hello, World!");
11
+ });
12
+
13
+ it("returns 'Hello, Alice!' when called with 'Alice'", () => {
14
+ expect(helloWorld("Alice")).toBe("Hello, Alice!");
15
+ });
16
+
17
+ it("trims whitespace from the name", () => {
18
+ expect(helloWorld(" Charlie ")).toBe("Hello, Charlie!");
19
+ });
20
+
21
+ it("returns 'Hello, World!' for empty string", () => {
22
+ expect(helloWorld("")).toBe("Hello, World!");
23
+ });
24
+ });
25
+
26
+ describe("DEFAULT_GREETING", () => {
27
+ it("is defined and equals 'Hello, World!'", () => {
28
+ expect(DEFAULT_GREETING).toBe("Hello, World!");
29
+ });
30
+ });
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Default greeting string.
3
+ */
4
+ export const DEFAULT_GREETING = "Hello, World!";
5
+
6
+ /**
7
+ * Returns a greeting string.
8
+ *
9
+ * @param name - Optional name to include in the greeting.
10
+ * `undefined`, empty string, or whitespace-only values
11
+ * produce the default greeting.
12
+ * @returns The greeting string.
13
+ */
14
+ export function helloWorld(name?: string): string {
15
+ if (name === undefined || name === null) {
16
+ return DEFAULT_GREETING;
17
+ }
18
+
19
+ const trimmed = name.trim();
20
+ if (trimmed === "") {
21
+ return DEFAULT_GREETING;
22
+ }
23
+
24
+ return `Hello, ${trimmed}!`;
25
+ }