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 |