crates/kernel/ents-query/src/eval.rs
eval.rshistorycomment on this file
| 1 | //! Query evaluation: full sets, incremental entry sets, and work sets |
| 2 | //! (`query.incremental`, `query.monotone`, `query.workset`). |
| 3 | //! |
| 4 | //! # Evaluation model |
| 5 | //! |
| 6 | //! The evaluator is pure over the read half of the ref store plus |
| 7 | //! gitoxide's object-find seam. A [`Transition`] carries both sides of |
| 8 | //! one ref's movement; every internal read goes through a state view |
| 9 | //! that overrides that one ref, so the same evaluator answers "was this |
| 10 | //! commit in the set before?" and "is it now?" against a single store. |
| 11 | //! |
| 12 | //! # Incrementality (`query.incremental`) |
| 13 | //! |
| 14 | //! An entry set is computed from the transition frontier: candidate |
| 15 | //! commits come only from the symmetric difference of the affected |
| 16 | //! atoms (a generation-bounded paint-down walk between the old and new |
| 17 | //! tips for `rev`, the one tested commit for `results`, the two tips |
| 18 | //! for `meta`), then each candidate is membership-tested against the |
| 19 | //! new and old states — reachability tests pruned by generation |
| 20 | //! numbers, results tests by refname scan (`query.results`), never a |
| 21 | //! walk of full history. Generation numbers are computed lazily and |
| 22 | //! memoized per evaluator; a persistent commit-graph file is a later |
| 23 | //! optimization with the same bound. |
| 24 | //! |
| 25 | //! # Monotonicity (`query.monotone`) |
| 26 | //! |
| 27 | //! Entry sets are additions only. A shrinking ref (force-push, branch |
| 28 | //! deletion) produces an empty or smaller entry set; nothing is ever |
| 29 | //! retracted, because the only durable record — a written result — |
| 30 | //! lives in immutable history, and the work set subtracts it by |
| 31 | //! refname scan. |
| 32 | |
| 33 | use std::cell::RefCell; |
| 34 | use std::collections::{BTreeSet, BinaryHeap, HashMap, HashSet}; |
| 35 | use std::rc::Rc; |
| 36 | |
| 37 | use ents_model::{ResultRecord, Status}; |
| 38 | use gix::refs::FullName; |
| 39 | use gix_hash::ObjectId; |
| 40 | use gix_object::{CommitRef, Find, Kind}; |
| 41 | use gix_ref_store::RefStoreRead; |
| 42 | |
| 43 | use crate::ast::{Query, StatusFilter}; |
| 44 | use crate::error::{EvalError, EvalResult}; |
| 45 | use crate::rev::{RevExpr, RevTerm, dwim_candidates}; |
| 46 | |
| 47 | /// One ref's movement, as `receive` observes it: the refname, the tip |
| 48 | /// before, and the tip after (`None` on either side for creation and |
| 49 | /// deletion). |
| 50 | /// |
| 51 | /// # Examples |
| 52 | /// |
| 53 | /// ``` |
| 54 | /// use ents_query::Transition; |
| 55 | /// |
| 56 | /// let t = Transition { |
| 57 | /// name: "refs/heads/main".try_into().expect("valid"), |
| 58 | /// old: None, |
| 59 | /// new: Some(gix_hash::ObjectId::null(gix_hash::Kind::Sha1)), |
| 60 | /// }; |
| 61 | /// assert!(t.old.is_none()); |
| 62 | /// ``` |
| 63 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 64 | pub struct Transition { |
| 65 | /// The ref that moved. |
| 66 | pub name: FullName, |
| 67 | /// Its tip before the transition (`None`: the ref did not exist). |
| 68 | pub old: Option<ObjectId>, |
| 69 | /// Its tip after the transition (`None`: the ref was deleted). |
| 70 | pub new: Option<ObjectId>, |
| 71 | } |
| 72 | |
| 73 | /// Which side of a [`Transition`] a read observes. |
| 74 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 75 | enum Side { |
| 76 | Old, |
| 77 | New, |
| 78 | } |
| 79 | |
| 80 | /// Cached structure of one commit: parents and generation number |
| 81 | /// (1 + the maximum parent generation; roots are generation 1). |
| 82 | #[derive(Debug)] |
| 83 | struct CommitInfo { |
| 84 | parents: Vec<ObjectId>, |
| 85 | generation: u64, |
| 86 | } |
| 87 | |
| 88 | /// A `CommitQuery` evaluator over one ref store and one object store. |
| 89 | /// |
| 90 | /// Caches commit structure (parents, generation numbers) and result |
| 91 | /// statuses across calls, so reusing one evaluator across many |
| 92 | /// transitions amortizes history walks — the shape a long-lived |
| 93 | /// `receive` process has. |
| 94 | /// |
| 95 | /// # Examples |
| 96 | /// |
| 97 | /// ``` |
| 98 | /// use ents_query::{Evaluator, Query}; |
| 99 | /// use ents_testutil::{MemRefStore, ObjectStore, advance_ref}; |
| 100 | /// |
| 101 | /// let refs = MemRefStore::default(); |
| 102 | /// let objects = ObjectStore::default(); |
| 103 | /// let commits = advance_ref(&refs, &objects, "refs/heads/main", 2, 100); |
| 104 | /// |
| 105 | /// let query: Query = "rev(refs/heads/main)".parse().expect("valid"); |
| 106 | /// let evaluator = Evaluator::new(&refs, &objects); |
| 107 | /// let set = evaluator.eval(&query).expect("evaluates"); |
| 108 | /// assert_eq!(set.len(), 2); |
| 109 | /// assert!(set.contains(&commits[0]) && set.contains(&commits[1])); |
| 110 | /// ``` |
| 111 | pub struct Evaluator<'a> { |
| 112 | refs: &'a dyn RefStoreRead, |
| 113 | objects: &'a dyn Find, |
| 114 | info: RefCell<HashMap<ObjectId, Rc<CommitInfo>>>, |
| 115 | status: RefCell<HashMap<ObjectId, Status>>, |
| 116 | } |
| 117 | |
| 118 | impl std::fmt::Debug for Evaluator<'_> { |
| 119 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 120 | f.debug_struct("Evaluator") |
| 121 | .field("cached_commits", &self.info.borrow().len()) |
| 122 | .finish_non_exhaustive() |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | impl<'a> Evaluator<'a> { |
| 127 | /// Build an evaluator over `refs` and `objects`. |
| 128 | pub fn new(refs: &'a dyn RefStoreRead, objects: &'a dyn Find) -> Self { |
| 129 | Self { |
| 130 | refs, |
| 131 | objects, |
| 132 | info: RefCell::new(HashMap::new()), |
| 133 | status: RefCell::new(HashMap::new()), |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | // -- public API ---------------------------------------------------- |
| 138 | |
| 139 | /// The full set `query` denotes against current ref state — the |
| 140 | /// reconciliation-grade evaluation (boot-time work-set scans); |
| 141 | /// steady-state consumers use [`Evaluator::entry_set`]. |
| 142 | pub fn eval(&self, query: &Query) -> EvalResult<BTreeSet<ObjectId>> { |
| 143 | self.eval_side(query, None, Side::New) |
| 144 | } |
| 145 | |
| 146 | /// Whether `oid` is in `query`'s set against current ref state. |
| 147 | /// |
| 148 | /// Reachability tests are pruned by generation numbers; results |
| 149 | /// tests are refname scans (`query.results`). |
| 150 | pub fn contains(&self, query: &Query, oid: ObjectId) -> EvalResult<bool> { |
| 151 | self.contains_side(query, oid, None, Side::New) |
| 152 | } |
| 153 | |
| 154 | /// The commits that *enter* `query`'s set under `transition`, |
| 155 | /// computed incrementally from that frontier (`query.incremental`) |
| 156 | /// with entry-only semantics (`query.monotone`): an effect fires |
| 157 | /// once per commit in this set; commits leaving the set appear |
| 158 | /// nowhere and retract nothing. |
| 159 | /// |
| 160 | /// # Examples |
| 161 | /// |
| 162 | /// ``` |
| 163 | /// use ents_query::{Evaluator, Query, Transition}; |
| 164 | /// use ents_testutil::{MemRefStore, ObjectStore, advance_ref}; |
| 165 | /// |
| 166 | /// let refs = MemRefStore::default(); |
| 167 | /// let objects = ObjectStore::default(); |
| 168 | /// let first = advance_ref(&refs, &objects, "refs/heads/main", 1, 100); |
| 169 | /// let second = advance_ref(&refs, &objects, "refs/heads/main", 1, 200); |
| 170 | /// |
| 171 | /// let query: Query = "rev(refs/heads/main)".parse().expect("valid"); |
| 172 | /// let evaluator = Evaluator::new(&refs, &objects); |
| 173 | /// let entered = evaluator.entry_set(&query, &Transition { |
| 174 | /// name: "refs/heads/main".try_into().expect("valid"), |
| 175 | /// old: Some(first[0]), |
| 176 | /// new: Some(second[0]), |
| 177 | /// }).expect("evaluates"); |
| 178 | /// assert_eq!(entered.into_iter().collect::<Vec<_>>(), vec![second[0]]); |
| 179 | /// ``` |
| 180 | // @relation(query.incremental, query.monotone, scope=function) |
| 181 | pub fn entry_set( |
| 182 | &self, |
| 183 | query: &Query, |
| 184 | transition: &Transition, |
| 185 | ) -> EvalResult<BTreeSet<ObjectId>> { |
| 186 | if !query.footprint().matches(transition.name.as_ref()) { |
| 187 | return Ok(BTreeSet::new()); |
| 188 | } |
| 189 | let mut candidates = HashSet::new(); |
| 190 | self.collect_candidates(query, transition, &mut candidates)?; |
| 191 | let mut entered = BTreeSet::new(); |
| 192 | for oid in candidates { |
| 193 | if self.contains_side(query, oid, Some(transition), Side::New)? |
| 194 | && !self.contains_side(query, oid, Some(transition), Side::Old)? |
| 195 | { |
| 196 | entered.insert(oid); |
| 197 | } |
| 198 | } |
| 199 | Ok(entered) |
| 200 | } |
| 201 | |
| 202 | /// The work set for `effect` under `transition`: |
| 203 | /// `trigger − results(self, any)` with `self` substituted here, at |
| 204 | /// evaluation time — the entry set of the trigger minus every |
| 205 | /// commit already carrying any recorded result for `effect`, by |
| 206 | /// refname scan of the effect's results namespace, never a walk of |
| 207 | /// history (`query.workset`). |
| 208 | /// |
| 209 | /// The effect's own results ref is the sole materialization marker: |
| 210 | /// there is no pipeline state anywhere else to consult. |
| 211 | // @relation(query.workset, scope=function) |
| 212 | pub fn work_set( |
| 213 | &self, |
| 214 | effect: &str, |
| 215 | trigger: &Query, |
| 216 | transition: &Transition, |
| 217 | ) -> EvalResult<BTreeSet<ObjectId>> { |
| 218 | let entered = self.entry_set(trigger, transition)?; |
| 219 | self.subtract_results(effect, entered, Some(transition)) |
| 220 | } |
| 221 | |
| 222 | /// The full outstanding set for `effect` against current ref |
| 223 | /// state: `eval(trigger) − results(self, any)` — the boot-time |
| 224 | /// reconciliation form of [`Evaluator::work_set`], from which the |
| 225 | /// obligation queue is reconstructible (`query.workset`). |
| 226 | // @relation(query.workset, scope=function) |
| 227 | pub fn outstanding(&self, effect: &str, trigger: &Query) -> EvalResult<BTreeSet<ObjectId>> { |
| 228 | let full = self.eval(trigger)?; |
| 229 | self.subtract_results(effect, full, None) |
| 230 | } |
| 231 | |
| 232 | fn subtract_results( |
| 233 | &self, |
| 234 | effect: &str, |
| 235 | set: BTreeSet<ObjectId>, |
| 236 | transition: Option<&Transition>, |
| 237 | ) -> EvalResult<BTreeSet<ObjectId>> { |
| 238 | let recorded = self.results_index(effect, StatusFilter::Any, transition, Side::New)?; |
| 239 | Ok(set |
| 240 | .into_iter() |
| 241 | .filter(|oid| !prefix_matches(&recorded, *oid)) |
| 242 | .collect()) |
| 243 | } |
| 244 | |
| 245 | // -- state views --------------------------------------------------- |
| 246 | |
| 247 | /// Resolve `name` in the given state: the transition overrides its |
| 248 | /// own ref on both sides, so the underlying store may hold either |
| 249 | /// the pre- or post-transition value. |
| 250 | fn resolve( |
| 251 | &self, |
| 252 | name: &str, |
| 253 | transition: Option<&Transition>, |
| 254 | side: Side, |
| 255 | ) -> EvalResult<Option<ObjectId>> { |
| 256 | if let Some(t) = transition |
| 257 | && t.name.as_bstr() == name |
| 258 | { |
| 259 | return Ok(match side { |
| 260 | Side::Old => t.old, |
| 261 | Side::New => t.new, |
| 262 | }); |
| 263 | } |
| 264 | let Ok(full) = FullName::try_from(name.to_owned()) else { |
| 265 | return Ok(None); |
| 266 | }; |
| 267 | Ok(self.refs.get(full.as_ref())?) |
| 268 | } |
| 269 | |
| 270 | /// All refs under `prefix` in the given state, transition applied. |
| 271 | fn iter_refs( |
| 272 | &self, |
| 273 | prefix: &str, |
| 274 | transition: Option<&Transition>, |
| 275 | side: Side, |
| 276 | ) -> EvalResult<Vec<(String, ObjectId)>> { |
| 277 | let mut out = Vec::new(); |
| 278 | for entry in self.refs.iter_prefix(prefix)? { |
| 279 | let (name, oid) = entry?; |
| 280 | out.push((name.as_bstr().to_string(), oid)); |
| 281 | } |
| 282 | if let Some(t) = transition { |
| 283 | let name = t.name.as_bstr().to_string(); |
| 284 | if name.starts_with(prefix) { |
| 285 | out.retain(|(n, _)| *n != name); |
| 286 | let value = match side { |
| 287 | Side::Old => t.old, |
| 288 | Side::New => t.new, |
| 289 | }; |
| 290 | if let Some(oid) = value { |
| 291 | out.push((name, oid)); |
| 292 | } |
| 293 | } |
| 294 | } |
| 295 | Ok(out) |
| 296 | } |
| 297 | |
| 298 | // -- atoms --------------------------------------------------------- |
| 299 | |
| 300 | /// The positive and negative tip sets of a rev expression in one |
| 301 | /// state. Short names resolve through the gitrevisions lookup |
| 302 | /// order; an unresolved name contributes nothing (a trigger over a |
| 303 | /// not-yet-created branch denotes the empty set); globs expand over |
| 304 | /// `refs/*` minus `refs/meta/*`, which is outside `rev()`'s domain |
| 305 | /// by definition (`query.rev`). |
| 306 | // @relation(query.rev, scope=function) |
| 307 | fn rev_tips( |
| 308 | &self, |
| 309 | expr: &RevExpr, |
| 310 | transition: Option<&Transition>, |
| 311 | side: Side, |
| 312 | ) -> EvalResult<(Vec<ObjectId>, Vec<ObjectId>)> { |
| 313 | let resolve_terms = |terms: &[RevTerm]| -> EvalResult<Vec<ObjectId>> { |
| 314 | let mut tips = Vec::new(); |
| 315 | for term in terms { |
| 316 | match term { |
| 317 | RevTerm::Oid(oid) => tips.push(*oid), |
| 318 | RevTerm::Name(name) => { |
| 319 | for candidate in dwim_candidates(name) { |
| 320 | if let Some(oid) = self.resolve(&candidate, transition, side)? { |
| 321 | tips.push(oid); |
| 322 | break; |
| 323 | } |
| 324 | } |
| 325 | } |
| 326 | RevTerm::Glob(pattern) => { |
| 327 | for (name, oid) in self.iter_refs("refs/", transition, side)? { |
| 328 | if !name.starts_with("refs/meta/") && pattern.matches_str(&name) { |
| 329 | tips.push(oid); |
| 330 | } |
| 331 | } |
| 332 | } |
| 333 | } |
| 334 | } |
| 335 | Ok(tips) |
| 336 | }; |
| 337 | Ok(( |
| 338 | resolve_terms(expr.include())?, |
| 339 | resolve_terms(expr.exclude())?, |
| 340 | )) |
| 341 | } |
| 342 | |
| 343 | /// The recorded-result index for one effect in one state: the |
| 344 | /// short-oid refname segments (hex prefixes of tested commits) |
| 345 | /// whose recorded status satisfies `filter` — a scan of refname |
| 346 | /// patterns under the effect's results namespace, never a walk of |
| 347 | /// commit history (`query.results`). |
| 348 | // @relation(query.results, scope=function) |
| 349 | fn results_index( |
| 350 | &self, |
| 351 | effect: &str, |
| 352 | filter: StatusFilter, |
| 353 | transition: Option<&Transition>, |
| 354 | side: Side, |
| 355 | ) -> EvalResult<Vec<String>> { |
| 356 | let prefix = format!("refs/meta/results/{effect}/"); |
| 357 | let mut shorts = Vec::new(); |
| 358 | for (name, tip) in self.iter_refs(&prefix, transition, side)? { |
| 359 | let Some(short) = name.strip_prefix(&prefix) else { |
| 360 | continue; |
| 361 | }; |
| 362 | if short.contains('/') || short.is_empty() { |
| 363 | continue; |
| 364 | } |
| 365 | if filter == StatusFilter::Any || filter.admits(self.result_status(&name, tip)?) { |
| 366 | shorts.push(short.to_ascii_lowercase()); |
| 367 | } |
| 368 | } |
| 369 | Ok(shorts) |
| 370 | } |
| 371 | |
| 372 | /// The recorded status behind one results ref tip, cached: the tip |
| 373 | /// commit's tree deserialized as a [`ResultRecord`], of which the |
| 374 | /// status is one field (`model.result-identity`: the tree also carries |
| 375 | /// the effect and judged commit, so a signed status means something |
| 376 | /// with the refname stripped away). |
| 377 | fn result_status(&self, name: &str, tip: ObjectId) -> EvalResult<Status> { |
| 378 | if let Some(status) = self.status.borrow().get(&tip) { |
| 379 | return Ok(*status); |
| 380 | } |
| 381 | let tree = self.commit_tree(tip)?; |
| 382 | let record: ResultRecord = |
| 383 | facet_git_tree::deserialize(&tree, self.objects).map_err(|source| { |
| 384 | EvalError::Status { |
| 385 | name: name.to_owned(), |
| 386 | source, |
| 387 | } |
| 388 | })?; |
| 389 | self.status.borrow_mut().insert(tip, record.status); |
| 390 | Ok(record.status) |
| 391 | } |
| 392 | |
| 393 | /// The tip commits of every author-written meta-ref matching the |
| 394 | /// glob (`query.meta`); the parser already guarantees the glob |
| 395 | /// cannot match an effect-written namespace. |
| 396 | // @relation(query.meta, scope=function) |
| 397 | fn meta_tips( |
| 398 | &self, |
| 399 | pattern: &crate::pattern::RefPattern, |
| 400 | transition: Option<&Transition>, |
| 401 | side: Side, |
| 402 | ) -> EvalResult<Vec<ObjectId>> { |
| 403 | let mut tips = Vec::new(); |
| 404 | for (name, oid) in self.iter_refs("refs/meta/", transition, side)? { |
| 405 | if pattern.matches_str(&name) { |
| 406 | tips.push(oid); |
| 407 | } |
| 408 | } |
| 409 | Ok(tips) |
| 410 | } |
| 411 | |
| 412 | // -- full evaluation ------------------------------------------------ |
| 413 | |
| 414 | fn eval_side( |
| 415 | &self, |
| 416 | query: &Query, |
| 417 | transition: Option<&Transition>, |
| 418 | side: Side, |
| 419 | ) -> EvalResult<BTreeSet<ObjectId>> { |
| 420 | match query { |
| 421 | Query::Rev(expr) => { |
| 422 | let (include, exclude) = self.rev_tips(expr, transition, side)?; |
| 423 | let reached = self.reachable(&include)?; |
| 424 | let excluded = self.reachable(&exclude)?; |
| 425 | Ok(reached.difference(&excluded).copied().collect()) |
| 426 | } |
| 427 | Query::Results { effect, status } => { |
| 428 | let shorts = self.results_index(effect, *status, transition, side)?; |
| 429 | let universe = self.universe(transition, side)?; |
| 430 | Ok(universe |
| 431 | .into_iter() |
| 432 | .filter(|oid| prefix_matches(&shorts, *oid)) |
| 433 | .collect()) |
| 434 | } |
| 435 | Query::Meta(pattern) => Ok(self |
| 436 | .meta_tips(pattern, transition, side)? |
| 437 | .into_iter() |
| 438 | .collect()), |
| 439 | // @relation(query.set-ops, scope=function) |
| 440 | Query::Op { op, lhs, rhs } => { |
| 441 | let l = self.eval_side(lhs, transition, side)?; |
| 442 | let r = self.eval_side(rhs, transition, side)?; |
| 443 | Ok(match op { |
| 444 | crate::ast::SetOp::Union => l.union(&r).copied().collect(), |
| 445 | crate::ast::SetOp::Intersect => l.intersection(&r).copied().collect(), |
| 446 | crate::ast::SetOp::Difference => l.difference(&r).copied().collect(), |
| 447 | }) |
| 448 | } |
| 449 | } |
| 450 | } |
| 451 | |
| 452 | /// Every commit reachable from any ref in the given state — the |
| 453 | /// resolution universe for standalone `results()` evaluation, where |
| 454 | /// the refname scan yields hex prefixes that must name real |
| 455 | /// commits. Only full (reconciliation-grade) evaluation pays this; |
| 456 | /// membership tests compare prefixes directly. |
| 457 | fn universe( |
| 458 | &self, |
| 459 | transition: Option<&Transition>, |
| 460 | side: Side, |
| 461 | ) -> EvalResult<HashSet<ObjectId>> { |
| 462 | let tips: Vec<ObjectId> = self |
| 463 | .iter_refs("refs/", transition, side)? |
| 464 | .into_iter() |
| 465 | .map(|(_, oid)| oid) |
| 466 | .collect(); |
| 467 | self.reachable(&tips) |
| 468 | } |
| 469 | |
| 470 | // -- membership ----------------------------------------------------- |
| 471 | |
| 472 | fn contains_side( |
| 473 | &self, |
| 474 | query: &Query, |
| 475 | oid: ObjectId, |
| 476 | transition: Option<&Transition>, |
| 477 | side: Side, |
| 478 | ) -> EvalResult<bool> { |
| 479 | match query { |
| 480 | Query::Rev(expr) => { |
| 481 | let (include, exclude) = self.rev_tips(expr, transition, side)?; |
| 482 | Ok(self.reaches(&include, oid)? && !self.reaches(&exclude, oid)?) |
| 483 | } |
| 484 | Query::Results { effect, status } => { |
| 485 | let shorts = self.results_index(effect, *status, transition, side)?; |
| 486 | Ok(prefix_matches(&shorts, oid)) |
| 487 | } |
| 488 | Query::Meta(pattern) => Ok(self.meta_tips(pattern, transition, side)?.contains(&oid)), |
| 489 | Query::Op { op, lhs, rhs } => { |
| 490 | let l = self.contains_side(lhs, oid, transition, side)?; |
| 491 | let r = self.contains_side(rhs, oid, transition, side)?; |
| 492 | Ok(match op { |
| 493 | crate::ast::SetOp::Union => l || r, |
| 494 | crate::ast::SetOp::Intersect => l && r, |
| 495 | crate::ast::SetOp::Difference => l && !r, |
| 496 | }) |
| 497 | } |
| 498 | } |
| 499 | } |
| 500 | |
| 501 | // -- candidates ----------------------------------------------------- |
| 502 | |
| 503 | /// Commits whose membership in some atom of `query` can have |
| 504 | /// changed under `transition` — the frontier the entry set is |
| 505 | /// filtered from. Everything else provably kept its membership in |
| 506 | /// every atom, so it cannot have entered the composite. |
| 507 | fn collect_candidates( |
| 508 | &self, |
| 509 | query: &Query, |
| 510 | transition: &Transition, |
| 511 | out: &mut HashSet<ObjectId>, |
| 512 | ) -> EvalResult<()> { |
| 513 | let moved = transition.name.as_bstr().to_string(); |
| 514 | match query { |
| 515 | Query::Rev(expr) => { |
| 516 | if expr |
| 517 | .patterns() |
| 518 | .iter() |
| 519 | .any(|pattern| pattern.matches_str(&moved)) |
| 520 | { |
| 521 | let old: Vec<_> = transition.old.into_iter().collect(); |
| 522 | let new: Vec<_> = transition.new.into_iter().collect(); |
| 523 | out.extend(self.ahead_of(&new, &old)?); |
| 524 | out.extend(self.ahead_of(&old, &new)?); |
| 525 | } |
| 526 | } |
| 527 | Query::Results { effect, .. } => { |
| 528 | let prefix = format!("refs/meta/results/{effect}/"); |
| 529 | if let Some(short) = moved.strip_prefix(&prefix) |
| 530 | && !short.contains('/') |
| 531 | && let Some(tested) = self.resolve_short(short, transition)? |
| 532 | { |
| 533 | out.insert(tested); |
| 534 | } |
| 535 | } |
| 536 | Query::Meta(pattern) => { |
| 537 | if pattern.matches_str(&moved) { |
| 538 | out.extend(transition.old); |
| 539 | out.extend(transition.new); |
| 540 | } |
| 541 | } |
| 542 | Query::Op { lhs, rhs, .. } => { |
| 543 | self.collect_candidates(lhs, transition, out)?; |
| 544 | self.collect_candidates(rhs, transition, out)?; |
| 545 | } |
| 546 | } |
| 547 | Ok(()) |
| 548 | } |
| 549 | |
| 550 | /// Resolve a results refname's short-oid segment to the full tested |
| 551 | /// commit id, searching commits reachable from the post-transition |
| 552 | /// ref state. A prefix that resolves to nothing reachable yields no |
| 553 | /// candidate: an unreachable commit is not an actionable entry. |
| 554 | fn resolve_short(&self, short: &str, transition: &Transition) -> EvalResult<Option<ObjectId>> { |
| 555 | let short = short.to_ascii_lowercase(); |
| 556 | let universe = self.universe(Some(transition), Side::New)?; |
| 557 | Ok(universe |
| 558 | .into_iter() |
| 559 | .find(|oid| oid.to_string().starts_with(&short))) |
| 560 | } |
| 561 | |
| 562 | // -- commit walks ---------------------------------------------------- |
| 563 | |
| 564 | /// Structure of `oid`, cached: parents and generation number, |
| 565 | /// resolved iteratively so a long first-parent chain cannot |
| 566 | /// overflow the stack. |
| 567 | fn commit_info(&self, oid: ObjectId) -> EvalResult<Rc<CommitInfo>> { |
| 568 | if let Some(info) = self.info.borrow().get(&oid) { |
| 569 | return Ok(Rc::clone(info)); |
| 570 | } |
| 571 | let mut pending: HashMap<ObjectId, Vec<ObjectId>> = HashMap::new(); |
| 572 | let mut stack = vec![oid]; |
| 573 | while let Some(&top) = stack.last() { |
| 574 | if self.info.borrow().contains_key(&top) { |
| 575 | stack.pop(); |
| 576 | continue; |
| 577 | } |
| 578 | let parents = match pending.get(&top) { |
| 579 | Some(parents) => parents.clone(), |
| 580 | None => { |
| 581 | let parents = self.read_parents(top)?; |
| 582 | pending.insert(top, parents.clone()); |
| 583 | parents |
| 584 | } |
| 585 | }; |
| 586 | let unresolved: Vec<ObjectId> = { |
| 587 | let cache = self.info.borrow(); |
| 588 | parents |
| 589 | .iter() |
| 590 | .filter(|p| !cache.contains_key(*p)) |
| 591 | .copied() |
| 592 | .collect() |
| 593 | }; |
| 594 | if unresolved.is_empty() { |
| 595 | let generation = { |
| 596 | let cache = self.info.borrow(); |
| 597 | parents |
| 598 | .iter() |
| 599 | .filter_map(|p| cache.get(p).map(|i| i.generation)) |
| 600 | .max() |
| 601 | .unwrap_or(0) |
| 602 | .saturating_add(1) |
| 603 | }; |
| 604 | self.info.borrow_mut().insert( |
| 605 | top, |
| 606 | Rc::new(CommitInfo { |
| 607 | parents, |
| 608 | generation, |
| 609 | }), |
| 610 | ); |
| 611 | stack.pop(); |
| 612 | } else { |
| 613 | stack.extend(unresolved); |
| 614 | } |
| 615 | } |
| 616 | let cache = self.info.borrow(); |
| 617 | cache |
| 618 | .get(&oid) |
| 619 | .map(Rc::clone) |
| 620 | .ok_or(EvalError::Missing { oid }) |
| 621 | } |
| 622 | |
| 623 | fn read_parents(&self, oid: ObjectId) -> EvalResult<Vec<ObjectId>> { |
| 624 | let mut buf = Vec::new(); |
| 625 | let data = self |
| 626 | .objects |
| 627 | .try_find(&oid, &mut buf) |
| 628 | .map_err(|source| EvalError::Object { oid, source })? |
| 629 | .ok_or(EvalError::Missing { oid })?; |
| 630 | if data.kind != Kind::Commit { |
| 631 | return Err(EvalError::Decode { |
| 632 | oid, |
| 633 | detail: format!("expected a commit, found a {}", data.kind), |
| 634 | }); |
| 635 | } |
| 636 | let commit = |
| 637 | CommitRef::from_bytes(data.data, oid.kind()).map_err(|e| EvalError::Decode { |
| 638 | oid, |
| 639 | detail: e.to_string(), |
| 640 | })?; |
| 641 | Ok(commit.parents().collect()) |
| 642 | } |
| 643 | |
| 644 | /// The tree of the commit at `oid`. |
| 645 | fn commit_tree(&self, oid: ObjectId) -> EvalResult<ObjectId> { |
| 646 | let mut buf = Vec::new(); |
| 647 | let data = self |
| 648 | .objects |
| 649 | .try_find(&oid, &mut buf) |
| 650 | .map_err(|source| EvalError::Object { oid, source })? |
| 651 | .ok_or(EvalError::Missing { oid })?; |
| 652 | if data.kind != Kind::Commit { |
| 653 | return Err(EvalError::Decode { |
| 654 | oid, |
| 655 | detail: format!("expected a commit, found a {}", data.kind), |
| 656 | }); |
| 657 | } |
| 658 | let commit = |
| 659 | CommitRef::from_bytes(data.data, oid.kind()).map_err(|e| EvalError::Decode { |
| 660 | oid, |
| 661 | detail: e.to_string(), |
| 662 | })?; |
| 663 | Ok(commit.tree()) |
| 664 | } |
| 665 | |
| 666 | /// Everything reachable from `tips` (inclusive) — full-walk |
| 667 | /// reachability, used by reconciliation-grade evaluation only. |
| 668 | fn reachable(&self, tips: &[ObjectId]) -> EvalResult<HashSet<ObjectId>> { |
| 669 | let mut seen = HashSet::new(); |
| 670 | let mut queue: Vec<ObjectId> = tips.to_vec(); |
| 671 | while let Some(oid) = queue.pop() { |
| 672 | if !seen.insert(oid) { |
| 673 | continue; |
| 674 | } |
| 675 | queue.extend(self.commit_info(oid)?.parents.iter().copied()); |
| 676 | } |
| 677 | Ok(seen) |
| 678 | } |
| 679 | |
| 680 | /// Whether any tip reaches `target` by parent edges, pruning every |
| 681 | /// path once its generation drops below `target`'s — the |
| 682 | /// generation-number bound of `query.incremental`. |
| 683 | fn reaches(&self, tips: &[ObjectId], target: ObjectId) -> EvalResult<bool> { |
| 684 | if tips.contains(&target) { |
| 685 | return Ok(true); |
| 686 | } |
| 687 | if tips.is_empty() { |
| 688 | return Ok(false); |
| 689 | } |
| 690 | let floor = self.commit_info(target)?.generation; |
| 691 | let mut heap = BinaryHeap::new(); |
| 692 | let mut seen = HashSet::new(); |
| 693 | for &tip in tips { |
| 694 | let info = self.commit_info(tip)?; |
| 695 | if info.generation >= floor && seen.insert(tip) { |
| 696 | heap.push((info.generation, tip)); |
| 697 | } |
| 698 | } |
| 699 | while let Some((_, oid)) = heap.pop() { |
| 700 | if oid == target { |
| 701 | return Ok(true); |
| 702 | } |
| 703 | for parent in self.commit_info(oid)?.parents.iter().copied() { |
| 704 | if seen.insert(parent) { |
| 705 | let generation = self.commit_info(parent)?.generation; |
| 706 | if generation >= floor { |
| 707 | heap.push((generation, parent)); |
| 708 | } |
| 709 | } |
| 710 | } |
| 711 | } |
| 712 | Ok(false) |
| 713 | } |
| 714 | |
| 715 | /// Commits reachable from `new_tips` but not from `old_tips` — the |
| 716 | /// transition frontier, walked in descending generation order so |
| 717 | /// old-side paint stops at the frontier's own depth instead of |
| 718 | /// walking to the roots. |
| 719 | fn ahead_of(&self, new_tips: &[ObjectId], old_tips: &[ObjectId]) -> EvalResult<Vec<ObjectId>> { |
| 720 | const NEW: u8 = 1; |
| 721 | const OLD: u8 = 2; |
| 722 | if new_tips.is_empty() { |
| 723 | return Ok(Vec::new()); |
| 724 | } |
| 725 | let mut flags: HashMap<ObjectId, u8> = HashMap::new(); |
| 726 | let mut heap: BinaryHeap<(u64, ObjectId)> = BinaryHeap::new(); |
| 727 | let mut queued: HashSet<ObjectId> = HashSet::new(); |
| 728 | let mut new_only_queued = 0usize; |
| 729 | |
| 730 | let push = |oid: ObjectId, |
| 731 | flag: u8, |
| 732 | flags: &mut HashMap<ObjectId, u8>, |
| 733 | heap: &mut BinaryHeap<(u64, ObjectId)>, |
| 734 | queued: &mut HashSet<ObjectId>, |
| 735 | new_only: &mut usize| |
| 736 | -> EvalResult<()> { |
| 737 | let entry = flags.entry(oid).or_insert(0); |
| 738 | let before = *entry; |
| 739 | *entry |= flag; |
| 740 | let after = *entry; |
| 741 | if queued.insert(oid) { |
| 742 | heap.push((self.commit_info(oid)?.generation, oid)); |
| 743 | if after == NEW { |
| 744 | *new_only = new_only.saturating_add(1); |
| 745 | } |
| 746 | } else if before == NEW && after != NEW { |
| 747 | *new_only = new_only.saturating_sub(1); |
| 748 | } |
| 749 | Ok(()) |
| 750 | }; |
| 751 | |
| 752 | for &tip in old_tips { |
| 753 | push( |
| 754 | tip, |
| 755 | OLD, |
| 756 | &mut flags, |
| 757 | &mut heap, |
| 758 | &mut queued, |
| 759 | &mut new_only_queued, |
| 760 | )?; |
| 761 | } |
| 762 | for &tip in new_tips { |
| 763 | push( |
| 764 | tip, |
| 765 | NEW, |
| 766 | &mut flags, |
| 767 | &mut heap, |
| 768 | &mut queued, |
| 769 | &mut new_only_queued, |
| 770 | )?; |
| 771 | } |
| 772 | |
| 773 | let mut ahead = Vec::new(); |
| 774 | while let Some((_, oid)) = heap.pop() { |
| 775 | // Descending generation order means every commit that could |
| 776 | // paint `oid` has already been processed, so its flag is |
| 777 | // final here. |
| 778 | let flag = flags.get(&oid).copied().unwrap_or(0); |
| 779 | queued.remove(&oid); |
| 780 | if flag == NEW { |
| 781 | new_only_queued = new_only_queued.saturating_sub(1); |
| 782 | ahead.push(oid); |
| 783 | } |
| 784 | for parent in self.commit_info(oid)?.parents.clone() { |
| 785 | push( |
| 786 | parent, |
| 787 | flag, |
| 788 | &mut flags, |
| 789 | &mut heap, |
| 790 | &mut queued, |
| 791 | &mut new_only_queued, |
| 792 | )?; |
| 793 | } |
| 794 | if new_only_queued == 0 { |
| 795 | // Nothing purely-new remains queued: everything deeper |
| 796 | // is reachable from the old tips too, so the frontier |
| 797 | // is complete — this is the generation bound. |
| 798 | break; |
| 799 | } |
| 800 | } |
| 801 | Ok(ahead) |
| 802 | } |
| 803 | } |
| 804 | |
| 805 | /// Whether any short-oid hex prefix in `shorts` prefixes `oid`. |
| 806 | fn prefix_matches(shorts: &[String], oid: ObjectId) -> bool { |
| 807 | let hex = oid.to_string(); |
| 808 | shorts.iter().any(|short| hex.starts_with(short.as_str())) |
| 809 | } |