Skip to content

Commit ec916d2

Browse files
feat(smartlog): Support for rendering a sparse smartlog
This should only happen when the user has requested to render a specific revset, as opposed to calling `smartlog` without any arguments, or when any of the other commands trigger a call to `smartlog`. Note that HEAD and the head of the main branch are always displayed, regardless of the revset provided by the user.
1 parent a95f755 commit ec916d2

File tree

3 files changed

+277
-54
lines changed

3 files changed

+277
-54
lines changed

git-branchless/src/commands/smartlog.rs

Lines changed: 163 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,20 @@ mod graph {
5252

5353
/// The OID of the parent node in the smartlog commit graph.
5454
///
55-
/// This is different from inspecting `commit.parents()`,& since the smartlog
55+
/// This is different from inspecting `commit.parents()`, since the smartlog
5656
/// will hide most nodes from the commit graph, including parent nodes.
5757
pub parent: Option<NonZeroOid>,
5858

5959
/// The OIDs of the children nodes in the smartlog commit graph.
6060
pub children: Vec<NonZeroOid>,
6161

62+
/// Does this commit have any non-immediate, non-main branch ancestor
63+
/// nodes in the smartlog commit graph?
64+
pub has_ancestors: bool,
65+
66+
/// The OIDs of any non-immediate descendant nodes in the smartlog commit graph.
67+
pub descendants: Vec<NonZeroOid>,
68+
6269
/// Indicates that this is a commit to the main branch.
6370
///
6471
/// These commits are considered to be immutable and should never leave the
@@ -80,6 +87,13 @@ mod graph {
8087
/// where you commit directly to the main branch and then later rewrite the
8188
/// commit.
8289
pub is_obsolete: bool,
90+
91+
/// Indicates that this commit has descendants, but that none of them
92+
/// are included in the graph.
93+
///
94+
/// This allows us to indicate this "false head" to the user. Otherwise,
95+
/// this commit would look like a normal, descendant-less head.
96+
pub is_false_head: bool,
8397
}
8498

8599
/// Graph of commits that the user is working on.
@@ -111,32 +125,28 @@ mod graph {
111125
}
112126
}
113127

