pi-var 1.0.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/.github/FUNDING.yml +4 -0
- package/.prettierrc +7 -0
- package/README.md +240 -0
- package/commitlint.config.cjs +3 -0
- package/extensions/pi-var/__tests__/integration/smoke.test.ts +308 -0
- package/extensions/pi-var/__tests__/unit/command.test.ts +322 -0
- package/extensions/pi-var/__tests__/unit/cow-detection.test.ts +340 -0
- package/extensions/pi-var/__tests__/unit/cow-git-merge.test.ts +404 -0
- package/extensions/pi-var/__tests__/unit/edr.test.ts +119 -0
- package/extensions/pi-var/__tests__/unit/environment.test.ts +369 -0
- package/extensions/pi-var/__tests__/unit/file-redirect.test.ts +295 -0
- package/extensions/pi-var/__tests__/unit/store.test.ts +489 -0
- package/extensions/pi-var/__tests__/unit/tools.test.ts +133 -0
- package/extensions/pi-var/__tests__/unit/variations.test.ts +352 -0
- package/extensions/pi-var/index.ts +111 -0
- package/extensions/pi-var/src/config.ts +30 -0
- package/extensions/pi-var/src/cow/clone.ts +46 -0
- package/extensions/pi-var/src/cow/detection.ts +215 -0
- package/extensions/pi-var/src/cow/index.ts +6 -0
- package/extensions/pi-var/src/edr/constants.ts +199 -0
- package/extensions/pi-var/src/edr/crowdstrike.ts +62 -0
- package/extensions/pi-var/src/edr/detection.ts +97 -0
- package/extensions/pi-var/src/edr/index.ts +6 -0
- package/extensions/pi-var/src/environment/copy-env.ts +79 -0
- package/extensions/pi-var/src/environment/detect.ts +91 -0
- package/extensions/pi-var/src/environment/index.ts +54 -0
- package/extensions/pi-var/src/environment/marker.ts +42 -0
- package/extensions/pi-var/src/environment/symlink.ts +82 -0
- package/extensions/pi-var/src/gitignore/manager.ts +77 -0
- package/extensions/pi-var/src/state/index.ts +7 -0
- package/extensions/pi-var/src/state/keys.ts +14 -0
- package/extensions/pi-var/src/state/store.ts +110 -0
- package/extensions/pi-var/src/state/types.ts +70 -0
- package/extensions/pi-var/src/tools/command/clean.ts +87 -0
- package/extensions/pi-var/src/tools/command/index.ts +95 -0
- package/extensions/pi-var/src/tools/command/status.ts +58 -0
- package/extensions/pi-var/src/tools/command/stop.ts +37 -0
- package/extensions/pi-var/src/tools/file-redirect.ts +126 -0
- package/extensions/pi-var/src/tools/redirect/bash.ts +75 -0
- package/extensions/pi-var/src/tools/redirect/edit.ts +60 -0
- package/extensions/pi-var/src/tools/redirect/index.ts +64 -0
- package/extensions/pi-var/src/tools/redirect/read.ts +61 -0
- package/extensions/pi-var/src/tools/redirect/write.ts +60 -0
- package/extensions/pi-var/src/tools/variation/create.ts +108 -0
- package/extensions/pi-var/src/tools/variation/merge.ts +80 -0
- package/extensions/pi-var/src/types/index.ts +139 -0
- package/extensions/pi-var/src/utils/names.ts +120 -0
- package/extensions/pi-var/src/variation/create-copy.ts +35 -0
- package/extensions/pi-var/src/variation/create-cow.ts +81 -0
- package/extensions/pi-var/src/variation/create-worktree.ts +31 -0
- package/extensions/pi-var/src/variation/create.ts +127 -0
- package/extensions/pi-var/src/variation/index.ts +9 -0
- package/extensions/pi-var/src/variation/merge/copy.ts +151 -0
- package/extensions/pi-var/src/variation/merge/git.ts +176 -0
- package/extensions/pi-var/src/variation/merge/index.ts +36 -0
- package/extensions/pi-var/src/variation/merge/rsync.ts +74 -0
- package/extensions/pi-var/src/variation/merge/strategy.ts +62 -0
- package/extensions/pi-var/src/variation/paths.ts +26 -0
- package/extensions/pi-var/src/variation/remove.ts +79 -0
- package/npm-shrinkwrap.json +6908 -0
- package/package.json +63 -0
- package/skills/variations/SKILL.md +182 -0
- package/tsconfig.json +25 -0
- package/vitest.config.ts +19 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for variation management utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
6
|
+
import { promises as fs } from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import os from 'os';
|
|
9
|
+
import crypto from 'crypto';
|
|
10
|
+
import {
|
|
11
|
+
detectCoWSupport,
|
|
12
|
+
hasGitRepo,
|
|
13
|
+
createVariation,
|
|
14
|
+
removeVariation,
|
|
15
|
+
mergeVariation,
|
|
16
|
+
} from '../../src/variation/index';
|
|
17
|
+
import type { Variation } from '../../src/types/index';
|
|
18
|
+
|
|
19
|
+
// Helper to create a temporary test directory
|
|
20
|
+
async function createTempDir(prefix: string): Promise<string> {
|
|
21
|
+
const tmpDir = os.tmpdir();
|
|
22
|
+
const randomId = crypto.randomBytes(4).toString('hex');
|
|
23
|
+
const dir = path.join(tmpDir, `${prefix}-${randomId}`);
|
|
24
|
+
await fs.mkdir(dir, { recursive: true });
|
|
25
|
+
return dir;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Helper to cleanup a directory
|
|
29
|
+
async function cleanupDir(dir: string): Promise<void> {
|
|
30
|
+
try {
|
|
31
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
32
|
+
} catch {
|
|
33
|
+
// Ignore cleanup errors
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('detectCoWSupport', () => {
|
|
38
|
+
let testDir: string;
|
|
39
|
+
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
testDir = await createTempDir('cow-test');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(async () => {
|
|
45
|
+
await cleanupDir(testDir);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should return a result object with supported boolean', async () => {
|
|
49
|
+
const result = await detectCoWSupport(testDir);
|
|
50
|
+
|
|
51
|
+
expect(result).toHaveProperty('supported');
|
|
52
|
+
expect(typeof result.supported).toBe('boolean');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should return method when CoW is supported', async () => {
|
|
56
|
+
const result = await detectCoWSupport(testDir);
|
|
57
|
+
|
|
58
|
+
if (result.supported) {
|
|
59
|
+
expect(result.method).toBeDefined();
|
|
60
|
+
expect(['clonefile', 'reflink']).toContain(result.method);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should not leave test files behind', async () => {
|
|
65
|
+
const filesBefore = await fs.readdir(testDir).catch(() => []);
|
|
66
|
+
|
|
67
|
+
await detectCoWSupport(testDir);
|
|
68
|
+
|
|
69
|
+
const filesAfter = await fs.readdir(testDir).catch(() => []);
|
|
70
|
+
|
|
71
|
+
// Should only have files we created, no cow-test files
|
|
72
|
+
const cowTestFiles = filesAfter.filter(
|
|
73
|
+
(f) => f.includes('cow-test') || f.includes('cow-clone')
|
|
74
|
+
);
|
|
75
|
+
expect(cowTestFiles).toHaveLength(0);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('hasGitRepo', () => {
|
|
80
|
+
let testDir: string;
|
|
81
|
+
|
|
82
|
+
beforeEach(async () => {
|
|
83
|
+
testDir = await createTempDir('git-test');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterEach(async () => {
|
|
87
|
+
await cleanupDir(testDir);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should return false for non-git directory', async () => {
|
|
91
|
+
const result = await hasGitRepo(testDir);
|
|
92
|
+
expect(result).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should return true for git directory', async () => {
|
|
96
|
+
// Initialize git repo
|
|
97
|
+
await fs.mkdir(path.join(testDir, '.git'), { recursive: true });
|
|
98
|
+
await fs.writeFile(path.join(testDir, '.git', 'HEAD'), 'ref: refs/heads/main\n');
|
|
99
|
+
|
|
100
|
+
const result = await hasGitRepo(testDir);
|
|
101
|
+
expect(result).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should return false for non-existent path', async () => {
|
|
105
|
+
const nonExistent = path.join(testDir, 'does-not-exist');
|
|
106
|
+
const result = await hasGitRepo(nonExistent);
|
|
107
|
+
expect(result).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('createVariation', () => {
|
|
112
|
+
let sourceDir: string;
|
|
113
|
+
let variationsBase: string;
|
|
114
|
+
|
|
115
|
+
beforeEach(async () => {
|
|
116
|
+
sourceDir = await createTempDir('source');
|
|
117
|
+
|
|
118
|
+
// Create some test files
|
|
119
|
+
await fs.writeFile(path.join(sourceDir, 'package.json'), JSON.stringify({ name: 'test' }));
|
|
120
|
+
await fs.mkdir(path.join(sourceDir, 'src'), { recursive: true });
|
|
121
|
+
await fs.writeFile(path.join(sourceDir, 'src', 'index'), 'console.log("hello");');
|
|
122
|
+
|
|
123
|
+
// Create .pi directory structure
|
|
124
|
+
variationsBase = path.join(sourceDir, '.pi', 'variations');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
afterEach(async () => {
|
|
128
|
+
await cleanupDir(sourceDir);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should create a variation with auto-generated name', async () => {
|
|
132
|
+
const variation = await createVariation(sourceDir);
|
|
133
|
+
|
|
134
|
+
expect(variation).toHaveProperty('id');
|
|
135
|
+
expect(variation).toHaveProperty('name');
|
|
136
|
+
expect(variation).toHaveProperty('path');
|
|
137
|
+
expect(variation).toHaveProperty('sourcePath', sourceDir);
|
|
138
|
+
expect(variation).toHaveProperty('type');
|
|
139
|
+
expect(variation).toHaveProperty('createdAt');
|
|
140
|
+
expect(variation).toHaveProperty('lastAccessed');
|
|
141
|
+
|
|
142
|
+
// Verify the directory was created
|
|
143
|
+
const stats = await fs.stat(variation.path);
|
|
144
|
+
expect(stats.isDirectory()).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should create a variation with specified name', async () => {
|
|
148
|
+
const variation = await createVariation(sourceDir, { name: 'my-variation' });
|
|
149
|
+
|
|
150
|
+
expect(variation.name).toBe('my-variation');
|
|
151
|
+
expect(variation.path).toContain('my-variation');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should throw error if variation already exists', async () => {
|
|
155
|
+
await createVariation(sourceDir, { name: 'duplicate' });
|
|
156
|
+
|
|
157
|
+
await expect(createVariation(sourceDir, { name: 'duplicate' })).rejects.toThrow(
|
|
158
|
+
/already exists/
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should create copy variation when type is explicitly set', async () => {
|
|
163
|
+
const variation = await createVariation(sourceDir, {
|
|
164
|
+
name: 'copy-test',
|
|
165
|
+
type: 'copy',
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(variation.type).toBe('copy');
|
|
169
|
+
|
|
170
|
+
// Verify files were copied
|
|
171
|
+
const pkgJson = await fs.readFile(path.join(variation.path, 'package.json'), 'utf-8');
|
|
172
|
+
expect(JSON.parse(pkgJson).name).toBe('test');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should not copy nested .pi/variations directory', async () => {
|
|
176
|
+
// Create a pre-existing variation
|
|
177
|
+
await createVariation(sourceDir, { name: 'existing' });
|
|
178
|
+
|
|
179
|
+
// Create new variation - should not include the existing variation's .pi folder
|
|
180
|
+
const newVariation = await createVariation(sourceDir, {
|
|
181
|
+
name: 'new-var',
|
|
182
|
+
type: 'copy',
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// The new variation should not have the existing variation inside it
|
|
186
|
+
const nestedVarPath = path.join(newVariation.path, '.pi', 'variations', 'existing');
|
|
187
|
+
await expect(fs.access(nestedVarPath)).rejects.toThrow();
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('removeVariation', () => {
|
|
192
|
+
let sourceDir: string;
|
|
193
|
+
|
|
194
|
+
beforeEach(async () => {
|
|
195
|
+
sourceDir = await createTempDir('source');
|
|
196
|
+
await fs.writeFile(path.join(sourceDir, 'file.txt'), 'content');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
afterEach(async () => {
|
|
200
|
+
await cleanupDir(sourceDir);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('should remove a copy variation', async () => {
|
|
204
|
+
const variation = await createVariation(sourceDir, {
|
|
205
|
+
name: 'to-remove',
|
|
206
|
+
type: 'copy',
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Verify it exists
|
|
210
|
+
await fs.access(variation.path);
|
|
211
|
+
|
|
212
|
+
// Remove it
|
|
213
|
+
await removeVariation(variation);
|
|
214
|
+
|
|
215
|
+
// Verify it's gone
|
|
216
|
+
await expect(fs.access(variation.path)).rejects.toThrow();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('should clean up empty parent directories', async () => {
|
|
220
|
+
const variation = await createVariation(sourceDir, {
|
|
221
|
+
name: 'cleanup-test',
|
|
222
|
+
type: 'copy',
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const variationsDir = path.dirname(variation.path);
|
|
226
|
+
const piDir = path.dirname(variationsDir);
|
|
227
|
+
|
|
228
|
+
await removeVariation(variation);
|
|
229
|
+
|
|
230
|
+
// If this was the only variation, .pi/variations should be cleaned up
|
|
231
|
+
try {
|
|
232
|
+
await fs.access(variationsDir);
|
|
233
|
+
// If it still exists, that's fine - other variations may exist
|
|
234
|
+
} catch {
|
|
235
|
+
// Was cleaned up as expected
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('mergeVariation', () => {
|
|
241
|
+
let sourceDir: string;
|
|
242
|
+
let variation: Variation;
|
|
243
|
+
|
|
244
|
+
beforeEach(async () => {
|
|
245
|
+
sourceDir = await createTempDir('source');
|
|
246
|
+
|
|
247
|
+
// Create source files
|
|
248
|
+
await fs.writeFile(path.join(sourceDir, 'file.txt'), 'original');
|
|
249
|
+
await fs.mkdir(path.join(sourceDir, 'src'), { recursive: true });
|
|
250
|
+
await fs.writeFile(path.join(sourceDir, 'src', 'app'), '// original');
|
|
251
|
+
|
|
252
|
+
// Create variation with changes
|
|
253
|
+
variation = await createVariation(sourceDir, {
|
|
254
|
+
name: 'to-merge',
|
|
255
|
+
type: 'copy',
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Modify files in variation
|
|
259
|
+
await fs.writeFile(path.join(variation.path, 'new-file.txt'), 'new content');
|
|
260
|
+
await fs.writeFile(path.join(variation.path, 'src', 'app'), '// modified');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
afterEach(async () => {
|
|
264
|
+
await cleanupDir(sourceDir);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('should merge new files to source', async () => {
|
|
268
|
+
// New file should not exist in source yet
|
|
269
|
+
await expect(fs.access(path.join(sourceDir, 'new-file.txt'))).rejects.toThrow();
|
|
270
|
+
|
|
271
|
+
await mergeVariation(variation, sourceDir);
|
|
272
|
+
|
|
273
|
+
// New file should now exist
|
|
274
|
+
const content = await fs.readFile(path.join(sourceDir, 'new-file.txt'), 'utf-8');
|
|
275
|
+
expect(content).toBe('new content');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('should merge modified files to source', async () => {
|
|
279
|
+
const original = await fs.readFile(path.join(sourceDir, 'src', 'app'), 'utf-8');
|
|
280
|
+
expect(original).toBe('// original');
|
|
281
|
+
|
|
282
|
+
// Force copy strategy to avoid rsync issues in test environment
|
|
283
|
+
await mergeVariation(variation, sourceDir, { strategy: 'copy' });
|
|
284
|
+
|
|
285
|
+
const merged = await fs.readFile(path.join(sourceDir, 'src', 'app'), 'utf-8');
|
|
286
|
+
expect(merged).toBe('// modified');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('should keep variation after merge', async () => {
|
|
290
|
+
const variationPath = variation.path;
|
|
291
|
+
|
|
292
|
+
await mergeVariation(variation, sourceDir);
|
|
293
|
+
|
|
294
|
+
// Variation should still exist
|
|
295
|
+
const stats = await fs.stat(variationPath);
|
|
296
|
+
expect(stats.isDirectory()).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('should support dry run mode', async () => {
|
|
300
|
+
const variationPath = variation.path;
|
|
301
|
+
|
|
302
|
+
await mergeVariation(variation, sourceDir, { dryRun: true });
|
|
303
|
+
|
|
304
|
+
// Variation should still exist
|
|
305
|
+
const stats = await fs.stat(variationPath);
|
|
306
|
+
expect(stats.isDirectory()).toBe(true);
|
|
307
|
+
|
|
308
|
+
// But files should not have been merged
|
|
309
|
+
await expect(fs.access(path.join(sourceDir, 'new-file.txt'))).rejects.toThrow();
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe('variation type selection', () => {
|
|
314
|
+
let sourceDir: string;
|
|
315
|
+
|
|
316
|
+
beforeEach(async () => {
|
|
317
|
+
sourceDir = await createTempDir('source');
|
|
318
|
+
await fs.writeFile(path.join(sourceDir, 'file.txt'), 'content');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
afterEach(async () => {
|
|
322
|
+
await cleanupDir(sourceDir);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('should auto-select appropriate type', async () => {
|
|
326
|
+
const variation = await createVariation(sourceDir);
|
|
327
|
+
|
|
328
|
+
// Type should be one of the valid types
|
|
329
|
+
expect(['cow', 'worktree', 'copy']).toContain(variation.type);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('should allow forcing specific type', async () => {
|
|
333
|
+
const copyVar = await createVariation(sourceDir, {
|
|
334
|
+
name: 'forced-copy',
|
|
335
|
+
type: 'copy',
|
|
336
|
+
});
|
|
337
|
+
expect(copyVar.type).toBe('copy');
|
|
338
|
+
|
|
339
|
+
// Create another source dir for next test
|
|
340
|
+
const anotherSource = await createTempDir('another-source');
|
|
341
|
+
await fs.writeFile(path.join(anotherSource, 'file.txt'), 'content');
|
|
342
|
+
|
|
343
|
+
const cowVar = await createVariation(anotherSource, {
|
|
344
|
+
name: 'forced-cow',
|
|
345
|
+
type: 'cow',
|
|
346
|
+
});
|
|
347
|
+
// May fall back to copy if CoW not supported
|
|
348
|
+
expect(['cow', 'copy']).toContain(cowVar.type);
|
|
349
|
+
|
|
350
|
+
await cleanupDir(anotherSource);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-var — Copy-on-write variations extension (autoregressive)
|
|
3
|
+
*
|
|
4
|
+
* AI-driven variation management:
|
|
5
|
+
* - create_variation tool — AI creates variations automatically
|
|
6
|
+
* - /var command — argument-free status and manual override
|
|
7
|
+
* - File redirection — transparent read/edit/write to active variation
|
|
8
|
+
* - Environment sync — copy .env, symlink node_modules
|
|
9
|
+
*
|
|
10
|
+
* Port isolation is handled by the AI via bash (portless), not extension code.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
14
|
+
import type { VarRuntime } from './src/types/index';
|
|
15
|
+
import { generateVariationName } from './src/utils/names';
|
|
16
|
+
import { getSessionKey, createRuntimeStore } from './src/state/index';
|
|
17
|
+
import { registerVarCommand } from './src/tools/command/index';
|
|
18
|
+
import { registerRedirectedTools } from './src/tools/redirect/index';
|
|
19
|
+
import { registerCreateVariationTool } from './src/tools/variation/create';
|
|
20
|
+
import { registerMergeVariationTool } from './src/tools/variation/merge';
|
|
21
|
+
import { detectVariationContext } from './src/environment/index';
|
|
22
|
+
import { DEFAULT_CONFIG } from './src/config';
|
|
23
|
+
|
|
24
|
+
export default function piVarExtension(pi: ExtensionAPI) {
|
|
25
|
+
// Session-scoped runtime store
|
|
26
|
+
const runtimeStore = createRuntimeStore();
|
|
27
|
+
const getRuntime = (ctx: ExtensionContext): VarRuntime => runtimeStore.ensure(getSessionKey(ctx));
|
|
28
|
+
const persistState = (ctx: ExtensionContext): void =>
|
|
29
|
+
runtimeStore.persistState(getSessionKey(ctx), pi);
|
|
30
|
+
|
|
31
|
+
// Register /var command (argument-free, status/overview)
|
|
32
|
+
registerVarCommand(pi, { getRuntime, pi, runtimeStore });
|
|
33
|
+
|
|
34
|
+
// Register redirected tools (read, edit, write, bash) - overrides built-ins when variation is active
|
|
35
|
+
registerRedirectedTools(pi, getRuntime);
|
|
36
|
+
|
|
37
|
+
// Register variation management tools
|
|
38
|
+
registerCreateVariationTool(pi, getRuntime);
|
|
39
|
+
registerMergeVariationTool(pi, getRuntime, persistState);
|
|
40
|
+
|
|
41
|
+
// Session lifecycle
|
|
42
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
43
|
+
const sessionKey = getSessionKey(ctx);
|
|
44
|
+
|
|
45
|
+
// First ensure runtime exists, then restore from session
|
|
46
|
+
runtimeStore.ensure(sessionKey);
|
|
47
|
+
runtimeStore.restoreState(sessionKey, ctx.sessionManager);
|
|
48
|
+
|
|
49
|
+
const runtime = getRuntime(ctx);
|
|
50
|
+
|
|
51
|
+
// Detect if we're inside a variation directory
|
|
52
|
+
const context = detectVariationContext(ctx.cwd);
|
|
53
|
+
if (context.inVariation && context.variationId) {
|
|
54
|
+
// Reconnect to existing variation
|
|
55
|
+
const existing = runtime.state.variations.find((v) => v.id === context.variationId);
|
|
56
|
+
if (existing) {
|
|
57
|
+
runtime.state.activeVariationId = existing.id;
|
|
58
|
+
ctx.ui.notify(`Reconnected to variation: ${existing.name}`, 'info');
|
|
59
|
+
// Persist the reconnection
|
|
60
|
+
runtimeStore.persistState(sessionKey, pi);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// System prompt extension when in variation
|
|
66
|
+
pi.on('before_agent_start', async (event, ctx) => {
|
|
67
|
+
const runtime = getRuntime(ctx);
|
|
68
|
+
if (!runtime.state.activeVariationId) return;
|
|
69
|
+
|
|
70
|
+
const variation = runtime.state.variations.find(
|
|
71
|
+
(v) => v.id === runtime.state.activeVariationId
|
|
72
|
+
);
|
|
73
|
+
if (!variation) return;
|
|
74
|
+
|
|
75
|
+
const typeLabel =
|
|
76
|
+
variation.type === 'cow'
|
|
77
|
+
? 'Copy-on-Write (APFS clonefile)'
|
|
78
|
+
: variation.type === 'worktree'
|
|
79
|
+
? 'Git worktree'
|
|
80
|
+
: 'Full copy';
|
|
81
|
+
|
|
82
|
+
const mergeInfo = variation.branchName
|
|
83
|
+
? '\n- Merge: Use merge_variation for git three-way merge (conflict detection enabled)'
|
|
84
|
+
: '';
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
systemPrompt:
|
|
88
|
+
event.systemPrompt +
|
|
89
|
+
`
|
|
90
|
+
|
|
91
|
+
## Active Variation: "${variation.name}"
|
|
92
|
+
You are working in an isolated variation. All operations automatically redirect:
|
|
93
|
+
|
|
94
|
+
**File Operations:** read, edit, write → variation directory (${variation.path})
|
|
95
|
+
**Shell Commands:** bash → executes in variation directory with cwd=${variation.path}
|
|
96
|
+
**Environment:** PI_VARIATION=${variation.name}, PI_VARIATION_PATH, PI_SOURCE_PATH set
|
|
97
|
+
|
|
98
|
+
- Source: ${variation.sourcePath}
|
|
99
|
+
- Variation: ${variation.path}
|
|
100
|
+
- Type: ${typeLabel}${mergeInfo}
|
|
101
|
+
|
|
102
|
+
Use tools normally - the extension handles all redirection. When finished, use merge_variation to merge back to source.
|
|
103
|
+
`,
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Session end - persist final state
|
|
108
|
+
pi.on('session_shutdown', async (_event, ctx) => {
|
|
109
|
+
runtimeStore.persistState(getSessionKey(ctx), pi);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extension configuration
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_CONFIG = {
|
|
6
|
+
copy: [
|
|
7
|
+
'.env',
|
|
8
|
+
'.env.*',
|
|
9
|
+
'.envrc',
|
|
10
|
+
'.npmrc',
|
|
11
|
+
'.yarnrc',
|
|
12
|
+
'.yarnrc.yml',
|
|
13
|
+
'.tool-versions',
|
|
14
|
+
'.node-version',
|
|
15
|
+
'.python-version',
|
|
16
|
+
'docker-compose.override.yml',
|
|
17
|
+
],
|
|
18
|
+
symlink: [
|
|
19
|
+
'node_modules',
|
|
20
|
+
'.next',
|
|
21
|
+
'.nuxt',
|
|
22
|
+
'.angular',
|
|
23
|
+
'.turbo',
|
|
24
|
+
'target',
|
|
25
|
+
'.venv',
|
|
26
|
+
'venv',
|
|
27
|
+
'vendor',
|
|
28
|
+
],
|
|
29
|
+
postCreate: [],
|
|
30
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Low-level CoW clone using platform-specific methods.
|
|
3
|
+
* This is the filesystem-level clone only — no git branch setup.
|
|
4
|
+
* For the full variation creation with git branch, use variation/create-cow.ts.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as os from 'os';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import { exec } from 'child_process';
|
|
10
|
+
import { promisify } from 'util';
|
|
11
|
+
import { promises as fs } from 'fs';
|
|
12
|
+
import { detectCoWSupport } from './detection';
|
|
13
|
+
|
|
14
|
+
const execAsync = promisify(exec);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create a CoW clone using platform-specific methods.
|
|
18
|
+
* Copies the entire source directory (including .git if present)
|
|
19
|
+
* using copy-on-write for zero-additional-space clones.
|
|
20
|
+
*/
|
|
21
|
+
export async function createCoWClone(sourcePath: string, destPath: string): Promise<void> {
|
|
22
|
+
const platform = os.platform();
|
|
23
|
+
const cowSupport = await detectCoWSupport(path.dirname(destPath));
|
|
24
|
+
|
|
25
|
+
if (!cowSupport.supported) {
|
|
26
|
+
throw new Error('CoW not supported on this platform/filesystem');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (platform === 'darwin' && cowSupport.method === 'clonefile') {
|
|
30
|
+
// macOS: Use cp -c for clonefile
|
|
31
|
+
await execAsync(`cp -c -R "${sourcePath}" "${destPath}"`);
|
|
32
|
+
} else if (platform === 'linux' && cowSupport.method === 'reflink') {
|
|
33
|
+
// Linux: Use cp --reflink=auto
|
|
34
|
+
await execAsync(`cp -a --reflink=auto "${sourcePath}" "${destPath}"`);
|
|
35
|
+
} else {
|
|
36
|
+
throw new Error(`Unsupported CoW method: ${cowSupport.method}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Remove the .pi/variations directory from the clone to avoid recursion
|
|
40
|
+
const nestedVariations = path.join(destPath, '.pi', 'variations');
|
|
41
|
+
try {
|
|
42
|
+
await fs.rm(nestedVariations, { recursive: true, force: true });
|
|
43
|
+
} catch {
|
|
44
|
+
// Ignore if doesn't exist
|
|
45
|
+
}
|
|
46
|
+
}
|