a73x

src/refs.rs

Ref:   Size: 20.3 KiB   History

//! What collab refs a repository holds, and what each of them is.
//!
//! The layout nests: a patch is `refs/collab/patches/<id>/events` with a
//! `<id>/rev/<oid>` beside it for every revision. That is fine for the tool,
//! which walks it with git2's `references_glob` — whose `*` crosses `/` — and a
//! trap for everyone else, because `git for-each-ref`'s globs do **not** cross
//! `/`. So the pattern printed in the README, `refs/collab/*`, matches nothing
//! below the first level and reports it as silence rather than as an error.
//!
//! Two different tools, two different meanings for one pattern, and no
//! diagnostic on the wrong guess. This module exists so nobody has to know
//! which side of that boundary they are standing on: the tool already knows how
//! to enumerate its own refs, so it can be asked.
//!
//! ## This is a read
//!
//! Nothing here writes. That mattered when every other command converted a
//! pre-release layout on the way past — a command that migrated before it
//! looked could not answer *does this repository still hold a pre-migration
//! shape?* — and a server-side read that wrote to the repository it was reading
//! was already one real bug (issue 5174338f).
//!
//! The conversion is gone (issue `e5096ffc`), which leaves this module holding
//! the only description of those shapes that still exists. A reader that meets
//! one now refuses and names this command, so `refs` has to keep working
//! exactly where everything else stops: it is a read, it takes no lock, and it
//! never touches what it reports.

use std::collections::BTreeMap;
use std::io::Write;

use git2::Repository;

/// The whole namespace. Deliberately without a glob: `references_glob` would
/// cross `/` here and match everything anyway, but naming the prefix says what
/// is meant.
const COLLAB_PREFIX: &str = "refs/collab/";

/// What a collab ref is, as far as its name can say.
///
/// Classification is by name alone: no object is read, so a ref pointing at a
/// missing or corrupt object is still reported rather than swallowed, which is
/// the case an operator debugging a half-finished sync most needs to see.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefKind {
    /// `issues/<id>` — an issue's event DAG.
    Issue,
    /// `patches/<id>/events` — a patch's event DAG, current layout.
    PatchEvents,
    /// `patches/<id>` — a patch's event DAG in the pre-migration layout, where
    /// the bare id ref *was* the DAG.
    PatchEventsLegacy,
    /// `patches/<id>/rev/<oid>` — a revision's commit, pinned by its OID.
    PatchRevision,
    /// `patches/<id>/r/<n>` — a revision pinned by number, from the draft the
    /// OID naming replaced.
    PatchRevisionNumbered,
    /// `local/…` — this clone's own bookkeeping (read markers, migration
    /// parks). Never pushed.
    Local,
    /// `sync/…` — scratch space a fetch writes and a sync clears.
    Sync,
    /// Under `refs/collab/` and matching nothing this version knows.
    Other,
}

impl RefKind {
    /// The stable machine name, for `--json`.
    pub fn slug(self) -> &'static str {
        match self {
            RefKind::Issue => "issue",
            RefKind::PatchEvents => "patch-events",
            RefKind::PatchEventsLegacy => "patch-events-legacy",
            RefKind::PatchRevision => "patch-revision",
            RefKind::PatchRevisionNumbered => "patch-revision-numbered",
            RefKind::Local => "local",
            RefKind::Sync => "sync",
            RefKind::Other => "other",
        }
    }

    /// What it is, in words, for the column a person reads.
    pub fn label(self) -> &'static str {
        match self {
            RefKind::Issue => "issue",
            RefKind::PatchEvents => "patch events",
            RefKind::PatchEventsLegacy => "patch events (legacy layout)",
            RefKind::PatchRevision => "patch revision",
            RefKind::PatchRevisionNumbered => "patch revision (legacy numbering)",
            RefKind::Local => "local bookkeeping",
            RefKind::Sync => "sync scratch",
            RefKind::Other => "unrecognised",
        }
    }

    /// Whether this shape is one written by a pre-release git-collab.
    ///
    /// Classifying these outlived the code that converted them, and that is the
    /// point: the migration was removed in issue `e5096ffc` once every
    /// repository we host was confirmed clear of them, so nothing reads these
    /// shapes any more. This is the only thing that still recognises one, which
    /// makes it the diagnostic every refusal elsewhere points at.
    pub fn is_legacy(self) -> bool {
        matches!(
            self,
            RefKind::PatchEventsLegacy | RefKind::PatchRevisionNumbered
        )
    }

    /// The plural noun this kind counts as in a summary, coarser than the kind
    /// itself: both patch layouts are patches, both revision namings are
    /// revisions.
    fn group(self) -> &'static str {
        match self {
            RefKind::Issue => "issues",
            RefKind::PatchEvents | RefKind::PatchEventsLegacy => "patches",
            RefKind::PatchRevision | RefKind::PatchRevisionNumbered => "revisions",
            RefKind::Local => "local refs",
            RefKind::Sync => "sync refs",
            RefKind::Other => "unrecognised refs",
        }
    }
}

