pi-thread-engine 0.4.6 → 0.4.7

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.
@@ -0,0 +1,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 { 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
+ }