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