//! Proposals (hub spec ยง7): the projected state, its diff and conflicts, //! atom anchors for comments, seal-backed approvals, code owners and the //! landing requirements. Pure functions over `oxy-core`, run under the //! repository lock. use super::cache::{Loaded, StateCache}; use super::view::{self, ChangeView, ConflictView, RefTarget}; use super::RepoError; use oxy_core::hash::{ChangeHash, KeyId, ObjectRef}; use oxy_core::objects::StateObject; use oxy_core::repo::{FileDiff, Repo}; use oxy_core::state::FileContent; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::Arc; type Result = std::result::Result; /// Where a proposal's changes come from, resolved. pub enum Source<'a> { /// Heads of a bookmark of this repository. Bookmark(Vec), /// A fork: its repository directory and the bookmark's heads there. Fork { fork: &'a Repo, heads: Vec, }, Changes(Vec), } /// The projected state of a proposal and what the pages show of it. pub struct Projection { pub target_heads: Vec, pub target_state: ObjectRef, pub heads: Vec, pub state: ObjectRef, /// The changes the proposal adds, dependency order. pub changes: Vec, pub hashes: Vec, pub conflicted: bool, pub conflicts: Vec, pub files: Vec, pub bases: BTreeMap>, pub loaded: Arc, } /// Compute the projection: the union of the target's heads and the /// source, with the source's changes brought into this store when they /// come from a fork. pub fn project(repo: &Repo, cache: &StateCache, target: &str, source: Source<'_>) -> Result { let target_heads = repo.bookmarks()?.into_iter().find(|b| b.name == target).map(|b| b.heads).unwrap_or_default(); let candidate: Vec = match &source { Source::Bookmark(heads) => heads.clone(), Source::Changes(hashes) => hashes.clone(), Source::Fork { fork, heads } => { // Objects are content addressed: copy what is missing straight from the fork's store. let fork_state = fork.load_state(heads)?; let missing: Vec = fork_state.changes().map(|(h, _)| *h).filter(|h| !repo.store().has(h)).collect(); if !missing.is_empty() { let bundle = fork.bundle_for(&missing)?; repo.import_bundle(&bundle, repo.current_policy()?.is_some())?; } heads.clone() } }; for h in &candidate { if !repo.store().has(h) { return Err(RepoError::Oxy(oxy_core::repo::RepoError::Msg(format!("change {} is not in this repository", h.short(12))))); } } let mut union: Vec = target_heads.clone(); union.extend(candidate.iter().copied()); let loaded = cache.load(repo, &union)?; let heads = loaded.state.heads(); let target_loaded = cache.load(repo, &target_heads)?; let labels = view::head_labels(repo)?; let head_set: BTreeSet = heads.iter().copied().collect(); let mut added: Vec<(ChangeHash, oxy_core::state::ChangeMeta)> = loaded.state.changes().filter(|(h, _)| !target_loaded.state.contains(h)).map(|(h, m)| (*h, m.clone())).collect(); // Dependency order, oldest first: a change after everything it depends on. added.sort_by(|a, b| a.1.hlc.cmp(&b.1.hlc).then(a.0.cmp(&b.0))); let hashes: Vec = added.iter().map(|(h, _)| *h).collect(); let changes: Vec = added.iter().map(|(h, m)| view::change_view_pub(repo, h, m, head_set.contains(h), &labels)).collect(); let conflicted = loaded.snapshot.is_conflicted(); let conflicts = if conflicted { view::conflicts_in(repo, &loaded, "projected") } else { vec![] }; let (files, bases) = crate::render::diff::snapshot_diff(&target_loaded.snapshot, &loaded.snapshot); let state = StateObject::new(heads.clone()).hash(); let target_state = StateObject::new(target_heads.clone()).hash(); Ok(Projection { target_heads, target_state, heads, state, changes, hashes, conflicted, conflicts, files, bases, loaded }) } /// The atom at line `new_no` (1-based) of `path` in the projected state. pub fn atom_at(loaded: &Loaded, path: &str, new_no: usize) -> Option<(ChangeHash, u32)> { let f = loaded.snapshot.files.get(path)?; if !matches!(f.content, FileContent::Text { .. }) { return None; } let lines = loaded.state.live_lines(&f.node); lines.get(new_no.checked_sub(1)?).map(|((h, i), _)| (*h, *i)) } /// The current line (1-based) of an atom in `path`, if it is still live. pub fn line_of(loaded: &Loaded, path: &str, atom: (ChangeHash, u32)) -> Option { let f = loaded.snapshot.files.get(path)?; let lines = loaded.state.live_lines(&f.node); lines.iter().position(|((h, i), _)| (*h, *i) == atom).map(|p| p + 1) } /// Keys that have sealed every one of `hashes` with a valid seal. pub fn sealing_keys(repo: &Repo, hashes: &[ChangeHash]) -> Result> { if hashes.is_empty() { return Ok(vec![]); } let mut counts: HashMap = HashMap::new(); for h in hashes { let mut keys_here: BTreeSet = BTreeSet::new(); for seal_ref in repo.store().seals_for(h)? { let attest = repo.store().get_attest(&seal_ref)?; let oxy_core::attest::AttestBody::Seal { hashes: covered } = &attest.body else { continue }; if !covered.contains(h) { continue; } for sig in repo.store().signatures(&seal_ref)? { if let Ok(id) = repo.store().get_identity(&sig.key) { if oxy_core::identity::verify(&id, &sig, oxy_core::identity::Domain::Attest, &seal_ref).is_ok() { keys_here.insert(sig.key); } } } } for k in keys_here { *counts.entry(k).or_default() += 1; } } Ok(counts.into_iter().filter(|(_, n)| *n == hashes.len()).map(|(k, _)| k).collect()) } /// `.oxyowners` in the target state (CODEOWNERS syntax): the owners of /// every path the proposal touches. pub fn owners_for(target: &Loaded, touched: &[String]) -> Vec { let Some(f) = target.snapshot.files.get(".oxyowners") else { return vec![] }; let FileContent::Text { bytes, .. } = &f.content else { return vec![] }; let text = String::from_utf8_lossy(bytes); let rules: Vec<(String, Vec)> = text .lines() .map(str::trim) .filter(|l| !l.is_empty() && !l.starts_with('#')) .filter_map(|l| { let mut parts = l.split_whitespace(); let pattern = parts.next()?.to_string(); let owners: Vec = parts.map(|o| o.trim_start_matches('@').to_string()).collect(); Some((pattern, owners)) }) .collect(); let mut out: BTreeSet = BTreeSet::new(); for path in touched { // The last matching rule wins, as in CODEOWNERS. if let Some((_, owners)) = rules.iter().rev().find(|(pattern, _)| owner_pattern_matches(pattern, path)) { out.extend(owners.iter().cloned()); } } out.into_iter().collect() } fn owner_pattern_matches(pattern: &str, path: &str) -> bool { let p = pattern.trim_start_matches('/'); if p == "*" { return true; } if let Some(dir) = p.strip_suffix('/') { return path.starts_with(&format!("{dir}/")); } if let Some(ext) = p.strip_prefix("*.") { return path.ends_with(&format!(".{ext}")); } if let Some(prefix) = p.strip_suffix("/*") { return path.strip_prefix(&format!("{prefix}/")).is_some_and(|rest| !rest.contains('/')); } if let Some(prefix) = p.strip_suffix("/**") { return path.starts_with(&format!("{prefix}/")); } path == p || path.starts_with(&format!("{p}/")) } /// The union of two head lists as a state object reference, for landing. pub fn state_ref(heads: &[ChangeHash]) -> ObjectRef { StateObject::new(heads.to_vec()).hash() } /// A proposal's changes as a `RefTarget`, for browsing its tree. pub fn target_of(label: &str, heads: &[ChangeHash]) -> RefTarget { RefTarget { label: label.into(), kind: view::RefKind::Change, heads: heads.to_vec() } } #[cfg(test)] mod tests { use super::*; #[test] fn owner_patterns() { assert!(owner_pattern_matches("*", "any/thing")); assert!(owner_pattern_matches("docs/", "docs/spec.md")); assert!(!owner_pattern_matches("docs/", "src/docs.rs")); assert!(owner_pattern_matches("*.rs", "src/main.rs")); assert!(owner_pattern_matches("src/*", "src/main.rs")); assert!(!owner_pattern_matches("src/*", "src/a/b.rs")); assert!(owner_pattern_matches("crates/**", "crates/hub/src/lib.rs")); assert!(owner_pattern_matches("Cargo.toml", "Cargo.toml")); } }