/// One collab ref, named in full.
///
/// Full names and full OIDs throughout: this is the output an operator pastes
/// into `git update-ref`, and an abbreviation that has to be expanded again is
/// not a diagnostic.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CollabRef {
    /// The full ref name.
    pub name: String,
    /// The full OID the ref points at, or empty if it could not be resolved.
    pub target: String,
    pub kind: RefKind,
    /// The full id of the issue or patch this ref belongs to, where it belongs
    /// to one. A revision ref carries its *patch's* id — the pinned commit is
    /// the target.
    pub id: Option<String>,
    /// Whether it lives under `refs/collab/archive/`.
    pub archived: bool,
}

impl CollabRef {
    pub fn is_legacy(&self) -> bool {
        self.kind.is_legacy()
    }

    /// The `label()` of the kind, with archived said out loud.
    fn display_label(&self) -> String {
        if self.archived {
            format!("{}, archived", self.kind.label())
        } else {
            self.kind.label().to_string()
        }
    }
}

/// Every ref under `refs/collab/`, classified, sorted by name.
///
/// `references_glob` is what makes this correct where a hand-written
/// `for-each-ref` pattern is not: its `*` crosses `/`, so one pattern reaches
/// the whole subtree.
pub fn scan(repo: &Repository) -> Result<Vec<CollabRef>, crate::error::Error> {
    let mut out = Vec::new();
    for r in repo.references_glob(&format!("{}*", COLLAB_PREFIX))? {
        let r = r?;
        let Some(name) = r.name() else { continue };
        // A symbolic ref has no direct target; resolve it rather than reporting
        // a blank, and leave the blank for the genuinely broken.
        let target = r
            .target()
            .or_else(|| r.resolve().ok().and_then(|r| r.target()))
            .map(|oid| oid.to_string())
            .unwrap_or_default();
        let (kind, id, archived) = classify(name);
        out.push(CollabRef {
            name: name.to_string(),
            target,
            kind,
            id,
            archived,
        });
    }
    out.sort_by(|a, b| a.name.cmp(&b.name));
    Ok(out)
}

/// Work out what a ref name is, by name alone.
fn classify(name: &str) -> (RefKind, Option<String>, bool) {
    let Some(rest) = name.strip_prefix(COLLAB_PREFIX) else {
        return (RefKind::Other, None, false);
    };
    let (rest, archived) = match rest.strip_prefix("archive/") {
        Some(inner) => (inner, true),
        None => (rest, false),
    };

    if let Some(id) = rest.strip_prefix("issues/") {
        // An issue is one ref, flat. Anything deeper is not an issue.
        if id.is_empty() || id.contains('/') {
            return (RefKind::Other, None, archived);
        }
        return (RefKind::Issue, Some(id.to_string()), archived);
    }

    if let Some(patch) = rest.strip_prefix("patches/") {
        return classify_patch(patch, archived);
    }

    // `local/` and `sync/` are namespaces, not items: neither carries an id at
    // a fixed depth, and both are this clone's own business.
    if rest.starts_with("local/") {
        return (RefKind::Local, None, archived);
    }
    if rest.starts_with("sync/") {
        return (RefKind::Sync, None, archived);
    }

    (RefKind::Other, None, archived)
}

