branchless/core/
node_descriptors.rs

1//! Additional description metadata to display for commits.
2//!
3//! These are rendered inline in the smartlog, between the commit hash and the
4//! commit message.
5
6use std::collections::{HashMap, HashSet};
7use std::sync::{Arc, Mutex};
8use std::time::SystemTime;
9
10use bstr::{ByteSlice, ByteVec};
11use cursive::theme::BaseColor;
12use cursive::utils::markup::StyledString;
13use lazy_static::lazy_static;
14use regex::Regex;
15use tracing::instrument;
16
17use crate::core::config::{
18    get_commit_descriptors_branches, get_commit_descriptors_differential_revision,
19    get_commit_descriptors_relative_time,
20};
21use crate::git::{
22    CategorizedReferenceName, Commit, NonZeroOid, ReferenceName, Repo, ResolvedReferenceInfo,
23};
24
25use super::eventlog::{Event, EventCursor, EventReplayer};
26use super::formatting::{Glyphs, StyledStringBuilder};
27use super::repo_ext::RepoReferencesSnapshot;
28use super::rewrite::find_rewrite_target;
29
30/// An object which can be rendered in the smartlog.
31#[derive(Clone, Debug)]
32pub enum NodeObject<'repo> {
33    /// A commit.
34    Commit {
35        /// The commit.
36        commit: Commit<'repo>,
37    },
38
39    /// A commit which has been garbage collected, for which detailed
40    /// information is no longer available.
41    GarbageCollected {
42        /// The OID of the garbage-collected commit.
43        oid: NonZeroOid,
44    },
45}
46
47impl<'repo> NodeObject<'repo> {
48    fn get_oid(&self) -> NonZeroOid {
49        match self {
50            NodeObject::Commit { commit } => commit.get_oid(),
51            NodeObject::GarbageCollected { oid } => *oid,
52        }
53    }
54
55    fn get_short_oid(&self) -> eyre::Result<String> {
56        match self {
57            NodeObject::Commit { commit } => Ok(commit.get_short_oid()?),
58            NodeObject::GarbageCollected { oid } => {
59                // `7` is the default value for config setting `core.abbrev`.
60                Ok(oid.to_string()[..7].to_string())
61            }
62        }
63    }
64}
65
66/// Object responsible for redacting sensitive information, so that it can be
67/// included in a bug report.
68#[derive(Debug)]
69pub enum Redactor {
70    /// No redaction will be performed. This is the default for general use.
71    Disabled,
72
73    /// Redaction will be performed.
74    Enabled {
75        /// A set of ref names which *shouldn't* be redacted. For example, the
76        /// main branch should probably keep its name.
77        preserved_ref_names: HashSet<ReferenceName>,
78
79        /// A mapping from ref name to its redacted version.
80        ref_names: Arc<Mutex<HashMap<ReferenceName, ReferenceName>>>,
81    },
82}
83
84impl Redactor {
85    /// Constructor.
86    pub fn new(preserved_ref_names: HashSet<ReferenceName>) -> Self {
87        Self::Enabled {
88            preserved_ref_names,
89            ref_names: Default::default(),
90        }
91    }
92
93    /// Redact the given ref name, if appropriate.
94    pub fn redact_ref_name(&self, ref_name: ReferenceName) -> ReferenceName {
95        match self {
96            Redactor::Disabled => ref_name,
97            Redactor::Enabled {
98                preserved_ref_names,
99                ref_names,
100            } => {
101                if preserved_ref_names.contains(&ref_name) || !ref_name.as_str().contains('/') {
102                    return ref_name;
103                }
104
105                let mut ref_names = ref_names.lock().expect("Poisoned mutex");
106                let len = ref_names.len();
107                ref_names
108                    .entry(ref_name)
109                    .or_insert_with_key(|ref_name| {
110                        let categorized_ref_name = CategorizedReferenceName::new(ref_name);
111                        let prefix = match categorized_ref_name {
112                            CategorizedReferenceName::LocalBranch { name: _, prefix } => prefix,
113                            CategorizedReferenceName::RemoteBranch { name: _, prefix } => prefix,
114                            CategorizedReferenceName::OtherRef { name: _ } => "",
115                        };
116                        format!("{prefix}redacted-ref-{len}").into()
117                    })
118                    .clone()
119            }
120        }
121    }
122
123    /// Redact the given commit summary, if appropriate.
124    pub fn redact_commit_summary(&self, summary: String) -> String {
125        match self {
126            Redactor::Disabled => summary,
127            Redactor::Enabled {
128                preserved_ref_names: _,
129                ref_names: _,
130            } => summary
131                .chars()
132                .map(|char| {
133                    if char.is_ascii_whitespace() {
134                        char
135                    } else {
136                        'x'
137                    }
138                })
139                .collect(),
140        }
141    }
142}
143
144/// Interface to display information about a node in the smartlog.
145pub trait NodeDescriptor {
146    /// Provide a description of the given commit.
147    ///
148    /// A return value of `None` indicates that this commit descriptor was
149    /// inapplicable for the provided commit.
150    fn describe_node(
151        &mut self,
152        glyphs: &Glyphs,
153        object: &NodeObject,
154    ) -> eyre::Result<Option<StyledString>>;
155}
156
157/// Get the complete description for a given commit.
158#[instrument(skip(node_descriptors))]
159pub fn render_node_descriptors(
160    glyphs: &Glyphs,
161    object: &NodeObject,
162    node_descriptors: &mut [&mut dyn NodeDescriptor],
163) -> eyre::Result<StyledString> {
164    let descriptions = node_descriptors
165        .iter_mut()
166        .filter_map(|provider: &mut &mut dyn NodeDescriptor| {
167            provider.describe_node(glyphs, object).transpose()
168        })
169        .collect::<eyre::Result<Vec<_>>>()?;
170    let result = StyledStringBuilder::join(" ", descriptions);
171    Ok(result)
172}
173
174/// Display an abbreviated commit hash.
175#[derive(Debug)]
176pub struct CommitOidDescriptor {
177    use_color: bool,
178}
179
180impl CommitOidDescriptor {
181    /// Constructor.
182    pub fn new(use_color: bool) -> eyre::Result<Self> {
183        Ok(CommitOidDescriptor { use_color })
184    }
185}
186
187impl NodeDescriptor for CommitOidDescriptor {
188    #[instrument]
189    fn describe_node(
190        &mut self,
191        _glyphs: &Glyphs,
192        object: &NodeObject,
193    ) -> eyre::Result<Option<StyledString>> {
194        let oid = object.get_short_oid()?;
195        let oid = if self.use_color {
196            StyledString::styled(oid, BaseColor::Yellow.dark())
197        } else {
198            StyledString::plain(oid)
199        };
200        Ok(Some(oid))
201    }
202}
203
204/// Display the first line of the commit message.
205#[derive(Debug)]
206pub struct CommitMessageDescriptor<'a> {
207    redactor: &'a Redactor,
208}
209
210impl<'a> CommitMessageDescriptor<'a> {
211    /// Constructor.
212    pub fn new(redactor: &'a Redactor) -> eyre::Result<Self> {
213        Ok(CommitMessageDescriptor { redactor })
214    }
215}
216
217impl<'a> NodeDescriptor for CommitMessageDescriptor<'a> {
218    #[instrument]
219    fn describe_node(
220        &mut self,
221        _glyphs: &Glyphs,
222        object: &NodeObject,
223    ) -> eyre::Result<Option<StyledString>> {
224        let summary = match object {
225            NodeObject::Commit { commit } => {
226                let summary = commit.get_summary()?.to_vec();
227                summary.into_string_lossy()
228            }
229            NodeObject::GarbageCollected { oid: _ } => "<garbage collected>".to_string(),
230        };
231        let summary = self.redactor.redact_commit_summary(summary);
232        Ok(Some(StyledString::plain(summary)))
233    }
234}
235
236/// For obsolete commits, provide the reason that it's obsolete.
237pub struct ObsolescenceExplanationDescriptor<'a> {
238    event_replayer: &'a EventReplayer,
239    event_cursor: EventCursor,
240}
241
242impl<'a> ObsolescenceExplanationDescriptor<'a> {
243    /// Constructor.
244    pub fn new(event_replayer: &'a EventReplayer, event_cursor: EventCursor) -> eyre::Result<Self> {
245        Ok(ObsolescenceExplanationDescriptor {
246            event_replayer,
247            event_cursor,
248        })
249    }
250}
251
252impl<'a> NodeDescriptor for ObsolescenceExplanationDescriptor<'a> {
253    fn describe_node(
254        &mut self,
255        _glyphs: &Glyphs,
256        object: &NodeObject,
257    ) -> eyre::Result<Option<StyledString>> {
258        let event = self
259            .event_replayer
260            .get_cursor_commit_latest_event(self.event_cursor, object.get_oid());
261
262        let event = match event {
263            Some(event) => event,
264            None => return Ok(None),
265        };
266
267        let result = match event {
268            Event::RewriteEvent { .. } => {
269                let rewrite_target =
270                    find_rewrite_target(self.event_replayer, self.event_cursor, object.get_oid());
271                rewrite_target.map(|rewritten_oid| {
272                    StyledString::styled(
273                        format!("(rewritten as {})", &rewritten_oid.to_string()[..8]),
274                        BaseColor::Black.light(),
275                    )
276                })
277            }
278
279            Event::ObsoleteEvent { .. } => Some(StyledString::styled(
280                "(manually hidden)",
281                BaseColor::Black.light(),
282            )),
283
284            Event::RefUpdateEvent { .. }
285            | Event::CommitEvent { .. }
286            | Event::UnobsoleteEvent { .. }
287            | Event::WorkingCopySnapshot { .. } => None,
288        };
289        Ok(result)
290    }
291}
292
293/// Display branches that point to a given commit.
294#[derive(Debug)]
295pub struct BranchesDescriptor<'a> {
296    is_enabled: bool,
297    head_info: &'a ResolvedReferenceInfo,
298    references_snapshot: &'a RepoReferencesSnapshot,
299    redactor: &'a Redactor,
300}
301
302impl<'a> BranchesDescriptor<'a> {
303    /// Constructor.
304    pub fn new(
305        repo: &Repo,
306        head_info: &'a ResolvedReferenceInfo,
307        references_snapshot: &'a RepoReferencesSnapshot,
308        redactor: &'a Redactor,
309    ) -> eyre::Result<Self> {
310        let is_enabled = get_commit_descriptors_branches(repo)?;
311        Ok(BranchesDescriptor {
312            is_enabled,
313            head_info,
314            references_snapshot,
315            redactor,
316        })
317    }
318}
319
320impl<'a> NodeDescriptor for BranchesDescriptor<'a> {
321    #[instrument]
322    fn describe_node(
323        &mut self,
324        glyphs: &Glyphs,
325        object: &NodeObject,
326    ) -> eyre::Result<Option<StyledString>> {
327        if !self.is_enabled {
328            return Ok(None);
329        }
330
331        let branch_names: HashSet<ReferenceName> = match self
332            .references_snapshot
333            .branch_oid_to_names
334            .get(&object.get_oid())
335        {
336            Some(branch_names) => branch_names
337                .iter()
338                .map(|branch_name| self.redactor.redact_ref_name(branch_name.to_owned()))
339                .collect(),
340            None => HashSet::new(),
341        };
342
343        if branch_names.is_empty() {
344            Ok(None)
345        } else {
346            let mut branch_names: Vec<String> = branch_names
347                .into_iter()
348                .map(|branch_name| {
349                    let is_checked_out_branch =
350                        self.head_info.reference_name.as_ref() == Some(&branch_name);
351                    let icon = if is_checked_out_branch {
352                        format!("{} ", glyphs.branch_arrow)
353                    } else {
354                        "".to_string()
355                    };
356
357                    match CategorizedReferenceName::new(&branch_name) {
358                        reference_name @ CategorizedReferenceName::LocalBranch { .. } => {
359                            format!("{}{}", icon, reference_name.render_suffix())
360                        }
361                        reference_name @ CategorizedReferenceName::RemoteBranch { .. } => {
362                            format!("{}remote {}", icon, reference_name.render_suffix())
363                        }
364                        reference_name @ CategorizedReferenceName::OtherRef { .. } => {
365                            format!("{}ref {}", icon, reference_name.render_suffix())
366                        }
367                    }
368                })
369                .collect();
370            branch_names.sort_unstable();
371            let result = StyledString::styled(
372                format!("({})", branch_names.join(", ")),
373                BaseColor::Green.light(),
374            );
375            Ok(Some(result))
376        }
377    }
378}
379
380/// Display the associated Phabricator revision for a given commit.
381#[derive(Debug)]
382pub struct DifferentialRevisionDescriptor<'a> {
383    is_enabled: bool,
384    redactor: &'a Redactor,
385}
386
387impl<'a> DifferentialRevisionDescriptor<'a> {
388    /// Constructor.
389    pub fn new(repo: &Repo, redactor: &'a Redactor) -> eyre::Result<Self> {
390        let is_enabled = get_commit_descriptors_differential_revision(repo)?;
391        Ok(DifferentialRevisionDescriptor {
392            is_enabled,
393            redactor,
394        })
395    }
396}
397
398fn extract_diff_number(message: &str) -> Option<String> {
399    lazy_static! {
400        static ref RE: Regex = Regex::new(
401            r"(?mx)
402^
403Differential[\ ]Revision:[\ ]
404    (.+ /)?
405    (?P<diff>D[0-9]+)
406$",
407        )
408        .expect("Failed to compile `extract_diff_number` regex");
409    }
410    let captures = RE.captures(message)?;
411    let diff_number = &captures["diff"];
412    Some(diff_number.to_owned())
413}
414
415impl<'a> NodeDescriptor for DifferentialRevisionDescriptor<'a> {
416    #[instrument]
417    fn describe_node(
418        &mut self,
419        _glyphs: &Glyphs,
420        object: &NodeObject,
421    ) -> eyre::Result<Option<StyledString>> {
422        match self.redactor {
423            Redactor::Enabled { .. } => return Ok(None),
424            Redactor::Disabled => {}
425        }
426        if !self.is_enabled {
427            return Ok(None);
428        }
429        let commit = match object {
430            NodeObject::Commit { commit } => commit,
431            NodeObject::GarbageCollected { oid: _ } => return Ok(None),
432        };
433
434        let diff_number = match extract_diff_number(&commit.get_message_raw().to_str_lossy()) {
435            Some(diff_number) => diff_number,
436            None => return Ok(None),
437        };
438        let result = StyledString::styled(diff_number, BaseColor::Green.dark());
439        Ok(Some(result))
440    }
441}
442
443/// Display how long ago the given commit was committed.
444#[derive(Debug)]
445pub struct RelativeTimeDescriptor {
446    is_enabled: bool,
447    now: SystemTime,
448}
449
450impl RelativeTimeDescriptor {
451    /// Constructor.
452    pub fn new(repo: &Repo, now: SystemTime) -> eyre::Result<Self> {
453        let is_enabled = get_commit_descriptors_relative_time(repo)?;
454        Ok(RelativeTimeDescriptor { is_enabled, now })
455    }
456
457    /// Whether or not relative times should be shown, according to the user's
458    /// settings.
459    pub fn is_enabled(&self) -> bool {
460        self.is_enabled
461    }
462
463    /// Describe a relative time delta, e.g. "3d ago".
464    pub fn describe_time_delta(now: SystemTime, previous_time: SystemTime) -> eyre::Result<String> {
465        let mut delta: i64 = if previous_time < now {
466            let delta = now.duration_since(previous_time)?;
467            delta.as_secs().try_into()?
468        } else {
469            let delta = previous_time.duration_since(now)?;
470            -(delta.as_secs().try_into()?)
471        };
472
473        if delta < 60 {
474            return Ok(format!("{delta}s"));
475        }
476        delta /= 60;
477
478        if delta < 60 {
479            return Ok(format!("{delta}m"));
480        }
481        delta /= 60;
482
483        if delta < 24 {
484            return Ok(format!("{delta}h"));
485        }
486        delta /= 24;
487
488        if delta < 365 {
489            return Ok(format!("{delta}d"));
490        }
491        delta /= 365;
492
493        // Arguably at this point, users would want a specific date rather than a delta.
494        Ok(format!("{delta}y"))
495    }
496}
497
498impl NodeDescriptor for RelativeTimeDescriptor {
499    #[instrument]
500    fn describe_node(
501        &mut self,
502        _glyphs: &Glyphs,
503        object: &NodeObject,
504    ) -> eyre::Result<Option<StyledString>> {
505        if !self.is_enabled {
506            return Ok(None);
507        }
508        let commit = match object {
509            NodeObject::Commit { commit } => commit,
510            NodeObject::GarbageCollected { oid: _ } => return Ok(None),
511        };
512
513        let description = Self::describe_time_delta(self.now, commit.get_time().to_system_time()?)?;
514        let result = StyledString::styled(description, BaseColor::Green.dark());
515        Ok(Some(result))
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use std::ops::{Add, Sub};
522    use std::time::Duration;
523
524    use super::*;
525
526    #[test]
527    fn test_extract_diff_number() -> eyre::Result<()> {
528        let message = "\
529This is a message
530
531Differential Revision: D123";
532        assert_eq!(extract_diff_number(message), Some(String::from("D123")));
533
534        let message = "\
535This is a message
536
537Differential Revision: phabricator.com/D123";
538        assert_eq!(extract_diff_number(message), Some(String::from("D123")));
539
540        let message = "This is a message";
541        assert_eq!(extract_diff_number(message), None);
542
543        Ok(())
544    }
545
546    #[test]
547    fn test_describe_time_delta() -> eyre::Result<()> {
548        let test_cases: Vec<(isize, &str)> = vec![
549            // Could improve formatting for times in the past.
550            (-100000, "-100000s"),
551            (-1, "-1s"),
552            (0, "0s"),
553            (10, "10s"),
554            (60, "1m"),
555            (90, "1m"),
556            (120, "2m"),
557            (135, "2m"),
558            (60 * 45, "45m"),
559            (60 * 60 - 1, "59m"),
560            (60 * 60, "1h"),
561            (60 * 60 * 24 * 3, "3d"),
562            (60 * 60 * 24 * 300, "300d"),
563            (60 * 60 * 24 * 400, "1y"),
564        ];
565
566        for (delta, expected) in test_cases {
567            let now = SystemTime::now();
568            let previous_time = if delta < 0 {
569                let delta = -delta;
570                now.add(Duration::from_secs(delta.try_into()?))
571            } else {
572                now.sub(Duration::from_secs(delta.try_into()?))
573            };
574            let delta = RelativeTimeDescriptor::describe_time_delta(now, previous_time)?;
575            assert_eq!(delta, expected);
576        }
577
578        Ok(())
579    }
580}