isomorphic-jj 1.0.0
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/LICENSE +21 -0
- package/README.md +971 -0
- package/dist/index.cjs +12609 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.mjs +12600 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +90 -0
- package/src/api/repository.js +6885 -0
- package/src/backend/isomorphic-git-backend.js +687 -0
- package/src/backend/lazy-git-backend.js +180 -0
- package/src/browser/helpers.js +189 -0
- package/src/browser/index.d.ts +174 -0
- package/src/browser/index.js +12 -0
- package/src/core/background-ops.js +272 -0
- package/src/core/bookmark-store.js +232 -0
- package/src/core/change-graph.js +285 -0
- package/src/core/conflict-model.js +505 -0
- package/src/core/jj-checkout.js +78 -0
- package/src/core/jj-operation-store.js +120 -0
- package/src/core/jj-tree-state.js +94 -0
- package/src/core/jj-view-store.js +105 -0
- package/src/core/merge-driver-registry.js +432 -0
- package/src/core/operation-log.js +272 -0
- package/src/core/revset-engine.js +1081 -0
- package/src/core/storage-manager.js +207 -0
- package/src/core/tag-store.js +167 -0
- package/src/core/user-config.js +142 -0
- package/src/core/working-copy.js +314 -0
- package/src/core/workspace-manager.js +283 -0
- package/src/drivers/built-in-drivers.js +296 -0
- package/src/index.js +15 -0
- package/src/protos/local_working_copy.proto +76 -0
- package/src/protos/simple_op_store.proto +147 -0
- package/src/types.d.ts +1129 -0
- package/src/utils/errors.js +21 -0
- package/src/utils/id-generation.js +54 -0
- package/src/utils/validation.js +124 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JJ TreeState File Manager
|
|
3
|
+
*
|
|
4
|
+
* Handles encoding/decoding of .jj/working_copy/tree_state file using protobuf.
|
|
5
|
+
* This file tracks the working copy tree and file states.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import protobuf from 'protobufjs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
|
|
12
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
13
|
+
const __dirname = path.dirname(__filename);
|
|
14
|
+
|
|
15
|
+
export class JJTreeState {
|
|
16
|
+
constructor(fs, dir) {
|
|
17
|
+
this.fs = fs;
|
|
18
|
+
this.dir = dir;
|
|
19
|
+
this.protoPath = path.join(__dirname, '..', 'protos', 'local_working_copy.proto');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Write tree_state file
|
|
24
|
+
*
|
|
25
|
+
* @param {string} treeId - Tree ID as hex string (40 characters for SHA-1)
|
|
26
|
+
* @param {Array} fileStates - Array of file state objects
|
|
27
|
+
*/
|
|
28
|
+
async writeTreeState(treeId, fileStates = []) {
|
|
29
|
+
// Load protobuf schema
|
|
30
|
+
const root = await protobuf.load(this.protoPath);
|
|
31
|
+
const TreeState = root.lookupType('local_working_copy.TreeState');
|
|
32
|
+
|
|
33
|
+
// Convert hex tree ID to bytes
|
|
34
|
+
const treeIdBuffer = Buffer.from(treeId, 'hex');
|
|
35
|
+
|
|
36
|
+
// Convert file states to protobuf format
|
|
37
|
+
const fileStateEntries = fileStates.map(fs => ({
|
|
38
|
+
path: fs.path,
|
|
39
|
+
state: {
|
|
40
|
+
mtimeMillisSinceEpoch: fs.mtime, // Use camelCase for protobufjs
|
|
41
|
+
size: fs.size,
|
|
42
|
+
fileType: fs.fileType || 0 // 0 = Normal
|
|
43
|
+
}
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
// Create message (use camelCase for protobufjs)
|
|
47
|
+
const message = TreeState.create({
|
|
48
|
+
treeIds: [treeIdBuffer], // Single tree ID (no conflicts)
|
|
49
|
+
fileStates: fileStateEntries,
|
|
50
|
+
isFileStatesSorted: false, // We don't sort them
|
|
51
|
+
sparsePatterns: {
|
|
52
|
+
prefixes: [] // No sparse patterns
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Verify the message
|
|
57
|
+
const errMsg = TreeState.verify(message);
|
|
58
|
+
if (errMsg) {
|
|
59
|
+
throw new Error(`TreeState message verification failed: ${errMsg}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Encode to binary
|
|
63
|
+
const buffer = TreeState.encode(message).finish();
|
|
64
|
+
|
|
65
|
+
// Write to .jj/working_copy/tree_state
|
|
66
|
+
const treeStatePath = `${this.dir}/.jj/working_copy/tree_state`;
|
|
67
|
+
await this.fs.promises.writeFile(treeStatePath, buffer);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Read tree_state file
|
|
72
|
+
*
|
|
73
|
+
* @returns {Object} Decoded tree state data
|
|
74
|
+
*/
|
|
75
|
+
async readTreeState() {
|
|
76
|
+
const treeStatePath = `${this.dir}/.jj/working_copy/tree_state`;
|
|
77
|
+
const buffer = await this.fs.promises.readFile(treeStatePath);
|
|
78
|
+
|
|
79
|
+
// Load protobuf schema
|
|
80
|
+
const root = await protobuf.load(this.protoPath);
|
|
81
|
+
const TreeState = root.lookupType('local_working_copy.TreeState');
|
|
82
|
+
|
|
83
|
+
// Decode
|
|
84
|
+
const message = TreeState.decode(buffer);
|
|
85
|
+
|
|
86
|
+
// Return the decoded message with proper field access
|
|
87
|
+
return {
|
|
88
|
+
tree_ids: message.treeIds,
|
|
89
|
+
file_states: message.fileStates,
|
|
90
|
+
is_file_states_sorted: message.isFileStatesSorted,
|
|
91
|
+
sparse_patterns: message.sparsePatterns
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JJ View Store Manager
|
|
3
|
+
*
|
|
4
|
+
* Handles encoding/decoding of .jj/repo/op_store/views/* files using protobuf.
|
|
5
|
+
* Views represent the state of the repository at a given operation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import protobuf from 'protobufjs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
|
|
12
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
13
|
+
const __dirname = path.dirname(__filename);
|
|
14
|
+
|
|
15
|
+
export class JJViewStore {
|
|
16
|
+
constructor(fs, dir) {
|
|
17
|
+
this.fs = fs;
|
|
18
|
+
this.dir = dir;
|
|
19
|
+
this.protoPath = path.join(__dirname, '..', 'protos', 'simple_op_store.proto');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Write view file
|
|
24
|
+
*
|
|
25
|
+
* @param {string} viewId - View ID as hex string (128 characters)
|
|
26
|
+
* @param {Array<string>} headIds - Head commit IDs as hex strings
|
|
27
|
+
* @param {Object} wcCommitIds - Working copy commit IDs by workspace name
|
|
28
|
+
*/
|
|
29
|
+
async writeView(viewId, headIds, wcCommitIds = {}) {
|
|
30
|
+
// Load protobuf schema
|
|
31
|
+
const root = await protobuf.load(this.protoPath);
|
|
32
|
+
const View = root.lookupType('simple_op_store.View');
|
|
33
|
+
|
|
34
|
+
// Convert hex IDs to bytes
|
|
35
|
+
const headBuffers = headIds.map(id => Buffer.from(id, 'hex'));
|
|
36
|
+
|
|
37
|
+
// Convert wcCommitIds object to map
|
|
38
|
+
const wcCommitIdsMap = {};
|
|
39
|
+
for (const [workspace, commitId] of Object.entries(wcCommitIds)) {
|
|
40
|
+
wcCommitIdsMap[workspace] = Buffer.from(commitId, 'hex');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Create view message (use camelCase for protobufjs)
|
|
44
|
+
const message = View.create({
|
|
45
|
+
headIds: headBuffers,
|
|
46
|
+
wcCommitIds: wcCommitIdsMap,
|
|
47
|
+
bookmarks: [],
|
|
48
|
+
localTags: [],
|
|
49
|
+
remoteViews: [],
|
|
50
|
+
gitRefs: [],
|
|
51
|
+
gitHead: null,
|
|
52
|
+
hasGitRefsMigratedToRemoteTags: false
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Verify the message
|
|
56
|
+
const errMsg = View.verify(message);
|
|
57
|
+
if (errMsg) {
|
|
58
|
+
throw new Error(`View message verification failed: ${errMsg}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Encode to binary
|
|
62
|
+
const buffer = View.encode(message).finish();
|
|
63
|
+
|
|
64
|
+
// Write to .jj/repo/op_store/views/VIEWID
|
|
65
|
+
const viewPath = `${this.dir}/.jj/repo/op_store/views/${viewId}`;
|
|
66
|
+
await this.fs.promises.writeFile(viewPath, buffer);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Read view file
|
|
71
|
+
*
|
|
72
|
+
* @param {string} viewId - View ID as hex string (128 characters)
|
|
73
|
+
* @returns {Object} Decoded view data
|
|
74
|
+
*/
|
|
75
|
+
async readView(viewId) {
|
|
76
|
+
// Read from .jj/repo/op_store/views/VIEWID
|
|
77
|
+
const viewPath = `${this.dir}/.jj/repo/op_store/views/${viewId}`;
|
|
78
|
+
const buffer = await this.fs.promises.readFile(viewPath);
|
|
79
|
+
|
|
80
|
+
// Load protobuf schema
|
|
81
|
+
const root = await protobuf.load(this.protoPath);
|
|
82
|
+
const View = root.lookupType('simple_op_store.View');
|
|
83
|
+
|
|
84
|
+
// Decode
|
|
85
|
+
const message = View.decode(buffer);
|
|
86
|
+
|
|
87
|
+
// Convert wcCommitIds map back to object
|
|
88
|
+
const wcCommitIds = {};
|
|
89
|
+
for (const [workspace, commitIdBuffer] of Object.entries(message.wcCommitIds || {})) {
|
|
90
|
+
wcCommitIds[workspace] = commitIdBuffer;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Return the decoded message with proper field access
|
|
94
|
+
return {
|
|
95
|
+
head_ids: message.headIds,
|
|
96
|
+
wc_commit_ids: wcCommitIds,
|
|
97
|
+
bookmarks: message.bookmarks,
|
|
98
|
+
local_tags: message.localTags,
|
|
99
|
+
remote_views: message.remoteViews,
|
|
100
|
+
git_refs: message.gitRefs,
|
|
101
|
+
git_head: message.gitHead,
|
|
102
|
+
has_git_refs_migrated_to_remote_tags: message.hasGitRefsMigratedToRemoteTags
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MergeDriverRegistry - Registry for custom merge drivers
|
|
3
|
+
*
|
|
4
|
+
* Manages registration, lookup, and execution of merge drivers.
|
|
5
|
+
* Drivers are matched against file paths using glob patterns.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { JJError } from '../utils/errors.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check if a file path matches a glob pattern
|
|
12
|
+
* Simple implementation - for complex patterns, consider using minimatch
|
|
13
|
+
*
|
|
14
|
+
* @param {string} path - File path
|
|
15
|
+
* @param {string} pattern - Glob pattern
|
|
16
|
+
* @returns {boolean} Whether path matches pattern
|
|
17
|
+
*/
|
|
18
|
+
function matchesPattern(path, pattern) {
|
|
19
|
+
// Exact match
|
|
20
|
+
if (pattern === path) return true;
|
|
21
|
+
|
|
22
|
+
// Convert glob pattern to regex
|
|
23
|
+
// Support: *, **, ?, [abc]
|
|
24
|
+
const regexPattern = pattern
|
|
25
|
+
.replace(/\*\*/g, '__DOUBLESTAR__')
|
|
26
|
+
.replace(/\*/g, '[^/]*')
|
|
27
|
+
.replace(/__DOUBLESTAR__/g, '.*')
|
|
28
|
+
.replace(/\?/g, '[^/]')
|
|
29
|
+
.replace(/\./g, '\\.');
|
|
30
|
+
|
|
31
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
32
|
+
return regex.test(path);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Check if a file is likely binary based on content
|
|
37
|
+
*
|
|
38
|
+
* @param {string|Buffer} content - File content
|
|
39
|
+
* @returns {boolean} Whether content appears binary
|
|
40
|
+
*/
|
|
41
|
+
function isBinaryContent(content) {
|
|
42
|
+
if (Buffer.isBuffer(content)) return true;
|
|
43
|
+
if (typeof content !== 'string') return false;
|
|
44
|
+
|
|
45
|
+
// Check for null bytes (common in binary files)
|
|
46
|
+
if (content.indexOf('\0') !== -1) return true;
|
|
47
|
+
|
|
48
|
+
// Check for high ratio of non-printable characters
|
|
49
|
+
const nonPrintable = content.split('').filter(
|
|
50
|
+
(char) => {
|
|
51
|
+
const code = char.charCodeAt(0);
|
|
52
|
+
return code < 32 && code !== 9 && code !== 10 && code !== 13;
|
|
53
|
+
}
|
|
54
|
+
).length;
|
|
55
|
+
|
|
56
|
+
const ratio = nonPrintable / content.length;
|
|
57
|
+
return ratio > 0.3;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Default merge driver - standard three-way text merge
|
|
62
|
+
*
|
|
63
|
+
* @param {MergeContext} context
|
|
64
|
+
* @returns {Promise<MergeResult>}
|
|
65
|
+
*/
|
|
66
|
+
async function defaultMergeDriver(context) {
|
|
67
|
+
const { content } = context;
|
|
68
|
+
const { base, ours, theirs } = content;
|
|
69
|
+
|
|
70
|
+
// Simple cases
|
|
71
|
+
if (ours === theirs) {
|
|
72
|
+
return { content: ours, hasConflict: false };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (ours === base) {
|
|
76
|
+
// No changes on our side, take theirs
|
|
77
|
+
return { content: theirs, hasConflict: false };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (theirs === base) {
|
|
81
|
+
// No changes on their side, take ours
|
|
82
|
+
return { content: ours, hasConflict: false };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Both sides changed - conflict
|
|
86
|
+
const conflictMarkers = `<<<<<<< ours\n${ours || ''}\n=======\n${theirs || ''}\n>>>>>>> theirs`;
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
content: conflictMarkers,
|
|
90
|
+
hasConflict: true,
|
|
91
|
+
conflicts: [{
|
|
92
|
+
type: 'content',
|
|
93
|
+
sides: ['ours', 'theirs'],
|
|
94
|
+
}],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Wrap a driver with timeout and error handling
|
|
100
|
+
*
|
|
101
|
+
* @param {Function} driver - Driver function
|
|
102
|
+
* @param {Object} options - Wrapper options
|
|
103
|
+
* @param {number} [options.timeout=5000] - Timeout in ms
|
|
104
|
+
* @param {boolean} [options.strict=false] - Throw on error instead of falling back
|
|
105
|
+
* @param {Object} [options.jj] - JJ instance for event emission
|
|
106
|
+
* @param {string} [options.pattern] - Pattern that matched this driver
|
|
107
|
+
* @returns {Function} Wrapped driver
|
|
108
|
+
*/
|
|
109
|
+
function wrapDriver(driver, options = {}) {
|
|
110
|
+
const { timeout = 5000, strict = false, jj = null, pattern = null } = options;
|
|
111
|
+
|
|
112
|
+
return async (context) => {
|
|
113
|
+
let timeoutId;
|
|
114
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
115
|
+
timeoutId = setTimeout(() => reject(new Error('Driver timeout')), timeout);
|
|
116
|
+
// Use unref() to allow process to exit if this is the only pending timer
|
|
117
|
+
if (timeoutId.unref) {
|
|
118
|
+
timeoutId.unref();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const result = await Promise.race([
|
|
124
|
+
driver(context),
|
|
125
|
+
timeoutPromise,
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
// Clear timeout if driver completed successfully
|
|
129
|
+
clearTimeout(timeoutId);
|
|
130
|
+
|
|
131
|
+
// Validate result
|
|
132
|
+
if (!result || typeof result.hasConflict !== 'boolean') {
|
|
133
|
+
throw new Error('Driver returned invalid result');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return result;
|
|
137
|
+
} catch (error) {
|
|
138
|
+
// Clear timeout on error too
|
|
139
|
+
clearTimeout(timeoutId);
|
|
140
|
+
|
|
141
|
+
// Emit driver:failed event if jj instance available
|
|
142
|
+
if (jj && jj.dispatchEvent) {
|
|
143
|
+
jj.dispatchEvent(new CustomEvent('driver:failed', {
|
|
144
|
+
detail: {
|
|
145
|
+
path: context.path,
|
|
146
|
+
pattern: pattern,
|
|
147
|
+
error: error.message,
|
|
148
|
+
timestamp: new Date().toISOString(),
|
|
149
|
+
},
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// In strict mode, throw the error
|
|
154
|
+
if (strict) {
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Fall back to default merge with error metadata
|
|
159
|
+
const result = await defaultMergeDriver(context);
|
|
160
|
+
return {
|
|
161
|
+
...result,
|
|
162
|
+
driverFailed: true,
|
|
163
|
+
driverError: error.message,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* MergeDriverRegistry manages custom merge drivers
|
|
171
|
+
*/
|
|
172
|
+
export class MergeDriverRegistry {
|
|
173
|
+
constructor(jj = null) {
|
|
174
|
+
/** @type {Array<{pattern: string, driver: Function, accepts: Object, strict: boolean}>} */
|
|
175
|
+
this.drivers = [];
|
|
176
|
+
this.jj = jj; // JJ instance for event emission
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Register one or more merge drivers
|
|
181
|
+
*
|
|
182
|
+
* @param {Object<string, Function|Object>} drivers - Pattern -> driver map
|
|
183
|
+
*
|
|
184
|
+
* @example
|
|
185
|
+
* registry.register({
|
|
186
|
+
* 'package.json': packageJsonDriver,
|
|
187
|
+
* '*.md': markdownDriver,
|
|
188
|
+
* 'critical.txt': { driver: myDriver, strict: true },
|
|
189
|
+
* });
|
|
190
|
+
*/
|
|
191
|
+
register(drivers) {
|
|
192
|
+
for (const [pattern, driverOrConfig] of Object.entries(drivers)) {
|
|
193
|
+
// Driver can be function or { driver, accepts, timeout, strict }
|
|
194
|
+
let driver, accepts, timeout, strict;
|
|
195
|
+
|
|
196
|
+
if (typeof driverOrConfig === 'function') {
|
|
197
|
+
driver = driverOrConfig;
|
|
198
|
+
accepts = { text: true, binary: false }; // Default: text only
|
|
199
|
+
timeout = 5000;
|
|
200
|
+
strict = false;
|
|
201
|
+
} else {
|
|
202
|
+
driver = driverOrConfig.driver || driverOrConfig.merge;
|
|
203
|
+
accepts = driverOrConfig.accepts || { text: true, binary: false };
|
|
204
|
+
timeout = driverOrConfig.timeout || 5000;
|
|
205
|
+
strict = driverOrConfig.strict || false;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (typeof driver !== 'function') {
|
|
209
|
+
throw new JJError(
|
|
210
|
+
'INVALID_DRIVER',
|
|
211
|
+
`Driver for pattern "${pattern}" must be a function`,
|
|
212
|
+
{ pattern }
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Wrap driver with error handling
|
|
217
|
+
const wrappedDriver = wrapDriver(driver, {
|
|
218
|
+
timeout,
|
|
219
|
+
strict,
|
|
220
|
+
jj: this.jj,
|
|
221
|
+
pattern,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
this.drivers.push({ pattern, driver: wrappedDriver, accepts, strict });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Unregister a driver by pattern
|
|
230
|
+
*
|
|
231
|
+
* @param {string} pattern - Pattern to unregister
|
|
232
|
+
*/
|
|
233
|
+
unregister(pattern) {
|
|
234
|
+
this.drivers = this.drivers.filter((d) => d.pattern !== pattern);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Get a specific driver by pattern
|
|
239
|
+
*
|
|
240
|
+
* @param {string} pattern - Pattern to look up
|
|
241
|
+
* @returns {Function|undefined} Driver function or undefined
|
|
242
|
+
*/
|
|
243
|
+
get(pattern) {
|
|
244
|
+
const entry = this.drivers.find((d) => d.pattern === pattern);
|
|
245
|
+
return entry?.driver;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* List all registered drivers
|
|
250
|
+
*
|
|
251
|
+
* @returns {Array<{pattern: string, accepts: Object}>} Driver list
|
|
252
|
+
*/
|
|
253
|
+
list() {
|
|
254
|
+
return this.drivers.map(({ pattern, accepts }) => ({ pattern, accepts }));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Find the best matching driver for a file path
|
|
259
|
+
*
|
|
260
|
+
* Priority:
|
|
261
|
+
* 1. Per-merge custom drivers (from merge options)
|
|
262
|
+
* 2. Registered drivers (by pattern specificity)
|
|
263
|
+
* 3. Default driver (three-way merge)
|
|
264
|
+
*
|
|
265
|
+
* @param {string} filePath - File path
|
|
266
|
+
* @param {Object} customDrivers - Per-merge custom drivers
|
|
267
|
+
* @param {boolean} isBinary - Whether file is binary
|
|
268
|
+
* @returns {Function} Driver function
|
|
269
|
+
*/
|
|
270
|
+
findDriver(filePath, customDrivers = {}, isBinary = false) {
|
|
271
|
+
// 1. Check custom drivers (highest priority)
|
|
272
|
+
for (const [pattern, driverOrConfig] of Object.entries(customDrivers)) {
|
|
273
|
+
if (matchesPattern(filePath, pattern)) {
|
|
274
|
+
let driver, accepts, timeout, strict;
|
|
275
|
+
|
|
276
|
+
if (typeof driverOrConfig === 'function') {
|
|
277
|
+
driver = driverOrConfig;
|
|
278
|
+
accepts = { text: true, binary: false };
|
|
279
|
+
timeout = 5000;
|
|
280
|
+
strict = false;
|
|
281
|
+
} else {
|
|
282
|
+
driver = driverOrConfig.driver || driverOrConfig.merge;
|
|
283
|
+
accepts = driverOrConfig.accepts || { text: true, binary: false };
|
|
284
|
+
timeout = driverOrConfig.timeout || 5000;
|
|
285
|
+
strict = driverOrConfig.strict || false;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Check if driver accepts this file type
|
|
289
|
+
if ((isBinary && accepts.binary) || (!isBinary && accepts.text)) {
|
|
290
|
+
return wrapDriver(driver, {
|
|
291
|
+
timeout,
|
|
292
|
+
strict,
|
|
293
|
+
jj: this.jj,
|
|
294
|
+
pattern,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 2. Check registered drivers (by specificity)
|
|
301
|
+
const matches = this.drivers
|
|
302
|
+
.filter(({ pattern, accepts }) => {
|
|
303
|
+
const matchesPath = matchesPattern(filePath, pattern);
|
|
304
|
+
const acceptsType = (isBinary && accepts.binary) || (!isBinary && accepts.text);
|
|
305
|
+
return matchesPath && acceptsType;
|
|
306
|
+
})
|
|
307
|
+
.sort((a, b) => b.pattern.length - a.pattern.length); // Most specific first
|
|
308
|
+
|
|
309
|
+
if (matches.length > 0) {
|
|
310
|
+
return matches[0].driver;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 3. Fall back to default driver
|
|
314
|
+
return defaultMergeDriver;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Execute a driver with proper content encoding
|
|
319
|
+
*
|
|
320
|
+
* @param {Function} driver - Driver function
|
|
321
|
+
* @param {Object} context - Merge context
|
|
322
|
+
* @param {string} context.path - File path
|
|
323
|
+
* @param {string|Buffer|null} context.base - Base content
|
|
324
|
+
* @param {string|Buffer|null} context.ours - Our content
|
|
325
|
+
* @param {string|Buffer|null} context.theirs - Their content
|
|
326
|
+
* @param {Object} context.metadata - Metadata
|
|
327
|
+
* @param {boolean} isBinary - Whether content is binary
|
|
328
|
+
* @returns {Promise<MergeResult>}
|
|
329
|
+
*/
|
|
330
|
+
async executeDriver(driver, context, isBinary) {
|
|
331
|
+
const { base, ours, theirs, ...rest } = context;
|
|
332
|
+
|
|
333
|
+
// Prepare content based on binary/text
|
|
334
|
+
const content = {
|
|
335
|
+
base: this.prepareContent(base, isBinary),
|
|
336
|
+
ours: this.prepareContent(ours, isBinary),
|
|
337
|
+
theirs: this.prepareContent(theirs, isBinary),
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const driverContext = {
|
|
341
|
+
...rest,
|
|
342
|
+
content,
|
|
343
|
+
isBinary,
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
return await driver(driverContext);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Prepare content for driver based on binary/text
|
|
351
|
+
*
|
|
352
|
+
* @param {string|Buffer|null} content - Raw content
|
|
353
|
+
* @param {boolean} isBinary - Whether to return as Buffer
|
|
354
|
+
* @returns {string|Buffer|null} Prepared content
|
|
355
|
+
*/
|
|
356
|
+
prepareContent(content, isBinary) {
|
|
357
|
+
if (content === null || content === undefined) {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (isBinary) {
|
|
362
|
+
// Convert to Buffer if needed
|
|
363
|
+
if (Buffer.isBuffer(content)) return content;
|
|
364
|
+
if (typeof content === 'string') return Buffer.from(content, 'utf-8');
|
|
365
|
+
return Buffer.from(String(content));
|
|
366
|
+
} else {
|
|
367
|
+
// Convert to string if needed
|
|
368
|
+
if (typeof content === 'string') return content;
|
|
369
|
+
if (Buffer.isBuffer(content)) return content.toString('utf-8');
|
|
370
|
+
return String(content);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Check if file is binary based on path or content
|
|
376
|
+
*
|
|
377
|
+
* @param {string} path - File path
|
|
378
|
+
* @param {string|Buffer} content - File content
|
|
379
|
+
* @returns {boolean} Whether file is binary
|
|
380
|
+
*/
|
|
381
|
+
isBinaryFile(path, content) {
|
|
382
|
+
// Check by extension first
|
|
383
|
+
const binaryExtensions = [
|
|
384
|
+
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico',
|
|
385
|
+
'.zip', '.tar', '.gz', '.bz2', '.7z',
|
|
386
|
+
'.pdf', '.doc', '.docx', '.xls', '.xlsx',
|
|
387
|
+
'.exe', '.dll', '.so', '.dylib',
|
|
388
|
+
'.mp3', '.mp4', '.avi', '.mov',
|
|
389
|
+
];
|
|
390
|
+
|
|
391
|
+
const ext = path.substring(path.lastIndexOf('.')).toLowerCase();
|
|
392
|
+
if (binaryExtensions.includes(ext)) {
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Check content
|
|
397
|
+
if (content) {
|
|
398
|
+
return isBinaryContent(content);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Standalone helper to check if file is binary
|
|
407
|
+
* (exported for external use)
|
|
408
|
+
*
|
|
409
|
+
* @param {string} path - File path
|
|
410
|
+
* @param {string|Buffer} content - File content
|
|
411
|
+
* @returns {boolean} Whether file is binary
|
|
412
|
+
*/
|
|
413
|
+
export function isBinaryFile(path, content) {
|
|
414
|
+
const binaryExtensions = [
|
|
415
|
+
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico',
|
|
416
|
+
'.zip', '.tar', '.gz', '.bz2', '.7z',
|
|
417
|
+
'.pdf', '.doc', '.docx', '.xls', '.xlsx',
|
|
418
|
+
'.exe', '.dll', '.so', '.dylib',
|
|
419
|
+
'.mp3', '.mp4', '.avi', '.mov',
|
|
420
|
+
];
|
|
421
|
+
|
|
422
|
+
const ext = path.substring(path.lastIndexOf('.')).toLowerCase();
|
|
423
|
+
if (binaryExtensions.includes(ext)) {
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (content) {
|
|
428
|
+
return isBinaryContent(content);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return false;
|
|
432
|
+
}
|