OXYGENHUB
# 736ae87ef318 oxygenhub / crates / hub / src / repos / view.rs
31.5 KiB · Rust · last changed Bookmark summaries: stop walking every state through the cache 1 month ago source blame history raw
d23464c0 Andy McCall ↶ 1
//! Page-ready views of a repository, computed from `oxy-core` alone (hub
2
//! spec §1.4). Nothing here knows about HTTP or templates; every function
3
//! takes an open `Repo` and returns plain data.
4
9ff27da5 Andy McCall ↶ 5
use super::{RepoError, StateCache};
d23464c0 Andy McCall ↶ 6
use oxy_core::hash::{ChangeHash, KeyId};
7
use oxy_core::objects::{Change, NodeKind};
8
use oxy_core::repo::{BlameLine, FileDiff, Repo, SignatureStatus};
9ff27da5 Andy McCall ↶ 9
use oxy_core::state::{FileContent, Snapshot};
d23464c0 Andy McCall ↶ 10
use oxy_core::tree::TreeConflict;
11
use std::collections::{BTreeMap, BTreeSet, HashMap};
12
13
type Result<T> = std::result::Result<T, RepoError>;
14
15
fn fail(msg: impl Into<String>) -> RepoError {
16
    RepoError::Oxy(oxy_core::repo::RepoError::Msg(msg.into()))
17
}
18
19
// ------------------------------------------------------------------ refs
20
21
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22
pub enum RefKind {
23
    Bookmark,
24
    Tag,
25
    Change,
26
}
27
28
/// What a `{ref}` path segment resolved to.
29
#[derive(Clone, Debug)]
30
pub struct RefTarget {
31
    /// The name as given (bookmark, tag) or the full hash.
32
    pub label: String,
33
    pub kind: RefKind,
34
    pub heads: Vec<ChangeHash>,
35
}
36
37
/// Resolve a bookmark name, a tag name, or a change hash prefix.
9ff27da5 Andy McCall ↶ 38
pub fn resolve(repo: &Repo, cache: &StateCache, spec: &str) -> Result<RefTarget> {
d23464c0 Andy McCall ↶ 39
    let view = repo.view()?;
40
    if let Some(heads) = view.bookmarks.get(spec).map(|st| repo.store().get_state(st).map(|s| s.heads)).transpose()? {
41
        return Ok(RefTarget { label: spec.into(), kind: RefKind::Bookmark, heads });
42
    }
43
    if let Some(heads) = repo.tag_heads(spec)? {
44
        return Ok(RefTarget { label: spec.into(), kind: RefKind::Tag, heads });
45
    }
9ff27da5 Andy McCall ↶ 46
    let hash = resolve_change(repo, cache, spec)?;
d23464c0 Andy McCall ↶ 47
    Ok(RefTarget { label: hash.to_hex(), kind: RefKind::Change, heads: vec![hash] })
48
}
49
50
/// A change by full hash or unique hex prefix, searched over every
51
/// bookmark's state.
9ff27da5 Andy McCall ↶ 52
pub fn resolve_change(repo: &Repo, cache: &StateCache, spec: &str) -> Result<ChangeHash> {
d23464c0 Andy McCall ↶ 53
    let spec = spec.trim_start_matches('#');
54
    if spec.len() < 4 || !spec.chars().all(|c| c.is_ascii_hexdigit()) {
55
        return Err(fail(format!("{spec:?} is not a change")));
56
    }
57
    if let Some(h) = ChangeHash::from_hex(spec) {
58
        if repo.store().has(&h) {
59
            return Ok(h);
60
        }
61
    }
62
    let heads = all_heads(repo)?;
9ff27da5 Andy McCall ↶ 63
    let loaded = cache.load(repo, &heads)?;
64
    let matches: Vec<ChangeHash> = loaded.state.changes().map(|(h, _)| *h).filter(|h| h.starts_with_hex(spec)).collect();
d23464c0 Andy McCall ↶ 65
    match matches.len() {
66
        1 => Ok(matches[0]),
67
        0 => Err(fail(format!("no change matches {spec:?}"))),
68
        _ => Err(fail(format!("{spec:?} is ambiguous"))),
69
    }
70
}
71
72
/// The heads of every bookmark, deduplicated.
73
pub fn all_heads(repo: &Repo) -> Result<Vec<ChangeHash>> {
74
    let mut set = BTreeSet::new();
75
    for b in repo.bookmarks()? {
76
        set.extend(b.heads);
77
    }
78
    Ok(set.into_iter().collect())
79
}
80
81
// ---------------------------------------------------------------- authors
82
83
#[derive(Clone, Debug, PartialEq, Eq)]
84
pub struct AuthorView {
85
    pub name: String,
86
    pub email: String,
87
    pub key: String,
88
    /// True when the key is not an identity the store knows.
89
    pub unknown: bool,
90
}
91
92
pub fn author(repo: &Repo, key: &KeyId, message: &str) -> AuthorView {
93
    if key.is_zero() {
94
        // Imported from Git: the trailer carries the author.
95
        let line = message.lines().rev().find_map(|l| l.strip_prefix("Git-Author: ")).unwrap_or("unknown");
96
        let (name, email) = match line.split_once('<') {
97
            Some((n, e)) => (n.trim().to_string(), e.trim_end_matches('>').trim().to_string()),
98
            None => (line.trim().to_string(), String::new()),
99
        };
100
        return AuthorView { name, email, key: String::new(), unknown: true };
101
    }
102
    match repo.store().get_identity(key) {
103
        Ok(id) => AuthorView { name: id.name, email: id.email, key: key.to_hex(), unknown: false },
104
        Err(_) => AuthorView { name: key.short(12), email: String::new(), key: key.to_hex(), unknown: true },
105
    }
106
}
107
108
// ---------------------------------------------------------------- changes
109
110
#[derive(Clone, Debug, PartialEq, Eq)]
111
pub enum SigView {
112
    Unsigned,
113
    Valid { key: String, name: String, sealed: bool, revoked: bool },
114
    Invalid(String),
115
}
116
117
impl SigView {
118
    pub fn glyph(&self) -> &'static str {
119
        match self {
120
            SigView::Unsigned => "○",
121
            SigView::Valid { revoked: true, .. } => "⊘",
122
            SigView::Valid { sealed: true, .. } => "◈",
123
            SigView::Valid { .. } => "✔",
124
            SigView::Invalid(_) => "✘",
125
        }
126
    }
