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