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,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for git-backed CoW variation features
|
|
3
|
+
*
|
|
4
|
+
* Tests the new behavior:
|
|
5
|
+
* - CoW variations in git repos get a branch and merge base commit
|
|
6
|
+
* - Merge uses git three-way merge (via remote fetch) for CoW+branch
|
|
7
|
+
* - Merge detects conflicts instead of silently overwriting
|
|
8
|
+
* - Copy merge backs up conflict files
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
12
|
+
import { promises as fs } from 'fs';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import os from 'os';
|
|
15
|
+
import crypto from 'crypto';
|
|
16
|
+
import { exec } from 'child_process';
|
|
17
|
+
import { promisify } from 'util';
|
|
18
|
+
|
|
19
|
+
const execAsync = promisify(exec);
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
createVariation,
|
|
23
|
+
removeVariation,
|
|
24
|
+
mergeVariation,
|
|
25
|
+
hasGitRepo,
|
|
26
|
+
} from '../../src/variation/index';
|
|
27
|
+
import { detectMergeStrategy } from '../../src/variation/merge/strategy';
|
|
28
|
+
import type { Variation } from '../../src/types/index';
|
|
29
|
+
|
|
30
|
+
// Helper to create a temporary test directory
|
|
31
|
+
async function createTempDir(prefix: string): Promise<string> {
|
|
32
|
+
const tmpDir = os.tmpdir();
|
|
33
|
+
const randomId = crypto.randomBytes(4).toString('hex');
|
|
34
|
+
const dir = path.join(tmpDir, `${prefix}-${randomId}`);
|
|
35
|
+
await fs.mkdir(dir, { recursive: true });
|
|
36
|
+
return dir;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Helper to cleanup a directory
|
|
40
|
+
async function cleanupDir(dir: string): Promise<void> {
|
|
41
|
+
try {
|
|
42
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
43
|
+
} catch {
|
|
44
|
+
// Ignore cleanup errors
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Helper to initialize a git repo with an initial commit
|
|
49
|
+
async function initGitRepo(dir: string): Promise<void> {
|
|
50
|
+
await execAsync(`git init "${dir}"`);
|
|
51
|
+
await execAsync(`git -C "${dir}" config user.email "test@test.com"`);
|
|
52
|
+
await execAsync(`git -C "${dir}" config user.name "Test"`);
|
|
53
|
+
await fs.writeFile(path.join(dir, 'README.md'), '# test');
|
|
54
|
+
await execAsync(`git -C "${dir}" add README.md`);
|
|
55
|
+
await execAsync(`git -C "${dir}" commit -m "initial commit"`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('CoW variation with git branch', () => {
|
|
59
|
+
let sourceDir: string;
|
|
60
|
+
|
|
61
|
+
beforeEach(async () => {
|
|
62
|
+
sourceDir = await createTempDir('cow-git-source');
|
|
63
|
+
await initGitRepo(sourceDir);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(async () => {
|
|
67
|
+
await cleanupDir(sourceDir);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should detect git merge strategy for CoW variations with a branch', async () => {
|
|
71
|
+
// Simulate a CoW variation that has a branchName
|
|
72
|
+
const variation: Variation = {
|
|
73
|
+
id: 'var-test',
|
|
74
|
+
name: 'test-branch',
|
|
75
|
+
path: path.join(sourceDir, '.pi', 'variations', 'test-branch'),
|
|
76
|
+
sourcePath: sourceDir,
|
|
77
|
+
type: 'cow',
|
|
78
|
+
createdAt: new Date().toISOString(),
|
|
79
|
+
lastAccessed: new Date().toISOString(),
|
|
80
|
+
branchName: 'var/test-branch',
|
|
81
|
+
mergeBaseCommit: 'abc123',
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const strategy = await detectMergeStrategy(variation, sourceDir);
|
|
85
|
+
expect(strategy).toBe('git');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should detect rsync/copy strategy for CoW variations without a branch', async () => {
|
|
89
|
+
const variation: Variation = {
|
|
90
|
+
id: 'var-test',
|
|
91
|
+
name: 'test-no-branch',
|
|
92
|
+
path: path.join(sourceDir, '.pi', 'variations', 'test-no-branch'),
|
|
93
|
+
sourcePath: sourceDir,
|
|
94
|
+
type: 'cow',
|
|
95
|
+
createdAt: new Date().toISOString(),
|
|
96
|
+
lastAccessed: new Date().toISOString(),
|
|
97
|
+
// No branchName — source was not a git repo at creation time
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const strategy = await detectMergeStrategy(variation, sourceDir);
|
|
101
|
+
// Should be rsync or copy (not git)
|
|
102
|
+
expect(['rsync', 'copy']).toContain(strategy);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should detect git strategy for worktree variations', async () => {
|
|
106
|
+
const variation: Variation = {
|
|
107
|
+
id: 'var-test',
|
|
108
|
+
name: 'test-worktree',
|
|
109
|
+
path: path.join(sourceDir, '.pi', 'variations', 'test-worktree'),
|
|
110
|
+
sourcePath: sourceDir,
|
|
111
|
+
type: 'worktree',
|
|
112
|
+
createdAt: new Date().toISOString(),
|
|
113
|
+
lastAccessed: new Date().toISOString(),
|
|
114
|
+
branchName: 'var/test-worktree',
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const strategy = await detectMergeStrategy(variation, sourceDir);
|
|
118
|
+
expect(strategy).toBe('git');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('git-backed CoW merge via remote fetch', () => {
|
|
123
|
+
let sourceDir: string;
|
|
124
|
+
|
|
125
|
+
beforeEach(async () => {
|
|
126
|
+
sourceDir = await createTempDir('cow-merge-source');
|
|
127
|
+
await initGitRepo(sourceDir);
|
|
128
|
+
await fs.writeFile(path.join(sourceDir, 'app.js'), '// original');
|
|
129
|
+
await execAsync(`git -C "${sourceDir}" add app.js`);
|
|
130
|
+
await execAsync(`git -C "${sourceDir}" commit -m "add app.js"`);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
afterEach(async () => {
|
|
134
|
+
await cleanupDir(sourceDir);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should merge a CoW variation with branch via git remote fetch', async () => {
|
|
138
|
+
// Create a variation directory that simulates a CoW clone with its own .git
|
|
139
|
+
const variationDir = path.join(sourceDir, '.pi', 'variations', 'test-merge');
|
|
140
|
+
await fs.mkdir(variationDir, { recursive: true });
|
|
141
|
+
|
|
142
|
+
// Clone the source repo into the variation (simulates CoW clone including .git)
|
|
143
|
+
await execAsync(`git clone "${sourceDir}" "${variationDir}"`);
|
|
144
|
+
await execAsync(`git -C "${variationDir}" config user.email "test@test.com"`);
|
|
145
|
+
await execAsync(`git -C "${variationDir}" config user.name "Test"`);
|
|
146
|
+
|
|
147
|
+
// Create branch and make changes
|
|
148
|
+
await execAsync(`git -C "${variationDir}" checkout -b var/test-merge`);
|
|
149
|
+
await fs.writeFile(path.join(variationDir, 'app.js'), '// modified by variation');
|
|
150
|
+
await execAsync(`git -C "${variationDir}" add app.js`);
|
|
151
|
+
await execAsync(`git -C "${variationDir}" commit -m "modify app.js"`);
|
|
152
|
+
|
|
153
|
+
const variation: Variation = {
|
|
154
|
+
id: 'var-test',
|
|
155
|
+
name: 'test-merge',
|
|
156
|
+
path: variationDir,
|
|
157
|
+
sourcePath: sourceDir,
|
|
158
|
+
type: 'cow',
|
|
159
|
+
createdAt: new Date().toISOString(),
|
|
160
|
+
lastAccessed: new Date().toISOString(),
|
|
161
|
+
branchName: 'var/test-merge',
|
|
162
|
+
mergeBaseCommit: (await execAsync(`git -C "${sourceDir}" rev-parse HEAD`)).stdout.trim(),
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Merge using git strategy
|
|
166
|
+
await mergeVariation(variation, sourceDir, { strategy: 'git' });
|
|
167
|
+
|
|
168
|
+
// Verify the merge happened
|
|
169
|
+
const content = await fs.readFile(path.join(sourceDir, 'app.js'), 'utf-8');
|
|
170
|
+
expect(content).toBe('// modified by variation');
|
|
171
|
+
|
|
172
|
+
// Verify no leftover temporary remote
|
|
173
|
+
const { stdout: remotes } = await execAsync(`git -C "${sourceDir}" remote`);
|
|
174
|
+
expect(remotes.trim()).toBe('');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should detect conflicts when another variation was merged first', async () => {
|
|
178
|
+
// Setup: create two variation directories simulating CoW clones
|
|
179
|
+
const varDir1 = path.join(sourceDir, '.pi', 'variations', 'var-a');
|
|
180
|
+
const varDir2 = path.join(sourceDir, '.pi', 'variations', 'var-b');
|
|
181
|
+
|
|
182
|
+
for (const dir of [varDir1, varDir2]) {
|
|
183
|
+
await execAsync(`git clone "${sourceDir}" "${dir}"`);
|
|
184
|
+
await execAsync(`git -C "${dir}" config user.email "test@test.com"`);
|
|
185
|
+
await execAsync(`git -C "${dir}" config user.name "Test"`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Variation A: modify app.js
|
|
189
|
+
await execAsync(`git -C "${varDir1}" checkout -b var/var-a`);
|
|
190
|
+
await fs.writeFile(path.join(varDir1, 'app.js'), '// changed by A');
|
|
191
|
+
await execAsync(`git -C "${varDir1}" add app.js`);
|
|
192
|
+
await execAsync(`git -C "${varDir1}" commit -m "A modifies app.js"`);
|
|
193
|
+
|
|
194
|
+
// Variation B: also modify app.js (differently)
|
|
195
|
+
await execAsync(`git -C "${varDir2}" checkout -b var/var-b`);
|
|
196
|
+
await fs.writeFile(path.join(varDir2, 'app.js'), '// changed by B');
|
|
197
|
+
await execAsync(`git -C "${varDir2}" add app.js`);
|
|
198
|
+
await execAsync(`git -C "${varDir2}" commit -m "B modifies app.js"`);
|
|
199
|
+
|
|
200
|
+
const baseCommit = (await execAsync(`git -C "${sourceDir}" rev-parse HEAD`)).stdout.trim();
|
|
201
|
+
|
|
202
|
+
const variationA: Variation = {
|
|
203
|
+
id: 'var-a',
|
|
204
|
+
name: 'var-a',
|
|
205
|
+
path: varDir1,
|
|
206
|
+
sourcePath: sourceDir,
|
|
207
|
+
type: 'cow',
|
|
208
|
+
createdAt: new Date().toISOString(),
|
|
209
|
+
lastAccessed: new Date().toISOString(),
|
|
210
|
+
branchName: 'var/var-a',
|
|
211
|
+
mergeBaseCommit: baseCommit,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const variationB: Variation = {
|
|
215
|
+
id: 'var-b',
|
|
216
|
+
name: 'var-b',
|
|
217
|
+
path: varDir2,
|
|
218
|
+
sourcePath: sourceDir,
|
|
219
|
+
type: 'cow',
|
|
220
|
+
createdAt: new Date().toISOString(),
|
|
221
|
+
lastAccessed: new Date().toISOString(),
|
|
222
|
+
branchName: 'var/var-b',
|
|
223
|
+
mergeBaseCommit: baseCommit,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// Merge A first — should succeed
|
|
227
|
+
await mergeVariation(variationA, sourceDir, { strategy: 'git' });
|
|
228
|
+
|
|
229
|
+
// Merge B second — should detect conflict (both modified same file)
|
|
230
|
+
await expect(mergeVariation(variationB, sourceDir, { strategy: 'git' })).rejects.toThrow(
|
|
231
|
+
/conflict/i
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
// Clean up the merge state (abort the conflicted merge)
|
|
235
|
+
try {
|
|
236
|
+
await execAsync(`git -C "${sourceDir}" merge --abort`);
|
|
237
|
+
} catch {
|
|
238
|
+
// Ignore
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('should auto-merge non-conflicting changes from sequential variations', async () => {
|
|
243
|
+
const varDir1 = path.join(sourceDir, '.pi', 'variations', 'seq-a');
|
|
244
|
+
const varDir2 = path.join(sourceDir, '.pi', 'variations', 'seq-b');
|
|
245
|
+
|
|
246
|
+
for (const dir of [varDir1, varDir2]) {
|
|
247
|
+
await execAsync(`git clone "${sourceDir}" "${dir}"`);
|
|
248
|
+
await execAsync(`git -C "${dir}" config user.email "test@test.com"`);
|
|
249
|
+
await execAsync(`git -C "${dir}" config user.name "Test"`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Variation A: add new file
|
|
253
|
+
await execAsync(`git -C "${varDir1}" checkout -b var/seq-a`);
|
|
254
|
+
await fs.writeFile(path.join(varDir1, 'new-file-a.js'), '// from A');
|
|
255
|
+
await execAsync(`git -C "${varDir1}" add new-file-a.js`);
|
|
256
|
+
await execAsync(`git -C "${varDir1}" commit -m "A adds new-file-a.js"`);
|
|
257
|
+
|
|
258
|
+
// Variation B: add different new file
|
|
259
|
+
await execAsync(`git -C "${varDir2}" checkout -b var/seq-b`);
|
|
260
|
+
await fs.writeFile(path.join(varDir2, 'new-file-b.js'), '// from B');
|
|
261
|
+
await execAsync(`git -C "${varDir2}" add new-file-b.js`);
|
|
262
|
+
await execAsync(`git -C "${varDir2}" commit -m "B adds new-file-b.js"`);
|
|
263
|
+
|
|
264
|
+
const baseCommit = (await execAsync(`git -C "${sourceDir}" rev-parse HEAD`)).stdout.trim();
|
|
265
|
+
|
|
266
|
+
const variationA: Variation = {
|
|
267
|
+
id: 'seq-a',
|
|
268
|
+
name: 'seq-a',
|
|
269
|
+
path: varDir1,
|
|
270
|
+
sourcePath: sourceDir,
|
|
271
|
+
type: 'cow',
|
|
272
|
+
createdAt: new Date().toISOString(),
|
|
273
|
+
lastAccessed: new Date().toISOString(),
|
|
274
|
+
branchName: 'var/seq-a',
|
|
275
|
+
mergeBaseCommit: baseCommit,
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const variationB: Variation = {
|
|
279
|
+
id: 'seq-b',
|
|
280
|
+
name: 'seq-b',
|
|
281
|
+
path: varDir2,
|
|
282
|
+
sourcePath: sourceDir,
|
|
283
|
+
type: 'cow',
|
|
284
|
+
createdAt: new Date().toISOString(),
|
|
285
|
+
lastAccessed: new Date().toISOString(),
|
|
286
|
+
branchName: 'var/seq-b',
|
|
287
|
+
mergeBaseCommit: baseCommit,
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
// Merge A
|
|
291
|
+
await mergeVariation(variationA, sourceDir, { strategy: 'git' });
|
|
292
|
+
|
|
293
|
+
// Merge B — should succeed because no conflicting files
|
|
294
|
+
await mergeVariation(variationB, sourceDir, { strategy: 'git' });
|
|
295
|
+
|
|
296
|
+
// Both files should exist in source
|
|
297
|
+
expect(await fs.readFile(path.join(sourceDir, 'new-file-a.js'), 'utf-8')).toBe('// from A');
|
|
298
|
+
expect(await fs.readFile(path.join(sourceDir, 'new-file-b.js'), 'utf-8')).toBe('// from B');
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe('copy merge with backup protection', () => {
|
|
303
|
+
let sourceDir: string;
|
|
304
|
+
|
|
305
|
+
beforeEach(async () => {
|
|
306
|
+
sourceDir = await createTempDir('copy-merge-source');
|
|
307
|
+
await fs.writeFile(path.join(sourceDir, 'file.txt'), 'original');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
afterEach(async () => {
|
|
311
|
+
await cleanupDir(sourceDir);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('should back up source files that differ from variation before overwrite', async () => {
|
|
315
|
+
// Create a variation
|
|
316
|
+
const variation = await createVariation(sourceDir, {
|
|
317
|
+
name: 'copy-overwrite',
|
|
318
|
+
type: 'copy',
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// Modify the same file in both source and variation
|
|
322
|
+
await fs.writeFile(path.join(sourceDir, 'file.txt'), 'source changed');
|
|
323
|
+
await fs.writeFile(path.join(variation.path, 'file.txt'), 'variation changed');
|
|
324
|
+
|
|
325
|
+
// Merge using copy strategy
|
|
326
|
+
const result = await mergeVariation(variation, sourceDir, { strategy: 'copy' });
|
|
327
|
+
|
|
328
|
+
// The variation's version should be in the source now
|
|
329
|
+
expect(await fs.readFile(path.join(sourceDir, 'file.txt'), 'utf-8')).toBe('variation changed');
|
|
330
|
+
|
|
331
|
+
// The source's original version should be backed up
|
|
332
|
+
const backupPath = path.join(sourceDir, '.pi', 'merge-backup', variation.name, 'file.txt');
|
|
333
|
+
expect(await fs.readFile(backupPath, 'utf-8')).toBe('source changed');
|
|
334
|
+
|
|
335
|
+
// The result should mention the overwrite
|
|
336
|
+
expect(result).toContain('overwritten');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('should not back up files when source and variation are identical', async () => {
|
|
340
|
+
const variation = await createVariation(sourceDir, {
|
|
341
|
+
name: 'copy-identical',
|
|
342
|
+
type: 'copy',
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// Don't modify anything — source and variation are identical
|
|
346
|
+
// Merge using copy strategy
|
|
347
|
+
const result = await mergeVariation(variation, sourceDir, { strategy: 'copy' });
|
|
348
|
+
|
|
349
|
+
// No backup directory should be created
|
|
350
|
+
const backupDir = path.join(sourceDir, '.pi', 'merge-backup', variation.name);
|
|
351
|
+
try {
|
|
352
|
+
const entries = await fs.readdir(backupDir);
|
|
353
|
+
// If it exists, it should be empty
|
|
354
|
+
expect(entries.length).toBe(0);
|
|
355
|
+
} catch {
|
|
356
|
+
// Backup dir doesn't exist — expected when no files differ
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
expect(result).not.toContain('overwritten');
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
describe('variation removal cleans up temporary remotes', () => {
|
|
364
|
+
let sourceDir: string;
|
|
365
|
+
|
|
366
|
+
beforeEach(async () => {
|
|
367
|
+
sourceDir = await createTempDir('remove-remote-source');
|
|
368
|
+
await initGitRepo(sourceDir);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
afterEach(async () => {
|
|
372
|
+
await cleanupDir(sourceDir);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('should clean up temporary remote when removing CoW variation with branch', async () => {
|
|
376
|
+
// Create a variation directory simulating a CoW clone
|
|
377
|
+
const variationDir = path.join(sourceDir, '.pi', 'variations', 'remove-test');
|
|
378
|
+
await execAsync(`git clone "${sourceDir}" "${variationDir}"`);
|
|
379
|
+
await execAsync(`git -C "${variationDir}" config user.email "test@test.com"`);
|
|
380
|
+
await execAsync(`git -C "${variationDir}" config user.name "Test"`);
|
|
381
|
+
await execAsync(`git -C "${variationDir}" checkout -b var/remove-test`);
|
|
382
|
+
|
|
383
|
+
// Simulate a leftover remote (as if a merge was attempted)
|
|
384
|
+
await execAsync(`git -C "${sourceDir}" remote add pi-var-remove-test "${variationDir}"`);
|
|
385
|
+
|
|
386
|
+
const variation: Variation = {
|
|
387
|
+
id: 'var-test',
|
|
388
|
+
name: 'remove-test',
|
|
389
|
+
path: variationDir,
|
|
390
|
+
sourcePath: sourceDir,
|
|
391
|
+
type: 'cow',
|
|
392
|
+
createdAt: new Date().toISOString(),
|
|
393
|
+
lastAccessed: new Date().toISOString(),
|
|
394
|
+
branchName: 'var/remove-test',
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
// Remove the variation
|
|
398
|
+
await removeVariation(variation);
|
|
399
|
+
|
|
400
|
+
// Verify the remote was cleaned up
|
|
401
|
+
const { stdout: remotes } = await execAsync(`git -C "${sourceDir}" remote`);
|
|
402
|
+
expect(remotes.trim()).not.toContain('pi-var-remove-test');
|
|
403
|
+
});
|
|
404
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for EDR detection utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
import type { EDRDetectionResult } from '../../src/types/index';
|
|
7
|
+
import { hasSlowCoWEDR, getEDRSummary } from '../../src/edr/index';
|
|
8
|
+
|
|
9
|
+
describe('hasSlowCoWEDR', () => {
|
|
10
|
+
it('should return true when known slow CoW EDR is detected', () => {
|
|
11
|
+
const result: EDRDetectionResult = {
|
|
12
|
+
detected: true,
|
|
13
|
+
found: ['Falcon'],
|
|
14
|
+
details: [{ process: 'Falcon', product: 'CrowdStrike Falcon', knownSlowCoW: true }],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
expect(hasSlowCoWEDR(result)).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should return false when only non-slow EDR is detected', () => {
|
|
21
|
+
const result: EDRDetectionResult = {
|
|
22
|
+
detected: true,
|
|
23
|
+
found: ['eset_daemon'],
|
|
24
|
+
details: [{ process: 'eset_daemon', product: 'ESET', knownSlowCoW: false }],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
expect(hasSlowCoWEDR(result)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should return false when no EDR is detected', () => {
|
|
31
|
+
const result: EDRDetectionResult = {
|
|
32
|
+
detected: false,
|
|
33
|
+
found: [],
|
|
34
|
+
details: [],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
expect(hasSlowCoWEDR(result)).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should return true when mix of slow and non-slow EDRs detected', () => {
|
|
41
|
+
const result: EDRDetectionResult = {
|
|
42
|
+
detected: true,
|
|
43
|
+
found: ['Falcon', 'eset_daemon'],
|
|
44
|
+
details: [
|
|
45
|
+
{ process: 'Falcon', product: 'CrowdStrike Falcon', knownSlowCoW: true },
|
|
46
|
+
{ process: 'eset_daemon', product: 'ESET', knownSlowCoW: false },
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
expect(hasSlowCoWEDR(result)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('getEDRSummary', () => {
|
|
55
|
+
it('should return no detection message when no EDR found', () => {
|
|
56
|
+
const result: EDRDetectionResult = {
|
|
57
|
+
detected: false,
|
|
58
|
+
found: [],
|
|
59
|
+
details: [],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const summary = getEDRSummary(result);
|
|
63
|
+
expect(summary).toBe('No EDR/AV software detected');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should list slow CoW EDRs first', () => {
|
|
67
|
+
const result: EDRDetectionResult = {
|
|
68
|
+
detected: true,
|
|
69
|
+
found: ['Falcon', 'eset_daemon'],
|
|
70
|
+
details: [
|
|
71
|
+
{ process: 'Falcon', product: 'CrowdStrike Falcon', knownSlowCoW: true },
|
|
72
|
+
{ process: 'eset_daemon', product: 'ESET', knownSlowCoW: false },
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const summary = getEDRSummary(result);
|
|
77
|
+
expect(summary).toContain('CoW-impacting EDR detected: CrowdStrike Falcon');
|
|
78
|
+
expect(summary).toContain('Other security software: ESET');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should only list slow CoW EDRs when no others present', () => {
|
|
82
|
+
const result: EDRDetectionResult = {
|
|
83
|
+
detected: true,
|
|
84
|
+
found: ['Falcon'],
|
|
85
|
+
details: [{ process: 'Falcon', product: 'CrowdStrike Falcon', knownSlowCoW: true }],
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const summary = getEDRSummary(result);
|
|
89
|
+
expect(summary).toBe('CoW-impacting EDR detected: CrowdStrike Falcon');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should only list other EDRs when no slow ones present', () => {
|
|
93
|
+
const result: EDRDetectionResult = {
|
|
94
|
+
detected: true,
|
|
95
|
+
found: ['eset_daemon', 'kav'],
|
|
96
|
+
details: [
|
|
97
|
+
{ process: 'eset_daemon', product: 'ESET', knownSlowCoW: false },
|
|
98
|
+
{ process: 'kav', product: 'Kaspersky', knownSlowCoW: false },
|
|
99
|
+
],
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const summary = getEDRSummary(result);
|
|
103
|
+
expect(summary).toContain('Other security software:');
|
|
104
|
+
expect(summary).toContain('ESET');
|
|
105
|
+
expect(summary).toContain('Kaspersky');
|
|
106
|
+
expect(summary).not.toContain('CoW-impacting');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should handle unknown EDR products', () => {
|
|
110
|
+
const result: EDRDetectionResult = {
|
|
111
|
+
detected: true,
|
|
112
|
+
found: ['unknown_edr'],
|
|
113
|
+
details: [{ process: 'unknown_edr', product: 'unknown_edr', knownSlowCoW: false }],
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const summary = getEDRSummary(result);
|
|
117
|
+
expect(summary).toContain('unknown_edr');
|
|
118
|
+
});
|
|
119
|
+
});
|