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.
Files changed (64) hide show
  1. package/.github/FUNDING.yml +4 -0
  2. package/.prettierrc +7 -0
  3. package/README.md +240 -0
  4. package/commitlint.config.cjs +3 -0
  5. package/extensions/pi-var/__tests__/integration/smoke.test.ts +308 -0
  6. package/extensions/pi-var/__tests__/unit/command.test.ts +322 -0
  7. package/extensions/pi-var/__tests__/unit/cow-detection.test.ts +340 -0
  8. package/extensions/pi-var/__tests__/unit/cow-git-merge.test.ts +404 -0
  9. package/extensions/pi-var/__tests__/unit/edr.test.ts +119 -0
  10. package/extensions/pi-var/__tests__/unit/environment.test.ts +369 -0
  11. package/extensions/pi-var/__tests__/unit/file-redirect.test.ts +295 -0
  12. package/extensions/pi-var/__tests__/unit/store.test.ts +489 -0
  13. package/extensions/pi-var/__tests__/unit/tools.test.ts +133 -0
  14. package/extensions/pi-var/__tests__/unit/variations.test.ts +352 -0
  15. package/extensions/pi-var/index.ts +111 -0
  16. package/extensions/pi-var/src/config.ts +30 -0
  17. package/extensions/pi-var/src/cow/clone.ts +46 -0
  18. package/extensions/pi-var/src/cow/detection.ts +215 -0
  19. package/extensions/pi-var/src/cow/index.ts +6 -0
  20. package/extensions/pi-var/src/edr/constants.ts +199 -0
  21. package/extensions/pi-var/src/edr/crowdstrike.ts +62 -0
  22. package/extensions/pi-var/src/edr/detection.ts +97 -0
  23. package/extensions/pi-var/src/edr/index.ts +6 -0
  24. package/extensions/pi-var/src/environment/copy-env.ts +79 -0
  25. package/extensions/pi-var/src/environment/detect.ts +91 -0
  26. package/extensions/pi-var/src/environment/index.ts +54 -0
  27. package/extensions/pi-var/src/environment/marker.ts +42 -0
  28. package/extensions/pi-var/src/environment/symlink.ts +82 -0
  29. package/extensions/pi-var/src/gitignore/manager.ts +77 -0
  30. package/extensions/pi-var/src/state/index.ts +7 -0
  31. package/extensions/pi-var/src/state/keys.ts +14 -0
  32. package/extensions/pi-var/src/state/store.ts +110 -0
  33. package/extensions/pi-var/src/state/types.ts +70 -0
  34. package/extensions/pi-var/src/tools/command/clean.ts +87 -0
  35. package/extensions/pi-var/src/tools/command/index.ts +95 -0
  36. package/extensions/pi-var/src/tools/command/status.ts +58 -0
  37. package/extensions/pi-var/src/tools/command/stop.ts +37 -0
  38. package/extensions/pi-var/src/tools/file-redirect.ts +126 -0
  39. package/extensions/pi-var/src/tools/redirect/bash.ts +75 -0
  40. package/extensions/pi-var/src/tools/redirect/edit.ts +60 -0
  41. package/extensions/pi-var/src/tools/redirect/index.ts +64 -0
  42. package/extensions/pi-var/src/tools/redirect/read.ts +61 -0
  43. package/extensions/pi-var/src/tools/redirect/write.ts +60 -0
  44. package/extensions/pi-var/src/tools/variation/create.ts +108 -0
  45. package/extensions/pi-var/src/tools/variation/merge.ts +80 -0
  46. package/extensions/pi-var/src/types/index.ts +139 -0
  47. package/extensions/pi-var/src/utils/names.ts +120 -0
  48. package/extensions/pi-var/src/variation/create-copy.ts +35 -0
  49. package/extensions/pi-var/src/variation/create-cow.ts +81 -0
  50. package/extensions/pi-var/src/variation/create-worktree.ts +31 -0
  51. package/extensions/pi-var/src/variation/create.ts +127 -0
  52. package/extensions/pi-var/src/variation/index.ts +9 -0
  53. package/extensions/pi-var/src/variation/merge/copy.ts +151 -0
  54. package/extensions/pi-var/src/variation/merge/git.ts +176 -0
  55. package/extensions/pi-var/src/variation/merge/index.ts +36 -0
  56. package/extensions/pi-var/src/variation/merge/rsync.ts +74 -0
  57. package/extensions/pi-var/src/variation/merge/strategy.ts +62 -0
  58. package/extensions/pi-var/src/variation/paths.ts +26 -0
  59. package/extensions/pi-var/src/variation/remove.ts +79 -0
  60. package/npm-shrinkwrap.json +6908 -0
  61. package/package.json +63 -0
  62. package/skills/variations/SKILL.md +182 -0
  63. package/tsconfig.json +25 -0
  64. package/vitest.config.ts +19 -0
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Variation creation orchestration
3
+ */
4
+
5
+ import * as path from 'path';
6
+ import { promises as fs } from 'fs';
7
+ import type { Variation, VariationType, CreateVariationOptions } from '../types/index';
8
+ import { detectCoWSupport } from '../cow/index';
9
+ import { hasGitRepo } from './merge/strategy';
10
+ import { createCoWVariation } from './create-cow';
11
+ import { createWorktreeVariation } from './create-worktree';
12
+ import { createCopyVariation } from './create-copy';
13
+ import { ensureGlobalGitignore } from '../gitignore/manager';
14
+ import { getProjectName, getProjectHash, generateVariationId } from './paths';
15
+ import { generateVariationName } from '../utils/names';
16
+
17
+ /**
18
+ * Get the base directory for variations
19
+ */
20
+ function getVariationsBaseDir(sourcePath: string): string {
21
+ const projectName = getProjectName(sourcePath);
22
+ const projectHash = getProjectHash(sourcePath);
23
+ return path.join(sourcePath, '.pi', 'variations', `${projectName}-${projectHash}`);
24
+ }
25
+
26
+ /**
27
+ * Create a new variation
28
+ * Auto-selects type: cow > worktree > copy
29
+ *
30
+ * CoW variations in git repos automatically get a branch and initial commit
31
+ * for proper three-way merge semantics (see create-cow.ts).
32
+ */
33
+ export async function createVariation(
34
+ sourcePath: string,
35
+ options: CreateVariationOptions = {}
36
+ ): Promise<Variation> {
37
+ const name = options.name || generateVariationName();
38
+ const id = generateVariationId();
39
+ const baseDir = getVariationsBaseDir(sourcePath);
40
+ const variationPath = path.join(baseDir, name);
41
+
42
+ // Ensure base directory exists
43
+ await fs.mkdir(baseDir, { recursive: true });
44
+
45
+ // Check if variation already exists
46
+ try {
47
+ await fs.access(variationPath);
48
+ throw new Error(`Variation "${name}" already exists at ${variationPath}`);
49
+ } catch (err) {
50
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
51
+ throw err;
52
+ }
53
+ }
54
+
55
+ let type: VariationType;
56
+ let branchName: string | undefined;
57
+ let mergeBaseCommit: string | undefined;
58
+
59
+ // Determine variation type
60
+ if (options.type) {
61
+ type = options.type;
62
+ } else {
63
+ // Auto-select based on CoW detection result (includes EDR awareness)
64
+ const cowResult = await detectCoWSupport(baseDir);
65
+ const isGitRepo = await hasGitRepo(sourcePath);
66
+
67
+ // Use the recommended type from detection
68
+ type = cowResult.recommendedType;
69
+
70
+ // If worktree is recommended but not a git repo, fall back to copy
71
+ if (type === 'worktree' && !isGitRepo) {
72
+ type = 'copy';
73
+ }
74
+
75
+ // Log EDR detection info if relevant (for debugging/visibility)
76
+ if (cowResult.edr?.detected) {
77
+ const edrList = cowResult.edr.products.join(', ');
78
+ console.log(`[pi-var] Security software detected: ${edrList}`);
79
+
80
+ if (cowResult.edr.hasSlowCoWEDR && cowResult.performance && !cowResult.performance.fast) {
81
+ console.log(
82
+ `[pi-var] CoW performance impacted (${cowResult.performance.durationMs.toFixed(1)}ms). ` +
83
+ `Using ${type} instead.`
84
+ );
85
+ }
86
+ }
87
+ }
88
+
89
+ // Create variation based on type
90
+ switch (type) {
91
+ case 'cow': {
92
+ // CoW now returns git metadata when the source is a git repo
93
+ const cowResult = await createCoWVariation(sourcePath, variationPath, name);
94
+ branchName = cowResult.branchName;
95
+ mergeBaseCommit = cowResult.mergeBaseCommit;
96
+ break;
97
+ }
98
+ case 'worktree':
99
+ branchName = await createWorktreeVariation(
100
+ sourcePath,
101
+ variationPath,
102
+ name,
103
+ options.createBranch
104
+ );
105
+ break;
106
+ case 'copy':
107
+ await createCopyVariation(sourcePath, variationPath);
108
+ break;
109
+ }
110
+
111
+ // Ensure global gitignore ignores .pi/variations/ directories
112
+ ensureGlobalGitignore();
113
+
114
+ const now = new Date().toISOString();
115
+
116
+ return {
117
+ id,
118
+ name,
119
+ path: variationPath,
120
+ sourcePath,
121
+ type,
122
+ createdAt: now,
123
+ lastAccessed: now,
124
+ branchName,
125
+ mergeBaseCommit,
126
+ };
127
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Variation module exports
3
+ */
4
+
5
+ export { createVariation } from './create.js';
6
+ export { removeVariation } from './remove.js';
7
+ export { mergeVariation } from './merge/index.js';
8
+ export { hasGitRepo } from './merge/strategy.js';
9
+ export { detectCoWSupport } from '../cow/index.js';
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Copy merge strategy (fallback for non-git sources)
3
+ *
4
+ * Backs up any source file that differs from the variation's version
5
+ * before overwriting, so data is never silently lost.
6
+ * For proper three-way merge with conflict detection, use git-backed
7
+ * CoW variations (source must be a git repo).
8
+ */
9
+
10
+ import { promises as fs } from 'fs';
11
+ import * as path from 'path';
12
+ import type { Variation, MergeOptions } from '../../types/index';
13
+
14
+ /**
15
+ * Get list of files that should be merged (excluding certain directories)
16
+ */
17
+ async function getFilesToMerge(variationPath: string, sourcePath: string): Promise<string[]> {
18
+ const files: string[] = [];
19
+ const excludePatterns = [
20
+ '.git',
21
+ '.pi/variations',
22
+ '.pi/merge-backup',
23
+ 'node_modules',
24
+ '.next',
25
+ '.nuxt',
26
+ 'target',
27
+ '.venv',
28
+ 'venv',
29
+ ];
30
+
31
+ async function scan(dir: string, relativeDir: string) {
32
+ const entries = await fs.readdir(dir, { withFileTypes: true });
33
+
34
+ for (const entry of entries) {
35
+ const relativePath = relativeDir ? path.join(relativeDir, entry.name) : entry.name;
36
+ const fullPath = path.join(dir, entry.name);
37
+
38
+ // Check if this path should be excluded
39
+ if (excludePatterns.some((pattern) => relativePath.startsWith(pattern))) {
40
+ continue;
41
+ }
42
+
43
+ if (entry.isDirectory()) {
44
+ await scan(fullPath, relativePath);
45
+ } else {
46
+ files.push(relativePath);
47
+ }
48
+ }
49
+ }
50
+
51
+ await scan(variationPath, '');
52
+ return files;
53
+ }
54
+
55
+ /**
56
+ * Check if a source file differs from the variation's version.
57
+ * When they differ, backing up the source version prevents data loss
58
+ * regardless of which side changed.
59
+ */
60
+ async function differsFromVariation(
61
+ relativePath: string,
62
+ variationPath: string,
63
+ sourcePath: string
64
+ ): Promise<boolean> {
65
+ const sourceFile = path.join(sourcePath, relativePath);
66
+ const variationFile = path.join(variationPath, relativePath);
67
+
68
+ try {
69
+ const [sourceContent, variationContent] = await Promise.all([
70
+ fs.readFile(sourceFile),
71
+ fs.readFile(variationFile),
72
+ ]);
73
+
74
+ // If files are identical, overwriting is a no-op
75
+ return !sourceContent.equals(variationContent);
76
+ } catch {
77
+ // One of the files doesn't exist — new file or deleted, no backup needed
78
+ return false;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Merge using direct file copy with backup protection
84
+ */
85
+ export async function mergeWithCopy(
86
+ variation: Variation,
87
+ sourcePath: string,
88
+ options: MergeOptions
89
+ ): Promise<string> {
90
+ // Get list of files to merge
91
+ const files = await getFilesToMerge(variation.path, sourcePath);
92
+
93
+ // Find files where the source version differs from the variation
94
+ // (these will be overwritten — back them up to prevent data loss)
95
+ const overwrittenFiles: string[] = [];
96
+ const backupDir = path.join(sourcePath, '.pi', 'merge-backup', variation.name);
97
+
98
+ for (const relativePath of files) {
99
+ if (await differsFromVariation(relativePath, variation.path, sourcePath)) {
100
+ overwrittenFiles.push(relativePath);
101
+ }
102
+ }
103
+
104
+ if (options.dryRun) {
105
+ const fileList = files.map((f) => ` ${f}`).join('\n');
106
+ const overwrittenList = overwrittenFiles.length
107
+ ? `\n\n⚠ Source files that differ from variation (will be backed up before overwrite):\n${overwrittenFiles.map((f) => ` ${f}`).join('\n')}\n → Backups saved to .pi/merge-backup/${variation.name}/`
108
+ : '';
109
+ return `Files that would be merged:\n${fileList}${overwrittenList}`;
110
+ }
111
+
112
+ // Back up source files that will be overwritten with different content
113
+ if (overwrittenFiles.length > 0) {
114
+ await fs.mkdir(backupDir, { recursive: true });
115
+
116
+ for (const relativePath of overwrittenFiles) {
117
+ const sourceFile = path.join(sourcePath, relativePath);
118
+ const backupFile = path.join(backupDir, relativePath);
119
+
120
+ try {
121
+ await fs.mkdir(path.dirname(backupFile), { recursive: true });
122
+ await fs.copyFile(sourceFile, backupFile);
123
+ } catch {
124
+ // Source file may not exist — skip backup
125
+ }
126
+ }
127
+ }
128
+
129
+ // Copy each file
130
+ for (const relativePath of files) {
131
+ const srcFile = path.join(variation.path, relativePath);
132
+ const destFile = path.join(sourcePath, relativePath);
133
+
134
+ // Ensure directory exists
135
+ await fs.mkdir(path.dirname(destFile), { recursive: true });
136
+
137
+ // Copy file
138
+ await fs.copyFile(srcFile, destFile);
139
+ }
140
+
141
+ // Report overwritten files and backups
142
+ if (overwrittenFiles.length > 0) {
143
+ return (
144
+ `⚠ ${overwrittenFiles.length} source file(s) overwritten. ` +
145
+ `Originals backed up to ${backupDir}\n` +
146
+ `Overwritten files:\n${overwrittenFiles.map((f) => ` ${f}`).join('\n')}`
147
+ );
148
+ }
149
+
150
+ return '';
151
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Git merge strategy for worktree and git-backed CoW variations
3
+ *
4
+ * Worktrees share the same .git as the source, so `git merge <branch>` works directly.
5
+ * CoW variations have their own .git (CoW-cloned from the source), so we add the
6
+ * variation as a temporary remote, fetch the branch, merge, and clean up.
7
+ *
8
+ * Both paths give proper three-way merge semantics with conflict detection,
9
+ * so merging Variation B after Variation A won't silently overwrite A's changes.
10
+ */
11
+
12
+ import { exec } from 'child_process';
13
+ import { promisify } from 'util';
14
+ import type { Variation, MergeOptions } from '../../types/index';
15
+
16
+ const execAsync = promisify(exec);
17
+
18
+ /**
19
+ * Generate a temporary remote name for a CoW variation merge.
20
+ * Uses a pi-var- prefix to avoid conflicts with user-defined remotes.
21
+ */
22
+ function tempRemoteName(variation: Variation): string {
23
+ // Sanitize the variation name for use as a git remote name
24
+ const safe = variation.name.replace(/[^a-zA-Z0-9-_]/g, '-');
25
+ return `pi-var-${safe}`;
26
+ }
27
+
28
+ /**
29
+ * Merge a CoW variation via remote fetch.
30
+ *
31
+ * Flow:
32
+ * 1. Commit any pending work in the variation
33
+ * 2. Add variation as a temporary remote to the source
34
+ * 3. Fetch the variation's branch
35
+ * 4. Merge with three-way merge
36
+ * 5. Clean up the temporary remote
37
+ */
38
+ async function mergeCowViaRemote(
39
+ variation: Variation,
40
+ sourcePath: string,
41
+ options: MergeOptions
42
+ ): Promise<string> {
43
+ if (!variation.branchName) {
44
+ throw new Error('Cannot merge: CoW variation has no associated branch');
45
+ }
46
+
47
+ const remoteName = tempRemoteName(variation);
48
+
49
+ // 1. Commit any pending work in the variation so it's all captured
50
+ try {
51
+ await execAsync(`git -C "${variation.path}" add -A`);
52
+ // Use --allow-empty so this doesn't fail if there's nothing new to commit
53
+ await execAsync(
54
+ `git -C "${variation.path}" commit -m "variation work: ${variation.name}" --allow-empty`
55
+ );
56
+ } catch {
57
+ // Nothing to commit or commit failed (e.g. empty repo) — proceed with what we have
58
+ }
59
+
60
+ // 2. Add variation as a temporary remote
61
+ try {
62
+ await execAsync(`git -C "${sourcePath}" remote add "${remoteName}" "${variation.path}"`);
63
+ } catch {
64
+ // Remote might already exist from a failed previous merge — update the URL
65
+ try {
66
+ await execAsync(`git -C "${sourcePath}" remote set-url "${remoteName}" "${variation.path}"`);
67
+ } catch {
68
+ throw new Error(`Failed to add temporary remote ${remoteName}`);
69
+ }
70
+ }
71
+
72
+ try {
73
+ // 3. Fetch the variation's branch
74
+ await execAsync(`git -C "${sourcePath}" fetch "${remoteName}" "${variation.branchName}"`);
75
+
76
+ // 4. Get current branch in source
77
+ const { stdout: currentBranch } = await execAsync(
78
+ `git -C "${sourcePath}" branch --show-current`
79
+ );
80
+ const targetBranch = currentBranch.trim();
81
+
82
+ if (!targetBranch) {
83
+ throw new Error('Cannot merge: source is in detached HEAD state');
84
+ }
85
+
86
+ if (options.dryRun) {
87
+ // Show what would be merged
88
+ const { stdout } = await execAsync(
89
+ `git -C "${sourcePath}" diff "${targetBranch}...${remoteName}/${variation.branchName}" --stat`
90
+ );
91
+ return `Files that would be merged (via git three-way merge):\n${stdout}`;
92
+ }
93
+
94
+ // 5. Merge with three-way merge
95
+ try {
96
+ await execAsync(
97
+ `git -C "${sourcePath}" merge "${remoteName}/${variation.branchName}" --no-edit`
98
+ );
99
+ return '';
100
+ } catch (err) {
101
+ // Merge conflict — report clearly so the user can resolve
102
+ const message = err instanceof Error ? err.message : String(err);
103
+ throw new Error(
104
+ `Git merge conflict. Resolve conflicts in ${sourcePath} and commit, then run:\n` +
105
+ ` git -C "${sourcePath}" mergetool\n` +
106
+ ` git -C "${sourcePath}" commit\n\n` +
107
+ `Details: ${message}`
108
+ );
109
+ }
110
+ } finally {
111
+ // 6. Always clean up the temporary remote, even on failure
112
+ try {
113
+ await execAsync(`git -C "${sourcePath}" remote remove "${remoteName}"`);
114
+ } catch {
115
+ // Ignore cleanup errors
116
+ }
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Merge a worktree variation directly (branch is in the same repo).
122
+ */
123
+ async function mergeWorktreeDirect(
124
+ variation: Variation,
125
+ sourcePath: string,
126
+ options: MergeOptions
127
+ ): Promise<string> {
128
+ if (!variation.branchName) {
129
+ throw new Error('Cannot merge: variation has no associated branch');
130
+ }
131
+
132
+ // Get current branch in source
133
+ const { stdout: currentBranch } = await execAsync(`git -C "${sourcePath}" branch --show-current`);
134
+ const targetBranch = currentBranch.trim();
135
+
136
+ if (!targetBranch) {
137
+ throw new Error('Cannot merge: source is in detached HEAD state');
138
+ }
139
+
140
+ if (options.dryRun) {
141
+ // Show what would be merged
142
+ const { stdout } = await execAsync(
143
+ `git -C "${sourcePath}" diff "${targetBranch}...${variation.branchName}" --stat`
144
+ );
145
+ return `Files that would be merged (via git three-way merge):\n${stdout}`;
146
+ }
147
+
148
+ // Merge the branch
149
+ try {
150
+ await execAsync(`git -C "${sourcePath}" merge "${variation.branchName}" --no-edit`);
151
+ return '';
152
+ } catch (err) {
153
+ const message = err instanceof Error ? err.message : String(err);
154
+ throw new Error(
155
+ `Git merge conflict. Resolve conflicts in ${sourcePath} and commit, then run:\n` +
156
+ ` git -C "${sourcePath}" mergetool\n` +
157
+ ` git -C "${sourcePath}" commit\n\n` +
158
+ `Details: ${message}`
159
+ );
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Merge using git (for worktrees and git-backed CoW variations)
165
+ */
166
+ export async function mergeWithGit(
167
+ variation: Variation,
168
+ sourcePath: string,
169
+ options: MergeOptions
170
+ ): Promise<string> {
171
+ if (variation.type === 'cow') {
172
+ return mergeCowViaRemote(variation, sourcePath, options);
173
+ }
174
+
175
+ return mergeWorktreeDirect(variation, sourcePath, options);
176
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Variation merge orchestration
3
+ */
4
+
5
+ import type { Variation, MergeOptions } from '../../types/index';
6
+ import { detectMergeStrategy } from './strategy';
7
+ import { mergeWithGit } from './git';
8
+ import { mergeWithRsync } from './rsync';
9
+ import { mergeWithCopy } from './copy';
10
+
11
+ /**
12
+ * Merge a variation back to source
13
+ */
14
+ export async function mergeVariation(
15
+ variation: Variation,
16
+ sourcePath: string,
17
+ options: MergeOptions = {}
18
+ ): Promise<string> {
19
+ const strategy = options.strategy || 'auto';
20
+ const effectiveStrategy =
21
+ strategy === 'auto' ? await detectMergeStrategy(variation, sourcePath) : strategy;
22
+
23
+ let result = '';
24
+ switch (effectiveStrategy) {
25
+ case 'git':
26
+ result = await mergeWithGit(variation, sourcePath, options);
27
+ break;
28
+ case 'rsync':
29
+ result = await mergeWithRsync(variation, sourcePath, options);
30
+ break;
31
+ default:
32
+ result = await mergeWithCopy(variation, sourcePath, options);
33
+ }
34
+
35
+ return result;
36
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Rsync merge strategy with backup protection
3
+ *
4
+ * Uses --backup to preserve overwritten source files, and --update
5
+ * to skip files where the source is newer than the variation.
6
+ */
7
+
8
+ import { exec } from 'child_process';
9
+ import { promisify } from 'util';
10
+ import * as path from 'path';
11
+ import type { Variation, MergeOptions } from '../../types/index';
12
+
13
+ const execAsync = promisify(exec);
14
+
15
+ /**
16
+ * Merge using rsync with backup
17
+ */
18
+ export async function mergeWithRsync(
19
+ variation: Variation,
20
+ sourcePath: string,
21
+ options: MergeOptions
22
+ ): Promise<string> {
23
+ const rsyncFlags = options.dryRun ? '-avn' : '-av';
24
+ const backupDir = path.join(sourcePath, '.pi', 'merge-backup', variation.name);
25
+
26
+ // Build exclude patterns for rsync
27
+ const excludes = [
28
+ '--exclude=.git',
29
+ '--exclude=.pi/variations',
30
+ '--exclude=.pi/merge-backup',
31
+ '--exclude=node_modules',
32
+ '--exclude=.next',
33
+ '--exclude=.nuxt',
34
+ '--exclude=target',
35
+ '--exclude=.venv',
36
+ '--exclude=venv',
37
+ ];
38
+
39
+ // --backup preserves overwritten source files
40
+ // --suffix labels them with the variation name
41
+ const backupFlags = options.dryRun
42
+ ? `--backup --backup-dir="${backupDir}"`
43
+ : `--backup --backup-dir="${backupDir}"`;
44
+
45
+ const cmd = `rsync ${rsyncFlags} ${backupFlags} ${excludes.join(' ')} "${variation.path}/" "${sourcePath}/"`;
46
+
47
+ if (options.dryRun) {
48
+ const { stdout } = await execAsync(cmd);
49
+ return `Files that would be merged (with backup of overwritten files):\n${stdout}`;
50
+ }
51
+
52
+ await execAsync(cmd);
53
+
54
+ // Check if backup dir has any files (indicating conflicts)
55
+ try {
56
+ const { stdout: backupList } = await execAsync(
57
+ `find "${backupDir}" -type f 2>/dev/null | head -20`
58
+ );
59
+ if (backupList.trim()) {
60
+ return (
61
+ `⚠ Some source files were overwritten. Backed up to ${backupDir}\n` +
62
+ `Overwritten files:\n${backupList
63
+ .trim()
64
+ .split('\n')
65
+ .map((f: string) => ` ${path.relative(backupDir, f)}`)
66
+ .join('\n')}`
67
+ );
68
+ }
69
+ } catch {
70
+ // No backup dir or empty
71
+ }
72
+
73
+ return '';
74
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Merge strategy detection
3
+ *
4
+ * CoW variations with a git branch (created from a git repo source)
5
+ * use git merge for proper three-way merge semantics — just like worktrees.
6
+ * CoW variations without a branch (non-git source) fall back to rsync/copy.
7
+ */
8
+
9
+ import { exec } from 'child_process';
10
+ import { promisify } from 'util';
11
+ import type { Variation } from '../../types/index.js';
12
+
13
+ const execAsync = promisify(exec);
14
+
15
+ /**
16
+ * Check if a directory contains a git repository
17
+ */
18
+ export async function hasGitRepo(checkPath: string): Promise<boolean> {
19
+ try {
20
+ const gitDir = (await import('path')).join(checkPath, '.git');
21
+ const { promises: fs } = await import('fs');
22
+ const stat = await fs.stat(gitDir);
23
+ return stat.isDirectory();
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Detect the best merge strategy for a variation
31
+ *
32
+ * Priority:
33
+ * 1. git — for worktrees (branch in same repo) and git-backed CoW (branch in variation repo)
34
+ * 2. rsync — for non-git CoW/copy when rsync is available
35
+ * 3. copy — fallback
36
+ */
37
+ export async function detectMergeStrategy(
38
+ variation: Variation,
39
+ sourcePath: string
40
+ ): Promise<'git' | 'rsync' | 'copy'> {
41
+ // Prefer git for worktrees (branch is in the same repo as source)
42
+ if (variation.type === 'worktree' && (await hasGitRepo(sourcePath))) {
43
+ return 'git';
44
+ }
45
+
46
+ // Prefer git for CoW variations that have a branch (source was a git repo)
47
+ // The branch is in the variation's own .git, so we merge via remote fetch.
48
+ if (variation.type === 'cow' && variation.branchName && (await hasGitRepo(sourcePath))) {
49
+ return 'git';
50
+ }
51
+
52
+ // Check for rsync
53
+ try {
54
+ await execAsync('which rsync');
55
+ return 'rsync';
56
+ } catch {
57
+ // rsync not available
58
+ }
59
+
60
+ // Fall back to copy
61
+ return 'copy';
62
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Variation name and ID utilities
3
+ */
4
+
5
+ import * as crypto from 'crypto';
6
+
7
+ /**
8
+ * Get the project name from a path
9
+ */
10
+ export function getProjectName(sourcePath: string): string {
11
+ return sourcePath.split(/[/\\]/).pop() || 'project';
12
+ }
13
+
14
+ /**
15
+ * Generate a short hash for the project path
16
+ */
17
+ export function getProjectHash(sourcePath: string): string {
18
+ return crypto.createHash('sha256').update(sourcePath).digest('hex').slice(0, 8);
19
+ }
20
+
21
+ /**
22
+ * Generate a unique variation ID
23
+ */
24
+ export function generateVariationId(): string {
25
+ return `var-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
26
+ }