Skip to main content

git_branchless_smartlog/
lib.rs

1//! Display a graph of commits that the user has worked on recently.
2//!
3//! The set of commits that are still being worked on is inferred from the event
4//! log; see the `eventlog` module.
5
6#![warn(missing_docs)]
7#![warn(
8    clippy::all,
9    clippy::as_conversions,
10    clippy::clone_on_ref_ptr,
11    clippy::dbg_macro
12)]
13#![allow(clippy::too_many_arguments, clippy::blocks_in_conditions)]
14
15use std::cmp::Ordering;
16use std::fmt::Write;
17use std::time::SystemTime;
18
19use git_branchless_invoke::CommandContext;
20use git_branchless_opts::{Revset, SmartlogArgs};
21use lib::core::config::{
22    Hint, get_hint_enabled, get_hint_string, get_smartlog_default_revset, get_smartlog_reverse,
23    print_hint_suppression_notice,
24};
25use lib::core::repo_ext::RepoExt;
26use lib::core::rewrite::find_rewrite_target;
27use lib::util::{ExitCode, EyreExitOr};
28use tracing::instrument;
29
30use lib::core::dag::{CommitSet, Dag};
31use lib::core::effects::Effects;
32use lib::core::eventlog::{EventLogDb, EventReplayer};
33use lib::core::formatting::Pluralize;
34use lib::core::node_descriptors::{
35    BranchesDescriptor, CommitMessageDescriptor, CommitOidDescriptor,
36    DifferentialRevisionDescriptor, ObsolescenceExplanationDescriptor, Redactor,
37    RelativeTimeDescriptor,
38};
39use lib::git::{GitRunInfo, Repo};
40
41pub use graph::{SmartlogGraph, make_smartlog_graph};
42pub use render::{SmartlogOptions, render_graph};
43
44use git_branchless_revset::resolve_commits;
45
46mod graph {
47    use std::collections::HashMap;
48
49    use lib::core::gc::mark_commit_reachable;
50    use tracing::instrument;
51
52    use lib::core::dag::{CommitSet, CommitVertex, Dag};
53    use lib::core::effects::{Effects, OperationType};
54    use lib::core::eventlog::{EventCursor, EventReplayer};
55    use lib::core::node_descriptors::NodeObject;
56    use lib::git::{Commit, Time};
57    use lib::git::{NonZeroOid, Repo};
58
59    #[derive(Debug)]
60    pub struct AncestorInfo {
61        pub oid: NonZeroOid,
62        pub distance: usize,
63    }
64
65    #[derive(Clone, Debug, PartialEq, Eq, Hash)]
66    pub struct ChildInfo {
67        pub oid: NonZeroOid,
68        pub is_merge_child: bool,
69    }
70    /// Node contained in the smartlog commit graph.
71    #[derive(Debug)]
72    pub struct Node<'repo> {
73        /// The underlying commit object.
74        pub object: NodeObject<'repo>,
75
76        /// The OIDs of the parent nodes in the smartlog commit graph.
77        ///
78        /// This is different from inspecting `commit.parents()`, since the smartlog
79        /// will hide most nodes from the commit graph, including parent nodes.
80        pub parents: Vec<NonZeroOid>,
81
82        /// The OIDs of the children nodes in the smartlog commit graph.
83        pub children: Vec<ChildInfo>,
84
85        /// Information about a non-immediate, non-main branch ancestor node in
86        /// the smartlog commit graph.
87        pub ancestor_info: Option<AncestorInfo>,
88
89        /// The OIDs of any non-immediate descendant nodes in the smartlog commit graph.
90        pub descendants: Vec<ChildInfo>,
91
92        /// Indicates that this is a commit to the main branch.
93        ///
94        /// These commits are considered to be immutable and should never leave the
95        /// `main` state. But this can still happen in practice if the user's
96        /// workflow is different than expected.
97        pub is_main: bool,
98
99        /// Indicates that this commit has been marked as obsolete.
100        ///
101        /// Commits are marked as obsolete when they've been rewritten into another
102        /// commit, or explicitly marked such by the user. Normally, they're not
103        /// visible in the smartlog, except if there's some anomalous situation that
104        /// the user should take note of (such as an obsolete commit having a
105        /// non-obsolete descendant).
106        ///
107        /// Occasionally, a main commit can be marked as obsolete, such as if a
108        /// commit in the main branch has been rewritten. We don't expect this to
109        /// happen in the monorepo workflow, but it can happen in other workflows
110        /// where you commit directly to the main branch and then later rewrite the
111        /// commit.
112        pub is_obsolete: bool,
113
114        /// Indicates that this commit has descendants, but that none of them
115        /// are included in the graph.
116        ///
117        /// This allows us to indicate a "false head" to the user. Otherwise,
118        /// this commit would look like a normal, descendant-less head.
119        pub num_omitted_descendants: usize,
120    }
121
122    /// Graph of commits that the user is working on.
123    pub struct SmartlogGraph<'repo> {
124        /// The nodes in the graph for use in rendering the smartlog.
125        pub nodes: HashMap<NonZeroOid, Node<'repo>>,
126    }
127
128    impl<'repo> SmartlogGraph<'repo> {
129        /// Get a list of commits stored in the graph.
130        /// Returns commits in descending commit time order.
131        pub fn get_commits(&self) -> Vec<Commit<'repo>> {
132            let mut commits = self
133                .nodes
134                .values()
135                .filter_map(|node| match &node.object {
136                    NodeObject::Commit { commit } => Some(commit.clone()),
137                    NodeObject::GarbageCollected { oid: _ } => None,
138                })
139                .collect::<Vec<Commit<'repo>>>();
140            commits.sort_by_key(|commit| (commit.get_committer().get_time(), commit.get_oid()));
141            commits.reverse();
142            commits
143        }
144    }
145
146    impl std::fmt::Debug for SmartlogGraph<'_> {
147        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148            write!(f, "<CommitGraph len={}>", self.nodes.len())
149        }
150    }
151
152    /// Build the smartlog graph by finding additional commits that should be displayed.
153    ///
154    /// For example, if you check out a commit that has intermediate parent commits
155    /// between it and the main branch, those intermediate commits should be shown
156    /// (or else you won't get a good idea of the line of development that happened
157    /// for this commit since the main branch).
158    #[instrument]
159    fn build_graph<'repo>(
160        effects: &Effects,
161        repo: &'repo Repo,
162        dag: &Dag,
163        commits: &CommitSet,
164    ) -> eyre::Result<SmartlogGraph<'repo>> {
165        let commits_include_main =
166            !dag.set_is_empty(&dag.main_branch_commit.intersection(commits))?;
167        let mut graph: HashMap<NonZeroOid, Node> = {
168            let mut result = HashMap::new();
169            for vertex in dag.commit_set_to_vec(commits)? {
170                let vertex = CommitSet::from(vertex);
171                let merge_bases = if commits_include_main {
172                    dag.query_gca_all(dag.main_branch_commit.union(&vertex))?
173                } else {
174                    dag.query_gca_all(commits.union(&vertex))?
175                };
176                let vertices = vertex.union(&merge_bases);
177
178                for oid in dag.commit_set_to_vec(&vertices)? {
179                    let object = match repo.find_commit(oid)? {
180                        Some(commit) => NodeObject::Commit { commit },
181                        None => {
182                            // Assume that this commit was garbage collected.
183                            NodeObject::GarbageCollected { oid }
184                        }
185                    };
186
187                    result.insert(
188                        oid,
189                        Node {
190                            object,
191                            parents: Vec::new(),  // populated below
192                            children: Vec::new(), // populated below
193                            ancestor_info: None,
194                            descendants: Vec::new(), // populated below
195                            is_main: dag.is_public_commit(oid)?,
196                            is_obsolete: dag.set_contains(&dag.query_obsolete_commits(), oid)?,
197                            num_omitted_descendants: 0, // populated below
198                        },
199                    );
200                }
201            }
202            result
203        };
204
205        let mut immediate_links: Vec<(NonZeroOid, NonZeroOid, bool)> = Vec::new();
206        let mut non_immediate_links: Vec<(NonZeroOid, NonZeroOid, bool)> = Vec::new();
207
208        let non_main_node_oids = graph
209            .iter()
210            .filter_map(|(child_oid, node)| if !node.is_main { Some(child_oid) } else { None });
211
212        let graph_vertices: CommitSet = graph.keys().cloned().collect();
213        for child_oid in non_main_node_oids {
214            let parent_vertices = dag.query_parent_names(CommitVertex::from(*child_oid))?;
215
216            // Find immediate parent-child links.
217            match parent_vertices.as_slice() {
218                [] => {}
219                [first_parent_vertex, merge_parent_vertices @ ..] => {
220                    if dag.set_contains(&graph_vertices, first_parent_vertex.clone())? {
221                        let first_parent_oid = NonZeroOid::try_from(first_parent_vertex.clone())?;
222                        immediate_links.push((*child_oid, first_parent_oid, false));
223                    }
224                    for merge_parent_vertex in merge_parent_vertices {
225                        if dag.set_contains(&graph_vertices, merge_parent_vertex.clone())? {
226                            let merge_parent_oid =
227                                NonZeroOid::try_from(merge_parent_vertex.clone())?;
228                            immediate_links.push((*child_oid, merge_parent_oid, true));
229                        }
230                    }
231                }
232            }
233
234            // Find non-immediate ancestor links.
235            for excluded_parent_vertex in parent_vertices {
236                if dag.set_contains(&graph_vertices, excluded_parent_vertex.clone())? {
237                    continue;
238                }
239
240                // Find the nearest ancestor that is included in the graph and
241                // also on the same branch.
242
243                let parent_set = CommitSet::from(excluded_parent_vertex);
244                let merge_base = dag.query_gca_one(dag.main_branch_commit.union(&parent_set))?;
245
246                let path_to_main_branch = match merge_base {
247                    Some(merge_base) => dag.query_range(CommitSet::from(merge_base), parent_set)?,
248                    None => CommitSet::empty(),
249                };
250                let nearest_branch_ancestor =
251                    dag.query_heads_ancestors(path_to_main_branch.intersection(&graph_vertices))?;
252
253                let ancestor_oids = dag.commit_set_to_vec(&nearest_branch_ancestor)?;
254                for ancestor_oid in ancestor_oids.iter() {
255                    non_immediate_links.push((*ancestor_oid, *child_oid, false));
256                }
257            }
258        }
259
260        for (child_oid, parent_oid, is_merge_link) in immediate_links.iter() {
261            graph.get_mut(child_oid).unwrap().parents.push(*parent_oid);
262            graph.get_mut(parent_oid).unwrap().children.push(ChildInfo {
263                oid: *child_oid,
264                is_merge_child: *is_merge_link,
265            });
266        }
267
268        for (ancestor_oid, descendent_oid, is_merge_link) in non_immediate_links.iter() {
269            let distance = dag.set_count(
270                &dag.query_range(
271                    CommitSet::from(*ancestor_oid),
272                    CommitSet::from(*descendent_oid),
273                )?
274                .difference(&vec![*ancestor_oid, *descendent_oid].into_iter().collect()),
275            )?;
276            graph.get_mut(descendent_oid).unwrap().ancestor_info = Some(AncestorInfo {
277                oid: *ancestor_oid,
278                distance,
279            });
280            graph
281                .get_mut(ancestor_oid)
282                .unwrap()
283                .descendants
284                .push(ChildInfo {
285                    oid: *descendent_oid,
286                    is_merge_child: *is_merge_link,
287                })
288        }
289
290        for (oid, node) in graph.iter_mut() {
291            let oid_set = CommitSet::from(*oid);
292            let is_main_head = !dag.set_is_empty(&dag.main_branch_commit.intersection(&oid_set))?;
293            let ancestor_of_main = node.is_main && !is_main_head;
294            let has_descendants_in_graph =
295                !node.children.is_empty() || !node.descendants.is_empty();
296
297            if ancestor_of_main || has_descendants_in_graph {
298                continue;
299            }
300
301            // This node has no descendants in the graph, so it's a
302            // false head if it has *any* visible descendants.
303            let descendants_not_in_graph =
304                dag.query_descendants(oid_set.clone())?.difference(&oid_set);
305            let descendants_not_in_graph = dag.filter_visible_commits(descendants_not_in_graph)?;
306
307            node.num_omitted_descendants = dag.set_count(&descendants_not_in_graph)?;
308        }
309
310        Ok(SmartlogGraph { nodes: graph })
311    }
312
313    /// Sort children nodes of the commit graph in a standard order, for determinism
314    /// in output.
315    fn sort_children(graph: &mut SmartlogGraph) {
316        let commit_times: HashMap<NonZeroOid, Option<Time>> = graph
317            .nodes
318            .iter()
319            .map(|(oid, node)| {
320                (
321                    *oid,
322                    match &node.object {
323                        NodeObject::Commit { commit } => Some(commit.get_time()),
324                        NodeObject::GarbageCollected { oid: _ } => None,
325                    },
326                )
327            })
328            .collect();
329        for node in graph.nodes.values_mut() {
330            node.children.sort_by_key(
331                |ChildInfo {
332                     oid,
333                     is_merge_child,
334                 }| (&commit_times[oid], *is_merge_child, oid.to_string()),
335            );
336        }
337    }
338
339    /// Construct the smartlog graph for the repo.
340    #[instrument]
341    pub fn make_smartlog_graph<'repo>(
342        effects: &Effects,
343        repo: &'repo Repo,
344        dag: &Dag,
345        event_replayer: &EventReplayer,
346        event_cursor: EventCursor,
347        commits: &CommitSet,
348        exact: bool,
349    ) -> eyre::Result<SmartlogGraph<'repo>> {
350        let (effects, _progress) = effects.start_operation(OperationType::MakeGraph);
351
352        let mut graph = {
353            let (effects, _progress) = effects.start_operation(OperationType::WalkCommits);
354
355            // HEAD and main head are automatically included unless `exact` is set
356            let commits = if exact {
357                commits.clone()
358            } else {
359                commits
360                    .union(&dag.head_commit)
361                    .union(&dag.main_branch_commit)
362            };
363
364            for oid in dag.commit_set_to_vec(&commits)? {
365                mark_commit_reachable(repo, oid)?;
366            }
367
368            build_graph(&effects, repo, dag, &commits)?
369        };
370        sort_children(&mut graph);
371        Ok(graph)
372    }
373}
374
375mod render {
376    use std::cmp::Ordering;
377    use std::collections::HashSet;
378
379    use cursive_core::theme::{BaseColor, Effect};
380    use cursive_core::utils::markup::StyledString;
381    use tracing::instrument;
382
383    use lib::core::dag::{CommitSet, Dag};
384    use lib::core::effects::Effects;
385    use lib::core::formatting::{Glyphs, StyledStringBuilder};
386    use lib::core::formatting::{Pluralize, set_effect};
387    use lib::core::node_descriptors::{NodeDescriptor, render_node_descriptors};
388    use lib::git::{NonZeroOid, Repo};
389
390    use git_branchless_opts::{ResolveRevsetOptions, Revset};
391
392    use super::graph::{AncestorInfo, ChildInfo, SmartlogGraph};
393
394    /// Split fully-independent subgraphs into multiple graphs.
395    ///
396    /// This is intended to handle the situation of having multiple lines of work
397    /// rooted from different commits in the main branch.
398    ///
399    /// Returns the list such that the topologically-earlier subgraphs are first in
400    /// the list (i.e. those that would be rendered at the bottom of the smartlog).
401    fn split_commit_graph_by_roots(
402        repo: &Repo,
403        dag: &Dag,
404        graph: &SmartlogGraph,
405    ) -> Vec<NonZeroOid> {
406        let mut root_commit_oids: Vec<NonZeroOid> = graph
407            .nodes
408            .iter()
409            .filter(|(_oid, node)| node.parents.is_empty() && node.ancestor_info.is_none())
410            .map(|(oid, _node)| oid)
411            .copied()
412            .collect();
413
414        let compare = |lhs_oid: &NonZeroOid, rhs_oid: &NonZeroOid| -> Ordering {
415            let lhs_commit = repo.find_commit(*lhs_oid);
416            let rhs_commit = repo.find_commit(*rhs_oid);
417
418            let (lhs_commit, rhs_commit) = match (lhs_commit, rhs_commit) {
419                (Ok(Some(lhs_commit)), Ok(Some(rhs_commit))) => (lhs_commit, rhs_commit),
420                _ => return lhs_oid.cmp(rhs_oid),
421            };
422
423            let merge_base_oid =
424                dag.query_gca_one(vec![*lhs_oid, *rhs_oid].into_iter().collect::<CommitSet>());
425            let merge_base_oid = match merge_base_oid {
426                Err(_) => return lhs_oid.cmp(rhs_oid),
427                Ok(None) => None,
428                Ok(Some(merge_base_oid)) => NonZeroOid::try_from(merge_base_oid).ok(),
429            };
430
431            match merge_base_oid {
432                // lhs was topologically first, so it should be sorted earlier in the list.
433                Some(merge_base_oid) if merge_base_oid == *lhs_oid => Ordering::Less,
434                Some(merge_base_oid) if merge_base_oid == *rhs_oid => Ordering::Greater,
435
436                // The commits were not orderable (pathlogical situation). Let's
437                // just order them by timestamp in that case to produce a consistent
438                // and reasonable guess at the intended topological ordering.
439                Some(_) | None => match lhs_commit.get_time().cmp(&rhs_commit.get_time()) {
440                    result @ Ordering::Less | result @ Ordering::Greater => result,
441                    Ordering::Equal => lhs_oid.cmp(rhs_oid),
442                },
443            }
444        };
445
446        root_commit_oids.sort_by(compare);
447        root_commit_oids
448    }
449
450    #[instrument(skip(commit_descriptors, graph))]
451    fn get_child_output(
452        glyphs: &Glyphs,
453        graph: &SmartlogGraph,
454        root_oids: &[NonZeroOid],
455        commit_descriptors: &mut [&mut dyn NodeDescriptor],
456        head_oid: Option<NonZeroOid>,
457        current_oid: NonZeroOid,
458        last_child_line_char: Option<&str>,
459    ) -> eyre::Result<Vec<StyledString>> {
460        let current_node = &graph.nodes[&current_oid];
461        let is_head = Some(current_oid) == head_oid;
462
463        let mut lines = vec![];
464
465        if let Some(AncestorInfo { oid: _, distance }) = current_node.ancestor_info {
466            lines.push(
467                StyledStringBuilder::new()
468                    .append_plain(glyphs.commit_omitted)
469                    .append_plain(" ")
470                    .append_styled(
471                        Pluralize {
472                            determiner: None,
473                            amount: distance,
474                            unit: ("omitted commit", "omitted commits"),
475                        }
476                        .to_string(),
477                        Effect::Dim,
478                    )
479                    .build(),
480            );
481            lines.push(StyledString::plain(glyphs.vertical_ellipsis));
482        };
483
484        if let [_, merge_parents @ ..] = current_node.parents.as_slice() {
485            if !merge_parents.is_empty() {
486                for merge_parent_oid in merge_parents {
487                    let merge_parent_node = &graph.nodes[merge_parent_oid];
488                    lines.push(
489                        StyledStringBuilder::new()
490                            .append_plain(last_child_line_char.unwrap_or(glyphs.line))
491                            .append_plain(" ")
492                            .append_styled(
493                                format!("{} (merge) ", glyphs.commit_merge),
494                                BaseColor::Blue.dark(),
495                            )
496                            .append(render_node_descriptors(
497                                glyphs,
498                                &merge_parent_node.object,
499                                commit_descriptors,
500                            )?)
501                            .build(),
502                    );
503                }
504                lines.push(StyledString::plain(format!(
505                    "{}{}",
506                    glyphs.line_with_offshoot, glyphs.merge,
507                )));
508            }
509        }
510
511        lines.push({
512            let cursor = match (current_node.is_main, current_node.is_obsolete, is_head) {
513                (false, false, false) => glyphs.commit_visible,
514                (false, false, true) => glyphs.commit_visible_head,
515                (false, true, false) => glyphs.commit_obsolete,
516                (false, true, true) => glyphs.commit_obsolete_head,
517                (true, false, false) => glyphs.commit_main,
518                (true, false, true) => glyphs.commit_main_head,
519                (true, true, false) => glyphs.commit_main_obsolete,
520                (true, true, true) => glyphs.commit_main_obsolete_head,
521            };
522            let text = render_node_descriptors(glyphs, &current_node.object, commit_descriptors)?;
523            let first_line = StyledStringBuilder::new()
524                .append_plain(cursor)
525                .append_plain(" ")
526                .append(text)
527                .build();
528            if is_head {
529                set_effect(first_line, Effect::Bold)
530            } else {
531                first_line
532            }
533        });
534
535        if current_node.num_omitted_descendants > 0 {
536            lines.push(StyledString::plain(glyphs.vertical_ellipsis));
537            lines.push(
538                StyledStringBuilder::new()
539                    .append_plain(glyphs.commit_omitted)
540                    .append_plain(" ")
541                    .append_styled(
542                        Pluralize {
543                            determiner: None,
544                            amount: current_node.num_omitted_descendants,
545                            unit: ("omitted descendant commit", "omitted descendant commits"),
546                        }
547                        .to_string(),
548                        Effect::Dim,
549                    )
550                    .build(),
551            );
552        };
553
554        let children: Vec<ChildInfo> = current_node
555            .children
556            .iter()
557            .filter(
558                |ChildInfo {
559                     oid,
560                     is_merge_child: _,
561                 }| graph.nodes.contains_key(oid),
562            )
563            .cloned()
564            .collect();
565        let descendants: HashSet<ChildInfo> = current_node
566            .descendants
567            .iter()
568            .filter(
569                |ChildInfo {
570                     oid,
571                     is_merge_child: _,
572                 }| graph.nodes.contains_key(oid),
573            )
574            .cloned()
575            .collect();
576        for (child_idx, child_info) in children.iter().chain(descendants.iter()).enumerate() {
577            let ChildInfo {
578                oid: child_oid,
579                is_merge_child,
580            } = child_info;
581            if root_oids.contains(child_oid) {
582                // Will be rendered by the parent.
583                continue;
584            }
585            if *is_merge_child {
586                // lines.push(StyledString::plain(format!(
587                //     "{}{}",
588                //     glyphs.line_with_offshoot, glyphs.split
589                // )));
590                lines.push(
591                    StyledStringBuilder::new()
592                        // .append_plain(last_child_line_char.unwrap_or(glyphs.line))
593                        // .append_plain(" ")
594                        .append_styled(
595                            format!("{} (merge) ", glyphs.commit_merge),
596                            BaseColor::Blue.dark(),
597                        )
598                        .append(render_node_descriptors(
599                            glyphs,
600                            &graph.nodes[child_oid].object,
601                            commit_descriptors,
602                        )?)
603                        .build(),
604                );
605                continue;
606            }
607
608            let is_last_child = child_idx == (children.len() + descendants.len()) - 1;
609            lines.push(StyledString::plain(
610                if !is_last_child || last_child_line_char.is_some() {
611                    format!("{}{}", glyphs.line_with_offshoot, glyphs.split)
612                } else if current_node.descendants.is_empty() {
613                    glyphs.line.to_string()
614                } else {
615                    glyphs.vertical_ellipsis.to_string()
616                },
617            ));
618
619            let child_output = get_child_output(
620                glyphs,
621                graph,
622                root_oids,
623                commit_descriptors,
624                head_oid,
625                *child_oid,
626                None,
627            )?;
628            for child_line in child_output {
629                let line = if is_last_child {
630                    match last_child_line_char {
631                        Some(last_child_line_char) => StyledStringBuilder::new()
632                            .append_plain(format!("{last_child_line_char} "))
633                            .append(child_line)
634                            .build(),
635                        None => child_line,
636                    }
637                } else {
638                    StyledStringBuilder::new()
639                        .append_plain(format!("{} ", glyphs.line))
640                        .append(child_line)
641                        .build()
642                };
643                lines.push(line)
644            }
645        }
646        Ok(lines)
647    }
648
649    /// Render a pretty graph starting from the given root OIDs in the given graph.
650    #[instrument(skip(commit_descriptors, graph))]
651    fn get_output(
652        glyphs: &Glyphs,
653        dag: &Dag,
654        graph: &SmartlogGraph,
655        commit_descriptors: &mut [&mut dyn NodeDescriptor],
656        head_oid: Option<NonZeroOid>,
657        root_oids: &[NonZeroOid],
658    ) -> eyre::Result<Vec<StyledString>> {
659        let mut lines = Vec::new();
660
661        // Determine if the provided OID has the provided parent OID as a parent.
662        //
663        // This returns `true` in strictly more cases than checking `graph`,
664        // since there may be links between adjacent main branch commits which
665        // are not reflected in `graph`.
666        let has_real_parent = |oid: NonZeroOid, parent_oid: NonZeroOid| -> eyre::Result<bool> {
667            let parents = dag.query_parents(CommitSet::from(oid))?;
668            let result = dag.set_contains(&parents, parent_oid)?;
669            Ok(result)
670        };
671
672        for (root_idx, root_oid) in root_oids.iter().enumerate() {
673            if !dag.set_is_empty(&dag.query_parents(CommitSet::from(*root_oid))?)? {
674                let line = if root_idx > 0 && has_real_parent(*root_oid, root_oids[root_idx - 1])? {
675                    StyledString::plain(glyphs.line.to_owned())
676                } else {
677                    StyledString::plain(glyphs.vertical_ellipsis.to_owned())
678                };
679                lines.push(line);
680            } else if root_idx > 0 {
681                // Pathological case: multiple topologically-unrelated roots.
682                // Separate them with a newline.
683                lines.push(StyledString::new());
684            }
685
686            let last_child_line_char = {
687                if root_idx == root_oids.len() - 1 {
688                    None
689                } else if has_real_parent(root_oids[root_idx + 1], *root_oid)? {
690                    Some(glyphs.line)
691                } else {
692                    Some(glyphs.vertical_ellipsis)
693                }
694            };
695
696            let child_output = get_child_output(
697                glyphs,
698                graph,
699                root_oids,
700                commit_descriptors,
701                head_oid,
702                *root_oid,
703                last_child_line_char,
704            )?;
705            lines.extend(child_output.into_iter());
706        }
707
708        Ok(lines)
709    }
710
711    /// Render the smartlog graph and write it to the provided stream.
712    #[instrument(skip(commit_descriptors, graph))]
713    pub fn render_graph(
714        effects: &Effects,
715        repo: &Repo,
716        dag: &Dag,
717        graph: &SmartlogGraph,
718        head_oid: Option<NonZeroOid>,
719        commit_descriptors: &mut [&mut dyn NodeDescriptor],
720    ) -> eyre::Result<Vec<StyledString>> {
721        let root_oids = split_commit_graph_by_roots(repo, dag, graph);
722        let lines = get_output(
723            effects.get_glyphs(),
724            dag,
725            graph,
726            commit_descriptors,
727            head_oid,
728            &root_oids,
729        )?;
730        Ok(lines)
731    }
732
733    /// Options for rendering the smartlog.
734    #[derive(Debug, Default)]
735    pub struct SmartlogOptions {
736        /// The point in time at which to show the smartlog. If not provided,
737        /// renders the smartlog as of the current time. If negative, is treated
738        /// as an offset from the current event.
739        pub event_id: Option<isize>,
740
741        /// The commits to render. These commits, plus any related commits, will
742        /// be rendered. If not provided, the user's default revset will be used
743        /// instead.
744        pub revset: Option<Revset>,
745
746        /// The options to use when resolving the revset.
747        pub resolve_revset_options: ResolveRevsetOptions,
748
749        /// Deprecated
750        /// Reverse the ordering of items in the smartlog output, list the most
751        /// recent commits first.
752        pub reverse: bool,
753
754        /// Normally HEAD and the main branch are included. Set this to exclude them.
755        pub exact: bool,
756    }
757}
758
759/// Display a nice graph of commits you've recently worked on.
760#[instrument]
761pub fn smartlog(
762    effects: &Effects,
763    git_run_info: &GitRunInfo,
764    options: SmartlogOptions,
765) -> EyreExitOr<()> {
766    let SmartlogOptions {
767        event_id,
768        revset,
769        resolve_revset_options,
770        reverse,
771        exact,
772    } = options;
773
774    let repo = Repo::from_dir(&git_run_info.working_directory)?;
775    let head_info = repo.get_head_info()?;
776    let conn = repo.get_db_conn()?;
777    let event_log_db = EventLogDb::new(&conn)?;
778    let event_replayer = EventReplayer::from_event_log_db(effects, &repo, &event_log_db)?;
779    let (references_snapshot, event_cursor) = {
780        let default_cursor = event_replayer.make_default_cursor();
781        match event_id {
782            None => (repo.get_references_snapshot()?, default_cursor),
783            Some(event_id) => {
784                let event_cursor = match event_id.cmp(&0) {
785                    Ordering::Less => event_replayer.advance_cursor(default_cursor, event_id),
786                    Ordering::Equal | Ordering::Greater => event_replayer.make_cursor(event_id),
787                };
788                let references_snapshot =
789                    event_replayer.get_references_snapshot(&repo, event_cursor)?;
790                (references_snapshot, event_cursor)
791            }
792        }
793    };
794    let mut dag = Dag::open_and_sync(
795        effects,
796        &repo,
797        &event_replayer,
798        event_cursor,
799        &references_snapshot,
800    )?;
801
802    let revset = match revset {
803        Some(revset) => revset,
804        None => Revset(get_smartlog_default_revset(&repo)?),
805    };
806    let commits =
807        match resolve_commits(effects, &repo, &mut dag, &[revset], &resolve_revset_options) {
808            Ok(result) => match result.as_slice() {
809                [commit_set] => commit_set.clone(),
810                other => panic!("Expected exactly 1 result from resolve commits, got: {other:?}"),
811            },
812            Err(err) => {
813                err.describe(effects)?;
814                return Ok(Err(ExitCode(1)));
815            }
816        };
817
818    let graph = make_smartlog_graph(
819        effects,
820        &repo,
821        &dag,
822        &event_replayer,
823        event_cursor,
824        &commits,
825        exact,
826    )?;
827
828    let reverse = if reverse {
829        writeln!(
830            effects.get_error_stream(),
831            "WARNING: The `--reverse` flag is deprecated.\nPlease use the `branchless.smartlog.reverse` configuration option."
832        )?;
833        true
834    } else {
835        get_smartlog_reverse(&repo)?
836    };
837
838    let mut lines = render_graph(
839        &effects.reverse_order(reverse),
840        &repo,
841        &dag,
842        &graph,
843        references_snapshot.head_oid,
844        &mut [
845            &mut CommitOidDescriptor::new(true)?,
846            &mut RelativeTimeDescriptor::new(&repo, SystemTime::now())?,
847            &mut ObsolescenceExplanationDescriptor::new(
848                &event_replayer,
849                event_replayer.make_default_cursor(),
850            )?,
851            &mut BranchesDescriptor::new(
852                &repo,
853                &head_info,
854                &references_snapshot,
855                &Redactor::Disabled,
856            )?,
857            &mut DifferentialRevisionDescriptor::new(&repo, &Redactor::Disabled)?,
858            &mut CommitMessageDescriptor::new(&Redactor::Disabled)?,
859        ],
860    )?
861    .into_iter();
862    while let Some(line) = if reverse {
863        lines.next_back()
864    } else {
865        lines.next()
866    } {
867        writeln!(
868            effects.get_output_stream(),
869            "{}",
870            effects.get_glyphs().render(line)?
871        )?;
872    }
873
874    if !resolve_revset_options.show_hidden_commits
875        && get_hint_enabled(&repo, Hint::SmartlogFixAbandoned)?
876    {
877        let commits_with_abandoned_children: CommitSet = graph
878            .nodes
879            .iter()
880            .filter_map(|(oid, node)| {
881                if node.is_obsolete
882                    && find_rewrite_target(&event_replayer, event_cursor, *oid).is_some()
883                {
884                    Some(*oid)
885                } else {
886                    None
887                }
888            })
889            .collect();
890        let children = dag.query_children(commits_with_abandoned_children)?;
891        let num_abandoned_children =
892            dag.set_count(&children.difference(&dag.query_obsolete_commits()))?;
893        if num_abandoned_children > 0 {
894            writeln!(
895                effects.get_output_stream(),
896                "{}: there {} in your commit graph",
897                effects.get_glyphs().render(get_hint_string())?,
898                Pluralize {
899                    determiner: Some(("is", "are")),
900                    amount: num_abandoned_children,
901                    unit: ("abandoned commit", "abandoned commits"),
902                },
903            )?;
904            writeln!(
905                effects.get_output_stream(),
906                "{}: to fix this, run: git restack",
907                effects.get_glyphs().render(get_hint_string())?,
908            )?;
909            print_hint_suppression_notice(effects, Hint::SmartlogFixAbandoned)?;
910        }
911    }
912
913    Ok(Ok(()))
914}
915
916/// `smartlog` command.
917#[instrument]
918pub fn command_main(ctx: CommandContext, args: SmartlogArgs) -> EyreExitOr<()> {
919    let CommandContext {
920        effects,
921        git_run_info,
922    } = ctx;
923    let SmartlogArgs {
924        event_id,
925        revset,
926        resolve_revset_options,
927        reverse,
928        exact,
929    } = args;
930
931    smartlog(
932        &effects,
933        &git_run_info,
934        SmartlogOptions {
935            event_id,
936            revset,
937            resolve_revset_options,
938            reverse,
939            exact,
940        },
941    )
942}