OXYGENHUB

Coverage / crates/hub/src/repos/proposals.rs 90.2%

138 of 153 lines run, 23 of 51 functions · run #481 on main · run missed

1
//! Proposals (hub spec §7): the projected state, its diff and conflicts,
2
//! atom anchors for comments, seal-backed approvals, code owners and the
3
//! landing requirements. Pure functions over `oxy-core`, run under the
4
//! repository lock.
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
/// Where a proposal's changes come from, resolved.
19
pub enum Source<'a> {
20
    /// Heads of a bookmark of this repository.
21
    Bookmark(Vec<ChangeHash>),
22
    /// A fork: its repository directory and the bookmark's heads there.
23
    Fork {
24
        fork: &'a Repo,
25
        heads: Vec<ChangeHash>,
26
    },
27
    Changes(Vec<ChangeHash>),
28
}
29
30
/// The projected state of a proposal and what the pages show of it.
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
    /// The changes the proposal adds, dependency order.
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
/// Compute the projection: the union of the target's heads and the
47
/// source, with the source's changes brought into this store when they
48
/// come from a fork.
49221×
pub fn project(repo: &Repo, cache: &StateCache, target: &str, source: Source<'_>) -> Result<Projection> {
50491×
    let target_heads = repo.bookmarks()?.into_iter().find(|b| b.name == target).map(|b| b.heads).unwrap_or_default();
51221×
    let candidate: Vec<ChangeHash> = match &source {
52211×
        Source::Bookmark(heads) => heads.clone(),
536×
        Source::Changes(hashes) => hashes.clone(),
544×
        Source::Fork { fork, heads } => {
55
            // Objects are content addressed: copy what is missing straight from the fork's store.
564×
            let fork_state = fork.load_state(heads)?;
5710×
            let missing: Vec<ChangeHash> = fork_state.changes().map(|(h, _)| *h).filter(|h| !repo.store().has(h)).collect();
584×
            if !missing.is_empty() {
592×
                let bundle = fork.bundle_for(&missing)?;
602×
                repo.import_bundle(&bundle, repo.current_policy()?.is_some())?;
612×
            }
624×
            heads.clone()
63
        }
64
    };
65422×
    for h in &candidate {
66422×
        if !repo.store().has(h) {
670×
            return Err(RepoError::Oxy(oxy_core::repo::RepoError::Msg(format!("change {} is not in this repository", h.short(12)))));
68422×
        }
69
    }
70221×
    let mut union: Vec<ChangeHash> = target_heads.clone();
71221×
    union.extend(candidate.iter().copied());
72221×
    let loaded = cache.load(repo, &union)?;
73221×
    let heads = loaded.state.heads();
74221×
    let target_loaded = cache.load(repo, &target_heads)?;
75221×
    let labels = view::head_labels(repo)?;
76221×
    let head_set: BTreeSet<ChangeHash> = heads.iter().copied().collect();
77221×
    let mut added: Vec<(ChangeHash, oxy_core::state::ChangeMeta)> =
78771×
        loaded.state.changes().filter(|(h, _)| !target_loaded.state.contains(h)).map(|(h, m)| (*h, m.clone())).collect();
79
    // Dependency order, oldest first: a change after everything it depends on.
80221×
    added.sort_by(|a, b| a.1.hlc.cmp(&b.1.hlc).then(a.0.cmp(&b.0)));
81221×
    let hashes: Vec<ChangeHash> = added.iter().map(|(h, _)| *h).collect();
82221×
    let changes: Vec<ChangeView> = added.iter().map(|(h, m)| view::change_view_pub(repo, h, m, head_set.contains(h), &labels)).collect();
83221×
    let conflicted = loaded.snapshot.is_conflicted();
84221×
    let conflicts = if conflicted { view::conflicts_in(repo, &loaded, "projected") } else { vec![] };
85221×
    let (files, bases) = crate::render::diff::snapshot_diff(&target_loaded.snapshot, &loaded.snapshot);
86221×
    let state = StateObject::new(heads.clone()).hash();
87221×
    let target_state = StateObject::new(target_heads.clone()).hash();
88221×
    Ok(Projection { target_heads, target_state, heads, state, changes, hashes, conflicted, conflicts, files, bases, loaded })
89221×
}
90
91
/// The atom at line `new_no` (1-based) of `path` in the projected state.
923×
pub fn atom_at(loaded: &Loaded, path: &str, new_no: usize) -> Option<(ChangeHash, u32)> {
933×
    let f = loaded.snapshot.files.get(path)?;
943×
    if !matches!(f.content, FileContent::Text { .. }) {
950×
        return None;
963×
    }
973×
    let lines = loaded.state.live_lines(&f.node);
983×
    lines.get(new_no.checked_sub(1)?).map(|((h, i), _)| (*h, *i))
993×
}
100
101
/// Every live line of `path` as its atom, in order: `atom_at` for a whole
102
/// file at once (computing them per line is quadratic in the file's length).
103104×
pub fn atoms_of(loaded: &Loaded, path: &str) -> Option<Vec<(ChangeHash, u32)>> {
104104×
    let f = loaded.snapshot.files.get(path)?;
105102×
    if !matches!(f.content, FileContent::Text { .. }) {
1060×
        return None;
107102×
    }
108216×
    Some(loaded.state.live_lines(&f.node).iter().map(|((h, i), _)| (*h, *i)).collect())
109104×
}
110
111
/// The current line (1-based) of an atom in `path`, if it is still live.
1120×
pub fn line_of(loaded: &Loaded, path: &str, atom: (ChangeHash, u32)) -> Option<usize> {
1130×
    let f = loaded.snapshot.files.get(path)?;
1140×
    let lines = loaded.state.live_lines(&f.node);
1150×
    lines.iter().position(|((h, i), _)| (*h, *i) == atom).map(|p| p + 1)
1160×
}
117
118
/// Keys that have sealed every one of `hashes` with a valid seal.
119172×
pub fn sealing_keys(repo: &Repo, hashes: &[ChangeHash]) -> Result<Vec<KeyId>> {
120172×
    if hashes.is_empty() {
12116×
        return Ok(vec![]);
122156×
    }
123156×
    let mut counts: HashMap<KeyId, usize> = HashMap::new();
124158×
    for h in hashes {
125158×
        let mut keys_here: BTreeSet<KeyId> = BTreeSet::new();
126158×
        for seal_ref in repo.store().seals_for(h)? {
12740×
            let attest = repo.store().get_attest(&seal_ref)?;
12840×
            let oxy_core::attest::AttestBody::Seal { hashes: covered } = &attest.body else { continue };
12940×
            if !covered.contains(h) {
1300×
                continue;
13140×
            }
13240×
            for sig in repo.store().signatures(&seal_ref)? {
13340×
                if let Ok(id) = repo.store().get_identity(&sig.key) {
13440×
                    if oxy_core::identity::verify(&id, &sig, oxy_core::identity::Domain::Attest, &seal_ref).is_ok() {
13540×
                        keys_here.insert(sig.key);
13640×
                    }
1370×
                }
138
            }
139
        }
140158×
        for k in keys_here {
14140×
            *counts.entry(k).or_default() += 1;
14240×
        }
143
    }
144156×
    Ok(counts.into_iter().filter(|(_, n)| *n == hashes.len()).map(|(k, _)| k).collect())
145172×
}
146
147
/// `.oxyowners` in the target state (CODEOWNERS syntax): the owners of
148
/// every path the proposal touches.
149172×
pub fn owners_for(target: &Loaded, touched: &[String]) -> Vec<String> {
150172×
    let Some(f) = target.snapshot.files.get(".oxyowners") else { return vec![] };
15126×
    let FileContent::Text { bytes, .. } = &f.content else { return vec![] };
15226×
    let text = String::from_utf8_lossy(bytes);
15326×
    let rules: Vec<(String, Vec<String>)> = text
15426×
        .lines()
15526×
        .map(str::trim)
15626×
        .filter(|l| !l.is_empty() && !l.starts_with('#'))
15726×
        .filter_map(|l| {
15826×
            let mut parts = l.split_whitespace();
15926×
            let pattern = parts.next()?.to_string();
16026×
            let owners: Vec<String> = parts.map(|o| o.trim_start_matches('@').to_string()).collect();
16126×
            Some((pattern, owners))
16226×
        })
16326×
        .collect();
16426×
    let mut out: BTreeSet<String> = BTreeSet::new();
16526×
    for path in touched {
166
        // The last matching rule wins, as in CODEOWNERS.
16720×
        if let Some((_, owners)) = rules.iter().rev().find(|(pattern, _)| owner_pattern_matches(pattern, path)) {
16820×
            out.extend(owners.iter().cloned());
16920×
        }
170
    }
17126×
    out.into_iter().collect()
172172×
}
173
17428×
fn owner_pattern_matches(pattern: &str, path: &str) -> bool {
17528×
    let p = pattern.trim_start_matches('/');
17628×
    if p == "*" {
17721×
        return true;
1787×
    }
1797×
    if let Some(dir) = p.strip_suffix('/') {
1802×
        return path.starts_with(&format!("{dir}/"));
1815×
    }
1825×
    if let Some(ext) = p.strip_prefix("*.") {
1831×
        return path.ends_with(&format!(".{ext}"));
1844×
    }
1854×
    if let Some(prefix) = p.strip_suffix("/*") {
1862×
        return path.strip_prefix(&format!("{prefix}/")).is_some_and(|rest| !rest.contains('/'));
1872×
    }
1882×
    if let Some(prefix) = p.strip_suffix("/**") {
1891×
        return path.starts_with(&format!("{prefix}/"));
1901×
    }
1911×
    path == p || path.starts_with(&format!("{p}/"))
19228×
}
193
194
/// The union of two head lists as a state object reference, for landing.
19519×
pub fn state_ref(heads: &[ChangeHash]) -> ObjectRef {
19619×
    StateObject::new(heads.to_vec()).hash()
19719×
}
198
199
/// A proposal's changes as a `RefTarget`, for browsing its tree.
2000×
pub fn target_of(label: &str, heads: &[ChangeHash]) -> RefTarget {
2010×
    RefTarget { label: label.into(), kind: view::RefKind::Change, heads: heads.to_vec() }
2020×
}
203
204
#[cfg(test)]
205
mod tests {
206
    use super::*;
207
208
    #[test]
2091×
    fn owner_patterns() {
2101×
        assert!(owner_pattern_matches("*", "any/thing"));
2111×
        assert!(owner_pattern_matches("docs/", "docs/spec.md"));
2121×
        assert!(!owner_pattern_matches("docs/", "src/docs.rs"));
2131×
        assert!(owner_pattern_matches("*.rs", "src/main.rs"));
2141×
        assert!(owner_pattern_matches("src/*", "src/main.rs"));
2151×
        assert!(!owner_pattern_matches("src/*", "src/a/b.rs"));
2161×
        assert!(owner_pattern_matches("crates/**", "crates/hub/src/lib.rs"));
2171×
        assert!(owner_pattern_matches("Cargo.toml", "Cargo.toml"));
2181×
    }
219
}