/// The part after `patches/`, which is where both layouts differ: the current
/// one always has a suffix, the pre-migration one never does.
fn classify_patch(rest: &str, archived: bool) -> (RefKind, Option<String>, bool) {
    let Some((id, suffix)) = rest.split_once('/') else {
        if rest.is_empty() {
            return (RefKind::Other, None, archived);
        }
        return (RefKind::PatchEventsLegacy, Some(rest.to_string()), archived);
    };
    let id = Some(id.to_string());
    let kind = match suffix {
        "events" => RefKind::PatchEvents,
        s if s.starts_with("rev/") => RefKind::PatchRevision,
        s if s.starts_with("r/") => RefKind::PatchRevisionNumbered,
        _ => return (RefKind::Other, None, archived),
    };
    (kind, id, archived)
}

/// How many refs of each kind, keyed by slug, zeroes omitted.
pub fn counts(refs: &[CollabRef]) -> BTreeMap<&'static str, usize> {
    let mut counts = BTreeMap::new();
    for r in refs {
        *counts.entry(r.kind.slug()).or_insert(0) += 1;
    }
    counts
}

/// How many refs are in a shape a migration would convert.
pub fn legacy_count(refs: &[CollabRef]) -> usize {
    refs.iter().filter(|r| r.is_legacy()).count()
}

/// The listing as one JSON object: the refs, counts by kind, and the two
/// numbers a script actually branches on.
pub fn to_json(refs: &[CollabRef]) -> serde_json::Value {
    let entries: Vec<serde_json::Value> = refs
        .iter()
        .map(|r| {
            serde_json::json!({
                "ref": r.name,
                "target": r.target,
                "kind": r.kind.slug(),
                "id": r.id,
                "archived": r.archived,
                "legacy": r.is_legacy(),
            })
        })
        .collect();
    let counts: serde_json::Map<String, serde_json::Value> = counts(refs)
        .into_iter()
        .map(|(k, v)| (k.to_string(), serde_json::Value::from(v)))
        .collect();
    serde_json::json!({
        "refs": entries,
        "counts": counts,
        "total": refs.len(),
        "legacy": legacy_count(refs),
    })
}

/// How much of an OID to show a person. Full OIDs are in `--json`; a listing is
/// read down a column.
const TARGET_WIDTH: usize = 12;

/// Render the listing for a person: one aligned line per ref, then what it adds
/// up to.
pub fn render(refs: &[CollabRef], out: &mut impl Write) -> std::io::Result<()> {
    if refs.is_empty() {
        writeln!(out, "No collab refs in this repository.")?;
        return Ok(());
    }

    let labels: Vec<String> = refs.iter().map(CollabRef::display_label).collect();
    let width = labels.iter().map(String::len).max().unwrap_or(0);
    for (r, label) in refs.iter().zip(&labels) {
        let target: String = r.target.chars().take(TARGET_WIDTH).collect();
        writeln!(
            out,
            "{:<width$}  {:<target_width$}  {}",
            label,
            target,
            r.name,
            width = width,
            target_width = TARGET_WIDTH
        )?;
    }

    writeln!(out)?;
    writeln!(out, "{} collab refs: {}.", refs.len(), groups(refs))?;

    // The two older shapes are not in the same situation, so they cannot share
    // a sentence. A bare patch ref is read — reporting it as unreadable sent
    // the operator looking for a migration that does not exist and is not
    // needed. A numbered revision ref is genuinely refused, and *that* is what
    // stops every other command in the repository.
    let bare = refs
        .iter()
        .filter(|r| r.kind == RefKind::PatchEventsLegacy)
        .count();
    if bare > 0 {
        writeln!(
            out,
            "{} in the older bare `patches/<id>` layout — read as they are, and \
             left as they are. Nothing needs doing: a patch created here from now \
             on gets the current layout, and these keep working beside it.",
            bare
        )?;
    }

    let numbered = refs
        .iter()
        .filter(|r| r.kind == RefKind::PatchRevisionNumbered)
        .count();
    if numbered > 0 {
        writeln!(
            out,
            "{} in the superseded `<id>/r/<n>` revision numbering — this version \
             does not read these, and every other git-collab command in this \
             repository will refuse until they are gone. They pin revision commits \
             and carry no events, so renaming them to `<id>/rev/<commit-oid>` with \
             `git update-ref`, or deleting them, loses nothing. Both are local; no \
             other copy of this repository is involved.",
            numbered
        )?;
    }
    Ok(())
}

