OXYGENHUB

Revsets: oxy log -r selects changes by set algebra over history 47ce8b8e2d4b

docs/revset_query_language.md, the core. A revset names changes by
symbol (bookmark, REMOTE/NAME, tag, ChangeId or #hash prefix, @),
relates them (::X, X::, X::Y, X..Y, deps, dependents, heads, roots,
latest) and combines them (| & ~, with ~ tightest after the ranges),
and selects by author (name, email, key prefix, me), description
(substring), date (absolute and relative ranges, taken as written),
kind and touches(GLOB), which follows a file's node through renames.
Predicates range over everything the repository names, or over the
other side of an intersection, so main.. & touches("src/**") reads
only what main lacks. A saved revset (oxy config revset.NAME EXPR)
is NAME(). Evaluation is read-only and fails past a time budget,
saying what to narrow.

The graph is the state index's change metadata, loaded by closure of
the heads a symbol names. A first page on the oxygen repository takes
10 to 40 ms; touches() 90 ms with the state loaded.

Not yet: independent(), conflicts(), description(re:), the trust
predicates, and -r on other commands.

refs #65
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-04 03:02 UTC
Signature
✔ signed by Andy McCall
Hash
47ce8b8e2d4b900bead43a3741c6fc8d8115451ce6a88ff9d603bb2b8d1407c2
Depends on
2bf22305bc48 648724d8e0de 6d3eefbce2f0 75167672ebcd 83d61e803ccd 8ad9dfe16303 94084087a876 982864068a59 a46f3e4f50b1 c07e0e7d2d85 d02e545dc265 ee06a6c557b7 fc1a466a1e66 fce44ae9ad29 ff0af1ee299d
Depended on by
c2e1d09047db 170bb436e1d5 bf3a67819b59 d069701a4997 614f55e14eee 543bbf64d127 2c9ddac3c387
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.10.0 v0.11.0 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
26 atoms, 2 tree operations, 11 files

Browse the tree at this change

added crates/oxy-core/src/repo/revset.rs +608 −0
1 +
//! Evaluating revsets (docs/revset_query_language.md §4) over a repository.
2 +
//!
3 +
//! The graph is the change metadata the state index already holds: a
4 +
//! symbol loads the closure of the heads it names, and `all()`, `X::`,
5 +
//! `dependents()` and `roots()` load every bookmark, remote bookmark, tag
6 +
//! and the working copy. Evaluation is read-only and has a time budget;
7 +
//! a query past it fails and says what to narrow.
8 +
9 +
use super::*;
10 +
use crate::ids::NodeId;
11 +
use crate::revset::{self, Expr};
12 +
use crate::state::ChangeMeta;
13 +
use std::time::{Duration, Instant};
14 +
15 +
type Set = BTreeSet<ChangeHash>;
16 +
17 +
/// The default budget for a command-line evaluation.
18 +
pub const REVSET_BUDGET: Duration = Duration::from_secs(10);
19 +
20 +
/// A revset's result with the metadata of every change it names.
21 +
#[derive(Clone, Debug)]
22 +
pub struct Revset {
23 +
    /// Newest first, as a log is ordered.
24 +
    pub hashes: Vec<ChangeHash>,
25 +
    pub metas: HashMap<ChangeHash, ChangeMeta>,
26 +
}
27 +
28 +
struct Eval<'a> {
29 +
    repo: &'a Repo,
30 +
    graph: HashMap<ChangeHash, ChangeMeta>,
31 +
    /// Which heads have had their closure loaded.
32 +
    loaded: HashSet<ChangeHash>,
33 +
    all_loaded: bool,
34 +
    dependents: Option<HashMap<ChangeHash, Vec<ChangeHash>>>,
35 +
    names: HashMap<KeyId, Option<(String, String)>>,
36 +
    deadline: Instant,
37 +
    budget: Duration,
38 +
    now_ms: u64,
39 +
    depth: usize,
40 +
}
41 +
42 +
fn err(msg: impl Into<String>) -> RepoError {
43 +
    RepoError::Msg(msg.into())
44 +
}
45 +
46 +
impl<'a> Eval<'a> {
47 +
    fn tick(&self) -> Result<()> {
48 +
        if Instant::now() > self.deadline {
49 +
            return Err(err(format!(
50 +
                "the revset went past its budget of {:.0} s; narrow it with a range such as main.. or a date()",
51 +
                self.budget.as_secs_f64()
52 +
            )));
53 +
        }
54 +
        Ok(())
55 +
    }
56 +
57 +
    /// Load the closure of `heads` into the graph.
58 +
    fn ensure(&mut self, heads: &[ChangeHash]) -> Result<()> {
59 +
        let fresh: Vec<ChangeHash> = heads
60 +
            .iter()
61 +
            .copied()
62 +
            .filter(|h| !self.loaded.contains(h))
63 +
            .collect();
64 +
        if fresh.is_empty() {
65 +
            return Ok(());
66 +
        }
67 +
        for (h, m) in self.repo.metas_of(&fresh)? {
68 +
            self.graph.insert(h, m);
69 +
        }
70 +
        self.loaded.extend(fresh);
71 +
        self.dependents = None;
72 +
        self.tick()
73 +
    }
74 +
75 +
    /// Load every change the repository names: bookmarks, remote
76 +
    /// bookmarks, tags, and the working copy.
77 +
    fn load_all(&mut self) -> Result<()> {
78 +
        if self.all_loaded {
79 +
            return Ok(());
80 +
        }
81 +
        let view = self.repo.view()?;
82 +
        let mut heads: Vec<ChangeHash> = Vec::new();
83 +
        for st in view.bookmarks.values() {
84 +
            heads.extend(self.repo.store().get_state(st)?.heads);
85 +
        }
86 +
        for bookmarks in view.remotes.values() {
87 +
            for st in bookmarks.values() {
88 +
                heads.extend(self.repo.store().get_state(st)?.heads);
89 +
            }
90 +
        }
91 +
        for st in view.tags.values() {
92 +
            heads.extend(self.repo.store().get_state(st)?.heads);
93 +
        }
94 +
        if let Some(wc) = self.working_copy() {
95 +
            heads.push(wc);
96 +
        }
97 +
        heads.sort();
98 +
        heads.dedup();
99 +
        self.ensure(&heads)?;
100 +
        self.all_loaded = true;
101 +
        Ok(())
102 +
    }
103 +
104 +
    fn working_copy(&self) -> Option<ChangeHash> {
105 +
        if self.repo.is_bare() {
106 +
            None
107 +
        } else {
108 +
            self.repo.working_copy().ok().map(|(h, _)| h)
109 +
        }
110 +
    }
111 +
112 +
    fn all(&mut self) -> Result<Set> {
113 +
        self.load_all()?;
114 +
        Ok(self.graph.keys().copied().collect())
115 +
    }
116 +
117 +
    fn dependents_map(&mut self) -> Result<&HashMap<ChangeHash, Vec<ChangeHash>>> {
118 +
        self.load_all()?;
119 +
        if self.dependents.is_none() {
120 +
            let mut map: HashMap<ChangeHash, Vec<ChangeHash>> = HashMap::new();
121 +
            for (h, m) in &self.graph {
122 +
                for d in &m.deps {
123 +
                    map.entry(*d).or_default().push(*h);
124 +
                }
125 +
            }
126 +
            self.dependents = Some(map);
127 +
        }
128 +
        Ok(self.dependents.as_ref().unwrap())
129 +
    }
130 +
131 +
    /// `::X`: the members and their dependency closures.
132 +
    fn ancestors(&mut self, set: &Set) -> Result<Set> {
133 +
        let mut out = set.clone();
134 +
        let mut stack: Vec<ChangeHash> = set.iter().copied().collect();
135 +
        let mut n = 0usize;
136 +
        while let Some(h) = stack.pop() {
137 +
            if let Some(m) = self.graph.get(&h) {
138 +
                for d in &m.deps {
139 +
                    if out.insert(*d) {
140 +
                        stack.push(*d);
141 +
                    }
142 +
                }
143 +
            }
144 +
            n += 1;
145 +
            if n.is_multiple_of(4096) {
146 +
                self.tick()?;
147 +
            }
148 +
        }
149 +
        Ok(out)
150 +
    }
151 +
152 +
    /// `X::`: the members and everything that transitively depends on them.
153 +
    fn descendants(&mut self, set: &Set) -> Result<Set> {
154 +
        let mut out = set.clone();
155 +
        let mut stack: Vec<ChangeHash> = set.iter().copied().collect();
156 +
        let deps = self.dependents_map()?;
157 +
        while let Some(h) = stack.pop() {
158 +
            if let Some(ds) = deps.get(&h) {
159 +
                for d in ds {
160 +
                    if out.insert(*d) {
161 +
                        stack.push(*d);
162 +
                    }
163 +
                }
164 +
            }
165 +
        }
166 +
        Ok(out)
167 +
    }
168 +
169 +
    /// Members no other member depends on, directly or through others.
170 +
    fn heads(&mut self, set: &Set) -> Result<Set> {
171 +
        let mut below: HashSet<ChangeHash> = HashSet::new();
172 +
        let mut stack: Vec<ChangeHash> = set
173 +
            .iter()
174 +
            .filter_map(|h| self.graph.get(h))
175 +
            .flat_map(|m| m.deps.iter().copied())
176 +
            .collect();
177 +
        while let Some(h) = stack.pop() {
178 +
            if below.insert(h) {
179 +
                if let Some(m) = self.graph.get(&h) {
180 +
                    stack.extend(m.deps.iter().copied());
181 +
                }
182 +
            }
183 +
        }
184 +
        Ok(set.iter().copied().filter(|h| !below.contains(h)).collect())
185 +
    }
186 +
187 +
    /// Members that depend on no other member, directly or through others.
188 +
    fn roots(&mut self, set: &Set) -> Result<Set> {
189 +
        let deps = self.dependents_map()?;
190 +
        let mut above: HashSet<ChangeHash> = HashSet::new();
191 +
        let mut stack: Vec<ChangeHash> = set
192 +
            .iter()
193 +
            .filter_map(|h| deps.get(h))
194 +
            .flat_map(|ds| ds.iter().copied())
195 +
            .collect();
196 +
        while let Some(h) = stack.pop() {
197 +
            if above.insert(h) {
198 +
                if let Some(ds) = deps.get(&h) {
199 +
                    stack.extend(ds.iter().copied());
200 +
                }
201 +
            }
202 +
        }
203 +
        Ok(set.iter().copied().filter(|h| !above.contains(h)).collect())
204 +
    }
205 +
206 +
    fn symbol(&mut self, name: &str) -> Result<Set> {
207 +
        if name == "@" {
208 +
            let Some(wc) = self.working_copy() else {
209 +
                return Err(err(
210 +
                    "@ names the working copy, and a bare repository has none",
211 +
                ));
212 +
            };
213 +
            self.ensure(&[wc])?;
214 +
            return Ok([wc].into_iter().collect());
215 +
        }
216 +
        let heads = self.repo.resolve_heads(name)?;
217 +
        self.ensure(&heads)?;
218 +
        Ok(heads.into_iter().collect())
219 +
    }
220 +
221 +
    /// The identity's name and email, if its object is known.
222 +
    fn who(&mut self, key: &KeyId) -> Option<(String, String)> {
223 +
        if let Some(w) = self.names.get(key) {
224 +
            return w.clone();
225 +
        }
226 +
        let w = self.repo.identity(key).ok().map(|id| (id.name, id.email));
227 +
        self.names.insert(*key, w.clone());
228 +
        w
229 +
    }
230 +
231 +
    /// The set a predicate ranges over: what it is intersected with when
232 +
    /// that is known, else everything.
233 +
    fn universe(&mut self, within: Option<&Set>) -> Result<Set> {
234 +
        match within {
235 +
            Some(s) => Ok(s.clone()),
236 +
            None => self.all(),
237 +
        }
238 +
    }
239 +
240 +
    fn filter_metas(
241 +
        &mut self,
242 +
        within: Option<&Set>,
243 +
        mut keep: impl FnMut(&ChangeHash, &ChangeMeta) -> bool,
244 +
    ) -> Result<Set> {
245 +
        let candidates = self.universe(within)?;
246 +
        let mut out = Set::new();
247 +
        for (i, h) in candidates.iter().enumerate() {
248 +
            if let Some(m) = self.graph.get(h) {
249 +
                if keep(h, m) {
250 +
                    out.insert(*h);
251 +
                }
252 +
            }
253 +
            if i % 8192 == 8191 {
254 +
                self.tick()?;
255 +
            }
256 +
        }
257 +
        Ok(out)
258 +
    }
259 +
260 +
    /// Changes touching any node whose path matches `glob` in the
261 +
    /// evaluation state: the working copy's base, or the bookmark's state
262 +
    /// in a bare repository. Nodes keep their identity through renames.
263 +
    fn touches(&mut self, within: Option<&Set>, glob: &str) -> Result<Set> {
264 +
        let matcher = globset::GlobBuilder::new(glob)
265 +
            .literal_separator(false)
266 +
            .build()
267 +
            .map_err(|e| err(format!("touches({glob:?}): {e}")))?
268 +
            .compile_matcher();
269 +
        let heads = match self.working_copy() {
270 +
            Some(wc) => self
271 +
                .graph
272 +
                .get(&wc)
273 +
                .map(|m| m.deps.clone())
274 +
                .or_else(|| self.repo.working_copy().ok().map(|(_, c)| c.deps))
275 +
                .unwrap_or_default(),
276 +
            None => self.repo.bookmark_heads()?,
277 +
        };
278 +
        let state = self.repo.load_state(&heads)?;
279 +
        let nodes: HashSet<NodeId> = state
280 +
            .tree()
281 +
            .view()
282 +
            .entries
283 +
            .iter()
284 +
            .filter(|(path, _)| matcher.is_match(path))
285 +
            .map(|(_, e)| e.node)
286 +
            .collect();
287 +
        if nodes.is_empty() {
288 +
            return Ok(Set::new());
289 +
        }
290 +
        let candidates = self.universe(within)?;
291 +
        let mut out = Set::new();
292 +
        for (i, h) in candidates.iter().enumerate() {
293 +
            let Ok(c) = self.repo.store().get_change(h) else {
294 +
                continue;
295 +
            };
296 +
            if c.tree_ops.iter().any(|o| nodes.contains(&o.node()))
297 +
                || c.edits.iter().any(|e| nodes.contains(&e.node()))
298 +
            {
299 +
                out.insert(*h);
300 +
            }
301 +
            if i % 256 == 255 {
302 +
                self.tick()?;
303 +
            }
304 +
        }
305 +
        Ok(out)
306 +
    }
307 +
308 +
    fn text_arg<'e>(&self, name: &str, args: &'e [Expr], i: usize) -> Result<&'e str> {
