isomorphic-jj 1.4.2 → 1.7.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/CHANGELOG.md +226 -0
- package/README.md +9 -11
- package/bin/isojj.js +270 -102
- package/dist/index.cjs +2949 -1676
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +2949 -1676
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -4
- package/src/api/repository.js +1099 -739
- package/src/backend/isomorphic-git-backend.js +183 -136
- package/src/backend/lazy-git-backend.js +12 -19
- package/src/browser/helpers.js +6 -2
- package/src/core/background-ops.js +58 -41
- package/src/core/bookmark-store.js +12 -8
- package/src/core/change-graph.js +26 -29
- package/src/core/conflict-model.js +66 -26
- package/src/core/jj-checkout.js +8 -4
- package/src/core/jj-operation-store.js +16 -12
- package/src/core/jj-tree-state.js +13 -9
- package/src/core/jj-view-store.js +11 -5
- package/src/core/merge-driver-registry.js +92 -46
- package/src/core/operation-log.js +30 -33
- package/src/core/revset-engine.js +1069 -485
- package/src/core/storage-manager.js +25 -19
- package/src/core/tag-store.js +2 -2
- package/src/core/user-config.js +3 -1
- package/src/core/working-copy.js +125 -9
- package/src/core/workspace-manager.js +2 -2
- package/src/drivers/built-in-drivers.js +66 -33
- package/src/types.d.ts +67 -1
- package/src/utils/errors.js +3 -3
- package/src/utils/id-generation.js +4 -4
- package/src/utils/validation.js +6 -10
|
@@ -1,510 +1,937 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* RevsetEngine - Query language for finding changes
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
4
|
* Implements a simplified revset language for querying the change graph.
|
|
5
|
+
*
|
|
6
|
+
* Parsing pipeline (v1.7): expressions are tokenized and parsed into a small
|
|
7
|
+
* AST via a real recursive-descent / precedence-climbing parser, then
|
|
8
|
+
* evaluated by walking the AST. This replaces an earlier implementation that
|
|
9
|
+
* matched the whole trimmed expression against one big chain of regexes per
|
|
10
|
+
* construct — which could not correctly handle a revset-taking function
|
|
11
|
+
* whose argument was itself a nested function call (e.g. `roots(ancestors(x))`
|
|
12
|
+
* would silently mis-parse), and had no real operator precedence for mixed
|
|
13
|
+
* `&`/`|`/`~` expressions. The AST only covers *structural* parsing (`@`,
|
|
14
|
+
* change-id atoms, the `-`/`+` suffix operators, `..` ranges, function-call
|
|
15
|
+
* boundaries, parenthesized grouping, and `&`/`|`/`~` precedence); the actual
|
|
16
|
+
* per-function semantics (author matching, ancestry walks, date filters,
|
|
17
|
+
* etc.) are unchanged from before and live in the filter/getX helper methods
|
|
18
|
+
* below the parser.
|
|
5
19
|
*/
|
|
6
20
|
|
|
7
21
|
import { JJError } from '../utils/errors.js';
|
|
8
22
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {object} ChangeGraph
|
|
25
|
+
* @property {() => Promise<any>} load
|
|
26
|
+
* @property {() => any[]} getAll
|
|
27
|
+
* @property {(id: string) => Promise<any>} getChange
|
|
28
|
+
* @property {(id: string) => string[]} getParents
|
|
29
|
+
* @property {(id: string) => string[]} getChildren
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {object} WorkingCopy
|
|
34
|
+
* @property {() => string} getCurrentChangeId
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// Tokenizer
|
|
39
|
+
// ============================================================================
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Find the index of the `)` matching the `(` at `openIndex`, respecting
|
|
43
|
+
* nested parentheses and single/double-quoted strings — so a quoted argument
|
|
44
|
+
* containing a literal `(`, `)`, or `,` (e.g. `description("a (b), c")`)
|
|
45
|
+
* doesn't confuse the boundary search.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} source - Full source string
|
|
48
|
+
* @param {number} openIndex - Index of the '(' to match
|
|
49
|
+
* @returns {number} Index of the matching ')'
|
|
50
|
+
*/
|
|
51
|
+
function findMatchingParen(source, openIndex) {
|
|
52
|
+
let depth = 0;
|
|
53
|
+
/** @type {string|null} */
|
|
54
|
+
let quote = null;
|
|
55
|
+
for (let i = openIndex; i < source.length; i++) {
|
|
56
|
+
const ch = source[i];
|
|
57
|
+
if (quote) {
|
|
58
|
+
if (ch === quote) quote = null;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (ch === '"' || ch === "'") {
|
|
62
|
+
quote = ch;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (ch === '(') depth++;
|
|
66
|
+
else if (ch === ')') {
|
|
67
|
+
depth--;
|
|
68
|
+
if (depth === 0) return i;
|
|
69
|
+
}
|
|
21
70
|
}
|
|
71
|
+
throw new JJError('INVALID_REVSET', `Unmatched '(' in revset expression: ${source}`, {
|
|
72
|
+
expression: source,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Characters that terminate a bare (unquoted, unparenthesized) symbol token
|
|
77
|
+
// at the top-level expression grammar. Everything else (letters, digits,
|
|
78
|
+
// dots, slashes, glob '*'/'?', hyphens WITHIN a symbol like a date, etc.) is
|
|
79
|
+
// swallowed into the symbol — hyphens are only split off as MINUS tokens
|
|
80
|
+
// when the tokenizer's main loop reaches them directly (i.e. not already
|
|
81
|
+
// consumed as part of a preceding symbol scan), matching how change-id/`@`
|
|
82
|
+
// suffix operators are recognized without breaking apart tokens that merely
|
|
83
|
+
// *contain* a hyphen in the middle (dates are never routed through this
|
|
84
|
+
// tokenizer at all — see the note on function argument kinds below).
|
|
85
|
+
const SYMBOL_STOP_CHARS = new Set([
|
|
86
|
+
' ',
|
|
87
|
+
'\t',
|
|
88
|
+
'\n',
|
|
89
|
+
'\r',
|
|
90
|
+
'(',
|
|
91
|
+
')',
|
|
92
|
+
',',
|
|
93
|
+
'&',
|
|
94
|
+
'|',
|
|
95
|
+
'~',
|
|
96
|
+
'@',
|
|
97
|
+
'-',
|
|
98
|
+
'+',
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Tokenize a top-level revset expression (or a nested revset-typed function
|
|
103
|
+
* argument). String-typed function arguments (author patterns, dates,
|
|
104
|
+
* glob patterns, etc.) are never routed through this tokenizer — they are
|
|
105
|
+
* extracted as raw substrings by `findMatchingParen` + `splitTopLevelArgs`,
|
|
106
|
+
* exactly as before, and are only interpreted once evaluateFunctionCall()
|
|
107
|
+
* dispatches on the now-unambiguous function name.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} source
|
|
110
|
+
* @returns {Array<{type: string, value?: string}>}
|
|
111
|
+
*/
|
|
112
|
+
function tokenize(source) {
|
|
113
|
+
const tokens = [];
|
|
114
|
+
let i = 0;
|
|
115
|
+
const n = source.length;
|
|
116
|
+
|
|
117
|
+
while (i < n) {
|
|
118
|
+
const ch = source[i];
|
|
22
119
|
|
|
120
|
+
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
|
|
121
|
+
i++;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (ch === '(') {
|
|
125
|
+
tokens.push({ type: 'LPAREN' });
|
|
126
|
+
i++;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (ch === ')') {
|
|
130
|
+
tokens.push({ type: 'RPAREN' });
|
|
131
|
+
i++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (ch === '&') {
|
|
135
|
+
tokens.push({ type: 'AMP' });
|
|
136
|
+
i++;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (ch === '|') {
|
|
140
|
+
tokens.push({ type: 'PIPE' });
|
|
141
|
+
i++;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (ch === '~') {
|
|
145
|
+
tokens.push({ type: 'TILDE' });
|
|
146
|
+
i++;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (ch === '@') {
|
|
150
|
+
tokens.push({ type: 'AT' });
|
|
151
|
+
i++;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (ch === '+') {
|
|
155
|
+
tokens.push({ type: 'PLUS' });
|
|
156
|
+
i++;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (ch === '-') {
|
|
160
|
+
tokens.push({ type: 'MINUS' });
|
|
161
|
+
i++;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Bare symbol (identifier, hex change id, glob pattern, `base..tip`
|
|
166
|
+
// range, etc.) — consume until a structural character.
|
|
167
|
+
let j = i;
|
|
168
|
+
while (j < n && !SYMBOL_STOP_CHARS.has(source[j])) {
|
|
169
|
+
j++;
|
|
170
|
+
}
|
|
171
|
+
if (j === i) {
|
|
172
|
+
throw new JJError('INVALID_REVSET', `Unexpected character '${ch}' in revset expression`, {
|
|
173
|
+
expression: source,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const name = source.slice(i, j);
|
|
177
|
+
|
|
178
|
+
if (j < n && source[j] === '(') {
|
|
179
|
+
// Function call: consume the whole balanced (...) group as one token,
|
|
180
|
+
// so nested calls (`roots(ancestors(x))`) are handled correctly no
|
|
181
|
+
// matter how deep, instead of a non-greedy regex truncating at the
|
|
182
|
+
// first ')' it sees.
|
|
183
|
+
const closeIndex = findMatchingParen(source, j);
|
|
184
|
+
tokens.push({ type: 'CALL', name, argsRaw: source.slice(j + 1, closeIndex) });
|
|
185
|
+
i = closeIndex + 1;
|
|
186
|
+
} else {
|
|
187
|
+
tokens.push({ type: 'SYMBOL', value: name });
|
|
188
|
+
i = j;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
tokens.push({ type: 'EOF' });
|
|
193
|
+
return tokens;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ============================================================================
|
|
197
|
+
// Parser (precedence climbing) — produces a small AST
|
|
198
|
+
// ============================================================================
|
|
199
|
+
//
|
|
200
|
+
// Grammar (highest to lowest precedence):
|
|
201
|
+
// primary := '@' suffix? | SYMBOL suffix? | CALL | '(' union ')'
|
|
202
|
+
// suffix := ('-' | '+')+ -- must be homogeneous
|
|
203
|
+
// intersect := primary (('&' | '~') primary)* -- left-assoc, same tier
|
|
204
|
+
// union := intersect ('|' intersect)* -- left-assoc, lowest
|
|
205
|
+
//
|
|
206
|
+
// A bare SYMBOL containing '..' (e.g. "abcd1234..ef567890") is recognized as
|
|
207
|
+
// a range atom at the primary level, matching the previous implementation's
|
|
208
|
+
// top-level `trimmed.includes('..')` special case.
|
|
209
|
+
|
|
210
|
+
class RevsetParser {
|
|
23
211
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* @param {string} expression - Revset expression
|
|
27
|
-
* @returns {Promise<Array<string>>} Array of matching change IDs
|
|
212
|
+
* @param {Array<{type: string, value?: string, name?: string, argsRaw?: string}>} tokens
|
|
213
|
+
* @param {string} source - Original source (for error messages)
|
|
28
214
|
*/
|
|
29
|
-
|
|
30
|
-
|
|
215
|
+
constructor(tokens, source) {
|
|
216
|
+
this.tokens = tokens;
|
|
217
|
+
this.pos = 0;
|
|
218
|
+
this.source = source;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
peek() {
|
|
222
|
+
return this.tokens[this.pos];
|
|
223
|
+
}
|
|
31
224
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
225
|
+
next() {
|
|
226
|
+
return this.tokens[this.pos++];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** @param {string} message */
|
|
230
|
+
fail(message) {
|
|
231
|
+
throw new JJError('INVALID_REVSET', message, { expression: this.source });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** @returns {any} */
|
|
235
|
+
parse() {
|
|
236
|
+
const node = this.parseUnion();
|
|
237
|
+
if (this.peek().type !== 'EOF') {
|
|
238
|
+
this.fail(`Invalid revset expression: ${this.source}`);
|
|
35
239
|
}
|
|
240
|
+
return node;
|
|
241
|
+
}
|
|
36
242
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
243
|
+
/** @returns {any} */
|
|
244
|
+
parseUnion() {
|
|
245
|
+
/** @type {any} */
|
|
246
|
+
let node = this.parseIntersect();
|
|
247
|
+
while (this.peek().type === 'PIPE') {
|
|
248
|
+
this.next();
|
|
249
|
+
const right = this.parseIntersect();
|
|
250
|
+
node = { type: 'union', left: node, right };
|
|
251
|
+
}
|
|
252
|
+
return node;
|
|
253
|
+
}
|
|
42
254
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
255
|
+
/** @returns {any} */
|
|
256
|
+
parseIntersect() {
|
|
257
|
+
/** @type {any} */
|
|
258
|
+
let node = this.parsePrimary();
|
|
259
|
+
while (this.peek().type === 'AMP' || this.peek().type === 'TILDE') {
|
|
260
|
+
const op = this.next().type === 'AMP' ? 'intersect' : 'difference';
|
|
261
|
+
const right = this.parsePrimary();
|
|
262
|
+
node = { type: op, left: node, right };
|
|
49
263
|
}
|
|
264
|
+
return node;
|
|
265
|
+
}
|
|
50
266
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
267
|
+
/** Consume a homogeneous run of '-' or '+' tokens, returning its depth (0 if none). */
|
|
268
|
+
parseSuffixDepth() {
|
|
269
|
+
const first = this.peek().type;
|
|
270
|
+
if (first !== 'PLUS' && first !== 'MINUS') return { dir: null, depth: 0 };
|
|
271
|
+
let depth = 0;
|
|
272
|
+
while (this.peek().type === first) {
|
|
273
|
+
this.next();
|
|
274
|
+
depth++;
|
|
275
|
+
}
|
|
276
|
+
// A mixed run (e.g. '-+') is not a valid suffix; only a homogeneous run
|
|
277
|
+
// is consumed above, so if the OPPOSITE token immediately follows, that's
|
|
278
|
+
// invalid syntax (matches the old regex's "all same character" requirement).
|
|
279
|
+
if (this.peek().type === 'PLUS' || this.peek().type === 'MINUS') {
|
|
280
|
+
this.fail(`Invalid revset expression: ${this.source}`);
|
|
281
|
+
}
|
|
282
|
+
return { dir: first === 'PLUS' ? '+' : '-', depth };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** @returns {any} */
|
|
286
|
+
parsePrimary() {
|
|
287
|
+
const tok = this.peek();
|
|
56
288
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
289
|
+
if (tok.type === 'LPAREN') {
|
|
290
|
+
this.next();
|
|
291
|
+
/** @type {any} */
|
|
292
|
+
const inner = this.parseUnion();
|
|
293
|
+
if (this.peek().type !== 'RPAREN') {
|
|
294
|
+
this.fail(`Unmatched '(' in revset expression: ${this.source}`);
|
|
61
295
|
}
|
|
62
|
-
|
|
296
|
+
this.next();
|
|
297
|
+
return inner;
|
|
63
298
|
}
|
|
64
299
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return
|
|
300
|
+
if (tok.type === 'AT') {
|
|
301
|
+
this.next();
|
|
302
|
+
const { dir, depth } = this.parseSuffixDepth();
|
|
303
|
+
return { type: 'workingCopy', dir, depth };
|
|
69
304
|
}
|
|
70
305
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return
|
|
306
|
+
if (tok.type === 'CALL') {
|
|
307
|
+
this.next();
|
|
308
|
+
return { type: 'call', name: tok.name, argsRaw: tok.argsRaw };
|
|
74
309
|
}
|
|
75
310
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
}
|
|
311
|
+
if (tok.type === 'SYMBOL') {
|
|
312
|
+
this.next();
|
|
313
|
+
const value = /** @type {string} */ (tok.value);
|
|
88
314
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
}
|
|
315
|
+
// A bare "base..tip" range, e.g. `abcd1234..ef567890`.
|
|
316
|
+
if (value.includes('..')) {
|
|
317
|
+
const parts = value.split('..');
|
|
318
|
+
if (parts.length === 2) {
|
|
319
|
+
return { type: 'range', base: parts[0].trim(), tip: parts[1].trim() };
|
|
104
320
|
}
|
|
105
321
|
}
|
|
106
322
|
|
|
107
|
-
|
|
108
|
-
return
|
|
109
|
-
.filter(c => !hasChildren.has(c.changeId))
|
|
110
|
-
.map(c => c.changeId);
|
|
323
|
+
const { dir, depth } = this.parseSuffixDepth();
|
|
324
|
+
return { type: 'symbol', value, dir, depth };
|
|
111
325
|
}
|
|
112
326
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
327
|
+
this.fail(`Invalid revset expression: ${this.source}`);
|
|
328
|
+
// unreachable; keeps TS/control-flow happy
|
|
329
|
+
return /** @type {any} */ (null);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export class RevsetEngine {
|
|
334
|
+
/**
|
|
335
|
+
* @param {ChangeGraph} graph - Change graph instance
|
|
336
|
+
* @param {WorkingCopy} workingCopy - Working copy instance
|
|
337
|
+
* @param {any} [userConfig] - User configuration instance (optional)
|
|
338
|
+
* @param {any} [bookmarkStore] - Bookmark store instance (optional, v0.4)
|
|
339
|
+
* @param {any} [tagStore] - Tag store instance (optional, v1.5)
|
|
340
|
+
*/
|
|
341
|
+
constructor(graph, workingCopy, userConfig = null, bookmarkStore = null, tagStore = null) {
|
|
342
|
+
this.graph = graph;
|
|
343
|
+
this.workingCopy = workingCopy;
|
|
344
|
+
this.userConfig = userConfig;
|
|
345
|
+
this.bookmarkStore = bookmarkStore;
|
|
346
|
+
this.tagStore = tagStore;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Evaluate a revset expression
|
|
351
|
+
*
|
|
352
|
+
* @param {string} expression - Revset expression
|
|
353
|
+
* @returns {Promise<Array<string>>} Array of matching change IDs
|
|
354
|
+
*/
|
|
355
|
+
async evaluate(expression) {
|
|
356
|
+
const trimmed = expression.trim();
|
|
357
|
+
if (trimmed === '') {
|
|
358
|
+
throw new JJError('INVALID_REVSET', `Invalid revset expression: ${expression}`, {
|
|
359
|
+
expression,
|
|
360
|
+
suggestion: this._suggestion(),
|
|
361
|
+
});
|
|
119
362
|
}
|
|
120
363
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
//
|
|
128
|
-
|
|
364
|
+
let ast;
|
|
365
|
+
try {
|
|
366
|
+
ast = new RevsetParser(tokenize(trimmed), trimmed).parse();
|
|
367
|
+
} catch (error) {
|
|
368
|
+
if (error instanceof JJError) {
|
|
369
|
+
// Attach the full suggestion text to the top-level parse failure,
|
|
370
|
+
// matching the previous single-throw-site behavior.
|
|
371
|
+
error.context = error.context || {};
|
|
372
|
+
if (error.code === 'INVALID_REVSET' && !error.context.suggestion) {
|
|
373
|
+
error.context.suggestion = this._suggestion();
|
|
374
|
+
error.suggestion = error.context.suggestion;
|
|
375
|
+
}
|
|
129
376
|
}
|
|
377
|
+
throw error;
|
|
130
378
|
}
|
|
131
379
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
await this.graph.load();
|
|
135
|
-
const all = this.graph.getAll();
|
|
136
|
-
return all
|
|
137
|
-
.filter(c => !c.abandoned)
|
|
138
|
-
.map(c => c.changeId);
|
|
139
|
-
}
|
|
380
|
+
return await this.evaluateAst(ast);
|
|
381
|
+
}
|
|
140
382
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
383
|
+
/**
|
|
384
|
+
* @private
|
|
385
|
+
* @returns {string}
|
|
386
|
+
*/
|
|
387
|
+
_suggestion() {
|
|
388
|
+
return 'Use @, @-, @+, bookmark(name), all(), none(), root(), visible_heads(), git_refs(), git_head(), ancestors(revset[, depth]), author(name), author_name(x), author_email(x), committer(x), committer_name(x), committer_email(x), subject(pattern), description(text), change_id(prefix), commit_id(prefix), empty(), mine(), merge(), merges(), forks(), signed(), divergent(), file(pattern), roots(revset), heads(revset), parents(revset), children(revset), first_parent(revset), first_ancestors(revset), fork_point(revset), merge_point(revset), exactly(revset, n), present(revset), coalesce(a, b, ...), latest(revset, [count]), tags([pattern]), remote_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';
|
|
389
|
+
}
|
|
149
390
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
391
|
+
/**
|
|
392
|
+
* Evaluate a parsed AST node into an array of change IDs.
|
|
393
|
+
*
|
|
394
|
+
* @param {any} node
|
|
395
|
+
* @returns {Promise<Array<string>>}
|
|
396
|
+
*/
|
|
397
|
+
async evaluateAst(node) {
|
|
398
|
+
switch (node.type) {
|
|
399
|
+
case 'union': {
|
|
400
|
+
const [left, right] = await Promise.all([
|
|
401
|
+
this.evaluateAst(node.left),
|
|
402
|
+
this.evaluateAst(node.right),
|
|
403
|
+
]);
|
|
404
|
+
return Array.from(new Set([...left, ...right]));
|
|
405
|
+
}
|
|
406
|
+
case 'intersect': {
|
|
407
|
+
const [left, right] = await Promise.all([
|
|
408
|
+
this.evaluateAst(node.left),
|
|
409
|
+
this.evaluateAst(node.right),
|
|
410
|
+
]);
|
|
411
|
+
const rightSet = new Set(right);
|
|
412
|
+
return left.filter((id) => rightSet.has(id));
|
|
413
|
+
}
|
|
414
|
+
case 'difference': {
|
|
415
|
+
const [left, right] = await Promise.all([
|
|
416
|
+
this.evaluateAst(node.left),
|
|
417
|
+
this.evaluateAst(node.right),
|
|
418
|
+
]);
|
|
419
|
+
const rightSet = new Set(right);
|
|
420
|
+
return left.filter((id) => !rightSet.has(id));
|
|
421
|
+
}
|
|
422
|
+
case 'workingCopy': {
|
|
423
|
+
const workingCopyId = this.workingCopy.getCurrentChangeId();
|
|
424
|
+
return await this.applySuffix([workingCopyId], node.dir, node.depth);
|
|
425
|
+
}
|
|
426
|
+
case 'symbol': {
|
|
427
|
+
if (!/^[0-9a-f]{32}$/.test(node.value)) {
|
|
428
|
+
throw new JJError('INVALID_REVSET', `Invalid revset expression: ${node.value}`, {
|
|
429
|
+
expression: node.value,
|
|
430
|
+
suggestion: this._suggestion(),
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
if (node.depth > 0) {
|
|
434
|
+
// Suffixed change-id form never validated existence of the base id
|
|
435
|
+
// up-front (getParentsOfSet/getChildrenOfSet already no-op on an
|
|
436
|
+
// unknown id), matching the previous implementation.
|
|
437
|
+
return await this.applySuffix([node.value], node.dir, node.depth);
|
|
438
|
+
}
|
|
439
|
+
await this.graph.load();
|
|
440
|
+
const change = await this.graph.getChange(node.value);
|
|
441
|
+
return change ? [node.value] : [];
|
|
442
|
+
}
|
|
443
|
+
case 'range':
|
|
444
|
+
return await this.evalRange(node.base, node.tip);
|
|
445
|
+
case 'call':
|
|
446
|
+
return await this.evaluateFunctionCall(node.name, node.argsRaw);
|
|
447
|
+
default:
|
|
448
|
+
throw new JJError('INVALID_REVSET', `Invalid revset expression`, {
|
|
449
|
+
suggestion: this._suggestion(),
|
|
450
|
+
});
|
|
155
451
|
}
|
|
452
|
+
}
|
|
156
453
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
454
|
+
/**
|
|
455
|
+
* Apply a run of '-' (parents) or '+' (children) suffix operators to a seed
|
|
456
|
+
* set, stepping generation-by-generation exactly like the previous
|
|
457
|
+
* `@(-+)$` / `x(-+)$` handling.
|
|
458
|
+
*
|
|
459
|
+
* @param {string[]} seedSet
|
|
460
|
+
* @param {'-'|'+'|null} dir
|
|
461
|
+
* @param {number} depth
|
|
462
|
+
* @returns {Promise<string[]>}
|
|
463
|
+
*/
|
|
464
|
+
async applySuffix(seedSet, dir, depth) {
|
|
465
|
+
let currentSet = seedSet;
|
|
466
|
+
for (let i = 0; i < depth; i++) {
|
|
467
|
+
currentSet =
|
|
468
|
+
dir === '-'
|
|
469
|
+
? await this.getParentsOfSet(currentSet)
|
|
470
|
+
: await this.getChildrenOfSet(currentSet);
|
|
471
|
+
if (currentSet.length === 0) break;
|
|
162
472
|
}
|
|
473
|
+
return currentSet;
|
|
474
|
+
}
|
|
163
475
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
476
|
+
/**
|
|
477
|
+
* Evaluate a bare `base..tip` range (ancestors(tip) ~ ancestors(base)).
|
|
478
|
+
*
|
|
479
|
+
* @param {string} base
|
|
480
|
+
* @param {string} tip
|
|
481
|
+
* @returns {Promise<string[]>}
|
|
482
|
+
*/
|
|
483
|
+
async evalRange(base, tip) {
|
|
484
|
+
const baseChange = await this.graph.getChange(base);
|
|
485
|
+
const tipChange = await this.graph.getChange(tip);
|
|
486
|
+
|
|
487
|
+
if (!baseChange || !tipChange) {
|
|
488
|
+
throw new JJError('INVALID_REVSET', `Invalid range: ${base}..${tip}`, {
|
|
489
|
+
suggestion: 'Both base and tip must be valid change IDs',
|
|
490
|
+
});
|
|
169
491
|
}
|
|
170
492
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
493
|
+
const tipAncestors = new Set(await this.getAncestors(tip));
|
|
494
|
+
const baseAncestors = new Set(await this.getAncestors(base));
|
|
495
|
+
return [...tipAncestors].filter((id) => !baseAncestors.has(id));
|
|
496
|
+
}
|
|
175
497
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
498
|
+
/**
|
|
499
|
+
* Dispatch a parsed function call (name + raw, unsplit argument string) to
|
|
500
|
+
* its semantic handler. This mirrors the previous per-function branches in
|
|
501
|
+
* `evaluate()` almost verbatim — only how `name`/`argsRaw` were obtained
|
|
502
|
+
* has changed (via the tokenizer/parser above, instead of one regex per
|
|
503
|
+
* function tried against the whole expression).
|
|
504
|
+
*
|
|
505
|
+
* @param {string} name
|
|
506
|
+
* @param {string} argsRaw - Raw text between the call's parentheses
|
|
507
|
+
* @returns {Promise<string[]>}
|
|
508
|
+
*/
|
|
509
|
+
async evaluateFunctionCall(name, argsRaw) {
|
|
510
|
+
const raw = argsRaw.trim();
|
|
511
|
+
const unquote = (/** @type {string} */ s) => s.trim().replace(/^['"]|['"]$/g, '');
|
|
180
512
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
513
|
+
switch (name) {
|
|
514
|
+
case 'all':
|
|
515
|
+
await this.graph.load();
|
|
516
|
+
return this.graph.getAll().map((c) => c.changeId);
|
|
185
517
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (fileMatch) {
|
|
189
|
-
const pattern = fileMatch[1].replace(/['"]/g, '');
|
|
190
|
-
return await this.filterByFile(pattern);
|
|
191
|
-
}
|
|
518
|
+
case 'none':
|
|
519
|
+
return [];
|
|
192
520
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
521
|
+
case 'root': {
|
|
522
|
+
await this.graph.load();
|
|
523
|
+
const allChanges = this.graph.getAll();
|
|
524
|
+
const rootCommits = allChanges.filter((c) => !c.parents || c.parents.length === 0);
|
|
525
|
+
if (rootCommits.length === 0) return [];
|
|
526
|
+
const oldest = rootCommits.sort(
|
|
527
|
+
(a, b) =>
|
|
528
|
+
/** @type {any} */ (new Date(a.timestamp)) - /** @type {any} */ (new Date(b.timestamp))
|
|
529
|
+
)[0];
|
|
530
|
+
return [oldest.changeId];
|
|
531
|
+
}
|
|
200
532
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
533
|
+
case 'visible_heads': {
|
|
534
|
+
await this.graph.load();
|
|
535
|
+
const allChanges = this.graph.getAll();
|
|
536
|
+
const changeIdSet = new Set(allChanges.map((c) => c.changeId));
|
|
537
|
+
const hasChildren = new Set();
|
|
538
|
+
for (const change of allChanges) {
|
|
539
|
+
if (change.parents) {
|
|
540
|
+
for (const parent of change.parents) {
|
|
541
|
+
if (changeIdSet.has(parent)) hasChildren.add(parent);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return allChanges.filter((c) => !hasChildren.has(c.changeId)).map((c) => c.changeId);
|
|
546
|
+
}
|
|
208
547
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
548
|
+
case 'git_refs': {
|
|
549
|
+
if (!this.bookmarkStore) return [];
|
|
550
|
+
await this.bookmarkStore.load();
|
|
551
|
+
const allBookmarks = /** @type {any[]} */ (await this.bookmarkStore.list());
|
|
552
|
+
return allBookmarks.map((b) => b.changeId);
|
|
553
|
+
}
|
|
216
554
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
555
|
+
case 'git_head': {
|
|
556
|
+
try {
|
|
557
|
+
const currentId = this.workingCopy.getCurrentChangeId();
|
|
558
|
+
return currentId ? [currentId] : [];
|
|
559
|
+
} catch (error) {
|
|
560
|
+
return [];
|
|
561
|
+
}
|
|
562
|
+
}
|
|
224
563
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}
|
|
564
|
+
case 'visible': {
|
|
565
|
+
await this.graph.load();
|
|
566
|
+
return this.graph
|
|
567
|
+
.getAll()
|
|
568
|
+
.filter((c) => !c.abandoned)
|
|
569
|
+
.map((c) => c.changeId);
|
|
570
|
+
}
|
|
233
571
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
572
|
+
case 'hidden': {
|
|
573
|
+
await this.graph.load();
|
|
574
|
+
return this.graph
|
|
575
|
+
.getAll()
|
|
576
|
+
.filter((c) => c.abandoned === true)
|
|
577
|
+
.map((c) => c.changeId);
|
|
578
|
+
}
|
|
240
579
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
580
|
+
case 'ancestors': {
|
|
581
|
+
const parts = this.splitTopLevelArgs(raw);
|
|
582
|
+
const seeds = await this.resolveArg(parts[0]);
|
|
583
|
+
const depth = parts[1] !== undefined ? parseInt(parts[1].trim(), 10) : undefined;
|
|
584
|
+
const result = new Set();
|
|
585
|
+
for (const seed of seeds) {
|
|
586
|
+
for (const id of await this.getAncestors(seed, depth)) {
|
|
587
|
+
result.add(id);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return Array.from(result);
|
|
591
|
+
}
|
|
250
592
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
593
|
+
case 'change_id':
|
|
594
|
+
return await this.filterByIdPrefix(unquote(raw), 'changeId');
|
|
595
|
+
case 'commit_id':
|
|
596
|
+
return await this.filterByIdPrefix(unquote(raw), 'commitId');
|
|
597
|
+
case 'subject':
|
|
598
|
+
return await this.filterBySubject(unquote(raw));
|
|
599
|
+
|
|
600
|
+
case 'author_name':
|
|
601
|
+
return await this.filterBySignatureField('author', 'name', unquote(raw));
|
|
602
|
+
case 'author_email':
|
|
603
|
+
return await this.filterBySignatureField('author', 'email', unquote(raw));
|
|
604
|
+
case 'committer_name':
|
|
605
|
+
return await this.filterBySignatureField('committer', 'name', unquote(raw));
|
|
606
|
+
case 'committer_email':
|
|
607
|
+
return await this.filterBySignatureField('committer', 'email', unquote(raw));
|
|
608
|
+
case 'committer': {
|
|
609
|
+
const pattern = unquote(raw);
|
|
610
|
+
const byName = await this.filterBySignatureField('committer', 'name', pattern);
|
|
611
|
+
const byEmail = await this.filterBySignatureField('committer', 'email', pattern);
|
|
612
|
+
return Array.from(new Set([...byName, ...byEmail]));
|
|
613
|
+
}
|
|
257
614
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const unit = lastMatch[2]; // 'd', 'h', or undefined
|
|
265
|
-
|
|
266
|
-
if (unit) {
|
|
267
|
-
// Time-based: last(Nd) or last(Nh)
|
|
268
|
-
return await this.filterByTimeRange(value, unit);
|
|
269
|
-
} else {
|
|
270
|
-
// Count-based: last(N)
|
|
271
|
-
return await this.filterLast(value);
|
|
615
|
+
case 'signed': {
|
|
616
|
+
await this.graph.load();
|
|
617
|
+
return this.graph
|
|
618
|
+
.getAll()
|
|
619
|
+
.filter((c) => c.signed === true || (c.signature && c.signature.status))
|
|
620
|
+
.map((c) => c.changeId);
|
|
272
621
|
}
|
|
273
|
-
}
|
|
274
622
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
623
|
+
case 'divergent': {
|
|
624
|
+
await this.graph.load();
|
|
625
|
+
const all = this.graph.getAll();
|
|
626
|
+
const counts = new Map();
|
|
627
|
+
for (const c of all) {
|
|
628
|
+
counts.set(c.changeId, (counts.get(c.changeId) || 0) + 1);
|
|
629
|
+
}
|
|
630
|
+
return all
|
|
631
|
+
.filter((c) => c.divergent === true || counts.get(c.changeId) > 1)
|
|
632
|
+
.map((c) => c.changeId);
|
|
633
|
+
}
|
|
281
634
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
const startDate = betweenMatch[1];
|
|
286
|
-
const endDate = betweenMatch[2];
|
|
287
|
-
return await this.filterBetween(startDate, endDate);
|
|
288
|
-
}
|
|
635
|
+
case 'merges':
|
|
636
|
+
case 'merge':
|
|
637
|
+
return await this.filterMerge();
|
|
289
638
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
639
|
+
case 'forks': {
|
|
640
|
+
await this.graph.load();
|
|
641
|
+
const all = this.graph.getAll();
|
|
642
|
+
return all
|
|
643
|
+
.filter((c) => this.graph.getChildren(c.changeId).length > 1)
|
|
644
|
+
.map((c) => c.changeId);
|
|
645
|
+
}
|
|
297
646
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if (commonAncestorMatch) {
|
|
301
|
-
const rev1 = commonAncestorMatch[1];
|
|
302
|
-
const rev2 = commonAncestorMatch[2];
|
|
303
|
-
return await this.findCommonAncestor(rev1, rev2);
|
|
304
|
-
}
|
|
647
|
+
case 'remote_tags':
|
|
648
|
+
return await this.filterRemoteTags(raw === '' ? undefined : unquote(raw));
|
|
305
649
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
650
|
+
case 'first_parent': {
|
|
651
|
+
const seeds = await this.resolveArg(raw);
|
|
652
|
+
const parents = new Set();
|
|
653
|
+
for (const seed of seeds) {
|
|
654
|
+
const change = await this.graph.getChange(seed);
|
|
655
|
+
if (change && change.parents && change.parents.length > 0) {
|
|
656
|
+
parents.add(change.parents[0]);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return Array.from(parents);
|
|
660
|
+
}
|
|
313
661
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
662
|
+
case 'first_ancestors': {
|
|
663
|
+
const seeds = await this.resolveArg(raw);
|
|
664
|
+
const result = new Set();
|
|
665
|
+
for (const seed of seeds) {
|
|
666
|
+
let current = seed;
|
|
667
|
+
while (current) {
|
|
668
|
+
if (result.has(current)) break;
|
|
669
|
+
result.add(current);
|
|
670
|
+
const change = await this.graph.getChange(current);
|
|
671
|
+
current =
|
|
672
|
+
change && change.parents && change.parents.length > 0 ? change.parents[0] : null;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return Array.from(result);
|
|
676
|
+
}
|
|
321
677
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
const rev2 = connectedMatch[2];
|
|
327
|
-
return await this.checkConnected(rev1, rev2);
|
|
328
|
-
}
|
|
678
|
+
case 'fork_point':
|
|
679
|
+
return await this.findForkPoint(await this.resolveArg(raw));
|
|
680
|
+
case 'merge_point':
|
|
681
|
+
return await this.findMergePoint(await this.resolveArg(raw));
|
|
329
682
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
683
|
+
case 'exactly': {
|
|
684
|
+
const parts = this.splitTopLevelArgs(raw);
|
|
685
|
+
if (parts.length !== 2) {
|
|
686
|
+
throw new JJError('INVALID_REVSET', `Invalid revset expression: exactly(${raw})`, {
|
|
687
|
+
suggestion: this._suggestion(),
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
const result = await this.evaluate(parts[0].trim());
|
|
691
|
+
const expected = parseInt(parts[1].trim(), 10);
|
|
692
|
+
if (result.length !== expected) {
|
|
693
|
+
throw new JJError(
|
|
694
|
+
'REVSET_EXACTLY_MISMATCH',
|
|
695
|
+
`exactly() expected ${expected} revision(s) but found ${result.length}`,
|
|
696
|
+
{ expected, actual: result.length }
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
return result;
|
|
700
|
+
}
|
|
335
701
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
702
|
+
case 'present': {
|
|
703
|
+
try {
|
|
704
|
+
return await this.evaluate(raw);
|
|
705
|
+
} catch {
|
|
706
|
+
return [];
|
|
707
|
+
}
|
|
340
708
|
}
|
|
341
|
-
return currentSet;
|
|
342
|
-
}
|
|
343
709
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
710
|
+
case 'coalesce': {
|
|
711
|
+
for (const part of this.splitTopLevelArgs(raw)) {
|
|
712
|
+
/** @type {string[]} */
|
|
713
|
+
let result = [];
|
|
714
|
+
try {
|
|
715
|
+
result = await this.evaluate(part.trim());
|
|
716
|
+
} catch {
|
|
717
|
+
result = [];
|
|
718
|
+
}
|
|
719
|
+
if (result.length > 0) return result;
|
|
720
|
+
}
|
|
721
|
+
return [];
|
|
722
|
+
}
|
|
349
723
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
724
|
+
case 'author':
|
|
725
|
+
return await this.filterByAuthor(unquote(raw));
|
|
726
|
+
case 'description':
|
|
727
|
+
return await this.filterByDescription(unquote(raw));
|
|
728
|
+
case 'empty':
|
|
729
|
+
return await this.filterEmpty();
|
|
730
|
+
case 'mine':
|
|
731
|
+
return await this.filterMine();
|
|
732
|
+
case 'file':
|
|
733
|
+
return await this.filterByFile(unquote(raw));
|
|
734
|
+
|
|
735
|
+
case 'roots':
|
|
736
|
+
return await this.filterRoots(await this.evaluate(raw));
|
|
737
|
+
case 'heads':
|
|
738
|
+
return await this.filterHeads(await this.evaluate(raw));
|
|
739
|
+
case 'parents':
|
|
740
|
+
return await this.getParentsOfSet(await this.evaluate(raw));
|
|
741
|
+
case 'children':
|
|
742
|
+
return await this.getChildrenOfSet(await this.evaluate(raw));
|
|
743
|
+
|
|
744
|
+
case 'latest': {
|
|
745
|
+
const parts = this.splitTopLevelArgs(raw);
|
|
746
|
+
const innerResults = await this.evaluate(parts[0]);
|
|
747
|
+
const count = parts[1] !== undefined ? parseInt(parts[1].trim(), 10) : 1;
|
|
748
|
+
return await this.filterLatest(innerResults, count);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
case 'tags':
|
|
752
|
+
return await this.filterTags(raw === '' ? undefined : unquote(raw));
|
|
753
|
+
case 'bookmark': {
|
|
754
|
+
const name = unquote(raw);
|
|
755
|
+
if (!this.bookmarkStore) return [];
|
|
756
|
+
await this.bookmarkStore.load();
|
|
757
|
+
const target = await this.bookmarkStore.get(name);
|
|
758
|
+
return target ? [target] : [];
|
|
759
|
+
}
|
|
760
|
+
case 'bookmarks':
|
|
761
|
+
return await this.filterBookmarks(raw === '' ? undefined : unquote(raw));
|
|
762
|
+
|
|
763
|
+
case 'last': {
|
|
764
|
+
const lastMatch = raw.match(/^(\d+)([dh])?$/);
|
|
765
|
+
if (!lastMatch) {
|
|
766
|
+
throw new JJError('INVALID_REVSET', `Invalid revset expression: last(${raw})`, {
|
|
767
|
+
suggestion: this._suggestion(),
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
const value = parseInt(lastMatch[1], 10);
|
|
771
|
+
const unit = lastMatch[2];
|
|
772
|
+
return unit ? await this.filterByTimeRange(value, unit) : await this.filterLast(value);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
case 'since': {
|
|
776
|
+
const dateMatch = raw.match(/^[0-9-]+$/);
|
|
777
|
+
if (!dateMatch) {
|
|
778
|
+
throw new JJError('INVALID_REVSET', `Invalid revset expression: since(${raw})`, {
|
|
779
|
+
suggestion: this._suggestion(),
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
return await this.filterSince(raw);
|
|
354
783
|
}
|
|
355
|
-
return currentSet;
|
|
356
|
-
}
|
|
357
784
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
const base = parts[0].trim();
|
|
364
|
-
const tip = parts[1].trim();
|
|
365
|
-
|
|
366
|
-
// Get ancestors of tip
|
|
367
|
-
const baseChange = await this.graph.getChange(base);
|
|
368
|
-
const tipChange = await this.graph.getChange(tip);
|
|
369
|
-
|
|
370
|
-
if (!baseChange || !tipChange) {
|
|
371
|
-
throw new JJError('INVALID_REVSET', `Invalid range: ${trimmed}`, {
|
|
372
|
-
suggestion: 'Both base and tip must be valid change IDs',
|
|
785
|
+
case 'between': {
|
|
786
|
+
const parts = this.splitTopLevelArgs(raw).map((p) => p.trim());
|
|
787
|
+
if (parts.length !== 2 || !/^[0-9-]+$/.test(parts[0]) || !/^[0-9-]+$/.test(parts[1])) {
|
|
788
|
+
throw new JJError('INVALID_REVSET', `Invalid revset expression: between(${raw})`, {
|
|
789
|
+
suggestion: this._suggestion(),
|
|
373
790
|
});
|
|
374
791
|
}
|
|
792
|
+
return await this.filterBetween(parts[0], parts[1]);
|
|
793
|
+
}
|
|
375
794
|
|
|
376
|
-
|
|
377
|
-
const
|
|
378
|
-
|
|
795
|
+
case 'descendants': {
|
|
796
|
+
const parts = this.splitTopLevelArgs(raw).map((p) => p.trim());
|
|
797
|
+
if (!/^[0-9a-f]{32}$/.test(parts[0])) {
|
|
798
|
+
throw new JJError('INVALID_REVSET', `Invalid revset expression: descendants(${raw})`, {
|
|
799
|
+
suggestion: this._suggestion(),
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
const depth = parts[1] !== undefined ? parseInt(parts[1], 10) : undefined;
|
|
803
|
+
return await this.getDescendants(parts[0], depth);
|
|
804
|
+
}
|
|
379
805
|
|
|
380
|
-
|
|
381
|
-
const
|
|
806
|
+
case 'common_ancestor': {
|
|
807
|
+
const [rev1, rev2] = this._twoHexArgs(name, raw);
|
|
808
|
+
return await this.findCommonAncestor(rev1, rev2);
|
|
809
|
+
}
|
|
382
810
|
|
|
383
|
-
|
|
811
|
+
case 'range': {
|
|
812
|
+
const rangeParts = raw.split('..');
|
|
813
|
+
if (
|
|
814
|
+
rangeParts.length !== 2 ||
|
|
815
|
+
!/^[0-9a-f]{32}$/.test(rangeParts[0]) ||
|
|
816
|
+
!/^[0-9a-f]{32}$/.test(rangeParts[1])
|
|
817
|
+
) {
|
|
818
|
+
throw new JJError('INVALID_REVSET', `Invalid revset expression: range(${raw})`, {
|
|
819
|
+
suggestion: this._suggestion(),
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
return await this.getRange(rangeParts[0], rangeParts[1]);
|
|
384
823
|
}
|
|
385
|
-
}
|
|
386
824
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
return all
|
|
392
|
-
.filter(c => c.conflicts && Object.keys(c.conflicts).length > 0)
|
|
393
|
-
.map(c => c.changeId);
|
|
394
|
-
}
|
|
825
|
+
case 'diverge_point': {
|
|
826
|
+
const [rev1, rev2] = this._twoHexArgs(name, raw);
|
|
827
|
+
return await this.findDivergePoint(rev1, rev2);
|
|
828
|
+
}
|
|
395
829
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
const innerStart = 'reachable('.length;
|
|
400
|
-
let parenCount = 1;
|
|
401
|
-
let endIndex = innerStart;
|
|
402
|
-
|
|
403
|
-
// Find the matching closing parenthesis
|
|
404
|
-
while (endIndex < trimmed.length && parenCount > 0) {
|
|
405
|
-
if (trimmed[endIndex] === '(') parenCount++;
|
|
406
|
-
if (trimmed[endIndex] === ')') parenCount--;
|
|
407
|
-
endIndex++;
|
|
830
|
+
case 'connected': {
|
|
831
|
+
const [rev1, rev2] = this._twoHexArgs(name, raw);
|
|
832
|
+
return /** @type {any} */ (await this.checkConnected(rev1, rev2));
|
|
408
833
|
}
|
|
409
834
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
835
|
+
case 'conflicted': {
|
|
836
|
+
await this.graph.load();
|
|
837
|
+
return this.graph
|
|
838
|
+
.getAll()
|
|
839
|
+
.filter((c) => c.conflicts && Object.keys(c.conflicts).length > 0)
|
|
840
|
+
.map((c) => c.changeId);
|
|
841
|
+
}
|
|
414
842
|
|
|
415
|
-
|
|
843
|
+
case 'reachable': {
|
|
844
|
+
const heads = await this.evaluate(raw);
|
|
416
845
|
const reachable = new Set();
|
|
417
846
|
for (const headId of heads) {
|
|
418
|
-
const
|
|
419
|
-
for (const ancestorId of ancestors) {
|
|
847
|
+
for (const ancestorId of await this.getAncestors(headId)) {
|
|
420
848
|
reachable.add(ancestorId);
|
|
421
849
|
}
|
|
422
850
|
}
|
|
423
|
-
|
|
424
851
|
return Array.from(reachable);
|
|
425
852
|
}
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
// tracked() - changes with tracked files (all changes with files)
|
|
429
|
-
if (trimmed === 'tracked()') {
|
|
430
|
-
await this.graph.load();
|
|
431
|
-
const all = this.graph.getAll();
|
|
432
|
-
return all
|
|
433
|
-
.filter(c => c.fileSnapshot && Object.keys(c.fileSnapshot).length > 0)
|
|
434
|
-
.map(c => c.changeId);
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
// untracked() - changes with no tracked files (empty changes)
|
|
438
|
-
if (trimmed === 'untracked()') {
|
|
439
|
-
await this.graph.load();
|
|
440
|
-
const all = this.graph.getAll();
|
|
441
|
-
return all
|
|
442
|
-
.filter(c => !c.fileSnapshot || Object.keys(c.fileSnapshot).length === 0)
|
|
443
|
-
.map(c => c.changeId);
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
// remote_branches([pattern]) - remote branch targets
|
|
447
|
-
const remoteBranchesMatch = trimmed.match(/^remote_branches\((?:"([^"]+)")?\)$/);
|
|
448
|
-
if (remoteBranchesMatch || trimmed === 'remote_branches()') {
|
|
449
|
-
if (!this.bookmarkStore) return [];
|
|
450
853
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
854
|
+
case 'tracked': {
|
|
855
|
+
await this.graph.load();
|
|
856
|
+
return this.graph
|
|
857
|
+
.getAll()
|
|
858
|
+
.filter((c) => c.fileSnapshot && Object.keys(c.fileSnapshot).length > 0)
|
|
859
|
+
.map((c) => c.changeId);
|
|
860
|
+
}
|
|
456
861
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
862
|
+
case 'untracked': {
|
|
863
|
+
await this.graph.load();
|
|
864
|
+
return this.graph
|
|
865
|
+
.getAll()
|
|
866
|
+
.filter((c) => !c.fileSnapshot || Object.keys(c.fileSnapshot).length === 0)
|
|
867
|
+
.map((c) => c.changeId);
|
|
462
868
|
}
|
|
463
869
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
870
|
+
case 'remote_branches': {
|
|
871
|
+
if (!this.bookmarkStore) return [];
|
|
872
|
+
await this.bookmarkStore.load();
|
|
873
|
+
const allBookmarks = /** @type {any[]} */ (await this.bookmarkStore.list());
|
|
874
|
+
let remoteBookmarks = allBookmarks.filter((bookmark) => bookmark.name.includes('/'));
|
|
875
|
+
if (raw !== '') {
|
|
876
|
+
const pattern = unquote(raw);
|
|
877
|
+
remoteBookmarks = remoteBookmarks.filter((bookmark) =>
|
|
878
|
+
this.globMatch(bookmark.name, pattern)
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
const targets = new Set();
|
|
882
|
+
for (const bookmark of remoteBookmarks) {
|
|
883
|
+
if (bookmark.changeId) targets.add(bookmark.changeId);
|
|
469
884
|
}
|
|
885
|
+
return Array.from(targets);
|
|
470
886
|
}
|
|
471
887
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
return await this.evaluateSetOperation(trimmed);
|
|
888
|
+
default:
|
|
889
|
+
throw new JJError('INVALID_REVSET', `Invalid revset expression: ${name}(${raw})`, {
|
|
890
|
+
expression: `${name}(${raw})`,
|
|
891
|
+
suggestion: this._suggestion(),
|
|
892
|
+
});
|
|
478
893
|
}
|
|
894
|
+
}
|
|
479
895
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
896
|
+
/**
|
|
897
|
+
* Parse and validate exactly two comma-separated 32-hex-char arguments for
|
|
898
|
+
* functions like common_ancestor/diverge_point/connected.
|
|
899
|
+
*
|
|
900
|
+
* @private
|
|
901
|
+
* @param {string} name - Function name (for the error message)
|
|
902
|
+
* @param {string} raw - Raw argument text
|
|
903
|
+
* @returns {[string, string]}
|
|
904
|
+
*/
|
|
905
|
+
_twoHexArgs(name, raw) {
|
|
906
|
+
const parts = this.splitTopLevelArgs(raw).map((p) => p.trim());
|
|
907
|
+
if (
|
|
908
|
+
parts.length !== 2 ||
|
|
909
|
+
!/^[0-9a-f]{32}$/.test(parts[0]) ||
|
|
910
|
+
!/^[0-9a-f]{32}$/.test(parts[1])
|
|
911
|
+
) {
|
|
912
|
+
throw new JJError('INVALID_REVSET', `Invalid revset expression: ${name}(${raw})`, {
|
|
913
|
+
suggestion: this._suggestion(),
|
|
914
|
+
});
|
|
485
915
|
}
|
|
486
|
-
|
|
487
|
-
throw new JJError('INVALID_REVSET', `Invalid revset expression: ${expression}`, {
|
|
488
|
-
expression,
|
|
489
|
-
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',
|
|
490
|
-
});
|
|
916
|
+
return [parts[0], parts[1]];
|
|
491
917
|
}
|
|
492
918
|
|
|
493
919
|
/**
|
|
494
920
|
* Get all ancestors of a change (including the change itself)
|
|
495
|
-
*
|
|
921
|
+
*
|
|
496
922
|
* @param {string} changeId - Change ID
|
|
923
|
+
* @param {number} [depth] - Optional depth limit
|
|
497
924
|
* @returns {Promise<Array<string>>} Array of ancestor change IDs
|
|
498
925
|
*/
|
|
499
|
-
async getAncestors(changeId) {
|
|
926
|
+
async getAncestors(changeId, depth = undefined) {
|
|
500
927
|
await this.graph.load();
|
|
501
928
|
|
|
502
929
|
const ancestors = [];
|
|
503
930
|
const visited = new Set();
|
|
504
|
-
const queue = [changeId];
|
|
931
|
+
const queue = [{ id: changeId, level: 0 }];
|
|
505
932
|
|
|
506
933
|
while (queue.length > 0) {
|
|
507
|
-
const current = queue.shift();
|
|
934
|
+
const { id: current, level } = /** @type {{ id: string, level: number }} */ (queue.shift());
|
|
508
935
|
|
|
509
936
|
if (visited.has(current)) {
|
|
510
937
|
continue;
|
|
@@ -513,43 +940,202 @@ export class RevsetEngine {
|
|
|
513
940
|
visited.add(current);
|
|
514
941
|
ancestors.push(current);
|
|
515
942
|
|
|
943
|
+
// Depth is measured from the seed change. A depth of N includes the seed
|
|
944
|
+
// plus N generations of parents (matching jj's `ancestors(x, depth)`).
|
|
945
|
+
if (depth !== undefined && level >= depth) {
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
|
|
516
949
|
const parents = this.graph.getParents(current);
|
|
517
950
|
for (const parent of parents) {
|
|
518
|
-
queue.push(parent);
|
|
951
|
+
queue.push({ id: parent, level: level + 1 });
|
|
519
952
|
}
|
|
520
953
|
}
|
|
521
954
|
|
|
522
955
|
return ancestors;
|
|
523
956
|
}
|
|
524
957
|
|
|
958
|
+
/**
|
|
959
|
+
* Resolve a revset argument to a set of change IDs (v1.5).
|
|
960
|
+
*
|
|
961
|
+
* Accepts a bare 32-hex change ID, a hex prefix, or any nested revset
|
|
962
|
+
* expression. Used by graph functions that take another revset as input.
|
|
963
|
+
*
|
|
964
|
+
* @param {string} arg - Argument expression
|
|
965
|
+
* @returns {Promise<Array<string>>} Change IDs
|
|
966
|
+
*/
|
|
967
|
+
async resolveArg(arg) {
|
|
968
|
+
const trimmed = arg.trim();
|
|
969
|
+
if (/^[0-9a-f]{32}$/.test(trimmed)) {
|
|
970
|
+
return [trimmed];
|
|
971
|
+
}
|
|
972
|
+
return await this.evaluate(trimmed);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Split a comma-separated argument list, respecting nested parentheses and
|
|
977
|
+
* quoted strings — so a quoted argument containing a literal comma (e.g.
|
|
978
|
+
* `description("a, b")`) isn't mis-split (v1.7: now quote-aware).
|
|
979
|
+
*
|
|
980
|
+
* @param {string} argString - Raw argument string (without the outer parens)
|
|
981
|
+
* @returns {Array<string>} Individual argument expressions
|
|
982
|
+
*/
|
|
983
|
+
splitTopLevelArgs(argString) {
|
|
984
|
+
const args = [];
|
|
985
|
+
let depth = 0;
|
|
986
|
+
/** @type {string|null} */
|
|
987
|
+
let quote = null;
|
|
988
|
+
let current = '';
|
|
989
|
+
for (const ch of argString) {
|
|
990
|
+
if (quote) {
|
|
991
|
+
current += ch;
|
|
992
|
+
if (ch === quote) quote = null;
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
if (ch === '"' || ch === "'") {
|
|
996
|
+
quote = ch;
|
|
997
|
+
current += ch;
|
|
998
|
+
continue;
|
|
999
|
+
}
|
|
1000
|
+
if (ch === '(') depth++;
|
|
1001
|
+
if (ch === ')') depth--;
|
|
1002
|
+
if (ch === ',' && depth === 0) {
|
|
1003
|
+
args.push(current);
|
|
1004
|
+
current = '';
|
|
1005
|
+
} else {
|
|
1006
|
+
current += ch;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
if (current.trim() !== '') args.push(current);
|
|
1010
|
+
return args;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* Filter changes whose changeId/commitId starts with a hex prefix (v1.5).
|
|
1015
|
+
*
|
|
1016
|
+
* @param {string} prefix - Hex prefix
|
|
1017
|
+
* @param {string} field - 'changeId' or 'commitId'
|
|
1018
|
+
* @returns {Promise<Array<string>>} Matching change IDs
|
|
1019
|
+
*/
|
|
1020
|
+
async filterByIdPrefix(prefix, field) {
|
|
1021
|
+
await this.graph.load();
|
|
1022
|
+
const lower = prefix.toLowerCase();
|
|
1023
|
+
return this.graph
|
|
1024
|
+
.getAll()
|
|
1025
|
+
.filter((c) => (c[field] || '').toLowerCase().startsWith(lower))
|
|
1026
|
+
.map((c) => c.changeId);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* Filter changes whose subject (first line of the description) matches (v1.5).
|
|
1031
|
+
*
|
|
1032
|
+
* @param {string} text - Text to match within the subject line
|
|
1033
|
+
* @returns {Promise<Array<string>>} Matching change IDs
|
|
1034
|
+
*/
|
|
1035
|
+
async filterBySubject(text) {
|
|
1036
|
+
await this.graph.load();
|
|
1037
|
+
return this.graph
|
|
1038
|
+
.getAll()
|
|
1039
|
+
.filter((c) => {
|
|
1040
|
+
const subject = (c.description || '').split('\n')[0];
|
|
1041
|
+
return subject.includes(text);
|
|
1042
|
+
})
|
|
1043
|
+
.map((c) => c.changeId);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* Filter changes by a signature field (v1.5).
|
|
1048
|
+
*
|
|
1049
|
+
* @param {string} role - 'author' or 'committer'
|
|
1050
|
+
* @param {string} field - 'name' or 'email'
|
|
1051
|
+
* @param {string} pattern - Substring to match
|
|
1052
|
+
* @returns {Promise<Array<string>>} Matching change IDs
|
|
1053
|
+
*/
|
|
1054
|
+
async filterBySignatureField(role, field, pattern) {
|
|
1055
|
+
await this.graph.load();
|
|
1056
|
+
return this.graph
|
|
1057
|
+
.getAll()
|
|
1058
|
+
.filter((c) => c[role] && c[role][field] && c[role][field].includes(pattern))
|
|
1059
|
+
.map((c) => c.changeId);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Find the fork point (youngest common ancestor) of a set of changes (v1.5).
|
|
1064
|
+
*
|
|
1065
|
+
* @param {Array<string>} changeIds - Seed change IDs
|
|
1066
|
+
* @returns {Promise<Array<string>>} Fork point (single element array or empty)
|
|
1067
|
+
*/
|
|
1068
|
+
async findForkPoint(changeIds) {
|
|
1069
|
+
await this.graph.load();
|
|
1070
|
+
if (changeIds.length === 0) return [];
|
|
1071
|
+
|
|
1072
|
+
// Intersect the ancestor sets of every seed.
|
|
1073
|
+
/** @type {Set<string> | null} */
|
|
1074
|
+
let common = null;
|
|
1075
|
+
for (const id of changeIds) {
|
|
1076
|
+
const ancestors = /** @type {Set<string>} */ (new Set(await this.getAncestors(id)));
|
|
1077
|
+
common =
|
|
1078
|
+
common === null
|
|
1079
|
+
? ancestors
|
|
1080
|
+
: new Set([.../** @type {Set<string>} */ (common)].filter((a) => ancestors.has(a)));
|
|
1081
|
+
}
|
|
1082
|
+
if (!common || common.size === 0) return [];
|
|
1083
|
+
|
|
1084
|
+
// Youngest common ancestor: the one that is not an ancestor of any other
|
|
1085
|
+
// member of the common set (i.e. a head within the common set).
|
|
1086
|
+
return await this.filterHeads(Array.from(common));
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* Find the merge point (youngest common descendant) of a set of changes (v1.5).
|
|
1091
|
+
*
|
|
1092
|
+
* @param {Array<string>} changeIds - Seed change IDs
|
|
1093
|
+
* @returns {Promise<Array<string>>} Merge point (single element array or empty)
|
|
1094
|
+
*/
|
|
1095
|
+
async findMergePoint(changeIds) {
|
|
1096
|
+
await this.graph.load();
|
|
1097
|
+
if (changeIds.length === 0) return [];
|
|
1098
|
+
|
|
1099
|
+
/** @type {Set<string>|null} */
|
|
1100
|
+
let common = null;
|
|
1101
|
+
for (const id of changeIds) {
|
|
1102
|
+
const descendants = /** @type {Set<string>} */ (new Set(await this.getDescendants(id)));
|
|
1103
|
+
descendants.add(id);
|
|
1104
|
+
common =
|
|
1105
|
+
common === null
|
|
1106
|
+
? descendants
|
|
1107
|
+
: new Set([.../** @type {Set<string>} */ (common)].filter((d) => descendants.has(d)));
|
|
1108
|
+
}
|
|
1109
|
+
if (!common || common.size === 0) return [];
|
|
1110
|
+
|
|
1111
|
+
// Youngest common descendant: a root within the common set.
|
|
1112
|
+
return await this.filterRoots(Array.from(common));
|
|
1113
|
+
}
|
|
1114
|
+
|
|
525
1115
|
/**
|
|
526
1116
|
* Filter changes by author name (v0.2)
|
|
527
|
-
*
|
|
1117
|
+
*
|
|
528
1118
|
* @param {string} authorName - Author name to match
|
|
529
1119
|
* @returns {Promise<Array<string>>} Array of matching change IDs
|
|
530
1120
|
*/
|
|
531
1121
|
async filterByAuthor(authorName) {
|
|
532
1122
|
await this.graph.load();
|
|
533
1123
|
const all = this.graph.getAll();
|
|
534
|
-
|
|
535
|
-
return all
|
|
536
|
-
.filter((c) => c.author && c.author.name.includes(authorName))
|
|
537
|
-
.map((c) => c.changeId);
|
|
1124
|
+
|
|
1125
|
+
return all.filter((c) => c.author && c.author.name.includes(authorName)).map((c) => c.changeId);
|
|
538
1126
|
}
|
|
539
1127
|
|
|
540
1128
|
/**
|
|
541
1129
|
* Filter changes by description text (v0.2)
|
|
542
|
-
*
|
|
1130
|
+
*
|
|
543
1131
|
* @param {string} text - Text to search for in description
|
|
544
1132
|
* @returns {Promise<Array<string>>} Array of matching change IDs
|
|
545
1133
|
*/
|
|
546
1134
|
async filterByDescription(text) {
|
|
547
1135
|
await this.graph.load();
|
|
548
1136
|
const all = this.graph.getAll();
|
|
549
|
-
|
|
550
|
-
return all
|
|
551
|
-
.filter((c) => c.description && c.description.includes(text))
|
|
552
|
-
.map((c) => c.changeId);
|
|
1137
|
+
|
|
1138
|
+
return all.filter((c) => c.description && c.description.includes(text)).map((c) => c.changeId);
|
|
553
1139
|
}
|
|
554
1140
|
|
|
555
1141
|
/**
|
|
@@ -585,9 +1171,10 @@ export class RevsetEngine {
|
|
|
585
1171
|
const currentUser = this.userConfig.getUser();
|
|
586
1172
|
|
|
587
1173
|
return all
|
|
588
|
-
.filter(
|
|
589
|
-
(c
|
|
590
|
-
|
|
1174
|
+
.filter(
|
|
1175
|
+
(c) =>
|
|
1176
|
+
c.author && (c.author.email === currentUser.email || c.author.name === currentUser.name)
|
|
1177
|
+
)
|
|
591
1178
|
.map((c) => c.changeId);
|
|
592
1179
|
}
|
|
593
1180
|
|
|
@@ -600,9 +1187,7 @@ export class RevsetEngine {
|
|
|
600
1187
|
await this.graph.load();
|
|
601
1188
|
const all = this.graph.getAll();
|
|
602
1189
|
|
|
603
|
-
return all
|
|
604
|
-
.filter((c) => c.parents && c.parents.length > 1)
|
|
605
|
-
.map((c) => c.changeId);
|
|
1190
|
+
return all.filter((c) => c.parents && c.parents.length > 1).map((c) => c.changeId);
|
|
606
1191
|
}
|
|
607
1192
|
|
|
608
1193
|
/**
|
|
@@ -620,10 +1205,8 @@ export class RevsetEngine {
|
|
|
620
1205
|
for (const change of all) {
|
|
621
1206
|
if (change.fileSnapshot) {
|
|
622
1207
|
const files = Object.keys(change.fileSnapshot);
|
|
623
|
-
const matches = files.some(
|
|
624
|
-
file === pattern ||
|
|
625
|
-
file.includes(pattern) ||
|
|
626
|
-
this.globMatch(file, pattern)
|
|
1208
|
+
const matches = files.some(
|
|
1209
|
+
(file) => file === pattern || file.includes(pattern) || this.globMatch(file, pattern)
|
|
627
1210
|
);
|
|
628
1211
|
if (matches) {
|
|
629
1212
|
results.push(change.changeId);
|
|
@@ -642,10 +1225,7 @@ export class RevsetEngine {
|
|
|
642
1225
|
* @returns {boolean} True if matches
|
|
643
1226
|
*/
|
|
644
1227
|
globMatch(str, pattern) {
|
|
645
|
-
const regexPattern = pattern
|
|
646
|
-
.replace(/\./g, '\\.')
|
|
647
|
-
.replace(/\*/g, '.*')
|
|
648
|
-
.replace(/\?/g, '.');
|
|
1228
|
+
const regexPattern = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
649
1229
|
const regex = new RegExp(`^${regexPattern}$`);
|
|
650
1230
|
return regex.test(str);
|
|
651
1231
|
}
|
|
@@ -665,7 +1245,7 @@ export class RevsetEngine {
|
|
|
665
1245
|
for (const changeId of changeIds) {
|
|
666
1246
|
// A change is a root if none of its parents are in the set
|
|
667
1247
|
const parents = this.graph.getParents(changeId);
|
|
668
|
-
const hasParentInSet = parents.some(p => changeSet.has(p));
|
|
1248
|
+
const hasParentInSet = parents.some((p) => changeSet.has(p));
|
|
669
1249
|
|
|
670
1250
|
if (!hasParentInSet) {
|
|
671
1251
|
roots.push(changeId);
|
|
@@ -690,7 +1270,7 @@ export class RevsetEngine {
|
|
|
690
1270
|
for (const changeId of changeIds) {
|
|
691
1271
|
// A change is a head if it has no children in the set
|
|
692
1272
|
const children = this.graph.getChildren(changeId);
|
|
693
|
-
const hasChildInSet = children.some(c => changeSet.has(c));
|
|
1273
|
+
const hasChildInSet = children.some((c) => changeSet.has(c));
|
|
694
1274
|
|
|
695
1275
|
if (!hasChildInSet) {
|
|
696
1276
|
heads.push(changeId);
|
|
@@ -722,10 +1302,10 @@ export class RevsetEngine {
|
|
|
722
1302
|
changes.sort((a, b) => {
|
|
723
1303
|
const timeA = a.committer?.timestamp || a.timestamp || 0;
|
|
724
1304
|
const timeB = b.committer?.timestamp || b.timestamp || 0;
|
|
725
|
-
return new Date(timeB) - new Date(timeA);
|
|
1305
|
+
return /** @type {any} */ (new Date(timeB)) - /** @type {any} */ (new Date(timeA));
|
|
726
1306
|
});
|
|
727
1307
|
|
|
728
|
-
return changes.slice(0, count).map(c => c.changeId);
|
|
1308
|
+
return changes.slice(0, count).map((c) => c.changeId);
|
|
729
1309
|
}
|
|
730
1310
|
|
|
731
1311
|
/**
|
|
@@ -735,11 +1315,47 @@ export class RevsetEngine {
|
|
|
735
1315
|
* @returns {Promise<Array<string>>} Change IDs pointed to by matching tags
|
|
736
1316
|
*/
|
|
737
1317
|
async filterTags(pattern) {
|
|
738
|
-
|
|
1318
|
+
if (!this.tagStore) {
|
|
1319
|
+
return [];
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
await this.tagStore.load();
|
|
1323
|
+
// TagStore.list([pattern]) returns [{ name, changeId }] and applies the
|
|
1324
|
+
// glob pattern itself when provided.
|
|
1325
|
+
const matchingTags = await this.tagStore.list(pattern);
|
|
739
1326
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
1327
|
+
const targets = new Set();
|
|
1328
|
+
for (const tag of matchingTags) {
|
|
1329
|
+
if (tag.changeId) {
|
|
1330
|
+
targets.add(tag.changeId);
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
return Array.from(targets);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/**
|
|
1337
|
+
* Filter remote tags (v1.5 - jj v0.38 remote_tags())
|
|
1338
|
+
*
|
|
1339
|
+
* Remote tags are stored with a slash-qualified name (e.g. "origin/v1.0").
|
|
1340
|
+
*
|
|
1341
|
+
* @param {string} [pattern] - Optional tag name pattern
|
|
1342
|
+
* @returns {Promise<Array<string>>} Change IDs pointed to by matching remote tags
|
|
1343
|
+
*/
|
|
1344
|
+
async filterRemoteTags(pattern) {
|
|
1345
|
+
if (!this.tagStore) {
|
|
1346
|
+
return [];
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
await this.tagStore.load();
|
|
1350
|
+
const allTags = await this.tagStore.list();
|
|
1351
|
+
|
|
1352
|
+
const targets = new Set();
|
|
1353
|
+
for (const tag of allTags) {
|
|
1354
|
+
if (!tag.name.includes('/')) continue; // only remote-qualified tags
|
|
1355
|
+
if (pattern && !this.globMatch(tag.name, pattern)) continue;
|
|
1356
|
+
if (tag.changeId) targets.add(tag.changeId);
|
|
1357
|
+
}
|
|
1358
|
+
return Array.from(targets);
|
|
743
1359
|
}
|
|
744
1360
|
|
|
745
1361
|
/**
|
|
@@ -769,7 +1385,7 @@ export class RevsetEngine {
|
|
|
769
1385
|
continue;
|
|
770
1386
|
}
|
|
771
1387
|
|
|
772
|
-
result.push(bookmark.
|
|
1388
|
+
result.push(bookmark.changeId);
|
|
773
1389
|
}
|
|
774
1390
|
|
|
775
1391
|
// Return unique changeIds
|
|
@@ -793,7 +1409,7 @@ export class RevsetEngine {
|
|
|
793
1409
|
return timeB - timeA;
|
|
794
1410
|
});
|
|
795
1411
|
|
|
796
|
-
return sorted.slice(0, count).map(c => c.changeId);
|
|
1412
|
+
return sorted.slice(0, count).map((c) => c.changeId);
|
|
797
1413
|
}
|
|
798
1414
|
|
|
799
1415
|
/**
|
|
@@ -821,11 +1437,11 @@ export class RevsetEngine {
|
|
|
821
1437
|
const cutoffTime = now - milliseconds;
|
|
822
1438
|
|
|
823
1439
|
return all
|
|
824
|
-
.filter(c => {
|
|
1440
|
+
.filter((c) => {
|
|
825
1441
|
const timestamp = c.committer?.timestamp || 0;
|
|
826
1442
|
return timestamp >= cutoffTime;
|
|
827
1443
|
})
|
|
828
|
-
.map(c => c.changeId);
|
|
1444
|
+
.map((c) => c.changeId);
|
|
829
1445
|
}
|
|
830
1446
|
|
|
831
1447
|
/**
|
|
@@ -841,11 +1457,11 @@ export class RevsetEngine {
|
|
|
841
1457
|
const sinceTime = new Date(dateStr).getTime();
|
|
842
1458
|
|
|
843
1459
|
return all
|
|
844
|
-
.filter(c => {
|
|
1460
|
+
.filter((c) => {
|
|
845
1461
|
const timestamp = c.committer?.timestamp || 0;
|
|
846
1462
|
return timestamp >= sinceTime;
|
|
847
1463
|
})
|
|
848
|
-
.map(c => c.changeId);
|
|
1464
|
+
.map((c) => c.changeId);
|
|
849
1465
|
}
|
|
850
1466
|
|
|
851
1467
|
/**
|
|
@@ -863,11 +1479,11 @@ export class RevsetEngine {
|
|
|
863
1479
|
const endTime = new Date(endDateStr).getTime();
|
|
864
1480
|
|
|
865
1481
|
return all
|
|
866
|
-
.filter(c => {
|
|
1482
|
+
.filter((c) => {
|
|
867
1483
|
const timestamp = c.committer?.timestamp || 0;
|
|
868
1484
|
return timestamp >= startTime && timestamp <= endTime;
|
|
869
1485
|
})
|
|
870
|
-
.map(c => c.changeId);
|
|
1486
|
+
.map((c) => c.changeId);
|
|
871
1487
|
}
|
|
872
1488
|
|
|
873
1489
|
/**
|
|
@@ -885,7 +1501,7 @@ export class RevsetEngine {
|
|
|
885
1501
|
const queue = [{ id: changeId, level: 0 }];
|
|
886
1502
|
|
|
887
1503
|
while (queue.length > 0) {
|
|
888
|
-
const { id: current, level } = queue.shift();
|
|
1504
|
+
const { id: current, level } = /** @type {{ id: string, level: number }} */ (queue.shift());
|
|
889
1505
|
|
|
890
1506
|
if (visited.has(current)) {
|
|
891
1507
|
continue;
|
|
@@ -1035,7 +1651,7 @@ export class RevsetEngine {
|
|
|
1035
1651
|
// Get all changes and check if their parents include any from our set
|
|
1036
1652
|
const allChanges = this.graph.getAll();
|
|
1037
1653
|
for (const change of allChanges) {
|
|
1038
|
-
if (change.parents && change.parents.some((p) => changeIdSet.has(p))) {
|
|
1654
|
+
if (change.parents && change.parents.some((/** @type {string} */ p) => changeIdSet.has(p))) {
|
|
1039
1655
|
children.add(change.changeId);
|
|
1040
1656
|
}
|
|
1041
1657
|
}
|
|
@@ -1044,56 +1660,24 @@ export class RevsetEngine {
|
|
|
1044
1660
|
}
|
|
1045
1661
|
|
|
1046
1662
|
/**
|
|
1047
|
-
* Evaluate set
|
|
1663
|
+
* Evaluate a bare set-operation expression directly (kept for backward
|
|
1664
|
+
* compatibility — evaluate() now handles `&`/`|`/`~` as part of normal
|
|
1665
|
+
* parsing/precedence, so this is a thin wrapper that still enforces the
|
|
1666
|
+
* "must contain an operator" contract for direct callers.
|
|
1048
1667
|
*
|
|
1049
|
-
* @param {string} expression - Expression
|
|
1050
|
-
* @returns {Promise<Array<string>>} Result of set operation
|
|
1668
|
+
* @param {string} expression - Expression expected to contain a set operator
|
|
1669
|
+
* @returns {Promise<Array<string>>} Result of the set operation
|
|
1051
1670
|
*/
|
|
1052
1671
|
async evaluateSetOperation(expression) {
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
if (
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
for (let i = 1; i < parts.length; i++) {
|
|
1062
|
-
const partResult = new Set(await this.evaluate(parts[i]));
|
|
1063
|
-
result = new Set([...result].filter(x => partResult.has(x)));
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
return Array.from(result);
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
// Handle union (|)
|
|
1070
|
-
if (expression.includes(' | ')) {
|
|
1071
|
-
const parts = expression.split(' | ');
|
|
1072
|
-
const result = new Set();
|
|
1073
|
-
|
|
1074
|
-
for (const part of parts) {
|
|
1075
|
-
const partResult = await this.evaluate(part);
|
|
1076
|
-
partResult.forEach(id => result.add(id));
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
return Array.from(result);
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
// Handle difference (~)
|
|
1083
|
-
if (expression.includes(' ~ ')) {
|
|
1084
|
-
const parts = expression.split(' ~ ');
|
|
1085
|
-
let result = new Set(await this.evaluate(parts[0]));
|
|
1086
|
-
|
|
1087
|
-
for (let i = 1; i < parts.length; i++) {
|
|
1088
|
-
const partResult = new Set(await this.evaluate(parts[i]));
|
|
1089
|
-
result = new Set([...result].filter(x => !partResult.has(x)));
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
return Array.from(result);
|
|
1672
|
+
const tokens = tokenize(expression.trim());
|
|
1673
|
+
const hasOperator = tokens.some(
|
|
1674
|
+
(t) => t.type === 'AMP' || t.type === 'PIPE' || t.type === 'TILDE'
|
|
1675
|
+
);
|
|
1676
|
+
if (!hasOperator) {
|
|
1677
|
+
throw new JJError('INVALID_SET_OPERATION', `Invalid set operation: ${expression}`, {
|
|
1678
|
+
expression,
|
|
1679
|
+
});
|
|
1093
1680
|
}
|
|
1094
|
-
|
|
1095
|
-
throw new JJError('INVALID_SET_OPERATION', `Invalid set operation: ${expression}`, {
|
|
1096
|
-
expression,
|
|
1097
|
-
});
|
|
1681
|
+
return await this.evaluate(expression);
|
|
1098
1682
|
}
|
|
1099
1683
|
}
|