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_if_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    get_hint_enabled, get_hint_string, get_smartlog_default_revset, print_hint_suppression_notice,
23    Hint,
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::{make_smartlog_graph, SmartlogGraph};
42pub use render::{render_graph, SmartlogOptions};
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 =
209            graph
210                .iter()
211                .filter_map(|(child_oid, node)| if !node.is_main { Some(child_oid) } else { None });
212
213        let graph_vertices: CommitSet = graph.keys().cloned().collect();
214        for child_oid in non_main_node_oids {
215            let parent_vertices = dag.query_parent_names(CommitVertex::from(*child_oid))?;
216
217            // Find immediate parent-child links.
218            match parent_vertices.as_slice() {
219                [] => {}
220                [first_parent_vertex, merge_parent_vertices @ ..] => {
221                    if dag.set_contains(&graph_vertices, first_parent_vertex.clone())? {
222                        let first_parent_oid = NonZeroOid::try_from(first_parent_vertex.clone())?;
223                        immediate_links.push((*child_oid, first_parent_oid, false));
224                    }
225                    for merge_parent_vertex in merge_parent_vertices {
226                        if dag.set_contains(&graph_vertices, merge_parent_vertex.clone())? {
227                            let merge_parent_oid =
228                                NonZeroOid::try_from(merge_parent_vertex.clone())?;
229                            immediate_links.push((*child_oid, merge_parent_oid, true));
230                        }
231                    }
232                }
233            }
234
235            // Find non-immediate ancestor links.
236            for excluded_parent_vertex in parent_vertices {
237                if dag.set_contains(&graph_vertices, excluded_parent_vertex.clone())? {
238                    continue;
239                }
240
241                // Find the nearest ancestor that is included in the graph and
242                // also on the same branch.
243
244                let parent_set = CommitSet::from(excluded_parent_vertex);
245                let merge_base = dag.query_gca_one(dag.main_branch_commit.union(&parent_set))?;
246
247                let path_to_main_branch = match merge_base {
248                    Some(merge_base) => dag.query_range(CommitSet::from(merge_base), parent_set)?,
249                    None => CommitSet::empty(),
250                };
251                let nearest_branch_ancestor =
252                    dag.query_heads_ancestors(path_to_main_branch.intersection(&graph_vertices))?;
253
254                let ancestor_oids = dag.commit_set_to_vec(&nearest_branch_ancestor)?;
255                for ancestor_oid in ancestor_oids.iter() {
256                    non_immediate_links.push((*ancestor_oid, *child_oid, false));
257                }
258            }
259        }
260
261        for (child_oid, parent_oid, is_merge_link) in immediate_links.iter() {
262            graph.get_mut(child_oid).unwrap().parents.push(*parent_oid);
263            graph.get_mut(parent_oid).unwrap().children.push(ChildInfo {
264                oid: *child_oid,
265                is_merge_child: *is_merge_link,
266            });
267        }
268
269        for (ancestor_oid, descendent_oid, is_merge_link) in non_immediate_links.iter() {
270            let distance = dag.set_count(
271                &dag.query_range(
272                    CommitSet::from(*ancestor_oid),
273                    CommitSet::from(*descendent_oid),
274                )?
275                .difference(&vec![*ancestor_oid, *descendent_oid].into_iter().collect()),
276            )?;
277            graph.get_mut(descendent_oid).unwrap().ancestor_info = Some(AncestorInfo {
278                oid: *ancestor_oid,
279                distance,
280            });
281            graph
282                .get_mut(ancestor_oid)
283                .unwrap()
284                .descendants
285                .push(ChildInfo {
286                    oid: *descendent_oid,
287                    is_merge_child: *is_merge_link,
288                })
289        }
290
291        for (oid, node) in graph.iter_mut() {
292            let oid_set = CommitSet::from(*oid);
293            let is_main_head = !dag.set_is_empty(&dag.main_branch_commit.intersection(&oid_set))?;
294            let ancestor_of_main = node.is_main && !is_main_head;
295            let has_descendants_in_graph =
296                !node.children.is_empty() || !node.descendants.is_empty();
297
298            if ancestor_of_main || has_descendants_in_graph {
299                continue;
300            }
301
302            // This node has no descendants in the graph, so it's a
303            // false head if it has *any* visible descendants.
304            let descendants_not_in_graph =
305                dag.query_descendants(oid_set.clone())?.difference(&oid_set);
306            let descendants_not_in_graph = dag.filter_visible_commits(descendants_not_in_graph)?;
307
308            node.num_omitted_descendants = dag.set_count(&descendants_not_in_graph)?;
309        }
310
311        Ok(SmartlogGraph { nodes: graph })
312    }
313
314    /// Sort children nodes of the commit graph in a standard order, for determinism
315    /// in output.
316    fn sort_children(graph: &mut SmartlogGraph) {
317        let commit_times: HashMap<NonZeroOid, Option<Time>> = graph
318            .nodes
319            .iter()
320            .map(|(oid, node)| {
321                (
322                    *oid,
323                    match &node.object {
324                        NodeObject::Commit { commit } => Some(commit.get_time()),
325                        NodeObject::GarbageCollected { oid: _ } => None,
326                    },
327                )
328            })
329            .collect();
330        for node in graph.nodes.values_mut() {
331            node.children.sort_by_key(
332                |ChildInfo {
333                     oid,
334                     is_merge_child,
335                 }| (&commit_times[oid], *is_merge_child, oid.to_string()),
336            );
337        }
338    }
339
340    /// Construct the smartlog graph for the repo.
341    #[instrument]
342    pub fn make_smartlog_graph<'repo>(
343        effects: &Effects,
344        repo: &'repo Repo,
345        dag: &Dag,
346        event_replayer: &EventReplayer,
347        event_cursor: EventCursor,
348        commits: &CommitSet,
349        exact: bool,
350    ) -> eyre::Result<SmartlogGraph<'repo>> {
351        let (effects, _progress) = effects.start_operation(OperationType::MakeGraph);
352
353        let mut graph = {
354            let (effects, _progress) = effects.start_operation(OperationType::WalkCommits);
355
356            // HEAD and main head are automatically included unless `exact` is set
357            let commits = if exact {
358                commits.clone()
359            } else {
360                commits
361                    .union(&dag.head_commit)
362                    .union(&dag.main_branch_commit)
363            };
364
365            for oid in dag.commit_set_to_vec(&commits)? {
366                mark_commit_reachable(repo, oid)?;
367            }
368
369            build_graph(&effects, repo, dag, &commits)?
370        };
371        sort_children(&mut graph);
372        Ok(graph)
373    }
374}
375
376mod render {
377    use std::cmp::Ordering;
378    use std::collections::HashSet;
379
380    use cursive_core::theme::{BaseColor, Effect};
381    use cursive_core::utils::markup::StyledString;
382    use tracing::instrument;
383
384    use lib::core::dag::{CommitSet, Dag};
385    use lib::core::effects::Effects;
386    use lib::core::formatting::{set_effect, Pluralize};
387    use lib::core::formatting::{Glyphs, StyledStringBuilder};
388    use lib::core::node_descriptors::{render_node_descriptors, NodeDescriptor};
389    use lib::git::{NonZeroOid, Repo};
390
391    use git_branchless_opts::{ResolveRevsetOptions, Revset};
392
393    use super::graph::{AncestorInfo, ChildInfo, SmartlogGraph};
394
395    /// Split fully-independent subgraphs into multiple graphs.
396    ///
397    /// This is intended to handle the situation of having multiple lines of work
398    /// rooted from different commits in the main branch.
399    ///
400    /// Returns the list such that the topologically-earlier subgraphs are first in
401    /// the list (i.e. those that would be rendered at the bottom of the smartlog).
402    fn split_commit_graph_by_roots(
403        repo: &Repo,
404        dag: &Dag,
405        graph: &SmartlogGraph,
406    ) -> Vec<NonZeroOid> {
407        let mut root_commit_oids: Vec<NonZeroOid> = graph
408            .nodes
409            .iter()
410            .filter(|(_oid, node)| node.parents.is_empty() && node.ancestor_info.is_none())
411            .map(|(oid, _node)| oid)
412            .copied()
413            .collect();
414
415        let compare = |lhs_oid: &NonZeroOid, rhs_oid: &NonZeroOid| -> Ordering {
416            let lhs_commit = repo.find_commit(*lhs_oid);
417            let rhs_commit = repo.find_commit(*rhs_oid);
418
419            let (lhs_commit, rhs_commit) = match (lhs_commit, rhs_commit) {
420                (Ok(Some(lhs_commit)), Ok(Some(rhs_commit))) => (lhs_commit, rhs_commit),
421                _ => return lhs_oid.cmp(rhs_oid),
422            };
423
424            let merge_base_oid =
425                dag.query_gca_one(vec![*lhs_oid, *rhs_oid].into_iter().collect::<CommitSet>());
426            let merge_base_oid = match merge_base_oid {
427                Err(_) => return lhs_oid.cmp(rhs_oid),
428                Ok(None) => None,
429                Ok(Some(merge_base_oid)) => NonZeroOid::try_from(merge_base_oid).ok(),
430            };
431
432            match merge_base_oid {
433                // lhs was topologically first, so it should be sorted earlier in the list.
434                Some(merge_base_oid) if merge_base_oid == *lhs_oid => Ordering::Less,
435                Some(merge_base_oid) if merge_base_oid == *rhs_oid => Ordering::Greater,
436
437                // The commits were not orderable (pathlogical situation). Let's
438                // just order them by timestamp in that case to produce a consistent
439                // and reasonable guess at the intended topological ordering.
440                Some(_) | None => match lhs_commit.get_time().cmp(&rhs_commit.get_time()) {
441                    result @ Ordering::Less | result @ Ordering::Greater => result,
442                    Ordering::Equal => lhs_oid.cmp(rhs_oid),
443                },
444            }
445        };
446
447        root_commit_oids.sort_by(compare);
448        root_commit_oids
449    }
450
451    #[instrument(skip(commit_descriptors, graph))]
452    fn get_child_output(
453        glyphs: &Glyphs,
454        graph: &SmartlogGraph,
455        root_oids: &[NonZeroOid],
456        commit_descriptors: &mut [&mut dyn NodeDescriptor],
457        head_oid: Option<NonZeroOid>,
458        current_oid: NonZeroOid,
459        last_child_line_char: Option<&str>,
460    ) -> eyre::Result<Vec<StyledString>> {
461        let current_node = &graph.nodes[&current_oid];
462        let is_head = Some(current_oid) == head_oid;
463
464        let mut lines = vec![];
465
466        if let Some(AncestorInfo { oid: _, distance }) = current_node.ancestor_info {
467            lines.push(
468                StyledStringBuilder::new()
469                    .append_plain(glyphs.commit_omitted)
470                    .append_plain(" ")
471                    .append_styled(
472                        Pluralize {
473                            determiner: None,
474                            amount: distance,
475                            unit: ("omitted commit", "omitted commits"),
476                        }
477                        .to_string(),
478                        Effect::Dim,
479                    )
480                    .build(),
481            );
482            lines.push(StyledString::plain(glyphs.vertical_ellipsis));
483        };
484
485        if let [_, merge_parents @ ..] = current_node.parents.as_slice() {
486            if !merge_parents.is_empty() {
487                for merge_parent_oid in merge_parents {
488                    let merge_parent_node = &graph.nodes[merge_parent_oid];
489                    lines.push(
490                        StyledStringBuilder::new()
491                            .append_plain(last_child_line_char.unwrap_or(glyphs.line))
492                            .append_plain(" ")
493                            .append_styled(
494                                format!("{} (merge) ", glyphs.commit_merge),
495                                BaseColor::Blue.dark(),
496                            )
497                            .append(render_node_descriptors(
498                                glyphs,
499                                &merge_parent_node.object,
500                                commit_descriptors,
501                            )?)
502                            .build(),
503                    );
504                }
505                lines.push(StyledString::plain(format!(
506                    "{}{}",
507                    glyphs.line_with_offshoot, glyphs.merge,
508                )));
509            }
510        }
511
512        lines.push({
513            let cursor = match (current_node.is_main, current_node.is_obsolete, is_head) {
514                (false, false, false) => glyphs.commit_visible,
515                (false, false, true) => glyphs.commit_visible_head,
516                (false, true, false) => glyphs.commit_obsolete,
517                (false, true, true) => glyphs.commit_obsolete_head,
518                (true, false, false) => glyphs.commit_main,
519                (true, false, true) => glyphs.commit_main_head,
520                (true, true, false) => glyphs.commit_main_obsolete,
521                (true, true, true) => glyphs.commit_main_obsolete_head,
522            };
523            let text = render_node_descriptors(glyphs, &current_node.object, commit_descriptors)?;
524            let first_line = StyledStringBuilder::new()
525                .append_plain(cursor)
526                .append_plain(" ")
527                .append(text)
528                .build();
529            if is_head {
530                set_effect(first_line, Effect::Bold)
531            } else {
532                first_line
533            }
534        });
535
536        if current_node.num_omitted_descendants > 0 {
537            lines.push(StyledString::plain(glyphs.vertical_ellipsis));
538            lines.push(
539                StyledStringBuilder::new()
540                    .append_plain(glyphs.commit_omitted)
541                    .append_plain(" ")
542                    .append_styled(
543                        Pluralize {
544                            determiner: None,
545                            amount: current_node.num_omitted_descendants,
546                            unit: ("omitted descendant commit", "omitted descendant commits"),
547                        }
548                        .to_string(),
549                        Effect::Dim,
550                    )
551                    .build(),
552            );
553        };
554
555        let children: Vec<ChildInfo> = current_node
556            .children
557            .iter()
558            .filter(
559                |ChildInfo {
560                     oid,
561                     is_merge_child: _,
562                 }| graph.nodes.contains_key(oid),
563            )
564            .cloned()
565            .collect();
566        let descendants: HashSet<ChildInfo> = current_node
567            .descendants
568            .iter()
569            .filter(
570                |ChildInfo {
571                     oid,
572                     is_merge_child: _,
573                 }| graph.nodes.contains_key(oid),
574            )
575            .cloned()
576            .collect();
577        for (child_idx, child_info) in children.iter().chain(descendants.iter()).enumerate() {
578            let ChildInfo {
579                oid: child_oid,
580                is_merge_child,
581            } = child_info;
582            if root_oids.contains(child_oid) {
583                // Will be rendered by the parent.
584                continue;
585            }
586            if *is_merge_child {
587                // lines.push(StyledString::plain(format!(
588                //     "{}{}",
589                //     glyphs.line_with_offshoot, glyphs.split
590                // )));
591                lines.push(
592                    StyledStringBuilder::new()
593                        // .append_plain(last_child_line_char.unwrap_or(glyphs.line))
594                        // .append_plain(" ")
595                        .append_styled(
596                            format!("{} (merge) ", glyphs.commit_merge),
597                            BaseColor::Blue.dark(),
598                        )
599                        .append(render_node_descriptors(
600                            glyphs,
601                            &graph.nodes[child_oid].object,
602                            commit_descriptors,
603                        )?)
604                        .build(),
605                );
606                continue;
607            }
608
609            let is_last_child = child_idx == (children.len() + descendants.len()) - 1;
610            lines.push(StyledString::plain(
611                if !is_last_child || last_child_line_char.is_some() {
612                    format!("{}{}", glyphs.line_with_offshoot, glyphs.split)
613                } else if current_node.descendants.is_empty() {
614                    glyphs.line.to_string()
615                } else {
616                    glyphs.vertical_ellipsis.to_string()
617                },
618            ));
619
620            let child_output = get_child_output(
621                glyphs,
622                graph,
623                root_oids,
624                commit_descriptors,
625                head_oid,
626                *child_oid,
627                None,
628            )?;
629            for child_line in child_output {
630                let line = if is_last_child {
631                    match last_child_line_char {
632                        Some(last_child_line_char) => StyledStringBuilder::new()
633                            .append_plain(format!("{last_child_line_char} "))
634                            .append(child_line)
635                            .build(),
636                        None => child_line,
637                    }
638                } else {
639                    StyledStringBuilder::new()
640                        .append_plain(format!("{} ", glyphs.line))
641                        .append(child_line)
642                        .build()
643                };
644                lines.push(line)
645            }
646        }
647        Ok(lines)
648    }
649
650    /// Render a pretty graph starting from the given root OIDs in the given graph.
651    #[instrument(skip(commit_descriptors, graph))]
652    fn get_output(
653        glyphs: &Glyphs,
654        dag: &Dag,
655        graph: &SmartlogGraph,
656        commit_descriptors: &mut [&mut dyn NodeDescriptor],
657        head_oid: Option<NonZeroOid>,
658        root_oids: &[NonZeroOid],
659    ) -> eyre::Result<Vec<StyledString>> {
660        let mut lines = Vec::new();
661
662        // Determine if the provided OID has the provided parent OID as a parent.
663        //
664        // This returns `true` in strictly more cases than checking `graph`,
665        // since there may be links between adjacent main branch commits which
666        // are not reflected in `graph`.
667        let has_real_parent = |oid: NonZeroOid, parent_oid: NonZeroOid| -> eyre::Result<bool> {
668            let parents = dag.query_parents(CommitSet::from(oid))?;
669            let result = dag.set_contains(&parents, parent_oid)?;
670            Ok(result)
671        };
672
673        for (root_idx, root_oid) in root_oids.iter().enumerate() {
674            if !dag.set_is_empty(&dag.query_parents(CommitSet::from(*root_oid))?)? {
675                let line = if root_idx > 0 && has_real_parent(*root_oid, root_oids[root_idx - 1])? {
676                    StyledString::plain(glyphs.line.to_owned())
677                } else {
678                    StyledString::plain(glyphs.vertical_ellipsis.to_owned())
679                };
680                lines.push(line);
681            } else if root_idx > 0 {
682                // Pathological case: multiple topologically-unrelated roots.
683                // Separate them with a newline.
684                lines.push(StyledString::new());
685            }
686
687            let last_child_line_char = {
688                if root_idx == root_oids.len() - 1 {
689                    None
690                } else if has_real_parent(root_oids[root_idx + 1], *root_oid)? {
691                    Some(glyphs.line)
692                } else {
693                    Some(glyphs.vertical_ellipsis)
694                }
695            };
696
697            let child_output = get_child_output(
698                glyphs,
699                graph,
700                root_oids,
701                commit_descriptors,
702                head_oid,
703                *root_oid,
704                last_child_line_char,
705            )?;
706            lines.extend(child_output.into_iter());
707        }
708
709        Ok(lines)
710    }
711
712    /// Render the smartlog graph and write it to the provided stream.
713    #[instrument(skip(commit_descriptors, graph))]
714    pub fn render_graph(
715        effects: &Effects,
716        repo: &Repo,
717        dag: &Dag,
718        graph: &SmartlogGraph,
719        head_oid: Option<NonZeroOid>,
720        commit_descriptors: &mut [&mut dyn NodeDescriptor],
721    ) -> eyre::Result<Vec<StyledString>> {
722        let root_oids = split_commit_graph_by_roots(repo, dag, graph);
723        let lines = get_output(
724            effects.get_glyphs(),
725            dag,
726            graph,
727            commit_descriptors,
728            head_oid,
729            &root_oids,
730        )?;
731        Ok(lines)
732    }
733
734    /// Options for rendering the smartlog.
735    #[derive(Debug, Default)]
736    pub struct SmartlogOptions {
737        /// The point in time at which to show the smartlog. If not provided,
738        /// renders the smartlog as of the current time. If negative, is treated
739        /// as an offset from the current event.
740        pub event_id: Option<isize>,
741
742        /// The commits to render. These commits, plus any related commits, will
743        /// be rendered. If not provided, the user's default revset will be used
744        /// instead.
745        pub revset: Option<Revset>,
746
747        /// The options to use when resolving the revset.
748        pub resolve_revset_options: ResolveRevsetOptions,
749
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 mut lines = render_graph(
829        &effects.reverse_order(reverse),
830        &repo,
831        &dag,
832        &graph,
833        references_snapshot.head_oid,
834        &mut [
835            &mut CommitOidDescriptor::new(true)?,
836            &mut RelativeTimeDescriptor::new(&repo, SystemTime::now())?,
837            &mut ObsolescenceExplanationDescriptor::new(
838                &event_replayer,
839                event_replayer.make_default_cursor(),
840            )?,
841            &mut BranchesDescriptor::new(
842                &repo,
843                &head_info,
844                &references_snapshot,
845                &Redactor::Disabled,
846            )?,
847            &mut DifferentialRevisionDescriptor::new(&repo, &Redactor::Disabled)?,
848            &mut CommitMessageDescriptor::new(&Redactor::Disabled)?,
849        ],
850    )?
851    .into_iter();
852    while let Some(line) = if reverse {
853        lines.next_back()
854    } else {
855        lines.next()
856    } {
857        writeln!(
858            effects.get_output_stream(),
859            "{}",
860            effects.get_glyphs().render(line)?
861        )?;
862    }
863
864    if !resolve_revset_options.show_hidden_commits
865        && get_hint_enabled(&repo, Hint::SmartlogFixAbandoned)?
866    {
867        let commits_with_abandoned_children: CommitSet = graph
868            .nodes
869            .iter()
870            .filter_map(|(oid, node)| {
871                if node.is_obsolete
872                    && find_rewrite_target(&event_replayer, event_cursor, *oid).is_some()
873                {
874                    Some(*oid)
875                } else {
876                    None
877                }
878            })
879            .collect();
880        let children = dag.query_children(commits_with_abandoned_children)?;
881        let num_abandoned_children =
882            dag.set_count(&children.difference(&dag.query_obsolete_commits()))?;
883        if num_abandoned_children > 0 {
884            writeln!(
885                effects.get_output_stream(),
886                "{}: there {} in your commit graph",
887                effects.get_glyphs().render(get_hint_string())?,
888                Pluralize {
889                    determiner: Some(("is", "are")),
890                    amount: num_abandoned_children,
891                    unit: ("abandoned commit", "abandoned commits"),
892                },
893            )?;
894            writeln!(
895                effects.get_output_stream(),
896                "{}: to fix this, run: git restack",
897                effects.get_glyphs().render(get_hint_string())?,
898            )?;
899            print_hint_suppression_notice(effects, Hint::SmartlogFixAbandoned)?;
900        }
901    }
902
903    Ok(Ok(()))
904}
905
906/// `smartlog` command.
907#[instrument]
908pub fn command_main(ctx: CommandContext, args: SmartlogArgs) -> EyreExitOr<()> {
909    let CommandContext {
910        effects,
911        git_run_info,
912    } = ctx;
913    let SmartlogArgs {
914        event_id,
915        revset,
916        resolve_revset_options,
917        reverse,
918        exact,
919    } = args;
920
921    smartlog(
922        &effects,
923        &git_run_info,
924        SmartlogOptions {
925            event_id,
926            revset,
927            resolve_revset_options,
928            reverse,
929            exact,
930        },
931    )
932}