309 +
        match args.get(i) {
310 +
            Some(Expr::Symbol(s)) => Ok(s),
311 +
            _ => Err(err(format!("{name}() takes text, quoted if it has spaces"))),
312 +
        }
313 +
    }
314 +
315 +
    fn arity(&self, name: &str, args: &[Expr], n: usize) -> Result<()> {
316 +
        if args.len() != n {
317 +
            return Err(err(format!(
318 +
                "{name}() takes {n} argument{}, not {}",
319 +
                if n == 1 { "" } else { "s" },
320 +
                args.len()
321 +
            )));
322 +
        }
323 +
        Ok(())
324 +
    }
325 +
326 +
    fn call(&mut self, name: &str, args: &[Expr], within: Option<&Set>) -> Result<Set> {
327 +
        match name {
328 +
            "all" => {
329 +
                self.arity(name, args, 0)?;
330 +
                self.universe(within)
331 +
            }
332 +
            "none" => {
333 +
                self.arity(name, args, 0)?;
334 +
                Ok(Set::new())
335 +
            }
336 +
            "deps" => {
337 +
                self.arity(name, args, 1)?;
338 +
                let x = self.eval(&args[0], None)?;
339 +
                let mut out = Set::new();
340 +
                for h in &x {
341 +
                    if let Some(m) = self.graph.get(h) {
342 +
                        out.extend(m.deps.iter().copied());
343 +
                    }
344 +
                }
345 +
                Ok(out)
346 +
            }
347 +
            "dependents" => {
348 +
                self.arity(name, args, 1)?;
349 +
                let x = self.eval(&args[0], None)?;
350 +
                let deps = self.dependents_map()?;
351 +
                let mut out = Set::new();
352 +
                for h in &x {
353 +
                    if let Some(ds) = deps.get(h) {
354 +
                        out.extend(ds.iter().copied());
355 +
                    }
356 +
                }
357 +
                Ok(out)
358 +
            }
359 +
            "heads" => {
360 +
                self.arity(name, args, 1)?;
361 +
                let x = self.eval(&args[0], None)?;
362 +
                self.heads(&x)
363 +
            }
364 +
            "roots" => {
365 +
                self.arity(name, args, 1)?;
366 +
                let x = self.eval(&args[0], None)?;
367 +
                self.roots(&x)
368 +
            }
369 +
            "latest" => {
370 +
                self.arity(name, args, 2)?;
371 +
                let x = self.eval(&args[0], None)?;
372 +
                let n: usize = self
373 +
                    .text_arg(name, args, 1)?
374 +
                    .parse()
375 +
                    .map_err(|_| err("latest(X, n) takes a count"))?;
376 +
                let mut members: Vec<ChangeHash> = x.into_iter().collect();
377 +
                self.sort_newest_first(&mut members);
378 +
                Ok(members.into_iter().take(n).collect())
379 +
            }
380 +
            "author" => {
381 +
                self.arity(name, args, 1)?;
382 +
                let who = self.text_arg(name, args, 0)?.to_string();
383 +
                let me = self.repo.config.author_key;
384 +
                let needle = who.to_lowercase();
385 +
                let mut matches: HashMap<KeyId, bool> = HashMap::new();
386 +
                self.filter_metas_with(within, |ev, _, m| {
387 +
                    if let Some(hit) = matches.get(&m.author) {
388 +
                        return *hit;
389 +
                    }
390 +
                    let hit = (who == "me" && Some(m.author) == me)
391 +
                        || m.author.starts_with_hex(&who)
392 +
                        || ev.who(&m.author).is_some_and(|(n, e)| {
393 +
                            n.to_lowercase().contains(&needle) || e.to_lowercase().contains(&needle)
394 +
                        });
395 +
                    matches.insert(m.author, hit);
396 +
                    hit
397 +
                })
398 +
            }
399 +
            "description" => {
400 +
                self.arity(name, args, 1)?;
401 +
                let text = self.text_arg(name, args, 0)?.to_string();
402 +
                if text.starts_with("re:") {
403 +
                    return Err(err(
404 +
                        "description(re:…) is not supported yet; give a substring",
405 +
                    ));
406 +
                }
407 +
                let needle = text.to_lowercase();
408 +
                self.filter_metas(within, |_, m| m.message.to_lowercase().contains(&needle))
409 +
            }
410 +
            "date" => {
411 +
                self.arity(name, args, 1)?;
412 +
                let range = revset::parse_date_range(self.text_arg(name, args, 0)?, self.now_ms)
413 +
                    .map_err(err)?;
414 +
                self.filter_metas(within, |_, m| range.contains(m.hlc.wall_ms))
415 +
            }
416 +
            "kind" => {
417 +
                self.arity(name, args, 1)?;
418 +
                let kind = match self.text_arg(name, args, 0)? {
419 +
                    "normal" => ChangeKind::Normal,
420 +
                    "resolution" => ChangeKind::Resolution,
421 +
                    "working-copy" | "working_copy" => ChangeKind::WorkingCopy,
422 +
                    other => {
423 +
                        return Err(err(format!(
424 +
                            "kind({other:?}): use normal, resolution or working-copy"
425 +
                        )))
426 +
                    }
427 +
                };
428 +
                self.filter_metas(within, |_, m| m.kind == kind)
429 +
            }
430 +
            "touches" => {
431 +
                self.arity(name, args, 1)?;
432 +
                let glob = self.text_arg(name, args, 0)?.to_string();
433 +
                self.touches(within, &glob)
434 +
            }
435 +
            other => {
436 +
                // A saved revset: `oxy config revset.NAME 'EXPR'` makes NAME().
437 +
                if let Some(saved) = self.repo.config.revsets.get(other).cloned() {
438 +
                    self.arity(name, args, 0)?;
439 +
                    if self.depth >= 16 {
440 +
                        return Err(err(format!("revset.{other} refers to itself")));
441 +
                    }
442 +
                    let e = revset::parse(&saved)
443 +
                        .map_err(|e| err(format!("revset.{other} = {saved:?}: {e}")))?;
444 +
                    self.depth += 1;
445 +
                    let r = self.eval(&e, within);
446 +
                    self.depth -= 1;
447 +
                    return r;
448 +
                }
449 +
                Err(err(format!(
450 +
                    "unknown function {other}(); see docs/revset_query_language.md"
451 +
                )))
452 +
            }
453 +
        }
454 +
    }
