Skip to main content

grit_lib/
name_rev.rs

1//! Name-rev: name commits relative to refs.
2//!
3//! This module implements the algorithm behind `git name-rev`: given a set of
4//! refs it walks backwards through the commit graph and assigns each reachable
5//! commit a human-readable name derived from the nearest ref tip.
6//!
7//! # Name format
8//!
9//! - Directly pointed to by a ref: `<refname>` (e.g. `tags/v1.0`, `main`)
10//! - Tag object pointing at the commit: `<refname>^0`
11//! - N first-parent hops from a tip: `<refname>~N`
12//! - Second or later parent of a merge: `<refname>^2`, `<refname>~3^2`, etc.
13
14use crate::error::{Error, Result};
15use crate::ident::committer_unix_seconds_for_ordering;
16use crate::objects::{parse_commit, parse_tag, CommitData, ObjectId, ObjectKind};
17use crate::refs;
18use crate::repo::Repository;
19use std::collections::{HashMap, VecDeque};
20
21/// How many first-parent generations are preferred over a single merge hop.
22const MERGE_TRAVERSAL_WEIGHT: u32 = 65_535;
23
24/// Internal per-commit naming record.
25#[derive(Clone, Debug)]
26struct RevName {
27    /// The ref-derived name for the tip of this naming chain.
28    ///
29    /// This is stored verbatim (e.g. `"main"` or `"tags/v1.0^0"`); the final
30    /// `~N` generation suffix is appended only at display time.
31    tip_name: String,
32    /// Tagger / committer date of the originating ref tip (Unix timestamp).
33    taggerdate: i64,
34    /// Number of first-parent hops from the naming tip to this commit.
35    generation: u32,
36    /// Weighted hop count used for name-quality comparisons.
37    distance: u32,
38    /// Whether the originating ref is under `refs/tags/`.
39    from_tag: bool,
40}
41
42/// Options that control which refs participate in naming.
43#[derive(Debug, Default, Clone)]
44pub struct NameRevOptions {
45    /// When true, only `refs/tags/` refs are used.
46    pub tags_only: bool,
47    /// When true, tag ref names are shortened to bare names (strips `tags/`).
48    ///
49    /// In Git this is enabled by `--tags --name-only` together.  Pass this as
50    /// `tags_only && name_only` from the CLI layer.
51    pub shorten_tags: bool,
52    /// Glob patterns — only refs whose path (or any sub-path) matches at least
53    /// one pattern are used.  Empty means "all refs pass".
54    pub ref_filters: Vec<String>,
55    /// Glob patterns — refs whose path (or any sub-path) matches are excluded.
56    pub exclude_filters: Vec<String>,
57}
58
59/// Build the complete `ObjectId → display-name` mapping for all commits
60/// reachable from the applicable refs in `repo`.
61///
62/// The returned map contains every commit that could be named given the
63/// supplied `options`.  Commits not reachable from any passing ref are absent
64/// from the map.
65///
66/// # Errors
67///
68/// Returns [`Error::Io`] for filesystem problems and [`Error::CorruptObject`]
69/// for malformed objects.
70pub fn build_name_map(
71    repo: &Repository,
72    options: &NameRevOptions,
73) -> Result<HashMap<ObjectId, String>> {
74    let tips = collect_tips(repo, options)?;
75    let mut names: HashMap<ObjectId, RevName> = HashMap::new();
76
77    let mut commit_cache: HashMap<ObjectId, CommitData> = HashMap::new();
78
79    for tip in &tips {
80        let Some(commit_oid) = tip.commit_oid else {
81            continue;
82        };
83        name_from_tip(
84            repo,
85            &mut names,
86            &mut commit_cache,
87            commit_oid,
88            &tip.display_name,
89            tip.taggerdate,
90            tip.from_tag,
91            tip.deref,
92        )?;
93    }
94
95    Ok(names
96        .into_iter()
97        .map(|(oid, name)| (oid, format_name(&name)))
98        .collect())
99}
100
101/// Return the display name for a named commit.
102///
103/// - `generation == 0`: returns `tip_name` as-is.
104/// - `generation > 0`: returns `<tip_name>~<generation>` with any trailing
105///   `"^0"` stripped from `tip_name` first.
106fn format_name(name: &RevName) -> String {
107    if name.generation == 0 {
108        return name.tip_name.clone();
109    }
110    let base = name.tip_name.strip_suffix("^0").unwrap_or(&name.tip_name);
111    format!("{}~{}", base, name.generation)
112}
113
114/// Compute the effective comparison distance for a (distance, generation) pair.
115///
116/// First-parent chains add [`MERGE_TRAVERSAL_WEIGHT`] to make merge-traversal
117/// paths look "closer" (lower distance) than deep first-parent chains when
118/// generation > 0.
119fn effective_distance(distance: u32, generation: u32) -> u32 {
120    distance.saturating_add(if generation > 0 {
121        MERGE_TRAVERSAL_WEIGHT
122    } else {
123        0
124    })
125}
126
127/// Return `true` when the proposed name is strictly better than the existing one.
128///
129/// Preference order:
130/// 1. Tags over non-tags.
131/// 2. Smaller effective distance.
132/// 3. Older tagger/committer date (more stable refs first).
133fn is_better_name(
134    existing: &RevName,
135    taggerdate: i64,
136    generation: u32,
137    distance: u32,
138    from_tag: bool,
139) -> bool {
140    let existing_eff = effective_distance(existing.distance, existing.generation);
141    let new_eff = effective_distance(distance, generation);
142
143    // Tags beat non-tags.
144    if from_tag && existing.from_tag {
145        return existing_eff > new_eff;
146    }
147    if existing.from_tag != from_tag {
148        return from_tag;
149    }
150
151    // Both non-tags: prefer smaller effective distance.
152    if existing_eff != new_eff {
153        return existing_eff > new_eff;
154    }
155
156    // Tiebreak by older date (more stable / earlier tag).
157    if existing.taggerdate != taggerdate {
158        return existing.taggerdate > taggerdate;
159    }
160
161    false
162}
163
164/// Derive the `tip_name` for a non-first parent.
165///
166/// Uses the *current* commit's name (not the parent's) so that e.g. the second
167/// parent of `main~3` is named `main~3^2`.
168fn get_parent_name(current: &RevName, parent_number: u32) -> String {
169    let base = current
170        .tip_name
171        .strip_suffix("^0")
172        .unwrap_or(&current.tip_name);
173    if current.generation > 0 {
174        format!("{}~{}^{}", base, current.generation, parent_number)
175    } else {
176        format!("{}^{}", base, parent_number)
177    }
178}
179
180/// Walk backwards from `start_oid`, assigning names to all reachable ancestors.
181///
182/// Uses an explicit stack to avoid deep recursion on long histories.
183fn name_from_tip(
184    repo: &Repository,
185    names: &mut HashMap<ObjectId, RevName>,
186    commit_cache: &mut HashMap<ObjectId, CommitData>,
187    start_oid: ObjectId,
188    tip_name: &str,
189    taggerdate: i64,
190    from_tag: bool,
191    deref: bool,
192) -> Result<()> {
193    let actual_tip_name = if deref {
194        format!("{}^0", tip_name)
195    } else {
196        tip_name.to_owned()
197    };
198
199    // Seed the starting commit.
200    let should_start = match names.get(&start_oid) {
201        None => true,
202        Some(existing) => is_better_name(existing, taggerdate, 0, 0, from_tag),
203    };
204    if !should_start {
205        return Ok(());
206    }
207    names.insert(
208        start_oid,
209        RevName {
210            tip_name: actual_tip_name,
211            taggerdate,
212            generation: 0,
213            distance: 0,
214            from_tag,
215        },
216    );
217
218    // Stack-based DFS; first parent is processed first.
219    let mut stack: Vec<ObjectId> = vec![start_oid];
220
221    while let Some(oid) = stack.pop() {
222        let current = match names.get(&oid) {
223            Some(n) => n.clone(),
224            None => continue,
225        };
226
227        let commit = match load_commit_cached(repo, commit_cache, oid) {
228            Ok(c) => c,
229            Err(_) => continue,
230        };
231        // Clone parents out so we can release the borrow.
232        let parents = commit.parents.clone();
233
234        let mut to_push: Vec<ObjectId> = Vec::new();
235
236        for (idx, parent_oid) in parents.iter().enumerate() {
237            let parent_number = (idx + 1) as u32;
238
239            let (parent_gen, parent_dist) = if parent_number > 1 {
240                (
241                    0u32,
242                    current.distance.saturating_add(MERGE_TRAVERSAL_WEIGHT),
243                )
244            } else {
245                (
246                    current.generation.saturating_add(1),
247                    current.distance.saturating_add(1),
248                )
249            };
250
251            let should_update = match names.get(parent_oid) {
252                None => true,
253                Some(existing) => {
254                    is_better_name(existing, taggerdate, parent_gen, parent_dist, from_tag)
255                }
256            };
257
258            if should_update {
259                let parent_tip_name = if parent_number > 1 {
260                    get_parent_name(&current, parent_number)
261                } else {
262                    current.tip_name.clone()
263                };
264
265                names.insert(
266                    *parent_oid,
267                    RevName {
268                        tip_name: parent_tip_name,
269                        taggerdate,
270                        generation: parent_gen,
271                        distance: parent_dist,
272                        from_tag,
273                    },
274                );
275                to_push.push(*parent_oid);
276            }
277        }
278
279        for parent in to_push.into_iter().rev() {
280            stack.push(parent);
281        }
282    }
283
284    Ok(())
285}
286
287/// A ref tip to be used as a naming source.
288struct TipEntry {
289    /// Short display name for output (e.g. `"main"`, `"tags/v1.0"`).
290    display_name: String,
291    /// Peeled commit OID, if the ref ultimately resolves to a commit.
292    commit_oid: Option<ObjectId>,
293    /// Tagger date (for annotated tags) or committer date.
294    taggerdate: i64,
295    /// True when the ref is under `refs/tags/`.
296    from_tag: bool,
297    /// True when the object was a tag and we peeled it to reach the commit.
298    deref: bool,
299}
300
301/// Collect all ref tips from the repository, applying option filters.
302fn collect_tips(repo: &Repository, options: &NameRevOptions) -> Result<Vec<TipEntry>> {
303    let all_refs = refs::list_refs(&repo.git_dir, "refs/")?;
304    let mut tips: Vec<TipEntry> = Vec::new();
305
306    for (refname, oid) in all_refs {
307        if options.tags_only && !refname.starts_with("refs/tags/") {
308            continue;
309        }
310
311        // Exclude filters.
312        if options
313            .exclude_filters
314            .iter()
315            .any(|pat| subpath_matches(&refname, pat))
316        {
317            continue;
318        }
319
320        // Include filters (if specified, at least one must match).
321        let can_abbreviate = if !options.ref_filters.is_empty() {
322            let mut matched = false;
323            let mut subpath_match = false;
324            for pat in &options.ref_filters {
325                match subpath_match_kind(&refname, pat) {
326                    SubpathMatch::Full => matched = true,
327                    SubpathMatch::Sub => {
328                        matched = true;
329                        subpath_match = true;
330                    }
331                    SubpathMatch::None => {}
332                }
333            }
334            if !matched {
335                continue;
336            }
337            subpath_match
338        } else {
339            // No filter: can abbreviate if tags_only (and caller also requests name_only,
340            // but we handle that at the CLI layer).
341            false
342        };
343
344        let from_tag = refname.starts_with("refs/tags/");
345        let display_name = shorten_refname(&refname, can_abbreviate || options.shorten_tags);
346
347        // Peel the object: follow tag chains until we reach a commit.
348        let (commit_oid, taggerdate, deref) = peel_to_commit(repo, oid)?;
349
350        tips.push(TipEntry {
351            display_name,
352            commit_oid,
353            taggerdate,
354            from_tag,
355            deref,
356        });
357    }
358
359    // Sort: tags first (from_tag=true sorts before false), then older taggerdate.
360    tips.sort_by(|a, b| {
361        let tag_cmp = b.from_tag.cmp(&a.from_tag); // true > false, so b first = tags first
362        if tag_cmp != std::cmp::Ordering::Equal {
363            return tag_cmp;
364        }
365        a.taggerdate.cmp(&b.taggerdate)
366    });
367
368    Ok(tips)
369}
370
371/// Peel an object ID through tag layers to find the underlying commit.
372///
373/// Returns `(commit_oid, taggerdate, was_dereffed)`.  `taggerdate` is the
374/// tagger timestamp from the first (outermost) tag encountered, or the
375/// committer timestamp if the object is a plain commit.  `was_dereffed` is
376/// true if at least one tag layer was peeled.
377fn peel_to_commit(repo: &Repository, mut oid: ObjectId) -> Result<(Option<ObjectId>, i64, bool)> {
378    let mut deref = false;
379    let mut taggerdate: Option<i64> = None;
380
381    loop {
382        let obj = match repo.odb.read(&oid) {
383            Ok(o) => o,
384            Err(_) => return Ok((None, taggerdate.unwrap_or(0), deref)),
385        };
386
387        match obj.kind {
388            ObjectKind::Commit => {
389                let ts = if let Ok(c) = parse_commit(&obj.data) {
390                    parse_signature_time(&c.committer)
391                } else {
392                    0
393                };
394                let date = taggerdate.unwrap_or(ts);
395                return Ok((Some(oid), date, deref));
396            }
397            ObjectKind::Tag => {
398                let tag = match parse_tag(&obj.data) {
399                    Ok(t) => t,
400                    Err(_) => return Ok((None, taggerdate.unwrap_or(0), deref)),
401                };
402                // Record the outermost tag's date.
403                if taggerdate.is_none() {
404                    taggerdate = Some(tag.tagger.as_deref().map(parse_signature_time).unwrap_or(0));
405                }
406                oid = tag.object;
407                deref = true;
408            }
409            _ => return Ok((None, taggerdate.unwrap_or(0), deref)),
410        }
411    }
412}
413
414/// Walk all commits reachable from every ref and return their OIDs.
415///
416/// Used by `--all` mode to enumerate commits that should be named and printed.
417///
418/// # Errors
419///
420/// Returns object or I/O errors on failure.
421pub fn all_reachable_commits(repo: &Repository) -> Result<Vec<ObjectId>> {
422    let all_refs = refs::list_refs(&repo.git_dir, "refs/")?;
423    let mut seen: std::collections::HashSet<ObjectId> = std::collections::HashSet::new();
424    let mut queue: VecDeque<ObjectId> = VecDeque::new();
425
426    for (_, oid) in all_refs {
427        // Peel to commit if needed.
428        let (commit_oid, _, _) = peel_to_commit(repo, oid)?;
429        if let Some(c) = commit_oid {
430            if seen.insert(c) {
431                queue.push_back(c);
432            }
433        }
434    }
435
436    while let Some(oid) = queue.pop_front() {
437        let commit = match load_commit(repo, oid) {
438            Ok(c) => c,
439            Err(_) => continue,
440        };
441        for parent in commit.parents {
442            if seen.insert(parent) {
443                queue.push_back(parent);
444            }
445        }
446    }
447
448    let mut result: Vec<ObjectId> = seen.into_iter().collect();
449    result.sort();
450    Ok(result)
451}
452
453/// Shorten a fully-qualified ref name to its display form.
454///
455/// - `can_abbreviate = true`: strip `refs/heads/`, `refs/tags/`, or `refs/`
456///   (mimics Git's unambiguous-ref shortening for `--tags --name-only`).
457/// - `can_abbreviate = false`: strip only `refs/heads/`; other prefixes lose
458///   only the leading `refs/` component, preserving `tags/`, `remotes/`, etc.
459fn shorten_refname(refname: &str, can_abbreviate: bool) -> String {
460    if can_abbreviate {
461        if let Some(rest) = refname.strip_prefix("refs/heads/") {
462            return rest.to_owned();
463        }
464        if let Some(rest) = refname.strip_prefix("refs/tags/") {
465            return rest.to_owned();
466        }
467        if let Some(rest) = refname.strip_prefix("refs/") {
468            return rest.to_owned();
469        }
470        return refname.to_owned();
471    }
472    // Default: strip refs/heads/ only; everything else keeps its sub-namespace.
473    if let Some(rest) = refname.strip_prefix("refs/heads/") {
474        return rest.to_owned();
475    }
476    if let Some(rest) = refname.strip_prefix("refs/") {
477        return rest.to_owned();
478    }
479    refname.to_owned()
480}
481
482/// Possible outcomes of matching a ref path against a glob pattern.
483#[derive(PartialEq, Eq)]
484enum SubpathMatch {
485    /// The pattern matched the full path starting at position 0.
486    Full,
487    /// The pattern matched a sub-path (after a `/`).
488    Sub,
489    /// No match.
490    None,
491}
492
493/// Test whether `pattern` matches `path` or any slash-separated suffix of it.
494///
495/// Returns the kind of match found (full, sub-path, or none), matching
496/// Git's `subpath_matches` semantics.
497fn subpath_match_kind(path: &str, pattern: &str) -> SubpathMatch {
498    // Try the full path first.
499    if glob_matches(pattern, path) {
500        return SubpathMatch::Full;
501    }
502    // Try every sub-path (after each '/').
503    let mut rest = path;
504    while let Some(pos) = rest.find('/') {
505        rest = &rest[pos + 1..];
506        if glob_matches(pattern, rest) {
507            return SubpathMatch::Sub;
508        }
509    }
510    SubpathMatch::None
511}
512
513/// Return `true` when the path (or any sub-path) matches the pattern.
514fn subpath_matches(path: &str, pattern: &str) -> bool {
515    subpath_match_kind(path, pattern) != SubpathMatch::None
516}
517
518/// Minimal glob matcher supporting `*` (any sequence) and `?` (any single char).
519///
520/// All other characters match literally.
521fn glob_matches(pattern: &str, text: &str) -> bool {
522    let pat: Vec<char> = pattern.chars().collect();
523    let txt: Vec<char> = text.chars().collect();
524    glob_match_inner(&pat, &txt)
525}
526
527fn glob_match_inner(pat: &[char], txt: &[char]) -> bool {
528    match (pat.first(), txt.first()) {
529        (None, None) => true,
530        (None, Some(_)) => false,
531        (Some('*'), _) => {
532            // '*' matches zero or more characters.
533            glob_match_inner(&pat[1..], txt)
534                || (!txt.is_empty() && glob_match_inner(pat, &txt[1..]))
535        }
536        (Some('?'), Some(_)) => glob_match_inner(&pat[1..], &txt[1..]),
537        (Some('?'), None) => false,
538        (Some(p), Some(t)) => p == t && glob_match_inner(&pat[1..], &txt[1..]),
539        (Some(_), None) => false,
540    }
541}
542
543/// Parse the Unix timestamp from a Git signature string.
544///
545/// A signature has the form `"Name <email> <timestamp> <tz>"`.  This returns
546/// the second-to-last whitespace-delimited token parsed as an `i64`, or `0`
547/// on parse failure.
548pub(crate) fn parse_signature_time(sig: &str) -> i64 {
549    committer_unix_seconds_for_ordering(sig)
550}
551
552/// Load and parse a commit object, using a cache to avoid re-reading.
553fn load_commit_cached<'c>(
554    repo: &Repository,
555    cache: &'c mut HashMap<ObjectId, CommitData>,
556    oid: ObjectId,
557) -> Result<&'c CommitData> {
558    if let std::collections::hash_map::Entry::Vacant(e) = cache.entry(oid) {
559        let obj = repo.odb.read(&oid)?;
560        if obj.kind != ObjectKind::Commit {
561            return Err(Error::CorruptObject(format!(
562                "object {oid} is not a commit"
563            )));
564        }
565        let commit = parse_commit(&obj.data)?;
566        e.insert(commit);
567    }
568    Ok(cache.get(&oid).unwrap())
569}
570
571/// Load and parse a commit object from the object database.
572fn load_commit(repo: &Repository, oid: ObjectId) -> Result<CommitData> {
573    let obj = repo.odb.read(&oid)?;
574    if obj.kind != ObjectKind::Commit {
575        return Err(Error::CorruptObject(format!(
576            "object {oid} is not a commit"
577        )));
578    }
579    parse_commit(&obj.data)
580}
581
582/// Resolve a full 40-hex OID string, an abbreviated OID, or a ref name to an
583/// [`ObjectId`].
584///
585/// Used when the caller passes raw OID strings on the command line.
586///
587/// # Errors
588///
589/// Returns [`Error::ObjectNotFound`] when the spec cannot be resolved.
590pub fn resolve_oid(repo: &Repository, spec: &str) -> Result<ObjectId> {
591    crate::rev_parse::resolve_revision(repo, spec)
592}
593
594/// Look up the best name for `oid` in the given map, peeling tags first.
595///
596/// For non-commit objects (e.g. a tag object), the map may contain an entry
597/// under the *underlying* commit OID.  This function tries `oid` directly, and
598/// if that misses (and the object is a tag), it peels and retries.
599///
600/// Returns `None` when no name is available.
601///
602/// # Errors
603///
604/// Returns object read errors.
605pub fn lookup_name<'m>(
606    repo: &Repository,
607    name_map: &'m HashMap<ObjectId, String>,
608    oid: ObjectId,
609) -> Result<Option<&'m String>> {
610    // Fast path: direct hit (works for commits and exact-ref matches).
611    if let Some(name) = name_map.get(&oid) {
612        return Ok(Some(name));
613    }
614
615    // For tag objects: peel to commit and try again.
616    let obj = match repo.odb.read(&oid) {
617        Ok(o) => o,
618        Err(_) => return Ok(None),
619    };
620    if obj.kind == ObjectKind::Tag {
621        let (commit_oid, _, _) = peel_to_commit(repo, oid)?;
622        if let Some(c) = commit_oid {
623            return Ok(name_map.get(&c));
624        }
625    }
626    Ok(None)
627}
628
629/// Annotate a line of text: wherever a 40-hex sequence appears whose OID is in
630/// `name_map`, append ` (<name>)` after the hex string (or replace the hex
631/// string with the name when `name_only` is true).
632///
633/// Returns the annotated line (including trailing newline if the original had
634/// one).
635pub fn annotate_line(
636    repo: &Repository,
637    name_map: &HashMap<ObjectId, String>,
638    line: &str,
639    name_only: bool,
640) -> Result<String> {
641    let mut out = String::with_capacity(line.len() + 32);
642    let chars: Vec<char> = line.chars().collect();
643    let hex_len = 40usize;
644    let mut i = 0usize;
645    let mut flush_start = 0usize;
646
647    while i + hex_len <= chars.len() {
648        // Check if chars[i..i+hex_len] is all hex and the char after is not hex.
649        let slice: String = chars[i..i + hex_len].iter().collect();
650        let after_is_hex = chars
651            .get(i + hex_len)
652            .map(|c| c.is_ascii_hexdigit())
653            .unwrap_or(false);
654        if !after_is_hex && slice.chars().all(|c| c.is_ascii_hexdigit()) {
655            // Try to resolve this as an OID.
656            if let Ok(oid) = slice.parse::<ObjectId>() {
657                if let Ok(Some(name)) = lookup_name(repo, name_map, oid) {
658                    // Flush everything before this match.
659                    let prefix: String = chars[flush_start..i].iter().collect();
660                    out.push_str(&prefix);
661
662                    if name_only {
663                        out.push_str(name);
664                    } else {
665                        out.push_str(&slice);
666                        out.push_str(" (");
667                        out.push_str(name);
668                        out.push(')');
669                    }
670                    flush_start = i + hex_len;
671                    i += hex_len;
672                    continue;
673                }
674            }
675        }
676        i += 1;
677    }
678
679    // Flush remainder.
680    let tail: String = chars[flush_start..].iter().collect();
681    out.push_str(&tail);
682    Ok(out)
683}
684
685/// Return a short abbreviated hex string for `oid` (first `len` hex chars).
686#[must_use]
687pub fn abbrev_oid(oid: ObjectId, len: usize) -> String {
688    let hex = oid.to_hex();
689    let n = len.clamp(4, 40).min(hex.len());
690    hex[..n].to_owned()
691}
692
693/// Return all commits reachable from `refs/` sorted by OID (for `--all` mode).
694///
695/// This is an alias for [`all_reachable_commits`] exposed for the CLI.
696pub use self::all_reachable_commits as walk_all_commits;
697
698/// Check if a path exists in the object database (either OID).
699pub fn object_exists(repo: &Repository, oid: ObjectId) -> bool {
700    repo.odb.exists(&oid)
701}