diff --git a/README.md b/README.md index 0fa1ddd..c1a3696 100644 --- a/README.md +++ b/README.md @@ -128,11 +128,13 @@ exclude_follow = ["nixpkgs"] # ...except bar's when a target named in `[all_follow]` isn't itself a top-level `[inputs]` pin, `tack update` synthesises a lock entry for it by walking every top-level -flake.lock, collecting the observed revs of the aliased name, and writing the -freshest by `lastModified` into pins.lock.json. the resolver then treats the -synthetic entry as a default flake, or as a bare source tree when its repo has -no `flake.nix`. this lets you dedup transitive inputs (e.g. `crane`) without declaring -them as top-level pins you don't actually consume. +flake.lock, collecting the observed revs of the aliased name, and preferring +the branch-ahead rev when GitHub can compare the commits. when comparison is +unavailable or histories have diverged, it falls back to `lastModified`. the +resolver then treats the synthetic entry as a default flake, or as a bare +source tree when its repo has no `flake.nix`. this lets you dedup transitive +inputs (e.g. `crane`) without declaring them as top-level pins you don't +actually consume. follows reach an upstream's tack pins too, provided the upstream wired its flake for it (see [publishing](#publishing)). when an upstream has diff --git a/src/commands.rs b/src/commands.rs index d8bb2be..bc1ecad 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -35,7 +35,10 @@ use toml_edit::Item; use crate::{ fetch, - fetch::CompareStatus, + fetch::{ + BranchComparison, + CompareStatus, + }, lock, pins, pins::{ @@ -54,6 +57,12 @@ const RESOLVER_NIX: &str = include_str!("../.tack/default.nix"); const SCAFFOLD_FLAKE: &str = include_str!("../templates/default/flake.nix"); const MARKER: &str = "# tack-managed resolver."; +struct UpdateFetch { + node: Value, + rev: String, + comparison: BranchComparison, +} + fn dir() -> PathBuf { if let Some(dir) = env::var_os("TACK_DIR") { return PathBuf::from(dir); @@ -389,13 +398,10 @@ pub fn update(names: &[String], accept: bool) -> Result<()> { let expanded = shorturl::expand(&inp.url, &shorturls); let old = lk.get(&inp.name); let old_rev = old.and_then(lock::rev_of); - let fetched = match inp.pin_type { - PinType::Fixed => fetch::fetch_fixed_pin(&expanded, inp.unpack), - PinType::Flake | PinType::Fetch => fetch::fetch_pin(&expanded, inp.submodules), - }; + let fetched = fetch_for_update(inp, &expanded, old_rev); match fetched { // for fixed pins sha256 is the identity; any mismatch is drift - Ok((node, rev)) + Ok(UpdateFetch { node, rev, .. }) if inp.pin_type == PinType::Fixed && old_rev.is_some() && old_rev != Some(rev.as_str()) => @@ -412,7 +418,7 @@ pub fn update(names: &[String], accept: bool) -> Result<()> { None } }, - Ok((node, rev)) if old_rev == Some(rev.as_str()) => { + Ok(UpdateFetch { node, rev, .. }) if old_rev == Some(rev.as_str()) => { // same rev, if hash moved, upstream changed under a stable rev let drifted = matches!( (old.and_then(lock::hash_of), lock::hash_of(&node)), @@ -434,10 +440,15 @@ pub fn update(names: &[String], accept: bool) -> Result<()> { None } }, - Ok((node, rev)) => { + Ok(UpdateFetch { + node, + rev, + comparison, + }) => { display.set(i, PinStatus::Updated { old: old_rev.map_or_else(|| "NEW".into(), short), new: short(&rev), + comparison, }); Some(node) }, @@ -474,11 +485,38 @@ pub fn update(names: &[String], accept: bool) -> Result<()> { Ok(()) } +fn fetch_for_update( + inp: &pins::Input, + expanded: &str, + old_rev: Option<&str>, +) -> Result { + match inp.pin_type { + PinType::Fixed => { + fetch::fetch_fixed_pin(expanded, inp.unpack).map(|(node, rev)| { + UpdateFetch { + node, + rev, + comparison: BranchComparison::none(), + } + }) + }, + PinType::Flake | PinType::Fetch => { + fetch::fetch_pin_compared(expanded, inp.submodules, old_rev).map(|fetched| { + UpdateFetch { + node: fetched.node, + rev: fetched.rev, + comparison: fetched.comparison, + } + }) + }, + } +} + /// for every `[all_follow]` entry whose target isn't a declared `[inputs]` pin, /// walk all top-level flake.locks once, collect every transitive observation -/// of the aliased name, and write the freshest by `lastModified` to -/// pins.lock.json under the target. also prunes stale auto-dedup entries that -/// no longer have a route. +/// of the aliased name, and write the freshest by branch comparison when +/// possible, falling back to `lastModified`. also prunes stale auto-dedup +/// entries that no longer have a route. fn write_auto_dedup( inputs: &[pins::Input], all_follow: &BTreeMap, @@ -565,10 +603,17 @@ fn write_auto_dedup( } } + let mut compare_cache = HashMap::new(); for (target, mut obs) in observations { - obs.sort_by_key(|entry| cmp::Reverse(entry.0)); - if let Some((_, winner)) = obs.into_iter().next() - && lock.get(&target) != Some(&winner) + if let Some(current) = lock.get(&target) { + let lm = last_modified(current) + .and_then(|lm| i64::try_from(lm).ok()) + .unwrap_or(0); + obs.insert(0, (lm, current.clone())); + } + if let Some(winner) = choose_lock_observation(obs, |base, head| { + compare_locked_nodes(&mut compare_cache, base, head) + }) && lock.get(&target) != Some(&winner) { lock.insert(target, winner); changed = true; @@ -578,6 +623,67 @@ fn write_auto_dedup( changed } +fn choose_lock_observation( + observations: Vec<(i64, Value)>, + mut compare: impl FnMut(&Value, &Value) -> Option, +) -> Option { + let mut iter = observations.into_iter(); + let mut winner = iter.next()?; + for candidate in iter { + match compare(&winner.1, &candidate.1) { + Some(CompareStatus::Ahead) => winner = candidate, + Some(CompareStatus::Behind | CompareStatus::Identical) => {}, + Some(CompareStatus::Diverged) | None => { + if candidate.0 > winner.0 { + winner = candidate; + } + }, + } + } + Some(winner.1) +} + +fn compare_locked_nodes( + cache: &mut HashMap<(String, String, String, String), Option>, + base: &Value, + head: &Value, +) -> Option { + let (owner, repo, base_rev, head_rev) = github_compare_parts(base, head)?; + if base_rev == head_rev { + return Some(CompareStatus::Identical); + } + let key = (owner, repo, base_rev, head_rev); + if let Some(cached) = cache.get(&key) { + return *cached; + } + let status = fetch::compare_status(&key.0, &key.1, &key.2, &key.3) + .ok() + .flatten(); + cache.insert(key, status); + status +} + +fn github_compare_parts(base: &Value, head: &Value) -> Option<(String, String, String, String)> { + if base.get("type").and_then(Value::as_str)? != "github" + || head.get("type").and_then(Value::as_str)? != "github" + { + return None; + } + let base_owner = base.get("owner")?.as_str()?; + let base_repo = base.get("repo")?.as_str()?; + let head_owner = head.get("owner")?.as_str()?; + let head_repo = head.get("repo")?.as_str()?; + if !base_owner.eq_ignore_ascii_case(head_owner) || !base_repo.eq_ignore_ascii_case(head_repo) { + return None; + } + Some(( + base_owner.to_owned(), + base_repo.to_owned(), + base.get("rev")?.as_str()?.to_owned(), + head.get("rev")?.as_str()?.to_owned(), + )) +} + pub fn look(names: &[String], verbose: bool) -> Result<()> { const LOG_LIMIT: usize = 5; @@ -614,19 +720,20 @@ pub fn look(names: &[String], verbose: bool) -> Result<()> { display.set(i, PinStatus::Fetching); let expanded = shorturl::expand(&inp.url, &shorturls); let old = lk.get(&inp.name).and_then(lock::rev_of).map(str::to_owned); - match fetch::current_rev(&expanded) { - Ok(rev) if old.as_deref() == Some(rev.as_str()) => { + match fetch::current_rev_compared(&expanded, old.as_deref()) { + Ok(current) if old.as_deref() == Some(current.rev.as_str()) => { display.set(i, PinStatus::NoChange); }, - Ok(rev) => { + Ok(current) => { display.set(i, PinStatus::Updated { - old: old.as_deref().map_or_else(|| "NEW".into(), short), - new: short(&rev), + old: old.as_deref().map_or_else(|| "NEW".into(), short), + new: short(¤t.rev), + comparison: current.comparison, }); if verbose && let Some(old_rev) = old.as_deref() && let Ok(Some(log)) = - fetch::commits_between(&expanded, old_rev, &rev, LOG_LIMIT) + fetch::commits_between(&expanded, old_rev, ¤t.rev, LOG_LIMIT) { *logs[i].lock().unwrap() = Some(log); } @@ -693,6 +800,15 @@ struct Entry { lm: Option, } +#[derive(Clone, Debug, PartialEq, Eq)] +struct CompareJob { + id: String, + owner: String, + repo: String, + base: String, + head: String, +} + struct Finding { identity: String, entry: Entry, @@ -714,6 +830,9 @@ enum SourceRef { Url(String), } +const MAX_COMPARE_JOBS: usize = 100; +const MAX_LIVE_COMPARE_JOBS: usize = 8; + /// build a name → value map over declared inputs plus undeclared lock entries, /// pulling each value with `project`. transitive-only names are left in the /// lock @@ -1233,16 +1352,24 @@ fn comparator(entries: &[Entry]) -> Option<&Entry> { /// a group is worth printing only when its revs disagree fn group_diverges(entries: &[Entry]) -> bool { - let mut revs = entries.iter().map(|entry| entry.rev.as_str()); + let mut revs = entries.iter().map(entry_compare_rev); revs.next() .is_some_and(|first| revs.any(|rev| rev != first)) } -/// ask github for the direction of every divergent rev against its comparator, -/// in parallel. keyed by `(group id, abbreviated rev)`. misses are reported -/// and fall back to commit-date ordering -fn ahead_behind(groups: &BTreeMap>) -> HashMap<(String, String), CompareStatus> { - let jobs = groups +const fn entry_compare_rev(entry: &Entry) -> &str { + if entry.full_rev.is_empty() { + entry.rev.as_str() + } else { + entry.full_rev.as_str() + } +} + +/// build github compare work for every divergent rev against its comparator. +/// returned jobs carry full revs for both the network request and the result +/// map keys; rendering remains separately abbreviated. +fn compare_jobs(groups: &BTreeMap>) -> (Vec, usize) { + let mut jobs = groups .iter() .filter(|group| group_diverges(group.1)) .filter_map(|(id, entries)| { @@ -1255,19 +1382,18 @@ fn ahead_behind(groups: &BTreeMap>) -> HashMap<(String, Strin let heads = entries .iter() .filter(|entry| { - entry.rev != base.rev + entry.full_rev != base.full_rev && !entry.full_rev.is_empty() - && seen.insert(entry.rev.as_str()) + && seen.insert(entry.full_rev.as_str()) }) .map(|entry| { - ( - id.clone(), - owner.to_owned(), - repo.to_owned(), - base.full_rev.clone(), - entry.full_rev.clone(), - entry.rev.clone(), - ) + CompareJob { + id: id.clone(), + owner: owner.to_owned(), + repo: repo.to_owned(), + base: base.full_rev.clone(), + head: entry.full_rev.clone(), + } }) .collect::>(); Some(heads) @@ -1275,51 +1401,75 @@ fn ahead_behind(groups: &BTreeMap>) -> HashMap<(String, Strin .flatten() .collect::>(); + let capped = jobs.len().saturating_sub(MAX_COMPARE_JOBS); + jobs.truncate(MAX_COMPARE_JOBS); + (jobs, capped) +} + +/// ask github for the direction of every divergent rev against its comparator, +/// in bounded parallel batches. keyed by `(group id, full rev)`. misses are +/// reported and fall back to commit-date ordering. +fn ahead_behind(groups: &BTreeMap>) -> HashMap<(String, String), CompareStatus> { + let (jobs, capped) = compare_jobs(groups); let attempted = jobs.len(); - let compares = jobs - .into_par_iter() - .filter_map(|(id, owner, repo, base, head, head_short)| { - fetch::compare_status(&owner, &repo, &base, &head) - .ok() - .flatten() - .map(|status| ((id, head_short), status)) - }) - .collect::>(); + let mut compares = HashMap::<(String, String), CompareStatus>::new(); + for chunk in jobs.chunks(MAX_LIVE_COMPARE_JOBS) { + compares.extend( + chunk + .into_par_iter() + .filter_map(|job| { + fetch::compare_status(&job.owner, &job.repo, &job.base, &job.head) + .ok() + .flatten() + .map(|status| ((job.id.clone(), job.head.clone()), status)) + }) + .collect::>(), + ); + } - let dropped = attempted - compares.len(); + let dropped = capped + attempted - compares.len(); if dropped > 0 { eprintln!( - "tack: {dropped} branch comparison(s) unavailable (rate limit or network); falling \ - back to commit-date order" + "tack: {dropped} branch comparison(s) unavailable or capped; falling back to \ + commit-date order" ); } compares } -/// per-rev status glyph for a printable group, measured against its comparator. -/// returns github ahead/behind when we have it, commit-date ordering otherwise, -/// and the widest glyph, for padding +/// per-rev status glyph (rendered text + visible width) for a printable group, +/// measured against its comparator: github ahead/behind when we have it, +/// commit-date ordering otherwise. also returns the widest glyph, for padding fn group_marks<'a>( id: &str, entries: &'a [Entry], revs: impl Iterator, compares: &HashMap<(String, String), CompareStatus>, ) -> (BTreeMap<&'a str, (String, usize)>, usize) { + // a plain "~" marks a date-based guess + const APPROX: &str = "~"; + let comp_entry = comparator(entries); let mut lm_of = BTreeMap::<&str, u64>::new(); for entry in entries { let Some(lm) = entry.lm else { continue; }; - let slot = lm_of.entry(entry.rev.as_str()).or_insert(lm); + let slot = lm_of.entry(entry_compare_rev(entry)).or_insert(lm); *slot = (*slot).max(lm); } let paint = |code: i32, body: &str| format!("\x1b[{code}m{body}\x1b[0m"); + let dated = |code: i32, arrow: &str| { + ( + format!("{}{}", paint(code, arrow), paint(36_i32, APPROX)), + 2, + ) + }; let render = |rev: &str| -> (String, usize) { let Some(comp) = comp_entry else { return (" ".to_owned(), 1); }; - if rev == comp.rev { + if rev == entry_compare_rev(comp) { return (paint(36_i32, "="), 1); // = comparator } if let Some(status) = compares.get(&(id.to_owned(), rev.to_owned())) { @@ -1344,9 +1494,9 @@ fn group_marks<'a>( return (" ".to_owned(), 1); }; match lm.cmp(&comparator_lm) { - cmp::Ordering::Equal => (" ".to_owned(), 1), - cmp::Ordering::Greater => (paint(32_i32, "\u{2191}"), 1), // ↑ newer - cmp::Ordering::Less => (paint(33_i32, "\u{2193}"), 1), // ↓ older + cmp::Ordering::Equal => (paint(36_i32, APPROX), 1), + cmp::Ordering::Greater => dated(32_i32, "\u{2191}"), // ↑ newer by timestamp + cmp::Ordering::Less => dated(33_i32, "\u{2193}"), // ↓ older by timestamp } }; let marks = revs @@ -1356,6 +1506,23 @@ fn group_marks<'a>( (marks, mw) } +type SourcesByRev<'a> = BTreeMap<&'a str, (&'a str, BTreeMap<&'a str, Vec>)>; + +fn group_sources_by_rev(entries: &[Entry]) -> SourcesByRev<'_> { + let mut by_rev = BTreeMap::<&str, (&str, BTreeMap<&str, Vec>)>::new(); + for entry in entries { + let names = &mut by_rev + .entry(entry_compare_rev(entry)) + .or_insert_with(|| (entry.rev.as_str(), BTreeMap::new())) + .1; + names + .entry(entry.name.as_str()) + .or_default() + .push(source_label(&entry.path)); + } + by_rev +} + fn print_groups( groups: &BTreeMap>, all_follow: &BTreeMap, @@ -1377,34 +1544,29 @@ fn print_groups( printed += 1; println!("\n{id} x{}", entries.len()); - // group by rev, then by name within rev - let mut by_rev = BTreeMap::<&str, BTreeMap<&str, Vec>>::new(); - for entry in entries { - by_rev - .entry(entry.rev.as_str()) - .or_default() - .entry(entry.name.as_str()) - .or_default() - .push(source_label(&entry.path)); - } + let by_rev = group_sources_by_rev(entries); - let rw = by_rev.keys().map(|rev| rev.len()).max().unwrap_or(0); + let rw = by_rev + .values() + .map(|&(rev, _)| rev.len()) + .max() + .unwrap_or(0); let nw = by_rev .values() - .flat_map(|names| names.keys().map(|name| name.len())) + .flat_map(|&(_, ref names)| names.keys().map(|name| name.len())) .max() .unwrap_or(0); let (marks, mw) = group_marks(id, entries, by_rev.keys().copied(), compares); - for (rev, names) in &by_rev { + for (rev, &(display_rev, ref names)) in &by_rev { let mark = &marks[rev]; let mark_on = format!("{}{}", mark.0, " ".repeat(mw - mark.1)); let blank = " ".repeat(mw); for (name, sources) in names { let shown = sources.len().min(MAX_SOURCES); for (idx, source) in sources.iter().take(shown).enumerate() { - let rev_cell = if idx == 0 { *rev } else { "" }; + let rev_cell = if idx == 0 { display_rev } else { "" }; let mark_cell = if idx == 0 { &mark_on } else { &blank }; let name_cell = if idx == 0 { *name } else { "" }; println!(" {rev_cell:rw$} {mark_cell} {name_cell:nw$} {source}"); @@ -1551,17 +1713,25 @@ mod tests { collections::{ BTreeMap, BTreeSet, + HashMap, }, fs, iter, }; + use serde_json::Value; + use super::{ Entry, + MAX_COMPARE_JOBS, Side, apply_follows, + choose_lock_observation, collapse_follow, comparator, + compare_jobs, + group_diverges, + group_marks, pick_name, rm_in_dir, wires_overrides, @@ -1601,16 +1771,33 @@ mod tests { } fn entry(path: &[&str], name: &str, rev: &str, lm: Option) -> Entry { + entry_full(path, name, rev, rev, lm) + } + + fn entry_full(path: &[&str], name: &str, rev: &str, full_rev: &str, lm: Option) -> Entry { Entry { path: path.iter().map(|item| (*item).to_owned()).collect(), name: name.to_owned(), side: Side::Flake, rev: rev.to_owned(), - full_rev: rev.to_owned(), + full_rev: full_rev.to_owned(), lm, } } + fn github_node(rev: &str) -> Value { + serde_json::json!({ + "type": "github", + "owner": "o", + "repo": "r", + "rev": rev, + }) + } + + fn node_rev(node: &Value) -> &str { + node.get("rev").and_then(Value::as_str).unwrap() + } + #[test] fn collapse_single_alias_uses_string_form() { let lines = collapse_follow(&map(&[("nixpkgs", "nixpkgs")])); @@ -1690,6 +1877,198 @@ mod tests { ); } + #[test] + fn auto_dedup_prefers_ahead_candidate_despite_older_timestamp() { + let winner = choose_lock_observation( + vec![(300, github_node("base")), (100, github_node("ahead"))], + |base, head| { + match (node_rev(base), node_rev(head)) { + ("base", "ahead") => Some(super::CompareStatus::Ahead), + _ => None, + } + }, + ) + .unwrap(); + + assert_eq!(node_rev(&winner), "ahead"); + } + + #[test] + fn auto_dedup_keeps_base_when_candidate_is_behind_despite_newer_timestamp() { + let winner = choose_lock_observation( + vec![(100, github_node("base")), (500, github_node("behind"))], + |base, head| { + match (node_rev(base), node_rev(head)) { + ("base", "behind") => Some(super::CompareStatus::Behind), + _ => None, + } + }, + ) + .unwrap(); + + assert_eq!(node_rev(&winner), "base"); + } + + #[test] + fn auto_dedup_falls_back_to_timestamp_for_diverged_histories() { + let winner = choose_lock_observation( + vec![(100, github_node("base")), (500, github_node("amended"))], + |base, head| { + match (node_rev(base), node_rev(head)) { + ("base", "amended") => Some(super::CompareStatus::Diverged), + _ => None, + } + }, + ) + .unwrap(); + + assert_eq!(node_rev(&winner), "amended"); + } + + #[test] + fn group_divergence_uses_full_revs_not_display_revs() { + let entries = vec![ + entry_full( + &[], + "base", + "abcdef0", + "abcdef0000000000000000000000000000000000", + Some(10), + ), + entry_full( + &["dep"], + "head", + "abcdef0", + "abcdef0999999999999999999999999999999999", + Some(20), + ), + ]; + + assert!(group_diverges(&entries)); + } + + #[test] + fn compare_jobs_use_full_revs_and_display_short_keys() { + let mut groups = BTreeMap::new(); + groups.insert("github:o/r".to_owned(), vec![ + entry_full( + &[], + "base", + "1111111", + "1111111111111111111111111111111111111111", + Some(10), + ), + entry_full( + &["dep"], + "head", + "2222222", + "2222222222222222222222222222222222222222", + Some(20), + ), + ]); + + let (jobs, capped) = compare_jobs(&groups); + + assert_eq!(capped, 0); + assert_eq!(jobs.len(), 1); + assert_eq!(jobs[0].base, "1111111111111111111111111111111111111111"); + assert_eq!(jobs[0].head, "2222222222222222222222222222222222222222"); + } + + #[test] + fn compare_jobs_are_capped_before_network_work() { + let mut entries = vec![entry_full(&[], "base", "base", "base-full", Some(0))]; + for i in 0..(MAX_COMPARE_JOBS + 5) { + entries.push(entry_full( + &["dep"], + &format!("head-{i:03}"), + &format!("h{i:06}"), + &format!("head-full-{i:03}"), + Some(u64::try_from(i).unwrap() + 1), + )); + } + let mut groups = BTreeMap::new(); + groups.insert("github:o/r".to_owned(), entries); + + let (jobs, capped) = compare_jobs(&groups); + + assert_eq!(jobs.len(), MAX_COMPARE_JOBS); + assert_eq!(capped, 5); + } + + #[test] + fn group_marks_prefer_branch_status_over_misleading_timestamps() { + let entries = vec![ + entry(&[], "base", "base", Some(500)), + entry(&["dep"], "head", "head", Some(100)), + ]; + let compares = HashMap::from([( + ("github:o/r".to_owned(), "head".to_owned()), + super::CompareStatus::Ahead, + )]); + + let (marks, width) = group_marks( + "github:o/r", + &entries, + entries.iter().map(|entry| entry.rev.as_str()), + &compares, + ); + let mark = &marks["head"]; + + assert_eq!(width, 1); + assert_eq!(mark.1, 1); + assert!(mark.0.contains('\u{2191}')); + assert!(!mark.0.contains('~')); + } + + #[test] + fn group_marks_show_diverged_branch_status_without_marker() { + let entries = vec![ + entry(&[], "base", "base", Some(100)), + entry(&["dep"], "head", "head", Some(200)), + ]; + let compares = HashMap::from([( + ("github:o/r".to_owned(), "head".to_owned()), + super::CompareStatus::Diverged, + )]); + + let (marks, width) = group_marks( + "github:o/r", + &entries, + entries.iter().map(|entry| entry.rev.as_str()), + &compares, + ); + let mark = &marks["head"]; + + assert_eq!(width, 2); + assert_eq!(mark.1, 2); + assert!(mark.0.contains('\u{2191}')); + assert!(mark.0.contains('\u{2193}')); + assert!(!mark.0.contains('~')); + } + + #[test] + fn group_marks_distinguish_timestamp_fallback_with_marker() { + let entries = vec![ + entry(&[], "base", "base", Some(100)), + entry(&["dep"], "head", "head", Some(200)), + ]; + let compares = HashMap::new(); + + let (marks, width) = group_marks( + "github:o/r", + &entries, + entries.iter().map(|entry| entry.rev.as_str()), + &compares, + ); + let mark = &marks["head"]; + + assert_eq!(width, 2); + assert_eq!(mark.1, 2); + assert!(mark.0.contains('\u{2191}')); // ↑ newer by timestamp + assert!(mark.0.contains('~')); // marked as a date-based guess + } + #[test] fn apply_follows_syncs_rev_full_rev_and_lm_to_target() { let mut groups = BTreeMap::new(); diff --git a/src/fetch.rs b/src/fetch.rs index 82a8a1f..8e78958 100644 --- a/src/fetch.rs +++ b/src/fetch.rs @@ -11,6 +11,7 @@ use std::{ }, str::FromStr, sync::OnceLock, + time::Duration, }; use anyhow::{ @@ -60,11 +61,18 @@ fn agent() -> &'static Agent { }) } +fn github_token() -> Option { + env::var("GITHUB_TOKEN") + .or_else(|_| env::var("GH_TOKEN")) + .ok() +} + enum Target { Github { owner: String, repo: String, reff: Option, + rev: Option, }, Git { url: String, @@ -76,20 +84,23 @@ enum Target { }, } +#[expect( + clippy::similar_names, + reason = "ref and rev are user-facing URL fields" +)] fn parse(expanded: &str) -> Result { if let Some(body) = expanded.strip_prefix("github:") { - let (path, query_ref, query_sha) = split_query(body); + let (path, query_ref, query_rev) = split_query(body); let segs = path.split('/').collect::>(); if segs.len() < 2 { bail!("malformed github url: {expanded}"); } - let reff = query_ref - .or(query_sha) - .or_else(|| (segs.len() > 2).then(|| segs[2..].join("/"))); + let reff = query_ref.or_else(|| (segs.len() > 2).then(|| segs[2..].join("/"))); return Ok(Target::Github { owner: segs[0].to_owned(), repo: segs[1].to_owned(), reff, + rev: query_rev, }); } if let Some(rest) = expanded.strip_prefix("git+") { @@ -124,17 +135,96 @@ fn split_query(str: &str) -> (&str, Option, Option) { (path, reff, rev) } -/// upstream rev, no tree fetch -pub fn current_rev(expanded: &str) -> Result { +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BranchComparison { + pub status: Option, + pub expected: bool, +} + +impl BranchComparison { + pub const fn none() -> Self { + Self { + status: None, + expected: false, + } + } + + pub const fn verified(status: CompareStatus) -> Self { + Self { + status: Some(status), + expected: true, + } + } + + pub const fn unavailable() -> Self { + Self { + status: None, + expected: true, + } + } +} + +pub struct CurrentRev { + pub rev: String, + pub comparison: BranchComparison, +} + +/// upstream rev plus, when possible, branch topology against `old_rev`. +pub fn current_rev_compared(expanded: &str, old_rev: Option<&str>) -> Result { match parse(expanded)? { - Target::Github { owner, repo, reff } => { + Target::Github { + owner, + repo, + reff, + rev: pinned, + } => { + if let Some(rev) = pinned { + let comparison = if old_rev == Some(rev.as_str()) { + BranchComparison::verified(CompareStatus::Identical) + } else { + BranchComparison::none() + }; + return Ok(CurrentRev { rev, comparison }); + } let ref_str = reff.as_deref().unwrap_or("HEAD"); - Ok(gh_commit(&owner, &repo, ref_str)?.0) + if let Some(previous_rev) = old_rev + && let Ok(resolved) = gh_ref_compare(&owner, &repo, reff.as_deref(), previous_rev) + { + let filled = backfill_comparison(&owner, &repo, previous_rev, resolved); + return Ok(CurrentRev { + rev: filled.rev, + comparison: filled.comparison, + }); + } + let (rev, _) = gh_commit(&owner, &repo, ref_str)?; + let comparison = old_rev.map_or_else(BranchComparison::none, |previous_rev| { + if previous_rev == rev.as_str() { + BranchComparison::verified(CompareStatus::Identical) + } else { + compare_status(&owner, &repo, previous_rev, &rev) + .ok() + .flatten() + .map_or_else(BranchComparison::unavailable, BranchComparison::verified) + } + }); + Ok(CurrentRev { rev, comparison }) }, - Target::Git { url, reff, rev } => { + Target::Git { + url, + reff, + rev: pinned, + } => { // a pinned rev never moves; report it without touching the network - if let Some(pinned) = rev { - return Ok(pinned); + if let Some(pinned_rev) = pinned { + let comparison = if old_rev == Some(pinned_rev.as_str()) { + BranchComparison::verified(CompareStatus::Identical) + } else { + BranchComparison::none() + }; + return Ok(CurrentRev { + rev: pinned_rev, + comparison, + }); } let cb = callbacks(); let mut remote = git2::Remote::create_detached(url.as_str())?; @@ -142,7 +232,16 @@ pub fn current_rev(expanded: &str) -> Result { let want = full_ref(reff.as_deref(), || branch_str(conn.default_branch())); for head in conn.list()? { if head.name() == want { - return Ok(head.oid().to_string()); + let head_rev = head.oid().to_string(); + let comparison = if old_rev == Some(head_rev.as_str()) { + BranchComparison::verified(CompareStatus::Identical) + } else { + BranchComparison::none() + }; + return Ok(CurrentRev { + rev: head_rev, + comparison, + }); } } bail!("ref {want} not found on {url}") @@ -160,7 +259,13 @@ pub fn current_rev(expanded: &str) -> Result { .map_err(Box::new) }) .with_context(|| format!("probe {url}"))?; - Ok(immutable_url_of(&resp, &url)) + let rev = immutable_url_of(&resp, &url); + let comparison = if old_rev == Some(rev.as_str()) { + BranchComparison::verified(CompareStatus::Identical) + } else { + BranchComparison::none() + }; + Ok(CurrentRev { rev, comparison }) }, } } @@ -263,10 +368,19 @@ pub fn fetch_locked_tree_into(node: &Value, dir: &Path) -> Result { /// used when traversing tack transitives that have no committed lock. pub fn fetch_tree_into(expanded: &str, submodules: bool, dir: &Path) -> Result { match parse(expanded)? { - Target::Github { owner, repo, reff } => { - let ref_str = reff.as_deref().unwrap_or("HEAD"); - let (rev, _) = gh_commit(&owner, &repo, ref_str)?; - download_github_tarball(&owner, &repo, &rev, dir) + Target::Github { + owner, + repo, + reff, + rev: pinned, + } => { + let tree_rev = if let Some(pinned_rev) = pinned { + pinned_rev + } else { + let ref_str = reff.as_deref().unwrap_or("HEAD"); + gh_commit(&owner, &repo, ref_str)?.0 + }; + download_github_tarball(&owner, &repo, &tree_rev, dir) }, Target::Git { url, reff, rev } => { git_checkout(&url, reff.as_deref(), rev.as_deref(), submodules, dir)?; @@ -287,23 +401,28 @@ pub fn fetch_tree_into(expanded: &str, submodules: bool, dir: &Path) -> Result

