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.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +971 -0
  3. package/dist/index.cjs +12609 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.mjs +12600 -0
  6. package/dist/index.mjs.map +1 -0
  7. package/package.json +90 -0
  8. package/src/api/repository.js +6885 -0
  9. package/src/backend/isomorphic-git-backend.js +687 -0
  10. package/src/backend/lazy-git-backend.js +180 -0
  11. package/src/browser/helpers.js +189 -0
  12. package/src/browser/index.d.ts +174 -0
  13. package/src/browser/index.js +12 -0
  14. package/src/core/background-ops.js +272 -0
  15. package/src/core/bookmark-store.js +232 -0
  16. package/src/core/change-graph.js +285 -0
  17. package/src/core/conflict-model.js +505 -0
  18. package/src/core/jj-checkout.js +78 -0
  19. package/src/core/jj-operation-store.js +120 -0
  20. package/src/core/jj-tree-state.js +94 -0
  21. package/src/core/jj-view-store.js +105 -0
  22. package/src/core/merge-driver-registry.js +432 -0
  23. package/src/core/operation-log.js +272 -0
  24. package/src/core/revset-engine.js +1081 -0
  25. package/src/core/storage-manager.js +207 -0
  26. package/src/core/tag-store.js +167 -0
  27. package/src/core/user-config.js +142 -0
  28. package/src/core/working-copy.js +314 -0
  29. package/src/core/workspace-manager.js +283 -0
  30. package/src/drivers/built-in-drivers.js +296 -0
  31. package/src/index.js +15 -0
  32. package/src/protos/local_working_copy.proto +76 -0
  33. package/src/protos/simple_op_store.proto +147 -0
  34. package/src/types.d.ts +1129 -0
  35. package/src/utils/errors.js +21 -0
  36. package/src/utils/id-generation.js +54 -0
  37. package/src/utils/validation.js +124 -0