455 +
456 +
    /// `filter_metas` for a test that needs the evaluator (identity lookups).
457 +
    fn filter_metas_with(
458 +
        &mut self,
459 +
        within: Option<&Set>,
460 +
        mut keep: impl FnMut(&mut Eval<'a>, &ChangeHash, &ChangeMeta) -> bool,
461 +
    ) -> Result<Set> {
462 +
        let candidates = self.universe(within)?;
463 +
        let mut out = Set::new();
464 +
        for (i, h) in candidates.iter().enumerate() {
465 +
            if let Some(m) = self.graph.get(h).cloned() {
466 +
                if keep(self, h, &m) {
467 +
                    out.insert(*h);
468 +
                }
469 +
            }
470 +
            if i % 8192 == 8191 {
471 +
                self.tick()?;
472 +
            }
473 +
        }
474 +
        Ok(out)
475 +
    }
476 +
477 +
    /// Whether an expression selects by a property, so that evaluating it
478 +
    /// within the other side of an intersection saves scanning everything.
479 +
    fn is_predicate(e: &Expr) -> bool {
480 +
        match e {
481 +
            Expr::Call(name, _) => matches!(
482 +
                name.as_str(),
483 +
                "author" | "description" | "date" | "kind" | "touches" | "all"
484 +
            ),
485 +
            Expr::Intersection(a, b) => Self::is_predicate(a) || Self::is_predicate(b),
486 +
            Expr::Union(a, b) => Self::is_predicate(a) && Self::is_predicate(b),
487 +
            Expr::Difference(a, _) => Self::is_predicate(a),
488 +
            _ => false,
489 +
        }
490 +
    }
491 +
492 +
    fn eval(&mut self, e: &Expr, within: Option<&Set>) -> Result<Set> {
493 +
        self.tick()?;
494 +
        match e {
495 +
            Expr::Symbol(s) => self.symbol(s),
496 +
            Expr::Ancestors(x) => {
497 +
                let x = self.eval(x, None)?;
498 +
                self.ancestors(&x)
499 +
            }
500 +
            Expr::Descendants(x) => {
501 +
                let x = self.eval(x, None)?;
502 +
                self.descendants(&x)
503 +
            }
504 +
            Expr::Between(x, y) => {
505 +
                let x = self.eval(x, None)?;
506 +
                let y = self.eval(y, None)?;
507 +
                let down = self.descendants(&x)?;
508 +
                let up = self.ancestors(&y)?;
509 +
                Ok(down.intersection(&up).copied().collect())
510 +
            }
511 +
            Expr::Range(x, y) => {
512 +
                let x = self.eval(x, None)?;
513 +
                let y = self.eval(y, None)?;
514 +
                let from = self.ancestors(&x)?;
515 +
                let to = self.ancestors(&y)?;
516 +
                Ok(to.difference(&from).copied().collect())
517 +
            }
518 +
            Expr::Union(a, b) => {
519 +
                let mut a = self.eval(a, within)?;
520 +
                a.extend(self.eval(b, within)?);
521 +
                Ok(a)
522 +
            }
523 +
            Expr::Intersection(a, b) => {
524 +
                let (first, second) = if Self::is_predicate(a) && !Self::is_predicate(b) {
525 +
                    (b, a)
526 +
                } else {
527 +
                    (a, b)
528 +
                };
529 +
                let left = self.eval(first, within)?;
530 +
                let right = self.eval(second, Some(&left))?;
531 +
                Ok(left.intersection(&right).copied().collect())
532 +
            }
533 +
            Expr::Difference(a, b) => {
534 +
                let left = self.eval(a, within)?;
535 +
                let right = self.eval(b, Some(&left))?;
536 +
                Ok(left.difference(&right).copied().collect())
537 +
            }
538 +
            Expr::Call(name, args) => self.call(name, args, within),
539 +
        }
540 +
    }
541 +
542 +
    fn sort_newest_first(&self, hashes: &mut [ChangeHash]) {
543 +
        hashes.sort_by(|a, b| {
544 +
            let (ma, mb) = (self.graph.get(a), self.graph.get(b));
545 +
            mb.map(|m| m.hlc).cmp(&ma.map(|m| m.hlc)).then(b.cmp(a))
546 +
        });
547 +
    }
548 +
}
549 +
550 +
impl Repo {
551 +
    /// Evaluate a revset within [`REVSET_BUDGET`].
552 +
    pub fn revset(&self, src: &str) -> Result<Revset> {
553 +
        self.revset_with_budget(src, REVSET_BUDGET)
554 +
    }
555 +
556 +
    /// Evaluate a revset: the changes it selects, newest first, with
557 +
    /// their metadata. Read-only. Fails past `budget`.
558 +
    pub fn revset_with_budget(&self, src: &str, budget: Duration) -> Result<Revset> {
559 +
        let expr = revset::parse(src).map_err(|e| err(format!("revset {src:?}: {e}")))?;
560 +
        let mut ev = Eval {
561 +
            repo: self,
562 +
            graph: HashMap::new(),
563 +
            loaded: HashSet::new(),
564 +
            all_loaded: false,
565 +
            dependents: None,
566 +
            names: HashMap::new(),
567 +
            deadline: Instant::now() + budget,
568 +
            budget,
569 +
            now_ms: Hlc::now().wall_ms,
570 +
            depth: 0,
571 +
        };
572 +
        let set = ev.eval(&expr, None)?;
573 +
        let mut hashes: Vec<ChangeHash> = set.into_iter().collect();
574 +
        ev.sort_newest_first(&mut hashes);
575 +
        let metas = hashes
576 +
            .iter()
577 +
            .filter_map(|h| ev.graph.get(h).map(|m| (*h, m.clone())))
578 +
            .collect();
579 +
        Ok(Revset { hashes, metas })
580 +
    }
581 +
582 +
    /// The log entries a revset selects, newest first. A head of the
583 +
    /// current bookmark is marked as in `log`.
584 +
    pub fn log_revset(&self, src: &str) -> Result<Vec<LogEntry>> {
585 +
        let r = self.revset(src)?;
586 +
        let heads: BTreeSet<ChangeHash> = self.bookmark_heads()?.into_iter().collect();
587 +
        let wc = if self.is_bare() {
588 +
            None
589 +
        } else {
590 +
            self.working_copy().ok().map(|(h, _)| h)
591 +
        };
592 +
        let mut entries: Vec<LogEntry> = r
593 +
            .hashes
594 +
            .iter()
595 +
            .filter_map(|h| {
596 +
                r.metas.get(h).map(|m| LogEntry {
597 +
                    hash: *h,
598 +
                    change: m.clone(),
599 +
                    is_working_copy: Some(*h) == wc,
600 +
                    is_head: heads.contains(h),
601 +
                })
602 +
            })
603 +
            .collect();
604 +
        // The working copy first, as `log` shows it.
605 +
        entries.sort_by_key(|e| !e.is_working_copy);
606 +
        Ok(entries)
607 +
    }
608 +
}
added crates/oxy-core/src/revset.rs +603 −0
1 +
//! Revsets: expressions that select a set of changes (docs/revset_query_language.md).
2 +
//!
3 +
//! This module parses the language; [`crate::repo::Repo::revset`] evaluates
4 +
//! it over a repository. Grammar, from loosest to tightest:
5 +
//!
6 +
//! ```text
7 +
//! union      = inter ('|' inter)*
8 +
//! inter      = diff ('&' diff)*
9 +
//! diff       = range ('~' range)*            -- set difference, as in Jujutsu
10 +
//! range      = '::' primary | primary '::' primary? | primary '..' primary? | '..' primary | primary
11 +
//! primary    = '(' union ')' | name '(' args ')' | symbol | string
12 +
//! ```
13 +
//!
14 +
//! A symbol is a bookmark, `REMOTE/NAME`, a tag, a ChangeId prefix, a
15 +
//! `#hash` prefix or `@`; a quoted string is a function argument.
16 +
17 +
use std::fmt;
18 +
19 +
#[derive(Clone, Debug, PartialEq, Eq)]
20 +
pub enum Expr {
21 +
    /// A bookmark, tag, change prefix or `@`: the heads it names.
22 +
    Symbol(String),
23 +
    /// `::X`: X and its dependency closure.
24 +
    Ancestors(Box<Expr>),
25 +
    /// `X::`: X and everything depending on it.
26 +
    Descendants(Box<Expr>),
27 +
    /// `X::Y`: in both `X::` and `::Y`.
28 +
    Between(Box<Expr>, Box<Expr>),
29 +
    /// `X..Y`: `::Y ~ ::X`.
30 +
    Range(Box<Expr>, Box<Expr>),
31 +
    Union(Box<Expr>, Box<Expr>),
32 +
    Intersection(Box<Expr>, Box<Expr>),
33 +
    Difference(Box<Expr>, Box<Expr>),
34 +
    /// `name(args)`; a string argument is an `Expr::Symbol` holding its text.
35 +
    Call(String, Vec<Expr>),
36 +
}
37 +
38 +
impl fmt::Display for Expr {
39 +
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40 +
        match self {
41 +
            Expr::Symbol(s) => {
42 +
                if s.is_empty() || s.chars().any(|c| !is_symbol_char(c)) {
43 +
                    write!(f, "{s:?}")
44 +
                } else {
45 +
                    f.write_str(s)
46 +
                }
47 +
            }
48 +
            Expr::Ancestors(x) => write!(f, "::{x}"),
49 +
            Expr::Descendants(x) => write!(f, "{x}::"),
50 +
            Expr::Between(x, y) => write!(f, "{x}::{y}"),
51 +
            Expr::Range(x, y) => write!(f, "{x}..{y}"),
52 +
            Expr::Union(x, y) => write!(f, "({x} | {y})"),
53 +
            Expr::Intersection(x, y) => write!(f, "({x} & {y})"),
54 +
            Expr::Difference(x, y) => write!(f, "({x} ~ {y})"),
55 +
            Expr::Call(name, args) => {
56 +
                write!(f, "{name}(")?;
57 +
                for (i, a) in args.iter().enumerate() {
58 +
                    if i > 0 {
59 +
                        f.write_str(", ")?;
60 +
                    }
61 +
                    write!(f, "{a}")?;
62 +
                }
63 +
                f.write_str(")")
64 +
            }
65 +
        }
66 +
    }