/// `2 issues, 1 patch, 3 revisions`, in a fixed order rather than an alphabetical
/// one, so the shape of the listing does not change with its contents.
pub fn groups(refs: &[CollabRef]) -> String {
    const ORDER: [RefKind; 6] = [
        RefKind::Issue,
        RefKind::PatchEvents,
        RefKind::PatchRevision,
        RefKind::Local,
        RefKind::Sync,
        RefKind::Other,
    ];
    let mut parts = Vec::new();
    for kind in ORDER {
        let n = refs
            .iter()
            .filter(|r| r.kind.group() == kind.group())
            .count();
        if n > 0 {
            parts.push(format!("{} {}", n, singularise(kind.group(), n)));
        }
    }
    parts.join(", ")
}

/// `1 bare patch ref, 2 numbered revision refs` — which superseded shapes, and
/// how many of each, so an operator knows what a migration would touch.
pub fn legacy_breakdown(refs: &[CollabRef]) -> String {
    let mut parts = Vec::new();
    for (kind, noun) in [
        (RefKind::PatchEventsLegacy, "bare patch ref"),
        (RefKind::PatchRevisionNumbered, "numbered revision ref"),
    ] {
        let n = refs.iter().filter(|r| r.kind == kind).count();
        if n > 0 {
            parts.push(format!("{} {}", n, singularise_noun(noun, n)));
        }
    }
    parts.join(", ")
}

/// The group nouns are written plural; one of anything is singular.
fn singularise(plural: &str, n: usize) -> String {
    if n != 1 {
        return plural.to_string();
    }
    match plural {
        "issues" => "issue".to_string(),
        "patches" => "patch".to_string(),
        "revisions" => "revision".to_string(),
        "local refs" => "local ref".to_string(),
        "sync refs" => "sync ref".to_string(),
        other => other.to_string(),
    }
}

