diff --git a/gitoxide-core/src/repository/commit.rs b/gitoxide-core/src/repository/commit.rs index 34a4df2e513..8ee4c491c48 100644 --- a/gitoxide-core/src/repository/commit.rs +++ b/gitoxide-core/src/repository/commit.rs @@ -141,6 +141,66 @@ pub fn describe( Ok(()) } +pub fn name_rev( + repo: gix::Repository, + rev_spec: Option<&str>, + mut out: impl std::io::Write, + name_rev::Options { + tags, + refs, + exclude, + always, + no_undefined, + name_only, + }: name_rev::Options, +) -> Result<()> { + let commit = match rev_spec { + Some(spec) => repo + .rev_parse_single(format!("{spec}^{{commit}}").as_str())? + .object()? + .into_commit(), + None => repo.head_commit()?, + }; + let mut platform = commit + .name_rev() + .names(if tags { + gix::commit::name_rev::SelectRef::AllTags + } else { + gix::commit::name_rev::SelectRef::AllRefs + }) + .id_as_fallback(always); + + for pattern in refs { + platform = platform.include_ref(pattern); + } + for pattern in exclude { + platform = platform.exclude_ref(pattern); + } + + match platform.try_format()? { + Some(format) => { + if name_only { + let mut name = format.to_string(); + if tags { + name = name.strip_prefix("tags/").unwrap_or(&name).to_owned(); + } + writeln!(out, "{name}")?; + } else { + let id_or_spec = rev_spec.map_or_else(|| commit.id.to_string(), ToOwned::to_owned); + writeln!(out, "{id_or_spec} {format}")?; + } + } + None if no_undefined => anyhow::bail!("Cannot describe '{}'", commit.id), + None if name_only => writeln!(out, "undefined")?, + None => { + let id_or_spec = rev_spec.map_or_else(|| commit.id.to_string(), ToOwned::to_owned); + writeln!(out, "{id_or_spec} undefined")?; + } + } + + Ok(()) +} + pub mod describe { #[derive(Debug, Clone)] pub struct Options { @@ -154,3 +214,15 @@ pub mod describe { pub dirty_suffix: Option, } } + +pub mod name_rev { + #[derive(Debug, Clone)] + pub struct Options { + pub tags: bool, + pub refs: Vec, + pub exclude: Vec, + pub always: bool, + pub no_undefined: bool, + pub name_only: bool, + } +} diff --git a/gix-revision/Cargo.toml b/gix-revision/Cargo.toml index e511638c1f3..229f2821d8d 100644 --- a/gix-revision/Cargo.toml +++ b/gix-revision/Cargo.toml @@ -15,7 +15,7 @@ rust-version = "1.85" doctest = false [features] -default = ["describe", "merge_base"] +default = ["describe", "merge_base", "name_rev"] ## Enable support for the SHA-1 hash by enabling the respective feature in the `gix-hash` crate. sha1 = ["gix-hash/sha1"] ## Enable support for the SHA-256 hash by enabling the respective feature in the `gix-hash` crate. @@ -24,6 +24,9 @@ sha256 = ["gix-hash/sha256"] ## `git describe` functionality describe = ["dep:gix-trace", "dep:gix-hashtable"] +## `git name-rev` functionality +name_rev = ["dep:gix-trace"] + ## `git merge-base` functionality merge_base = ["dep:gix-trace", "dep:bitflags"] diff --git a/gix-revision/src/lib.rs b/gix-revision/src/lib.rs index bd2bfab94de..67b3d242da6 100644 --- a/gix-revision/src/lib.rs +++ b/gix-revision/src/lib.rs @@ -14,6 +14,11 @@ pub mod describe; #[cfg(feature = "describe")] pub use describe::function::describe; /// +#[cfg(feature = "name_rev")] +pub mod name_rev; +#[cfg(feature = "name_rev")] +pub use name_rev::function::name_rev; +/// #[allow(clippy::empty_docs)] #[cfg(feature = "merge_base")] pub mod merge_base; diff --git a/gix-revision/src/name_rev.rs b/gix-revision/src/name_rev.rs new file mode 100644 index 00000000000..42039bc9893 --- /dev/null +++ b/gix-revision/src/name_rev.rs @@ -0,0 +1,336 @@ +use std::{ + borrow::Cow, + fmt::{Display, Formatter}, +}; + +use bstr::{BStr, BString, ByteVec}; + +/// A prepared reference tip from which names will be propagated through the commit graph. +#[derive(Debug, Clone)] +pub struct Tip<'name> { + /// The peeled commit id the name points to. + pub id: gix_hash::ObjectId, + /// The display name associated with `id`. + pub name: Cow<'name, BStr>, + /// The timestamp used for ordering competing names. + pub taggerdate: i64, + /// If true, this tip came from a tag reference. + pub from_tag: bool, + /// If true, the display name names a tag object and needs `^0` when naming the tagged commit directly. + pub deref: bool, +} + +/// The positive result produced by [name_rev()][function::name_rev()]. +#[derive(Debug, Clone)] +pub struct Outcome<'name> { + /// The input commit object id that we name. + pub id: gix_hash::ObjectId, + /// The name found for `id`, or `None` if fallback formatting was requested and no name exists. + pub name: Option>, + /// The amount of commits traversed while propagating names. + pub commits_seen: u32, +} + +impl<'name> Outcome<'name> { + /// Turn this outcome into a displayable `git name-rev`-like format. + pub fn into_format(self, hex_len: usize) -> Format<'name> { + Format { + id: self.id, + name: self.name, + hex_len, + } + } +} + +/// A structure implementing `Display`, producing a `git name-rev`-like string. +#[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone)] +pub struct Format<'name> { + /// The object id to use if no symbolic name is available. + pub id: gix_hash::ObjectId, + /// The symbolic name to display, if one was found. + pub name: Option>, + /// The amount of hex characters to use for id fallback formatting. + pub hex_len: usize, +} + +impl Display for Format<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self.name.as_deref() { + Some(name) => name.fmt(f), + None => self.id.to_hex_with_len(self.hex_len).fmt(f), + } + } +} + +/// The options required to call [`name_rev()`][function::name_rev()]. +#[derive(Clone, Debug, Default)] +pub struct Options<'name> { + /// The prepared tips from which to propagate names through history. + pub tips: Vec>, + /// If no candidate for naming exists, always show the abbreviated hash. + pub fallback_to_oid: bool, +} + +/// The error returned by the [`name_rev()`](function::name_rev()) function. +pub type Error = gix_error::Message; + +/// The per-commit state stored in the graph while running [`name_rev()`](function::name_rev()). +#[derive(Debug, Clone)] +pub struct State<'name> { + tip_name: Cow<'name, BStr>, + taggerdate: i64, + generation: u32, + distance: u32, + from_tag: bool, +} + +impl State<'_> { + fn format(&self) -> BString { + if self.generation == 0 { + self.tip_name.as_ref().to_owned() + } else { + let base = self.tip_name.as_ref().strip_suffix(b"^0").unwrap_or(&self.tip_name); + let mut out = BString::from(base); + out.push_str(format!("~{}", self.generation)); + out + } + } +} + +pub(crate) mod function { + use std::{borrow::Cow, cmp::Ordering}; + + use bstr::{BString, ByteVec}; + use gix_error::{Exn, ResultExt, message}; + use gix_hash::oid; + + use super::{Error, Outcome, State}; + use crate::{Graph, name_rev::Options}; + + const MERGE_TRAVERSAL_WEIGHT: u32 = 65535; + const CUTOFF_DATE_SLOP: i64 = 86400; + + /// Given a `commit` id, propagate all prepared names through `graph` and produce an `Outcome`. + pub fn name_rev<'name>( + commit: &oid, + graph: &mut Graph<'_, '_, State<'name>>, + Options { + mut tips, + fallback_to_oid, + }: Options<'name>, + ) -> Result>, Exn> { + let _span = gix_trace::coarse!( + "gix_revision::name_rev()", + commit = %commit, + name_count = tips.len() + ); + + if tips.is_empty() { + return if fallback_to_oid { + Ok(Some(Outcome { + id: commit.to_owned(), + name: None, + commits_seen: 0, + })) + } else { + Ok(None) + }; + } + + tips.sort_by(|a, b| { + b.from_tag + .cmp(&a.from_tag) + .then_with(|| a.taggerdate.cmp(&b.taggerdate)) + }); + + let mut commits_seen = 0; + graph.clear(); + let cutoff = cutoff_timestamp( + graph + .lookup(commit) + .or_raise(|| message!("could not lookup commit {}", commit.to_hex()))? + .committer_timestamp() + .or_raise(|| message!("could not read commit timestamp {}", commit.to_hex()))?, + ); + + for tip in tips { + if graph + .lookup(&tip.id) + .or_raise(|| message!("could not lookup tip commit {}", tip.id.to_hex()))? + .committer_timestamp() + .or_raise(|| message!("could not read tip commit timestamp {}", tip.id.to_hex()))? + < cutoff + { + continue; + } + + let mut name = tip.name.into_owned(); + if tip.deref { + name.push_str("^0"); + } + let name = State { + tip_name: Cow::Owned(name), + taggerdate: tip.taggerdate, + generation: 0, + distance: 0, + from_tag: tip.from_tag, + }; + + if create_or_update_name(graph, tip.id, name) { + propagate_names(tip.id, cutoff, graph, &mut commits_seen)?; + } + } + + match graph.get(commit) { + Some(name) => Ok(Some(Outcome { + id: commit.to_owned(), + name: Some(Cow::Owned(name.format())), + commits_seen, + })), + None if fallback_to_oid => Ok(Some(Outcome { + id: commit.to_owned(), + name: None, + commits_seen, + })), + None => Ok(None), + } + } + + fn propagate_names( + start: gix_hash::ObjectId, + cutoff: i64, + graph: &mut Graph<'_, '_, State<'_>>, + commits_seen: &mut u32, + ) -> Result<(), Exn> { + let mut stack = vec![start]; + let mut parents_to_queue = Vec::new(); + + while let Some(commit) = stack.pop() { + *commits_seen += 1; + let name = graph.get(&commit).expect("all queued commits have names").clone(); + + parents_to_queue.clear(); + let parents = { + let commit_object = graph + .lookup(&commit) + .or_raise(|| message!("could not lookup commit {}", commit.to_hex()))?; + commit_object + .iter_parents() + .collect::, _>>() + .or_raise(|| message!("could not read parents of commit {}", commit.to_hex()))? + }; + + for (parent_index, parent_id) in parents.into_iter().enumerate() { + let Some(parent_commit) = graph + .try_lookup(&parent_id) + .or_raise(|| message!("could not lookup parent commit {}", parent_id.to_hex()))? + else { + continue; + }; + if parent_commit + .committer_timestamp() + .or_raise(|| message!("could not read parent commit timestamp {}", parent_id.to_hex()))? + < cutoff + { + continue; + } + + let parent_number = parent_index as u32 + 1; + let (tip_name, generation, distance) = if parent_number > 1 { + ( + Cow::Owned(parent_name(&name, parent_number)), + 0, + name.distance.saturating_add(MERGE_TRAVERSAL_WEIGHT), + ) + } else { + ( + name.tip_name.clone(), + name.generation.saturating_add(1), + name.distance.saturating_add(1), + ) + }; + + let parent_name = State { + tip_name, + taggerdate: name.taggerdate, + generation, + distance, + from_tag: name.from_tag, + }; + + if create_or_update_name(graph, parent_id, parent_name) { + parents_to_queue.push(parent_id); + } + } + + while let Some(parent) = parents_to_queue.pop() { + stack.push(parent); + } + } + + Ok(()) + } + + fn cutoff_timestamp(timestamp: i64) -> i64 { + timestamp.saturating_sub(CUTOFF_DATE_SLOP) + } + + fn create_or_update_name<'name>( + graph: &mut Graph<'_, '_, State<'name>>, + id: gix_hash::ObjectId, + new_name: State<'name>, + ) -> bool { + match graph.get_mut(&id) { + Some(name) => { + if !is_better_name(name, &new_name) { + return false; + } + *name = new_name; + } + None => { + graph.insert(id, new_name); + } + } + true + } + + fn is_better_name(current: &State<'_>, new: &State<'_>) -> bool { + let current_distance = effective_distance(current.distance, current.generation); + let new_distance = effective_distance(new.distance, new.generation); + + if new.from_tag && current.from_tag { + return current_distance > new_distance; + } + + if current.from_tag != new.from_tag { + return new.from_tag; + } + + match current_distance.cmp(&new_distance) { + Ordering::Greater => return true, + Ordering::Less => return false, + Ordering::Equal => {} + } + + if current.taggerdate != new.taggerdate { + return current.taggerdate > new.taggerdate; + } + + false + } + + fn effective_distance(distance: u32, generation: u32) -> u32 { + distance.saturating_add(if generation > 0 { MERGE_TRAVERSAL_WEIGHT } else { 0 }) + } + + fn parent_name(name: &State<'_>, parent_number: u32) -> BString { + let base = name.tip_name.as_ref().strip_suffix(b"^0").unwrap_or(&name.tip_name); + let mut out = BString::from(base); + if name.generation > 0 { + out.push_str(format!("~{}^{}", name.generation, parent_number)); + } else { + out.push_str(format!("^{parent_number}")); + } + out + } +} diff --git a/gix-revision/tests/revision/main.rs b/gix-revision/tests/revision/main.rs index 9d2e9b77041..b80e370ae89 100644 --- a/gix-revision/tests/revision/main.rs +++ b/gix-revision/tests/revision/main.rs @@ -2,6 +2,8 @@ mod describe; #[cfg(feature = "merge_base")] mod merge_base; +#[cfg(feature = "name_rev")] +mod name_rev; mod spec; pub use gix_testtools::Result; diff --git a/gix-revision/tests/revision/name_rev/mod.rs b/gix-revision/tests/revision/name_rev/mod.rs new file mode 100644 index 00000000000..bfce397bd9e --- /dev/null +++ b/gix-revision/tests/revision/name_rev/mod.rs @@ -0,0 +1,179 @@ +use bstr::ByteSlice; +use gix_error::Exn; +use gix_revision::{ + name_rev, + name_rev::{Error, Outcome, Tip}, +}; +use std::{borrow::Cow, path::PathBuf}; + +use crate::hex_to_id; + +fn run_test( + options: impl Fn(gix_hash::ObjectId) -> gix_revision::name_rev::Options<'static>, + run_assertions: impl Fn( + Result>, Exn>, + gix_hash::ObjectId, + ) -> Result<(), gix_error::Error>, +) -> Result<(), gix_error::Error> { + let store = odb_at("."); + let commit_id = hex_to_id("01ec18a3ebf2855708ad3c9d244306bc1fae3e9b"); + for use_commitgraph in [false, true] { + let cache = use_commitgraph + .then(|| gix_commitgraph::Graph::from_info_dir(&store.store_ref().path().join("info")).ok()) + .flatten(); + let mut graph = gix_revision::Graph::new(&store, cache.as_ref()); + run_assertions( + gix_revision::name_rev(&commit_id, &mut graph, options(commit_id)), + commit_id, + )?; + } + Ok(()) +} + +#[test] +fn direct_tip_match() -> Result<(), gix_error::Error> { + run_test( + |id| name_rev::Options { + tips: vec![tip(id, "main", false)], + ..Default::default() + }, + |res, id| { + let res = res?.expect("name found"); + assert_eq!(res.id, id); + assert_eq!(res.name.as_deref(), Some("main".as_bytes().as_bstr())); + assert_eq!(res.into_format(7).to_string(), "main"); + Ok(()) + }, + ) +} + +#[test] +fn first_parent_ancestor_is_named_by_generation() -> Result<(), gix_error::Error> { + run_test( + |id| name_rev::Options { + tips: vec![tip(id, "main", false)], + ..Default::default() + }, + |_res, _id| { + let store = odb_at("."); + let mut graph = gix_revision::Graph::new(&store, None); + let res = gix_revision::name_rev( + &hex_to_id("efd9a841189668f1bab5b8ebade9cd0a1b139a37"), + &mut graph, + name_rev::Options { + tips: vec![tip( + hex_to_id("01ec18a3ebf2855708ad3c9d244306bc1fae3e9b"), + "main", + false, + )], + ..Default::default() + }, + )? + .expect("name found"); + assert_eq!(res.into_format(7).to_string(), "main~1"); + Ok(()) + }, + ) +} + +#[test] +fn side_parent_paths_are_preserved() -> Result<(), gix_error::Error> { + run_test( + |id| name_rev::Options { + tips: vec![tip(id, "main", false)], + ..Default::default() + }, + |_res, _id| { + let store = odb_at("."); + let mut graph = gix_revision::Graph::new(&store, None); + let res = gix_revision::name_rev( + &hex_to_id("9152eeee2328073cf23dcf8e90c949170b711659"), + &mut graph, + name_rev::Options { + tips: vec![tip( + hex_to_id("01ec18a3ebf2855708ad3c9d244306bc1fae3e9b"), + "main", + false, + )], + ..Default::default() + }, + )? + .expect("name found"); + assert_eq!(res.into_format(7).to_string(), "main^2~1"); + Ok(()) + }, + ) +} + +#[test] +fn tags_are_preferred_over_branches() -> Result<(), gix_error::Error> { + run_test( + |id| name_rev::Options { + tips: vec![ + tip(id, "main", false), + tip( + hex_to_id("efd9a841189668f1bab5b8ebade9cd0a1b139a37"), + "tags/at-c5", + true, + ), + ], + ..Default::default() + }, + |_res, _id| { + let store = odb_at("."); + let mut graph = gix_revision::Graph::new(&store, None); + let res = gix_revision::name_rev( + &hex_to_id("efd9a841189668f1bab5b8ebade9cd0a1b139a37"), + &mut graph, + name_rev::Options { + tips: vec![ + tip(hex_to_id("01ec18a3ebf2855708ad3c9d244306bc1fae3e9b"), "main", false), + tip( + hex_to_id("efd9a841189668f1bab5b8ebade9cd0a1b139a37"), + "tags/at-c5", + true, + ), + ], + ..Default::default() + }, + )? + .expect("name found"); + assert_eq!(res.into_format(7).to_string(), "tags/at-c5"); + Ok(()) + }, + ) +} + +#[test] +fn fallback_if_configured_but_no_name_matches() -> Result<(), gix_error::Error> { + run_test( + |_| name_rev::Options { + fallback_to_oid: true, + ..Default::default() + }, + |res, _id| { + let res = res?.expect("fallback active"); + assert!(res.name.is_none(), "no symbolic name was found"); + assert_eq!(res.into_format(7).to_string(), "01ec18a"); + Ok(()) + }, + ) +} + +fn tip(id: gix_hash::ObjectId, name: &'static str, from_tag: bool) -> Tip<'static> { + Tip { + id, + name: Cow::Borrowed(name.as_bytes().as_bstr()), + taggerdate: 0, + from_tag, + deref: false, + } +} + +fn odb_at(name: &str) -> gix_odb::Handle { + gix_odb::at(fixture_path().join(name).join(".git/objects")).unwrap() +} + +fn fixture_path() -> PathBuf { + gix_testtools::scripted_fixture_read_only("make_repo_with_branches.sh").unwrap() +} diff --git a/gix/Cargo.toml b/gix/Cargo.toml index c4d95eb9978..4465e0ef19d 100644 --- a/gix/Cargo.toml +++ b/gix/Cargo.toml @@ -153,7 +153,7 @@ attributes = [ mailmap = ["dep:gix-mailmap", "revision"] ## Make revspec parsing possible, as well describing revision. -revision = ["gix-revision/describe", "gix-revision/merge_base", "index"] +revision = ["gix-revision/describe", "gix-revision/merge_base", "gix-revision/name_rev", "index"] ## If enabled, revspecs now support the regex syntax like `@^{/^.*x}`. Otherwise, only substring search is supported. ## This feature does increase compile time for niche-benefit, but is required for fully git-compatible revspec parsing. diff --git a/gix/src/commit.rs b/gix/src/commit.rs index 835cb0bfa83..9dbfddd8d72 100644 --- a/gix/src/commit.rs +++ b/gix/src/commit.rs @@ -30,6 +30,207 @@ impl From for Error { } } +#[cfg(feature = "revision")] +mod named_refs { + use std::borrow::Cow; + + use crate::bstr::{BStr, BString, ByteSlice}; + use gix_hash::ObjectId; + + use crate::Repository; + + /// A selector to choose what kind of references should contribute to names. + #[derive(Default, Debug, Clone, Copy, PartialOrd, PartialEq, Ord, Eq, Hash)] + pub enum SelectRef { + /// Only use annotated tags for names. + #[default] + AnnotatedTags, + /// Use all tags for names, annotated or plain reference. + AllTags, + /// Use all references, including local branch names. + AllRefs, + } + + pub(crate) struct Candidate { + pub(crate) peeled_id: ObjectId, + pub(crate) name: Cow<'static, BStr>, + pub(crate) name_rev_name: Cow<'static, BStr>, + pub(crate) priority: u8, + pub(crate) taggerdate: i64, + pub(crate) from_tag: bool, + pub(crate) deref: bool, + } + + pub(crate) struct Filters<'a> { + pub(crate) include: &'a [BString], + pub(crate) exclude: &'a [BString], + } + + impl Filters<'_> { + pub(crate) const NONE: Filters<'static> = Filters { + include: &[], + exclude: &[], + }; + + fn allows(&self, name: &BStr) -> Option { + if self + .exclude + .iter() + .any(|pattern| subpath_match(name, pattern.as_bstr()).is_some()) + { + return None; + } + + if self.include.is_empty() { + return Some(false); + } + + let mut matched = false; + let mut abbreviate = false; + for pattern in self.include { + if let Some(is_subpath_match) = subpath_match(name, pattern.as_bstr()) { + matched = true; + abbreviate |= is_subpath_match; + } + } + matched.then_some(abbreviate) + } + } + + pub(crate) fn collect(repo: &Repository, select: SelectRef, filters: Filters<'_>) -> Result, E> + where + E: From + From, + { + let platform = repo.references()?; + let refs = match select { + SelectRef::AllRefs => platform.all()?, + SelectRef::AllTags | SelectRef::AnnotatedTags => platform.tags()?, + }; + + let mut out = Vec::new(); + for reference in refs.filter_map(Result::ok) { + let Some(abbreviate_name_rev) = filters.allows(reference.inner.name.as_bstr()) else { + continue; + }; + + match select { + SelectRef::AnnotatedTags => { + if let Some(candidate) = annotated_tag_candidate(repo, reference, abbreviate_name_rev) { + out.push(candidate); + } + } + SelectRef::AllTags | SelectRef::AllRefs => { + if let Some(candidate) = any_ref_candidate(repo, reference, abbreviate_name_rev) { + out.push(candidate); + } + } + } + } + + Ok(out) + } + + fn any_ref_candidate( + repo: &Repository, + mut reference: crate::Reference<'_>, + abbreviate_name_rev: bool, + ) -> Option { + let full_name = reference.inner.name.as_bstr().to_owned(); + let name = Cow::from(reference.inner.name.shorten().to_owned()); + let name_rev_name = if abbreviate_name_rev { + name.clone() + } else { + Cow::from(name_rev_name(full_name.as_bstr())) + }; + let target_id = reference.target().try_id().map(ToOwned::to_owned); + let peeled_id = reference.peel_to_id().ok()?; + let from_tag = full_name.starts_with_str("refs/tags/"); + let mut priority = 0; + let mut taggerdate = 0; + let deref = match target_id { + Some(target_id) if peeled_id != *target_id => { + let tag = repo.find_object(target_id).ok()?.try_into_tag().ok()?; + taggerdate = tag.tagger().ok().and_then(|s| s.map(|s| s.seconds())).unwrap_or(0); + priority = 1; + true + } + _ => false, + }; + + Some(Candidate { + peeled_id: peeled_id.inner, + name, + name_rev_name, + priority, + taggerdate, + from_tag, + deref, + }) + } + + fn annotated_tag_candidate( + _repo: &Repository, + reference: crate::Reference<'_>, + abbreviate_name_rev: bool, + ) -> Option { + // TODO: we assume direct refs for tags, which is the common case, but it doesn't have to be + // so rather follow symrefs till the first object and then peel tags after the first object was found. + let name = Cow::from(reference.name().shorten().to_owned()); + let name_rev_name = if abbreviate_name_rev { + name.clone() + } else { + Cow::from(name_rev_name(reference.name().as_bstr())) + }; + let tag = reference.try_id()?.object().ok()?.try_into_tag().ok()?; + let taggerdate = tag.tagger().ok().and_then(|s| s.map(|s| s.seconds())).unwrap_or(0); + let commit_id = tag.target_id().ok()?.object().ok()?.try_into_commit().ok()?.id; + Some(Candidate { + peeled_id: commit_id, + name, + name_rev_name, + priority: 1, + taggerdate, + from_tag: true, + deref: true, + }) + } + + pub(crate) fn commit_time(repo: &Repository, id: ObjectId) -> Option { + repo.find_object(id) + .ok()? + .try_into_commit() + .ok()? + .committer() + .ok() + .map(|committer| committer.seconds()) + } + + fn name_rev_name(name: &BStr) -> BString { + name.strip_prefix(b"refs/heads/") + .or_else(|| name.strip_prefix(b"refs/")) + .unwrap_or(name) + .to_owned() + .into() + } + + fn subpath_match(name: &BStr, pattern: &BStr) -> Option { + let mut subpath = name; + let mut is_subpath = false; + loop { + if gix_glob::wildmatch(pattern, subpath, gix_glob::wildmatch::Mode::empty()) { + return Some(is_subpath); + } + match subpath.find_byte(b'/') { + Some(pos) => { + subpath = subpath[pos + 1..].as_bstr(); + is_subpath = true; + } + None => return None, + } + } + } +} + /// #[cfg(feature = "revision")] pub mod describe { @@ -40,6 +241,8 @@ pub mod describe { use crate::{Repository, bstr::BStr, ext::ObjectIdExt}; + pub use super::named_refs::SelectRef; + /// The result of [`try_resolve()`][Platform::try_resolve()]. pub struct Resolution<'repo> { /// The outcome of the describe operation. @@ -96,83 +299,30 @@ pub mod describe { DetermineIsDirty(#[from] crate::status::is_dirty::Error), } - /// A selector to choose what kind of references should contribute to names. - #[derive(Default, Debug, Clone, Copy, PartialOrd, PartialEq, Ord, Eq, Hash)] - pub enum SelectRef { - /// Only use annotated tags for names. - #[default] - AnnotatedTags, - /// Use all tags for names, annotated or plain reference. - AllTags, - /// Use all references, including local branch names. - AllRefs, - } - impl SelectRef { fn names(&self, repo: &Repository) -> Result>, Error> { - let platform = repo.references()?; - Ok(match self { SelectRef::AllTags | SelectRef::AllRefs => { - let mut refs: Vec<_> = match self { - SelectRef::AllRefs => platform.all()?, - SelectRef::AllTags => platform.tags()?, - _ => unreachable!(), - } - .filter_map(Result::ok) - .filter_map(|mut r: crate::Reference<'_>| { - let target_id = r.target().try_id().map(ToOwned::to_owned); - let peeled_id = r.peel_to_id().ok()?; - let (prio, tag_time) = match target_id { - Some(target_id) if peeled_id != *target_id => { - let tag = repo.find_object(target_id).ok()?.try_into_tag().ok()?; - let tag_time = tag.tagger().ok().and_then(|s| s.map(|s| s.seconds())).unwrap_or(0); - (1, tag_time) - } - _ => (0, 0), - }; - ( - peeled_id.inner, - prio, - tag_time, - Cow::from(r.inner.name.shorten().to_owned()), - ) - .into() - }) - .collect(); + let mut refs = super::named_refs::collect::(repo, *self, super::named_refs::Filters::NONE)?; // By priority, then by time ascending, then lexicographically. // More recent entries overwrite older ones due to collection into hashmap. - refs.sort_by( - |(_a_peeled_id, a_prio, a_time, a_name), (_b_peeled_id, b_prio, b_time, b_name)| { - a_prio - .cmp(b_prio) - .then_with(|| a_time.cmp(b_time)) - .then_with(|| b_name.cmp(a_name)) - }, - ); - refs.into_iter().map(|(a, _, _, b)| (a, b)).collect() + refs.sort_by(|a, b| { + a.priority + .cmp(&b.priority) + .then_with(|| a.taggerdate.cmp(&b.taggerdate)) + .then_with(|| b.name.cmp(&a.name)) + }); + refs.into_iter() + .map(|candidate| (candidate.peeled_id, candidate.name)) + .collect() } SelectRef::AnnotatedTags => { - let mut peeled_commits_and_tag_date: Vec<_> = platform - .tags()? - .filter_map(Result::ok) - .filter_map(|r: crate::Reference<'_>| { - // TODO: we assume direct refs for tags, which is the common case, but it doesn't have to be - // so rather follow symrefs till the first object and then peel tags after the first object was found. - let tag = r.try_id()?.object().ok()?.try_into_tag().ok()?; - let tag_time = tag.tagger().ok().and_then(|s| s.map(|s| s.seconds())).unwrap_or(0); - let commit_id = tag.target_id().ok()?.object().ok()?.try_into_commit().ok()?.id; - Some((commit_id, tag_time, Cow::::from(r.name().shorten().to_owned()))) - }) - .collect(); + let mut refs = super::named_refs::collect::(repo, *self, super::named_refs::Filters::NONE)?; // Sort by time ascending, then lexicographically. // More recent entries overwrite older ones due to collection into hashmap. - peeled_commits_and_tag_date.sort_by(|(_a_id, a_time, a_name), (_b_id, b_time, b_name)| { - a_time.cmp(b_time).then_with(|| b_name.cmp(a_name)) - }); - peeled_commits_and_tag_date - .into_iter() - .map(|(a, _, c)| (a, c)) + refs.sort_by(|a, b| a.taggerdate.cmp(&b.taggerdate).then_with(|| b.name.cmp(&a.name))); + refs.into_iter() + .map(|candidate| (candidate.peeled_id, candidate.name)) .collect() } }) @@ -270,3 +420,152 @@ pub mod describe { } } } + +/// +#[cfg(feature = "revision")] +pub mod name_rev { + use crate::bstr::BString; + use gix_error::Exn; + + use crate::ext::ObjectIdExt; + + pub use super::named_refs::SelectRef; + + /// The result of [`try_resolve()`][Platform::try_resolve()]. + pub struct Resolution<'repo> { + /// The outcome of the name-rev operation. + pub outcome: gix_revision::name_rev::Outcome<'static>, + /// The id to name. + pub id: crate::Id<'repo>, + } + + impl Resolution<'_> { + /// Turn this instance into something displayable. + pub fn format(self) -> Result, Error> { + let prefix = self.id.shorten()?; + Ok(self.outcome.into_format(prefix.hex_len())) + } + } + + /// The error returned by [`try_format()`][Platform::try_format()]. + #[derive(Debug, thiserror::Error)] + #[allow(missing_docs)] + pub enum Error { + #[error(transparent)] + OpenCache(#[from] crate::repository::commit_graph_if_enabled::Error), + #[error(transparent)] + NameRev(#[from] gix_revision::name_rev::Error), + #[error("Could not produce an unambiguous shortened id for formatting.")] + ShortId(#[from] crate::id::shorten::Error), + #[error(transparent)] + RefIter(#[from] crate::reference::iter::Error), + #[error(transparent)] + RefIterInit(#[from] crate::reference::iter::init::Error), + } + + /// A support type to allow configuring a `git name-rev` operation. + pub struct Platform<'repo> { + pub(crate) id: gix_hash::ObjectId, + /// The owning repository. + pub repo: &'repo crate::Repository, + pub(crate) select: SelectRef, + pub(crate) include_refs: Vec, + pub(crate) exclude_refs: Vec, + pub(crate) id_as_fallback: bool, + } + + impl<'repo> Platform<'repo> { + /// Configure which references to use for names. + pub fn names(mut self, select: SelectRef) -> Self { + self.select = select; + self + } + + /// Only use references matching `pattern`. + pub fn include_ref(mut self, pattern: impl Into) -> Self { + self.include_refs.push(pattern.into()); + self + } + + /// Ignore references matching `pattern`. + pub fn exclude_ref(mut self, pattern: impl Into) -> Self { + self.exclude_refs.push(pattern.into()); + self + } + + /// If true, even if no name is available a format will always be produced. + pub fn id_as_fallback(mut self, use_fallback: bool) -> Self { + self.id_as_fallback = use_fallback; + self + } + + /// Try to find a name for the configured commit id using all prior configuration. + pub fn try_format(&self) -> Result>, Error> { + self.try_resolve()?.map(Resolution::format).transpose() + } + + /// Try to find a name for the configured commit id using all prior configuration, returning `Some(Outcome)` + /// if one was found. + /// + /// # Performance + /// + /// Prefer to use [`Self::try_resolve_with_cache()`] when processing more than one commit at a time. + pub fn try_resolve_with_cache( + &self, + cache: Option<&'_ gix_commitgraph::Graph>, + ) -> Result>, Error> { + let mut graph = self.repo.revision_graph(cache); + let tips = super::named_refs::collect::( + self.repo, + self.select, + super::named_refs::Filters { + include: &self.include_refs, + exclude: &self.exclude_refs, + }, + )? + .into_iter() + .filter_map(|candidate| { + let taggerdate = if candidate.deref { + candidate.taggerdate + } else { + super::named_refs::commit_time(self.repo, candidate.peeled_id)? + }; + Some(gix_revision::name_rev::Tip { + id: candidate.peeled_id, + name: candidate.name_rev_name, + taggerdate, + from_tag: candidate.from_tag, + deref: candidate.deref, + }) + }) + .collect(); + + let outcome = gix_revision::name_rev( + &self.id, + &mut graph, + gix_revision::name_rev::Options { + tips, + fallback_to_oid: self.id_as_fallback, + }, + ) + .map_err(Exn::into_inner)?; + + Ok(outcome.map(|outcome| Resolution { + outcome, + id: self.id.attach(self.repo), + })) + } + + /// Like [`Self::try_resolve_with_cache()`], but obtains the commitgraph-cache internally for a single use. + pub fn try_resolve(&self) -> Result>, Error> { + let cache = self.repo.commit_graph_if_enabled()?; + self.try_resolve_with_cache(cache.as_ref()) + } + + /// Like [`try_format()`](Self::try_format()), but turns `id_as_fallback()` on to always produce a format. + pub fn format(&mut self) -> Result, Error> { + self.id_as_fallback = true; + Ok(self.try_format()?.expect("BUG: fallback must always produce a format")) + } + } +} diff --git a/gix/src/object/commit.rs b/gix/src/object/commit.rs index 988a5c9082d..67cdc3434e0 100644 --- a/gix/src/object/commit.rs +++ b/gix/src/object/commit.rs @@ -211,6 +211,20 @@ impl<'repo> Commit<'repo> { } } + /// Create a platform to further configure a `git name-rev` operation to find a name for this commit by looking + /// at all references by default. + #[cfg(feature = "revision")] + pub fn name_rev(&self) -> crate::commit::name_rev::Platform<'repo> { + crate::commit::name_rev::Platform { + id: self.id, + repo: self.repo, + select: crate::commit::name_rev::SelectRef::AllRefs, + include_refs: Vec::new(), + exclude_refs: Vec::new(), + id_as_fallback: false, + } + } + /// Extracts the PGP signature and the data that was used to create the signature, or `None` if it wasn't signed. // TODO: make it possible to verify the signature, probably by wrapping `SignedData`. It's quite some work to do it properly. pub fn signature( diff --git a/gix/tests/fixtures/generated-archives/make_commit_name_rev_cutoff.tar b/gix/tests/fixtures/generated-archives/make_commit_name_rev_cutoff.tar new file mode 100644 index 00000000000..68758a0796a Binary files /dev/null and b/gix/tests/fixtures/generated-archives/make_commit_name_rev_cutoff.tar differ diff --git a/gix/tests/fixtures/generated-archives/make_commit_name_rev_cutoff_sha256.tar b/gix/tests/fixtures/generated-archives/make_commit_name_rev_cutoff_sha256.tar new file mode 100644 index 00000000000..f76f84d6952 Binary files /dev/null and b/gix/tests/fixtures/generated-archives/make_commit_name_rev_cutoff_sha256.tar differ diff --git a/gix/tests/fixtures/generated-archives/make_commit_name_rev_edge_cases.tar b/gix/tests/fixtures/generated-archives/make_commit_name_rev_edge_cases.tar new file mode 100644 index 00000000000..4fff943b71a Binary files /dev/null and b/gix/tests/fixtures/generated-archives/make_commit_name_rev_edge_cases.tar differ diff --git a/gix/tests/fixtures/generated-archives/make_commit_name_rev_edge_cases_sha256.tar b/gix/tests/fixtures/generated-archives/make_commit_name_rev_edge_cases_sha256.tar new file mode 100644 index 00000000000..300fa33b726 Binary files /dev/null and b/gix/tests/fixtures/generated-archives/make_commit_name_rev_edge_cases_sha256.tar differ diff --git a/gix/tests/fixtures/make_commit_name_rev_cutoff.sh b/gix/tests/fixtures/make_commit_name_rev_cutoff.sh new file mode 100755 index 00000000000..7fc98b5efaa --- /dev/null +++ b/gix/tests/fixtures/make_commit_name_rev_cutoff.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -eu -o pipefail + +git init -q + +# The target commit is deliberately newer than its child. Git name-rev uses the +# target date, minus a one-day slop, as a cutoff and does not let the older child +# tag name the newer parent. +echo base >f +git add f +GIT_AUTHOR_DATE="2001-01-01T00:00:00+0000" GIT_COMMITTER_DATE="2001-01-01T00:00:00+0000" git commit -qm base +git branch skew-target + +echo skewed-child >>f +git add f +GIT_AUTHOR_DATE="2000-01-01T00:00:00+0000" GIT_COMMITTER_DATE="2000-01-01T00:00:00+0000" git commit -qm skewed-child +GIT_COMMITTER_DATE="2000-02-01T00:00:00+0000" git tag -a skewed-tag -m skewed-tag diff --git a/gix/tests/fixtures/make_commit_name_rev_edge_cases.sh b/gix/tests/fixtures/make_commit_name_rev_edge_cases.sh new file mode 100755 index 00000000000..8a1f36b248b --- /dev/null +++ b/gix/tests/fixtures/make_commit_name_rev_edge_cases.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -eu -o pipefail + +git init -q + +# One commit with far-future annotated tags checks that ref sorting keeps the full +# Git timestamp range instead of truncating to u32. +echo A >f +git add f +GIT_COMMITTER_DATE="@1 +0000" GIT_AUTHOR_DATE="@1 +0000" git commit -qm A +GIT_COMMITTER_DATE="@4294967296 +0000" git tag -a a -m a +GIT_COMMITTER_DATE="@4294967297 +0000" git tag -a z -m z + +# A lightweight tag to a blob must be ignored by name-rev even when all tags are +# selected. Passing it to graph traversal would try to look up the blob as a commit. +git tag blob-tag HEAD:f diff --git a/gix/tests/gix/commit.rs b/gix/tests/gix/commit.rs index 2d05ef95096..d76e8c58cf6 100644 --- a/gix/tests/gix/commit.rs +++ b/gix/tests/gix/commit.rs @@ -86,4 +86,120 @@ mod describe { } Ok(()) } + + #[test] + fn tags_preserve_the_full_git_timestamp_range() -> crate::Result { + let repo = named_repo("make_commit_name_rev_edge_cases.sh")?; + let mut describe = repo.head_commit()?.describe(); + for filter in &[AnnotatedTags, AllTags, AllRefs] { + describe = describe.names(*filter); + assert_eq!(describe.format()?.to_string(), "z", "{filter:?}"); + } + Ok(()) + } +} + +#[cfg(feature = "revision")] +mod name_rev { + use gix::commit::name_rev::SelectRef::{AllRefs, AllTags}; + + use crate::named_repo; + + #[test] + fn default_uses_all_refs() -> crate::Result { + let repo = named_repo("make_commit_describe_multiple_tags.sh")?; + let actual = repo.head_commit()?.name_rev().format()?.to_string(); + assert!( + actual.starts_with("tags/"), + "default selection should consider tag refs before the branch name: {actual}" + ); + Ok(()) + } + + #[test] + fn tags_only_uses_tag_refs() -> crate::Result { + let repo = named_repo("make_commit_describe_multiple_tags.sh")?; + assert_eq!( + repo.head_commit()?.name_rev().names(AllTags).format()?.to_string(), + "tags/v2^0" + ); + Ok(()) + } + + #[test] + fn excluding_tags_allows_branch_names_to_win() -> crate::Result { + let repo = named_repo("make_commit_describe_multiple_tags.sh")?; + assert_eq!( + repo.head_commit()? + .name_rev() + .exclude_ref("tags/*") + .format()? + .to_string(), + "main" + ); + Ok(()) + } + + #[test] + fn include_patterns_limit_usable_refs() -> crate::Result { + let repo = named_repo("make_commit_describe_multiple_tags.sh")?; + assert_eq!( + repo.head_commit()? + .name_rev() + .names(AllRefs) + .include_ref("refs/tags/v4") + .format()? + .to_string(), + "tags/v4^0" + ); + assert_eq!( + repo.head_commit()? + .name_rev() + .names(AllRefs) + .include_ref("tags/v4") + .format()? + .to_string(), + "v4^0" + ); + Ok(()) + } + + #[test] + fn fallback_to_id_if_no_refs_match() -> crate::Result { + let repo = named_repo("make_commit_describe_multiple_tags.sh")?; + let mut name_rev = repo.head_commit()?.name_rev().exclude_ref("refs/*"); + assert!( + name_rev.try_format()?.is_none(), + "no symbolic name is available after excluding all refs" + ); + assert_eq!(name_rev.format()?.to_string().len(), 7); + Ok(()) + } + + #[test] + fn non_commit_refs_are_ignored() -> crate::Result { + let repo = named_repo("make_commit_name_rev_edge_cases.sh")?; + assert_eq!( + repo.head_commit()?.name_rev().names(AllTags).format()?.to_string(), + "tags/a^0" + ); + Ok(()) + } + + #[test] + fn older_tips_do_not_name_newer_targets() -> crate::Result { + let repo = named_repo("make_commit_name_rev_cutoff.sh")?; + let commit = repo + .find_reference("refs/heads/skew-target")? + .id() + .object()? + .into_commit(); + + assert!( + commit.name_rev().names(AllTags).try_format()?.is_none(), + "the skewed child tag is older than the target cutoff and should be ignored" + ); + assert_eq!(commit.name_rev().format()?.to_string(), "skew-target"); + Ok(()) + } } diff --git a/src/plumbing/main.rs b/src/plumbing/main.rs index d9ef65d66dd..d7df899eea0 100644 --- a/src/plumbing/main.rs +++ b/src/plumbing/main.rs @@ -1381,6 +1381,37 @@ pub fn main() -> Result<()> { ) }, ), + commit::Subcommands::NameRev { + tags, + refs, + exclude, + always, + no_undefined, + name_only, + rev_spec, + } => prepare_and_run( + "commit-name-rev", + trace, + verbose, + progress, + progress_keep_open, + None, + move |_progress, out, _err| { + core::repository::commit::name_rev( + repository(Mode::Strict)?, + rev_spec.as_deref(), + out, + core::repository::commit::name_rev::Options { + tags, + refs, + exclude, + always, + no_undefined, + name_only, + }, + ) + }, + ), }, Subcommands::Tag(platform) => match platform.cmds { Some(tag::Subcommands::List) | None => prepare_and_run( diff --git a/src/plumbing/options/mod.rs b/src/plumbing/options/mod.rs index f21eff79194..cc94f2ca584 100644 --- a/src/plumbing/options/mod.rs +++ b/src/plumbing/options/mod.rs @@ -990,6 +990,35 @@ pub mod commit { #[clap(short = 'd', long)] dirty_suffix: Option>, + /// A specification of the revision to use, or the current `HEAD` if unset. + rev_spec: Option, + }, + /// Find a symbolic name for the current commit or the given one. + NameRev { + /// Only use tags to name commits. + #[clap(long)] + tags: bool, + + /// Only use refs matching the given pattern. Can be passed multiple times. + #[clap(long = "refs")] + refs: Vec, + + /// Ignore refs matching the given pattern. Can be passed multiple times. + #[clap(long)] + exclude: Vec, + + /// Show an abbreviated commit object as fallback. + #[clap(long)] + always: bool, + + /// Fail if no name can be found. + #[clap(long = "no-undefined")] + no_undefined: bool, + + /// Print only the ref-based name. + #[clap(long)] + name_only: bool, + /// A specification of the revision to use, or the current `HEAD` if unset. rev_spec: Option, },