When rendering a graph, if the history contains multiple "visual roots",
actual roots or commits that look like roots (i.e. have their parents
filtered out) can end up being vertically adjacent to unrelated commits,
falsely appearing to be related.
A fix for this issue was already attempted [1] a while ago.
This happens because the commits fill the space from left to right and
when a visual root ends, its column becomes free for the following
commit even if they are not related. Once this happens the unrelated
commit is rendered below the visual root. Because there is no special
character or way to identify when a visual root is rendered making the
graph confusing.
By indenting the visual roots when there are still commits to show the
vertical adjacency can be avoided.
Add is_visual_root flag to git_graph making it visible in all graph states,
give graph_update() a new function, graph_is_visual_root() to know if the
current commit is a visual root and set is_visual_root.
The different handled cases are:
- If a visual root has children: similar to GRAPH_PRE_COMMIT state when
octopus merges need space, an edge row needs to be printed to connect
the child with the indented visual root. A new state GRAPH_PRE_ROOT is
needed to connect the child with the visual root:
* child of the visual root
\ GRAPH_PRE_ROOT
* visual root indented
- If a visual root is child-less we can skip GRAPH_PRE_ROOT state and
render the indented commit directly.
* visual root indented
* unrelated commit
- If two or more visual roots are adjacent: by having a lookahead to the
next commit that will be rendered, if the next commit is also a visual
root and we are on a visual root, meaning two visual root adjacent in
the history, the top one can omit the indent, making the one below to
indent only once, if there are more adjacent visual commits, the
indentation will increase for each adjacent one, cascading.
* visual root
* visual root
* visual root
* last commit
Even if the last commit is a root, because there is nothing that will be
rendered below we can omit the indentation on purpose.
[1]: https://lore.kernel.org/git/xmqqwnwajbuj.fsf@gitster.c.googlers.com/
Helped-by: Kristofer Karlsson <krka@spotify.com>
Mentored-by: Karthik Nayak <karthik.188@gmail.com>
Mentored-by: Chandra Pratap <chandrapratap3519@gmail.com>
Signed-off-by: Pablo Sabater <pabloosabaterr@gmail.com>
Signed-off-by: Junio C Hamano <gitster@pobox.com>
Pablo Sabater committedJul 14, 2026 at 14:09 UTCa34c00d96851009e94ec7618ad5c1464f920c80d
3 files changed+759
graph.c
+244
index 89ebcf7540..087094189f 100644--- a/graph.c+++ b/graph.c@@ -60,12 +60,23 @@ struct column { * index into column_colors. */ unsigned short color;+ /*+ * Marks if a commit is a non-first parent of a merge. These columns are+ * already visually connected to the merge commit and do not need+ * indentation.+ *+ * The first parent is the one that inherits the column and it can need+ * indentation if turns out to be a visual root and there's still+ * commits to render.+ */+ unsigned int is_merge_parent:1; }; enum graph_state { GRAPH_PADDING, GRAPH_SKIP, GRAPH_PRE_COMMIT,+ GRAPH_PRE_ROOT, GRAPH_COMMIT, GRAPH_POST_MERGE, GRAPH_COLLAPSING@@ -323,6 +334,51 @@ struct git_graph { */ struct commit *lookahead[2]; int lookahead_nr;++ /*+ * If a commit is a visual root, we need to indent it to prevent+ * unrelated commits from being vertically adjacent to it.+ */+ unsigned int is_visual_root:1;++ /*+ * Indentation increases for each visual root adjacent to another visual+ * root, making visual root commits indentation cascade.+ */+ unsigned int visual_root_depth;++ /*+ * When a visual root is adjacent to other visual roots, the first one+ * can avoid indentation and the rest cascades, increasing the indentation+ * for each one.+ */+ unsigned int visual_root_cascade:1;++ /*+ * Set when the current commit was already present in graph->columns+ * before being processed.+ */+ unsigned int commit_in_columns:1;+};++struct graph_lookahead_flags {++ /*+ * Set when there will be a commit after the current one that will be+ * rendered.+ */+ unsigned int is_next_visible:1;++ /*+ * Set when the next visible commit is candidate to be a visual root.+ */+ unsigned int is_next_visual_root:1;++ /*+ * Set when the next visible commit will be rendered under the current+ * commit.+ */+ unsigned int next_has_column:1; }; static inline int graph_needs_truncation(struct git_graph *graph, int lane)@@ -399,6 +455,8 @@ struct git_graph *graph_init(struct rev_info *opt) graph->lookahead[0] = NULL; graph->lookahead[1] = NULL; graph->lookahead_nr = 0;+ graph->visual_root_depth = 0;+ graph->visual_root_cascade = 0; /* * Start the column color at the maximum value, since we'll * always increment it for the first commit we output.@@ -581,6 +639,11 @@ static void graph_insert_into_new_columns(struct git_graph *graph, struct commit *commit, int idx) {+ /*+ * Get the initial merge_layout before it's modified to know if this+ * is a merge.+ */+ int initial_merge_layout = graph->merge_layout; int i = graph_find_new_column_by_commit(graph, commit); int mapping_idx;@@ -592,6 +655,7 @@ static void graph_insert_into_new_columns(struct git_graph *graph, i = graph->num_new_columns++; graph->new_columns[i].commit = commit; graph->new_columns[i].color = graph_find_commit_color(graph, commit);+ graph->new_columns[i].is_merge_parent = 0; } if (graph->num_parents > 1 && idx > -1 && graph->merge_layout == -1) {@@ -630,6 +694,12 @@ static void graph_insert_into_new_columns(struct git_graph *graph, } graph->mapping[mapping_idx] = i;++ /*+ * Mark non-first parents of a merge.+ */+ if (graph->num_parents > 1 && initial_merge_layout >= 0 && idx > -1)+ graph->new_columns[i].is_merge_parent = 1; } static void graph_update_columns(struct git_graph *graph)@@ -721,10 +791,20 @@ static void graph_update_columns(struct git_graph *graph) if (graph->num_parents == 0) graph->width += 2; } else {+ int j; graph_insert_into_new_columns(graph, col_commit, -1);+ /*+ * This column is not the current commit, but we need to+ * propagate the flag until the commit is processed.+ */+ j = graph_find_new_column_by_commit(graph, col_commit);+ if (j >= 0 && graph->columns[i].is_merge_parent)+ graph->new_columns[j].is_merge_parent = 1; } }+ graph->commit_in_columns = is_commit_in_columns;+ /* * If graph_max_lanes is set, cap the width */@@ -814,9 +894,113 @@ void graph_push_lookahead(struct git_graph *graph, struct commit *c) graph->lookahead[graph->lookahead_nr++] = c; }+/*+ * A commit can be a visual root when:+ *+ * - It has no parents.+ *+ * - It has parents but they are all filtered out and+ * commit->parents arrives NULL.+ *+ * - Its parents are uninteresting.+ *+ * - It is not a boundary commit. Boundary commits also have no visible+ * parents, but they are not selected as visual roots because they cannot+ * cause the ambiguity of being vertically adjacent because:+ *+ * 1. A boundary only appears because an included commit is its child.+ * Children are always above, and the renderer draws an edge down to+ * the boundary from that child. Rather than starting a column like a+ * visual root would do, it inherits its child column.+ *+ * 2. Included commits cannot appear below a boundary. Boundaries are+ * ancestors of the exclusion point; if an included commit were an+ * ancestor of the boundary it would be excluded and not rendered.+ * Boundaries therefore always sink to the bottom.+ */+static int graph_is_visual_root_candidate(struct commit *c, struct git_graph *graph)+{+ struct commit_list *p;++ if (c->object.flags & BOUNDARY)+ return 0;+ for (p = c->parents; p; p = p->next)+ if (graph_is_interesting(graph, p->item))+ return 0;+ return 1;+}++static int graph_is_visual_root(struct git_graph *graph,+ struct graph_lookahead_flags *flags)+{+ /*+ * This must be only called for the current commit as graph contains+ * the state for the current commit only.+ *+ * To check if a commit is a visual root, call graph_is_visual_root_candidate()+ * but we won't know if it is really a visual root until we get to the+ * next commit state.+ *+ * The current commit is an actual visual root if it is a candidate and+ * the commit is not a non-first parent of a merge.+ *+ * *+ * |\+ * | * <- it is a visual root candidate but it shouldn't be indented+ * * because it is already connected by an edge.+ * ^ if commit_in_columns && is_merge_parent means the commit+ * | was put by a merge and is connected.+ * |+ * `-------- if !is_next_visible means we're on the last commit, avoid+ * indentation unless the one before is a visual root, then+ * we need to differentiate from the one above.+ *+ * If next_has_columns means that the next commit has+ * already a column, so it will not be rendered below, the+ * current commit has to act as the last commit and omit+ * indentation.+ */+ return graph_is_visual_root_candidate(graph->commit, graph) &&+ !(graph->commit_in_columns &&+ graph->columns[graph->commit_index].is_merge_parent) &&+ flags->is_next_visible &&+ (!flags->next_has_column || graph->visual_root_depth > 0);+}++/*+ * Peeks the next commits via the lookahead buffer and sets the lookahead flags.+ */+static void graph_peek_next_visible(struct git_graph *graph,+ struct graph_lookahead_flags *flags)+{+ flags->is_next_visible = 0;+ flags->is_next_visual_root = 0;+ flags->next_has_column = 0;++ if (!graph->lookahead_nr)+ return;++ flags->is_next_visible = 1;+ flags->next_has_column =+ graph_find_new_column_by_commit(graph, graph->lookahead[0]) >= 0;++ if (!graph_is_visual_root_candidate(graph->lookahead[0], graph))+ return;++ if (graph->lookahead_nr >= 2)+ flags->is_next_visual_root = 1;+}++static int graph_needs_pre_root_line(struct git_graph *graph)+{+ return graph->commit_in_columns && graph->is_visual_root &&+ graph->num_columns > 0 && !graph->visual_root_cascade;+}+ void graph_update(struct git_graph *graph, struct commit *commit) { struct commit_list *parent;+ struct graph_lookahead_flags flags; /* * Set the new commit@@ -847,6 +1031,23 @@ void graph_update(struct git_graph *graph, struct commit *commit) */ graph_update_columns(graph);+ graph_peek_next_visible(graph, &flags);++ graph->is_visual_root = graph_is_visual_root(graph, &flags);++ if (graph->is_visual_root) {+ /*+ * If next is a visual root we can omit the indent for the first+ * visual root and start cascading.+ */+ if (!graph->visual_root_depth && flags.is_next_visual_root)+ graph->visual_root_cascade = 1;+ graph->visual_root_depth++;+ } else {+ graph->visual_root_depth = 0;+ graph->visual_root_cascade = 0;+ }+ graph->expansion_row = 0; /*@@ -864,11 +1065,16 @@ void graph_update(struct git_graph *graph, struct commit *commit) * room for it. We need to do this only if there is a branch row * (or more) to the right of this commit. *+ * If it is a visual root, we need to print an extra row to+ * connect the indentation.+ * * If there are less than 3 parents, we can immediately print the * commit line. */ if (graph->state != GRAPH_PADDING) graph->state = GRAPH_SKIP;+ else if (graph_needs_pre_root_line(graph))+ graph->state = GRAPH_PRE_ROOT; else if (graph_needs_pre_commit_line(graph)) graph->state = GRAPH_PRE_COMMIT; else@@ -1116,6 +1322,17 @@ static void graph_output_commit_line(struct git_graph *graph, struct graph_line if (col_commit == graph->commit) { seen_this = 1;+ if (graph->is_visual_root) {+ int depth = graph->visual_root_depth;+ /*+ * Each visual column is 2 characters wide.+ * Omit the indentation for the first visual+ * root in cascade mode.+ */+ int padding = (depth - graph->visual_root_cascade) * 2;+ graph_line_addchars(line, ' ', padding);+ graph->width += padding;+ } graph_output_commit_char(graph, line); if (graph_needs_truncation(graph, i)) {@@ -1487,6 +1704,30 @@ static void graph_output_collapsing_line(struct git_graph *graph, struct graph_l graph_update_state(graph, GRAPH_PADDING); }+static void graph_output_pre_root_line(struct git_graph *graph, struct graph_line *line)+{+ /*+ * This function adds a row before a visual root, to connect the+ * branch to the indented commit. It must only be called on a+ * visual root.+ */+ if (!graph->is_visual_root)+ BUG("commit must be a visual root to call pre_root_line");++ for (int i = 0; i < graph->num_columns; i++) {+ struct column *col = &graph->columns[i];+ if (col->commit == graph->commit) {+ graph_line_addch(line, ' ');+ graph_line_write_column(line, col, '\\');+ } else {+ graph_line_write_column(line, col, '|');+ }+ graph_line_addch(line, ' ');+ }++ graph_update_state(graph, GRAPH_COMMIT);+}+ int graph_next_line(struct git_graph *graph, struct strbuf *sb) { int shown_commit_line = 0;@@ -1512,6 +1753,9 @@ int graph_next_line(struct git_graph *graph, struct strbuf *sb) case GRAPH_PRE_COMMIT: graph_output_pre_commit_line(graph, &line); break;+ case GRAPH_PRE_ROOT:+ graph_output_pre_root_line(graph, &line);+ break; case GRAPH_COMMIT: graph_output_commit_line(graph, &line); shown_commit_line = 1;