/// The legacy nouns are written singular; more than one takes an `s`.
fn singularise_noun(singular: &str, n: usize) -> String {
    if n == 1 {
        singular.to_string()
    } else {
        format!("{}s", singular)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn kind_of(name: &str) -> RefKind {
        classify(name).0
    }

    #[test]
    fn both_patch_layouts_are_told_apart() {
        assert_eq!(
            kind_of("refs/collab/patches/abc"),
            RefKind::PatchEventsLegacy
        );
        assert_eq!(
            kind_of("refs/collab/patches/abc/events"),
            RefKind::PatchEvents
        );
        assert_eq!(
            kind_of("refs/collab/patches/abc/rev/def"),
            RefKind::PatchRevision
        );
        assert_eq!(
            kind_of("refs/collab/patches/abc/r/1"),
            RefKind::PatchRevisionNumbered
        );
    }

    #[test]
    fn a_revision_ref_carries_its_patchs_id() {
        let (_, id, _) = classify("refs/collab/patches/abc/rev/def");
        assert_eq!(id.as_deref(), Some("abc"));
    }

    #[test]
    fn the_archive_namespace_keeps_its_shapes() {
        let (kind, id, archived) = classify("refs/collab/archive/patches/abc/events");
        assert_eq!(kind, RefKind::PatchEvents);
        assert_eq!(id.as_deref(), Some("abc"));
        assert!(archived);

        let (kind, _, archived) = classify("refs/collab/archive/issues/abc");
        assert_eq!(kind, RefKind::Issue);
        assert!(archived);
    }

    #[test]
    fn clone_private_namespaces_are_not_items() {
        assert_eq!(kind_of("refs/collab/local/seen/issues/abc"), RefKind::Local);
        assert_eq!(
            kind_of("refs/collab/local/migrating/patches/abc"),
            RefKind::Local
        );
        assert_eq!(
            kind_of("refs/collab/sync/origin/patches/abc"),
            RefKind::Sync
        );
    }

    #[test]
    fn an_unknown_shape_is_reported_not_guessed() {
        assert_eq!(kind_of("refs/collab/futures/abc"), RefKind::Other);
        assert_eq!(kind_of("refs/collab/issues/abc/extra"), RefKind::Other);
        assert_eq!(kind_of("refs/heads/main"), RefKind::Other);
    }

    #[test]
    fn only_the_superseded_shapes_count_as_legacy() {
        assert!(RefKind::PatchEventsLegacy.is_legacy());
        assert!(RefKind::PatchRevisionNumbered.is_legacy());
        for kind in [
            RefKind::Issue,
            RefKind::PatchEvents,
            RefKind::PatchRevision,
            RefKind::Local,
            RefKind::Sync,
            RefKind::Other,
        ] {
            assert!(!kind.is_legacy(), "{:?} is current", kind);
        }
    }

    fn sample() -> Vec<CollabRef> {
        ["refs/collab/patches/a/events", "refs/collab/patches/b"]
            .into_iter()
            .map(|name| {
                let (kind, id, archived) = classify(name);
                CollabRef {
                    name: name.to_string(),
                    target: "0".repeat(40),
                    kind,
                    id,
                    archived,
                }
            })
            .collect()
    }

    #[test]
    fn the_summary_names_the_older_layout_without_calling_it_broken() {
        // The bare layout is read (issue `05b18df6`), so the summary counts it
        // and stops there. It used to say every other command would refuse,
        // which was true for a while and sent the operator hunting for a
        // migration that no longer exists — the worst kind of stale message,
        // because it is specific enough to be believed.
        let mut out = Vec::new();
        render(&sample(), &mut out).unwrap();
        let text = String::from_utf8(out).unwrap();
        assert!(text.contains("2 collab refs: 2 patches."), "got {}", text);
        assert!(
            text.contains("1 in the older bare `patches/<id>` layout"),
            "got {}",
            text
        );
        assert!(
            !text.contains("will refuse"),
            "the bare layout is read, so nothing may claim otherwise: {}",
            text
        );
    }

    #[test]
    fn the_summary_says_what_to_do_about_a_numbered_revision_ref() {
        // This one *is* refused, so the summary has to say so — and has to
        // offer a remedy that works when every copy of the repository is the
        // same age, which is the case the old "fetch it from a remote holding
        // the current layout" advice could not cover.
        let refs: Vec<CollabRef> = ["refs/collab/patches/a/events", "refs/collab/patches/a/r/1"]
            .into_iter()
            .map(|name| {
                let (kind, id, archived) = classify(name);
                CollabRef {
                    name: name.to_string(),
                    target: "0".repeat(40),
                    kind,
                    id,
                    archived,
                }
            })
            .collect();

        let mut out = Vec::new();
        render(&refs, &mut out).unwrap();
        let text = String::from_utf8(out).unwrap();
        assert!(text.contains("will refuse"), "got {}", text);
        assert!(text.contains("git update-ref"), "got {}", text);
        assert!(
            !text.contains("Fetch the patch from a remote"),
            "the remedy must not require a newer copy to exist: {}",
            text
        );
    }

    #[test]
    fn an_empty_namespace_is_stated() {
        let mut out = Vec::new();
        render(&[], &mut out).unwrap();
        assert_eq!(
            String::from_utf8(out).unwrap(),
            "No collab refs in this repository.\n"
        );
    }
}