114-
/// Find additional commits that should be displayed.
128+
/// Build the smartlog graph by finding additional commits that should be displayed.
115129
///
116130
/// For example, if you check out a commit that has intermediate parent commits
117131
/// between it and the main branch, those intermediate commits should be shown
118132
/// (or else you won't get a good idea of the line of development that happened
119133
/// for this commit since the main branch).
120134
#[instrument]
121-
fn walk_from_commits<'repo>(
135+
fn build_graph<'repo>(
122136
effects: &Effects,
123137
repo: &'repo Repo,
124138
dag: &Dag,
125139
public_commits: &CommitSet,
126-
active_heads: &CommitSet,
140+
commits: &CommitSet,
127141
) -> eyre::Result<SmartlogGraph<'repo>> {
128142
let mut graph: HashMap<NonZeroOid, Node> = {
129143
let mut result = HashMap::new();
130-
for vertex in commit_set_to_vec(active_heads)? {
144+
for vertex in commit_set_to_vec(commits)? {
131145
let vertex = CommitSet::from(vertex);
132146
let merge_bases = dag.query().gca_all(dag.main_branch_commit.union(&vertex))?;
133-
let intermediate_commits = if merge_bases.is_empty()? {
134-
vertex
135-
} else {
136-
dag.query().range(merge_bases, vertex)?
137-
};
147+
let vertices = vertex.union(&merge_bases);
138148

139-
for oid in commit_set_to_vec(&intermediate_commits)? {
149+
for oid in commit_set_to_vec(&vertices)? {
140150
let object = match repo.find_commit(oid)? {
141151
Some(commit) => NodeObject::Commit { commit },
142152
None => {
@@ -151,40 +161,103 @@ mod graph {
151161
object,
152162
parent: None, // populated below
153163
children: Vec::new(), // populated below
164+
has_ancestors: false,
165+
descendants: Vec::new(), // populated below
154166
is_main: public_commits.contains(&oid.into())?,
155167
is_obsolete: dag.query_obsolete_commits().contains(&oid.into())?,
168+
is_false_head: false,
156169
},
157170
);
158171
}
159172
}
160173
result
161174
};
162175

163-
// Find immediate parent-child links.
164-
let links: Vec<(NonZeroOid, NonZeroOid)> = {
165-
let non_main_node_oids =
166-
graph.iter().filter_map(
167-
|(child_oid, node)| if !node.is_main { Some(child_oid) } else { None },
168-
);
169-
170-
let mut links = Vec::new();
171-
for child_oid in non_main_node_oids {
172-
let parent_vertexes = dag.query().parents(CommitSet::from(*child_oid))?;
173-
let parent_oids = commit_set_to_vec(&parent_vertexes)?;
174-
for parent_oid in parent_oids {
175-
if graph.contains_key(&parent_oid) {
176-
links.push((*child_oid, parent_oid))
176+
let mut immediate_links: Vec<(NonZeroOid, NonZeroOid)> = Vec::new();
177+
let mut non_immediate_links: Vec<(NonZeroOid, NonZeroOid)> = Vec::new();
178+
179+
let non_main_node_oids =
180+
graph
181+
.iter()
182+
.filter_map(|(child_oid, node)| if !node.is_main { Some(child_oid) } else { None });
183+
184+
let graph_vertices: CommitSet = graph.keys().cloned().collect();
185+
for child_oid in non_main_node_oids {
186+
let parent_vertices = dag.query().parents(CommitSet::from(*child_oid))?;
187+
188+
// Find immediate parent-child links.
189+
let parents_in_graph = parent_vertices.intersection(&graph_vertices);
190+
let parent_oids = commit_set_to_vec(&parents_in_graph)?;
191+
for parent_oid in parent_oids {
192+
immediate_links.push((*child_oid, parent_oid))
193+
}
194+
195+
if parent_vertices.count()? != parents_in_graph.count()? {
196+
// Find non-immediate ancestor links.
197+
let excluded_parents = parent_vertices.difference(&graph_vertices);
198+
let excluded_parent_oids = commit_set_to_vec(&excluded_parents)?;
199+
for parent_oid in excluded_parent_oids {
200+
// Find the nearest ancestor that is included in the graph and
201+
// also on the same branch.
202+
203+
let parent_set = CommitSet::from(parent_oid);
204+
let merge_base = dag
205+
.query()
206+
.gca_one(dag.main_branch_commit.union(&parent_set))?;
207+
208+
let path_to_main_branch = match merge_base {
209+
Some(merge_base) => {
210+
dag.query().range(CommitSet::from(merge_base), parent_set)?
211+
}
212+
None => CommitSet::empty(),
213+
};
214+
let nearest_branch_ancestor = dag
215+
.query()
216+
.heads_ancestors(path_to_main_branch.intersection(&graph_vertices))?;
217+
218+
let ancestor_oids = commit_set_to_vec(&nearest_branch_ancestor)?;
219+
for ancestor_oid in ancestor_oids.iter() {
220+
non_immediate_links.push((*ancestor_oid, *child_oid));
177221
}
178222
}
179223
}
180-
links
181-
};
224+
}
182225

183-
for (child_oid, parent_oid) in links.iter() {
226+
for (child_oid, parent_oid) in immediate_links.iter() {
184227
graph.get_mut(child_oid).unwrap().parent = Some(*parent_oid);
185228
graph.get_mut(parent_oid).unwrap().children.push(*child_oid);
186229
}
187230

231+
for (ancestor_oid, descendent_oid) in non_immediate_links.iter() {
232+
graph.get_mut(descendent_oid).unwrap().has_ancestors = true;
233+
graph
234+
.get_mut(ancestor_oid)
235+
.unwrap()
236+
.descendants
237+
.push(*descendent_oid);
238+
}
239+
240+
for (oid, node) in graph.iter_mut() {
241+
let oid_set = CommitSet::from(*oid);
242+
let is_main_head = !dag.main_branch_commit.intersection(&oid_set).is_empty()?;
243+
let ancestor_of_main = node.is_main && !is_main_head;
244+
let has_descendants_in_graph =
245+
!node.children.is_empty() || !node.descendants.is_empty();
246+
247+
if ancestor_of_main || has_descendants_in_graph {
248+
continue;
249+
}
250+
251+
// This node has no descendants in the graph, so it's a
252+
// false head if it has *any* (non-obsolete) children.
253+
let children_not_in_graph = dag
254+
.query()
255+
.children(oid_set)?
256+
.difference(&dag.query_obsolete_commits());
257+
258+
node.is_false_head = !children_not_in_graph.is_empty()?;
259+
}
260+
188261
Ok(SmartlogGraph { nodes: graph })
189262
}
190263

@@ -225,12 +298,16 @@ mod graph {
225298
let mut graph = {
226299
let (effects, _progress) = effects.start_operation(OperationType::WalkCommits);
227300

228-
let public_commits = dag.query_public_commits()?;
229-
for oid in commit_set_to_vec(commits)? {
301+
// HEAD and main head must be included
302+
let commits = commits
303+
.union(&dag.head_commit)
304+
.union(&dag.main_branch_commit);
305+
306+
for oid in commit_set_to_vec(&commits)? {
230307
mark_commit_reachable(repo, oid)?;
231308
}
232309

233-
walk_from_commits(&effects, repo, dag, public_commits, commits)?
310+
build_graph(&effects, repo, dag, dag.query_public_commits()?, &commits)?
234311
};
235312
sort_children(&mut graph);
236313
Ok(graph)
@@ -239,6 +316,7 @@ mod graph {
239316

240317
mod render {
241318
use std::cmp::Ordering;
319+
use std::collections::HashSet;
242320
use std::convert::TryFrom;
243321

244322
use cursive::theme::Effect;
@@ -272,7 +350,16 @@ mod render {
272350
let mut root_commit_oids: Vec<NonZeroOid> = graph
273351
.nodes
274352
.iter()
275-
.filter(|(_oid, node)| node.parent.is_none())
353+
.filter(|(_oid, node)| {
354+
// Common case: on main w/ no parents in graph, eg a merge base
355+
node.parent.is_none() && node.is_main ||
356+
// Pathological cases: orphaned, garbage collected, etc
357+
node.parent.is_none()
358+
&& !node.is_main
359+
&& node.children.is_empty()
360+
&& node.descendants.is_empty()
361+
&& !node.has_ancestors
362+
})
276363
.map(|(oid, _node)| oid)
277364
.copied()
278365
.collect();
@@ -339,7 +426,13 @@ mod render {
339426
(true, true, true) => glyphs.commit_main_obsolete_head,
340427
};
341428

342-
let first_line = {
429+
let mut lines = vec![];
430+
431+
if current_node.has_ancestors {
432+
lines.push(StyledString::plain(glyphs.vertical_ellipsis.to_string()));
433+
};
434+
435+
lines.push({
343436
let mut first_line = StyledString::new();
344437
first_line.append_plain(cursor);
345438
first_line.append_plain(" ");
@@ -349,36 +442,50 @@ mod render {
349442
} else {
350443
first_line
351444
}
445+
});
446+
447+
if current_node.is_false_head {
448+
lines.push(StyledString::plain(glyphs.vertical_ellipsis.to_string()));
352449
};
353450

354-
let mut lines = vec![first_line];
355451
let children: Vec<_> = current_node
356452
.children
357453
.iter()
358454
.filter(|child_oid| graph.nodes.contains_key(child_oid))
359455
.copied()
360456
.collect();
361-
for (child_idx, child_oid) in children.iter().enumerate() {
457+
let descendants: HashSet<_> = current_node
458+
.descendants
459+
.iter()
460+
.filter(|descendent_oid| graph.nodes.contains_key(descendent_oid))
461+
.copied()
462+
.collect();
463+
for (child_idx, child_oid) in children.iter().chain(descendants.iter()).enumerate() {
362464
if root_oids.contains(child_oid) {
363465
// Will be rendered by the parent.
364466
continue;
365467
}
366468

367-
if child_idx == children.len() - 1 {
469+
let is_last_child = child_idx == (children.len() + descendants.len()) - 1;
470+
if is_last_child {
368471
let line = match last_child_line_char {
369-
Some(_) => StyledString::plain(format!(
472+
Some(_) => Some(StyledString::plain(format!(
370473
"{}{}",
371474
glyphs.line_with_offshoot, glyphs.slash
372-
)),
373-
374-
None => StyledString::plain(glyphs.line.to_string()),
475+
))),
476+
None if current_node.descendants.is_empty() => {
477+
Some(StyledString::plain(glyphs.line.to_string()))
478+
}
479+
None => None,
375480
};
376-
lines.push(line)
481+
if let Some(line) = line {
482+
lines.push(line);
483+
}
377484
} else {
378485
lines.push(StyledString::plain(format!(
379486
"{}{}",
380487
glyphs.line_with_offshoot, glyphs.slash
381-
)))
488+
)));
382489
}
383490

384491
let child_output = get_child_output(
@@ -391,7 +498,7 @@ mod render {
391498
None,
392499
)?;
393500
for child_line in child_output {
394-
let line = if child_idx == children.len() - 1 {
501+
let line = if is_last_child {
395502
match last_child_line_char {
396503
Some(last_child_line_char) => StyledStringBuilder::new()
397504
.append_plain(format!("{} ", last_child_line_char))
@@ -401,7 +508,14 @@ mod render {
401508
}
402509
} else {
403510
StyledStringBuilder::new()
404-
.append_plain(format!("{} ", glyphs.line))
511+
.append_plain(format!(
512+
"{} ",
513+
if !current_node.descendants.is_empty() {
514+
glyphs.vertical_ellipsis
515+
} else {
516+
glyphs.line
517+
}
518+
))
405519
.append(child_line)
406520
.build()
407521
};
@@ -455,13 +569,10 @@ mod render {
455569
let last_child_line_char = {
456570
if root_idx == root_oids.len() - 1 {
457571
None
572+
} else if has_real_parent(root_oids[root_idx + 1], *root_oid)? {
573+
Some(glyphs.line)
458574
} else {
459-
let next_root_oid = root_oids[root_idx + 1];
460-
if has_real_parent(next_root_oid, *root_oid)? {
461-
Some(glyphs.line)
462-
} else {
463-
Some(glyphs.vertical_ellipsis)
464-
}
575+
Some(glyphs.vertical_ellipsis)
465576
}
466577
};
467578

@@ -510,8 +621,8 @@ mod render {
510621
/// as an offset from the current event.
511622
pub event_id: Option<isize>,
512623

513-
/// The commits to render. These commits and their ancestors up to the
514-
/// main branch will be rendered.
624+
/// The commits to render. These commits, plus any related commits, will
625+
/// be rendered.
515626
pub revset: Revset,
516627

517628
pub resolve_revset_options: ResolveRevsetOptions,

git-branchless/src/opts.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -460,8 +460,8 @@ pub enum Command {
460460
#[clap(value_parser, long = "event-id")]
461461
event_id: Option<isize>,
462462

463-
/// The commits to render. These commits and their ancestors up to the
464-
/// main branch will be rendered.
463+
/// The commits to render. These commits, plus any related commits, will
464+
/// be rendered.
465465
#[clap(value_parser)]
466466
revset: Option<Revset>,
467467

0 commit comments

Comments
 (0)