Skip to content

Commit b5576bb

Browse files
committed
Add graph subcommand to invoke graph visualization in the real world.
Also make some tweaks for better performance logging and better abort condition handling. Now integrated workspace tips will be limited as well, if a limit was set.
1 parent 9f21f99 commit b5576bb

File tree

9 files changed

+304
-112
lines changed

9 files changed

+304
-112
lines changed

crates/but-graph/src/api.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,15 @@ impl Graph {
177177
self.inner.edge_count()
178178
}
179179

180+
/// Return the number of commits in all segments.
181+
pub fn num_commits(&self) -> usize {
182+
self.inner
183+
.raw_nodes()
184+
.iter()
185+
.map(|n| n.weight.commits.len())
186+
.sum::<usize>()
187+
}
188+
180189
/// Return an iterator over all indices of segments in the graph.
181190
pub fn segments(&self) -> impl Iterator<Item = SegmentIndex> {
182191
self.inner.node_indices()
@@ -257,7 +266,7 @@ impl Graph {
257266

258267
/// Validate the graph for consistency and fail loudly when an issue was found, after printing the dot graph.
259268
/// Mostly useful for debugging to stop early when a connection wasn't created correctly.
260-
#[cfg(target_os = "macos")]
269+
#[cfg(unix)]
261270
pub fn validated_or_open_as_svg(self) -> anyhow::Result<Self> {
262271
for edge in self.inner.edge_references() {
263272
let res = check_edge(&self.inner, edge);
@@ -283,7 +292,8 @@ impl Graph {
283292
}
284293

285294
/// Open an SVG dot visualization in the browser or panic if the `dot` or `open` tool can't be found.
286-
#[cfg(target_os = "macos")]
295+
#[cfg(unix)]
296+
#[tracing::instrument(skip(self))]
287297
pub fn open_as_svg(&self) {
288298
use std::io::Write;
289299
use std::process::Stdio;

crates/but-graph/src/init/mod.rs

Lines changed: 24 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
use crate::{CommitFlags, Edge};
22
use crate::{CommitIndex, Graph, Segment, SegmentIndex, SegmentMetadata};
3-
use anyhow::{Context, bail};
3+
use anyhow::bail;
44
use but_core::RefMetadata;
5-
use gix::ObjectId;
65
use gix::hashtable::hash_map::Entry;
76
use gix::prelude::{ObjectIdExt, ReferenceExt};
87
use gix::refs::Category;
98
use petgraph::graph::EdgeReference;
109
use petgraph::prelude::EdgeRef;
1110
use std::collections::VecDeque;
11+
use tracing::instrument;
1212

1313
mod utils;
1414
use utils::*;
@@ -46,6 +46,7 @@ pub struct Options {
4646
/// * HEAD - uses the limit
4747
/// * workspaces with target branch - no limit, but auto-stop if workspace is exhausted as everything is integrated.
4848
/// - The target branch: no limit
49+
/// - Integrated workspace branches: use the limit
4950
/// * workspace without target branch - uses the limit
5051
/// * remotes tracking branches - use the limit
5152
pub max_commits_outside_of_workspace: Option<usize>,
@@ -146,6 +147,7 @@ impl Graph {
146147
/// * The traversal is cut short when there is only tips which are integrated, even though named segments that are
147148
/// supposed to be in the workspace will be fully traversed (implying they will stop at the first anon segment
148149
/// as will happen at merge commits).
150+
#[instrument(skip(meta, ref_name), err(Debug))]
149151
pub fn from_commit_traversal(
150152
tip: gix::Id<'_>,
151153
ref_name: impl Into<Option<gix::refs::FullName>>,
@@ -279,7 +281,7 @@ impl Graph {
279281
Instruction::CollectCommit {
280282
into: target_segment,
281283
},
282-
/* unlimited traversal for 'negative' commits */
284+
/* unlimited traversal for integrated commits */
283285
None,
284286
));
285287
}
@@ -307,67 +309,15 @@ impl Graph {
307309
propagated_flags |= src_flags;
308310
let segment_idx_for_id = match instruction {
309311
Instruction::CollectCommit { into: src_sidx } => match seen.entry(id) {
310-
Entry::Occupied(mut existing_sidx) => {
311-
let dst_sidx = *existing_sidx.get();
312-
let (top_sidx, mut bottom_sidx) =
313-
// If a normal branch walks into a workspace branch, put the workspace branch on top.
314-
if graph[dst_sidx].workspace_metadata().is_some() &&
315-
graph[src_sidx].ref_name.as_ref()
316-
.is_some_and(|rn| rn.category().is_some_and(|c| matches!(c, Category::LocalBranch))) {
317-
// `dst` is basically swapping with `src`, so must swap commits and connections.
318-
swap_commits_and_connections(&mut graph.inner, dst_sidx, src_sidx);
319-
swap_queued_segments(&mut next, dst_sidx, src_sidx);
320-
321-
// Assure the first commit doesn't name the new owner segment.
322-
{
323-
let s = &mut graph[src_sidx];
324-
if let Some(c) = s.commits.first_mut() {
325-
c.refs.retain(|rn| Some(rn) != s.ref_name.as_ref())
326-
}
327-
// Update the commit-ownership of the connecting commit, but also
328-
// of all other commits in the segment.
329-
existing_sidx.insert(src_sidx);
330-
for commit_id in s.commits.iter().skip(1).map(|c| c.id) {
331-
seen.entry(commit_id).insert(src_sidx);
332-
}
333-
}
334-
(dst_sidx, src_sidx)
335-
} else {
336-
// `src` naturally runs into destination, so nothing needs to be done
337-
// except for connecting both. Commit ownership doesn't change.
338-
(src_sidx, dst_sidx)
339-
};
340-
let top_cidx = graph[top_sidx].last_commit_index();
341-
let mut bottom_cidx =
342-
graph[bottom_sidx].commit_index_of(id).with_context(|| {
343-
format!(
344-
"BUG: Didn't find commit {id} in segment {bottom_sidx}",
345-
bottom_sidx = dst_sidx.index(),
346-
)
347-
})?;
348-
349-
if bottom_cidx != 0 {
350-
let new_bottom_sidx = split_commit_into_segment(
351-
&mut graph,
352-
&mut next,
353-
&mut seen,
354-
bottom_sidx,
355-
bottom_cidx,
356-
)?;
357-
bottom_sidx = new_bottom_sidx;
358-
bottom_cidx = 0;
359-
}
360-
graph.connect_segments(top_sidx, top_cidx, bottom_sidx, bottom_cidx);
361-
let top_flags = top_cidx
362-
.map(|cidx| graph[top_sidx].commits[cidx].flags)
363-
.unwrap_or_default();
364-
let bottom_flags = graph[bottom_sidx].commits[bottom_cidx].flags;
365-
propagate_flags_downward(
366-
&mut graph.inner,
367-
propagated_flags | top_flags | bottom_flags,
368-
bottom_sidx,
369-
Some(bottom_cidx),
370-
);
312+
Entry::Occupied(_) => {
313+
possibly_split_occupied_segment(
314+
&mut graph,
315+
&mut seen,
316+
&mut next,
317+
id,
318+
propagated_flags,
319+
src_sidx,
320+
)?;
371321
continue;
372322
}
373323
Entry::Vacant(e) => {
@@ -387,23 +337,15 @@ impl Graph {
387337
parent_above,
388338
at_commit,
389339
} => match seen.entry(id) {
390-
Entry::Occupied(existing_sidx) => {
391-
let bottom_sidx = *existing_sidx.get();
392-
let bottom = &graph[bottom_sidx];
393-
let bottom_cidx = bottom.commit_index_of(id).context(
394-
"BUG: bottom segment must contain ID, `seen` seems out of date",
340+
Entry::Occupied(_) => {
341+
possibly_split_occupied_segment(
342+
&mut graph,
343+
&mut seen,
344+
&mut next,
345+
id,
346+
propagated_flags,
347+
parent_above,
395348
)?;
396-
if bottom_cidx != 0 {
397-
todo!("split bottom segment at `at_commit`");
398-
}
399-
let bottom_flags = bottom.commits[bottom_cidx].flags;
400-
graph.connect_segments(parent_above, at_commit, bottom_sidx, bottom_cidx);
401-
propagate_flags_downward(
402-
&mut graph.inner,
403-
propagated_flags | bottom_flags,
404-
bottom_sidx,
405-
Some(bottom_cidx),
406-
);
407349
continue;
408350
}
409351
Entry::Vacant(e) => {
@@ -463,7 +405,7 @@ impl Graph {
463405
limit,
464406
)?;
465407

466-
prune_integrated_tips(&mut graph.inner, &mut next, &desired_refs);
408+
prune_integrated_tips(&mut graph.inner, &mut next, &desired_refs, max_limit);
467409
}
468410

469411
graph.post_processed(
@@ -511,7 +453,7 @@ impl Instruction {
511453
}
512454
}
513455

514-
type QueueItem = (ObjectId, CommitFlags, Instruction, Option<usize>);
456+
type QueueItem = (gix::ObjectId, CommitFlags, Instruction, Option<usize>);
515457

516458
#[derive(Debug)]
517459
pub(crate) struct EdgeOwned {

crates/but-graph/src/init/utils.rs

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use crate::{
77
use anyhow::{Context, bail};
88
use bstr::BString;
99
use but_core::{RefMetadata, ref_metadata};
10+
use gix::hashtable::hash_map::Entry;
1011
use gix::prelude::ObjectIdExt;
1112
use gix::reference::Category;
1213
use gix::refs::FullName;
@@ -268,8 +269,20 @@ pub fn queue_parents(
268269
parent_above: current_sidx,
269270
at_commit: current_cidx,
270271
};
272+
// It's important to try to split the limit evenly so we don't create too
273+
// much extra gas here. We do, however, make sure that we see each segment of a parent
274+
// with one commit so we know exactly where it stops.
275+
// The problem with this is that we never get back the split limit when segments re-unite,
276+
// so effectively we loose gas here.
277+
let limit_per_parent = limit.map(|l| {
278+
if l == 0 {
279+
0
280+
} else {
281+
(l / parent_ids.len()).max(1)
282+
}
283+
});
271284
for pid in parent_ids {
272-
next.push_back((*pid, flags, instruction, limit))
285+
next.push_back((*pid, flags, instruction, limit_per_parent))
273286
}
274287
} else if !parent_ids.is_empty() {
275288
next.push_back((
@@ -629,18 +642,21 @@ pub fn try_queue_remote_tracking_branches(
629642
/// - keep tips that are adding segments that are or contain a workspace ref
630643
/// - prune the rest
631644
/// - delete empty segments of pruned tips.
645+
///
646+
/// `limit` is applied to integrated workspace refs if they are not yet limited.
632647
pub fn prune_integrated_tips(
633648
graph: &mut PetGraph,
634649
next: &mut VecDeque<QueueItem>,
635650
workspace_refs: &BTreeSet<gix::refs::FullName>,
651+
limit: Option<usize>,
636652
) {
637653
let all_integated = next
638654
.iter()
639655
.all(|tip| tip.1.contains(CommitFlags::Integrated));
640656
if !all_integated {
641657
return;
642658
}
643-
next.retain(|(_id, _flags, instruction, _limit)| {
659+
next.retain_mut(|(_id, _flags, instruction, tip_limit)| {
644660
let sidx = instruction.segment_idx();
645661
let s = &graph[sidx];
646662
let any_segment_ref_is_contained_in_workspace = s
@@ -649,9 +665,81 @@ pub fn prune_integrated_tips(
649665
.into_iter()
650666
.chain(s.commits.iter().flat_map(|c| c.refs.iter()))
651667
.any(|segment_rn| workspace_refs.contains(segment_rn));
668+
// For integrated workspace tips, use a limit to prevent runaway-worst-cases.
669+
if any_segment_ref_is_contained_in_workspace && tip_limit.is_none() {
670+
*tip_limit = limit;
671+
}
652672
if !any_segment_ref_is_contained_in_workspace && s.commits.is_empty() {
653673
graph.remove_node(sidx);
654674
}
655675
any_segment_ref_is_contained_in_workspace
656676
});
657677
}
678+
679+
pub fn possibly_split_occupied_segment(
680+
graph: &mut Graph,
681+
seen: &mut gix::revwalk::graph::IdMap<SegmentIndex>,
682+
next: &mut VecDeque<QueueItem>,
683+
id: gix::ObjectId,
684+
propagated_flags: CommitFlags,
685+
src_sidx: SegmentIndex,
686+
) -> anyhow::Result<()> {
687+
let Entry::Occupied(mut existing_sidx) = seen.entry(id) else {
688+
bail!("BUG: Can only work with occupied entries")
689+
};
690+
let dst_sidx = *existing_sidx.get();
691+
let (top_sidx, mut bottom_sidx) =
692+
// If a normal branch walks into a workspace branch, put the workspace branch on top.
693+
if graph[dst_sidx].workspace_metadata().is_some() &&
694+
graph[src_sidx].ref_name.as_ref()
695+
.is_some_and(|rn| rn.category().is_some_and(|c| matches!(c, Category::LocalBranch))) {
696+
// `dst` is basically swapping with `src`, so must swap commits and connections.
697+
swap_commits_and_connections(&mut graph.inner, dst_sidx, src_sidx);
698+
swap_queued_segments(next, dst_sidx, src_sidx);
699+
700+
// Assure the first commit doesn't name the new owner segment.
701+
{
702+
let s = &mut graph[src_sidx];
703+
if let Some(c) = s.commits.first_mut() {
704+
c.refs.retain(|rn| Some(rn) != s.ref_name.as_ref())
705+
}
706+
// Update the commit-ownership of the connecting commit, but also
707+
// of all other commits in the segment.
708+
existing_sidx.insert(src_sidx);
709+
for commit_id in s.commits.iter().skip(1).map(|c| c.id) {
710+
seen.entry(commit_id).insert(src_sidx);
711+
}
712+
}
713+
(dst_sidx, src_sidx)
714+
} else {
715+
// `src` naturally runs into destination, so nothing needs to be done
716+
// except for connecting both. Commit ownership doesn't change.
717+
(src_sidx, dst_sidx)
718+
};
719+
let top_cidx = graph[top_sidx].last_commit_index();
720+
let mut bottom_cidx = graph[bottom_sidx].commit_index_of(id).with_context(|| {
721+
format!(
722+
"BUG: Didn't find commit {id} in segment {bottom_sidx}",
723+
bottom_sidx = dst_sidx.index(),
724+
)
725+
})?;
726+
727+
if bottom_cidx != 0 {
728+
let new_bottom_sidx =
729+
split_commit_into_segment(graph, next, seen, bottom_sidx, bottom_cidx)?;
730+
bottom_sidx = new_bottom_sidx;
731+
bottom_cidx = 0;
732+
}
733+
graph.connect_segments(top_sidx, top_cidx, bottom_sidx, bottom_cidx);
734+
let top_flags = top_cidx
735+
.map(|cidx| graph[top_sidx].commits[cidx].flags)
736+
.unwrap_or_default();
737+
let bottom_flags = graph[bottom_sidx].commits[bottom_cidx].flags;
738+
propagate_flags_downward(
739+
&mut graph.inner,
740+
propagated_flags | top_flags | bottom_flags,
741+
bottom_sidx,
742+
Some(bottom_cidx),
743+
);
744+
Ok(())
745+
}

crates/but-graph/tests/fixtures/scenarios.sh

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,12 +289,20 @@ mkdir ws
289289
git init deduced-remote-ahead
290290
(cd deduced-remote-ahead
291291
commit init
292+
git checkout -b A
292293
commit shared
293294
git checkout -b soon-remote;
295+
git checkout -b tmp
296+
commit feat-on-remote
297+
git checkout soon-remote
298+
git merge --no-ff -m "merge" tmp && git branch -d tmp
294299
commit only-remote-01;
295300
commit only-remote-02;
296-
git checkout main && create_workspace_commit_once main
297-
setup_remote_tracking soon-remote main "move"
301+
git checkout A
302+
commit A1
303+
commit A2
304+
create_workspace_commit_once A
305+
setup_remote_tracking soon-remote A "move"
298306

299307
cat <<EOF >>.git/config
300308
[remote "origin"]

crates/but-graph/tests/graph/init/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ fn four_diamond() -> anyhow::Result<()> {
202202
8,
203203
"just as many as are displayed in the tree"
204204
);
205+
assert_eq!(graph.num_commits(), 8, "one commit per node");
205206
assert_eq!(
206207
graph.num_edges(),
207208
10,

0 commit comments

Comments
 (0)