gix_revision/
describe.rs

1use std::{
2    borrow::Cow,
3    fmt::{Display, Formatter},
4};
5
6use bstr::BStr;
7use gix_hashtable::HashMap;
8
9/// The positive result produced by [describe()][function::describe()].
10#[derive(Debug, Clone)]
11pub struct Outcome<'name> {
12    /// The name of the tag or branch that is closest to the commit `id`.
13    ///
14    /// If `None`, no name was found but it was requested to provide the `id` itself as fallback.
15    pub name: Option<Cow<'name, BStr>>,
16    /// The input commit object id that we describe.
17    pub id: gix_hash::ObjectId,
18    /// The number of commits that are between the tag or branch with `name` and `id`.
19    /// These commits are all in the future of the named tag or branch.
20    pub depth: u32,
21    /// The mapping between object ids and their names initially provided by the describe call.
22    pub name_by_oid: HashMap<gix_hash::ObjectId, Cow<'name, BStr>>,
23    /// The amount of commits we traversed.
24    pub commits_seen: u32,
25}
26
27impl<'a> Outcome<'a> {
28    /// Turn this outcome into a structure that can display itself in the typical `git describe` format.
29    pub fn into_format(self, hex_len: usize) -> Format<'a> {
30        Format {
31            name: self.name,
32            id: self.id,
33            hex_len,
34            depth: self.depth,
35            long: false,
36            dirty_suffix: None,
37        }
38    }
39}
40
41/// A structure implementing `Display`, producing a `git describe` like string.
42#[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone)]
43pub struct Format<'a> {
44    /// The name of the branch or tag to display, as is.
45    ///
46    /// If `None`, the `id` will be displayed as a fallback.
47    pub name: Option<Cow<'a, BStr>>,
48    /// The `id` of the commit to describe.
49    pub id: gix_hash::ObjectId,
50    /// The amount of hex characters to use to display `id`.
51    pub hex_len: usize,
52    /// The amount of commits between `name` and `id`, where `id` is in the future of `name`.
53    pub depth: u32,
54    /// If true, the long form of the describe string will be produced even if `id` lies directly on `name`,
55    /// hence has a depth of 0.
56    pub long: bool,
57    /// If `Some(suffix)`, it will be appended to the describe string.
58    /// This should be set if the working tree was determined to be dirty.
59    pub dirty_suffix: Option<String>,
60}
61
62impl Format<'_> {
63    /// Return true if the `name` is directly associated with `id`, i.e. there are no commits between them.
64    pub fn is_exact_match(&self) -> bool {
65        self.depth == 0
66    }
67
68    /// Set this instance to print in long mode, that is if `depth` is 0, it will still print the whole
69    /// long form even though it's not quite necessary.
70    ///
71    /// Otherwise, it is allowed to shorten itself.
72    pub fn long(&mut self, long: bool) -> &mut Self {
73        self.long = long;
74        self
75    }
76}
77
78impl Display for Format<'_> {
79    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
80        if let Some(name) = self.name.as_deref() {
81            if !self.long && self.is_exact_match() {
82                name.fmt(f)?;
83            } else {
84                write!(f, "{}-{}-g{}", name, self.depth, self.id.to_hex_with_len(self.hex_len))?;
85            }
86        } else {
87            self.id.to_hex_with_len(self.hex_len).fmt(f)?;
88        }
89
90        if let Some(suffix) = &self.dirty_suffix {
91            write!(f, "-{suffix}")?;
92        }
93        Ok(())
94    }
95}
96
97/// A bit-field which keeps track of which commit is reachable by one of 32 candidates names.
98pub type Flags = u32;
99const MAX_CANDIDATES: usize = std::mem::size_of::<Flags>() * 8;
100
101/// The options required to call [`describe()`][function::describe()].
102#[derive(Clone, Debug)]
103pub struct Options<'name> {
104    /// The candidate names from which to determine the `name` to use for the describe string,
105    /// as a mapping from a commit id and the name associated with it.
106    pub name_by_oid: HashMap<gix_hash::ObjectId, Cow<'name, BStr>>,
107    /// The amount of names we will keep track of. Defaults to the maximum of 32.
108    ///
109    /// If the number is exceeded, it will be capped at 32 and defaults to 10.
110    pub max_candidates: usize,
111    /// If no candidate for naming, always show the abbreviated hash. Default: false.
112    pub fallback_to_oid: bool,
113    /// Only follow the first parent during graph traversal. Default: false.
114    ///
115    /// This may speed up the traversal at the cost of accuracy.
116    pub first_parent: bool,
117}
118
119impl Default for Options<'_> {
120    fn default() -> Self {
121        Options {
122            max_candidates: 10, // the same number as git uses, otherwise we perform worse by default on big repos
123            name_by_oid: Default::default(),
124            fallback_to_oid: false,
125            first_parent: false,
126        }
127    }
128}
129
130/// The error returned by the [`describe()`][function::describe()] function.
131#[derive(Debug, thiserror::Error)]
132#[allow(missing_docs)]
133pub enum Error {
134    #[error("The parents of commit {} could not be added to graph during traversal", oid.to_hex())]
135    InsertParentsToGraph {
136        #[source]
137        err: crate::graph::insert_parents::Error,
138        oid: gix_hash::ObjectId,
139    },
140    #[error("A commit could not be decoded during traversal")]
141    Decode(#[from] gix_object::decode::Error),
142}
143
144pub(crate) mod function {
145    use std::{borrow::Cow, cmp::Ordering};
146
147    use bstr::BStr;
148    use gix_hash::oid;
149
150    use super::{Error, Outcome};
151    use crate::{
152        describe::{CommitTime, Flags, Options, MAX_CANDIDATES},
153        Graph, PriorityQueue,
154    };
155
156    /// Given a `commit` id, traverse the commit `graph` and collect candidate names from the `name_by_oid` mapping to produce
157    /// an `Outcome`, which converted [`into_format()`][Outcome::into_format()] will produce a typical `git describe` string.
158    ///
159    /// Note that the `name_by_oid` map is returned in the [`Outcome`], which can be forcefully returned even if there was no matching
160    /// candidate by setting `fallback_to_oid` to true.
161    pub fn describe<'name>(
162        commit: &oid,
163        graph: &mut Graph<'_, '_, Flags>,
164        Options {
165            name_by_oid,
166            mut max_candidates,
167            fallback_to_oid,
168            first_parent,
169        }: Options<'name>,
170    ) -> Result<Option<Outcome<'name>>, Error> {
171        let _span = gix_trace::coarse!(
172            "gix_revision::describe()",
173            commit = %commit,
174            name_count = name_by_oid.len(),
175            max_candidates,
176            first_parent
177        );
178        max_candidates = max_candidates.min(MAX_CANDIDATES);
179        if let Some(name) = name_by_oid.get(commit) {
180            return Ok(Some(Outcome {
181                name: name.clone().into(),
182                id: commit.to_owned(),
183                depth: 0,
184                name_by_oid,
185                commits_seen: 0,
186            }));
187        }
188
189        if max_candidates == 0 || name_by_oid.is_empty() {
190            return if fallback_to_oid {
191                Ok(Some(Outcome {
192                    id: commit.to_owned(),
193                    name: None,
194                    name_by_oid,
195                    depth: 0,
196                    commits_seen: 0,
197                }))
198            } else {
199                Ok(None)
200            };
201        }
202
203        let mut queue = PriorityQueue::from_iter(Some((u32::MAX, commit.to_owned())));
204        let mut candidates = Vec::new();
205        let mut commits_seen = 0;
206        let mut gave_up_on_commit = None;
207        graph.clear();
208        graph.insert(commit.to_owned(), 0u32);
209
210        while let Some(commit) = queue.pop_value() {
211            commits_seen += 1;
212            let flags = if let Some(name) = name_by_oid.get(&commit) {
213                if candidates.len() < max_candidates {
214                    let identity_bit = 1 << candidates.len();
215                    candidates.push(Candidate {
216                        name: name.clone(),
217                        commits_in_its_future: commits_seen - 1,
218                        identity_bit,
219                        order: candidates.len(),
220                    });
221                    let flags = graph.get_mut(&commit).expect("inserted");
222                    *flags |= identity_bit;
223                    *flags
224                } else {
225                    gave_up_on_commit = Some(commit);
226                    break;
227                }
228            } else {
229                graph[&commit]
230            };
231
232            for candidate in candidates
233                .iter_mut()
234                .filter(|c| (flags & c.identity_bit) != c.identity_bit)
235            {
236                candidate.commits_in_its_future += 1;
237            }
238
239            if queue.is_empty() && !candidates.is_empty() {
240                // single-trunk history that waits to be replenished.
241                // Abort early if the best-candidate is in the current commits past.
242                let mut shortest_depth = Flags::MAX;
243                let mut best_candidates_at_same_depth = 0_u32;
244                for candidate in &candidates {
245                    match candidate.commits_in_its_future.cmp(&shortest_depth) {
246                        Ordering::Less => {
247                            shortest_depth = candidate.commits_in_its_future;
248                            best_candidates_at_same_depth = candidate.identity_bit;
249                        }
250                        Ordering::Equal => {
251                            best_candidates_at_same_depth |= candidate.identity_bit;
252                        }
253                        Ordering::Greater => {}
254                    }
255                }
256
257                if (flags & best_candidates_at_same_depth) == best_candidates_at_same_depth {
258                    break;
259                }
260            }
261
262            parents_by_date_onto_queue_and_track_names(graph, &mut queue, commit, flags, first_parent)?;
263        }
264
265        if candidates.is_empty() {
266            return if fallback_to_oid {
267                Ok(Some(Outcome {
268                    id: commit.to_owned(),
269                    name: None,
270                    name_by_oid,
271                    depth: 0,
272                    commits_seen,
273                }))
274            } else {
275                Ok(None)
276            };
277        }
278
279        candidates.sort_by(|a, b| {
280            a.commits_in_its_future
281                .cmp(&b.commits_in_its_future)
282                .then_with(|| a.order.cmp(&b.order))
283        });
284
285        if let Some(commit_id) = gave_up_on_commit {
286            queue.insert(u32::MAX, commit_id);
287            commits_seen -= 1;
288        }
289
290        commits_seen += finish_depth_computation(
291            queue,
292            graph,
293            candidates.first_mut().expect("at least one candidate"),
294            first_parent,
295        )?;
296
297        Ok(candidates.into_iter().next().map(|c| Outcome {
298            name: c.name.into(),
299            id: commit.to_owned(),
300            depth: c.commits_in_its_future,
301            name_by_oid,
302            commits_seen,
303        }))
304    }
305
306    fn parents_by_date_onto_queue_and_track_names(
307        graph: &mut Graph<'_, '_, Flags>,
308        queue: &mut PriorityQueue<CommitTime, gix_hash::ObjectId>,
309        commit: gix_hash::ObjectId,
310        commit_flags: Flags,
311        first_parent: bool,
312    ) -> Result<(), Error> {
313        graph
314            .insert_parents(
315                &commit,
316                &mut |parent_id, parent_commit_date| {
317                    queue.insert(parent_commit_date as u32, parent_id);
318                    commit_flags
319                },
320                &mut |_parent_id, flags| *flags |= commit_flags,
321                first_parent,
322            )
323            .map_err(|err| Error::InsertParentsToGraph { err, oid: commit })?;
324        Ok(())
325    }
326
327    fn finish_depth_computation(
328        mut queue: PriorityQueue<CommitTime, gix_hash::ObjectId>,
329        graph: &mut Graph<'_, '_, Flags>,
330        best_candidate: &mut Candidate<'_>,
331        first_parent: bool,
332    ) -> Result<u32, Error> {
333        let mut commits_seen = 0;
334        while let Some(commit) = queue.pop_value() {
335            commits_seen += 1;
336            let flags = graph[&commit];
337            if (flags & best_candidate.identity_bit) == best_candidate.identity_bit {
338                if queue
339                    .iter_unordered()
340                    .all(|id| (graph[id] & best_candidate.identity_bit) == best_candidate.identity_bit)
341                {
342                    break;
343                }
344            } else {
345                best_candidate.commits_in_its_future += 1;
346            }
347
348            parents_by_date_onto_queue_and_track_names(graph, &mut queue, commit, flags, first_parent)?;
349        }
350        Ok(commits_seen)
351    }
352
353    #[derive(Debug)]
354    struct Candidate<'a> {
355        name: Cow<'a, BStr>,
356        commits_in_its_future: Flags,
357        /// A single bit identifying this candidate uniquely in a bitset
358        identity_bit: Flags,
359        /// The order at which we found the candidate, first one has order = 0
360        order: usize,
361    }
362}
363
364/// The timestamp for the creation date of a commit in seconds since unix epoch.
365type CommitTime = u32;