67 +
}
68 +
69 +
#[derive(Clone, Debug, PartialEq, Eq)]
70 +
pub struct ParseError {
71 +
    pub at: usize,
72 +
    pub message: String,
73 +
}
74 +
75 +
impl fmt::Display for ParseError {
76 +
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77 +
        write!(f, "{} (at character {})", self.message, self.at + 1)
78 +
    }
79 +
}
80 +
81 +
impl std::error::Error for ParseError {}
82 +
83 +
#[derive(Clone, Debug, PartialEq, Eq)]
84 +
enum Tok {
85 +
    Symbol(String),
86 +
    Str(String),
87 +
    ColonColon,
88 +
    DotDot,
89 +
    Tilde,
90 +
    Amp,
91 +
    Pipe,
92 +
    LParen,
93 +
    RParen,
94 +
    Comma,
95 +
}
96 +
97 +
fn is_symbol_char(c: char) -> bool {
98 +
    c.is_alphanumeric() || matches!(c, '_' | '.' | '/' | '@' | '#' | '-' | '+')
99 +
}
100 +
101 +
fn lex(src: &str) -> Result<Vec<(usize, Tok)>, ParseError> {
102 +
    let mut out = Vec::new();
103 +
    let chars: Vec<(usize, char)> = src.char_indices().collect();
104 +
    let mut i = 0;
105 +
    while i < chars.len() {
106 +
        let (at, c) = chars[i];
107 +
        match c {
108 +
            c if c.is_whitespace() => i += 1,
109 +
            ':' => {
110 +
                if chars.get(i + 1).map(|x| x.1) == Some(':') {
111 +
                    out.push((at, Tok::ColonColon));
112 +
                    i += 2;
113 +
                } else {
114 +
                    return Err(ParseError {
115 +
                        at,
116 +
                        message: "expected `::`".into(),
117 +
                    });
118 +
                }
119 +
            }
120 +
            '.' if chars.get(i + 1).map(|x| x.1) == Some('.') => {
121 +
                out.push((at, Tok::DotDot));
122 +
                i += 2;
123 +
            }
124 +
            '~' => {
125 +
                out.push((at, Tok::Tilde));
126 +
                i += 1;
127 +
            }
128 +
            '&' => {
129 +
                out.push((at, Tok::Amp));
130 +
                i += 1;
131 +
            }
132 +
            '|' => {
133 +
                out.push((at, Tok::Pipe));
134 +
                i += 1;
135 +
            }
136 +
            '(' => {
137 +
                out.push((at, Tok::LParen));
138 +
                i += 1;
139 +
            }
140 +
            ')' => {
141 +
                out.push((at, Tok::RParen));
142 +
                i += 1;
143 +
            }
144 +
            ',' => {
145 +
                out.push((at, Tok::Comma));
146 +
                i += 1;
147 +
            }
148 +
            '"' | '\'' => {
149 +
                let quote = c;
150 +
                let mut s = String::new();
151 +
                let mut j = i + 1;
152 +
                loop {
153 +
                    match chars.get(j) {
154 +
                        None => {
155 +
                            return Err(ParseError {
156 +
                                at,
157 +
                                message: "unterminated string".into(),
158 +
                            })
159 +
                        }
160 +
                        Some((_, ch)) if *ch == quote => break,
161 +
                        Some((_, '\\')) => {
162 +
                            if let Some((_, next)) = chars.get(j + 1) {
163 +
                                s.push(*next);
164 +
                                j += 2;
165 +
                            } else {
166 +
                                j += 1;
167 +
                            }
168 +
                        }
169 +
                        Some((_, ch)) => {
170 +
                            s.push(*ch);
171 +
                            j += 1;
172 +
                        }
173 +
                    }
174 +
                }
175 +
                out.push((at, Tok::Str(s)));
176 +
                i = j + 1;
177 +
            }
178 +
            c if is_symbol_char(c) => {
179 +
                let mut s = String::new();
180 +
                let mut j = i;
181 +
                // `..` ends a symbol: `main..feature`.
182 +
                while let Some((_, ch)) = chars.get(j) {
183 +
                    if !is_symbol_char(*ch) {
184 +
                        break;
185 +
                    }
186 +
                    if *ch == '.' && chars.get(j + 1).map(|x| x.1) == Some('.') {
187 +
                        break;
188 +
                    }
189 +
                    s.push(*ch);
190 +
                    j += 1;
191 +
                }
192 +
                out.push((at, Tok::Symbol(s)));
193 +
                i = j;
194 +
            }
195 +
            other => {
196 +
                return Err(ParseError {
197 +
                    at,
198 +
                    message: format!("unexpected character {other:?}"),
199 +
                })
200 +
            }
201 +
        }
202 +
    }
203 +
    Ok(out)
204 +
}
205 +
206 +
struct Parser {
207 +
    src: String,
208 +
    toks: Vec<(usize, Tok)>,
209 +
    pos: usize,
210 +
    end: usize,
211 +
}
212 +
213 +
/// Functions whose argument is taken as written, not parsed: a date range
214 +
/// such as `-7d..` would otherwise read as a revset range.
215 +
const RAW_ARGS: [&str; 1] = ["date"];
216 +
217 +
impl Parser {
218 +
    fn peek(&self) -> Option<&Tok> {
219 +
        self.toks.get(self.pos).map(|t| &t.1)
220 +
    }
221 +
    fn at(&self) -> usize {
222 +
        self.toks.get(self.pos).map(|t| t.0).unwrap_or(self.end)
223 +
    }
224 +
    fn eat(&mut self, t: &Tok) -> bool {
225 +
        if self.peek() == Some(t) {
226 +
            self.pos += 1;
227 +
            true
228 +
        } else {
229 +
            false
230 +
        }
231 +
    }
232 +
    fn err<T>(&self, message: impl Into<String>) -> Result<T, ParseError> {
233 +
        Err(ParseError {
234 +
            at: self.at(),
235 +
            message: message.into(),
236 +
        })
237 +
    }
238 +
239 +
    fn union(&mut self) -> Result<Expr, ParseError> {
240 +
        let mut left = self.inter()?;
241 +
        while self.eat(&Tok::Pipe) {
242 +
            let right = self.inter()?;
243 +
            left = Expr::Union(Box::new(left), Box::new(right));
244 +
        }
245 +
        Ok(left)
246 +
    }
247 +
248 +
    fn inter(&mut self) -> Result<Expr, ParseError> {
249 +
        let mut left = self.diff()?;
250 +
        while self.eat(&Tok::Amp) {
251 +
            let right = self.diff()?;
252 +
            left = Expr::Intersection(Box::new(left), Box::new(right));
253 +
        }
254 +
        Ok(left)
255 +
    }
256 +
257 +
    fn diff(&mut self) -> Result<Expr, ParseError> {
258 +
        let mut left = self.range()?;
259 +
        while self.eat(&Tok::Tilde) {
260 +
            let right = self.range()?;
261 +
            left = Expr::Difference(Box::new(left), Box::new(right));
262 +
        }
263 +
        Ok(left)
264 +
    }
265 +
266 +
    /// Whether the next token starts a primary expression.
267 +
    fn starts_primary(&self) -> bool {
268 +
        matches!(
269 +
            self.peek(),
270 +
            Some(Tok::Symbol(_) | Tok::Str(_) | Tok::LParen)
271 +
        )
272 +
    }
273 +
274 +
    fn range(&mut self) -> Result<Expr, ParseError> {
275 +
        if self.eat(&Tok::ColonColon) {
276 +
            let x = self.primary()?;
277 +
            return Ok(Expr::Ancestors(Box::new(x)));
278 +
        }
279 +
        if self.eat(&Tok::DotDot) {
280 +
            let y = self.primary()?;
281 +
            return Ok(Expr::Ancestors(Box::new(y)));
282 +
        }
283 +
        let x = self.primary()?;
284 +
        if self.eat(&Tok::ColonColon) {
285 +
            if self.starts_primary() {
286 +
                let y = self.primary()?;
287 +
                return Ok(Expr::Between(Box::new(x), Box::new(y)));
288 +
            }
289 +
            return Ok(Expr::Descendants(Box::new(x)));
290 +
        }
291 +
        if self.eat(&Tok::DotDot) {
292 +
            if self.starts_primary() {
293 +
                let y = self.primary()?;
294 +
                return Ok(Expr::Range(Box::new(x), Box::new(y)));
295 +
            }
296 +
            // `X..`: what the repository has beyond X.
297 +
            return Ok(Expr::Range(
298 +
                Box::new(x),
299 +
                Box::new(Expr::Call("all".into(), vec![])),
300 +
            ));
301 +
        }
302 +
        Ok(x)
303 +
    }
304 +
305 +
    fn primary(&mut self) -> Result<Expr, ParseError> {
306 +
        match self.peek().cloned() {
307 +
            Some(Tok::LParen) => {
308 +
                self.pos += 1;
309 +
                let e = self.union()?;
310 +
                if !self.eat(&Tok::RParen) {
311 +
                    return self.err("expected `)`");
312 +
                }
313 +
                Ok(e)
314 +
            }
315 +
            Some(Tok::Str(s)) => {
316 +
                self.pos += 1;
317 +
                Ok(Expr::Symbol(s))
318 +
            }
319 +
            Some(Tok::Symbol(s)) => {
320 +
                self.pos += 1;
321 +
                if self.eat(&Tok::LParen) {
322 +
                    if RAW_ARGS.contains(&s.as_str()) {
323 +
                        return self.raw_call(s);
324 +
                    }
325 +
                    let mut args = Vec::new();
326 +
                    if !self.eat(&Tok::RParen) {
327 +
                        loop {
328 +
                            args.push(self.union()?);
329 +
                            if self.eat(&Tok::Comma) {
330 +
                                continue;
331 +
                            }
332 +
                            if self.eat(&Tok::RParen) {
333 +
                                break;
334 +
                            }
335 +
                            return self.err("expected `,` or `)` in the arguments");
336 +
                        }
337 +
                    }
338 +
                    return Ok(Expr::Call(s, args));
339 +
                }
340 +
                Ok(Expr::Symbol(s))
341 +
            }
342 +
            Some(Tok::RParen) => self.err("unexpected `)`"),
343 +
            Some(_) => self.err("expected a change, bookmark, tag or function here"),
344 +
            None => self.err("the expression ends too soon"),
345 +
        }
346 +
    }
347 +
}
348 +
349 +
impl Parser {
350 +
    /// `name(` has been consumed: the text up to the matching `)` is the
351 +
    /// one argument, or a quoted string is.
352 +
    fn raw_call(&mut self, name: String) -> Result<Expr, ParseError> {
353 +
        if let (Some(Tok::Str(s)), Some(Tok::RParen)) = (
354 +
            self.peek().cloned(),
355 +
            self.toks.get(self.pos + 1).map(|t| &t.1),
356 +
        ) {
357 +
            self.pos += 2;
358 +
            return Ok(Expr::Call(name, vec![Expr::Symbol(s)]));
359 +
        }
360 +
        let start = self.at();
361 +
        let mut depth = 0usize;
362 +
        while let Some(t) = self.peek() {
363 +
            match t {
364 +
                Tok::LParen => depth += 1,
365 +
                Tok::RParen if depth == 0 => {
366 +
                    let end = self.at();
367 +
                    self.pos += 1;
368 +
                    let raw = self.src[start..end].trim().to_string();
369 +
                    return Ok(Expr::Call(name, vec![Expr::Symbol(raw)]));
370 +
                }
371 +
                Tok::RParen => depth -= 1,
372 +
                _ => {}
373 +
            }
374 +
            self.pos += 1;
375 +
        }
376 +
        self.err(format!("expected `)` to end {name}(…)"))
377 +
    }
378 +
}
379 +
380 +
/// Parse a revset expression.
381 +
pub fn parse(src: &str) -> Result<Expr, ParseError> {
382 +
    let toks = lex(src)?;
383 +
    if toks.is_empty() {
384 +
        return Err(ParseError {
385 +
            at: 0,
386 +
            message: "empty revset".into(),
387 +
        });
388 +
    }
389 +
    let mut p = Parser {
390 +
        src: src.to_string(),
391 +
        toks,
392 +
        pos: 0,
393 +
        end: src.len(),
394 +
    };
395 +
    let e = p.union()?;
396 +
    if p.pos < p.toks.len() {
397 +
        return p.err("unexpected input after the expression");
398 +
    }
399 +
    Ok(e)
400 +
}
401 +
402 +
/// A date range for `date(RANGE)`: wall-clock milliseconds, inclusive at
403 +
/// both ends, either end open.
404 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
405 +
pub struct DateRange {
406 +
    pub from: Option<u64>,
407 +
    pub to: Option<u64>,
408 +
}
409 +
410 +
impl DateRange {
411 +
    pub fn contains(&self, ms: u64) -> bool {
412 +
        self.from.is_none_or(|f| ms >= f) && self.to.is_none_or(|t| ms <= t)
413 +
    }
414 +
}
415 +
416 +
/// `2026-10-01..`, `..2026-09-30`, `2026-09-01..2026-09-30`, `-7d..`,
417 +
/// `-12h..-1h`, or a single point, which means that day (or, for a
418 +
/// relative time, from then on). Dates are UTC; a time of day may follow
419 +
/// as `T13:45` or `T13:45:00`, with an optional `Z`.
420 +
pub fn parse_date_range(s: &str, now_ms: u64) -> Result<DateRange, String> {
421 +
    let s = s.trim();
422 +
    let (a, b, dotted) = match s.find("..") {
423 +
        Some(i) => (&s[..i], &s[i + 2..], true),
424 +
        None => (s, "", false),
425 +
    };
426 +
    let from = if a.is_empty() {
427 +
        None
428 +
    } else {
429 +
        Some(parse_point(a, now_ms)?.0)
430 +
    };
431 +
    let to = if dotted {
432 +
        if b.is_empty() {
433 +
            None
434 +
        } else {
435 +
            Some(parse_point(b, now_ms)?.1)
436 +
        }
437 +
    } else if a.starts_with('-') {
438 +
        None
439 +
    } else {
440 +
        Some(parse_point(a, now_ms)?.1)
441 +
    };
442 +
    if let (Some(f), Some(t)) = (from, to) {
443 +
        if f > t {
444 +
            return Err(format!("date range {s:?} ends before it starts"));
445 +
        }
446 +
    }
447 +
    Ok(DateRange { from, to })
448 +
}
449 +
450 +
/// A point in time as the (start, end) of what it names: a day spans its
451 +
/// 24 hours, a minute its 60 seconds, a relative time is an instant.
452 +
fn parse_point(s: &str, now_ms: u64) -> Result<(u64, u64), String> {
453 +
    let s = s.trim();
454 +
    if let Some(rel) = s.strip_prefix('-') {
455 +
        let (n, unit) = rel.split_at(rel.find(|c: char| !c.is_ascii_digit()).unwrap_or(rel.len()));
456 +
        let n: u64 = n
457 +
            .parse()
458 +
            .map_err(|_| format!("{s:?}: a relative time is -7d, -12h or -30m"))?;
459 +
        let ms = match unit {
460 +
            "d" => n * 86_400_000,
461 +
            "h" => n * 3_600_000,
462 +
            "m" => n * 60_000,
463 +
            "w" => n * 7 * 86_400_000,
464 +
            _ => return Err(format!("{s:?}: a relative time is -7d, -12h or -30m")),
465 +
        };
466 +
        let at = now_ms.saturating_sub(ms);
467 +
        return Ok((at, at));
468 +
    }
469 +
    let (date, time) = match s.split_once('T') {
470 +
        Some((d, t)) => (d, Some(t.trim_end_matches('Z'))),
471 +
        None => (s, None),
472 +
    };
473 +
    let mut parts = date.split('-');
474 +
    let bad = || format!("{s:?}: a date is YYYY-MM-DD, with an optional THH:MM[:SS]");
475 +
    let y: i64 = parts.next().and_then(|p| p.parse().ok()).ok_or_else(bad)?;
476 +
    let m: i64 = parts.next().and_then(|p| p.parse().ok()).ok_or_else(bad)?;
477 +
    let d: i64 = parts.next().and_then(|p| p.parse().ok()).ok_or_else(bad)?;
478 +
    if parts.next().is_some() || !(1..=12).contains(&m) || !(1..=31).contains(&d) {
479 +
        return Err(bad());
480 +
    }
481 +
    let day_ms = days_from_civil(y, m, d) * 86_400_000;
482 +
    if day_ms < 0 {
483 +
        return Err(bad());
484 +
    }
485 +
    let day_ms = day_ms as u64;
486 +
    match time {
487 +
        None => Ok((day_ms, day_ms + 86_400_000 - 1)),
488 +
        Some(t) => {
489 +
            let mut hms = t.split(':');
490 +
            let h: u64 = hms.next().and_then(|p| p.parse().ok()).ok_or_else(bad)?;
491 +
            let mi: u64 = hms.next().and_then(|p| p.parse().ok()).ok_or_else(bad)?;
492 +
            let sec: Option<u64> = match hms.next() {
493 +
                Some(p) => Some(p.parse().map_err(|_| bad())?),
494 +
                None => None,
495 +
            };
496 +
            if hms.next().is_some() || h > 23 || mi > 59 || sec.is_some_and(|x| x > 59) {
497 +
                return Err(bad());
498 +
            }
499 +
            let start = day_ms + h * 3_600_000 + mi * 60_000 + sec.unwrap_or(0) * 1000;
500 +
            let span = if sec.is_some() { 1000 } else { 60_000 };
501 +
            Ok((start, start + span - 1))
502 +
        }
503 +
    }
504 +
}
505 +
506 +
/// Days since 1970-01-01 of a civil date (Howard Hinnant's algorithm).
507 +
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
508 +
    let y = if m <= 2 { y - 1 } else { y };