127
128
    pub fn class(&self) -> &'static str {
129
        match self {
130
            SigView::Unsigned => "sig-unsigned",
131
            SigView::Valid { revoked: true, .. } => "sig-revoked",
132
            SigView::Valid { .. } => "sig-valid",
133
            SigView::Invalid(_) => "sig-invalid",
134
        }
135
    }
136
137
    pub fn title(&self) -> String {
138
        match self {
139
            SigView::Unsigned => "unsigned".into(),
140
            SigView::Valid { name, sealed: true, revoked: false, .. } => format!("sealed by {name}"),
141
            SigView::Valid { name, revoked: true, .. } => format!("signed by {name}, key revoked"),
142
            SigView::Valid { name, .. } => format!("signed by {name}"),
143
            SigView::Invalid(why) => format!("INVALID: {why}"),
144
        }
145
    }
146
}
147
148
pub fn signature(repo: &Repo, hash: &ChangeHash) -> SigView {
149
    match repo.verify_change(hash) {
150
        Ok(SignatureStatus::Unsigned) => SigView::Unsigned,
151
        Ok(SignatureStatus::Valid { key, name, sealed, revoked, .. }) => SigView::Valid { key: key.to_hex(), name, sealed, revoked },
152
        Ok(SignatureStatus::Invalid(why)) => SigView::Invalid(why),
153
        Err(e) => SigView::Invalid(e.to_string()),
154
    }
155
}
156
157
/// One change as history rows and change pages show it.
158
#[derive(Clone, Debug)]
159
pub struct ChangeView {
160
    pub hash: String,
161
    pub short: String,
162
    pub title: String,
163
    pub body: String,
164
    pub author: AuthorView,
165
    pub when_ms: u64,
166
    pub deps: Vec<String>,
167
    pub head: bool,
168
    pub resolution: bool,
169
    pub signature: SigView,
170
    /// Bookmarks and tags whose state has this change as a head.
171
    pub labels: Vec<Label>,
172
}
173
174
#[derive(Clone, Debug, PartialEq, Eq)]
175
pub struct Label {
176
    pub name: String,
177
    pub kind: RefKind,
178
}
179
180
pub fn split_message(message: &str) -> (String, String) {
181
    let mut lines = message.lines();
182
    let title = lines.next().unwrap_or("").trim().to_string();
183
    let body = lines.collect::<Vec<_>>().join("\n").trim().to_string();
184
    (title, body)
185
}
186
187
/// Every bookmark and tag head, for labelling rows.
188
pub fn head_labels(repo: &Repo) -> Result<HashMap<ChangeHash, Vec<Label>>> {
189
    let mut out: HashMap<ChangeHash, Vec<Label>> = HashMap::new();
190
    for b in repo.bookmarks()? {
191
        for h in b.heads {
192
            out.entry(h).or_default().push(Label { name: b.name.clone(), kind: RefKind::Bookmark });
193
        }
194
    }
195
    for t in repo.tags()? {
196
        for h in t.heads {
197
            out.entry(h).or_default().push(Label { name: t.name.clone(), kind: RefKind::Tag });
198
        }
199
    }
200
    Ok(out)
201
}
202
203
fn change_view(
204
    repo: &Repo,
205
    hash: &ChangeHash,
206
    meta: &oxy_core::state::ChangeMeta,
207
    head: bool,
208
    labels: &HashMap<ChangeHash, Vec<Label>>,
209
) -> ChangeView {
210
    let (title, body) = split_message(&meta.message);
211
    ChangeView {
212
        hash: hash.to_hex(),
213
        short: hash.short(12),
214
        title,
215
        body,
216
        author: author(repo, &meta.author, &meta.message),
217
        when_ms: meta.hlc.wall_ms,
218
        deps: meta.deps.iter().map(|d| d.to_hex()).collect(),
219
        head,
220
        resolution: meta.kind == oxy_core::objects::ChangeKind::Resolution,
221
        signature: signature(repo, hash),
222
        labels: labels.get(hash).cloned().unwrap_or_default(),
223
    }
224
}
225
226
// ---------------------------------------------------------------- history
227
228
#[derive(Clone, Debug)]
229
pub struct History {
230
    pub target: RefTarget,
231
    pub entries: Vec<ChangeView>,
232
    pub total: usize,
233
    pub offset: usize,
234
    pub has_more: bool,
235
    pub path: Option<String>,
236
}
237
238
/// A window of the history of a state, newest first.
9ff27da5 Andy McCall ↶ 239
pub fn history(repo: &Repo, cache: &StateCache, target: RefTarget, path: Option<&str>, offset: usize, limit: usize) -> Result<History> {
240
    let loaded = cache.load(repo, &target.heads)?;
241
    let head_set: BTreeSet<ChangeHash> = loaded.state.heads().into_iter().collect();
242
    let mut entries: Vec<(ChangeHash, oxy_core::state::ChangeMeta)> = match path {
243
        Some(p) => {
244
            let Some(node) = loaded.snapshot.files.get(p).map(|f| f.node) else { return Err(fail(format!("{p} is not in that state"))) };
245
            loaded
246
                .state
247
                .changes()
248
                .filter(|(h, _)| {
249
                    repo.store()
250
                        .get_change(h)
251
                        .map(|c| c.tree_ops.iter().any(|o| o.node() == node) || c.edits.iter().any(|x| x.node() == node))
252
                        .unwrap_or(false)
253
                })
254
                .map(|(h, m)| (*h, m.clone()))
255
                .collect()
256
        }
257
        None => loaded.state.changes().map(|(h, m)| (*h, m.clone())).collect(),
d23464c0 Andy McCall ↶ 258
    };
9ff27da5 Andy McCall ↶ 259
    entries.sort_by(|a, b| b.1.hlc.cmp(&a.1.hlc).then(b.0.cmp(&a.0)));
d23464c0 Andy McCall ↶ 260
    let total = entries.len();
261
    let labels = head_labels(repo)?;
262
    let window: Vec<ChangeView> =
9ff27da5 Andy McCall ↶ 263
        entries.iter().skip(offset).take(limit).map(|(h, m)| change_view(repo, h, m, head_set.contains(h), &labels)).collect();
d23464c0 Andy McCall ↶ 264
    Ok(History { target, entries: window, total, offset, has_more: offset + limit < total, path: path.map(String::from) })
265
}
266
267
// ----------------------------------------------------------------- change
268
269
#[derive(Clone, Debug)]
270
pub struct ChangeDetail {
271
    pub view: ChangeView,
272
    pub dependents: Vec<ChangeView>,
273
    pub deps: Vec<ChangeView>,
274
    pub files: Vec<FileDiff>,
275
    /// Base content of each modified file, for context lines.
276
    pub bases: BTreeMap<String, Vec<u8>>,
277
    pub in_bookmarks: Vec<String>,
278
    pub in_tags: Vec<String>,
279
    pub atoms: usize,
280
    pub tree_ops: usize,
281
}
282
9ff27da5 Andy McCall ↶ 283
pub fn change(repo: &Repo, cache: &StateCache, hash: &ChangeHash) -> Result<ChangeDetail> {
d23464c0 Andy McCall ↶ 284
    let c: Change = repo.store().get_change(hash)?;
285
    let labels = head_labels(repo)?;
286
    let heads = all_heads(repo)?;
9ff27da5 Andy McCall ↶ 287
    let loaded = cache.load(repo, &heads)?;
288
    let state = &loaded.state;
d23464c0 Andy McCall ↶ 289
    let is_head = state.heads().contains(hash);
290
    let meta = oxy_core::state::ChangeMeta::of(&c);
291
    let view = change_view(repo, hash, &meta, is_head, &labels);
292
    let deps = c.deps.iter().filter_map(|d| state.get(d).map(|m| change_view(repo, d, m, state.heads().contains(d), &labels))).collect();
293
    let mut dependents: Vec<ChangeView> = state
294
        .changes()
295
        .filter(|(_, m)| m.deps.contains(hash))
296
        .map(|(h, m)| change_view(repo, h, m, state.heads().contains(h), &labels))
297
        .collect();
298
    dependents.sort_by_key(|c| std::cmp::Reverse(c.when_ms));
9ff27da5 Andy McCall ↶ 299
    let base = cache.load(repo, &c.deps)?;
300
    let files = crate::render::diff::change_files(&c, &base.state, &base.snapshot);
301
    let base_snapshot = &base.snapshot;
d23464c0 Andy McCall ↶ 302
    let mut bases = BTreeMap::new();
303
    for f in &files {
304
        let old = f.from.as_deref().unwrap_or(&f.path);
305
        if let Some(FileContent::Text { bytes, .. }) = base_snapshot.files.get(old).map(|s| &s.content) {
306
            bases.insert(f.path.clone(), bytes.clone());
307
        }
308
    }
309
    let mut in_bookmarks = Vec::new();
310
    for b in repo.bookmarks()? {
2e640ffa Andy McCall ↶ 311
        if cache.summary(repo, &b.heads)?.changes.contains(hash) {
d23464c0 Andy McCall ↶ 312
            in_bookmarks.push(b.name);
313
        }
314
    }
315
    let mut in_tags = Vec::new();
316
    for t in repo.tags()? {
2e640ffa Andy McCall ↶ 317
        if cache.summary(repo, &t.heads)?.changes.contains(hash) {
d23464c0 Andy McCall ↶ 318
            in_tags.push(t.name);
319
        }
320
    }
321
    Ok(ChangeDetail { view, dependents, deps, files, bases, in_bookmarks, in_tags, atoms: c.edits.len(), tree_ops: c.tree_ops.len() })
322
}
323
324
// ------------------------------------------------------------------- tree
325
326
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
327
pub enum EntryKind {
328
    Dir,
329
    File,
330
    Symlink,
331
    Subrepo,
332
}
333
334
#[derive(Clone, Debug)]
335
pub struct Entry {
336
    pub name: String,
337
    pub path: String,
338
    pub kind: EntryKind,
339
    pub exec: bool,
340
    pub size: Option<u64>,
341
    pub conflicted: bool,
342
}
343
344
#[derive(Clone, Debug)]
345
pub struct Tree {
346
    pub target: RefTarget,
347
    pub path: String,
348
    pub entries: Vec<Entry>,
349
    /// `(name, path)` from the root down to this directory.
350
    pub crumbs: Vec<(String, String)>,
351
    pub readme: Option<Readme>,
352
}
353
354
#[derive(Clone, Debug)]
355
pub struct Readme {
356
    pub path: String,
357
    pub text: String,
358
}
359
360
pub fn crumbs(path: &str) -> Vec<(String, String)> {
361
    let mut out = Vec::new();
362
    let mut so_far = String::new();
363
    for part in path.split('/').filter(|p| !p.is_empty()) {
364
        if !so_far.is_empty() {
365
            so_far.push('/');
366
        }
367
        so_far.push_str(part);
368
        out.push((part.to_string(), so_far.clone()));
369
    }
370
    out
371
}
372
373
fn entry_of(path: &str, name: &str, f: &oxy_core::state::FileState) -> Entry {
374
    let kind = match f.kind {
375
        NodeKind::Dir => EntryKind::Dir,
376
        NodeKind::File => EntryKind::File,
377
        NodeKind::Symlink => EntryKind::Symlink,
378
        NodeKind::Subrepo => EntryKind::Subrepo,
379
    };
380
    let size = match &f.content {
381
        FileContent::Text { bytes, .. } => Some(bytes.len() as u64),
382
        _ => None,
383
    };
384
    Entry { name: name.to_string(), path: path.to_string(), kind, exec: f.mode.exec, size, conflicted: f.is_conflicted() }
385
}
386
387
/// The entries directly under `dir` (`""` for the root).
9ff27da5 Andy McCall ↶ 388
pub fn tree(repo: &Repo, cache: &StateCache, target: RefTarget, dir: &str) -> Result<Tree> {
389
    let loaded = cache.load(repo, &target.heads)?;
390
    tree_of(repo, target, &loaded.snapshot, dir)
d23464c0 Andy McCall ↶ 391
}
392
393
pub fn tree_of(_repo: &Repo, target: RefTarget, snapshot: &Snapshot, dir: &str) -> Result<Tree> {
394
    let dir = dir.trim_matches('/');
395
    if !dir.is_empty() {
396
        match snapshot.files.get(dir) {
397
            Some(f) if f.kind == NodeKind::Dir => {}
398
            _ => return Err(RepoError::NotFound),
399
        }
400
    }
401
    let prefix = if dir.is_empty() { String::new() } else { format!("{dir}/") };
402
    let mut entries: Vec<Entry> = snapshot
403
        .files
404
        .iter()
405
        .filter(|(p, _)| p.starts_with(&prefix) && !p[prefix.len()..].contains('/'))
406
        .map(|(p, f)| entry_of(p, &p[prefix.len()..], f))
407
        .collect();
408
    entries.sort_by(|a, b| {
409
        (a.kind != EntryKind::Dir).cmp(&(b.kind != EntryKind::Dir)).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
410
    });
411
    let readme = entries
412
        .iter()
413
        .find(|e| {
414
            e.kind == EntryKind::File && matches!(e.name.to_lowercase().as_str(), "readme.md" | "readme" | "readme.txt" | "readme.markdown")
415
        })
416
        .and_then(|e| match &snapshot.files.get(&e.path)?.content {
417
            FileContent::Text { bytes, .. } => Some(Readme { path: e.path.clone(), text: String::from_utf8_lossy(bytes).into_owned() }),
418
            _ => None,
419
        });
420
    Ok(Tree { target, path: dir.to_string(), entries, crumbs: crumbs(dir), readme })
421
}
422
423
// ------------------------------------------------------------------- file
424
425
#[derive(Clone, Debug)]
426
pub enum FileBody {
427
    Text(String),
428
    Binary,
429
    TooLarge,
430
}
431
432
#[derive(Clone, Debug)]
433
pub struct FileView {
434
    pub target: RefTarget,
435
    pub path: String,
436
    pub name: String,
437
    pub crumbs: Vec<(String, String)>,
438
    pub kind: EntryKind,
439
    pub exec: bool,
440
    pub size: u64,
441
    pub body: FileBody,
442
    pub conflicted: bool,
443
    /// Raw bytes, for the `?plain=1` response and images.
444
    pub bytes: Vec<u8>,
445
    pub last_change: Option<ChangeView>,
446
}
447
9ff27da5 Andy McCall ↶ 448
pub fn file(repo: &Repo, cache: &StateCache, target: RefTarget, path: &str, render_limit: u64) -> Result<FileView> {
449
    let loaded = cache.load(repo, &target.heads)?;
450
    let snapshot = &loaded.snapshot;
d23464c0 Andy McCall ↶ 451
    let path = path.trim_matches('/');
452
    let Some(f) = snapshot.files.get(path) else { return Err(RepoError::NotFound) };
453
    let (kind, bytes) = match (&f.kind, &f.content) {
454
        (NodeKind::Dir, _) => return Err(RepoError::NotFound),
455
        (NodeKind::Symlink, FileContent::Text { bytes, .. }) => (EntryKind::Symlink, bytes.clone()),
456
        (NodeKind::Subrepo, FileContent::Text { bytes, .. }) => (EntryKind::Subrepo, bytes.clone()),
457
        (_, FileContent::Text { bytes, .. }) => (EntryKind::File, bytes.clone()),
458
        (_, FileContent::Opaque { blobs }) => {
459
            let bytes = match blobs.first() {
460
                Some(b) => repo.read_blob(b)?,
461
                None => Vec::new(),
462
            };
463
            (EntryKind::File, bytes)
464
        }
465
        (_, FileContent::Dir) => return Err(RepoError::NotFound),
466
    };
467
    let size = bytes.len() as u64;
468
    let body = if size > render_limit {
469
        FileBody::TooLarge
470
    } else if is_binary(&bytes) {
471
        FileBody::Binary
472
    } else {
473
        FileBody::Text(String::from_utf8_lossy(&bytes).into_owned())
474
    };
475
    let labels = head_labels(repo)?;
476
    let last_change = repo
477
        .log_for_path_in(path, &target.heads)
478
        .ok()
479
        .and_then(|l| l.into_iter().next())
480
        .map(|e| change_view(repo, &e.hash, &e.change, e.is_head, &labels));
481
    let name = path.rsplit('/').next().unwrap_or(path).to_string();
482
    Ok(FileView {
483
        target,
484
        path: path.to_string(),
485
        name,
486
        crumbs: crumbs(path),
487
        kind,
488
        exec: f.mode.exec,
489
        size,
490
        body,
491
        conflicted: f.is_conflicted(),
492
        bytes,
493
        last_change,
494
    })
495
}
496
497
pub fn is_binary(bytes: &[u8]) -> bool {
498
    let head = &bytes[..bytes.len().min(8192)];
499
    head.contains(&0)
500
        || std::str::from_utf8(head).is_err()
501
            && head.iter().filter(|b| **b < 0x20 && !matches!(**b, b'\n' | b'\r' | b'\t')).count() > head.len() / 20
502
}
503
504
/// Last change per entry of a directory, for tree rows (fetched lazily).
505
/// A directory's last change is the newest change touching anything
506
/// beneath it, as GitHub shows it.
9ff27da5 Andy McCall ↶ 507
pub fn last_changes(repo: &Repo, cache: &StateCache, heads: &[ChangeHash], paths: &[String]) -> Result<Vec<(String, ChangeView)>> {
d23464c0 Andy McCall ↶ 508
    let labels = head_labels(repo)?;
9ff27da5 Andy McCall ↶ 509
    let loaded = cache.load(repo, heads)?;
510
    let state = &loaded.state;
511
    let snapshot = &loaded.snapshot;
d23464c0 Andy McCall ↶ 512
    // Newest change touching each node, from one pass over the log.
513
    let mut newest: HashMap<oxy_core::ids::NodeId, (ChangeHash, u64)> = HashMap::new();
514
    for e in repo.log_state(heads)? {
515
        let Ok(c) = repo.store().get_change(&e.hash) else { continue };
516
        let when = e.change.hlc.wall_ms;
517
        for node in c.tree_ops.iter().map(|o| o.node()).chain(c.edits.iter().map(|x| x.node())) {
518
            newest.entry(node).or_insert((e.hash, when));
519
        }
520
    }
521
    let mut out = Vec::new();
522
    for p in paths {
523
        let Some(f) = snapshot.files.get(p) else { continue };
524
        let best = if f.kind == NodeKind::Dir {
525
            let prefix = format!("{p}/");
526
            snapshot
527
                .files
528
                .iter()
529
                .filter(|(q, _)| q.starts_with(&prefix))
530
                .filter_map(|(_, g)| newest.get(&g.node))
531
                .chain(newest.get(&f.node))
532
                .max_by_key(|(_, when)| *when)
533
                .copied()
534
        } else {
535
            newest.get(&f.node).copied()
536
        };
537
        if let Some((hash, _)) = best {
538
            if let Some(m) = state.get(&hash) {
539
                out.push((p.clone(), change_view(repo, &hash, m, state.heads().contains(&hash), &labels)));
540
            }
541
        }
542
    }
543
    Ok(out)
544
}
545
546
// ------------------------------------------------------------------ blame
547
548
#[derive(Clone, Debug)]
549
pub struct Blame {
550
    pub file: FileView,
551
    pub lines: Vec<BlameLine>,
552
}
553
9ff27da5 Andy McCall ↶ 554
pub fn blame(repo: &Repo, cache: &StateCache, target: RefTarget, path: &str, render_limit: u64) -> Result<Blame> {
d23464c0 Andy McCall ↶ 555
    let heads = target.heads.clone();
9ff27da5 Andy McCall ↶ 556
    let file = file(repo, cache, target, path, render_limit)?;
d23464c0 Andy McCall ↶ 557
    let lines = repo.blame_in(path, &heads)?;
558
    Ok(Blame { file, lines })
559
}
560
561
// -------------------------------------------------------------- overview
562
563
#[derive(Clone, Debug)]
564
pub struct BookmarkView {
565
    pub name: String,
566
    pub heads: Vec<String>,
567
    pub changes: usize,
568
    pub default: bool,
569
    pub conflicted: bool,
570
    pub latest_ms: u64,
571
}
572
573
#[derive(Clone, Debug)]
574
pub struct TagView {
575
    pub name: String,
576
    pub heads: Vec<String>,
577
    pub message: String,
578
    pub signer: Option<String>,
579
}
580
581
#[derive(Clone, Debug)]
582
pub struct Overview {
583
    pub tree: Tree,
584
    pub changes: usize,
585
    pub heads: usize,
586
    pub bookmarks: Vec<BookmarkView>,
587
    pub tags: Vec<TagView>,
588
    pub latest: Option<ChangeView>,
589
    pub conflicted: bool,
590
    pub license: Option<String>,
591
    pub policy: PolicyBadge,
592
}
593
594
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
595
pub enum PolicyBadge {
596
    None,
597
    Active,
598
    Expired,
599
}
600
9ff27da5 Andy McCall ↶ 601
pub fn bookmarks(repo: &Repo, cache: &StateCache, default: &str) -> Result<Vec<BookmarkView>> {
d23464c0 Andy McCall ↶ 602
    let mut out = Vec::new();
603
    for b in repo.bookmarks()? {
2e640ffa Andy McCall ↶ 604
        let s = cache.summary(repo, &b.heads)?;
d23464c0 Andy McCall ↶ 605
        out.push(BookmarkView {
606
            name: b.name.clone(),
607
            heads: b.heads.iter().map(|h| h.to_hex()).collect(),
2e640ffa Andy McCall ↶ 608
            changes: s.changes.len(),
d23464c0 Andy McCall ↶ 609
            default: b.name == default,
2e640ffa Andy McCall ↶ 610
            conflicted: s.conflicted,
611
            latest_ms: s.latest_ms,
d23464c0 Andy McCall ↶ 612
        });
613
    }
614
    out.sort_by(|a, b| b.default.cmp(&a.default).then_with(|| a.name.cmp(&b.name)));
615
    Ok(out)
616
}
617
618
pub fn tags(repo: &Repo) -> Result<Vec<TagView>> {
619
    let mut out: Vec<TagView> = repo
620
        .tags()?
621
        .into_iter()
622
        .map(|t| TagView { name: t.name, heads: t.heads.iter().map(|h| h.to_hex()).collect(), message: t.message, signer: t.signer })
623
        .collect();
624
    out.sort_by(|a, b| b.name.cmp(&a.name));
625
    Ok(out)
626
}
627
628
pub fn policy_badge(repo: &Repo) -> PolicyBadge {
629
    match repo.current_policy() {
630
        Ok(Some((_, sp))) => {
631
            if sp.policy.expires != 0 && (sp.policy.expires as i64) < crate::db::now_ms() {
632
                PolicyBadge::Expired
633
            } else {
634
                PolicyBadge::Active
635
            }
636
        }
637
        _ => PolicyBadge::None,
638
    }
639
}
640
9ff27da5 Andy McCall ↶ 641
pub fn overview(repo: &Repo, cache: &StateCache, default_bookmark: &str) -> Result<Overview> {
642
    let target = match resolve(repo, cache, default_bookmark) {
d23464c0 Andy McCall ↶ 643
        Ok(t) => t,
644
        Err(_) => RefTarget { label: default_bookmark.into(), kind: RefKind::Bookmark, heads: vec![] },
645
    };
646
    let heads = target.heads.len();
647
    // One load of the default state serves the counts, the latest change
648
    // and the tree (hub spec §16.3).
9ff27da5 Andy McCall ↶ 649
    let loaded = cache.load(repo, &target.heads)?;
650
    let state = &loaded.state;
651
    let snapshot = &loaded.snapshot;
d23464c0 Andy McCall ↶ 652
    let changes = state.len();
653
    let conflicted = snapshot.is_conflicted();
654
    let labels = head_labels(repo)?;
655
    let latest = state
656
        .changes()
657
        .max_by(|(ha, a), (hb, b)| a.hlc.cmp(&b.hlc).then(ha.cmp(hb)))
658
        .map(|(h, m)| change_view(repo, h, m, state.heads().contains(h), &labels));
9ff27da5 Andy McCall ↶ 659
    let tree = tree_of(repo, target, snapshot, "")?;
d23464c0 Andy McCall ↶ 660
    let license = tree
661
        .entries
662
        .iter()
663
        .find(|e| matches!(e.name.to_lowercase().as_str(), "license" | "license.md" | "license.txt" | "licence" | "copying"))
664
        .map(|e| e.path.clone());
665
    Ok(Overview {
666
        tree,
667
        changes,
668
        heads,
9ff27da5 Andy McCall ↶ 669
        bookmarks: bookmarks(repo, cache, default_bookmark)?,
d23464c0 Andy McCall ↶ 670
        tags: tags(repo)?,
671
        latest,
672
        conflicted,
673
        license,
674
        policy: policy_badge(repo),
675
    })
676
}
677
678
// -------------------------------------------------------------- compare
679
680
#[derive(Clone, Debug)]
681
pub struct Compare {
682
    pub base: RefTarget,
683
    pub head: RefTarget,
684
    /// Changes in `head`'s state not in `base`'s.
685
    pub added: Vec<ChangeView>,
686
    /// Changes in `base`'s state not in `head`'s.
687
    pub missing: usize,
688
    pub files: Vec<FileDiff>,
689
    pub bases: BTreeMap<String, Vec<u8>>,
690
}
691
9ff27da5 Andy McCall ↶ 692
pub fn compare(repo: &Repo, cache: &StateCache, base: RefTarget, head: RefTarget) -> Result<Compare> {
693
    let base_loaded = cache.load(repo, &base.heads)?;
694
    let head_loaded = cache.load(repo, &head.heads)?;
695
    let base_state = &base_loaded.state;
696
    let head_state = &head_loaded.state;
d23464c0 Andy McCall ↶ 697
    let labels = head_labels(repo)?;
698
    let mut added: Vec<ChangeView> = head_state
699
        .changes()
700
        .filter(|(h, _)| !base_state.contains(h))
701
        .map(|(h, m)| change_view(repo, h, m, head_state.heads().contains(h), &labels))
702
        .collect();
703
    added.sort_by_key(|c| std::cmp::Reverse(c.when_ms));
704
    let missing = base_state.changes().filter(|(h, _)| !head_state.contains(h)).count();
9ff27da5 Andy McCall ↶ 705
    let (files, bases) = crate::render::diff::snapshot_diff(&base_loaded.snapshot, &head_loaded.snapshot);
d23464c0 Andy McCall ↶ 706
    Ok(Compare { base, head, added, missing, files, bases })
707
}
708
709
// ------------------------------------------------------------ conflicts
710
711
#[derive(Clone, Debug)]
712
pub struct ConflictView {
713
    pub bookmark: String,
714
    pub path: String,
715
    pub kind: String,
716
    /// The file rendered with `oxy` conflict markers, when a text file.
717
    pub marked: Option<String>,
718
    pub contributors: Vec<ChangeView>,
719
}
720
9ff27da5 Andy McCall ↶ 721
pub fn conflicts(repo: &Repo, cache: &StateCache) -> Result<Vec<ConflictView>> {
d23464c0 Andy McCall ↶ 722
    let labels = head_labels(repo)?;
723
    let mut out = Vec::new();
724
    for b in repo.bookmarks()? {
2e640ffa Andy McCall ↶ 725
        // Only a conflicted state is loaded whole.
726
        if !cache.summary(repo, &b.heads)?.conflicted {
727
            continue;
728
        }
9ff27da5 Andy McCall ↶ 729
        let loaded = cache.load(repo, &b.heads)?;
730
        let state = &loaded.state;
731
        let snapshot = &loaded.snapshot;
d23464c0 Andy McCall ↶ 732
        for c in &snapshot.tree.conflicts {
9ff27da5 Andy McCall ↶ 733
            let (path, kind) = describe_tree_conflict(snapshot, c);
d23464c0 Andy McCall ↶ 734
            out.push(ConflictView { bookmark: b.name.clone(), path, kind, marked: None, contributors: vec![] });
735
        }
736
        for (path, f) in &snapshot.files {
737
            if !f.is_conflicted() {
738
                continue;
739
            }
740
            let (kind, marked, contributors) = match &f.content {
741
                FileContent::Text { .. } => {
742
                    let (bytes, _) = state.render_file(&f.node);
743
                    let regions = state.conflict_regions(&f.node);
744
                    let contributors: Vec<ChangeView> = regions
745
                        .contributors
746
                        .iter()
747
                        .filter_map(|h| state.get(h).map(|m| change_view(repo, h, m, state.heads().contains(h), &labels)))
748
                        .collect();
749
                    ("lines".to_string(), Some(String::from_utf8_lossy(&bytes).into_owned()), contributors)
750
                }
751
                FileContent::Opaque { blobs } => (format!("replace ({} candidates)", blobs.len()), None, vec![]),
752
                FileContent::Dir => continue,
753
            };
754
            out.push(ConflictView { bookmark: b.name.clone(), path: path.clone(), kind, marked, contributors });
755
        }
756
    }
757
    Ok(out)
758
}
759
760
fn describe_tree_conflict(snapshot: &Snapshot, c: &TreeConflict) -> (String, String) {
761
    let by_node = snapshot.tree.by_node();
762
    let path_of = |n: &oxy_core::ids::NodeId| by_node.get(n).map(|e| e.path.clone()).unwrap_or_else(|| n.short(8));
763
    match c {
764
        TreeConflict::Name { name, nodes, .. } => (name.clone(), format!("name: {} entries want this name", nodes.len())),
765
        TreeConflict::MultiPlacement { node, placements } => (path_of(node), format!("placement: {} places", placements.len())),
766
        TreeConflict::Orphan { node, .. } => (path_of(node), "orphan: parent directory was removed".into()),
767
        TreeConflict::Cycle { node } => (path_of(node), "cycle: directory moved into itself".into()),
768
        other => (String::new(), format!("{other:?}")),
769
    }
770
}
771
772
// ------------------------------------------------------------------ trust
773
774
#[derive(Clone, Debug)]
775
pub struct KeyView {
776
    pub key: String,
777
    pub short: String,
778
    pub name: String,
779
    pub email: String,
780
}
781
782
#[derive(Clone, Debug)]
783
pub struct RuleView {
784
    pub pattern: String,
785
    pub keys: Vec<KeyView>,
786
    pub threshold: u32,
787
    pub require: String,
788
    pub allow_conflicts: bool,
789
}
790
791
#[derive(Clone, Debug)]
792
pub struct PolicyView {
793
    pub serial: u64,
794
    pub expires_ms: u64,
795
    pub expired: bool,
796
    pub roots: Vec<KeyView>,
797
    pub root_threshold: u32,
798
    pub rules: Vec<RuleView>,
799
    pub revoked: Vec<KeyView>,
800
    pub signatures: usize,
801
    pub history: usize,
802
}
803
804
#[derive(Clone, Debug, Default)]
805
pub struct Verification {
806
    pub total: usize,
807
    pub signed: usize,
808
    pub sealed: usize,
809
    pub unsigned: usize,
810
    pub revoked: usize,
811
    pub invalid: Vec<(String, String)>,
812
}
813
814
#[derive(Clone, Debug)]
815
pub struct Trust {
816
    pub policy: Option<PolicyView>,
817
    pub verification: Verification,
818
    pub hub_key: String,
819
    pub hub_delegated_by: Vec<KeyView>,
820
}
821
822
fn key_view(repo: &Repo, ids: &[oxy_core::identity::Identity], key: &KeyId) -> KeyView {
823
    let found = ids.iter().find(|i| i.hash() == *key).cloned().or_else(|| repo.store().get_identity(key).ok());
824
    match found {
825
        Some(i) => KeyView { key: key.to_hex(), short: key.short(12), name: i.name, email: i.email },
826
        None => KeyView { key: key.to_hex(), short: key.short(12), name: String::new(), email: String::new() },
827
    }
828
}
829
9ff27da5 Andy McCall ↶ 830
pub fn verification(repo: &Repo, cache: &StateCache) -> Result<Verification> {
d23464c0 Andy McCall ↶ 831
    let heads = all_heads(repo)?;
9ff27da5 Andy McCall ↶ 832
    let loaded = cache.load(repo, &heads)?;
833
    let state = &loaded.state;
d23464c0 Andy McCall ↶ 834
    let mut v = Verification::default();
835
    for (h, _) in state.changes() {
836
        v.total += 1;
837
        match signature(repo, h) {
838
            SigView::Unsigned => v.unsigned += 1,
839
            SigView::Valid { sealed, revoked, .. } => {
840
                if revoked {
841
                    v.revoked += 1;
842
                }
843
                if sealed {
844
                    v.sealed += 1
845
                } else {
846
                    v.signed += 1
847
                }
848
            }
849
            SigView::Invalid(why) => v.invalid.push((h.to_hex(), why)),
850
        }
851
    }
852
    Ok(v)
853
}
854
9ff27da5 Andy McCall ↶ 855
pub fn trust(repo: &Repo, cache: &StateCache, hub_key: &KeyId) -> Result<Trust> {
d23464c0 Andy McCall ↶ 856
    let policy = match repo.current_policy()? {
857
        Some((_, sp)) => {
858
            let p = &sp.policy;
859
            let ids = &p.identities;
860
            let require = |r: &oxy_core::policy::Require| match r {
861
                oxy_core::policy::Require::Signed => "signed",
862
                oxy_core::policy::Require::Sealed => "sealed",
863
                oxy_core::policy::Require::Any => "any",
864
            };
865
            Some(PolicyView {
866
                serial: p.serial,
867
                expires_ms: p.expires,
868
                expired: p.expires != 0 && (p.expires as i64) < crate::db::now_ms(),
869
                roots: p.roots.iter().map(|k| key_view(repo, ids, k)).collect(),
870
                root_threshold: p.root_threshold,
871
                rules: p
872
                    .rules
873
                    .iter()
874
                    .map(|r| RuleView {
875
                        pattern: r.pattern.clone(),
876
                        keys: r.keys.iter().map(|k| key_view(repo, ids, k)).collect(),
877
                        threshold: r.threshold,
878
                        require: require(&r.require).into(),
879
                        allow_conflicts: r.allow_conflicts,
880
                    })
881
                    .collect(),
882
                revoked: p.revoked.iter().map(|k| key_view(repo, ids, k)).collect(),
883
                signatures: sp.sigs.len(),
884
                history: repo.policy_history().map(|h| h.len()).unwrap_or(0),
885
            })
886
        }
887
        None => None,
888
    };
889
    // Who has delegated to the hub: every identity in the policy or store
890
    // that lists the hub key.
891
    let mut delegated = Vec::new();
892
    if let Some(p) = repo.current_policy()?.map(|(_, sp)| sp.policy) {
893
        for i in &p.identities {
894
            if i.delegates.contains(hub_key) {
895
                delegated.push(KeyView { key: i.hash().to_hex(), short: i.hash().short(12), name: i.name.clone(), email: i.email.clone() });
896
            }
897
        }
898
    }
9ff27da5 Andy McCall ↶ 899
    Ok(Trust { policy, verification: verification(repo, cache)?, hub_key: hub_key.to_hex(), hub_delegated_by: delegated })
d23464c0 Andy McCall ↶ 900
}