Skip to content

Commit 8ae560a

Browse files
committed
Minor refactors
- lints - test fixtures inline - make test harder so it could run past the desired tip
1 parent 8ec3969 commit 8ae560a

File tree

7 files changed

+105
-140
lines changed

7 files changed

+105
-140
lines changed

crates/but-graph/src/api.rs

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -76,14 +76,14 @@ impl Graph {
7676
/// Compute the first merge-base between two segments (may not be the lowest).
7777
///
7878
/// **Warning**: This finds the first common ancestor encountered during traversal,
79-
/// which may not be the correct merge-base when there are multiple paths between
80-
/// segments (e.g., via merge commits). Use [`lowest_merge_base`](Self::lowest_merge_base)
81-
/// for correct results in all cases.
79+
/// which may not be the *desired* merge-base when there are multiple paths between
80+
/// segments (e.g., via merge commits). Use [`lowest_merge_base`](Self::find_lowest_merge_base)
81+
/// if in doubt.
8282
///
8383
/// The segment representing the merge-base is expected to not be empty, as its first commit
8484
/// is usually what one is interested in.
8585
// TODO: should be multi, with extra segments as third parameter
86-
pub fn first_merge_base(&self, a: SegmentIndex, b: SegmentIndex) -> Option<SegmentIndex> {
86+
pub fn first_first_merge_base(&self, a: SegmentIndex, b: SegmentIndex) -> Option<SegmentIndex> {
8787
// TODO(perf): improve this by allowing to set bitflags on the segments themselves, to allow
8888
// marking them accordingly, just like Git does.
8989
// Right now we 'emulate' bitflags on pre-allocated data with two data sets, expensive
@@ -122,7 +122,7 @@ impl Graph {
122122

123123
/// Compute the lowest merge-base between two segments.
124124
///
125-
/// Unlike [`first_merge_base`](Self::first_merge_base), this finds a common ancestor
125+
/// Unlike [`first_merge_base`](Self::first_first_merge_base), this finds a common ancestor
126126
/// with "no path around it" - meaning ALL paths from both `a` and `b` must pass through
127127
/// the returned segment.
128128
///
@@ -133,19 +133,24 @@ impl Graph {
133133
///
134134
/// The segment representing the merge-base is expected to not be empty, as its first commit
135135
/// is usually what one is interested in.
136-
pub fn lowest_merge_base(&self, a: SegmentIndex, b: SegmentIndex) -> Option<SegmentIndex> {
136+
pub fn find_lowest_merge_base(&self, a: SegmentIndex, b: SegmentIndex) -> Option<SegmentIndex> {
137137
// Each walker tracks its origin and all nodes it has visited
138138
#[derive(Clone)]
139139
struct Walker {
140+
// The segment the walker resides on currently.
140141
position: SegmentIndex,
142+
// The color or flag used to mark segments.
141143
origin: u8, // COLOR_A or COLOR_B
144+
// All segments that this walker has visited.
145+
// TODO: could be better with bitflags.
142146
visited: BTreeSet<SegmentIndex>,
143147
}
144148

145149
const COLOR_A: u8 = 1;
146150
const COLOR_B: u8 = 2;
147151

148152
// Track which nodes have been visited by A-origin and B-origin walkers
153+
// TODO: remove these duplicates - they are effectively
149154
let mut nodes_a: BTreeSet<SegmentIndex> = BTreeSet::new();
150155
let mut nodes_b: BTreeSet<SegmentIndex> = BTreeSet::new();
151156

@@ -187,10 +192,11 @@ impl Graph {
187192

188193
for (&node, &generation) in &merged_nodes {
189194
let in_all = walkers.iter().all(|w| w.visited.contains(&node));
190-
if in_all {
191-
if common_merged.is_none() || generation > common_merged.unwrap().1 {
192-
common_merged = Some((node, generation));
193-
}
195+
if in_all
196+
&& common_merged
197+
.is_none_or(|(_sid, common_generation)| generation > common_generation)
198+
{
199+
common_merged = Some((node, generation));
194200
}
195201
}
196202

@@ -200,6 +206,8 @@ impl Graph {
200206
}
201207

202208
// Expand all walkers
209+
// TODO: no, no, no, something is very strange here.
210+
// This should just be a stack of segments to visit.
203211
let mut next_walkers: Vec<Walker> = Vec::new();
204212

205213
for walker in walkers {

crates/but-graph/src/projection/workspace.rs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -562,7 +562,7 @@ impl Graph {
562562
} else {
563563
self.inner
564564
.neighbors_directed(ws_tip_segment.id, Direction::Outgoing)
565-
.reduce(|a, b| self.first_merge_base(a, b).unwrap_or(a))
565+
.reduce(|a, b| self.first_first_merge_base(a, b).unwrap_or(a))
566566
.and_then(|base| self[base].commits.first().map(|c| (c.id, base)))
567567
}
568568
})
@@ -813,17 +813,15 @@ impl Graph {
813813
ComputeBaseTip::SingleBranch(tip) => (vec![tip], tip),
814814
};
815815
let mut count = 0;
816-
let all_segments: Vec<_> = tips
816+
let all_segments = tips
817817
.into_iter()
818818
.chain(target_ref.map(|t| t.segment_index))
819819
.chain(target_commit.map(|t| t.segment_index))
820-
.chain(additional)
821-
.collect();
820+
.chain(additional);
822821

823822
let base = all_segments
824-
.into_iter()
825823
.inspect(|_| count += 1)
826-
.reduce(|a, b| self.lowest_merge_base(a, b).unwrap_or(a))?;
824+
.reduce(|a, b| self.find_lowest_merge_base(a, b).unwrap_or(a))?;
827825

828826
if count < 2 || base == actual_tip {
829827
match tip {

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

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -179,29 +179,26 @@ git init four-diamond
179179
# A graph that demonstrates the merge-base bug: there's a "path around" the first
180180
# common ancestor found.
181181
#
182-
# This creates a diamond structure with a path around mid_a:
182+
# This creates a diamond structure with a path around mid_a via `mid_c`:
183183
#
184-
# A B
185-
# │ │
186-
# ▼ ▼
187-
# mid_a ◄───────────────── M (merge of mid_a and mid_c)
188-
# │ / \
189-
# │ ▼ ▼
190-
# ▼ mid_c
191-
# base ◄─────────────────────┘
192-
#
193-
# The segment graph topology:
194-
# - seg_A -> seg_mid_a -> seg_base
195-
# - seg_B -> seg_mid_a (first parent of M) -> seg_base
196-
# - seg_B -> seg_mid_c (second parent of M) -> seg_base
197-
#
198-
# Key points:
199-
# - first_merge_base(A, B) returns mid_a (the first common ancestor encountered)
200-
# - But mid_a has a "path around" it: B can reach base via mid_c without going through mid_a
201-
# - lowest_merge_base(A, B) should return base (the dominator with no path around)
202-
184+
# A B
185+
# │ │
186+
# mid_a ─────────────── M (merge commit)
187+
# │ ╱ ╲
188+
# │ ╱ ╲
189+
# │ ╱ ╲
190+
# │ ╱ mid_c
191+
# │ ╱ │
192+
# │ ╱ │
193+
# main ◄───────────────────────┘#
194+
#
195+
# M2
196+
#
197+
# M1
203198
git init merge-base-path-around
204199
(cd merge-base-path-around
200+
commit M1
201+
commit M2
205202
# base commit - the true dominator
206203
commit base
207204

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

Lines changed: 65 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use but_graph::Graph;
22
use but_testsupport::{graph_tree, graph_workspace, visualize_commit_graph_all};
3+
use gix::refs::Category;
34

45
#[test]
56
fn unborn() -> anyhow::Result<()> {
@@ -410,111 +411,100 @@ fn four_diamond() -> anyhow::Result<()> {
410411
Ok(())
411412
}
412413

413-
/// This test demonstrates a bug in `first_merge_base`: it finds the first common
414-
/// ancestor encountered during traversal, but that's not necessarily the merge-base
415-
/// with "no paths around it."
414+
/// This test demonstrates the difference between `find_first_merge_base` and `find_lowest_merge_base`
415+
/// where the former finds the first common ancestor encountered during traversal,
416+
/// but that's not necessarily the merge-base with "no paths around it.
416417
///
417418
/// The `merge-base-path-around` fixture creates this git commit graph:
418419
/// ```text
419-
/// A B
420-
/// │ │
421-
/// ▼ ▼
422-
/// mid_a ◄───────────────── M (merge of mid_a and mid_c)
423-
/// │ / \
424-
/// │ ▼ ▼
425-
/// ▼ mid_c
426-
/// base ◄─────────────────────┘
420+
/// A B
421+
/// │ │
422+
/// mid_a ─────────────── M (merge commit)
423+
/// │ ╱ ╲
424+
/// │ ╱ ╲
425+
/// │ ╱ ╲
426+
/// │ ╱ mid_c
427+
/// │ ╱ │
428+
/// │ ╱ │
429+
/// main ◄─────────────────────┘
427430
/// ```
428431
///
429-
/// B is a merge commit with two parents: mid_a and mid_c.
430-
/// A sits on top of mid_a, which sits on base.
431-
/// mid_c is a sibling branch that also connects to base.
432+
/// `B` is a merge commit with two parents: `mid_a` and `mid_c`.
433+
/// A sits on top of `mid_a`, which sits on `main`.
434+
/// `mid_c` is a sibling branch that also connects to `main`.
432435
///
433-
/// When finding merge-base of segments A and B:
434-
/// - Current (buggy) behavior: finds mid_a (first common ancestor encountered)
435-
/// - Correct behavior: finds base (the only point with no path around it)
436+
/// When finding merge-base of segments `A` and `B`:
437+
/// - undesired behavior: finds `mid_a` (first common ancestor encountered)
438+
/// - Desired behavior: finds `main` (the only point with no path around it)
436439
///
437-
/// The issue is that mid_a has a "path around it": B can reach base via mid_c
438-
/// without going through mid_a. Therefore, mid_a is not the true merge-base
440+
/// The issue is that `mid_a` has a "path around it": `B` can reach `main` via `mid_c`
441+
/// without going through `mid_a`. Therefore, `mid_a` is not the true merge-base
439442
/// where ALL paths from both segments converge.
440443
#[test]
441-
fn first_merge_base_bug_path_around() -> anyhow::Result<()> {
444+
fn lowest_vs_first_merge_base() -> anyhow::Result<()> {
442445
let (repo, meta) = read_only_in_memory_scenario("merge-base-path-around")?;
443-
insta::assert_snapshot!(visualize_commit_graph_all(&repo)?);
446+
insta::assert_snapshot!(visualize_commit_graph_all(&repo)?, @"
447+
* dceb229 (HEAD -> A) A
448+
| * a9cdc20 (B) B merges mid_a and mid_c
449+
|/|
450+
| * dcc619e (mid_c) mid_c
451+
* | 9e44c07 (mid_a) mid_a
452+
|/
453+
* ce1ecf3 (main) base
454+
* bce0c5e M2
455+
* 3183e43 M1
456+
");
444457

445458
// Use B as an extra target so it's included in the graph
446459
let graph = Graph::from_head(
447460
&repo,
448461
&*meta,
449462
standard_options_with_extra_target(&repo, "B"),
450463
)?;
451-
insta::assert_snapshot!(graph_tree(&graph));
464+
insta::assert_snapshot!(graph_tree(&graph), @"
465+
466+
├── 👉►:0[0]:A[🌳]
467+
│ └── ·dceb229 (⌂|1)
468+
│ └── ►:2[1]:mid_a
469+
│ └── ·9e44c07 (⌂|✓|1)
470+
│ └── ►:4[2]:main
471+
│ ├── ·ce1ecf3 (⌂|✓|1)
472+
│ └── ✂·bce0c5e (⌂|✓|1)
473+
└── ►:1[0]:B
474+
└── 🟣a9cdc20 (✓)
475+
├── →:2: (mid_a)
476+
└── ►:3[1]:mid_c
477+
└── 🟣dcc619e (✓)
478+
└── →:4: (main)
479+
");
452480

453481
// Find segments by looking for their ref names in the segment list
454482
let find_segment = |name: &str| -> but_graph::SegmentIndex {
455483
graph
456-
.segments()
457-
.find(|&sidx| {
458-
graph[sidx]
459-
.ref_name()
460-
.is_some_and(|rn| rn.as_bstr().to_string().ends_with(name))
461-
})
484+
.named_segment_by_ref_name(
485+
Category::LocalBranch
486+
.to_full_name(name)
487+
.expect("statically known good ref names")
488+
.as_ref(),
489+
)
462490
.unwrap_or_else(|| panic!("segment {name} not found"))
491+
.id
463492
};
464493

465-
let seg_a = find_segment("/A");
466-
let seg_b = find_segment("/B");
467-
let seg_mid_a = find_segment("/mid_a");
468-
let seg_main = find_segment("/main"); // base
469-
470-
// Verify graph connectivity using edges
471-
// A should connect to mid_a
472-
// B should connect to both mid_a and mid_c (it's a merge commit)
473-
use petgraph::Direction;
474-
let a_edges: Vec<_> = graph.edges_directed(seg_a, Direction::Outgoing).collect();
475-
let b_edges: Vec<_> = graph.edges_directed(seg_b, Direction::Outgoing).collect();
476-
477-
assert_eq!(a_edges.len(), 1, "A should have 1 outgoing edge (to mid_a)");
478-
assert_eq!(
479-
b_edges.len(),
480-
2,
481-
"B should have 2 outgoing edges (to mid_a and mid_c) - this creates the 'path around'"
482-
);
494+
let seg_a = find_segment("A");
495+
let seg_b = find_segment("B");
496+
let seg_mid_a = find_segment("mid_a");
497+
let seg_main = find_segment("main");
483498

484499
// Now test first_merge_base
485-
let merge_base = graph.first_merge_base(seg_a, seg_b);
486-
487-
// Common ancestors of A and B are: {mid_a, deep_a, base}
488-
// - mid_a: reachable from A (directly) and B (first parent)
489-
// - deep_a: reachable from A (via mid_a) and B (via mid_a)
490-
// - base: reachable from A (via mid_a -> deep_a) and B (via mid_a OR mid_c)
491-
//
492-
// The BUG: first_merge_base returns mid_a because it's encountered first
493-
// when walking from A. But mid_a has a "path around it" - B can reach base
494-
// via mid_c without going through mid_a.
495-
//
496-
// The CORRECT merge-base is base, because ALL paths from both A and B
497-
// must pass through base. There's no way around base.
498-
499-
// First verify we got one of the common ancestors
500-
assert!(
501-
merge_base == Some(seg_mid_a) || merge_base == Some(seg_main),
502-
"merge_base should be one of the common ancestors (mid_a or main), got {:?}",
503-
merge_base
504-
);
505-
506-
// Document the buggy behavior of first_merge_base - this assertion shows it
507-
// returns mid_a, which has a "path around it" via mid_c
500+
let first_merge_base = graph.first_first_merge_base(seg_a, seg_b);
508501
assert_eq!(
509-
merge_base,
502+
first_merge_base,
510503
Some(seg_mid_a),
511-
"first_merge_base returns mid_a, but there's a path around it. \
512-
Use lowest_merge_base for correct results."
504+
"the first merge-base is always one with a young generation",
513505
);
514506

515-
// Now test lowest_merge_base - this should return the correct answer
516-
let lowest_merge_base = graph.lowest_merge_base(seg_a, seg_b);
517-
507+
let lowest_merge_base = graph.find_lowest_merge_base(seg_a, seg_b);
518508
assert_eq!(
519509
lowest_merge_base,
520510
Some(seg_main),

crates/but-graph/tests/graph/init/snapshots/graph__init__first_merge_base_bug_path_around-2.snap

Lines changed: 0 additions & 17 deletions
This file was deleted.

crates/but-graph/tests/graph/init/snapshots/graph__init__first_merge_base_bug_path_around.snap

Lines changed: 0 additions & 11 deletions
This file was deleted.

crates/but-workspace/src/commit/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,7 @@ pub mod merge {
423423
left: SegmentIndex,
424424
right: SegmentIndex,
425425
) -> anyhow::Result<(gix::ObjectId, SegmentIndex)> {
426-
let base_sidx = graph.first_merge_base(left, right).with_context(|| {
426+
let base_sidx = graph.first_first_merge_base(left, right).with_context(|| {
427427
format!(
428428
"Couldn't find merge-base between segments {l} and {r} - they are disjoint in the commit-graph",
429429
l = left.index(),

0 commit comments

Comments
 (0)