509 +
    let era = y.div_euclid(400);
510 +
    let yoe = y - era * 400;
511 +
    let mp = (m + 9) % 12;
512 +
    let doy = (153 * mp + 2) / 5 + d - 1;
513 +
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
514 +
    era * 146_097 + doe - 719_468
515 +
}
516 +
517 +
#[cfg(test)]
518 +
mod tests {
519 +
    use super::*;
520 +
521 +
    fn p(s: &str) -> String {
522 +
        parse(s).unwrap().to_string()
523 +
    }
524 +
525 +
    #[test]
526 +
    fn precedence_and_ranges() {
527 +
        assert_eq!(p("a | b & c ~ d"), "(a | (b & (c ~ d)))");
528 +
        assert_eq!(p("a ~ b & c"), "((a ~ b) & c)");
529 +
        assert_eq!(p("(a | b) & c"), "((a | b) & c)");
530 +
        assert_eq!(p("::main"), "::main");
531 +
        assert_eq!(p("main::"), "main::");
532 +
        assert_eq!(p("a::b"), "a::b");
533 +
        assert_eq!(p("main..feature"), "main..feature");
534 +
        assert_eq!(p("..feature"), "::feature");
535 +
        assert_eq!(p("main.."), "main..all()");
536 +
        assert_eq!(p("origin/main..@"), "origin/main..@");
537 +
        assert_eq!(p("#fc68459c & v0.3.1"), "(#fc68459c & v0.3.1)");
538 +
        assert_eq!(p("a ~ b ~ c"), "((a ~ b) ~ c)");
539 +
    }
540 +
541 +
    #[test]
542 +
    fn calls_and_strings() {
543 +
        assert_eq!(
544 +
            p("touches(\"src/**\") & date(-7d..)"),
545 +
            "(touches(\"src/**\") & date(-7d..))"
546 +
        );
547 +
        assert_eq!(p("author('Andy McCall')"), "author(\"Andy McCall\")");
548 +
        assert_eq!(p("latest(main.., 5)"), "latest(main..all(), 5)");
549 +
        assert_eq!(
550 +
            p("date(2026-09-01..2026-09-30)"),
551 +
            "date(2026-09-01..2026-09-30)"
552 +
        );
553 +
        assert_eq!(p("date(\"..-1h\")"), "date(..-1h)");
554 +
        assert_eq!(p("all() ~ ::main"), "(all() ~ ::main)");
555 +
        assert_eq!(p("heads(a | b)"), "heads((a | b))");
556 +
        assert_eq!(p("none()"), "none()");
557 +
    }
558 +
559 +
    #[test]
560 +
    fn errors_point_at_the_problem() {
561 +
        assert!(parse("").is_err());
562 +
        assert_eq!(parse("a &").unwrap_err().at, 3);
563 +
        assert!(parse("a & )").is_err());
564 +
        assert!(parse("f(a, ").is_err());
565 +
        assert!(parse("'open").is_err());
566 +
        assert!(parse("a : b").is_err());
567 +
        assert!(parse("a b").is_err());
568 +
    }
569 +
570 +
    #[test]
571 +
    fn dates() {
572 +
        let now = 1_790_000_000_000; // 2026-09-21T06:13:20Z
573 +
        let r = parse_date_range("2026-09-01..2026-09-30", now).unwrap();
574 +
        assert_eq!(
575 +
            r.from,
576 +
            Some(days_from_civil(2026, 9, 1) as u64 * 86_400_000)
577 +
        );
578 +
        assert_eq!(
579 +
            r.to,
580 +
            Some(days_from_civil(2026, 9, 30) as u64 * 86_400_000 + 86_399_999)
581 +
        );
582 +
        assert!(r.contains(days_from_civil(2026, 9, 15) as u64 * 86_400_000 + 1));
583 +
        let r = parse_date_range("-7d..", now).unwrap();
584 +
        assert_eq!(
585 +
            r,
586 +
            DateRange {
587 +
                from: Some(now - 7 * 86_400_000),
588 +
                to: None
589 +
            }
590 +
        );
591 +
        let r = parse_date_range("..2026-01-01", now).unwrap();
592 +
        assert!(r.from.is_none() && r.to.is_some());
593 +
        let day = parse_date_range("2026-10-03", now).unwrap();
594 +
        assert_eq!(day.to.unwrap() - day.from.unwrap(), 86_399_999);
595 +
        let minute = parse_date_range("2026-10-03T09:30Z", now).unwrap();
596 +
        assert_eq!(minute.to.unwrap() - minute.from.unwrap(), 59_999);
597 +
        assert!(parse_date_range("2026-13-01", now).is_err());
598 +
        assert!(parse_date_range("yesterday", now).is_err());
599 +
        assert!(parse_date_range("2026-09-30..2026-09-01", now).is_err());
600 +
        assert_eq!(days_from_civil(1970, 1, 1), 0);
601 +
        assert_eq!(days_from_civil(2000, 3, 1), 11_017);
602 +
    }
603 +
}
modified README.md +1 −0
…
36 unchanged lines
37 37
oxy status
38 38
oxy commit -m "First change"
39 39
oxy log                                  # -n 10 for the latest ten, -v for full messages
40 +
oxy log -r 'v0.3.1..main & author(me)'   # a revset: set algebra over history
40 41
```
41 42
42 43
Output uses colour, glyphs, and relative times on a UTF-8 terminal, and
…
58 unchanged lines
modified crates/oxy-core/src/lib.rs +1 −0
…
23 unchanged lines
24 24
pub mod protocol;
25 25
pub mod receipt;
26 26
pub mod repo;
27 +
pub mod revset;
27 28
pub mod rewrite;
28 29
pub mod state;
29 30
pub mod store;
…
3 unchanged lines
modified crates/oxy-core/src/repo/config.rs +22 −0
…
34 unchanged lines
35 35
    pub sparse_paths: Vec<String>,
36 36
    /// `hooks.semantic-deps`: run the semantic-deps hook at commit (spec §20).
37 37
    pub semantic_deps: bool,
38 +
    /// `revset.<name>`: a saved revset, used as `name()`.
39 +
    pub revsets: BTreeMap<String, String>,
38 40
}
39 41
40 42
impl Config {
…
16 unchanged lines
57 59
            max_blob: None,
58 60
            sparse_paths: Vec::new(),
59 61
            semantic_deps: false,
62 +
            revsets: BTreeMap::new(),
60 63
        }
61 64
    }
62 65
…
98 unchanged lines
161 164
                    map.insert(name.to_string(), value.into());
162 165
                }
163 166
            }
167 +
            k if k.starts_with("revset.") => {
168 +
                let name = &k["revset.".len()..];
169 +
                if name.is_empty()
170 +
                    || !name
171 +
                        .chars()
172 +
                        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
173 +
                {
174 +
                    return fail(format!(
175 +
                        "a revset's name is letters, digits, _ and -: {key:?}"
176 +
                    ));
177 +
                }
178 +
                if value.is_empty() {
179 +
                    self.revsets.remove(name);
180 +
                } else {
181 +
                    crate::revset::parse(value)
182 +
                        .map_err(|e| RepoError::Msg(format!("{key} = {value:?}: {e}")))?;
183 +
                    self.revsets.insert(name.to_string(), value.into());
184 +
                }
185 +
            }
164 186
            _ => return fail(format!("unknown configuration key {key:?}")),
165 187
        }
166 188
        Ok(())
…
79 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +2 −0
…
78 unchanged lines
79 79
mod policy;
80 80
mod prompt;
81 81
mod receipts;
82 +
mod revset;
82 83
mod snapshot;
83 84
mod subrepos;
84 85
mod sync;
…
18 unchanged lines
103 104
pub use policy::PolicyUpdate;
104 105
pub use prompt::{PromptInfo, DEFAULT_PROMPT_FORMAT, GLYPH_PROMPT_FORMAT};
105 106
pub use receipts::ReceiptInfo;
107 +
pub use revset::{Revset, REVSET_BUDGET};
106 108
pub use snapshot::{
107 109
    added_node_paths, diff_files_of, has_conflict_markers, is_binary, referenced_changes,
108 110
    render_change, render_hunks, similarity, ChangeStatus, DiffHunk, FileDiff, Hunk, PathStatus,
…
2036 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +132 −0
…
6993 unchanged lines
6994 6994
        b"v1\n"
6995 6995
    );
6996 6996
}
6997 +
6998 +
/// Revsets (docs/revset_query_language.md): symbols, relations, set
6999 +
/// operators and the predicates, over a small graph with two heads and
7000 +
/// a rename.
7001 +
#[test]
7002 +
fn revsets_select_changes_by_relation_author_text_date_and_path() {
7003 +
    hermetic();
7004 +
    let dir = tempfile::tempdir().unwrap();
7005 +
    let root = dir.path();
7006 +
    let mut repo = Repo::init(root).unwrap().with_home(root.join("home"));
7007 +
    repo.create_identity("Ada", "[email protected]", IdentityScope::User)
7008 +
        .unwrap();
7009 +
    write(root, "src/a.rs", b"one\n");
7010 +
    let a = repo.commit("first: add a", &[]).unwrap();
7011 +
    write(root, "src/a.rs", b"one\ntwo\n");
7012 +
    let b = repo.commit("second: grow a", &[]).unwrap();
7013 +
    // A second head, independent of b: a new file on top of a.
7014 +
    repo.checkout(&[a], false, "checkout a").unwrap();
7015 +
    write(root, "docs/notes.md", b"notes\n");
7016 +
    let c = repo.commit("third: notes", &[]).unwrap();
7017 +
    // Back to both heads, then rename a.rs: the node keeps its identity.
7018 +
    repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main")
7019 +
        .unwrap();
7020 +
    fs::remove_file(root.join("src/a.rs")).unwrap();
7021 +
    write(root, "src/z.rs", b"one\ntwo\n");
7022 +
    let d = repo.commit("fourth: rename a to z", &[]).unwrap();
7023 +
    let wc = repo.working_copy().unwrap().0;
7024 +
    // A saved revset is checked when set.
7025 +
    repo.set_user_config("revset.mine", "author(me) & ::main")
7026 +
        .unwrap();
7027 +
    assert!(repo.set_user_config("revset.bad", "a &").is_err());
7028 +
    let set = |src: &str| -> Vec<ChangeHash> {
7029 +
        let mut v = repo.revset(src).unwrap().hashes;
7030 +
        v.sort();
7031 +
        v
7032 +
    };
7033 +
    let sorted = |hs: &[ChangeHash]| -> Vec<ChangeHash> {
7034 +
        let mut v = hs.to_vec();
7035 +
        v.sort();
7036 +
        v
7037 +
    };
7038 +
7039 +
    // Changes commute: a new file depends on nothing and the rename on the
7040 +
    // file's creation, so the graph is a, b->a, d->a, c, and the working
7041 +
    // copy on the three heads b, c, d.
7042 +
    let heads = sorted(&repo.bookmark_heads().unwrap());
7043 +
    let meta = |h: &ChangeHash| repo.store().get_change_meta(h).unwrap();
7044 +
    assert!(heads.contains(&d) && heads.contains(&c));
7045 +
    // Symbols and closures.
7046 +
    assert_eq!(set("main"), heads);
7047 +
    assert_eq!(set("::main"), sorted(&[a, b, c, d]));
7048 +
    assert_eq!(set("@"), vec![wc]);
7049 +
    assert_eq!(set("::@"), sorted(&[a, b, c, d, wc]));
7050 +
    assert_eq!(set(&format!("::#{}", b.short(12))), sorted(&[a, b]));
7051 +
    assert_eq!(set(&format!("#{}::", a.short(12))), sorted(&[a, b, d, wc]));
7052 +
    assert_eq!(
7053 +
        set(&format!("#{}::#{}", a.short(12), b.short(12))),
7054 +
        sorted(&[a, b])
7055 +
    );
7056 +
    // Ranges: what main has beyond b, and `..` forms.
7057 +
    assert_eq!(set(&format!("#{}..main", b.short(12))), sorted(&[c, d]));
7058 +
    assert_eq!(set(&format!("#{}..", c.short(12))), sorted(&[a, b, d, wc]));
7059 +
    assert_eq!(set("..main"), sorted(&[a, b, c, d]));
7060 +
    // Operators and precedence: ~ binds tighter than &, & than |.
7061 +
    assert_eq!(
7062 +
        set(&format!("::main ~ #{}", d.short(12))),
7063 +
        sorted(&[a, b, c])
7064 +
    );
7065 +
    assert_eq!(
7066 +
        set(&format!("::main & #{} | #{}", b.short(12), c.short(12))),
7067 +
        sorted(&[b, c])
7068 +
    );
7069 +
    assert_eq!(set("all() ~ ::main"), vec![wc]);
7070 +
    // Relations.
7071 +
    let mut head_deps: Vec<ChangeHash> = heads.iter().flat_map(|h| meta(h).deps).collect();
7072 +
    head_deps.sort();
7073 +
    head_deps.dedup();
7074 +
    assert_eq!(set("deps(main)"), head_deps);
7075 +
    let on_a: Vec<ChangeHash> = [b, c, d]
7076 +
        .into_iter()
7077 +
        .filter(|h| meta(h).deps.contains(&a))
7078 +
        .collect();
7079 +
    assert_eq!(set(&format!("dependents(#{})", a.short(12))), sorted(&on_a));
7080 +
    assert_eq!(set("heads(::main)"), heads);
7081 +
    assert_eq!(set("roots(::main)"), sorted(&[a, c]));
7082 +
    assert_eq!(
7083 +
        set(&format!(
7084 +
            "heads(#{} | #{} | #{})",
7085 +
            a.short(12),
7086 +
            b.short(12),
7087 +
            c.short(12)
7088 +
        )),
7089 +
        sorted(&[b, c])
7090 +
    );
7091 +
    assert_eq!(set("latest(::main, 1)"), vec![d]);
7092 +
    assert_eq!(set("kind(working-copy)"), vec![wc]);
7093 +
    assert_eq!(set("none()"), vec![]);
7094 +
    // Predicates: author by name, email, key prefix and `me`.
7095 +
    let key = repo.config.author_key.unwrap();
7096 +
    assert_eq!(set("author(ada) & ::main"), sorted(&[a, b, c, d]));
7097 +
    assert_eq!(set("author(\"[email protected]\") & ::main").len(), 4);
7098 +
    assert_eq!(set(&format!("author({}) & ::main", key.short(12))).len(), 4);
7099 +
    assert_eq!(set("author(me) & ::main").len(), 4);
7100 +
    assert_eq!(set("author(nobody)"), vec![]);
7101 +
    assert_eq!(set("description(notes)"), vec![c]);
7102 +
    assert_eq!(set("description(\"rename a\")"), vec![d]);
7103 +
    assert_eq!(set("date(-1h..) & ::main").len(), 4);
7104 +
    assert_eq!(set("date(..2000-01-01)"), vec![]);
7105 +
    // touches follows the node through the rename, in the current state.
7106 +
    assert_eq!(set("touches(\"src/z.rs\")"), sorted(&[a, b, d]));
7107 +
    assert_eq!(set("touches(\"src/**\") & ::main"), sorted(&[a, b, d]));
7108 +
    assert_eq!(set("touches(\"docs/*\")"), vec![c]);
7109 +
    assert_eq!(set("touches(\"nothing/*\")"), vec![]);
7110 +
    // Newest first in the result order.
7111 +
    assert_eq!(repo.revset("::main").unwrap().hashes[0], d);
7112 +
    // The saved revset, and errors that say what is wrong.
7113 +
    assert_eq!(set("mine()").len(), 4);
7114 +
    let e = repo.revset("nosuch()").unwrap_err().to_string();
7115 +
    assert!(e.contains("unknown function nosuch()"), "{e}");
7116 +
    let e = repo.revset("main &").unwrap_err().to_string();
7117 +
    assert!(e.contains("at character"), "{e}");
7118 +
    let e = repo.revset("zzzz").unwrap_err().to_string();
7119 +
    assert!(e.contains("no change matches"), "{e}");
7120 +
    let e = repo
7121 +
        .revset_with_budget("::main", std::time::Duration::ZERO)
7122 +
        .unwrap_err()
7123 +
        .to_string();
7124 +
    assert!(e.contains("budget"), "{e}");
7125 +
    // The log entries carry the bookmark's heads and the working copy.
7126 +
    let entries = repo.log_revset("::@").unwrap();
7127 +
    assert!(entries[0].is_working_copy && entries.iter().any(|e| e.is_head && e.hash == d));
7128 +
}
modified docs/guide.md +1 −0
…
60 unchanged lines
61 61
oxy diff                   # the edits, file by file
62 62
oxy commit -m "Add retry loop"
63 63
oxy log                    # newest first; -n 10, -v for full messages
64 +
oxy log -r 'main.. & touches("src/**")'   # a revset (docs/revset_query_language.md)
64 65
oxy show 658990aa          # one change by ChangeId prefix, #hash prefix, or @
65 66
```
66 67
…
547 unchanged lines
modified docs/revset_query_language.md +19 −8
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 1.1 |
6 -
| **Date** | 2026-10-03 |
5 +
| **Status** | Draft 1.2: the core is implemented (`oxy log -r`), trust predicates and `-r` on other commands are next |
6 +
| **Date** | 2026-10-04 |
7 7
| **Audience** | Implementers and tooling developers |
8 8
| **Depends on** | spec.md §6 (changes), §8 (dependencies), §9 (conflicts), §10 (states), §11 (checkpoints), §14 (identity, attestations), §22 (profiles) |
9 9
…
14 unchanged lines
24 24
Oxygen also has things no other system can query: signatures, delegates,
25 25
attestations and build receipts. Section 2.4 adds those.
26 26
27 -
**Today** no command takes `-r`. Commands take a single change (a
28 -
ChangeId prefix, a `#hash` prefix or `@`) or a bookmark or tag name.
29 -
Everything here is new, except that symbols resolve exactly as they do
30 -
today.
27 +
**Implemented (2026-10-04, oxygen#65):** `oxy log -r REVSET` with the
28 +
symbols, relations, operators, `author`, `description` (substring),
29 +
`date`, `kind`, `touches` and `latest` below, saved revsets, and a
30 +
time budget. Not yet: `independent()`, `conflicts()`, `description(re:…)`,
31 +
the trust predicates of §2.4, `-r` on other commands, and the hub's
32 +
`?r=`. Other commands still take a single change (a ChangeId prefix, a
33 +
`#hash` prefix or `@`) or a bookmark or tag name; symbols in a revset
34 +
resolve exactly as they do there.
31 35
32 36
## 2. Syntax
33 37
…
68 unchanged lines
102 106
103 107
Parentheses group. Strings with spaces or operators are quoted.
104 108
`~` is difference, as in Jujutsu. There is no unary not: write `all() ~ X`.
109 +
`..Y` is `::Y`, and `X..` is everything the repository has beyond `X`.
110 +
A `date()` argument is taken as written, so `date(-7d..)` needs no quotes.
105 111
106 112
## 3. Where revsets are used
107 113
…
7 unchanged lines
115 121
- **The hub API** takes `?r=` on history endpoints, evaluated only over
116 122
  what the caller can read.
117 123
- **Saved revsets:** `oxy config revset.mine 'author(me) & ::@'` lets
118 -
  you write `mine()`.
124 +
  you write `mine()`. `author(me)` is the configured identity.
119 125
120 126
## 4. Evaluation
121 127
…
4 unchanged lines
126 132
  closure being walked.
127 133
- **Checkpoints** stand for a state's bulk without visiting each change,
128 134
  so a closure walk stops at the newest checkpoint it reaches.
129 -
- **Budgets.** Evaluation has a time and size budget, and a query that
135 +
- **Budgets.** Evaluation has a time budget (10 s from the command
136 +
  line; `Repo::revset_with_budget` takes another), and a query that
130 137
  exceeds it fails with a message saying what to narrow. The hub
131 138
  enforces a stricter one, so a query cannot be used to load the
132 139
  server.
140 +
- **Predicates range over `all()`** (every change a bookmark, remote
141 +
  bookmark, tag or the working copy reaches), except that in `X & p()`
142 +
  the predicate is evaluated over `X` alone, so `main.. & touches("src/**")`
143 +
  reads only the changes beyond `main`.
133 144
- **Targets.** A first page of `oxy log -r` for a simple revset should
134 145
  appear in under 100 ms, in line with the existing log target.
135 146
  `touches()` and the trust predicates are slower and are cached by
…
13 unchanged lines
modified src/cli/mod.rs +13 −1
…
151 unchanged lines
152 152
    },