Result<(Value, String)> { + fetch_pin_compared(expanded, submodules, None).map(|fetched| (fetched.node, fetched.rev)) +} + +pub struct FetchedPin { + pub node: Value, + pub rev: String, + pub comparison: BranchComparison, +} + +/// fetch the tree, returning branch topology against `old_rev` when available. +pub fn fetch_pin_compared( + expanded: &str, + submodules: bool, + old_rev: Option<&str>, +) -> Result { match parse(expanded)? { - Target::Github { owner, repo, reff } => { - let ref_str = reff.as_deref().unwrap_or("HEAD"); - let (rev, last_modified) = gh_commit(&owner, &repo, ref_str)?; - let dir = tempfile::tempdir()?; - let root = download_github_tarball(&owner, &repo, &rev, dir.path())?; - let nar_hash = nar::hash_path(&root)?; - let node = json!({ - "type": "github", - "owner": owner, - "repo": repo, - "rev": rev, - "narHash": nar_hash, - "lastModified": last_modified, - }); - Ok((node, rev)) - }, + Target::Github { + owner, + repo, + reff, + rev: pinned, + } => fetch_github_pin_compared(&owner, &repo, reff.as_deref(), pinned, old_rev), Target::Git { url, reff, @@ -330,7 +449,11 @@ pub fn fetch_pin(expanded: &str, submodules: bool) -> Result<(Value, String)> { if submodules { node["submodules"] = json!(true); } - Ok((node, rev)) + Ok(FetchedPin { + node, + rev, + comparison: BranchComparison::none(), + }) }, Target::Tarball { url } => { let mut resp = agent() @@ -358,11 +481,83 @@ pub fn fetch_pin(expanded: &str, submodules: bool) -> Result<(Value, String)> { "narHash": nar_hash, "lastModified": last_modified, }); - Ok((node, immutable_url)) + Ok(FetchedPin { + node, + rev: immutable_url, + comparison: BranchComparison::none(), + }) }, } } +fn fetch_github_pin_compared( + owner: &str, + repo: &str, + reff: Option<&str>, + pinned: Option, + old_rev: Option<&str>, +) -> Result { + let resolved = resolve_github_for_pin(owner, repo, reff, pinned, old_rev)?; + let dir = tempfile::tempdir()?; + let root = download_github_tarball(owner, repo, &resolved.rev, dir.path())?; + let nar_hash = nar::hash_path(&root)?; + let rev = resolved.rev; + let node = json!({ + "type": "github", + "owner": owner, + "repo": repo, + "rev": rev, + "narHash": nar_hash, + "lastModified": resolved.last_modified, + }); + Ok(FetchedPin { + node, + rev, + comparison: resolved.comparison, + }) +} + +fn resolve_github_for_pin( + owner: &str, + repo: &str, + reff: Option<&str>, + pinned: Option, + old_rev: Option<&str>, +) -> Result { + if let Some(rev) = pinned { + let (_, last_modified) = gh_commit(owner, repo, &rev)?; + return Ok(ResolvedGithubRef { + rev, + last_modified, + comparison: BranchComparison::none(), + }); + } + + let ref_str = reff.unwrap_or("HEAD"); + if let Some(previous_rev) = old_rev + && let Ok(resolved) = gh_ref_compare(owner, repo, reff, previous_rev) + { + return Ok(backfill_comparison(owner, repo, previous_rev, resolved)); + } + + let (rev, last_modified) = gh_commit(owner, repo, ref_str)?; + let comparison = old_rev.map_or_else(BranchComparison::none, |previous_rev| { + if previous_rev == rev.as_str() { + BranchComparison::verified(CompareStatus::Identical) + } else { + compare_status(owner, repo, previous_rev, &rev) + .ok() + .flatten() + .map_or_else(BranchComparison::unavailable, BranchComparison::verified) + } + }); + Ok(ResolvedGithubRef { + rev, + last_modified, + comparison, + }) +} + /// Locked URL for a tarball response fn immutable_url_of(resp: &http::Response, fallback: &str) -> String { resp.headers() @@ -460,13 +655,20 @@ where } fn gh_get(url: &str) -> Result { + gh_get_with_timeout(url, None) +} + +fn gh_get_with_timeout(url: &str, timeout_limit: Option) -> Result { let mut req = agent() .get(url) .header("User-Agent", "tack") .header("Accept", "application/vnd.github+json"); - if let Ok(token) = env::var("GITHUB_TOKEN").or_else(|_| env::var("GH_TOKEN")) { + if let Some(token) = github_token() { req = req.header("Authorization", &format!("Bearer {token}")); } + if let Some(timeout) = timeout_limit { + req = req.config().timeout_global(Some(timeout)).build(); + } let body = req .call() .with_context(|| format!("github api {url}"))? @@ -475,6 +677,38 @@ fn gh_get(url: &str) -> Result { Ok(serde_json::from_str(&body)?) } +fn gh_graphql(query: &str, variables: &Value) -> Result { + let token = github_token().context("github graphql requires GITHUB_TOKEN or GH_TOKEN")?; + let payload = json!({ + "query": query, + "variables": variables, + }) + .to_string(); + let mut resp = agent() + .post("https://api.github.com/graphql") + .header("User-Agent", "tack") + .header("Accept", "application/vnd.github+json") + .header("Content-Type", "application/json") + .header("Authorization", &format!("Bearer {token}")) + .config() + .timeout_global(Some(Duration::from_secs(2))) + .build() + .send(payload) + .context("github graphql")?; + let body = resp.body_mut().read_to_string()?; + let parsed = serde_json::from_str::(&body)?; + if let Some(message) = parsed + .get("errors") + .and_then(Value::as_array) + .and_then(|errors| errors.first()) + .and_then(|error| error.get("message")) + .and_then(Value::as_str) + { + bail!("github graphql: {message}"); + } + Ok(parsed) +} + /// direction of `head` relative to `base`, as reported by github's compare /// endpoint #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -503,6 +737,183 @@ impl FromStr for CompareStatus { } } +#[cfg(test)] +impl CompareStatus { + pub const fn from_ancestry( + base_is_ancestor_of_head: bool, + head_is_ancestor_of_base: bool, + ) -> Self { + match (base_is_ancestor_of_head, head_is_ancestor_of_base) { + (true, true) => Self::Identical, + (true, false) => Self::Ahead, + (false, true) => Self::Behind, + (false, false) => Self::Diverged, + } + } +} + +struct ResolvedGithubRef { + rev: String, + last_modified: i64, + comparison: BranchComparison, +} + +const GITHUB_REF_COMPARE_QUERY: &str = " +query($owner: String!, $repo: String!, $ref: String!, $old: String!) { + repository(owner: $owner, name: $repo) { + targetRef: ref(qualifiedName: $ref) { + target { + oid + ... on Commit { committedDate } + ... on Tag { + target { + oid + ... on Commit { committedDate } + } + } + } + compare(headRef: $old) { + status + aheadBy + behindBy + } + } + } +} +"; + +const GITHUB_DEFAULT_COMPARE_QUERY: &str = " +query($owner: String!, $repo: String!, $old: String!) { + repository(owner: $owner, name: $repo) { + targetRef: defaultBranchRef { + target { + oid + ... on Commit { committedDate } + ... on Tag { + target { + oid + ... on Commit { committedDate } + } + } + } + compare(headRef: $old) { + status + aheadBy + behindBy + } + } + } +} +"; + +fn gh_ref_compare( + owner: &str, + repo: &str, + reff: Option<&str>, + old_rev: &str, +) -> Result { + let (query, variables) = reff.map_or_else( + || { + ( + GITHUB_DEFAULT_COMPARE_QUERY, + json!({ + "owner": owner, + "repo": repo, + "old": old_rev, + }), + ) + }, + |ref_name| { + ( + GITHUB_REF_COMPARE_QUERY, + json!({ + "owner": owner, + "repo": repo, + "ref": ref_name, + "old": old_rev, + }), + ) + }, + ); + parse_gh_ref_compare(&gh_graphql(query, &variables)?) +} + +fn parse_gh_ref_compare(parsed: &Value) -> Result { + let ref_node = parsed + .get("data") + .and_then(|data| data.get("repository")) + .and_then(|repo| repo.get("targetRef")) + .filter(|node| !node.is_null()) + .context("github graphql response missing ref")?; + let target = ref_node + .get("target") + .context("github graphql response missing ref target")?; + let (rev, last_modified) = target_commit(target)?; + let comparison = ref_node + .get("compare") + .and_then(|compare| compare.get("status")) + .and_then(Value::as_str) + .and_then(graphql_ref_compare_status) + .map_or_else(BranchComparison::unavailable, BranchComparison::verified); + Ok(ResolvedGithubRef { + rev, + last_modified, + comparison, + }) +} + +/// when the GraphQL ref resolved but its comparison came back unavailable, the +/// REST `/compare` endpoint may still classify the two revs. a verified or +/// not-attempted comparison is left untouched. +fn backfill_comparison( + owner: &str, + repo: &str, + old_rev: &str, + resolved: ResolvedGithubRef, +) -> ResolvedGithubRef { + let unavailable = resolved.comparison.status.is_none() && resolved.comparison.expected; + if !unavailable || old_rev == resolved.rev { + return resolved; + } + let comparison = compare_status(owner, repo, old_rev, &resolved.rev) + .ok() + .flatten() + .map_or(resolved.comparison, BranchComparison::verified); + ResolvedGithubRef { + comparison, + ..resolved + } +} + +fn target_commit(target: &Value) -> Result<(String, i64)> { + let commit = target + .get("target") + .filter(|inner| inner.get("committedDate").is_some()) + .unwrap_or(target); + let rev = commit + .get("oid") + .and_then(Value::as_str) + .context("github graphql response missing commit oid")? + .to_owned(); + let date = commit + .get("committedDate") + .and_then(Value::as_str) + .context("github graphql response missing commit date")?; + Ok((rev, epoch_from_iso(date)?)) +} + +fn graphql_ref_compare_status(status: &str) -> Option { + Some(match status { + // Ref.compare compares `targetRef` as the base to the old locked rev + // as the head. tack displays the current ref relative to the old rev. + "AHEAD" => CompareStatus::Behind, + "BEHIND" => CompareStatus::Ahead, + "DIVERGED" => CompareStatus::Diverged, + "IDENTICAL" => CompareStatus::Identical, + _ => return None, + }) +} + /// compare `head` against `base` via github's compare endpoint. returns /// [`None`] when the response carries no recognised `status`, which callers /// treat as "no answer" and fall back to commit-date ordering @@ -512,14 +923,19 @@ pub fn compare_status( base: &str, head: &str, ) -> Result> { - let url = format!("https://api.github.com/repos/{owner}/{repo}/compare/{base}...{head}"); - let parsed = gh_get(&url).with_context(|| format!("github compare {owner}/{repo}"))?; + let url = gh_compare_url(owner, repo, base, head); + let parsed = gh_get_with_timeout(&url, Some(Duration::from_secs(5))) + .with_context(|| format!("github compare {owner}/{repo}"))?; Ok(parsed .get("status") .and_then(Value::as_str) .and_then(|status| status.parse().ok())) } +fn gh_compare_url(owner: &str, repo: &str, base: &str, head: &str) -> String { + format!("https://api.github.com/repos/{owner}/{repo}/compare/{base}...{head}?per_page=1") +} + fn gh_commit(owner: &str, repo: &str, reff: &str) -> Result<(String, i64)> { let url = format!("https://api.github.com/repos/{owner}/{repo}/commits/{reff}"); let parsed = gh_get(&url).with_context(|| format!("github api {owner}/{repo}@{reff}"))?; @@ -823,11 +1239,161 @@ mod tests { #[test] fn github_rev_is_committish() { match parse("github:o/r?rev=abc123").unwrap() { - Target::Github { reff, .. } => assert_eq!(reff.as_deref(), Some("abc123")), + Target::Github { reff, rev, .. } => { + assert_eq!(reff, None); + assert_eq!(rev.as_deref(), Some("abc123")); + }, Target::Git { .. } | Target::Tarball { .. } => panic!("expected github target"), } } + #[test] + fn github_pinned_rev_skips_branch_comparison() { + let mismatched = current_rev_compared("github:o/r?rev=abc123", Some("old")).unwrap(); + assert_eq!(mismatched.rev, "abc123"); + assert_eq!(mismatched.comparison, BranchComparison::none()); + + let identical = current_rev_compared("github:o/r?rev=abc123", Some("abc123")).unwrap(); + assert_eq!(identical.rev, "abc123"); + assert_eq!( + identical.comparison, + BranchComparison::verified(CompareStatus::Identical) + ); + } + + #[test] + fn graphql_ref_compare_status_is_inverted_for_tack_display() { + assert_eq!( + graphql_ref_compare_status("BEHIND"), + Some(CompareStatus::Ahead) + ); + assert_eq!( + graphql_ref_compare_status("AHEAD"), + Some(CompareStatus::Behind) + ); + assert_eq!( + graphql_ref_compare_status("DIVERGED"), + Some(CompareStatus::Diverged) + ); + assert_eq!( + graphql_ref_compare_status("IDENTICAL"), + Some(CompareStatus::Identical) + ); + assert_eq!(graphql_ref_compare_status("UNKNOWN"), None); + } + + #[test] + fn parses_graphql_ref_compare_response() { + let parsed = json!({ + "data": { + "repository": { + "targetRef": { + "target": { + "oid": "new", + "committedDate": "2026-05-30T18:08:13Z" + }, + "compare": { + "status": "BEHIND", + "aheadBy": 0_i32, + "behindBy": 1_264_i32 + } + } + } + } + }); + + let resolved = parse_gh_ref_compare(&parsed).unwrap(); + + assert_eq!(resolved.rev, "new"); + assert_eq!(resolved.last_modified, 1_780_164_493); + assert_eq!( + resolved.comparison, + BranchComparison::verified(CompareStatus::Ahead) + ); + } + + #[test] + fn parses_graphql_annotated_tag_target() { + let parsed = json!({ + "data": { + "repository": { + "targetRef": { + "target": { + "oid": "tag-object", + "target": { + "oid": "commit", + "committedDate": "2026-05-30T18:08:13Z" + } + }, + "compare": { + "status": "IDENTICAL" + } + } + } + } + }); + + let resolved = parse_gh_ref_compare(&parsed).unwrap(); + + assert_eq!(resolved.rev, "commit"); + assert_eq!( + resolved.comparison, + BranchComparison::verified(CompareStatus::Identical) + ); + } + + #[test] + fn rest_compare_url_limits_payload() { + assert_eq!( + gh_compare_url("o", "r", "base", "head"), + "https://api.github.com/repos/o/r/compare/base...head?per_page=1" + ); + } + + fn commit(repo: &Repository, parent_ids: &[git2::Oid], message: &str, time: i64) -> git2::Oid { + let sig = git2::Signature::new("tack", "tack@example.invalid", &git2::Time::new(time, 0)) + .unwrap(); + let tree_id = repo.treebuilder(None).unwrap().write().unwrap(); + let tree = repo.find_tree(tree_id).unwrap(); + let parent_commits = parent_ids + .iter() + .map(|oid| repo.find_commit(*oid).unwrap()) + .collect::>(); + let parent_refs = parent_commits.iter().collect::>(); + repo.commit(None, &sig, &sig, message, &tree, &parent_refs) + .unwrap() + } + + fn local_compare(repo: &Repository, base: git2::Oid, head: git2::Oid) -> CompareStatus { + if base == head { + return CompareStatus::Identical; + } + let base_is_ancestor = repo.graph_descendant_of(head, base).unwrap(); + let head_is_ancestor = repo.graph_descendant_of(base, head).unwrap(); + CompareStatus::from_ancestry(base_is_ancestor, head_is_ancestor) + } + + #[test] + fn compare_status_from_local_merge_base_semantics() { + let tmp = tempfile::tempdir().unwrap(); + let repo = Repository::init(tmp.path()).unwrap(); + let root = commit(&repo, &[], "root", 100); + let base = commit(&repo, &[root], "base", 300); + let ahead_with_older_timestamp = commit(&repo, &[base], "ahead", 200); + let amended_with_newer_timestamp = commit(&repo, &[root], "amended", 400); + + assert_eq!( + local_compare(&repo, base, ahead_with_older_timestamp), + CompareStatus::Ahead + ); + assert_eq!(local_compare(&repo, base, root), CompareStatus::Behind); + assert_eq!(local_compare(&repo, base, base), CompareStatus::Identical); + assert_eq!( + local_compare(&repo, base, amended_with_newer_timestamp), + CompareStatus::Diverged + ); + } + #[test] fn https_url_is_tarball() { match parse("https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz").unwrap() { diff --git a/src/ui.rs b/src/ui.rs index 5430b86..8ce8c9c 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -22,7 +22,11 @@ use std::{ time::Duration, }; -use crate::fetch::CommitLog; +use crate::fetch::{ + BranchComparison, + CommitLog, + CompareStatus, +}; #[derive(Clone)] pub enum PinStatus { @@ -30,8 +34,9 @@ pub enum PinStatus { Fetching, NoChange, Updated { - old: String, - new: String, + old: String, + new: String, + comparison: BranchComparison, }, Drift { rev: String, @@ -208,7 +213,13 @@ fn glyph(st: &PinStatus, frame: usize) -> String { fn suffix(st: &PinStatus) -> String { match *st { - PinStatus::Updated { ref old, ref new } => format!(" {old} -> {new}"), + PinStatus::Updated { + ref old, + ref new, + comparison, + } => { + format!(" {old} -> {new}{}", comparison_suffix(comparison)) + }, PinStatus::Drift { ref rev, accepted: false, @@ -245,7 +256,16 @@ fn suffix(st: &PinStatus) -> String { fn plain_line(name: &str, st: &PinStatus) -> Option { match *st { - PinStatus::Updated { ref old, ref new } => Some(format!("{name}: {old} -> {new}")), + PinStatus::Updated { + ref old, + ref new, + comparison, + } => { + Some(format!( + "{name}: {old} -> {new}{}", + comparison_suffix(comparison) + )) + }, PinStatus::NoChange => Some(format!("{name}: unchanged")), PinStatus::Drift { ref rev, @@ -287,3 +307,31 @@ fn plain_line(name: &str, st: &PinStatus) -> Option { PinStatus::Pending | PinStatus::Fetching => None, } } + +const fn comparison_suffix(comparison: BranchComparison) -> &'static str { + match comparison.status { + Some(CompareStatus::Ahead) => " (ahead)", + Some(CompareStatus::Behind) => " (behind)", + Some(CompareStatus::Diverged) => " (diverged)", + None if comparison.expected => " (unverified)", + Some(CompareStatus::Identical) | None => "", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn branch_comparison_suffix_marks_unverified_results() { + assert_eq!( + comparison_suffix(BranchComparison::verified(CompareStatus::Ahead)), + " (ahead)" + ); + assert_eq!( + comparison_suffix(BranchComparison::unavailable()), + " (unverified)" + ); + assert_eq!(comparison_suffix(BranchComparison::none()), ""); + } +}