src/tui/tips.rs
Ref: Size: 6.7 KiB History
//! Noticing that somebody else wrote, without reading what they wrote.
//!
//! The dashboard used to show whatever was true the last time `r` was pressed,
//! with nothing on screen admitting it. The fix is not to reload on a timer:
//! this is a review surface, and rows appearing, vanishing or reordering while
//! someone is halfway through a comment is worse than being briefly out of
//! date. So the timer only *compares tips* — a ref read — and when they move,
//! a banner says so and the reader presses `r` when they are ready.
//!
//! Deliberately not a fold. `list_issues`/`list_patches` walk every DAG in the
//! repository; doing that every couple of seconds to discover that nothing
//! changed is the cost this exists to avoid.
use git2::{Oid, Repository};
/// Where every collab DAG stood at a moment in time.
#[derive(Debug, Default, Clone, PartialEq)]
pub(crate) struct Tips {
/// `(ref name, tip)`, sorted by name so two snapshots compare directly.
entries: Vec<(String, Oid)>,
}
impl Tips {
/// Read the current tips. Purely a ref read: nothing is folded, nothing is
/// written, and a repository that cannot be enumerated yields an empty
/// snapshot rather than an error the dashboard would have to render.
pub(crate) fn read(repo: &Repository) -> Self {
let mut entries = Vec::new();
if let Ok(refs) = repo.references_glob("refs/collab/**") {
for r in refs.flatten() {
let Some(name) = r.name() else { continue };
// `refs/collab/local/` is this clone's own bookkeeping — the
// `seen/` read markers `patch show` moves, and the migration
// parking spots. Counting those would make reading a patch in
// another terminal look like somebody else said something.
if name.starts_with("refs/collab/local/") {
continue;
}
let Some(oid) = r.target() else { continue };
entries.push((name.to_string(), oid));
}
}
entries.sort();
Tips { entries }
}
/// How many events arrived since `self` was taken.
///
/// Only walked for refs that actually moved, which is the whole point of
/// comparing tips first: on the common tick nothing moved and this is not
/// called at all. A ref that appeared since the snapshot contributes its
/// whole history, which for a new issue or patch is the one or two events
/// that created it.
pub(crate) fn events_since(&self, repo: &Repository, now: &Tips) -> usize {
let mut count = 0usize;
for (name, tip) in &now.entries {
match self.entries.binary_search_by(|(n, _)| n.as_str().cmp(name)) {
Ok(idx) => {
let was = self.entries[idx].1;
if was == *tip {
continue;
}
count += match repo.graph_ahead_behind(*tip, was) {
Ok((ahead, _behind)) => ahead.max(1),
// Unrelated histories, or an object this clone lacks:
// the ref moved, so something happened. Say one rather
// than nothing.
Err(_) => 1,
};
}
Err(_) => count += walk_len(repo, *tip),
}
}
count
}
}
/// Number of commits reachable from `tip`, or 1 when it cannot be walked.
fn walk_len(repo: &Repository, tip: Oid) -> usize {
let Ok(mut walk) = repo.revwalk() else {
return 1;
};
if walk.push(tip).is_err() {
return 1;
}
walk.count().max(1)
}
#[cfg(test)]
mod tests {
use super::*;
/// A repo with one commit, and a helper for pointing collab refs at it.
fn scratch_repo() -> (tempfile::TempDir, Repository, Oid) {
let dir = tempfile::TempDir::new().unwrap();
let repo = Repository::init(dir.path()).unwrap();
let first = {
let sig = git2::Signature::now("t", "t@t").unwrap();
let mut index = repo.index().unwrap();
let tree_oid = index.write_tree().unwrap();
let tree = repo.find_tree(tree_oid).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "one", &tree, &[])
.unwrap()
};
(dir, repo, first)
}
#[test]
fn a_moved_ref_counts_the_events_that_moved_it() {
let (_dir, repo, first) = scratch_repo();
repo.reference("refs/collab/issues/abc", first, true, "seed")
.unwrap();
let before = Tips::read(&repo);
let sig = git2::Signature::now("t", "t@t").unwrap();
let tree = repo.find_commit(first).unwrap().tree().unwrap();
let parent = repo.find_commit(first).unwrap();
let second = repo
.commit(None, &sig, &sig, "two", &tree, &[&parent])
.unwrap();
repo.reference("refs/collab/issues/abc", second, true, "move")
.unwrap();
let after = Tips::read(&repo);
assert_ne!(before, after);
assert_eq!(before.events_since(&repo, &after), 1);
}
/// The `seen/` markers `patch show` moves are this clone's own
/// bookkeeping. Counting them would make reading a patch in another
/// terminal look like somebody else had said something.
#[test]
fn local_bookkeeping_refs_are_not_news() {
let (_dir, repo, first) = scratch_repo();
repo.reference("refs/collab/issues/abc", first, true, "seed")
.unwrap();
let before = Tips::read(&repo);
repo.reference(
"refs/collab/local/seen/patches/abc",
first,
true,
"mark seen",
)
.unwrap();
let after = Tips::read(&repo);
assert_eq!(before, after, "a local marker showed up as a tip");
assert_eq!(before.events_since(&repo, &after), 0);
}
/// A ref that did not exist at the baseline contributes its history — for
/// a new issue, the one event that opened it.
#[test]
fn a_new_ref_counts_as_news() {
let (_dir, repo, first) = scratch_repo();
let before = Tips::read(&repo);
repo.reference("refs/collab/issues/abc", first, true, "seed")
.unwrap();
let after = Tips::read(&repo);
assert_eq!(before.events_since(&repo, &after), 1);
}
#[test]
fn an_unchanged_repository_is_not_news() {
let (_dir, repo, first) = scratch_repo();
repo.reference("refs/collab/issues/abc", first, true, "seed")
.unwrap();
let before = Tips::read(&repo);
let after = Tips::read(&repo);
assert_eq!(before.events_since(&repo, &after), 0);
}
}