| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | | use super::cache::{Loaded, StateCache}; |
| 7 | | use super::view::{self, ChangeView, ConflictView, RefTarget}; |
| 8 | | use super::RepoError; |
| 9 | | use oxy_core::hash::{ChangeHash, KeyId, ObjectRef}; |
| 10 | | use oxy_core::objects::StateObject; |
| 11 | | use oxy_core::repo::{FileDiff, Repo}; |
| 12 | | use oxy_core::state::FileContent; |
| 13 | | use std::collections::{BTreeMap, BTreeSet, HashMap}; |
| 14 | | use std::sync::Arc; |
| 15 | |
|
| 16 | | type Result<T> = std::result::Result<T, RepoError>; |
| 17 | |
|
| 18 | |
|
| 19 | | pub enum Source<'a> { |
| 20 | | |
| 21 | | Bookmark(Vec<ChangeHash>), |
| 22 | | |
| 23 | | Fork { |
| 24 | | fork: &'a Repo, |
| 25 | | heads: Vec<ChangeHash>, |
| 26 | | }, |
| 27 | | Changes(Vec<ChangeHash>), |
| 28 | | } |
| 29 | |
|
| 30 | |
|
| 31 | | pub struct Projection { |
| 32 | | pub target_heads: Vec<ChangeHash>, |
| 33 | | pub target_state: ObjectRef, |
| 34 | | pub heads: Vec<ChangeHash>, |
| 35 | | pub state: ObjectRef, |
| 36 | | |
| 37 | | pub changes: Vec<ChangeView>, |
| 38 | | pub hashes: Vec<ChangeHash>, |
| 39 | | pub conflicted: bool, |
| 40 | | pub conflicts: Vec<ConflictView>, |
| 41 | | pub files: Vec<FileDiff>, |
| 42 | | pub bases: BTreeMap<String, Vec<u8>>, |
| 43 | | pub loaded: Arc<Loaded>, |
| 44 | | } |
| 45 | |
|
| 46 | |
|
| 47 | |
|
| 48 | |
|
| 49 | 213× | pub fn project(repo: &Repo, cache: &StateCache, target: &str, source: Source<'_>) -> Result<Projection> { |
| 50 | 477× | let target_heads = repo.bookmarks()?.into_iter().find(|b| b.name == target).map(|b| b.heads).unwrap_or_default(); |
| 51 | 213× | let candidate: Vec<ChangeHash> = match &source { |
| 52 | 203× | Source::Bookmark(heads) => heads.clone(), |
| 53 | 6× | Source::Changes(hashes) => hashes.clone(), |
| 54 | 4× | Source::Fork { fork, heads } => { |
| 55 | | |
| 56 | 4× | let fork_state = fork.load_state(heads)?; |
| 57 | 10× | let missing: Vec<ChangeHash> = fork_state.changes().map(|(h, _)| *h).filter(|h| !repo.store().has(h)).collect(); |
| 58 | 4× | if !missing.is_empty() { |
| 59 | 2× | let bundle = fork.bundle_for(&missing)?; |
| 60 | 2× | repo.import_bundle(&bundle, repo.current_policy()?.is_some())?; |
| 61 | 2× | } |
| 62 | 4× | heads.clone() |
| 63 | | } |
| 64 | | }; |
| 65 | 414× | for h in &candidate { |
| 66 | 414× | if !repo.store().has(h) { |
| 67 | 0× | return Err(RepoError::Oxy(oxy_core::repo::RepoError::Msg(format!("change {} is not in this repository", h.short(12))))); |
| 68 | 414× | } |
| 69 | | } |
| 70 | 213× | let mut union: Vec<ChangeHash> = target_heads.clone(); |
| 71 | 213× | union.extend(candidate.iter().copied()); |
| 72 | 213× | let loaded = cache.load(repo, &union)?; |
| 73 | 213× | let heads = loaded.state.heads(); |
| 74 | 213× | let target_loaded = cache.load(repo, &target_heads)?; |
| 75 | 213× | let labels = view::head_labels(repo)?; |
| 76 | 213× | let head_set: BTreeSet<ChangeHash> = heads.iter().copied().collect(); |
| 77 | 213× | let mut added: Vec<(ChangeHash, oxy_core::state::ChangeMeta)> = |
| 78 | 756× | loaded.state.changes().filter(|(h, _)| !target_loaded.state.contains(h)).map(|(h, m)| (*h, m.clone())).collect(); |
| 79 | | |
| 80 | 213× | added.sort_by(|a, b| a.1.hlc.cmp(&b.1.hlc).then(a.0.cmp(&b.0))); |
| 81 | 213× | let hashes: Vec<ChangeHash> = added.iter().map(|(h, _)| *h).collect(); |
| 82 | 213× | let changes: Vec<ChangeView> = added.iter().map(|(h, m)| view::change_view_pub(repo, h, m, head_set.contains(h), &labels)).collect(); |
| 83 | 213× | let conflicted = loaded.snapshot.is_conflicted(); |
| 84 | 213× | let conflicts = if conflicted { view::conflicts_in(repo, &loaded, "projected") } else { vec![] }; |
| 85 | 213× | let (files, bases) = crate::render::diff::snapshot_diff(&target_loaded.snapshot, &loaded.snapshot); |
| 86 | 213× | let state = StateObject::new(heads.clone()).hash(); |
| 87 | 213× | let target_state = StateObject::new(target_heads.clone()).hash(); |
| 88 | 213× | Ok(Projection { target_heads, target_state, heads, state, changes, hashes, conflicted, conflicts, files, bases, loaded }) |
| 89 | 213× | } |
| 90 | |
|
| 91 | |
|
| 92 | 3× | pub fn atom_at(loaded: &Loaded, path: &str, new_no: usize) -> Option<(ChangeHash, u32)> { |
| 93 | 3× | let f = loaded.snapshot.files.get(path)?; |
| 94 | 3× | if !matches!(f.content, FileContent::Text { .. }) { |
| 95 | 0× | return None; |
| 96 | 3× | } |
| 97 | 3× | let lines = loaded.state.live_lines(&f.node); |
| 98 | 3× | lines.get(new_no.checked_sub(1)?).map(|((h, i), _)| (*h, *i)) |
| 99 | 3× | } |
| 100 | |
|
| 101 | |
|
| 102 | |
|
| 103 | 105× | pub fn atoms_of(loaded: &Loaded, path: &str) -> Option<Vec<(ChangeHash, u32)>> { |
| 104 | 105× | let f = loaded.snapshot.files.get(path)?; |
| 105 | 103× | if !matches!(f.content, FileContent::Text { .. }) { |
| 106 | 0× | return None; |
| 107 | 103× | } |
| 108 | 219× | Some(loaded.state.live_lines(&f.node).iter().map(|((h, i), _)| (*h, *i)).collect()) |
| 109 | 105× | } |
| 110 | |
|
| 111 | |
|
| 112 | 0× | pub fn line_of(loaded: &Loaded, path: &str, atom: (ChangeHash, u32)) -> Option<usize> { |
| 113 | 0× | let f = loaded.snapshot.files.get(path)?; |
| 114 | 0× | let lines = loaded.state.live_lines(&f.node); |
| 115 | 0× | lines.iter().position(|((h, i), _)| (*h, *i) == atom).map(|p| p + 1) |
| 116 | 0× | } |
| 117 | |
|
| 118 | |
|
| 119 | 175× | pub fn sealing_keys(repo: &Repo, hashes: &[ChangeHash]) -> Result<Vec<KeyId>> { |
| 120 | 175× | if hashes.is_empty() { |
| 121 | 11× | return Ok(vec![]); |
| 122 | 164× | } |
| 123 | 164× | let mut counts: HashMap<KeyId, usize> = HashMap::new(); |
| 124 | 166× | for h in hashes { |
| 125 | 166× | let mut keys_here: BTreeSet<KeyId> = BTreeSet::new(); |
| 126 | 166× | for seal_ref in repo.store().seals_for(h)? { |
| 127 | 45× | let attest = repo.store().get_attest(&seal_ref)?; |
| 128 | 45× | let oxy_core::attest::AttestBody::Seal { hashes: covered } = &attest.body else { continue }; |
| 129 | 45× | if !covered.contains(h) { |
| 130 | 0× | continue; |
| 131 | 45× | } |
| 132 | 45× | for sig in repo.store().signatures(&seal_ref)? { |
| 133 | 45× | if let Ok(id) = repo.store().get_identity(&sig.key) { |
| 134 | 45× | if oxy_core::identity::verify(&id, &sig, oxy_core::identity::Domain::Attest, &seal_ref).is_ok() { |
| 135 | 45× | keys_here.insert(sig.key); |
| 136 | 45× | } |
| 137 | 0× | } |
| 138 | | } |
| 139 | | } |
| 140 | 166× | for k in keys_here { |
| 141 | 45× | *counts.entry(k).or_default() += 1; |
| 142 | 45× | } |
| 143 | | } |
| 144 | 164× | Ok(counts.into_iter().filter(|(_, n)| *n == hashes.len()).map(|(k, _)| k).collect()) |
| 145 | 175× | } |
| 146 | |
|
| 147 | |
|
| 148 | |
|
| 149 | 164× | pub fn owners_for(target: &Loaded, touched: &[String]) -> Vec<String> { |
| 150 | 164× | let Some(f) = target.snapshot.files.get(".oxyowners") else { return vec![] }; |
| 151 | 23× | let FileContent::Text { bytes, .. } = &f.content else { return vec![] }; |
| 152 | 23× | let text = String::from_utf8_lossy(bytes); |
| 153 | 23× | let rules: Vec<(String, Vec<String>)> = text |
| 154 | 23× | .lines() |
| 155 | 23× | .map(str::trim) |
| 156 | 23× | .filter(|l| !l.is_empty() && !l.starts_with('#')) |
| 157 | 23× | .filter_map(|l| { |
| 158 | 23× | let mut parts = l.split_whitespace(); |
| 159 | 23× | let pattern = parts.next()?.to_string(); |
| 160 | 23× | let owners: Vec<String> = parts.map(|o| o.trim_start_matches('@').to_string()).collect(); |
| 161 | 23× | Some((pattern, owners)) |
| 162 | 23× | }) |
| 163 | 23× | .collect(); |
| 164 | 23× | let mut out: BTreeSet<String> = BTreeSet::new(); |
| 165 | 23× | for path in touched { |
| 166 | | |
| 167 | 20× | if let Some((_, owners)) = rules.iter().rev().find(|(pattern, _)| owner_pattern_matches(pattern, path)) { |
| 168 | 20× | out.extend(owners.iter().cloned()); |
| 169 | 20× | } |
| 170 | | } |
| 171 | 23× | out.into_iter().collect() |
| 172 | 164× | } |
| 173 | |
|
| 174 | 28× | fn owner_pattern_matches(pattern: &str, path: &str) -> bool { |
| 175 | 28× | let p = pattern.trim_start_matches('/'); |
| 176 | 28× | if p == "*" { |
| 177 | 21× | return true; |
| 178 | 7× | } |
| 179 | 7× | if let Some(dir) = p.strip_suffix('/') { |
| 180 | 2× | return path.starts_with(&format!("{dir}/")); |
| 181 | 5× | } |
| 182 | 5× | if let Some(ext) = p.strip_prefix("*.") { |
| 183 | 1× | return path.ends_with(&format!(".{ext}")); |
| 184 | 4× | } |
| 185 | 4× | if let Some(prefix) = p.strip_suffix("/*") { |
| 186 | 2× | return path.strip_prefix(&format!("{prefix}/")).is_some_and(|rest| !rest.contains('/')); |
| 187 | 2× | } |
| 188 | 2× | if let Some(prefix) = p.strip_suffix("/**") { |
| 189 | 1× | return path.starts_with(&format!("{prefix}/")); |
| 190 | 1× | } |
| 191 | 1× | path == p || path.starts_with(&format!("{p}/")) |
| 192 | 28× | } |
| 193 | |
|
| 194 | |
|
| 195 | 19× | pub fn state_ref(heads: &[ChangeHash]) -> ObjectRef { |
| 196 | 19× | StateObject::new(heads.to_vec()).hash() |
| 197 | 19× | } |
| 198 | |
|
| 199 | |
|
| 200 | 0× | pub fn target_of(label: &str, heads: &[ChangeHash]) -> RefTarget { |
| 201 | 0× | RefTarget { label: label.into(), kind: view::RefKind::Change, heads: heads.to_vec() } |
| 202 | 0× | } |
| 203 | |
|
| 204 | | #[cfg(test)] |
| 205 | | mod tests { |
| 206 | | use super::*; |
| 207 | |
|
| 208 | | #[test] |
| 209 | 1× | fn owner_patterns() { |
| 210 | 1× | assert!(owner_pattern_matches("*", "any/thing")); |
| 211 | 1× | assert!(owner_pattern_matches("docs/", "docs/spec.md")); |
| 212 | 1× | assert!(!owner_pattern_matches("docs/", "src/docs.rs")); |
| 213 | 1× | assert!(owner_pattern_matches("*.rs", "src/main.rs")); |
| 214 | 1× | assert!(owner_pattern_matches("src/*", "src/main.rs")); |
| 215 | 1× | assert!(!owner_pattern_matches("src/*", "src/a/b.rs")); |
| 216 | 1× | assert!(owner_pattern_matches("crates/**", "crates/hub/src/lib.rs")); |
| 217 | 1× | assert!(owner_pattern_matches("Cargo.toml", "Cargo.toml")); |
| 218 | 1× | } |
| 219 | | } |