153 153
    /// List changes reachable from the bookmark and the working copy
154 154
    Log {
155 +
        /// Show the changes a revset selects: `main..feature & author(andy)`,
156 +
        /// `touches("src/**") & date(-7d..)` (see docs/revset_query_language.md)
157 +
        #[arg(short = 'r', long, value_name = "REVSET", conflicts_with_all = ["path", "lines"])]
158 +
        revset: Option<String>,
155 159
        /// Show at most this many changes
156 160
        #[arg(short = 'n', long)]
157 161
        limit: Option<usize>,
…
901 unchanged lines
1059 1063
            BisectCmd::Reset => workspace::bisect_reset(),
1060 1064
        },
1061 1065
        Cmd::Log {
1066 +
            revset,
1062 1067
            limit,
1063 1068
            verbose,
1064 1069
            path,
1065 1070
            lines,
1066 1071
            json,
1067 -
        } => workspace::log(limit, verbose, path.as_deref(), lines.as_deref(), json),
1072 +
        } => workspace::log(
1073 +
            revset.as_deref(),
1074 +
            limit,
1075 +
            verbose,
1076 +
            path.as_deref(),
1077 +
            lines.as_deref(),
1078 +
            json,
1079 +
        ),
1068 1080
        Cmd::Diff { change, json } => workspace::diff(change.as_deref(), json),
