a73x

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);
    }
}