@@ -0,0 +1,314 @@
1
+ /**
2
+ * WorkingCopy - Manages working copy state and file tracking
3
+ *
4
+ * Tracks file states (mtime, size, mode) for efficient modification detection.
5
+ */
6
+
7
+ import { JJError } from '../utils/errors.js';
8
+ import { validateChangeId, validatePath } from '../utils/validation.js';
9
+
10
+ export class WorkingCopy {
11
+ /**
12
+ * @param {Storage} storage - Storage manager instance
13
+ * @param {Object} fs - Filesystem implementation
14
+ * @param {string} dir - Repository directory
15
+ * @param {string} [workspaceId='default'] - Workspace identifier
16
+ */
17
+ constructor(storage, fs, dir, workspaceId = 'default') {
18
+ this.storage = storage;
19
+ this.fs = fs;
20
+ this.dir = dir;
21
+ this.workspaceId = workspaceId;
22
+ this.state = null;
23
+ }
24
+
25
+ /**
26
+ * Initialize working copy state
27
+ *
28
+ * @param {string} changeId - Initial change ID
29
+ * @param {string} [operationId] - Operation ID that created this state
30
+ */
31
+ async init(changeId, operationId = '') {
32
+ validateChangeId(changeId);
33
+
34
+ this.state = {
35
+ version: 1,
36
+ workspaceId: this.workspaceId,
37
+ changeId,
38
+ operation: operationId,
39
+ fileStates: {},
40
+ sparsePatterns: [], // Empty array = full checkout
41
+ };
42
+
43
+ await this.save();
44
+ }
45
+
46
+ /**
47
+ * Load working copy state from storage
48
+ *
49
+ * Workspace-specific path: working_copy/workspace_id/state.json
50
+ */
51
+ async load() {
52
+ const workspacePath = `working_copy/${this.workspaceId}/state.json`;
53
+ const data = await this.storage.read(workspacePath);
54
+
55
+ if (!data) {
56
+ throw new JJError('STORAGE_CORRUPT', `${workspacePath} not found`, {
57
+ suggestion: 'Initialize repository with init()',
58
+ workspaceId: this.workspaceId,
59
+ });
60
+ }
61
+
62
+ if (data.version !== 1) {
63
+ throw new JJError(
64
+ 'STORAGE_VERSION_MISMATCH',
65
+ `Unsupported working copy version: ${data.version}`,
66
+ {
67
+ version: data.version,
68
+ suggestion: 'Upgrade isomorphic-jj or run migration tool',
69
+ }
70
+ );
71
+ }
72
+
73
+ this.state = data;
74
+ }
75
+
76
+ /**
77
+ * Save working copy state to storage
78
+ *
79
+ * Workspace-specific path: working_copy/workspace_id/state.json
80
+ */
81
+ async save() {
82
+ const workspacePath = `working_copy/${this.workspaceId}/state.json`;
83
+ await this.storage.write(workspacePath, this.state);
84
+ }
85
+
86
+ /**
87
+ * Get current working copy state
88
+ *
89
+ * @returns {Object} Working copy state
90
+ */
91
+ async getState() {
92
+ if (!this.state) {
93
+ await this.load();
94
+ }
95
+ return this.state;
96
+ }
97
+
98
+ /**
99
+ * Get current change ID
100
+ *
101
+ * @returns {string} Current change ID
102
+ */
103
+ getCurrentChangeId() {
104
+ if (!this.state) {
105
+ throw new JJError('WORKING_COPY_NOT_LOADED', 'Working copy state not loaded', {
106
+ suggestion: 'Call load() or init() first',
107
+ });
108
+ }
109
+ return this.state.changeId;
110
+ }
111
+
112
+ /**
113
+ * Set current change ID
114
+ *
115
+ * @param {string} changeId - New change ID
116
+ */
117
+ async setCurrentChange(changeId) {
118
+ validateChangeId(changeId);
119
+
120
+ if (!this.state) {
121
+ await this.load();
122
+ }
123
+
124
+ this.state.changeId = changeId;
125
+ await this.save();
126
+ }
127
+
128
+ /**
129
+ * Track a file in the working copy
130
+ *
131
+ * @param {string} path - File path relative to repo root
132
+ * @param {Object} fileState - File state (mtime, size, mode, hash)
133
+ */
134
+ async trackFile(path, fileState) {
135
+ validatePath(path);
136
+
137
+ if (!this.state) {
138
+ await this.load();
139
+ }
140
+
141
+ this.state.fileStates[path] = fileState;
142
+ await this.save();
143
+ }
144
+
145
+ /**
146
+ * Untrack a file from the working copy
147
+ *
148
+ * @param {string} path - File path to untrack
149
+ */
150
+ async untrackFile(path) {
151
+ validatePath(path);
152
+
153
+ if (!this.state) {
154
+ await this.load();
155
+ }
156
+
157
+ delete this.state.fileStates[path];
158
+ await this.save();
159
+ }
160
+
161
+ /**
162
+ * Get list of modified files
163
+ *
164
+ * Uses mtime + size comparison for fast detection.
165
+ *
166
+ * @returns {Promise<Array<string>>} Array of modified file paths
167
+ */
168
+ async getModifiedFiles() {
169
+ if (!this.state) {
170
+ await this.load();
171
+ }
172
+
173
+ const modified = [];
174
+
175
+ for (const [path, tracked] of Object.entries(this.state.fileStates)) {
176
+ const fullPath = `${this.dir}/${path}`;
177
+
178
+ try {
179
+ const stats = await this.fs.promises.stat(fullPath);
180
+
181
+ // Fast path: check mtime and size
182
+ if (stats.mtime !== tracked.mtime || stats.size !== tracked.size) {
183
+ modified.push(path);
184
+ }
185
+ } catch (error) {
186
+ if (error.code === 'ENOENT') {
187
+ // File was deleted
188
+ modified.push(path);
189
+ } else {
190
+ throw new JJError('STORAGE_READ_FAILED', `Failed to stat ${path}: ${error.message}`, {
191
+ path: fullPath,
192
+ originalError: error,
193
+ });
194
+ }
195
+ }
196
+ }
197
+
198
+ return modified;
199
+ }
200
+
201
+ /**
202
+ * List all tracked files in working copy
203
+ *
204
+ * @returns {Promise<Array<string>>} Array of tracked file paths
205
+ */
206
+ async listFiles() {
207
+ if (!this.state) {
208
+ await this.load();
209
+ }
210
+
211
+ return Object.keys(this.state.fileStates);
212
+ }
213
+
214
+ /**
215
+ * Clear all file states
216
+ */
217
+ async clearFileStates() {
218
+ if (!this.state) {
219
+ await this.load();
220
+ }
221
+
222
+ this.state.fileStates = {};
223
+ await this.save();
224
+ }
225
+
226
+ /**
227
+ * Get sparse patterns
228
+ *
229
+ * @returns {string[]} Array of sparse patterns
230
+ */
231
+ getSparsePatterns() {
232
+ if (!this.state) {
233
+ throw new JJError('WORKING_COPY_NOT_LOADED', 'Working copy state not loaded', {
234
+ suggestion: 'Call load() or init() first',
235
+ });
236
+ }
237
+
238
+ // Return empty array if not set (full checkout)
239
+ return this.state.sparsePatterns || [];
240
+ }
241
+
242
+ /**
243
+ * Set sparse patterns
244
+ *
245
+ * @param {string[]} patterns - Array of sparse patterns
246
+ */
247
+ async setSparsePatterns(patterns) {
248
+ if (!this.state) {
249
+ await this.load();
250
+ }
251
+
252
+ this.state.sparsePatterns = patterns;
253
+ // Note: save() is called by the caller (repository API)
254
+ }
255
+
256
+ /**
257
+ * Check if a file path matches sparse patterns
258
+ *
259
+ * @param {string} filePath - File path to check
260
+ * @returns {boolean} True if file should be materialized
261
+ */
262
+ matchesSparsePatterns(filePath) {
263
+ const patterns = this.getSparsePatterns();
264
+
265
+ // Empty patterns = full checkout (all files match)
266
+ if (patterns.length === 0) {
267
+ return true;
268
+ }
269
+
270
+ // Check if file matches any pattern
271
+ for (const pattern of patterns) {
272
+ if (this._matchesPattern(filePath, pattern)) {
273
+ return true;
274
+ }
275
+ }
276
+
277
+ return false;
278
+ }
279
+
280
+ /**
281
+ * Simple glob pattern matching
282
+ *
283
+ * @private
284
+ * @param {string} path - File path
285
+ * @param {string} pattern - Glob pattern
286
+ * @returns {boolean} True if path matches pattern
287
+ */
288
+ _matchesPattern(path, pattern) {
289
+ // Exact match
290
+ if (path === pattern) {
291
+ return true;
292
+ }
293
+
294
+ // Directory match (pattern ending with /)
295
+ if (pattern.endsWith('/') && path.startsWith(pattern)) {
296
+ return true;
297
+ }
298
+
299
+ // Glob patterns
300
+ if (pattern.includes('*')) {
301
+ // Convert glob to regex
302
+ const regexPattern = pattern
303
+ .replace(/\./g, '\\.')
304
+ .replace(/\*\*/g, '___DOUBLESTAR___')
305
+ .replace(/\*/g, '[^/]*')
306
+ .replace(/___DOUBLESTAR___/g, '.*');
307
+
308
+ const regex = new RegExp(`^${regexPattern}$`);
309
+ return regex.test(path);
310
+ }
311
+
312
+ return false;
313
+ }
314
+ }
@@ -0,0 +1,283 @@
1
+ /**
2
+ * WorkspaceManager - Manages multiple working copies (JJ workspaces)
3
+ *
4
+ * Allows multiple independent working directories to coexist,
5
+ * each pointing to different changes in the repository.
6
+ *
7
+ * Note: JJ calls these "workspaces" not "worktrees" (Git terminology).
8
+ */
9
+
10
+ import { JJError } from '../utils/errors.js';
11
+ import { validateChangeId, validatePath } from '../utils/validation.js';
12
+ import { generateId } from '../utils/id-generation.js';
13
+ import path from 'path';
14
+
15
+ export class WorkspaceManager {
16
+ /**
17
+ * @param {Storage} storage - Storage manager instance
18
+ * @param {Object} fs - Filesystem implementation
19
+ * @param {string} repoDir - Repository directory
20
+ */
21
+ constructor(storage, fs, repoDir) {
22
+ this.storage = storage;
23
+ this.fs = fs;
24
+ this.repoDir = repoDir;
25
+ this.workspaces = new Map(); // workspaceId -> Workspace
26
+ }
27
+
28
+ /**
29
+ * Initialize workspace manager
30
+ */
31
+ async init() {
32
+ // Create default workspace (main working copy)
33
+ const defaultWorkspace = {
34
+ id: 'default',
35
+ name: 'default',
36
+ path: this.repoDir,
37
+ changeId: null,
38
+ created: new Date().toISOString(),
39
+ };
40
+
41
+ this.workspaces.set('default', defaultWorkspace);
42
+ await this.save();
43
+ }
44
+
45
+ /**
46
+ * Load workspaces from storage
47
+ */
48
+ async load() {
49
+ try {
50
+ const data = await this.storage.read('repo/store/workspaces.json');
51
+ if (data) {
52
+ this.workspaces = new Map(Object.entries(data.workspaces || {}));
53
+ }
54
+ } catch (error) {
55
+ // No workspaces file yet, initialize with default
56
+ await this.init();
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Save workspaces to storage
62
+ */
63
+ async save() {
64
+ const data = {
65
+ workspaces: Object.fromEntries(this.workspaces),
66
+ };
67
+ await this.storage.write('repo/store/workspaces.json', data);
68
+ }
69
+
70
+ /**
71
+ * Add a new workspace
72
+ *
73
+ * @param {Object} args - Workspace options
74
+ * @param {string} args.path - Path for the new workspace
75
+ * @param {string} [args.name] - Optional name for the workspace
76
+ * @param {string} [args.changeId] - Change to check out in new workspace
77
+ * @returns {Promise<Object>} Created workspace
78
+ */
79
+ async add(args) {
80
+ if (!args || !args.path) {
81
+ throw new JJError('INVALID_ARGUMENT', 'Missing path for workspace', {
82
+ suggestion: 'Provide a path for the new workspace',
83
+ });
84
+ }
85
+
86
+ // Workspace paths can be absolute (unlike regular file paths)
87
+ // Just validate changeId if provided
88
+ if (args.changeId) {
89
+ validateChangeId(args.changeId);
90
+ }
91
+
92
+ // Check if path already exists as a workspace
93
+ for (const workspace of this.workspaces.values()) {
94
+ if (workspace.path === args.path) {
95
+ throw new JJError('WORKSPACE_EXISTS', `Workspace already exists at ${args.path}`, {
96
+ path: args.path,
97
+ });
98
+ }
99
+ }
100
+
101
+ const workspaceId = generateId('workspace');
102
+ const workspace = {
103
+ id: workspaceId,
104
+ name: args.name || `workspace-${workspaceId.slice(0, 8)}`,
105
+ path: args.path,
106
+ changeId: args.changeId || null,
107
+ created: new Date().toISOString(),
108
+ };
109
+
110
+ // Create workspace directory
111
+ await this.fs.promises.mkdir(args.path, { recursive: true });
112
+
113
+ // Create workspace-specific working_copy directory in .jj
114
+ const workspaceCopyDir = path.join(this.repoDir, '.jj', 'working_copy', workspaceId);
115
+ await this.fs.promises.mkdir(workspaceCopyDir, { recursive: true });
116
+
117
+ // Create .git file pointing to main repo's .git directory
118
+ // This allows Git tools to work in the workspace
119
+ // IMPORTANT: Must use absolute paths for Git compatibility
120
+ const gitFilePath = path.join(args.path, '.git');
121
+ const mainGitDir = path.resolve(this.repoDir, '.git');
122
+ const gitFileContent = `gitdir: ${mainGitDir}\n`;
123
+ await this.fs.promises.writeFile(gitFilePath, gitFileContent, 'utf8');
124
+
125
+ // Create .jj file pointing to main repo's .jj directory
126
+ // This allows JJ tools to work in the workspace
127
+ // IMPORTANT: Must use absolute paths for JJ CLI compatibility
128
+ const jjFilePath = path.join(args.path, '.jj');
129
+ const mainJJDir = path.resolve(this.repoDir, '.jj');
130
+ const jjFileContent = `jjdir: ${mainJJDir}\n`;
131
+ await this.fs.promises.writeFile(jjFilePath, jjFileContent, 'utf8');
132
+
133
+ this.workspaces.set(workspaceId, workspace);
134
+ await this.save();
135
+
136
+ return workspace;
137
+ }
138
+
139
+ /**
140
+ * Remove a workspace
141
+ *
142
+ * @param {string} workspaceId - Workspace ID to remove
143
+ * @param {boolean} [force=false] - Force removal even if files exist
144
+ */
145
+ async remove(workspaceId, force = false) {
146
+ if (workspaceId === 'default') {
147
+ throw new JJError('INVALID_OPERATION', 'Cannot remove default workspace', {
148
+ suggestion: 'Use a different workspace ID',
149
+ });
150
+ }
151
+
152
+ const workspace = this.workspaces.get(workspaceId);
153
+ if (!workspace) {
154
+ throw new JJError('WORKSPACE_NOT_FOUND', `Workspace ${workspaceId} not found`, {
155
+ workspaceId,
156
+ });
157
+ }
158
+
159
+ // Check if directory is empty (unless force is true)
160
+ if (!force) {
161
+ try {
162
+ const files = await this.fs.promises.readdir(workspace.path);
163
+ if (files.length > 0) {
164
+ throw new JJError('WORKSPACE_NOT_EMPTY', `Workspace at ${workspace.path} is not empty`, {
165
+ path: workspace.path,
166
+ suggestion: 'Use force=true to remove anyway',
167
+ });
168
+ }
169
+ } catch (error) {
170
+ if (error.code !== 'ENOENT') {
171
+ throw error;
172
+ }
173
+ }
174
+ }
175
+
176
+ // Remove the workspace directory and all its contents
177
+ try {
178
+ await this.fs.promises.rm(workspace.path, { recursive: true, force: true });
179
+ } catch (error) {
180
+ // Ignore errors if directory doesn't exist
181
+ if (error.code !== 'ENOENT') {
182
+ throw new JJError(
183
+ 'WORKSPACE_REMOVAL_FAILED',
184
+ `Failed to remove workspace directory: ${error.message}`,
185
+ { path: workspace.path, originalError: error.message }
186
+ );
187
+ }
188
+ }
189
+
190
+ this.workspaces.delete(workspaceId);
191
+ await this.save();
192
+ }
193
+
194
+ /**
195
+ * Forget a workspace without deleting files
196
+ *
197
+ * @param {string} workspaceId - Workspace ID to forget
198
+ */
199
+ async forget(workspaceId) {
200
+ if (workspaceId === 'default') {
201
+ throw new JJError('INVALID_OPERATION', 'Cannot forget default workspace', {
202
+ suggestion: 'Use a different workspace ID',
203
+ });
204
+ }
205
+
206
+ const workspace = this.workspaces.get(workspaceId);
207
+ if (!workspace) {
208
+ throw new JJError('WORKSPACE_NOT_FOUND', `Workspace ${workspaceId} not found`, {
209
+ workspaceId,
210
+ });
211
+ }
212
+
213
+ // Just remove from tracking, leave files alone
214
+ this.workspaces.delete(workspaceId);
215
+ await this.save();
216
+ }
217
+
218
+ /**
219
+ * Get a workspace by ID
220
+ *
221
+ * @param {string} workspaceId - Workspace ID
222
+ * @returns {Object|undefined} Workspace object
223
+ */
224
+ get(workspaceId) {
225
+ return this.workspaces.get(workspaceId);
226
+ }
227
+
228
+ /**
229
+ * Get workspace by path
230
+ *
231
+ * @param {string} path - Workspace path
232
+ * @returns {Object|null} Workspace object or null
233
+ */
234
+ getByPath(path) {
235
+ for (const workspace of this.workspaces.values()) {
236
+ if (workspace.path === path) {
237
+ return workspace;
238
+ }
239
+ }
240
+ return null;
241
+ }
242
+
243
+ /**
244
+ * List all workspaces
245
+ *
246
+ * @returns {Array<Object>} Array of workspaces
247
+ */
248
+ list() {
249
+ return Array.from(this.workspaces.values());
250
+ }
251
+
252
+ /**
253
+ * Update a workspace's change
254
+ *
255
+ * @param {string} workspaceId - Workspace ID
256
+ * @param {string} changeId - New change ID
257
+ */
258
+ async updateChange(workspaceId, changeId) {
259
+ validateChangeId(changeId);
260
+
261
+ const workspace = this.workspaces.get(workspaceId);
262
+ if (!workspace) {
263
+ throw new JJError('WORKSPACE_NOT_FOUND', `Workspace ${workspaceId} not found`, {
264
+ workspaceId,
265
+ });
266
+ }
267
+
268
+ workspace.changeId = changeId;
269
+ await this.save();
270
+ }
271
+
272
+ /**
273
+ * Clear all workspaces (except default)
274
+ */
275
+ async clear() {
276
+ const defaultWorkspace = this.workspaces.get('default');
277
+ this.workspaces.clear();
278
+ if (defaultWorkspace) {
279
+ this.workspaces.set('default', defaultWorkspace);
280
+ }
281
+ await this.save();
282
+ }
283
+ }