1069 1081
        Cmd::Describe {
1070 1082
            message,
…
247 unchanged lines
modified src/cli/workspace.rs +3 −1
…
576 unchanged lines
577 577
}
578 578
579 579
pub fn log(
580 +
    revset: Option<&str>,
580 581
    limit: Option<usize>,
581 582
    verbose: bool,
582 583
    path: Option<&str>,
…
9 unchanged lines
592 593
    let conflicted = !repo.is_at_op() && repo.recorded_conflicted()?;
593 594
    let listing = oxy_core::timing::phase("log: entries");
594 595
    let entries = match (path, lines) {
596 +
        _ if revset.is_some() => repo.log_revset(revset.unwrap_or_default())?,
595 597
        (_, Some(spec)) => {
596 598
            let (first, last, p) = parse_line_range(spec)?;
597 599
            let (_, wc) = repo.working_copy()?;
…
33 unchanged lines
631 633
    // Highlight the shortest prefix that names each change: against the
632 634
    // whole log, which is what `resolve_change` searches.
633 635
    let everything;
634 -
    let all = if path.is_some() || lines.is_some() {
636 +
    let all = if path.is_some() || lines.is_some() || revset.is_some() {
635 637
        everything = repo.log()?;
636 638
        &everything
637 639
    } else {
…
1804 unchanged lines