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,1081 @@
1
+ /**
2
+ * RevsetEngine - Query language for finding changes
3
+ *
4
+ * Implements a simplified revset language for querying the change graph.
5
+ */
6
+
7
+ import { JJError } from '../utils/errors.js';
8
+
9
+ export class RevsetEngine {
10
+ /**
11
+ * @param {ChangeGraph} graph - Change graph instance
12
+ * @param {WorkingCopy} workingCopy - Working copy instance
13
+ * @param {UserConfig} userConfig - User configuration instance (optional)
14
+ * @param {BookmarkStore} bookmarkStore - Bookmark store instance (optional, v0.4)
15
+ */
16
+ constructor(graph, workingCopy, userConfig = null, bookmarkStore = null) {
17
+ this.graph = graph;
18
+ this.workingCopy = workingCopy;
19
+ this.userConfig = userConfig;
20
+ this.bookmarkStore = bookmarkStore;
21
+ }
22
+
23
+ /**
24
+ * Evaluate a revset expression
25
+ *
26
+ * @param {string} expression - Revset expression
27
+ * @returns {Promise<Array<string>>} Array of matching change IDs
28
+ */
29
+ async evaluate(expression) {
30
+ const trimmed = expression.trim();
31
+
32
+ // @ - working copy
33
+ if (trimmed === '@') {
34
+ return [this.workingCopy.getCurrentChangeId()];
35
+ }
36
+
37
+ // @- operator (parent of working copy) - handles chaining like @-- for grandparents
38
+ const atParentsMatch = trimmed.match(/^@(-+)$/);
39
+ if (atParentsMatch) {
40
+ const depth = atParentsMatch[1].length; // Count number of '-' characters
41
+ const workingCopyId = this.workingCopy.getCurrentChangeId();
42
+
43
+ let currentSet = [workingCopyId];
44
+ for (let i = 0; i < depth; i++) {
45
+ currentSet = await this.getParentsOfSet(currentSet);
46
+ if (currentSet.length === 0) break; // Stop if we reach root
47
+ }
48
+ return currentSet;
49
+ }
50
+
51
+ // @+ operator (children of working copy)
52
+ const atChildrenMatch = trimmed.match(/^@(\++)$/);
53
+ if (atChildrenMatch) {
54
+ const depth = atChildrenMatch[1].length; // Count number of '+' characters
55
+ const workingCopyId = this.workingCopy.getCurrentChangeId();
56
+
57
+ let currentSet = [workingCopyId];
58
+ for (let i = 0; i < depth; i++) {
59
+ currentSet = await this.getChildrenOfSet(currentSet);
60
+ if (currentSet.length === 0) break; // Stop if we reach leaves
61
+ }
62
+ return currentSet;
63
+ }
64
+
65
+ // all() - all changes
66
+ if (trimmed === 'all()') {
67
+ await this.graph.load();
68
+ return this.graph.getAll().map((c) => c.changeId);
69
+ }
70
+
71
+ // v1.0: none() - empty set
72
+ if (trimmed === 'none()') {
73
+ return [];
74
+ }
75
+
76
+ // v1.0: root() - the first commit (oldest commit with no parents)
77
+ if (trimmed === 'root()') {
78
+ await this.graph.load();
79
+ const allChanges = this.graph.getAll();
80
+ const rootCommits = allChanges.filter(c => !c.parents || c.parents.length === 0);
81
+ if (rootCommits.length === 0) return [];
82
+ // Return the oldest root by timestamp
83
+ const oldest = rootCommits.sort((a, b) =>
84
+ new Date(a.timestamp) - new Date(b.timestamp)
85
+ )[0];
86
+ return [oldest.changeId];
87
+ }
88
+
89
+ // v1.0: visible_heads() - all commits with no children
90
+ if (trimmed === 'visible_heads()') {
91
+ await this.graph.load();
92
+ const allChanges = this.graph.getAll();
93
+ const changeIdSet = new Set(allChanges.map(c => c.changeId));
94
+ const hasChildren = new Set();
95
+
96
+ // Mark all commits that have children
97
+ for (const change of allChanges) {
98
+ if (change.parents) {
99
+ for (const parent of change.parents) {
100
+ if (changeIdSet.has(parent)) {
101
+ hasChildren.add(parent);
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ // Return commits without children
108
+ return allChanges
109
+ .filter(c => !hasChildren.has(c.changeId))
110
+ .map(c => c.changeId);
111
+ }
112
+
113
+ // v1.0: git_refs() - all commits with bookmarks
114
+ if (trimmed === 'git_refs()') {
115
+ if (!this.bookmarkStore) return [];
116
+ await this.bookmarkStore.load();
117
+ const allBookmarks = await this.bookmarkStore.list();
118
+ return allBookmarks.map(b => b.changeId);
119
+ }
120
+
121
+ // v1.0: git_head() - current working copy (Git HEAD equivalent)
122
+ if (trimmed === 'git_head()') {
123
+ try {
124
+ const currentId = this.workingCopy.getCurrentChangeId();
125
+ return currentId ? [currentId] : [];
126
+ } catch (error) {
127
+ // Working copy not loaded, return empty set
128
+ return [];
129
+ }
130
+ }
131
+
132
+ // ancestors(changeId) - all ancestors including the change itself
133
+ const ancestorsMatch = trimmed.match(/^ancestors\(([0-9a-f]{32})\)$/);
134
+ if (ancestorsMatch) {
135
+ const changeId = ancestorsMatch[1];
136
+ return await this.getAncestors(changeId);
137
+ }
138
+
139
+ // v0.2: author(name) - changes by author
140
+ const authorMatch = trimmed.match(/^author\((.+?)\)$/);
141
+ if (authorMatch) {
142
+ const authorName = authorMatch[1].replace(/['"]/g, '');
143
+ return await this.filterByAuthor(authorName);
144
+ }
145
+
146
+ // v0.2: description(text) - changes with description containing text
147
+ const descMatch = trimmed.match(/^description\((.+?)\)$/);
148
+ if (descMatch) {
149
+ const text = descMatch[1].replace(/['"]/g, '');
150
+ return await this.filterByDescription(text);
151
+ }
152
+
153
+ // v0.2: empty() - changes with no content
154
+ if (trimmed === 'empty()') {
155
+ return await this.filterEmpty();
156
+ }
157
+
158
+ // v0.3.1: mine() - changes by current user
159
+ if (trimmed === 'mine()') {
160
+ return await this.filterMine();
161
+ }
162
+
163
+ // v0.3.1: merge() - merge commits (multiple parents)
164
+ if (trimmed === 'merge()') {
165
+ return await this.filterMerge();
166
+ }
167
+
168
+ // v0.3.1: file(pattern) - changes touching files matching pattern
169
+ const fileMatch = trimmed.match(/^file\((.+?)\)$/);
170
+ if (fileMatch) {
171
+ const pattern = fileMatch[1].replace(/['"]/g, '');
172
+ return await this.filterByFile(pattern);
173
+ }
174
+
175
+ // v0.4: roots(revset) - commits not descendants of others in set
176
+ const rootsMatch = trimmed.match(/^roots\((.+?)\)$/);
177
+ if (rootsMatch) {
178
+ const innerRevset = rootsMatch[1];
179
+ const innerResults = await this.evaluate(innerRevset);
180
+ return await this.filterRoots(innerResults);
181
+ }
182
+
183
+ // v0.4: heads(revset) - commits not ancestors of others in set
184
+ const headsMatch = trimmed.match(/^heads\((.+?)\)$/);
185
+ if (headsMatch) {
186
+ const innerRevset = headsMatch[1];
187
+ const innerResults = await this.evaluate(innerRevset);
188
+ return await this.filterHeads(innerResults);
189
+ }
190
+
191
+ // v1.0: parents(revset) - direct parents of commits in set
192
+ const parentsMatch = trimmed.match(/^parents\((.+?)\)$/);
193
+ if (parentsMatch) {
194
+ const innerRevset = parentsMatch[1];
195
+ const innerResults = await this.evaluate(innerRevset);
196
+ return await this.getParentsOfSet(innerResults);
197
+ }
198
+
199
+ // v1.0: children(revset) - direct children of commits in set
200
+ const childrenMatch = trimmed.match(/^children\((.+?)\)$/);
201
+ if (childrenMatch) {
202
+ const innerRevset = childrenMatch[1];
203
+ const innerResults = await this.evaluate(innerRevset);
204
+ return await this.getChildrenOfSet(innerResults);
205
+ }
206
+
207
+ // v0.4: latest(revset, [count]) - latest N commits by committer timestamp
208
+ const latestMatch = trimmed.match(/^latest\((.+?)(?:,\s*(\d+))?\)$/);
209
+ if (latestMatch) {
210
+ const innerRevset = latestMatch[1];
211
+ const count = latestMatch[2] ? parseInt(latestMatch[2], 10) : 1;
212
+ const innerResults = await this.evaluate(innerRevset);
213
+ return await this.filterLatest(innerResults, count);
214
+ }
215
+
216
+ // v0.4: tags([pattern]) - tag targets
217
+ const tagsMatch = trimmed.match(/^tags\((?:(.+?))?\)$/);
218
+ if (tagsMatch || trimmed === 'tags()') {
219
+ const pattern = tagsMatch ? tagsMatch[1]?.replace(/['"]/g, '') : undefined;
220
+ return await this.filterTags(pattern);
221
+ }
222
+
223
+ // v1.0: bookmark(name) - single bookmark by exact name
224
+ const bookmarkMatch = trimmed.match(/^bookmark\((.+?)\)$/);
225
+ if (bookmarkMatch) {
226
+ const name = bookmarkMatch[1].replace(/['"]/g, '');
227
+ if (!this.bookmarkStore) return [];
228
+ await this.bookmarkStore.load();
229
+ const target = await this.bookmarkStore.get(name);
230
+ return target ? [target] : [];
231
+ }
232
+
233
+ // v0.4: bookmarks([pattern]) - bookmark targets
234
+ const bookmarksMatch = trimmed.match(/^bookmarks\((?:(.+?))?\)$/);
235
+ if (bookmarksMatch || trimmed === 'bookmarks()') {
236
+ const pattern = bookmarksMatch ? bookmarksMatch[1]?.replace(/['"]/g, '') : undefined;
237
+ return await this.filterBookmarks(pattern);
238
+ }
239
+
240
+ // v0.5: last(N) - last N commits
241
+ // v0.5: last(Nd) - commits in last N days
242
+ // v0.5: last(Nh) - commits in last N hours
243
+ const lastMatch = trimmed.match(/^last\((\d+)([dh])?\)$/);
244
+ if (lastMatch) {
245
+ const value = parseInt(lastMatch[1], 10);
246
+ const unit = lastMatch[2]; // 'd', 'h', or undefined
247
+
248
+ if (unit) {
249
+ // Time-based: last(Nd) or last(Nh)
250
+ return await this.filterByTimeRange(value, unit);
251
+ } else {
252
+ // Count-based: last(N)
253
+ return await this.filterLast(value);
254
+ }
255
+ }
256
+
257
+ // v0.5: since(date) - commits since date
258
+ const sinceMatch = trimmed.match(/^since\(([0-9-]+)\)$/);
259
+ if (sinceMatch) {
260
+ const date = sinceMatch[1];
261
+ return await this.filterSince(date);
262
+ }
263
+
264
+ // v0.5: between(start, end) - commits between dates
265
+ const betweenMatch = trimmed.match(/^between\(([0-9-]+),\s*([0-9-]+)\)$/);
266
+ if (betweenMatch) {
267
+ const startDate = betweenMatch[1];
268
+ const endDate = betweenMatch[2];
269
+ return await this.filterBetween(startDate, endDate);
270
+ }
271
+
272
+ // v0.5: descendants(changeId[, depth]) - all descendants
273
+ const descendantsMatch = trimmed.match(/^descendants\(([0-9a-f]{32})(?:,\s*(\d+))?\)$/);
274
+ if (descendantsMatch) {
275
+ const changeId = descendantsMatch[1];
276
+ const depth = descendantsMatch[2] ? parseInt(descendantsMatch[2], 10) : undefined;
277
+ return await this.getDescendants(changeId, depth);
278
+ }
279
+
280
+ // v0.5: common_ancestor(rev1, rev2) - common ancestor
281
+ const commonAncestorMatch = trimmed.match(/^common_ancestor\(([0-9a-f]{32}),\s*([0-9a-f]{32})\)$/);
282
+ if (commonAncestorMatch) {
283
+ const rev1 = commonAncestorMatch[1];
284
+ const rev2 = commonAncestorMatch[2];
285
+ return await this.findCommonAncestor(rev1, rev2);
286
+ }
287
+
288
+ // v0.5: range(base..tip) - commits in range
289
+ const rangeMatch = trimmed.match(/^range\(([0-9a-f]{32})\.\.([0-9a-f]{32})\)$/);
290
+ if (rangeMatch) {
291
+ const base = rangeMatch[1];
292
+ const tip = rangeMatch[2];
293
+ return await this.getRange(base, tip);
294
+ }
295
+
296
+ // v0.5: diverge_point(rev1, rev2) - divergence point
297
+ const divergeMatch = trimmed.match(/^diverge_point\(([0-9a-f]{32}),\s*([0-9a-f]{32})\)$/);
298
+ if (divergeMatch) {
299
+ const rev1 = divergeMatch[1];
300
+ const rev2 = divergeMatch[2];
301
+ return await this.findDivergePoint(rev1, rev2);
302
+ }
303
+
304
+ // v0.5: connected(rev1, rev2) - check if path exists
305
+ const connectedMatch = trimmed.match(/^connected\(([0-9a-f]{32}),\s*([0-9a-f]{32})\)$/);
306
+ if (connectedMatch) {
307
+ const rev1 = connectedMatch[1];
308
+ const rev2 = connectedMatch[2];
309
+ return await this.checkConnected(rev1, rev2);
310
+ }
311
+
312
+ // v1.0: x- operator (parents) - handles chaining like x-- for grandparents
313
+ const parentsOpMatch = trimmed.match(/^([0-9a-f]{32})(-+)$/);
314
+ if (parentsOpMatch) {
315
+ const baseChangeId = parentsOpMatch[1];
316
+ const depth = parentsOpMatch[2].length; // Count number of '-' characters
317
+
318
+ let currentSet = [baseChangeId];
319
+ for (let i = 0; i < depth; i++) {
320
+ currentSet = await this.getParentsOfSet(currentSet);
321
+ if (currentSet.length === 0) break; // Stop if we reach root
322
+ }
323
+ return currentSet;
324
+ }
325
+
326
+ // v1.0: x+ operator (children) - handles chaining like x++ for grandchildren
327
+ const childrenOpMatch = trimmed.match(/^([0-9a-f]{32})(\++)$/);
328
+ if (childrenOpMatch) {
329
+ const baseChangeId = childrenOpMatch[1];
330
+ const depth = childrenOpMatch[2].length; // Count number of '+' characters
331
+
332
+ let currentSet = [baseChangeId];
333
+ for (let i = 0; i < depth; i++) {
334
+ currentSet = await this.getChildrenOfSet(currentSet);
335
+ if (currentSet.length === 0) break; // Stop if we reach leaves
336
+ }
337
+ return currentSet;
338
+ }
339
+
340
+ // Range operator (..) - all changes from base to tip
341
+ // Format: base..tip means ancestors(tip) ~ ancestors(base)
342
+ if (trimmed.includes('..')) {
343
+ const parts = trimmed.split('..');
344
+ if (parts.length === 2) {
345
+ const base = parts[0].trim();
346
+ const tip = parts[1].trim();
347
+
348
+ // Get ancestors of tip
349
+ const baseChange = await this.graph.getChange(base);
350
+ const tipChange = await this.graph.getChange(tip);
351
+
352
+ if (!baseChange || !tipChange) {
353
+ throw new JJError('INVALID_REVSET', `Invalid range: ${trimmed}`, {
354
+ suggestion: 'Both base and tip must be valid change IDs',
355
+ });
356
+ }
357
+
358
+ // ancestors(tip) ~ ancestors(base) gives us all changes from base to tip
359
+ const tipAncestors = new Set(await this.getAncestors(tip));
360
+ const baseAncestors = new Set(await this.getAncestors(base));
361
+
362
+ // Remove base's ancestors from tip's ancestors
363
+ const rangeChanges = [...tipAncestors].filter(id => !baseAncestors.has(id));
364
+
365
+ return rangeChanges;
366
+ }
367
+ }
368
+
369
+ // conflicted() - changes with conflicts
370
+ if (trimmed === 'conflicted()') {
371
+ await this.graph.load();
372
+ const all = this.graph.getAll();
373
+ return all
374
+ .filter(c => c.conflicts && Object.keys(c.conflicts).length > 0)
375
+ .map(c => c.changeId);
376
+ }
377
+
378
+ // reachable(heads) - all changes reachable from heads
379
+ // Use a more careful regex that handles nested expressions
380
+ if (trimmed.startsWith('reachable(')) {
381
+ const innerStart = 'reachable('.length;
382
+ let parenCount = 1;
383
+ let endIndex = innerStart;
384
+
385
+ // Find the matching closing parenthesis
386
+ while (endIndex < trimmed.length && parenCount > 0) {
387
+ if (trimmed[endIndex] === '(') parenCount++;
388
+ if (trimmed[endIndex] === ')') parenCount--;
389
+ endIndex++;
390
+ }
391
+
392
+ // Check if this is a complete reachable() expression (not part of a larger expression)
393
+ if (parenCount === 0 && endIndex === trimmed.length) {
394
+ const headsExpr = trimmed.substring(innerStart, endIndex - 1).trim();
395
+ const heads = await this.evaluate(headsExpr);
396
+
397
+ // Get all ancestors of all heads
398
+ const reachable = new Set();
399
+ for (const headId of heads) {
400
+ const ancestors = await this.getAncestors(headId);
401
+ for (const ancestorId of ancestors) {
402
+ reachable.add(ancestorId);
403
+ }
404
+ }
405
+
406
+ return Array.from(reachable);
407
+ }
408
+ }
409
+
410
+ // tracked() - changes with tracked files (all changes with files)
411
+ if (trimmed === 'tracked()') {
412
+ await this.graph.load();
413
+ const all = this.graph.getAll();
414
+ return all
415
+ .filter(c => c.fileSnapshot && Object.keys(c.fileSnapshot).length > 0)
416
+ .map(c => c.changeId);
417
+ }
418
+
419
+ // untracked() - changes with no tracked files (empty changes)
420
+ if (trimmed === 'untracked()') {
421
+ await this.graph.load();
422
+ const all = this.graph.getAll();
423
+ return all
424
+ .filter(c => !c.fileSnapshot || Object.keys(c.fileSnapshot).length === 0)
425
+ .map(c => c.changeId);
426
+ }
427
+
428
+ // remote_branches([pattern]) - remote branch targets
429
+ const remoteBranchesMatch = trimmed.match(/^remote_branches\((?:"([^"]+)")?\)$/);
430
+ if (remoteBranchesMatch || trimmed === 'remote_branches()') {
431
+ if (!this.bookmarkStore) return [];
432
+
433
+ await this.bookmarkStore.load();
434
+ const allBookmarks = await this.bookmarkStore.list();
435
+
436
+ // Filter for remote branches (bookmarks with '/' in the name)
437
+ let remoteBookmarks = allBookmarks.filter(bookmark => bookmark.name.includes('/'));
438
+
439
+ // If pattern provided, filter by pattern
440
+ if (remoteBranchesMatch && remoteBranchesMatch[1]) {
441
+ const pattern = remoteBranchesMatch[1];
442
+ const regex = new RegExp(pattern.replace(/\*/g, '.*'));
443
+ remoteBookmarks = remoteBookmarks.filter(bookmark => regex.test(bookmark.name));
444
+ }
445
+
446
+ // Get target change IDs
447
+ const targets = new Set();
448
+ for (const bookmark of remoteBookmarks) {
449
+ if (bookmark.changeId) {
450
+ targets.add(bookmark.changeId);
451
+ }
452
+ }
453
+
454
+ return Array.from(targets);
455
+ }
456
+
457
+ // v0.5: Set operations - intersection (&), union (|), difference (~)
458
+ if (trimmed.includes(' & ') || trimmed.includes(' | ') || trimmed.includes(' ~ ')) {
459
+ return await this.evaluateSetOperation(trimmed);
460
+ }
461
+
462
+ // Direct changeId
463
+ if (/^[0-9a-f]{32}$/.test(trimmed)) {
464
+ await this.graph.load();
465
+ const change = await this.graph.getChange(trimmed);
466
+ return change ? [trimmed] : [];
467
+ }
468
+
469
+ throw new JJError('INVALID_REVSET', `Invalid revset expression: ${expression}`, {
470
+ expression,
471
+ suggestion: 'Use @, @-, @+, bookmark(name), all(), none(), root(), visible_heads(), git_refs(), git_head(), ancestors(changeId), author(name), description(text), empty(), mine(), merge(), file(pattern), roots(revset), heads(revset), parents(revset), children(revset), latest(revset, [count]), tags([pattern]), bookmarks([pattern]), last(N[dh]), since(date), between(start, end), descendants(rev[, depth]), common_ancestor(rev1, rev2), range(base..tip), diverge_point(rev1, rev2), connected(rev1, rev2), operators (x-, x+), set operations (& | ~), or a direct change ID',
472
+ });
473
+ }
474
+
475
+ /**
476
+ * Get all ancestors of a change (including the change itself)
477
+ *
478
+ * @param {string} changeId - Change ID
479
+ * @returns {Promise<Array<string>>} Array of ancestor change IDs
480
+ */
481
+ async getAncestors(changeId) {
482
+ await this.graph.load();
483
+
484
+ const ancestors = [];
485
+ const visited = new Set();
486
+ const queue = [changeId];
487
+
488
+ while (queue.length > 0) {
489
+ const current = queue.shift();
490
+
491
+ if (visited.has(current)) {
492
+ continue;
493
+ }
494
+
495
+ visited.add(current);
496
+ ancestors.push(current);
497
+
498
+ const parents = this.graph.getParents(current);
499
+ for (const parent of parents) {
500
+ queue.push(parent);
501
+ }
502
+ }
503
+
504
+ return ancestors;
505
+ }
506
+
507
+ /**
508
+ * Filter changes by author name (v0.2)
509
+ *
510
+ * @param {string} authorName - Author name to match
511
+ * @returns {Promise<Array<string>>} Array of matching change IDs
512
+ */
513
+ async filterByAuthor(authorName) {
514
+ await this.graph.load();
515
+ const all = this.graph.getAll();
516
+
517
+ return all
518
+ .filter((c) => c.author && c.author.name.includes(authorName))
519
+ .map((c) => c.changeId);
520
+ }
521
+
522
+ /**
523
+ * Filter changes by description text (v0.2)
524
+ *
525
+ * @param {string} text - Text to search for in description
526
+ * @returns {Promise<Array<string>>} Array of matching change IDs
527
+ */
528
+ async filterByDescription(text) {
529
+ await this.graph.load();
530
+ const all = this.graph.getAll();
531
+
532
+ return all
533
+ .filter((c) => c.description && c.description.includes(text))
534
+ .map((c) => c.changeId);
535
+ }
536
+
537
+ /**
538
+ * Filter empty changes (v0.2)
539
+ *
540
+ * @returns {Promise<Array<string>>} Array of empty change IDs
541
+ */
542
+ async filterEmpty() {
543
+ await this.graph.load();
544
+ const all = this.graph.getAll();
545
+
546
+ // A change is empty if it has an empty tree (placeholder for now)
547
+ return all
548
+ .filter((c) => c.tree === '0000000000000000000000000000000000000000')
549
+ .map((c) => c.changeId);
550
+ }
551
+
552
+ /**
553
+ * Filter changes by current user (v0.3.1)
554
+ *
555
+ * @returns {Promise<Array<string>>} Array of change IDs by current user
556
+ */
557
+ async filterMine() {
558
+ await this.graph.load();
559
+ const all = this.graph.getAll();
560
+
561
+ if (!this.userConfig) {
562
+ // If no userConfig, return all changes
563
+ return all.map((c) => c.changeId);
564
+ }
565
+
566
+ await this.userConfig.load();
567
+ const currentUser = this.userConfig.getUser();
568
+
569
+ return all
570
+ .filter((c) => c.author &&
571
+ (c.author.email === currentUser.email ||
572
+ c.author.name === currentUser.name))
573
+ .map((c) => c.changeId);
574
+ }
575
+
576
+ /**
577
+ * Filter merge commits (multiple parents) (v0.3.1)
578
+ *
579
+ * @returns {Promise<Array<string>>} Array of merge commit IDs
580
+ */
581
+ async filterMerge() {
582
+ await this.graph.load();
583
+ const all = this.graph.getAll();
584
+
585
+ return all
586
+ .filter((c) => c.parents && c.parents.length > 1)
587
+ .map((c) => c.changeId);
588
+ }
589
+
590
+ /**
591
+ * Filter changes touching files matching pattern (v0.3.1)
592
+ *
593
+ * @param {string} pattern - File pattern (glob-style)
594
+ * @returns {Promise<Array<string>>} Array of change IDs
595
+ */
596
+ async filterByFile(pattern) {
597
+ await this.graph.load();
598
+ const all = this.graph.getAll();
599
+
600
+ // Simple pattern matching for now (exact match or contains)
601
+ const results = [];
602
+ for (const change of all) {
603
+ if (change.fileSnapshot) {
604
+ const files = Object.keys(change.fileSnapshot);
605
+ const matches = files.some(file =>
606
+ file === pattern ||
607
+ file.includes(pattern) ||
608
+ this.globMatch(file, pattern)
609
+ );
610
+ if (matches) {
611
+ results.push(change.changeId);
612
+ }
613
+ }
614
+ }
615
+
616
+ return results;
617
+ }
618
+
619
+ /**
620
+ * Simple glob pattern matching
621
+ *
622
+ * @param {string} str - String to test
623
+ * @param {string} pattern - Glob pattern (* and ? wildcards)
624
+ * @returns {boolean} True if matches
625
+ */
626
+ globMatch(str, pattern) {
627
+ const regexPattern = pattern
628
+ .replace(/\./g, '\\.')
629
+ .replace(/\*/g, '.*')
630
+ .replace(/\?/g, '.');
631
+ const regex = new RegExp(`^${regexPattern}$`);
632
+ return regex.test(str);
633
+ }
634
+
635
+ /**
636
+ * Filter roots - commits not descendants of others in set (v0.4)
637
+ *
638
+ * @param {Array<string>} changeIds - Set of change IDs
639
+ * @returns {Promise<Array<string>>} Change IDs that are roots
640
+ */
641
+ async filterRoots(changeIds) {
642
+ await this.graph.load();
643
+
644
+ const roots = [];
645
+ const changeSet = new Set(changeIds);
646
+
647
+ for (const changeId of changeIds) {
648
+ // A change is a root if none of its parents are in the set
649
+ const parents = this.graph.getParents(changeId);
650
+ const hasParentInSet = parents.some(p => changeSet.has(p));
651
+
652
+ if (!hasParentInSet) {
653
+ roots.push(changeId);
654
+ }
655
+ }
656
+
657
+ return roots;
658
+ }
659
+
660
+ /**
661
+ * Filter heads - commits not ancestors of others in set (v0.4)
662
+ *
663
+ * @param {Array<string>} changeIds - Set of change IDs
664
+ * @returns {Promise<Array<string>>} Change IDs that are heads
665
+ */
666
+ async filterHeads(changeIds) {
667
+ await this.graph.load();
668
+
669
+ const heads = [];
670
+ const changeSet = new Set(changeIds);
671
+
672
+ for (const changeId of changeIds) {
673
+ // A change is a head if it has no children in the set
674
+ const children = this.graph.getChildren(changeId);
675
+ const hasChildInSet = children.some(c => changeSet.has(c));
676
+
677
+ if (!hasChildInSet) {
678
+ heads.push(changeId);
679
+ }
680
+ }
681
+
682
+ return heads;
683
+ }
684
+
685
+ /**
686
+ * Filter latest N commits by committer timestamp (v0.4)
687
+ *
688
+ * @param {Array<string>} changeIds - Set of change IDs
689
+ * @param {number} count - Number of latest commits to return
690
+ * @returns {Promise<Array<string>>} Latest N change IDs
691
+ */
692
+ async filterLatest(changeIds, count = 1) {
693
+ await this.graph.load();
694
+
695
+ const changes = [];
696
+ for (const changeId of changeIds) {
697
+ const change = await this.graph.getChange(changeId);
698
+ if (change) {
699
+ changes.push(change);
700
+ }
701
+ }
702
+
703
+ // Sort by committer timestamp (descending)
704
+ changes.sort((a, b) => {
705
+ const timeA = a.committer?.timestamp || a.timestamp || 0;
706
+ const timeB = b.committer?.timestamp || b.timestamp || 0;
707
+ return new Date(timeB) - new Date(timeA);
708
+ });
709
+
710
+ return changes.slice(0, count).map(c => c.changeId);
711
+ }
712
+
713
+ /**
714
+ * Filter tags matching pattern (v0.4)
715
+ *
716
+ * @param {string} [pattern] - Optional tag name pattern
717
+ * @returns {Promise<Array<string>>} Change IDs pointed to by matching tags
718
+ */
719
+ async filterTags(pattern) {
720
+ await this.graph.load();
721
+
722
+ // For now, return empty array since we don't have tag support yet
723
+ // TODO: Implement when tag storage is added
724
+ return [];
725
+ }
726
+
727
+ /**
728
+ * Filter bookmarks matching pattern (v0.4)
729
+ *
730
+ * @param {string} [pattern] - Optional bookmark name pattern (glob-style)
731
+ * @returns {Promise<Array<string>>} Change IDs pointed to by matching bookmarks
732
+ */
733
+ async filterBookmarks(pattern) {
734
+ if (!this.bookmarkStore) {
735
+ // No bookmark store available, return empty
736
+ return [];
737
+ }
738
+
739
+ await this.bookmarkStore.load();
740
+ const allBookmarks = await this.bookmarkStore.list();
741
+
742
+ const result = [];
743
+ for (const bookmark of allBookmarks) {
744
+ // Only include local bookmarks (not remote)
745
+ if (bookmark.remote) {
746
+ continue;
747
+ }
748
+
749
+ // Apply pattern if provided
750
+ if (pattern && !this.globMatch(bookmark.name, pattern)) {
751
+ continue;
752
+ }
753
+
754
+ result.push(bookmark.target);
755
+ }
756
+
757
+ // Return unique changeIds
758
+ return [...new Set(result)];
759
+ }
760
+
761
+ /**
762
+ * Filter last N commits by timestamp (v0.5)
763
+ *
764
+ * @param {number} count - Number of commits to return
765
+ * @returns {Promise<Array<string>>} Most recent change IDs
766
+ */
767
+ async filterLast(count) {
768
+ await this.graph.load();
769
+ const all = this.graph.getAll();
770
+
771
+ // Sort by committer timestamp (most recent first)
772
+ const sorted = all.sort((a, b) => {
773
+ const timeA = a.committer?.timestamp || 0;
774
+ const timeB = b.committer?.timestamp || 0;
775
+ return timeB - timeA;
776
+ });
777
+
778
+ return sorted.slice(0, count).map(c => c.changeId);
779
+ }
780
+
781
+ /**
782
+ * Filter commits within time range (v0.5)
783
+ *
784
+ * @param {number} value - Time value
785
+ * @param {string} unit - Time unit ('d' for days, 'h' for hours)
786
+ * @returns {Promise<Array<string>>} Change IDs within time range
787
+ */
788
+ async filterByTimeRange(value, unit) {
789
+ await this.graph.load();
790
+ const all = this.graph.getAll();
791
+
792
+ const now = Date.now();
793
+ let milliseconds;
794
+
795
+ if (unit === 'd') {
796
+ milliseconds = value * 24 * 60 * 60 * 1000; // days to ms
797
+ } else if (unit === 'h') {
798
+ milliseconds = value * 60 * 60 * 1000; // hours to ms
799
+ } else {
800
+ throw new JJError('INVALID_TIME_UNIT', `Invalid time unit: ${unit}`, { unit });
801
+ }
802
+
803
+ const cutoffTime = now - milliseconds;
804
+
805
+ return all
806
+ .filter(c => {
807
+ const timestamp = c.committer?.timestamp || 0;
808
+ return timestamp >= cutoffTime;
809
+ })
810
+ .map(c => c.changeId);
811
+ }
812
+
813
+ /**
814
+ * Filter commits since a date (v0.5)
815
+ *
816
+ * @param {string} dateStr - ISO date string (YYYY-MM-DD)
817
+ * @returns {Promise<Array<string>>} Change IDs since date
818
+ */
819
+ async filterSince(dateStr) {
820
+ await this.graph.load();
821
+ const all = this.graph.getAll();
822
+
823
+ const sinceTime = new Date(dateStr).getTime();
824
+
825
+ return all
826
+ .filter(c => {
827
+ const timestamp = c.committer?.timestamp || 0;
828
+ return timestamp >= sinceTime;
829
+ })
830
+ .map(c => c.changeId);
831
+ }
832
+
833
+ /**
834
+ * Filter commits between two dates (v0.5)
835
+ *
836
+ * @param {string} startDateStr - ISO date string (YYYY-MM-DD)
837
+ * @param {string} endDateStr - ISO date string (YYYY-MM-DD)
838
+ * @returns {Promise<Array<string>>} Change IDs between dates
839
+ */
840
+ async filterBetween(startDateStr, endDateStr) {
841
+ await this.graph.load();
842
+ const all = this.graph.getAll();
843
+
844
+ const startTime = new Date(startDateStr).getTime();
845
+ const endTime = new Date(endDateStr).getTime();
846
+
847
+ return all
848
+ .filter(c => {
849
+ const timestamp = c.committer?.timestamp || 0;
850
+ return timestamp >= startTime && timestamp <= endTime;
851
+ })
852
+ .map(c => c.changeId);
853
+ }
854
+
855
+ /**
856
+ * Get all descendants of a change (v0.5)
857
+ *
858
+ * @param {string} changeId - Change ID
859
+ * @param {number} [depth] - Optional depth limit
860
+ * @returns {Promise<Array<string>>} Array of descendant change IDs
861
+ */
862
+ async getDescendants(changeId, depth = undefined) {
863
+ await this.graph.load();
864
+
865
+ const descendants = [];
866
+ const visited = new Set();
867
+ const queue = [{ id: changeId, level: 0 }];
868
+
869
+ while (queue.length > 0) {
870
+ const { id: current, level } = queue.shift();
871
+
872
+ if (visited.has(current)) {
873
+ continue;
874
+ }
875
+
876
+ visited.add(current);
877
+
878
+ // Don't include the starting change itself
879
+ if (current !== changeId) {
880
+ descendants.push(current);
881
+ }
882
+
883
+ // Check depth limit
884
+ if (depth !== undefined && level >= depth) {
885
+ continue;
886
+ }
887
+
888
+ const children = this.graph.getChildren(current);
889
+ for (const child of children) {
890
+ queue.push({ id: child, level: level + 1 });
891
+ }
892
+ }
893
+
894
+ return descendants;
895
+ }
896
+
897
+ /**
898
+ * Find common ancestor of two revisions (v0.5)
899
+ *
900
+ * @param {string} rev1 - First revision
901
+ * @param {string} rev2 - Second revision
902
+ * @returns {Promise<Array<string>>} Common ancestor (single element array or empty)
903
+ */
904
+ async findCommonAncestor(rev1, rev2) {
905
+ await this.graph.load();
906
+
907
+ // Get all ancestors of both revisions
908
+ const ancestors1 = new Set(await this.getAncestors(rev1));
909
+ const ancestors2 = await this.getAncestors(rev2);
910
+
911
+ // Find first common ancestor
912
+ for (const ancestor of ancestors2) {
913
+ if (ancestors1.has(ancestor)) {
914
+ return [ancestor];
915
+ }
916
+ }
917
+
918
+ return []; // No common ancestor
919
+ }
920
+
921
+ /**
922
+ * Get commits in range (base..tip) (v0.5)
923
+ *
924
+ * @param {string} base - Base revision
925
+ * @param {string} tip - Tip revision
926
+ * @returns {Promise<Array<string>>} Commits in range (excluding base)
927
+ */
928
+ async getRange(base, tip) {
929
+ await this.graph.load();
930
+
931
+ const tipAncestors = new Set(await this.getAncestors(tip));
932
+ const baseAncestors = new Set(await this.getAncestors(base));
933
+
934
+ // Commits in range are ancestors of tip but not ancestors of base
935
+ const range = [];
936
+ for (const ancestor of tipAncestors) {
937
+ if (!baseAncestors.has(ancestor)) {
938
+ range.push(ancestor);
939
+ }
940
+ }
941
+
942
+ return range;
943
+ }
944
+
945
+ /**
946
+ * Find divergence point of two revisions (v0.5)
947
+ *
948
+ * @param {string} rev1 - First revision
949
+ * @param {string} rev2 - Second revision
950
+ * @returns {Promise<Array<string>>} Divergence point (same as common ancestor)
951
+ */
952
+ async findDivergePoint(rev1, rev2) {
953
+ // Divergence point is the same as common ancestor
954
+ return await this.findCommonAncestor(rev1, rev2);
955
+ }
956
+
957
+ /**
958
+ * Check if two revisions are connected (v0.5)
959
+ *
960
+ * @param {string} rev1 - First revision
961
+ * @param {string} rev2 - Second revision
962
+ * @returns {Promise<Array<boolean>>} Single element array with boolean result
963
+ */
964
+ async checkConnected(rev1, rev2) {
965
+ await this.graph.load();
966
+
967
+ // Check if rev2 is reachable from rev1
968
+ const descendants = new Set(await this.getDescendants(rev1));
969
+ const isDescendant = descendants.has(rev2);
970
+
971
+ if (isDescendant) {
972
+ return [true];
973
+ }
974
+
975
+ // Check if rev1 is reachable from rev2
976
+ const ancestors = new Set(await this.getAncestors(rev2));
977
+ const isAncestor = ancestors.has(rev1);
978
+
979
+ return [isAncestor];
980
+ }
981
+
982
+ /**
983
+ * Get direct parents of all commits in a set (v1.0)
984
+ *
985
+ * @param {Array<string>} changeIds - Array of change IDs
986
+ * @returns {Promise<Array<string>>} Array of parent change IDs (deduplicated)
987
+ */
988
+ async getParentsOfSet(changeIds) {
989
+ await this.graph.load();
990
+
991
+ const parents = new Set();
992
+
993
+ for (const changeId of changeIds) {
994
+ const change = await this.graph.getChange(changeId);
995
+ if (change && change.parents) {
996
+ for (const parent of change.parents) {
997
+ parents.add(parent);
998
+ }
999
+ }
1000
+ }
1001
+
1002
+ return Array.from(parents);
1003
+ }
1004
+
1005
+ /**
1006
+ * Get direct children of all commits in a set (v1.0)
1007
+ *
1008
+ * @param {Array<string>} changeIds - Array of change IDs
1009
+ * @returns {Promise<Array<string>>} Array of child change IDs (deduplicated)
1010
+ */
1011
+ async getChildrenOfSet(changeIds) {
1012
+ await this.graph.load();
1013
+
1014
+ const children = new Set();
1015
+ const changeIdSet = new Set(changeIds);
1016
+
1017
+ // Get all changes and check if their parents include any from our set
1018
+ const allChanges = this.graph.getAll();
1019
+ for (const change of allChanges) {
1020
+ if (change.parents && change.parents.some((p) => changeIdSet.has(p))) {
1021
+ children.add(change.changeId);
1022
+ }
1023
+ }
1024
+
1025
+ return Array.from(children);
1026
+ }
1027
+
1028
+ /**
1029
+ * Evaluate set operations (v0.5)
1030
+ *
1031
+ * @param {string} expression - Expression with set operations
1032
+ * @returns {Promise<Array<string>>} Result of set operation
1033
+ */
1034
+ async evaluateSetOperation(expression) {
1035
+ // Parse set operations: & (intersection), | (union), ~ (difference)
1036
+ // Simple implementation - split by operators and evaluate left to right
1037
+
1038
+ // Handle intersection (&)
1039
+ if (expression.includes(' & ')) {
1040
+ const parts = expression.split(' & ');
1041
+ let result = new Set(await this.evaluate(parts[0]));
1042
+
1043
+ for (let i = 1; i < parts.length; i++) {
1044
+ const partResult = new Set(await this.evaluate(parts[i]));
1045
+ result = new Set([...result].filter(x => partResult.has(x)));
1046
+ }
1047
+
1048
+ return Array.from(result);
1049
+ }
1050
+
1051
+ // Handle union (|)
1052
+ if (expression.includes(' | ')) {
1053
+ const parts = expression.split(' | ');
1054
+ const result = new Set();
1055
+
1056
+ for (const part of parts) {
1057
+ const partResult = await this.evaluate(part);
1058
+ partResult.forEach(id => result.add(id));
1059
+ }
1060
+
1061
+ return Array.from(result);
1062
+ }
1063
+
1064
+ // Handle difference (~)
1065
+ if (expression.includes(' ~ ')) {
1066
+ const parts = expression.split(' ~ ');
1067
+ let result = new Set(await this.evaluate(parts[0]));
1068
+
1069
+ for (let i = 1; i < parts.length; i++) {
1070
+ const partResult = new Set(await this.evaluate(parts[i]));
1071
+ result = new Set([...result].filter(x => !partResult.has(x)));
1072
+ }
1073
+
1074
+ return Array.from(result);
1075
+ }
1076
+
1077
+ throw new JJError('INVALID_SET_OPERATION', `Invalid set operation: ${expression}`, {
1078
+ expression,
1079
+ });
1080
+ }
1081
+ }