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