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,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
|
+
}
|