From ff7ed00a22d5d7ea813f4199cfd3fb8da5e50475 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Wed, 18 Jun 2025 08:09:00 +0200 Subject: [PATCH 1/8] Allow traversal to be aborted when there is only 'integrated' tips left. We should, however, also make sure that we finish dedicated segments, if possible. --- crates/but-graph/src/api.rs | 67 +++++++- crates/but-graph/src/init/mod.rs | 36 +++- crates/but-graph/src/init/post.rs | 24 +++ crates/but-graph/src/init/utils.rs | 45 ++++- crates/but-graph/tests/fixtures/scenarios.sh | 42 +++++ .../tests/graph/init/with_workspace.rs | 157 ++++++++++++++++++ crates/but-graph/tests/graph/utils.rs | 21 +-- crates/but-graph/tests/graph/vis.rs | 4 +- 8 files changed, 365 insertions(+), 31 deletions(-) diff --git a/crates/but-graph/src/api.rs b/crates/but-graph/src/api.rs index 4f609f1b1b..c8d6e6ef5a 100644 --- a/crates/but-graph/src/api.rs +++ b/crates/but-graph/src/api.rs @@ -42,7 +42,14 @@ impl Graph { ) -> SegmentIndex { let dst = self.inner.add_node(dst); self.inner[dst].id = dst.index(); - self.connect_segments_with_dst_id(src, src_commit, dst, dst_commit, dst_commit_id.into()); + self.connect_segments_with_ids( + src, + src_commit, + None, + dst, + dst_commit, + dst_commit_id.into(), + ); dst } } @@ -56,13 +63,14 @@ impl Graph { dst: SegmentIndex, dst_commit: impl Into>, ) { - self.connect_segments_with_dst_id(src, src_commit, dst, dst_commit, None) + self.connect_segments_with_ids(src, src_commit, None, dst, dst_commit, None) } - pub(crate) fn connect_segments_with_dst_id( + pub(crate) fn connect_segments_with_ids( &mut self, src: SegmentIndex, src_commit: impl Into>, + src_id: Option, dst: SegmentIndex, dst_commit: impl Into>, dst_id: Option, @@ -74,7 +82,7 @@ impl Graph { dst, Edge { src: src_commit, - src_id: self[src].commit_id_by_index(src_commit), + src_id: src_id.or_else(|| self[src].commit_id_by_index(src_commit)), dst: dst_commit, dst_id: dst_id.or_else(|| self[dst].commit_id_by_index(dst_commit)), }, @@ -116,6 +124,26 @@ impl Graph { self.inner.externals(Direction::Outgoing) } + /// Return all segments that are both [base segments](Self::base_segments) and which + /// aren't fully defined as traversal stopped due to some abort condition being met. + /// Valid partial segments always have at least one commit. + pub fn partial_segments(&self) -> impl Iterator { + self.base_segments().filter(|s| { + let has_outgoing = self + .inner + .edges_directed(*s, Direction::Outgoing) + .next() + .is_some(); + if has_outgoing { + return false; + } + self[*s] + .commits + .first() + .is_none_or(|c| !c.parent_ids.is_empty()) + }) + } + /// Return all segments that sit on top of the `sidx` segment as `(source_commit_index(of sidx), destination_segment_index)`, /// along with the exact commit at which the segment branches off as seen from `sidx`, usually the last one. /// Also, **this will only return those segments where the incoming connection points to their first commit**. @@ -174,11 +202,13 @@ impl Graph { has_conflicts: bool, is_entrypoint: bool, show_message: bool, + is_early_end: bool, ) -> String { let extra = extra.into(); format!( - "{ep}{kind}{conflict}{hex}{extra}{flags}{msg}{refs}", + "{ep}{end}{kind}{conflict}{hex}{extra}{flags}{msg}{refs}", ep = if is_entrypoint { "πŸ‘‰" } else { "" }, + end = if is_early_end { "βœ‚οΈ" } else { "" }, kind = if commit.flags.contains(CommitFlags::NotInRemote) { "Β·" } else { @@ -234,15 +264,15 @@ impl Graph { /// Validate the graph for consistency and fail loudly when an issue was found, after printing the dot graph. /// Mostly useful for debugging to stop early when a connection wasn't created correctly. - pub(crate) fn validate_or_eprint_dot(&mut self) -> anyhow::Result<()> { + pub fn validated_or_open_as_svg(self) -> anyhow::Result { for edge in self.inner.edge_references() { let res = check_edge(&self.inner, edge); if res.is_err() { - self.eprint_dot_graph(); + self.open_as_svg(); } res?; } - Ok(()) + Ok(self) } /// Output this graph in dot-format to stderr to allow copying it, and using like this for visualization: @@ -298,6 +328,26 @@ impl Graph { ); } + /// Return `true` if commit `cidx` in `sidx` is 'cut off', i.e. the traversal finished at this + /// commit due to an abort condition. + pub fn is_early_end_of_traversal(&self, sidx: SegmentIndex, cidx: CommitIndex) -> bool { + if cidx + 1 == self[sidx].commits.len() { + !self[sidx] + .commits + .last() + .expect("length check above works") + .parent_ids + .is_empty() + && self + .inner + .edges_directed(sidx, Direction::Outgoing) + .next() + .is_none() + } else { + false + } + } + /// Produces a dot-version of the graph. pub fn dot_graph(&self) -> String { const HEX: usize = 7; @@ -325,6 +375,7 @@ impl Graph { c.has_conflicts, !show_segment_entrypoint && Some((sidx, Some(cidx))) == entrypoint, false, + self.is_early_end_of_traversal(sidx, cidx), ) }) .collect::>() diff --git a/crates/but-graph/src/init/mod.rs b/crates/but-graph/src/init/mod.rs index 4f4351aaad..862c074566 100644 --- a/crates/but-graph/src/init/mod.rs +++ b/crates/but-graph/src/init/mod.rs @@ -79,9 +79,10 @@ impl Graph { /// - It maintains information about the intended connections, so modifications afterwards will show /// in debugging output if edges are now in violation of this constraint. /// - /// ### (Arbitrary) Rules + /// ### Rules /// - /// These rules should help to create graphs and segmentations that feel natural and are desirable to the user. + /// These rules should help to create graphs and segmentations that feel natural and are desirable to the user, + /// while avoiding traversing the entire commit-graph all the time. /// Change the rules as you see fit to accomplish this. /// /// * a commit can be governed by multiple workspaces @@ -98,6 +99,9 @@ impl Graph { /// - This implies that remotes aren't relevant for segments added during post-processing, which would typically /// be empty anyway. /// - Remotes never take commits that are already owned. + /// * The traversal is cut short when there is only tips which are integrated, even though named segments that are + /// supposed to be in the workspace will be fully traversed (implying they will stop at the first anon segment + /// as will happen at merge commits). pub fn from_commit_traversal( tip: gix::Id<'_>, ref_name: impl Into>, @@ -106,8 +110,6 @@ impl Graph { ) -> anyhow::Result { // TODO: also traverse (outside)-branches that ought to be in the workspace. That way we have the desired ones // automatically and just have to find a way to prune the undesired ones. - // TODO: pickup ref-names and see if some simple logic can avoid messes, like lot's of refs pointing to a single commit. - // while at it: make tags work. let repo = tip.repo; let ref_name = ref_name.into(); if ref_name @@ -133,7 +135,7 @@ impl Graph { None }), )?; - let (workspaces, target_refs) = + let (workspaces, target_refs, desired_refs) = obtain_workspace_infos(ref_name.as_ref().map(|rn| rn.as_ref()), meta)?; let mut seen = gix::revwalk::graph::IdMap::::default(); let tip_flags = CommitFlags::NotInRemote; @@ -299,8 +301,6 @@ impl Graph { bottom_sidx, Some(bottom_cidx), ); - graph.validate_or_eprint_dot().unwrap(); - continue; } Entry::Vacant(e) => { @@ -320,8 +320,24 @@ impl Graph { parent_above, at_commit, } => match seen.entry(id) { - Entry::Occupied(_) => { - todo!("handle previously existing segment when connecting a new one") + Entry::Occupied(existing_sidx) => { + let bottom_sidx = *existing_sidx.get(); + let bottom = &graph[bottom_sidx]; + let bottom_cidx = bottom.commit_index_of(id).context( + "BUG: bottom segment must contain ID, `seen` seems out of date", + )?; + if bottom_cidx != 0 { + todo!("split bottom segment at `at_commit`"); + } + let bottom_flags = bottom.commits[bottom_cidx].flags; + graph.connect_segments(parent_above, at_commit, bottom_sidx, bottom_cidx); + propagate_flags_downward( + &mut graph.inner, + propagated_flags | bottom_flags, + bottom_sidx, + Some(bottom_cidx), + ); + continue; } Entry::Vacant(e) => { let segment_below = @@ -377,6 +393,8 @@ impl Graph { &target_refs, meta, )?; + + prune_integrated_tips(&mut graph.inner, &mut next, &desired_refs); } graph.post_processed( diff --git a/crates/but-graph/src/init/post.rs b/crates/but-graph/src/init/post.rs index 9007db569d..7a33b0e407 100644 --- a/crates/but-graph/src/init/post.rs +++ b/crates/but-graph/src/init/post.rs @@ -35,10 +35,34 @@ impl Graph { configured_remote_tracking_branches, )?; self.workspace_upgrades(meta)?; + self.fill_flags_at_border_segments()?; Ok(self) } + /// Borders are special as these are not fully traversed segments, so their flags might be partial. + /// Here we want to assure that segments with local branch names have `NotInRemote` set + /// (just to indicate they are local first). + fn fill_flags_at_border_segments(&mut self) -> anyhow::Result<()> { + for segment in self.partial_segments().collect::>() { + let segment = &mut self[segment]; + // Partial segments are naturally the end, so no need to propagate flags. + // Note that this is usually not relevant except for yielding more correct looking graphs. + let is_named_and_local = segment + .ref_name + .as_ref() + .is_some_and(|rn| rn.category() == Some(Category::LocalBranch)); + if is_named_and_local { + for commit in segment.commits.iter_mut() { + // We set this flag as the *lack* of it means it's in a remote, which is definitely wrong + // knowing that we know what's purely remote by looking at the absence of this flag. + commit.flags |= CommitFlags::NotInRemote; + } + } + } + Ok(()) + } + /// Perform operations on segments that can reach a workspace segment when searching upwards. /// /// * insert empty segments as defined by the workspace that affects its downstream. diff --git a/crates/but-graph/src/init/utils.rs b/crates/but-graph/src/init/utils.rs index d2c72ec931..b487914083 100644 --- a/crates/but-graph/src/init/utils.rs +++ b/crates/but-graph/src/init/utils.rs @@ -443,7 +443,7 @@ pub fn collect_ref_mapping_by_prefix<'a>( Ok(all_refs_by_id) } -/// Returns `([(workspace_ref_name, workspace_info)], target_refs)` for all available workspace, +/// Returns `([(workspace_ref_name, workspace_info)], target_refs, desired_refs)` for all available workspace, /// or exactly one workspace if `maybe_ref_name`. /// already points to a workspace. That way we can discover the workspace containing any starting point, but only if needed. /// @@ -457,6 +457,7 @@ pub fn obtain_workspace_infos( ) -> anyhow::Result<( Vec<(gix::refs::FullName, ref_metadata::Workspace)>, Vec, + BTreeSet, )> { let mut workspaces = if let Some((ref_name, ws_data)) = maybe_ref_name .and_then(|ref_name| { @@ -483,6 +484,14 @@ pub fn obtain_workspace_infos( .iter() .filter_map(|(_, data)| data.target_ref.clone()) .collect(); + let desired_refs = workspaces + .iter() + .flat_map(|(_, data): &(_, ref_metadata::Workspace)| { + data.stacks + .iter() + .flat_map(|stacks| stacks.branches.iter().map(|b| b.ref_name.clone())) + }) + .collect(); // defensive pruning workspaces.retain(|(rn, data)| { if rn.category() != Some(Category::LocalBranch) { @@ -510,7 +519,7 @@ pub fn obtain_workspace_infos( true }); - Ok((workspaces, target_refs)) + Ok((workspaces, target_refs, desired_refs)) } pub fn try_refname_to_id( @@ -591,3 +600,35 @@ pub fn try_queue_remote_tracking_branches( } Ok(()) } + +/// Remove if there are only tips with integrated commits… +/// +/// - keep tips that are adding segments that are or contain a workspace ref +/// - prune the rest +/// - delete empty segments of pruned tips. +pub fn prune_integrated_tips( + graph: &mut PetGraph, + next: &mut VecDeque, + workspace_refs: &BTreeSet, +) { + let all_integated = next + .iter() + .all(|tip| tip.1.contains(CommitFlags::Integrated)); + if !all_integated { + return; + } + next.retain(|(_id, _flags, instruction)| { + let sidx = instruction.segment_idx(); + let s = &graph[sidx]; + let any_segment_ref_is_contained_in_workspace = s + .ref_name + .as_ref() + .into_iter() + .chain(s.commits.iter().flat_map(|c| c.refs.iter())) + .any(|segment_rn| workspace_refs.contains(segment_rn)); + if !any_segment_ref_is_contained_in_workspace && s.commits.is_empty() { + graph.remove_node(sidx); + } + any_segment_ref_is_contained_in_workspace + }); +} diff --git a/crates/but-graph/tests/fixtures/scenarios.sh b/crates/but-graph/tests/fixtures/scenarios.sh index c44eb726a0..b0687bb1ee 100644 --- a/crates/but-graph/tests/fixtures/scenarios.sh +++ b/crates/but-graph/tests/fixtures/scenarios.sh @@ -330,5 +330,47 @@ EOF git checkout gitbutler/workspace ) + + git init two-segments-one-integrated + (cd two-segments-one-integrated + for c in $(seq 3); do + commit "$c" + done + git checkout -b A + commit 4 + git checkout -b A-feat + commit "A-feat-1" + commit "A-feat-2" + git checkout A + git merge --no-ff A-feat + for c in $(seq 5 8); do + commit "$c" + done + git checkout -b B + commit "B1" + commit "B2" + + create_workspace_commit_once B + + tick + git checkout -b soon-origin-main main + git merge --no-ff A + setup_remote_tracking soon-origin-main main "move" + git checkout gitbutler/workspace + ) + + git init on-top-of-target-with-history + (cd on-top-of-target-with-history + commit outdated-main + git checkout -b soon-origin-main + for c in $(seq 5); do + commit "$c" + done + for name in A B C D E F gitbutler/workspace; do + git branch "$name" + done + setup_remote_tracking soon-origin-main main "move" + git checkout gitbutler/workspace + ) ) diff --git a/crates/but-graph/tests/graph/init/with_workspace.rs b/crates/but-graph/tests/graph/init/with_workspace.rs index 160bf29bad..26b0824753 100644 --- a/crates/but-graph/tests/graph/init/with_workspace.rs +++ b/crates/but-graph/tests/graph/init/with_workspace.rs @@ -659,5 +659,162 @@ fn disambiguate_by_remote() -> anyhow::Result<()> { └── β†’:7: (A) "#); + assert_eq!( + graph.partial_segments().count(), + 0, + "a fully realized graph" + ); + Ok(()) +} + +#[test] +fn integrated_tips_stop_early() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/two-segments-one-integrated")?; + insta::assert_snapshot!(visualize_commit_graph_all(&repo)?, @r" + * 7b9f260 (origin/main) Merge branch 'A' into soon-origin-main + |\ + | | * 4077353 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + | | * 6b1a13b (B) B2 + | | * 03ad472 B1 + | |/ + | * 79bbb29 (A) 8 + | * fc98174 7 + | * a381df5 6 + | * 777b552 5 + | * ce4a760 Merge branch 'A-feat' into A + | |\ + | | * fea59b5 (A-feat) A-feat-2 + | | * 4deea74 A-feat-1 + | |/ + | * 01d0e1e 4 + |/ + * 4b3e5a8 (main) 3 + * 34d0715 2 + * eb5f731 1 + "); + + add_workspace(&mut meta); + // We can abort early if there is only integrated commits left. + // We also abort integrated named segments early, unless these are named as being part of the + // workspace - here `A` is cut off. + let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; + insta::assert_snapshot!(graph_tree(&graph), @r#" + β”œβ”€β”€ πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace + β”‚ └── Β·4077353 (βŒ‚|🏘️)❱"GitButler Workspace Commit" + β”‚ └── β–Ί:4:B + β”‚ β”œβ”€β”€ Β·6b1a13b (βŒ‚|🏘️)❱"B2" + β”‚ └── Β·03ad472 (βŒ‚|🏘️)❱"B1" + β”‚ └── β–Ί:3:A + β”‚ β”œβ”€β”€ Β·79bbb29 (βŒ‚|🏘️|βœ“)❱"8" + β”‚ β”œβ”€β”€ Β·fc98174 (βŒ‚|🏘️|βœ“)❱"7" + β”‚ β”œβ”€β”€ Β·a381df5 (βŒ‚|🏘️|βœ“)❱"6" + β”‚ └── βœ‚οΈΒ·777b552 (βŒ‚|🏘️|βœ“)❱"5" + └── β–Ί:1:origin/main + └── 🟣7b9f260 (βœ“)❱"Merge branch \'A\' into soon-origin-main" + β”œβ”€β”€ β†’:3: (A) + └── β–Ί:2:main + β”œβ”€β”€ Β·4b3e5a8 (βŒ‚|βœ“)❱"3" + β”œβ”€β”€ Β·34d0715 (βŒ‚|βœ“)❱"2" + └── Β·eb5f731 (βŒ‚|βœ“)❱"1" + "#); + + add_stack_with_segments( + &mut meta, + StackId::from_number_for_testing(0), + "B", + StackState::InWorkspace, + &["A"], + ); + let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; + // Now that `A` is part of the workspace, it's not cut off anymore. + // Instead, we get to keep `A` in full, and it aborts only one later as the + // segment definitely isnt' in the workspace. + insta::assert_snapshot!(graph_tree(&graph), @r#" + β”œβ”€β”€ πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace + β”‚ └── Β·4077353 (βŒ‚|🏘️)❱"GitButler Workspace Commit" + β”‚ └── β–Ί:4:B + β”‚ β”œβ”€β”€ Β·6b1a13b (βŒ‚|🏘️)❱"B2" + β”‚ └── Β·03ad472 (βŒ‚|🏘️)❱"B1" + β”‚ └── β–Ί:3:A + β”‚ β”œβ”€β”€ Β·79bbb29 (βŒ‚|🏘️|βœ“)❱"8" + β”‚ β”œβ”€β”€ Β·fc98174 (βŒ‚|🏘️|βœ“)❱"7" + β”‚ β”œβ”€β”€ Β·a381df5 (βŒ‚|🏘️|βœ“)❱"6" + β”‚ └── Β·777b552 (βŒ‚|🏘️|βœ“)❱"5" + β”‚ └── β–Ί:5:anon: + β”‚ └── βœ‚οΈΒ·ce4a760 (βŒ‚|🏘️|βœ“)❱"Merge branch \'A-feat\' into A" + └── β–Ί:1:origin/main + └── 🟣7b9f260 (βœ“)❱"Merge branch \'A\' into soon-origin-main" + β”œβ”€β”€ β†’:3: (A) + └── β–Ί:2:main + β”œβ”€β”€ Β·4b3e5a8 (βŒ‚|βœ“)❱"3" + β”œβ”€β”€ Β·34d0715 (βŒ‚|βœ“)❱"2" + └── Β·eb5f731 (βŒ‚|βœ“)❱"1" + "#); + + let (main_id, ref_name) = id_at(&repo, "main"); + let graph = + Graph::from_commit_traversal(main_id, ref_name, &*meta, standard_options())?.validated()?; + insta::assert_snapshot!(graph_tree(&graph), @r#" + β”œβ”€β”€ β–Ίβ–Ίβ–Ί:1:gitbutler/workspace + β”‚ └── Β·4077353 (βŒ‚|🏘️)❱"GitButler Workspace Commit" + β”‚ └── β–Ί:4:B + β”‚ β”œβ”€β”€ Β·6b1a13b (βŒ‚|🏘️)❱"B2" + β”‚ └── Β·03ad472 (βŒ‚|🏘️)❱"B1" + β”‚ └── β–Ί:3:A + β”‚ β”œβ”€β”€ Β·79bbb29 (βŒ‚|🏘️|βœ“)❱"8" + β”‚ β”œβ”€β”€ Β·fc98174 (βŒ‚|🏘️|βœ“)❱"7" + β”‚ β”œβ”€β”€ Β·a381df5 (βŒ‚|🏘️|βœ“)❱"6" + β”‚ └── Β·777b552 (βŒ‚|🏘️|βœ“)❱"5" + β”‚ └── β–Ί:5:anon: + β”‚ └── βœ‚οΈΒ·ce4a760 (βŒ‚|🏘️|βœ“)❱"Merge branch \'A-feat\' into A" + └── β–Ί:2:origin/main + └── 🟣7b9f260 (βœ“)❱"Merge branch \'A\' into soon-origin-main" + β”œβ”€β”€ β†’:3: (A) + └── πŸ‘‰β–Ί:0:main + β”œβ”€β”€ Β·4b3e5a8 (βŒ‚|βœ“)❱"3" + β”œβ”€β”€ Β·34d0715 (βŒ‚|βœ“)❱"2" + └── Β·eb5f731 (βŒ‚|βœ“)❱"1" + "#); + Ok(()) +} + +#[test] +fn on_top_of_target_with_history() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/on-top-of-target-with-history")?; + insta::assert_snapshot!(visualize_commit_graph_all(&repo)?, @r" + * 2cde30a (HEAD -> gitbutler/workspace, origin/main, F, E, D, C, B, A) 5 + * 1c938f4 4 + * b82769f 3 + * 988032f 2 + * cd5b655 1 + * 2be54cd (main) outdated-main + "); + + add_workspace(&mut meta); + let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; + insta::assert_snapshot!(graph_tree(&graph), @r#" + └── πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace + └── β–Ί:1:origin/main + β”œβ”€β”€ Β·2cde30a (βŒ‚|🏘️|βœ“)❱"5" β–ΊA, β–ΊB, β–ΊC, β–ΊD, β–ΊE, β–ΊF + └── βœ‚οΈΒ·1c938f4 (βŒ‚|🏘️|βœ“)❱"4" + "#); + + // TODO: fix this - it builds a wrong graph. + // add_stack_with_segments( + // &mut meta, + // StackId::from_number_for_testing(0), + // "C", + // StackState::InWorkspace, + // &["B", "A"], + // ); + // add_stack_with_segments( + // &mut meta, + // StackId::from_number_for_testing(1), + // "D", + // StackState::InWorkspace, + // &["E", "F"], + // ); + // let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated_or_open_as_svg()?; + // insta::assert_snapshot!(graph_tree(&graph), @r#""#); Ok(()) } diff --git a/crates/but-graph/tests/graph/utils.rs b/crates/but-graph/tests/graph/utils.rs index 50d173e3e7..2e8ebfed93 100644 --- a/crates/but-graph/tests/graph/utils.rs +++ b/crates/but-graph/tests/graph/utils.rs @@ -11,8 +11,17 @@ pub fn graph_tree(graph: &but_graph::Graph) -> SegmentTree { extra: impl Into>, has_conflicts: bool, is_entrypoint: bool, + is_early_end: bool, ) -> SegmentTree { - Graph::commit_debug_string(commit, extra, has_conflicts, is_entrypoint, true).into() + Graph::commit_debug_string( + commit, + extra, + has_conflicts, + is_entrypoint, + true, /* show message */ + is_early_end, + ) + .into() } fn recurse_segment( graph: &but_graph::Graph, @@ -91,6 +100,7 @@ pub fn graph_tree(graph: &but_graph::Graph) -> SegmentTree { }, commit.has_conflicts, segment_is_entrypoint && Some(cidx) == ep.commit_index, + graph.is_early_end_of_traversal(sidx, cidx), ); if let Some(segment_indices) = connected_segments.get(&Some(cidx)) { for sidx in segment_indices { @@ -106,15 +116,6 @@ pub fn graph_tree(graph: &but_graph::Graph) -> SegmentTree { } } - for commit in segment.commits_unique_in_remote_tracking_branch.iter() { - root.push(tree_for_commit( - &commit.inner, - None, - commit.has_conflicts, - false, /* is_entrypoint */ - )); - } - root } diff --git a/crates/but-graph/tests/graph/vis.rs b/crates/but-graph/tests/graph/vis.rs index 2bb67d4d8b..04b7d63c3b 100644 --- a/crates/but-graph/tests/graph/vis.rs +++ b/crates/but-graph/tests/graph/vis.rs @@ -105,9 +105,9 @@ fn post_graph_traversal() -> anyhow::Result<()> { β”‚ β”œβ”€β”€ 🟣πŸ’₯aaaaaaa (🏘️)❱"2 in A" β”‚ └── 🟣febafeb (🏘️)❱"1 in A" β”‚ └── β–Ί:4:origin/A - β”‚ └── 🟣bbbbbbb❱"remote: on top of 1A" + β”‚ └── βœ‚οΈπŸŸ£bbbbbbb❱"remote: on top of 1A" β”œβ”€β”€ β–Ί:2:origin/main - β”‚ └── 🟣ccccccc❱"remote: on top of main" + β”‚ └── βœ‚οΈπŸŸ£ccccccc❱"remote: on top of main" └── β–Ί:1:new-stack "#); From c665e8cec805edcb9497cb33f546cbe92ba92103 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Wed, 18 Jun 2025 14:43:40 +0200 Subject: [PATCH 2/8] Let `but-workspace` use it's own `ui::Segment` and subtypes That way we can craft the `but_graph::Segment` so that it makes sense for the graph and doesn't have to compromise on types. --- crates/but-graph/src/api.rs | 19 +- crates/but-graph/src/init/mod.rs | 2 +- crates/but-graph/src/init/utils.rs | 19 +- crates/but-graph/src/lib.rs | 6 +- crates/but-graph/src/segment.rs | 182 ++---------------- crates/but-graph/tests/graph/init/mod.rs | 57 +++--- crates/but-graph/tests/graph/utils.rs | 13 +- crates/but-graph/tests/graph/vis.rs | 53 ++--- crates/but-workspace/src/branch.rs | 7 +- crates/but-workspace/src/ref_info.rs | 234 ++++++++++++++++++++++- crates/but-workspace/src/stacks.rs | 12 +- 11 files changed, 322 insertions(+), 282 deletions(-) diff --git a/crates/but-graph/src/api.rs b/crates/but-graph/src/api.rs index c8d6e6ef5a..aa10ea600d 100644 --- a/crates/but-graph/src/api.rs +++ b/crates/but-graph/src/api.rs @@ -13,7 +13,7 @@ impl Graph { /// Insert `segment` to the graph so that it's not connected to any other segment, and return its index. pub fn insert_root(&mut self, segment: Segment) -> SegmentIndex { let index = self.inner.add_node(segment); - self.inner[index].id = index.index(); + self.inner[index].id = index; if self.entrypoint.is_none() { self.entrypoint = Some((index, None)) } @@ -41,7 +41,7 @@ impl Graph { dst_commit_id: impl Into>, ) -> SegmentIndex { let dst = self.inner.add_node(dst); - self.inner[dst].id = dst.index(); + self.inner[dst].id = dst; self.connect_segments_with_ids( src, src_commit, @@ -196,17 +196,15 @@ impl Graph { } /// Produce a string that concisely represents `commit`, adding `extra` information as needed. - pub fn commit_debug_string<'a>( + pub fn commit_debug_string( commit: &crate::Commit, - extra: impl Into>, has_conflicts: bool, is_entrypoint: bool, show_message: bool, is_early_end: bool, ) -> String { - let extra = extra.into(); format!( - "{ep}{end}{kind}{conflict}{hex}{extra}{flags}{msg}{refs}", + "{ep}{end}{kind}{conflict}{hex}{flags}{msg}{refs}", ep = if is_entrypoint { "πŸ‘‰" } else { "" }, end = if is_early_end { "βœ‚οΈ" } else { "" }, kind = if commit.flags.contains(CommitFlags::NotInRemote) { @@ -215,11 +213,6 @@ impl Graph { "🟣" }, conflict = if has_conflicts { "πŸ’₯" } else { "" }, - extra = if let Some(extra) = extra { - format!(" [{extra}]") - } else { - "".into() - }, flags = if !commit.flags.is_empty() { format!(" ({})", commit.flags.debug_string()) } else { @@ -264,6 +257,7 @@ impl Graph { /// Validate the graph for consistency and fail loudly when an issue was found, after printing the dot graph. /// Mostly useful for debugging to stop early when a connection wasn't created correctly. + #[cfg(target_os = "macos")] pub fn validated_or_open_as_svg(self) -> anyhow::Result { for edge in self.inner.edge_references() { let res = check_edge(&self.inner, edge); @@ -370,8 +364,7 @@ impl Graph { .enumerate() .map(|(cidx, c)| { Self::commit_debug_string( - &c.inner, - None, + c, c.has_conflicts, !show_segment_entrypoint && Some((sidx, Some(cidx))) == entrypoint, false, diff --git a/crates/but-graph/src/init/mod.rs b/crates/but-graph/src/init/mod.rs index 862c074566..8d4a1a37b0 100644 --- a/crates/but-graph/src/init/mod.rs +++ b/crates/but-graph/src/init/mod.rs @@ -367,7 +367,7 @@ impl Graph { let refs_at_commit_before_removal = refs_by_id.remove(&id).unwrap_or_default(); segment.commits.push( - info.into_local_commit( + info.into_commit( repo, segment .commits diff --git a/crates/but-graph/src/init/utils.rs b/crates/but-graph/src/init/utils.rs index b487914083..1b5b4b743e 100644 --- a/crates/but-graph/src/init/utils.rs +++ b/crates/but-graph/src/init/utils.rs @@ -1,8 +1,8 @@ use crate::init::walk::TopoWalk; use crate::init::{EdgeOwned, Instruction, PetGraph, QueueItem, remotes}; use crate::{ - Commit, CommitFlags, CommitIndex, Edge, Graph, LocalCommit, Segment, SegmentIndex, - SegmentMetadata, is_workspace_ref_name, + Commit, CommitFlags, CommitIndex, Edge, Graph, Segment, SegmentIndex, SegmentMetadata, + is_workspace_ref_name, }; use anyhow::{Context, bail}; use bstr::BString; @@ -308,15 +308,15 @@ pub struct TraverseInfo { } impl TraverseInfo { - pub fn into_local_commit( + pub fn into_commit( self, repo: &gix::Repository, flags: CommitFlags, refs: Vec, - ) -> anyhow::Result { + ) -> anyhow::Result { let commit = but_core::Commit::from_id(self.id.attach(repo))?; let has_conflicts = commit.is_conflicted(); - let commit = match self.commit { + Ok(match self.commit { Some(commit) => Commit { refs, flags, @@ -329,13 +329,8 @@ impl TraverseInfo { author: commit.author.clone(), flags, refs, + has_conflicts, }, - }; - - Ok(LocalCommit { - inner: commit, - relation: Default::default(), - has_conflicts, }) } } @@ -394,6 +389,8 @@ pub fn find( author: author.context("Every valid commit must have an author signature")?, refs: Vec::new(), flags: CommitFlags::empty(), + // TODO: we probably should set this + has_conflicts: false, }) } }; diff --git a/crates/but-graph/src/lib.rs b/crates/but-graph/src/lib.rs index 49531c7ecb..342b763a78 100644 --- a/crates/but-graph/src/lib.rs +++ b/crates/but-graph/src/lib.rs @@ -3,9 +3,7 @@ #![deny(missing_docs, rust_2018_idioms)] mod segment; -pub use segment::{ - Commit, CommitFlags, LocalCommit, LocalCommitRelation, RemoteCommit, Segment, SegmentMetadata, -}; +pub use segment::{Commit, CommitFlags, Segment, SegmentMetadata}; /// Edges to other segments are the index into the list of local commits of the parent segment. /// That way we can tell where a segment branches off, despite the graph only connecting segments, and not commits. @@ -33,7 +31,7 @@ pub struct EntryPoint<'graph> { /// The segment that served starting point for the traversal into this graph. pub segment: &'graph Segment, /// If present, the commit that started the traversal in the `segment`. - pub commit: Option<&'graph LocalCommit>, + pub commit: Option<&'graph Commit>, } /// This structure is used as data associated with each edge and is mainly for collecting diff --git a/crates/but-graph/src/segment.rs b/crates/but-graph/src/segment.rs index b7130935f9..daba16c183 100644 --- a/crates/but-graph/src/segment.rs +++ b/crates/but-graph/src/segment.rs @@ -1,7 +1,6 @@ -use crate::CommitIndex; +use crate::{CommitIndex, SegmentIndex}; use bitflags::bitflags; use gix::bstr::BString; -use std::ops::{Deref, DerefMut}; /// A commit with must useful information extracted from the Git commit itself. /// @@ -21,12 +20,21 @@ pub struct Commit { pub refs: Vec, /// Additional properties to help classify this commit. pub flags: CommitFlags, - // TODO: bring has_conflict: bool here, then remove `RemoteCommit` type. + /// Whether the commit is in a conflicted state, a GitButler concept. + /// GitButler will perform rebasing/reordering etc. without interruptions and flag commits as conflicted if needed. + /// Conflicts are resolved via the Edit Mode mechanism. + /// + /// Note that even though GitButler won't push branches with conflicts, the user can still push such branches at will. + pub has_conflicts: bool, } impl Commit { /// Read the object of the `commit_id` and extract relevant values, while setting `flags` as well. - pub fn new_from_id(commit_id: gix::Id<'_>, flags: CommitFlags) -> anyhow::Result { + pub fn new_from_id( + commit_id: gix::Id<'_>, + flags: CommitFlags, + has_conflicts: bool, + ) -> anyhow::Result { let commit = commit_id.object()?.into_commit(); // Decode efficiently, no need to own this. let commit = commit.decode()?; @@ -37,6 +45,7 @@ impl Commit { author: commit.author.to_owned()?, refs: Vec::new(), flags, + has_conflicts, }) } } @@ -66,6 +75,7 @@ impl From> for Commit { author: value.inner.author, refs: Vec::new(), flags: CommitFlags::empty(), + has_conflicts: false, } } } @@ -109,152 +119,6 @@ impl CommitFlags { } } -/// A commit that is reachable through the *local tracking branch*, with additional, computed information. -#[derive(Clone, Eq, PartialEq)] -pub struct LocalCommit { - /// The simple commit. - pub inner: Commit, - /// Provide additional information on how this commit relates to other points of reference, like its remote branch, - /// or the target branch to integrate with. - pub relation: LocalCommitRelation, - /// Whether the commit is in a conflicted state, a GitButler concept. - /// GitButler will perform rebasing/reordering etc. without interruptions and flag commits as conflicted if needed. - /// Conflicts are resolved via the Edit Mode mechanism. - /// - /// Note that even though GitButler won't push branches with conflicts, the user can still push such branches at will. - pub has_conflicts: bool, -} - -impl std::fmt::Debug for LocalCommit { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let refs = self - .refs - .iter() - .map(|rn| format!("β–Ί{}", rn.shorten())) - .collect::>() - .join(", "); - write!( - f, - "LocalCommit({conflict}{hash}, {msg:?}, {relation}{refs})", - conflict = if self.has_conflicts { "πŸ’₯" } else { "" }, - hash = self.id.to_hex_with_len(7), - msg = self.message, - relation = self.relation.display(self.id), - refs = if refs.is_empty() { - "".to_string() - } else { - format!(", {refs}") - } - ) - } -} - -impl LocalCommit { - /// Create a new branch-commit, along with default values for the non-commit fields. - // TODO: remove this function once ref_info code doesn't need it anymore (i.e. mapping is implemented). - pub fn new_from_id(value: gix::Id<'_>, flags: CommitFlags) -> anyhow::Result { - Ok(LocalCommit { - inner: Commit::new_from_id(value, flags)?, - relation: LocalCommitRelation::LocalOnly, - has_conflicts: false, - }) - } -} - -/// The state of the [local commit](LocalCommit) in relation to its remote tracking branch or its integration branch. -#[derive(Default, Debug, Eq, PartialEq, Clone, Copy)] -pub enum LocalCommitRelation { - /// The commit is only local - #[default] - LocalOnly, - /// The commit is also present in the remote tracking branch. - /// - /// This is the case if: - /// - The commit has been pushed to the remote - /// - The commit has been copied from a remote commit (when applying a remote branch) - /// - /// This variant carries the remote commit id. - /// The `remote_commit_id` may be the same as the `id` or it may be different if the local commit has been rebased - /// or updated in another way. - LocalAndRemote(gix::ObjectId), - /// The commit is considered integrated. - /// This should happen when the commit or the contents of this commit is already part of the base. - Integrated, -} - -impl LocalCommitRelation { - /// Convert this relation into something displaying, mainly for debugging. - pub fn display(&self, id: gix::ObjectId) -> &'static str { - match self { - LocalCommitRelation::LocalOnly => "local", - LocalCommitRelation::LocalAndRemote(remote_id) => { - if *remote_id == id { - "local/remote(identity)" - } else { - "local/remote(similarity)" - } - } - LocalCommitRelation::Integrated => "integrated", - } - } -} - -impl Deref for LocalCommit { - type Target = Commit; - - fn deref(&self) -> &Self::Target { - &self.inner - } -} - -impl DerefMut for LocalCommit { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.inner - } -} - -/// A commit that is reachable only through the *remote tracking branch*, with additional, computed information. -/// -/// TODO: Remote commits can also be integrated, without the local branch being all caught up. Currently we can't represent that. -#[derive(Clone, Eq, PartialEq)] -pub struct RemoteCommit { - /// The simple commit. - pub inner: Commit, - /// Whether the commit is in a conflicted state, a GitButler concept. - /// GitButler will perform rebasing/reordering etc. without interruptions and flag commits as conflicted if needed. - /// Conflicts are resolved via the Edit Mode mechanism. - /// - /// Note that even though GitButler won't push branches with conflicts, the user can still push such branches at will. - /// For remote commits, this only happens if someone manually pushed them. - pub has_conflicts: bool, -} - -impl std::fmt::Debug for RemoteCommit { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "RemoteCommit({conflict}{hash}, {msg:?}", - conflict = if self.has_conflicts { "πŸ’₯" } else { "" }, - hash = self.id.to_hex_with_len(7), - msg = self.message, - ) - } -} - -impl Deref for RemoteCommit { - type Target = Commit; - - fn deref(&self) -> &Self::Target { - &self.inner - } -} - -impl DerefMut for RemoteCommit { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.inner - } -} - /// A segment of a commit graph, representing a set of commits exclusively. #[derive(Default, Clone, Eq, PartialEq)] pub struct Segment { @@ -269,7 +133,7 @@ pub struct Segment { pub ref_name: Option, /// An ID which can uniquely identify this segment among all segments within the graph that owned it. /// Note that it's not suitable to permanently identify the segment, so should not be persisted. - pub id: usize, + pub id: SegmentIndex, /// The name of the remote tracking branch of this segment, if present, i.e. `refs/remotes/origin/main`. /// Its presence means that a remote is configured and that the stack content pub remote_tracking_ref_name: Option, @@ -277,16 +141,7 @@ pub struct Segment { /// for that stack segment and not included in any other stack or stack segment. /// /// The list could be empty for when this is a dedicated empty segment as insertion position of commits. - pub commits: Vec, - /// Commits that are reachable from the remote-tracking branch associated with this branch, - /// but are not reachable from this branch or duplicated by a commit in it. - /// Note that commits that are also similar to commits in `commits` are pruned, and not present here. - /// - /// Note that remote commits along with their remote tracking branch should always retain a shared history - /// with the local tracking branch. If these diverge, we can represent this in data, but currently there is - /// no derived value to make this visible explicitly. - // TODO: remove this in favor of having a UI-only variant of the segment that contains these. - pub commits_unique_in_remote_tracking_branch: Vec, + pub commits: Vec, /// Read-only metadata with additional information, or `None` if nothing was present. pub metadata: Option, } @@ -347,7 +202,6 @@ impl std::fmt::Debug for Segment { ref_name, id, commits, - commits_unique_in_remote_tracking_branch, remote_tracking_ref_name, metadata, } = self; @@ -368,10 +222,6 @@ impl std::fmt::Debug for Segment { }, ) .field("commits", &commits) - .field( - "commits_unique_in_remote_tracking_branch", - &commits_unique_in_remote_tracking_branch, - ) .field( "metadata", match metadata { diff --git a/crates/but-graph/tests/graph/init/mod.rs b/crates/but-graph/tests/graph/init/mod.rs index 99723af059..cde27ddc8c 100644 --- a/crates/but-graph/tests/graph/init/mod.rs +++ b/crates/but-graph/tests/graph/init/mod.rs @@ -9,31 +9,30 @@ fn unborn() -> anyhow::Result<()> { let graph = Graph::from_head(&repo, &*meta, standard_options())?; insta::assert_snapshot!(graph_tree(&graph), @"└── πŸ‘‰β–Ί:0:main"); insta::assert_debug_snapshot!(graph, @r#" - Graph { - inner: Graph { - Ty: "Directed", - node_count: 1, - edge_count: 0, - node weights: { - 0: StackSegment { - id: 0, - ref_name: "refs/heads/main", - remote_tracking_ref_name: "None", - commits: [], - commits_unique_in_remote_tracking_branch: [], - metadata: "None", - }, - }, - edge weights: {}, + Graph { + inner: Graph { + Ty: "Directed", + node_count: 1, + edge_count: 0, + node weights: { + 0: StackSegment { + id: NodeIndex(0), + ref_name: "refs/heads/main", + remote_tracking_ref_name: "None", + commits: [], + metadata: "None", }, - entrypoint: Some( - ( - NodeIndex(0), - None, - ), - ), - } - "#); + }, + edge weights: {}, + }, + entrypoint: Some( + ( + NodeIndex(0), + None, + ), + ), + } + "#); Ok(()) } @@ -61,23 +60,21 @@ fn detached() -> anyhow::Result<()> { edges: (0, 1), node weights: { 0: StackSegment { - id: 0, + id: NodeIndex(0), ref_name: "refs/heads/main", remote_tracking_ref_name: "None", commits: [ - LocalCommit(541396b, "first\n", local, β–Ίannotated, β–Ίrelease/v1), + Commit(541396b, "first\n", βŒ‚), ], - commits_unique_in_remote_tracking_branch: [], metadata: "None", }, 1: StackSegment { - id: 1, + id: NodeIndex(1), ref_name: "refs/heads/other", remote_tracking_ref_name: "None", commits: [ - LocalCommit(fafd9d0, "init\n", local), + Commit(fafd9d0, "init\n", βŒ‚), ], - commits_unique_in_remote_tracking_branch: [], metadata: "None", }, }, diff --git a/crates/but-graph/tests/graph/utils.rs b/crates/but-graph/tests/graph/utils.rs index 2e8ebfed93..0842630001 100644 --- a/crates/but-graph/tests/graph/utils.rs +++ b/crates/but-graph/tests/graph/utils.rs @@ -1,4 +1,4 @@ -use but_graph::{EntryPoint, Graph, LocalCommitRelation, SegmentIndex}; +use but_graph::{EntryPoint, Graph, SegmentIndex}; use std::collections::{BTreeMap, BTreeSet}; use termtree::Tree; @@ -6,16 +6,14 @@ type SegmentTree = Tree; /// Visualize `graph` as a tree. pub fn graph_tree(graph: &but_graph::Graph) -> SegmentTree { - fn tree_for_commit<'a>( + fn tree_for_commit( commit: &but_graph::Commit, - extra: impl Into>, has_conflicts: bool, is_entrypoint: bool, is_early_end: bool, ) -> SegmentTree { Graph::commit_debug_string( commit, - extra, has_conflicts, is_entrypoint, true, /* show message */ @@ -61,7 +59,7 @@ pub fn graph_tree(graph: &but_graph::Graph) -> SegmentTree { let mut root = Tree::new(format!( "{entrypoint}{kind}:{id}:{ref_name}{remote}", - id = segment.id, + id = segment.id.index(), kind = if segment.workspace_metadata().is_some() { "β–Ίβ–Ίβ–Ί" } else { @@ -93,11 +91,6 @@ pub fn graph_tree(graph: &but_graph::Graph) -> SegmentTree { for (cidx, commit) in segment.commits.iter().enumerate() { let mut commit_tree = tree_for_commit( commit, - if commit.relation == LocalCommitRelation::LocalOnly { - None - } else { - Some(commit.relation.display(commit.id)) - }, commit.has_conflicts, segment_is_entrypoint && Some(cidx) == ep.commit_index, graph.is_early_end_of_traversal(sidx, cidx), diff --git a/crates/but-graph/tests/graph/vis.rs b/crates/but-graph/tests/graph/vis.rs index 04b7d63c3b..9cb7a4978b 100644 --- a/crates/but-graph/tests/graph/vis.rs +++ b/crates/but-graph/tests/graph/vis.rs @@ -2,7 +2,7 @@ use crate::graph_tree; use but_core::ref_metadata; -use but_graph::{CommitFlags, Graph, LocalCommit, Segment, SegmentMetadata}; +use but_graph::{Commit, CommitFlags, Graph, Segment, SegmentMetadata}; /// Simulate a graph data structure after the first pass, i.e., right after the walk. /// There is no pruning of 'empty' branches, just a perfect representation of the graph as is, @@ -14,7 +14,7 @@ fn post_graph_traversal() -> anyhow::Result<()> { // The local target branch sets right at the base and typically doesn't have commits, // these are in the segments above it. let local_target = Segment { - id: 0, + id: 0.into(), ref_name: Some("refs/heads/main".try_into()?), remote_tracking_ref_name: Some("refs/remotes/origin/main".try_into()?), metadata: Some(SegmentMetadata::Workspace(ref_metadata::Workspace { @@ -32,7 +32,7 @@ fn post_graph_traversal() -> anyhow::Result<()> { None, // A newly created branch which appears at the workspace base. Segment { - id: 1, + id: 1.into(), ref_name: Some("refs/heads/new-stack".try_into()?), ..Default::default() }, @@ -41,55 +41,48 @@ fn post_graph_traversal() -> anyhow::Result<()> { ); let remote_to_local_target = Segment { - id: 2, + id: 2.into(), ref_name: Some("refs/remotes/origin/main".try_into()?), - commits: vec![local_commit(commit( + commits: vec![commit( id("c"), "remote: on top of main", Some(init_commit_id), CommitFlags::empty(), - ))], + )], ..Default::default() }; graph.connect_new_segment(local_target, None, remote_to_local_target, 0, None); let branch = Segment { - id: 3, + id: 3.into(), ref_name: Some("refs/heads/A".try_into()?), remote_tracking_ref_name: Some("refs/remotes/origin/A".try_into()?), commits: vec![ - LocalCommit { + Commit { has_conflicts: true, - ..local_commit(commit( + ..commit( id("a"), "2 in A", Some(init_commit_id), CommitFlags::InWorkspace, - )) + ) }, - local_commit(commit( - init_commit_id, - "1 in A", - None, - CommitFlags::InWorkspace, - )), + commit(init_commit_id, "1 in A", None, CommitFlags::InWorkspace), ], - // Empty as we didn't process commits yet, right after graph traversal - commits_unique_in_remote_tracking_branch: vec![], metadata: None, }; let branch = graph.connect_new_segment(local_target, None, branch, 0, None); let remote_to_root_branch = Segment { - id: 4, + id: 4.into(), ref_name: Some("refs/remotes/origin/A".try_into()?), commits: vec![ - local_commit(commit( + commit( id("b"), "remote: on top of 1A", Some(init_commit_id), CommitFlags::empty(), - )), + ), // Note that the initial commit was assigned to the base segment already, // and we are connected to it. // This also means that local branches absorb commits preferably and that commit-traversal @@ -118,12 +111,7 @@ fn post_graph_traversal() -> anyhow::Result<()> { fn detached_head() { let mut graph = Graph::default(); graph.insert_root(Segment { - commits: vec![local_commit(commit( - id("a"), - "init", - None, - CommitFlags::empty(), - ))], + commits: vec![commit(id("a"), "init", None, CommitFlags::empty())], ..Default::default() }); insta::assert_snapshot!(graph_tree(&graph), @r#" @@ -138,7 +126,7 @@ fn unborn_head() { } mod utils { - use but_graph::{Commit, CommitFlags, LocalCommit}; + use but_graph::{Commit, CommitFlags}; use gix::ObjectId; use std::str::FromStr; @@ -155,13 +143,6 @@ mod utils { author: author(), refs: Vec::new(), flags, - } - } - - pub fn local_commit(commit: Commit) -> LocalCommit { - LocalCommit { - inner: commit, - relation: Default::default(), has_conflicts: false, } } @@ -188,4 +169,4 @@ mod utils { } } } -use utils::{commit, id, local_commit}; +use utils::{commit, id}; diff --git a/crates/but-workspace/src/branch.rs b/crates/but-workspace/src/branch.rs index 82754b6c43..f1d3e0ef17 100644 --- a/crates/but-workspace/src/branch.rs +++ b/crates/but-workspace/src/branch.rs @@ -412,10 +412,9 @@ //! walk along. //! ``` -use crate::StashStatus; +use crate::{StashStatus, ref_info}; use anyhow::{Context, bail}; use but_core::RefMetadata; -use but_graph::Segment; use gix::prelude::ObjectIdExt; /// The result of [`add_branch_to_workspace`]. @@ -501,7 +500,7 @@ pub struct Stack { pub base: Option, /// The branch-name denoted segments of the stack from its tip to the point of reference, typically a merge-base. /// This array is never empty. - pub segments: Vec, + pub segments: Vec, /// Additional information about possibly still available stashes, sitting on top of this stack. /// /// This means the stash is still there to be applied, something that can happen if the user switches branches @@ -535,6 +534,6 @@ impl Stack { } /// Return all stack segments within the given `stack`. -pub fn stack_segments(stack: Stack) -> anyhow::Result> { +pub fn stack_segments(stack: Stack) -> anyhow::Result> { todo!() } diff --git a/crates/but-workspace/src/ref_info.rs b/crates/but-workspace/src/ref_info.rs index 4d5bb836df..ee20c649e8 100644 --- a/crates/but-workspace/src/ref_info.rs +++ b/crates/but-workspace/src/ref_info.rs @@ -21,14 +21,246 @@ pub struct Options { pub expensive_commit_info: bool, } +/// Types driven by the user interface, not general purpose. +pub mod ui { + use but_graph::{Commit, CommitFlags, SegmentMetadata}; + use std::ops::{Deref, DerefMut}; + + /// A commit that is reachable through the *local tracking branch*, with additional, computed information. + #[derive(Clone, Eq, PartialEq)] + pub struct LocalCommit { + /// The simple commit. + pub inner: Commit, + /// Provide additional information on how this commit relates to other points of reference, like its remote branch, + /// or the target branch to integrate with. + pub relation: LocalCommitRelation, + } + + impl std::fmt::Debug for LocalCommit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let refs = self + .refs + .iter() + .map(|rn| format!("β–Ί{}", rn.shorten())) + .collect::>() + .join(", "); + write!( + f, + "LocalCommit({conflict}{hash}, {msg:?}, {relation}{refs})", + conflict = if self.has_conflicts { "πŸ’₯" } else { "" }, + hash = self.id.to_hex_with_len(7), + msg = self.message, + relation = self.relation.display(self.id), + refs = if refs.is_empty() { + "".to_string() + } else { + format!(", {refs}") + } + ) + } + } + + impl LocalCommit { + /// Create a new branch-commit, along with default values for the non-commit fields. + // TODO: remove this function once ref_info code doesn't need it anymore (i.e. mapping is implemented). + pub fn new_from_id(value: gix::Id<'_>, flags: CommitFlags) -> anyhow::Result { + Ok(LocalCommit { + inner: Commit::new_from_id(value, flags, false)?, + relation: LocalCommitRelation::LocalOnly, + }) + } + } + + /// The state of the [local commit](LocalCommit) in relation to its remote tracking branch or its integration branch. + #[derive(Default, Debug, Eq, PartialEq, Clone, Copy)] + pub enum LocalCommitRelation { + /// The commit is only local + #[default] + LocalOnly, + /// The commit is also present in the remote tracking branch. + /// + /// This is the case if: + /// - The commit has been pushed to the remote + /// - The commit has been copied from a remote commit (when applying a remote branch) + /// + /// This variant carries the remote commit id. + /// The `remote_commit_id` may be the same as the `id` or it may be different if the local commit has been rebased + /// or updated in another way. + LocalAndRemote(gix::ObjectId), + /// The commit is considered integrated. + /// This should happen when the commit or the contents of this commit is already part of the base. + Integrated, + } + + impl LocalCommitRelation { + /// Convert this relation into something displaying, mainly for debugging. + pub fn display(&self, id: gix::ObjectId) -> &'static str { + match self { + LocalCommitRelation::LocalOnly => "local", + LocalCommitRelation::LocalAndRemote(remote_id) => { + if *remote_id == id { + "local/remote(identity)" + } else { + "local/remote(similarity)" + } + } + LocalCommitRelation::Integrated => "integrated", + } + } + } + + impl Deref for LocalCommit { + type Target = Commit; + + fn deref(&self) -> &Self::Target { + &self.inner + } + } + + impl DerefMut for LocalCommit { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } + } + + /// A commit that is reachable only through the *remote tracking branch*, with additional, computed information. + /// + /// TODO: Remote commits can also be integrated, without the local branch being all caught up. Currently we can't represent that. + #[derive(Clone, Eq, PartialEq)] + pub struct RemoteCommit { + /// The simple commit. + pub inner: Commit, + /// Whether the commit is in a conflicted state, a GitButler concept. + /// GitButler will perform rebasing/reordering etc. without interruptions and flag commits as conflicted if needed. + /// Conflicts are resolved via the Edit Mode mechanism. + /// + /// Note that even though GitButler won't push branches with conflicts, the user can still push such branches at will. + /// For remote commits, this only happens if someone manually pushed them. + pub has_conflicts: bool, + } + + impl std::fmt::Debug for RemoteCommit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "RemoteCommit({conflict}{hash}, {msg:?}", + conflict = if self.has_conflicts { "πŸ’₯" } else { "" }, + hash = self.id.to_hex_with_len(7), + msg = self.message, + ) + } + } + + impl Deref for RemoteCommit { + type Target = Commit; + + fn deref(&self) -> &Self::Target { + &self.inner + } + } + + impl DerefMut for RemoteCommit { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } + } + + /// A segment of a commit graph, representing a set of commits exclusively. + #[derive(Default, Clone, Eq, PartialEq)] + pub struct Segment { + /// The unambiguous or disambiguated name of the branch at the tip of the segment, i.e. at the first commit. + /// + /// It is `None` if this branch is the top-most stack segment and the `ref_name` wasn't pointing to + /// a commit anymore that was reached by our rev-walk. + /// This can happen if the ref is deleted, or if it was advanced by other means. + /// Alternatively, the naming would have been ambiguous. + /// Finally, this is `None` of the original name can be found searching upwards, finding exactly one + /// named segment. + pub ref_name: Option, + /// An ID which can uniquely identify this segment among all segments within the graph that owned it. + /// Note that it's not suitable to permanently identify the segment, so should not be persisted. + pub id: usize, + /// The name of the remote tracking branch of this segment, if present, i.e. `refs/remotes/origin/main`. + /// Its presence means that a remote is configured and that the stack content + pub remote_tracking_ref_name: Option, + /// The portion of commits that can be reached from the tip of the *branch* downwards, so that they are unique + /// for that stack segment and not included in any other stack or stack segment. + /// + /// The list could be empty for when this is a dedicated empty segment as insertion position of commits. + pub commits: Vec, + /// Commits that are reachable from the remote-tracking branch associated with this branch, + /// but are not reachable from this branch or duplicated by a commit in it. + /// Note that commits that are also similar to commits in `commits` are pruned, and not present here. + /// + /// Note that remote commits along with their remote tracking branch should always retain a shared history + /// with the local tracking branch. If these diverge, we can represent this in data, but currently there is + /// no derived value to make this visible explicitly. + pub commits_unique_in_remote_tracking_branch: Vec, + /// Read-only metadata with additional information, or `None` if nothing was present. + pub metadata: Option, + } + + /// Direct Access (without graph) + impl Segment { + /// Return the top-most commit id of the segment. + pub fn tip(&self) -> Option { + self.commits.first().map(|commit| commit.id) + } + } + + impl std::fmt::Debug for Segment { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Segment { + ref_name, + id, + commits, + commits_unique_in_remote_tracking_branch, + remote_tracking_ref_name, + metadata, + } = self; + f.debug_struct("StackSegment") + .field("id", &id) + .field( + "ref_name", + &match ref_name.as_ref() { + None => "None".to_string(), + Some(name) => name.to_string(), + }, + ) + .field( + "remote_tracking_ref_name", + &match remote_tracking_ref_name.as_ref() { + None => "None".to_string(), + Some(name) => name.to_string(), + }, + ) + .field("commits", &commits) + .field( + "commits_unique_in_remote_tracking_branch", + &commits_unique_in_remote_tracking_branch, + ) + .field( + "metadata", + match metadata { + None => &"None", + Some(SegmentMetadata::Branch(m)) => m, + Some(SegmentMetadata::Workspace(m)) => m, + }, + ) + .finish() + } + } +} + pub(crate) mod function { + use super::ui::{LocalCommit, LocalCommitRelation, RemoteCommit, Segment}; use crate::branch::Stack; use crate::integrated::{IsCommitIntegrated, MergeBaseCommitGraph}; use crate::{RefInfo, WorkspaceCommit}; use anyhow::bail; use bstr::BString; use but_core::ref_metadata::{ValueInfo, Workspace, WorkspaceStack}; - use but_graph::{CommitFlags, LocalCommit, LocalCommitRelation, RemoteCommit, Segment}; + use but_graph::CommitFlags; use but_graph::{SegmentMetadata, is_workspace_ref_name}; use gix::prelude::{ObjectIdExt, ReferenceExt}; use gix::refs::{Category, FullName}; diff --git a/crates/but-workspace/src/stacks.rs b/crates/but-workspace/src/stacks.rs index 5c7db7a017..5586f3ad87 100644 --- a/crates/but-workspace/src/stacks.rs +++ b/crates/but-workspace/src/stacks.rs @@ -1,4 +1,6 @@ use crate::integrated::IsCommitIntegrated; +use crate::ref_info::ui::Segment; +use crate::ref_info::ui::{LocalCommit, LocalCommitRelation, RemoteCommit}; use crate::ui::{CommitState, PushStatus, StackDetails}; use crate::{ RefInfo, StacksFilter, branch, head_info, id_from_name_v2_to_v3, ref_info, state_handle, ui, @@ -6,10 +8,7 @@ use crate::{ use anyhow::Context; use bstr::BString; use but_core::RefMetadata; -use but_graph::{ - Commit, LocalCommit, LocalCommitRelation, RemoteCommit, Segment, SegmentMetadata, - VirtualBranchesTomlMetadata, -}; +use but_graph::{Commit, SegmentMetadata, VirtualBranchesTomlMetadata}; use gitbutler_command_context::CommandContext; use gitbutler_commit::commit_ext::CommitExt; use gitbutler_oxidize::{ObjectIdExt, OidExt, git2_signature_to_gix_signature}; @@ -564,8 +563,9 @@ impl From<&RemoteCommit> for ui::UpstreamCommit { refs: _, // TODO: also pass flags for the frontend. flags: _, + // TODO: Represent this in the UI (maybe) and/or deal with divergence of the local and remote tracking branch. + has_conflicts: _, }, - // TODO: Represent this in the UI (maybe) and/or deal with divergence of the local and remote tracking branch. has_conflicts: _, }: &RemoteCommit, ) -> Self { @@ -593,9 +593,9 @@ impl From<&LocalCommit> for ui::Commit { refs: _, // TODO: also flags refs flags: _, + has_conflicts, }, relation, - has_conflicts, }: &LocalCommit, ) -> Self { ui::Commit { From 9f21f99c278bb343bcde01b4f715ac9b77d8b3c2 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Wed, 18 Jun 2025 15:23:15 +0200 Subject: [PATCH 3/8] Add non-workspace commit limit to deal with graphs Also provide means to obtain next chunks akin to pages. --- crates/but-graph/src/init/mod.rs | 77 +++++++++- crates/but-graph/src/init/utils.rs | 34 ++++- crates/but-graph/tests/fixtures/scenarios.sh | 27 ++++ crates/but-graph/tests/graph/init/mod.rs | 137 +++++++++++++++++- crates/but-graph/tests/graph/init/utils.rs | 11 +- .../tests/graph/init/with_workspace.rs | 122 +++++++++++----- 6 files changed, 360 insertions(+), 48 deletions(-) diff --git a/crates/but-graph/src/init/mod.rs b/crates/but-graph/src/init/mod.rs index 8d4a1a37b0..d21957c99f 100644 --- a/crates/but-graph/src/init/mod.rs +++ b/crates/but-graph/src/init/mod.rs @@ -21,12 +21,56 @@ mod walk; pub(super) type PetGraph = petgraph::Graph; /// Options for use in [`Graph::from_head()`] and [`Graph::from_commit_traversal()`]. -#[derive(Default, Debug, Copy, Clone)] +#[derive(Default, Debug, Clone)] pub struct Options { /// Associate tag references with commits. /// /// If `false`, tags are not collected. pub collect_tags: bool, + /// The maximum number of commits we should traverse outside any workspace *with a target branch*. + /// Workspaces with a target branch automatically have unlimited traversals as they rely on the target + /// branch to eventually stop the traversal. + /// + /// If `None`, there is no limit, which typically means that when lacking a workspace, the traversal + /// will end only when no commit is left to traverse. + /// `Some(0)` means nothing is going to be returned. + /// + /// Note that this doesn't affect the traversal of integrated commits, which is always stopped once there + /// is nothing interesting left to traverse. + /// + /// Also note: This is not a perfectly exact measure, and it's always possible to receive a few more commits + /// than the maximum as for simplicity, we assign each 'split' the same limit, effectively doubling it. + /// + /// ### Tip Configuration + /// + /// * HEAD - uses the limit + /// * workspaces with target branch - no limit, but auto-stop if workspace is exhausted as everything is integrated. + /// - The target branch: no limit + /// * workspace without target branch - uses the limit + /// * remotes tracking branches - use the limit + pub max_commits_outside_of_workspace: Option, + /// A list of the last commits of partial segments previously returned that reset the amount of available + /// commits to traverse back to `max_commits_outside_of_workspace`. + /// Imagine it like a gas station that can be chosen to direct where the commit-budge should be spent. + pub max_commits_recharge_location: Vec, +} + +/// Builder +impl Options { + /// Set the maximum amount of commits that each lane in a tip may traverse. + pub fn with_limit(mut self, limit: usize) -> Self { + self.max_commits_outside_of_workspace = Some(limit); + self + } + + /// Keep track of commits at which the traversal limit should be reset to the [`limit`](Self::with_limit()). + pub fn with_limit_extension_at( + mut self, + commits: impl IntoIterator, + ) -> Self { + self.max_commits_recharge_location.extend(commits); + self + } } /// Lifecycle @@ -106,7 +150,11 @@ impl Graph { tip: gix::Id<'_>, ref_name: impl Into>, meta: &impl RefMetadata, - Options { collect_tags }: Options, + Options { + collect_tags, + max_commits_outside_of_workspace: limit, + mut max_commits_recharge_location, + }: Options, ) -> anyhow::Result { // TODO: also traverse (outside)-branches that ought to be in the workspace. That way we have the desired ones // automatically and just have to find a way to prune the undesired ones. @@ -168,6 +216,7 @@ impl Graph { tip.detach(), tip_flags, Instruction::CollectCommit { into: current }, + limit, )); } for (ws_ref, workspace_info) in workspaces { @@ -198,6 +247,12 @@ impl Graph { CommitFlags::empty() }; let mut ws_segment = branch_segment_from_name_and_meta(Some(ws_ref), meta, None)?; + // Drop the limit if we have a target ref + let limit = if workspace_info.target_ref.is_some() { + None + } else { + limit + }; ws_segment.metadata = Some(SegmentMetadata::Workspace(workspace_info)); let ws_segment = graph.insert_root(ws_segment); // As workspaces typically have integration branches which can help us to stop the traversal, @@ -210,6 +265,7 @@ impl Graph { // their status for now. CommitFlags::NotInRemote | add_flags, Instruction::CollectCommit { into: ws_segment }, + limit, )); if let Some((target_ref, target_ref_id)) = target { let target_segment = graph.insert_root(branch_segment_from_name_and_meta( @@ -223,11 +279,22 @@ impl Graph { Instruction::CollectCommit { into: target_segment, }, + /* unlimited traversal for 'negative' commits */ + None, )); } } - while let Some((id, mut propagated_flags, instruction)) = next.pop_front() { + max_commits_recharge_location.sort(); + // Set max-limit so that we compensate for the way this is counted. + let max_limit = limit.map(|l| l + 1); + while let Some((id, mut propagated_flags, instruction, mut limit)) = next.pop_front() { + if max_commits_recharge_location.binary_search(&id).is_ok() { + limit = max_limit; + } + if limit.is_some_and(|l| l == 0) { + continue; + } let info = find(commit_graph.as_ref(), repo, id, &mut buf)?; let src_flags = graph[instruction.segment_idx()] .commits @@ -363,6 +430,7 @@ impl Graph { propagated_flags, segment_idx_for_id, commit_idx_for_possible_fork, + limit, ); let refs_at_commit_before_removal = refs_by_id.remove(&id).unwrap_or_default(); @@ -392,6 +460,7 @@ impl Graph { &configured_remote_tracking_branches, &target_refs, meta, + limit, )?; prune_integrated_tips(&mut graph.inner, &mut next, &desired_refs); @@ -442,7 +511,7 @@ impl Instruction { } } -type QueueItem = (ObjectId, CommitFlags, Instruction); +type QueueItem = (ObjectId, CommitFlags, Instruction, Option); #[derive(Debug)] pub(crate) struct EdgeOwned { diff --git a/crates/but-graph/src/init/utils.rs b/crates/but-graph/src/init/utils.rs index 1b5b4b743e..58bff05ae3 100644 --- a/crates/but-graph/src/init/utils.rs +++ b/crates/but-graph/src/init/utils.rs @@ -150,7 +150,7 @@ pub fn replace_queued_segments( find: SegmentIndex, replace: SegmentIndex, ) { - for instruction_to_replace in queue.iter_mut().map(|(_, _, instruction)| instruction) { + for instruction_to_replace in queue.iter_mut().map(|(_, _, instruction, _)| instruction) { let cmp = instruction_to_replace.segment_idx(); if cmp == find { *instruction_to_replace = instruction_to_replace.with_replaced_sidx(replace); @@ -159,7 +159,7 @@ pub fn replace_queued_segments( } pub fn swap_queued_segments(queue: &mut VecDeque, a: SegmentIndex, b: SegmentIndex) { - for instruction_to_replace in queue.iter_mut().map(|(_, _, instruction)| instruction) { + for instruction_to_replace in queue.iter_mut().map(|(_, _, instruction, _)| instruction) { let cmp = instruction_to_replace.segment_idx(); if cmp == a { *instruction_to_replace = instruction_to_replace.with_replaced_sidx(b); @@ -236,28 +236,47 @@ pub fn try_split_non_empty_segment_at_branch( Ok(Some(segment_below)) } +fn is_exhausted_or_decrement(limit: &mut Option) -> bool { + *limit = match limit { + Some(limit) => { + if *limit == 0 { + return true; + } + Some(*limit - 1) + } + None => None, + }; + false +} + /// Queue the `parent_ids` of the current commit, whose additional information like `current_kind` and `current_index` /// are used. +/// `limit` is used to determine if the tip is NOT supposed to be dropped, with `0` meaning it's depleted. pub fn queue_parents( next: &mut VecDeque, parent_ids: &[gix::ObjectId], flags: CommitFlags, current_sidx: SegmentIndex, current_cidx: CommitIndex, + mut limit: Option, ) { + if is_exhausted_or_decrement(&mut limit) { + return; + } if parent_ids.len() > 1 { let instruction = Instruction::ConnectNewSegment { parent_above: current_sidx, at_commit: current_cidx, }; for pid in parent_ids { - next.push_back((*pid, flags, instruction)) + next.push_back((*pid, flags, instruction, limit)) } } else if !parent_ids.is_empty() { next.push_back(( parent_ids[0], flags, Instruction::CollectCommit { into: current_sidx }, + limit, )); } else { return; @@ -549,6 +568,7 @@ pub fn propagate_flags_downward( /// This eager queuing makes sure that the post-processing doesn't have to traverse again when it creates segments /// that were previously ambiguous. /// If a remote tracking branch is in `target_refs`, we assume it was already scheduled and won't schedule it again. +/// Note that remotes fully obey the limit. #[allow(clippy::too_many_arguments)] pub fn try_queue_remote_tracking_branches( repo: &gix::Repository, @@ -559,7 +579,12 @@ pub fn try_queue_remote_tracking_branches( configured_remote_tracking_branches: &BTreeSet, target_refs: &[gix::refs::FullName], meta: &impl RefMetadata, + limit: Option, ) -> anyhow::Result<()> { + if limit.is_some_and(|l| l == 0) { + return Ok(()); + } + for rn in refs { let Some(remote_tracking_branch) = remotes::lookup_remote_tracking_branch_or_deduce_it( repo, @@ -593,6 +618,7 @@ pub fn try_queue_remote_tracking_branches( Instruction::CollectCommit { into: remote_segment, }, + limit.map(|l| l - 1), )); } Ok(()) @@ -614,7 +640,7 @@ pub fn prune_integrated_tips( if !all_integated { return; } - next.retain(|(_id, _flags, instruction)| { + next.retain(|(_id, _flags, instruction, _limit)| { let sidx = instruction.segment_idx(); let s = &graph[sidx]; let any_segment_ref_is_contained_in_workspace = s diff --git a/crates/but-graph/tests/fixtures/scenarios.sh b/crates/but-graph/tests/fixtures/scenarios.sh index b0687bb1ee..372d7f99c1 100644 --- a/crates/but-graph/tests/fixtures/scenarios.sh +++ b/crates/but-graph/tests/fixtures/scenarios.sh @@ -165,6 +165,30 @@ EOF ) +git init triple-merge +(cd triple-merge + for c in $(seq 5); do + commit "$c" + done + git checkout -b A + git branch B + git branch C + for c in $(seq 3); do + commit "A$c" + done + + git checkout B + for c in $(seq 3); do + commit "B$c" + done + + git checkout C + for c in $(seq 3); do + commit "C$c" + done + git merge A B +) + mkdir ws (cd ws git init single-stack-ambiguous @@ -355,6 +379,9 @@ EOF tick git checkout -b soon-origin-main main git merge --no-ff A + for c in $(seq 2); do + commit "remote-$c" + done setup_remote_tracking soon-origin-main main "move" git checkout gitbutler/workspace ) diff --git a/crates/but-graph/tests/graph/init/mod.rs b/crates/but-graph/tests/graph/init/mod.rs index cde27ddc8c..52dfb9680e 100644 --- a/crates/but-graph/tests/graph/init/mod.rs +++ b/crates/but-graph/tests/graph/init/mod.rs @@ -212,7 +212,7 @@ fn four_diamond() -> anyhow::Result<()> { #[test] fn stacked_rebased_remotes() -> anyhow::Result<()> { - let (repo, mut meta) = read_only_in_memory_scenario("remote-includes-another-remote")?; + let (repo, meta) = read_only_in_memory_scenario("remote-includes-another-remote")?; insta::assert_snapshot!(visualize_commit_graph_all(&repo)?, @r" * 682be32 (origin/B) B * e29c23d (origin/A) A @@ -223,7 +223,6 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { "); // Everything we encounter is checked for remotes. - add_workspace(&mut meta); let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ πŸ‘‰β–Ί:0:B @@ -254,6 +253,140 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { Ok(()) } +#[test] +fn with_limits() -> anyhow::Result<()> { + let (repo, meta) = read_only_in_memory_scenario("triple-merge")?; + insta::assert_snapshot!(visualize_commit_graph_all(&repo)?, @r" + *-. 2a95729 (HEAD -> C) Merge branches 'A' and 'B' into C + |\ \ + | | * 9908c99 (B) B3 + | | * 60d9a56 B2 + | | * 9d171ff B1 + | * | 20a823c (A) A3 + | * | 442a12f A2 + | * | 686706b A1 + | |/ + * | 6861158 C3 + * | 4f1f248 C2 + * | 487ffce C1 + |/ + * edc4dee (main) 5 + * 01d0e1e 4 + * 4b3e5a8 3 + * 34d0715 2 + * eb5f731 1 + "); + + // Without limits + let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; + insta::assert_snapshot!(graph_tree(&graph), @r#" + └── πŸ‘‰β–Ί:0:C + └── Β·2a95729 (βŒ‚)❱"Merge branches \'A\' and \'B\' into C" + β”œβ”€β”€ β–Ί:3:B + β”‚ β”œβ”€β”€ Β·9908c99 (βŒ‚)❱"B3" + β”‚ β”œβ”€β”€ Β·60d9a56 (βŒ‚)❱"B2" + β”‚ └── Β·9d171ff (βŒ‚)❱"B1" + β”‚ └── β–Ί:4:main + β”‚ β”œβ”€β”€ Β·edc4dee (βŒ‚)❱"5" + β”‚ β”œβ”€β”€ Β·01d0e1e (βŒ‚)❱"4" + β”‚ β”œβ”€β”€ Β·4b3e5a8 (βŒ‚)❱"3" + β”‚ β”œβ”€β”€ Β·34d0715 (βŒ‚)❱"2" + β”‚ └── Β·eb5f731 (βŒ‚)❱"1" + β”œβ”€β”€ β–Ί:2:A + β”‚ β”œβ”€β”€ Β·20a823c (βŒ‚)❱"A3" + β”‚ β”œβ”€β”€ Β·442a12f (βŒ‚)❱"A2" + β”‚ └── Β·686706b (βŒ‚)❱"A1" + β”‚ └── β†’:4: (main) + └── β–Ί:1:anon: + β”œβ”€β”€ Β·6861158 (βŒ‚)❱"C3" + β”œβ”€β”€ Β·4f1f248 (βŒ‚)❱"C2" + └── Β·487ffce (βŒ‚)❱"C1" + └── β†’:4: (main) + "#); + + // Just empty starting points. + let graph = Graph::from_head(&repo, &*meta, standard_options().with_limit(0))?.validated()?; + insta::assert_snapshot!(graph_tree(&graph), @"└── πŸ‘‰β–Ί:0:C"); + + // A single commit, the merge commit. + let graph = Graph::from_head(&repo, &*meta, standard_options().with_limit(1))?.validated()?; + insta::assert_snapshot!(graph_tree(&graph), @r#" + └── πŸ‘‰β–Ί:0:C + └── βœ‚οΈΒ·2a95729 (βŒ‚)❱"Merge branches \'A\' and \'B\' into C" + "#); + + // The merge commit, then we witness lane-duplication of the limit so we get more than requested. + let graph = Graph::from_head(&repo, &*meta, standard_options().with_limit(2))?.validated()?; + insta::assert_snapshot!(graph_tree(&graph), @r#" + └── πŸ‘‰β–Ί:0:C + └── Β·2a95729 (βŒ‚)❱"Merge branches \'A\' and \'B\' into C" + β”œβ”€β”€ β–Ί:3:B + β”‚ └── βœ‚οΈΒ·9908c99 (βŒ‚)❱"B3" + β”œβ”€β”€ β–Ί:2:A + β”‚ └── βœ‚οΈΒ·20a823c (βŒ‚)❱"A3" + └── β–Ί:1:anon: + └── βœ‚οΈΒ·6861158 (βŒ‚)❱"C3" + "#); + + // Allow to see more commits just in the middle lane, the limit is reset + // and we see two more. + let id = id_by_rev(&repo, ":/A3"); + let graph = Graph::from_head( + &repo, + &*meta, + standard_options() + .with_limit(2) + .with_limit_extension_at(Some(id.detach())), + )? + .validated()?; + insta::assert_snapshot!(graph_tree(&graph), @r#" + └── πŸ‘‰β–Ί:0:C + └── Β·2a95729 (βŒ‚)❱"Merge branches \'A\' and \'B\' into C" + β”œβ”€β”€ β–Ί:3:B + β”‚ └── βœ‚οΈΒ·9908c99 (βŒ‚)❱"B3" + β”œβ”€β”€ β–Ί:2:A + β”‚ β”œβ”€β”€ Β·20a823c (βŒ‚)❱"A3" + β”‚ β”œβ”€β”€ Β·442a12f (βŒ‚)❱"A2" + β”‚ └── βœ‚οΈΒ·686706b (βŒ‚)❱"A1" + └── β–Ί:1:anon: + └── βœ‚οΈΒ·6861158 (βŒ‚)❱"C3" + "#); + + // Multiple extensions are fine as well. + let id = |rev| id_by_rev(&repo, rev).detach(); + let graph = Graph::from_head( + &repo, + &*meta, + standard_options().with_limit(2).with_limit_extension_at([ + id(":/A3"), + id(":/A1"), + id(":/B3"), + id(":/C3"), + ]), + )? + .validated()?; + insta::assert_snapshot!(graph_tree(&graph), @r#" + └── πŸ‘‰β–Ί:0:C + └── Β·2a95729 (βŒ‚)❱"Merge branches \'A\' and \'B\' into C" + β”œβ”€β”€ β–Ί:3:B + β”‚ β”œβ”€β”€ Β·9908c99 (βŒ‚)❱"B3" + β”‚ β”œβ”€β”€ Β·60d9a56 (βŒ‚)❱"B2" + β”‚ └── βœ‚οΈΒ·9d171ff (βŒ‚)❱"B1" + β”œβ”€β”€ β–Ί:2:A + β”‚ β”œβ”€β”€ Β·20a823c (βŒ‚)❱"A3" + β”‚ β”œβ”€β”€ Β·442a12f (βŒ‚)❱"A2" + β”‚ └── Β·686706b (βŒ‚)❱"A1" + β”‚ └── β–Ί:4:main + β”‚ β”œβ”€β”€ Β·edc4dee (βŒ‚)❱"5" + β”‚ └── βœ‚οΈΒ·01d0e1e (βŒ‚)❱"4" + └── β–Ί:1:anon: + β”œβ”€β”€ Β·6861158 (βŒ‚)❱"C3" + β”œβ”€β”€ Β·4f1f248 (βŒ‚)❱"C2" + └── βœ‚οΈΒ·487ffce (βŒ‚)❱"C1" + "#); + Ok(()) +} + mod with_workspace; mod utils; diff --git a/crates/but-graph/tests/graph/init/utils.rs b/crates/but-graph/tests/graph/init/utils.rs index 6d48aa8e8b..2ea70769eb 100644 --- a/crates/but-graph/tests/graph/init/utils.rs +++ b/crates/but-graph/tests/graph/init/utils.rs @@ -54,6 +54,11 @@ pub fn add_workspace(meta: &mut VirtualBranchesTomlMetadata) { ); } +pub fn add_workspace_without_target(meta: &mut VirtualBranchesTomlMetadata) { + add_workspace(meta); + meta.data_mut().default_target = None; +} + pub fn add_stack( meta: &mut VirtualBranchesTomlMetadata, stack_id: StackId, @@ -127,5 +132,9 @@ pub fn id_by_rev<'repo>(repo: &'repo gix::Repository, rev: &str) -> gix::Id<'rep } pub fn standard_options() -> but_graph::init::Options { - but_graph::init::Options { collect_tags: true } + but_graph::init::Options { + collect_tags: true, + max_commits_outside_of_workspace: None, + max_commits_recharge_location: vec![], + } } diff --git a/crates/but-graph/tests/graph/init/with_workspace.rs b/crates/but-graph/tests/graph/init/with_workspace.rs index 26b0824753..de208c17aa 100644 --- a/crates/but-graph/tests/graph/init/with_workspace.rs +++ b/crates/but-graph/tests/graph/init/with_workspace.rs @@ -1,4 +1,5 @@ use crate::graph_tree; +use crate::init::utils::add_workspace_without_target; use crate::init::{StackState, add_stack_with_segments, add_workspace, id_at, id_by_rev}; use crate::init::{read_only_in_memory_scenario, standard_options}; use but_graph::Graph; @@ -671,7 +672,9 @@ fn disambiguate_by_remote() -> anyhow::Result<()> { fn integrated_tips_stop_early() -> anyhow::Result<()> { let (repo, mut meta) = read_only_in_memory_scenario("ws/two-segments-one-integrated")?; insta::assert_snapshot!(visualize_commit_graph_all(&repo)?, @r" - * 7b9f260 (origin/main) Merge branch 'A' into soon-origin-main + * d0df794 (origin/main) remote-2 + * 09c6e08 remote-1 + * 7b9f260 Merge branch 'A' into soon-origin-main |\ | | * 4077353 (HEAD -> gitbutler/workspace) GitButler Workspace Commit | | * 6b1a13b (B) B2 @@ -701,21 +704,20 @@ fn integrated_tips_stop_early() -> anyhow::Result<()> { insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace β”‚ └── Β·4077353 (βŒ‚|🏘️)❱"GitButler Workspace Commit" - β”‚ └── β–Ί:4:B + β”‚ └── β–Ί:2:B β”‚ β”œβ”€β”€ Β·6b1a13b (βŒ‚|🏘️)❱"B2" β”‚ └── Β·03ad472 (βŒ‚|🏘️)❱"B1" - β”‚ └── β–Ί:3:A - β”‚ β”œβ”€β”€ Β·79bbb29 (βŒ‚|🏘️|βœ“)❱"8" - β”‚ β”œβ”€β”€ Β·fc98174 (βŒ‚|🏘️|βœ“)❱"7" - β”‚ β”œβ”€β”€ Β·a381df5 (βŒ‚|🏘️|βœ“)❱"6" - β”‚ └── βœ‚οΈΒ·777b552 (βŒ‚|🏘️|βœ“)❱"5" + β”‚ └── β–Ί:5:A + β”‚ └── βœ‚οΈΒ·79bbb29 (βŒ‚|🏘️|βœ“)❱"8" └── β–Ί:1:origin/main - └── 🟣7b9f260 (βœ“)❱"Merge branch \'A\' into soon-origin-main" - β”œβ”€β”€ β†’:3: (A) - └── β–Ί:2:main - β”œβ”€β”€ Β·4b3e5a8 (βŒ‚|βœ“)❱"3" - β”œβ”€β”€ Β·34d0715 (βŒ‚|βœ“)❱"2" - └── Β·eb5f731 (βŒ‚|βœ“)❱"1" + β”œβ”€β”€ 🟣d0df794 (βœ“)❱"remote-2" + └── 🟣09c6e08 (βœ“)❱"remote-1" + └── β–Ί:3:anon: + └── 🟣7b9f260 (βœ“)❱"Merge branch \'A\' into soon-origin-main" + β”œβ”€β”€ β†’:5: (A) + └── β–Ί:4:main + β”œβ”€β”€ Β·4b3e5a8 (βŒ‚|βœ“)❱"3" + └── βœ‚οΈΒ·34d0715 (βŒ‚|βœ“)❱"2" "#); add_stack_with_segments( @@ -725,55 +727,101 @@ fn integrated_tips_stop_early() -> anyhow::Result<()> { StackState::InWorkspace, &["A"], ); - let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; + // As we start at a workspace, even a limit of 0 has no effect - we get to see the whole workspace. + let graph = Graph::from_head(&repo, &*meta, standard_options().with_limit(0))?.validated()?; // Now that `A` is part of the workspace, it's not cut off anymore. // Instead, we get to keep `A` in full, and it aborts only one later as the - // segment definitely isnt' in the workspace. + // segment definitely isn't in the workspace. insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace β”‚ └── Β·4077353 (βŒ‚|🏘️)❱"GitButler Workspace Commit" - β”‚ └── β–Ί:4:B + β”‚ └── β–Ί:2:B β”‚ β”œβ”€β”€ Β·6b1a13b (βŒ‚|🏘️)❱"B2" β”‚ └── Β·03ad472 (βŒ‚|🏘️)❱"B1" - β”‚ └── β–Ί:3:A + β”‚ └── β–Ί:5:A β”‚ β”œβ”€β”€ Β·79bbb29 (βŒ‚|🏘️|βœ“)❱"8" β”‚ β”œβ”€β”€ Β·fc98174 (βŒ‚|🏘️|βœ“)❱"7" β”‚ β”œβ”€β”€ Β·a381df5 (βŒ‚|🏘️|βœ“)❱"6" β”‚ └── Β·777b552 (βŒ‚|🏘️|βœ“)❱"5" - β”‚ └── β–Ί:5:anon: + β”‚ └── β–Ί:6:anon: β”‚ └── βœ‚οΈΒ·ce4a760 (βŒ‚|🏘️|βœ“)❱"Merge branch \'A-feat\' into A" └── β–Ί:1:origin/main - └── 🟣7b9f260 (βœ“)❱"Merge branch \'A\' into soon-origin-main" - β”œβ”€β”€ β†’:3: (A) - └── β–Ί:2:main - β”œβ”€β”€ Β·4b3e5a8 (βŒ‚|βœ“)❱"3" - β”œβ”€β”€ Β·34d0715 (βŒ‚|βœ“)❱"2" - └── Β·eb5f731 (βŒ‚|βœ“)❱"1" + β”œβ”€β”€ 🟣d0df794 (βœ“)❱"remote-2" + └── 🟣09c6e08 (βœ“)❱"remote-1" + └── β–Ί:3:anon: + └── 🟣7b9f260 (βœ“)❱"Merge branch \'A\' into soon-origin-main" + β”œβ”€β”€ β†’:5: (A) + └── β–Ί:4:main + β”œβ”€β”€ Β·4b3e5a8 (βŒ‚|βœ“)❱"3" + └── βœ‚οΈΒ·34d0715 (βŒ‚|βœ“)❱"2" "#); - let (main_id, ref_name) = id_at(&repo, "main"); + meta.data_mut().branches.clear(); + add_workspace(&mut meta); + // When looking from an integrated branch, we get a bit further until we know we can stop as + // the target branch first has to catch up with us. + let (id, ref_name) = id_at(&repo, "A"); let graph = - Graph::from_commit_traversal(main_id, ref_name, &*meta, standard_options())?.validated()?; + Graph::from_commit_traversal(id, ref_name, &*meta, standard_options())?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ β–Ίβ–Ίβ–Ί:1:gitbutler/workspace β”‚ └── Β·4077353 (βŒ‚|🏘️)❱"GitButler Workspace Commit" - β”‚ └── β–Ί:4:B + β”‚ └── β–Ί:3:B β”‚ β”œβ”€β”€ Β·6b1a13b (βŒ‚|🏘️)❱"B2" β”‚ └── Β·03ad472 (βŒ‚|🏘️)❱"B1" - β”‚ └── β–Ί:3:A + β”‚ └── πŸ‘‰β–Ί:0:A β”‚ β”œβ”€β”€ Β·79bbb29 (βŒ‚|🏘️|βœ“)❱"8" β”‚ β”œβ”€β”€ Β·fc98174 (βŒ‚|🏘️|βœ“)❱"7" β”‚ β”œβ”€β”€ Β·a381df5 (βŒ‚|🏘️|βœ“)❱"6" - β”‚ └── Β·777b552 (βŒ‚|🏘️|βœ“)❱"5" - β”‚ └── β–Ί:5:anon: - β”‚ └── βœ‚οΈΒ·ce4a760 (βŒ‚|🏘️|βœ“)❱"Merge branch \'A-feat\' into A" + β”‚ └── βœ‚οΈΒ·777b552 (βŒ‚|🏘️|βœ“)❱"5" └── β–Ί:2:origin/main - └── 🟣7b9f260 (βœ“)❱"Merge branch \'A\' into soon-origin-main" - β”œβ”€β”€ β†’:3: (A) - └── πŸ‘‰β–Ί:0:main - β”œβ”€β”€ Β·4b3e5a8 (βŒ‚|βœ“)❱"3" - β”œβ”€β”€ Β·34d0715 (βŒ‚|βœ“)❱"2" - └── Β·eb5f731 (βŒ‚|βœ“)❱"1" + β”œβ”€β”€ 🟣d0df794 (βœ“)❱"remote-2" + └── 🟣09c6e08 (βœ“)❱"remote-1" + └── β–Ί:4:anon: + └── 🟣7b9f260 (βœ“)❱"Merge branch \'A\' into soon-origin-main" + β”œβ”€β”€ β†’:0: (A) + └── β–Ί:5:main + └── βœ‚οΈΒ·4b3e5a8 (βŒ‚|βœ“)❱"3" + "#); + Ok(()) +} + +#[test] +fn workspace_obeys_limit_when_target_branch_is_missing() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/two-segments-one-integrated")?; + add_workspace_without_target(&mut meta); + assert!( + meta.data_mut().default_target.is_none(), + "without target, limits affect workspaces too" + ); + let graph = Graph::from_head(&repo, &*meta, standard_options().with_limit(0))?.validated()?; + insta::assert_snapshot!(graph_tree(&graph), @"└── πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace"); + + meta.data_mut().branches.clear(); + add_workspace(&mut meta); + assert!( + meta.data_mut().default_target.is_some(), + "But with workspace and target, we see everything" + ); + // It's notable that there is no way to bypass the early abort when everything is integrated. + let graph = Graph::from_head(&repo, &*meta, standard_options().with_limit(0))?.validated()?; + insta::assert_snapshot!(graph_tree(&graph), @r#" + β”œβ”€β”€ πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace + β”‚ └── Β·4077353 (βŒ‚|🏘️)❱"GitButler Workspace Commit" + β”‚ └── β–Ί:2:B + β”‚ β”œβ”€β”€ Β·6b1a13b (βŒ‚|🏘️)❱"B2" + β”‚ └── Β·03ad472 (βŒ‚|🏘️)❱"B1" + β”‚ └── β–Ί:5:A + β”‚ └── βœ‚οΈΒ·79bbb29 (βŒ‚|🏘️|βœ“)❱"8" + └── β–Ί:1:origin/main + β”œβ”€β”€ 🟣d0df794 (βœ“)❱"remote-2" + └── 🟣09c6e08 (βœ“)❱"remote-1" + └── β–Ί:3:anon: + └── 🟣7b9f260 (βœ“)❱"Merge branch \'A\' into soon-origin-main" + β”œβ”€β”€ β†’:5: (A) + └── β–Ί:4:main + β”œβ”€β”€ Β·4b3e5a8 (βŒ‚|βœ“)❱"3" + └── βœ‚οΈΒ·34d0715 (βŒ‚|βœ“)❱"2" "#); Ok(()) } From bad027bceff2420727e7d01e932d34bfbaa5a87b Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Thu, 19 Jun 2025 13:50:43 +0200 Subject: [PATCH 4/8] 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. --- crates/but-graph/src/api.rs | 14 +- crates/but-graph/src/init/mod.rs | 145 +++++---------- crates/but-graph/src/init/utils.rs | 171 +++++++++++++++--- crates/but-graph/tests/fixtures/scenarios.sh | 12 +- crates/but-graph/tests/graph/init/mod.rs | 53 ++++-- crates/but-graph/tests/graph/init/utils.rs | 4 +- .../tests/graph/init/with_workspace.rs | 98 +++++++--- crates/but-testing/src/args.rs | 15 ++ crates/but-testing/src/command/mod.rs | 69 +++++++ crates/but-testing/src/main.rs | 12 ++ 10 files changed, 425 insertions(+), 168 deletions(-) diff --git a/crates/but-graph/src/api.rs b/crates/but-graph/src/api.rs index aa10ea600d..8e3e0b9586 100644 --- a/crates/but-graph/src/api.rs +++ b/crates/but-graph/src/api.rs @@ -177,6 +177,15 @@ impl Graph { self.inner.edge_count() } + /// Return the number of commits in all segments. + pub fn num_commits(&self) -> usize { + self.inner + .raw_nodes() + .iter() + .map(|n| n.weight.commits.len()) + .sum::() + } + /// Return an iterator over all indices of segments in the graph. pub fn segments(&self) -> impl Iterator { self.inner.node_indices() @@ -257,7 +266,7 @@ impl Graph { /// Validate the graph for consistency and fail loudly when an issue was found, after printing the dot graph. /// Mostly useful for debugging to stop early when a connection wasn't created correctly. - #[cfg(target_os = "macos")] + #[cfg(unix)] pub fn validated_or_open_as_svg(self) -> anyhow::Result { for edge in self.inner.edge_references() { let res = check_edge(&self.inner, edge); @@ -283,7 +292,8 @@ impl Graph { } /// Open an SVG dot visualization in the browser or panic if the `dot` or `open` tool can't be found. - #[cfg(target_os = "macos")] + #[cfg(unix)] + #[tracing::instrument(skip(self))] pub fn open_as_svg(&self) { use std::io::Write; use std::process::Stdio; diff --git a/crates/but-graph/src/init/mod.rs b/crates/but-graph/src/init/mod.rs index d21957c99f..95efa15e3f 100644 --- a/crates/but-graph/src/init/mod.rs +++ b/crates/but-graph/src/init/mod.rs @@ -1,14 +1,14 @@ use crate::{CommitFlags, Edge}; use crate::{CommitIndex, Graph, Segment, SegmentIndex, SegmentMetadata}; -use anyhow::{Context, bail}; +use anyhow::bail; use but_core::RefMetadata; -use gix::ObjectId; use gix::hashtable::hash_map::Entry; use gix::prelude::{ObjectIdExt, ReferenceExt}; use gix::refs::Category; use petgraph::graph::EdgeReference; use petgraph::prelude::EdgeRef; use std::collections::VecDeque; +use tracing::instrument; mod utils; use utils::*; @@ -27,39 +27,41 @@ pub struct Options { /// /// If `false`, tags are not collected. pub collect_tags: bool, - /// The maximum number of commits we should traverse outside any workspace *with a target branch*. + /// The (soft) maximum number of commits we should traverse. /// Workspaces with a target branch automatically have unlimited traversals as they rely on the target /// branch to eventually stop the traversal. /// /// If `None`, there is no limit, which typically means that when lacking a workspace, the traversal /// will end only when no commit is left to traverse. - /// `Some(0)` means nothing is going to be returned. + /// `Some(0)` means nothing but the first commit is going to be returned, but it should be avoided. /// /// Note that this doesn't affect the traversal of integrated commits, which is always stopped once there /// is nothing interesting left to traverse. /// - /// Also note: This is not a perfectly exact measure, and it's always possible to receive a few more commits - /// than the maximum as for simplicity, we assign each 'split' the same limit, effectively doubling it. + /// Also note: This is a hint and not an exact measure, and it's always possible to receive a more commits + /// for various reasons, for instance the need to let remote branches find their local brnach independently + /// of the limit. /// /// ### Tip Configuration /// /// * HEAD - uses the limit /// * workspaces with target branch - no limit, but auto-stop if workspace is exhausted as everything is integrated. /// - The target branch: no limit + /// - Integrated workspace branches: use the limit /// * workspace without target branch - uses the limit - /// * remotes tracking branches - use the limit - pub max_commits_outside_of_workspace: Option, + /// * remotes tracking branches - use the limit, but only once they have reached a local branch. + pub commits_limit_hint: Option, /// A list of the last commits of partial segments previously returned that reset the amount of available - /// commits to traverse back to `max_commits_outside_of_workspace`. + /// commits to traverse back to `commit_limit_hint`. /// Imagine it like a gas station that can be chosen to direct where the commit-budge should be spent. - pub max_commits_recharge_location: Vec, + pub commits_limit_recharge_location: Vec, } /// Builder impl Options { /// Set the maximum amount of commits that each lane in a tip may traverse. pub fn with_limit(mut self, limit: usize) -> Self { - self.max_commits_outside_of_workspace = Some(limit); + self.commits_limit_hint = Some(limit); self } @@ -68,7 +70,7 @@ impl Options { mut self, commits: impl IntoIterator, ) -> Self { - self.max_commits_recharge_location.extend(commits); + self.commits_limit_recharge_location.extend(commits); self } } @@ -146,16 +148,18 @@ impl Graph { /// * The traversal is cut short when there is only tips which are integrated, even though named segments that are /// supposed to be in the workspace will be fully traversed (implying they will stop at the first anon segment /// as will happen at merge commits). + #[instrument(skip(meta, ref_name), err(Debug))] pub fn from_commit_traversal( tip: gix::Id<'_>, ref_name: impl Into>, meta: &impl RefMetadata, Options { collect_tags, - max_commits_outside_of_workspace: limit, - mut max_commits_recharge_location, + commits_limit_hint: limit, + commits_limit_recharge_location: mut max_commits_recharge_location, }: Options, ) -> anyhow::Result { + let limit = Limit(limit); // TODO: also traverse (outside)-branches that ought to be in the workspace. That way we have the desired ones // automatically and just have to find a way to prune the undesired ones. let repo = tip.repo; @@ -249,7 +253,7 @@ impl Graph { let mut ws_segment = branch_segment_from_name_and_meta(Some(ws_ref), meta, None)?; // Drop the limit if we have a target ref let limit = if workspace_info.target_ref.is_some() { - None + Limit::unspecified() } else { limit }; @@ -279,22 +283,20 @@ impl Graph { Instruction::CollectCommit { into: target_segment, }, - /* unlimited traversal for 'negative' commits */ - None, + /* unlimited traversal for integrated commits */ + Limit::unspecified(), )); } } max_commits_recharge_location.sort(); // Set max-limit so that we compensate for the way this is counted. - let max_limit = limit.map(|l| l + 1); + // let max_limit = limit.incremented(); + let max_limit = limit; while let Some((id, mut propagated_flags, instruction, mut limit)) = next.pop_front() { if max_commits_recharge_location.binary_search(&id).is_ok() { limit = max_limit; } - if limit.is_some_and(|l| l == 0) { - continue; - } let info = find(commit_graph.as_ref(), repo, id, &mut buf)?; let src_flags = graph[instruction.segment_idx()] .commits @@ -307,67 +309,15 @@ impl Graph { propagated_flags |= src_flags; let segment_idx_for_id = match instruction { Instruction::CollectCommit { into: src_sidx } => match seen.entry(id) { - Entry::Occupied(mut existing_sidx) => { - let dst_sidx = *existing_sidx.get(); - let (top_sidx, mut bottom_sidx) = - // If a normal branch walks into a workspace branch, put the workspace branch on top. - if graph[dst_sidx].workspace_metadata().is_some() && - graph[src_sidx].ref_name.as_ref() - .is_some_and(|rn| rn.category().is_some_and(|c| matches!(c, Category::LocalBranch))) { - // `dst` is basically swapping with `src`, so must swap commits and connections. - swap_commits_and_connections(&mut graph.inner, dst_sidx, src_sidx); - swap_queued_segments(&mut next, dst_sidx, src_sidx); - - // Assure the first commit doesn't name the new owner segment. - { - let s = &mut graph[src_sidx]; - if let Some(c) = s.commits.first_mut() { - c.refs.retain(|rn| Some(rn) != s.ref_name.as_ref()) - } - // Update the commit-ownership of the connecting commit, but also - // of all other commits in the segment. - existing_sidx.insert(src_sidx); - for commit_id in s.commits.iter().skip(1).map(|c| c.id) { - seen.entry(commit_id).insert(src_sidx); - } - } - (dst_sidx, src_sidx) - } else { - // `src` naturally runs into destination, so nothing needs to be done - // except for connecting both. Commit ownership doesn't change. - (src_sidx, dst_sidx) - }; - let top_cidx = graph[top_sidx].last_commit_index(); - let mut bottom_cidx = - graph[bottom_sidx].commit_index_of(id).with_context(|| { - format!( - "BUG: Didn't find commit {id} in segment {bottom_sidx}", - bottom_sidx = dst_sidx.index(), - ) - })?; - - if bottom_cidx != 0 { - let new_bottom_sidx = split_commit_into_segment( - &mut graph, - &mut next, - &mut seen, - bottom_sidx, - bottom_cidx, - )?; - bottom_sidx = new_bottom_sidx; - bottom_cidx = 0; - } - graph.connect_segments(top_sidx, top_cidx, bottom_sidx, bottom_cidx); - let top_flags = top_cidx - .map(|cidx| graph[top_sidx].commits[cidx].flags) - .unwrap_or_default(); - let bottom_flags = graph[bottom_sidx].commits[bottom_cidx].flags; - propagate_flags_downward( - &mut graph.inner, - propagated_flags | top_flags | bottom_flags, - bottom_sidx, - Some(bottom_cidx), - ); + Entry::Occupied(_) => { + possibly_split_occupied_segment( + &mut graph, + &mut seen, + &mut next, + id, + propagated_flags, + src_sidx, + )?; continue; } Entry::Vacant(e) => { @@ -387,23 +337,15 @@ impl Graph { parent_above, at_commit, } => match seen.entry(id) { - Entry::Occupied(existing_sidx) => { - let bottom_sidx = *existing_sidx.get(); - let bottom = &graph[bottom_sidx]; - let bottom_cidx = bottom.commit_index_of(id).context( - "BUG: bottom segment must contain ID, `seen` seems out of date", + Entry::Occupied(_) => { + possibly_split_occupied_segment( + &mut graph, + &mut seen, + &mut next, + id, + propagated_flags, + parent_above, )?; - if bottom_cidx != 0 { - todo!("split bottom segment at `at_commit`"); - } - let bottom_flags = bottom.commits[bottom_cidx].flags; - graph.connect_segments(parent_above, at_commit, bottom_sidx, bottom_cidx); - propagate_flags_downward( - &mut graph.inner, - propagated_flags | bottom_flags, - bottom_sidx, - Some(bottom_cidx), - ); continue; } Entry::Vacant(e) => { @@ -463,7 +405,7 @@ impl Graph { limit, )?; - prune_integrated_tips(&mut graph.inner, &mut next, &desired_refs); + prune_integrated_tips(&mut graph.inner, &mut next, &desired_refs, max_limit); } graph.post_processed( @@ -476,6 +418,9 @@ impl Graph { } } +#[derive(Debug, Copy, Clone)] +struct Limit(Option); + #[derive(Debug, Copy, Clone)] enum Instruction { /// Contains the segment into which to place this commit. @@ -511,7 +456,7 @@ impl Instruction { } } -type QueueItem = (ObjectId, CommitFlags, Instruction, Option); +type QueueItem = (gix::ObjectId, CommitFlags, Instruction, Limit); #[derive(Debug)] pub(crate) struct EdgeOwned { diff --git a/crates/but-graph/src/init/utils.rs b/crates/but-graph/src/init/utils.rs index 58bff05ae3..ba0121cee9 100644 --- a/crates/but-graph/src/init/utils.rs +++ b/crates/but-graph/src/init/utils.rs @@ -1,5 +1,5 @@ use crate::init::walk::TopoWalk; -use crate::init::{EdgeOwned, Instruction, PetGraph, QueueItem, remotes}; +use crate::init::{EdgeOwned, Instruction, Limit, PetGraph, QueueItem, remotes}; use crate::{ Commit, CommitFlags, CommitIndex, Edge, Graph, Segment, SegmentIndex, SegmentMetadata, is_workspace_ref_name, @@ -7,6 +7,7 @@ use crate::{ use anyhow::{Context, bail}; use bstr::BString; use but_core::{RefMetadata, ref_metadata}; +use gix::hashtable::hash_map::Entry; use gix::prelude::ObjectIdExt; use gix::reference::Category; use gix::refs::FullName; @@ -236,19 +237,6 @@ pub fn try_split_non_empty_segment_at_branch( Ok(Some(segment_below)) } -fn is_exhausted_or_decrement(limit: &mut Option) -> bool { - *limit = match limit { - Some(limit) => { - if *limit == 0 { - return true; - } - Some(*limit - 1) - } - None => None, - }; - false -} - /// Queue the `parent_ids` of the current commit, whose additional information like `current_kind` and `current_index` /// are used. /// `limit` is used to determine if the tip is NOT supposed to be dropped, with `0` meaning it's depleted. @@ -258,9 +246,9 @@ pub fn queue_parents( flags: CommitFlags, current_sidx: SegmentIndex, current_cidx: CommitIndex, - mut limit: Option, + mut limit: Limit, ) { - if is_exhausted_or_decrement(&mut limit) { + if limit.is_exhausted_or_decrement(flags, next) { return; } if parent_ids.len() > 1 { @@ -268,8 +256,9 @@ pub fn queue_parents( parent_above: current_sidx, at_commit: current_cidx, }; + let limit_per_parent = limit.per_parent(parent_ids.len()); for pid in parent_ids { - next.push_back((*pid, flags, instruction, limit)) + next.push_back((*pid, flags, instruction, limit_per_parent)) } } else if !parent_ids.is_empty() { next.push_back(( @@ -579,9 +568,14 @@ pub fn try_queue_remote_tracking_branches( configured_remote_tracking_branches: &BTreeSet, target_refs: &[gix::refs::FullName], meta: &impl RefMetadata, - limit: Option, + mut limit: Limit, ) -> anyhow::Result<()> { - if limit.is_some_and(|l| l == 0) { + // As a commit can be reachable by many remote tracking branches while we need + // specificity, there is no need to propagate anything. + // We also *do not* propagate "NotInCommit" so remote-exclusive commits can be identified + // even across segment boundaries + let flags = CommitFlags::empty(); + if limit.is_exhausted_or_decrement(flags, next) { return Ok(()); } @@ -610,15 +604,11 @@ pub fn try_queue_remote_tracking_branches( )?); next.push_back(( remote_tip, - // As a commit can be reachable by many remote tracking branches while we need - // specificity, there is no need to propagate anything. - // We also *do not* propagate "NotInCommit" so remote-exclusive commits can be identified - // even across segment boundaries - CommitFlags::empty(), + flags, Instruction::CollectCommit { into: remote_segment, }, - limit.map(|l| l - 1), + limit, )); } Ok(()) @@ -629,10 +619,14 @@ pub fn try_queue_remote_tracking_branches( /// - keep tips that are adding segments that are or contain a workspace ref /// - prune the rest /// - delete empty segments of pruned tips. +/// +/// `max_limit` is applied to integrated workspace refs if they are not yet limited, and should +/// be the initially configured limit. pub fn prune_integrated_tips( graph: &mut PetGraph, next: &mut VecDeque, workspace_refs: &BTreeSet, + max_limit: Limit, ) { let all_integated = next .iter() @@ -640,7 +634,7 @@ pub fn prune_integrated_tips( if !all_integated { return; } - next.retain(|(_id, _flags, instruction, _limit)| { + next.retain_mut(|(_id, _flags, instruction, tip_limit)| { let sidx = instruction.segment_idx(); let s = &graph[sidx]; let any_segment_ref_is_contained_in_workspace = s @@ -649,9 +643,134 @@ pub fn prune_integrated_tips( .into_iter() .chain(s.commits.iter().flat_map(|c| c.refs.iter())) .any(|segment_rn| workspace_refs.contains(segment_rn)); + // For integrated workspace tips, use a limit to prevent runaway-worst-cases. + if any_segment_ref_is_contained_in_workspace && tip_limit.is_unset() { + *tip_limit = max_limit; + } if !any_segment_ref_is_contained_in_workspace && s.commits.is_empty() { graph.remove_node(sidx); } any_segment_ref_is_contained_in_workspace }); } + +pub fn possibly_split_occupied_segment( + graph: &mut Graph, + seen: &mut gix::revwalk::graph::IdMap, + next: &mut VecDeque, + id: gix::ObjectId, + propagated_flags: CommitFlags, + src_sidx: SegmentIndex, +) -> anyhow::Result<()> { + let Entry::Occupied(mut existing_sidx) = seen.entry(id) else { + bail!("BUG: Can only work with occupied entries") + }; + let dst_sidx = *existing_sidx.get(); + let (top_sidx, mut bottom_sidx) = + // If a normal branch walks into a workspace branch, put the workspace branch on top. + if graph[dst_sidx].workspace_metadata().is_some() && + graph[src_sidx].ref_name.as_ref() + .is_some_and(|rn| rn.category().is_some_and(|c| matches!(c, Category::LocalBranch))) { + // `dst` is basically swapping with `src`, so must swap commits and connections. + swap_commits_and_connections(&mut graph.inner, dst_sidx, src_sidx); + swap_queued_segments(next, dst_sidx, src_sidx); + + // Assure the first commit doesn't name the new owner segment. + { + let s = &mut graph[src_sidx]; + if let Some(c) = s.commits.first_mut() { + c.refs.retain(|rn| Some(rn) != s.ref_name.as_ref()) + } + // Update the commit-ownership of the connecting commit, but also + // of all other commits in the segment. + existing_sidx.insert(src_sidx); + for commit_id in s.commits.iter().skip(1).map(|c| c.id) { + seen.entry(commit_id).insert(src_sidx); + } + } + (dst_sidx, src_sidx) + } else { + // `src` naturally runs into destination, so nothing needs to be done + // except for connecting both. Commit ownership doesn't change. + (src_sidx, dst_sidx) + }; + let top_cidx = graph[top_sidx].last_commit_index(); + let mut bottom_cidx = graph[bottom_sidx].commit_index_of(id).with_context(|| { + format!( + "BUG: Didn't find commit {id} in segment {bottom_sidx}", + bottom_sidx = dst_sidx.index(), + ) + })?; + + if bottom_cidx != 0 { + let new_bottom_sidx = + split_commit_into_segment(graph, next, seen, bottom_sidx, bottom_cidx)?; + bottom_sidx = new_bottom_sidx; + bottom_cidx = 0; + } + graph.connect_segments(top_sidx, top_cidx, bottom_sidx, bottom_cidx); + let top_flags = top_cidx + .map(|cidx| graph[top_sidx].commits[cidx].flags) + .unwrap_or_default(); + let bottom_flags = graph[bottom_sidx].commits[bottom_cidx].flags; + propagate_flags_downward( + &mut graph.inner, + propagated_flags | top_flags | bottom_flags, + bottom_sidx, + Some(bottom_cidx), + ); + Ok(()) +} + +impl Limit { + /// It's important to try to split the limit evenly so we don't create too + /// much extra gas here. We do, however, make sure that we see each segment of a parent + /// with one commit so we know exactly where it stops. + /// The problem with this is that we never get back the split limit when segments re-unite, + /// so effectively we loose gas here. + fn per_parent(&self, num_parents: usize) -> Self { + Limit( + self.0 + .map(|l| if l == 0 { 0 } else { (l / num_parents).max(1) }), + ) + } + + /// Return `true` if this limit is depleted, or decrement it by one otherwise. + /// + /// `flags` are used to selectively decrement this limit. + /// Thanks to flag-propagation there can be no runaways. + fn is_exhausted_or_decrement( + &mut self, + flags: CommitFlags, + next: &VecDeque, + ) -> bool { + // Allow remote tracking branch tips to traverse until they meet a portion that is local, + // without burning their own fuel so that it arrives in the local branch they meet eventually. + if flags.is_empty() { + return false; + } + // Do not let *any* tip consume gas as long as there is still remotes tracking branches in the game + // that need to meet their local branches. Otherwise, everything is considered remote as the local tips + // that could tell otherwise never reach their remote counterparts. + let traverses_any_unmatched_remote = next.iter().any(|(_, flags, _, _)| flags.is_empty()); + if traverses_any_unmatched_remote { + return false; + } + if self.0.is_some_and(|l| l == 0) { + return true; + } + self.0 = self.0.map(|l| l - 1); + false + } + + fn is_unset(&self) -> bool { + self.0.is_none() + } +} + +impl Limit { + /// Allow unlimited traversal. + pub(crate) fn unspecified() -> Self { + Limit(None) + } +} diff --git a/crates/but-graph/tests/fixtures/scenarios.sh b/crates/but-graph/tests/fixtures/scenarios.sh index 372d7f99c1..5e0794fda6 100644 --- a/crates/but-graph/tests/fixtures/scenarios.sh +++ b/crates/but-graph/tests/fixtures/scenarios.sh @@ -289,12 +289,20 @@ mkdir ws git init deduced-remote-ahead (cd deduced-remote-ahead commit init + git checkout -b A commit shared git checkout -b soon-remote; + git checkout -b tmp + commit feat-on-remote + git checkout soon-remote + git merge --no-ff -m "merge" tmp && git branch -d tmp commit only-remote-01; commit only-remote-02; - git checkout main && create_workspace_commit_once main - setup_remote_tracking soon-remote main "move" + git checkout A + commit A1 + commit A2 + create_workspace_commit_once A + setup_remote_tracking soon-remote A "move" cat <>.git/config [remote "origin"] diff --git a/crates/but-graph/tests/graph/init/mod.rs b/crates/but-graph/tests/graph/init/mod.rs index 52dfb9680e..5d206e4604 100644 --- a/crates/but-graph/tests/graph/init/mod.rs +++ b/crates/but-graph/tests/graph/init/mod.rs @@ -202,6 +202,7 @@ fn four_diamond() -> anyhow::Result<()> { 8, "just as many as are displayed in the tree" ); + assert_eq!(graph.num_commits(), 8, "one commit per node"); assert_eq!( graph.num_edges(), 10, @@ -222,6 +223,22 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { * fafd9d0 (main) init "); + // A remote will always be able to find their non-remotes so they don't seem cut-off. + let graph = Graph::from_head(&repo, &*meta, standard_options().with_limit(1))?.validated()?; + insta::assert_snapshot!(graph_tree(&graph), @r#" + β”œβ”€β”€ πŸ‘‰β–Ί:0:B + β”‚ └── Β·312f819 (βŒ‚)❱"B" + β”‚ └── β–Ί:2:A + β”‚ └── Β·e255adc (βŒ‚)❱"A" + β”‚ └── β–Ί:4:main + β”‚ └── Β·fafd9d0 (βŒ‚)❱"init" + └── β–Ί:1:origin/B + └── 🟣682be32❱"B" + └── β–Ί:3:origin/A + └── 🟣e29c23d❱"A" + └── β†’:4: (main) + "#); + // Everything we encounter is checked for remotes. let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" @@ -304,15 +321,25 @@ fn with_limits() -> anyhow::Result<()> { └── β†’:4: (main) "#); - // Just empty starting points. + // There is no empty starting points, we always traverse the first commit as we really want + // to get to remote processing there. let graph = Graph::from_head(&repo, &*meta, standard_options().with_limit(0))?.validated()?; - insta::assert_snapshot!(graph_tree(&graph), @"└── πŸ‘‰β–Ί:0:C"); + insta::assert_snapshot!(graph_tree(&graph), @r#" + └── πŸ‘‰β–Ί:0:C + └── βœ‚οΈΒ·2a95729 (βŒ‚)❱"Merge branches \'A\' and \'B\' into C" + "#); // A single commit, the merge commit. let graph = Graph::from_head(&repo, &*meta, standard_options().with_limit(1))?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" └── πŸ‘‰β–Ί:0:C - └── βœ‚οΈΒ·2a95729 (βŒ‚)❱"Merge branches \'A\' and \'B\' into C" + └── Β·2a95729 (βŒ‚)❱"Merge branches \'A\' and \'B\' into C" + β”œβ”€β”€ β–Ί:3:B + β”‚ └── βœ‚οΈΒ·9908c99 (βŒ‚)❱"B3" + β”œβ”€β”€ β–Ί:2:A + β”‚ └── βœ‚οΈΒ·20a823c (βŒ‚)❱"A3" + └── β–Ί:1:anon: + └── βœ‚οΈΒ·6861158 (βŒ‚)❱"C3" "#); // The merge commit, then we witness lane-duplication of the limit so we get more than requested. @@ -321,35 +348,39 @@ fn with_limits() -> anyhow::Result<()> { └── πŸ‘‰β–Ί:0:C └── Β·2a95729 (βŒ‚)❱"Merge branches \'A\' and \'B\' into C" β”œβ”€β”€ β–Ί:3:B - β”‚ └── βœ‚οΈΒ·9908c99 (βŒ‚)❱"B3" + β”‚ β”œβ”€β”€ Β·9908c99 (βŒ‚)❱"B3" + β”‚ └── βœ‚οΈΒ·60d9a56 (βŒ‚)❱"B2" β”œβ”€β”€ β–Ί:2:A - β”‚ └── βœ‚οΈΒ·20a823c (βŒ‚)❱"A3" + β”‚ β”œβ”€β”€ Β·20a823c (βŒ‚)❱"A3" + β”‚ └── βœ‚οΈΒ·442a12f (βŒ‚)❱"A2" └── β–Ί:1:anon: - └── βœ‚οΈΒ·6861158 (βŒ‚)❱"C3" + β”œβ”€β”€ Β·6861158 (βŒ‚)❱"C3" + └── βœ‚οΈΒ·4f1f248 (βŒ‚)❱"C2" "#); - // Allow to see more commits just in the middle lane, the limit is reset + // Allow to see more commits just in the middle lane, the limit is reset, // and we see two more. - let id = id_by_rev(&repo, ":/A3"); let graph = Graph::from_head( &repo, &*meta, standard_options() .with_limit(2) - .with_limit_extension_at(Some(id.detach())), + .with_limit_extension_at(Some(id_by_rev(&repo, ":/A3").detach())), )? .validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" └── πŸ‘‰β–Ί:0:C └── Β·2a95729 (βŒ‚)❱"Merge branches \'A\' and \'B\' into C" β”œβ”€β”€ β–Ί:3:B - β”‚ └── βœ‚οΈΒ·9908c99 (βŒ‚)❱"B3" + β”‚ β”œβ”€β”€ Β·9908c99 (βŒ‚)❱"B3" + β”‚ └── βœ‚οΈΒ·60d9a56 (βŒ‚)❱"B2" β”œβ”€β”€ β–Ί:2:A β”‚ β”œβ”€β”€ Β·20a823c (βŒ‚)❱"A3" β”‚ β”œβ”€β”€ Β·442a12f (βŒ‚)❱"A2" β”‚ └── βœ‚οΈΒ·686706b (βŒ‚)❱"A1" └── β–Ί:1:anon: - └── βœ‚οΈΒ·6861158 (βŒ‚)❱"C3" + β”œβ”€β”€ Β·6861158 (βŒ‚)❱"C3" + └── βœ‚οΈΒ·4f1f248 (βŒ‚)❱"C2" "#); // Multiple extensions are fine as well. diff --git a/crates/but-graph/tests/graph/init/utils.rs b/crates/but-graph/tests/graph/init/utils.rs index 2ea70769eb..92f0f18884 100644 --- a/crates/but-graph/tests/graph/init/utils.rs +++ b/crates/but-graph/tests/graph/init/utils.rs @@ -134,7 +134,7 @@ pub fn id_by_rev<'repo>(repo: &'repo gix::Repository, rev: &str) -> gix::Id<'rep pub fn standard_options() -> but_graph::init::Options { but_graph::init::Options { collect_tags: true, - max_commits_outside_of_workspace: None, - max_commits_recharge_location: vec![], + commits_limit_hint: None, + commits_limit_recharge_location: vec![], } } diff --git a/crates/but-graph/tests/graph/init/with_workspace.rs b/crates/but-graph/tests/graph/init/with_workspace.rs index de208c17aa..1708529622 100644 --- a/crates/but-graph/tests/graph/init/with_workspace.rs +++ b/crates/but-graph/tests/graph/init/with_workspace.rs @@ -520,12 +520,17 @@ fn proper_remote_ahead() -> anyhow::Result<()> { fn deduced_remote_ahead() -> anyhow::Result<()> { let (repo, mut meta) = read_only_in_memory_scenario("ws/deduced-remote-ahead")?; insta::assert_snapshot!(visualize_commit_graph_all(&repo)?, @r" - * 9bcd3af (HEAD -> gitbutler/workspace) GitButler Workspace Commit - | * ca7baa7 (origin/main) only-remote-02 - | * 7ea1468 only-remote-01 + * 8b39ce4 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + * 9d34471 (A) A2 + * 5b89c71 A1 + | * 3ea1a8f (origin/A) only-remote-02 + | * 9c50f71 only-remote-01 + | * 2cfbb79 merge + |/| + | * e898cd0 feat-on-remote |/ - * 998eae6 (main) shared - * fafd9d0 init + * 998eae6 shared + * fafd9d0 (main) init "); // Remote segments are picked up automatically and traversed - they never take ownership of already assigned commits. @@ -533,29 +538,46 @@ fn deduced_remote_ahead() -> anyhow::Result<()> { let graph = Graph::from_head(&repo, &*meta, standard_options())?; insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace - β”‚ └── Β·9bcd3af (βŒ‚|🏘️)❱"GitButler Workspace Commit" - β”‚ └── β–Ί:2:main - β”‚ β”œβ”€β”€ Β·998eae6 (βŒ‚|🏘️|βœ“)❱"shared" - β”‚ └── Β·fafd9d0 (βŒ‚|🏘️|βœ“)❱"init" - └── β–Ί:1:origin/main - β”œβ”€β”€ 🟣ca7baa7 (βœ“)❱"only-remote-02" - └── 🟣7ea1468 (βœ“)❱"only-remote-01" - └── β†’:2: (main) + β”‚ └── Β·8b39ce4 (βŒ‚|🏘️)❱"GitButler Workspace Commit" + β”‚ └── β–Ί:1:A + β”‚ β”œβ”€β”€ Β·9d34471 (βŒ‚|🏘️)❱"A2" + β”‚ └── Β·5b89c71 (βŒ‚|🏘️)❱"A1" + β”‚ └── β–Ί:5:anon: + β”‚ └── Β·998eae6 (βŒ‚|🏘️)❱"shared" + β”‚ └── β–Ί:3:main + β”‚ └── Β·fafd9d0 (βŒ‚|🏘️)❱"init" + └── β–Ί:2:origin/A + β”œβ”€β”€ 🟣3ea1a8f❱"only-remote-02" + └── 🟣9c50f71❱"only-remote-01" + └── β–Ί:4:anon: + └── 🟣2cfbb79❱"merge" + β”œβ”€β”€ β–Ί:6:anon: + β”‚ └── 🟣e898cd0❱"feat-on-remote" + β”‚ └── β†’:5: + └── β†’:5: "#); let id = id_by_rev(&repo, ":/init"); let graph = Graph::from_commit_traversal(id, None, &*meta, standard_options())?; insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ β–Ίβ–Ίβ–Ί:1:gitbutler/workspace - β”‚ └── Β·9bcd3af (βŒ‚|🏘️)❱"GitButler Workspace Commit" - β”‚ └── β–Ί:3:main - β”‚ └── Β·998eae6 (βŒ‚|🏘️|βœ“)❱"shared" - β”‚ └── β–Ί:0:anon: - β”‚ └── πŸ‘‰Β·fafd9d0 (βŒ‚|🏘️|βœ“)❱"init" - └── β–Ί:2:origin/main - β”œβ”€β”€ 🟣ca7baa7 (βœ“)❱"only-remote-02" - └── 🟣7ea1468 (βœ“)❱"only-remote-01" - └── β†’:3: (main) + β”‚ └── Β·8b39ce4 (βŒ‚|🏘️)❱"GitButler Workspace Commit" + β”‚ └── β–Ί:2:A + β”‚ β”œβ”€β”€ Β·9d34471 (βŒ‚|🏘️)❱"A2" + β”‚ └── Β·5b89c71 (βŒ‚|🏘️)❱"A1" + β”‚ └── β–Ί:5:anon: + β”‚ └── Β·998eae6 (βŒ‚|🏘️)❱"shared" + β”‚ └── πŸ‘‰β–Ί:0:main + β”‚ └── Β·fafd9d0 (βŒ‚|🏘️)❱"init" + └── β–Ί:3:origin/A + β”œβ”€β”€ 🟣3ea1a8f❱"only-remote-02" + └── 🟣9c50f71❱"only-remote-01" + └── β–Ί:4:anon: + └── 🟣2cfbb79❱"merge" + β”œβ”€β”€ β–Ί:6:anon: + β”‚ └── 🟣e898cd0❱"feat-on-remote" + β”‚ └── β†’:5: + └── β†’:5: "#); Ok(()) } @@ -727,11 +749,11 @@ fn integrated_tips_stop_early() -> anyhow::Result<()> { StackState::InWorkspace, &["A"], ); - // As we start at a workspace, even a limit of 0 has no effect - we get to see the whole workspace. - let graph = Graph::from_head(&repo, &*meta, standard_options().with_limit(0))?.validated()?; // Now that `A` is part of the workspace, it's not cut off anymore. // Instead, we get to keep `A` in full, and it aborts only one later as the // segment definitely isn't in the workspace. + // As we start at a workspace, even a limit of 0 has no effect - we get to see the whole workspace. + let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace β”‚ └── Β·4077353 (βŒ‚|🏘️)❱"GitButler Workspace Commit" @@ -756,6 +778,29 @@ fn integrated_tips_stop_early() -> anyhow::Result<()> { └── βœ‚οΈΒ·34d0715 (βŒ‚|βœ“)❱"2" "#); + // The limit is effective for integrated workspaces branches though to prevent runaways. + let graph = Graph::from_head(&repo, &*meta, standard_options().with_limit(1))?.validated()?; + insta::assert_snapshot!(graph_tree(&graph), @r#" + β”œβ”€β”€ πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace + β”‚ └── Β·4077353 (βŒ‚|🏘️)❱"GitButler Workspace Commit" + β”‚ └── β–Ί:2:B + β”‚ β”œβ”€β”€ Β·6b1a13b (βŒ‚|🏘️)❱"B2" + β”‚ └── Β·03ad472 (βŒ‚|🏘️)❱"B1" + β”‚ └── β–Ί:5:A + β”‚ β”œβ”€β”€ Β·79bbb29 (βŒ‚|🏘️|βœ“)❱"8" + β”‚ β”œβ”€β”€ Β·fc98174 (βŒ‚|🏘️|βœ“)❱"7" + β”‚ └── βœ‚οΈΒ·a381df5 (βŒ‚|🏘️|βœ“)❱"6" + └── β–Ί:1:origin/main + β”œβ”€β”€ 🟣d0df794 (βœ“)❱"remote-2" + └── 🟣09c6e08 (βœ“)❱"remote-1" + └── β–Ί:3:anon: + └── 🟣7b9f260 (βœ“)❱"Merge branch \'A\' into soon-origin-main" + β”œβ”€β”€ β†’:5: (A) + └── β–Ί:4:main + β”œβ”€β”€ Β·4b3e5a8 (βŒ‚|βœ“)❱"3" + └── βœ‚οΈΒ·34d0715 (βŒ‚|βœ“)❱"2" + "#); + meta.data_mut().branches.clear(); add_workspace(&mut meta); // When looking from an integrated branch, we get a bit further until we know we can stop as @@ -795,7 +840,10 @@ fn workspace_obeys_limit_when_target_branch_is_missing() -> anyhow::Result<()> { "without target, limits affect workspaces too" ); let graph = Graph::from_head(&repo, &*meta, standard_options().with_limit(0))?.validated()?; - insta::assert_snapshot!(graph_tree(&graph), @"└── πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace"); + insta::assert_snapshot!(graph_tree(&graph), @r#" + └── πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace + └── βœ‚οΈΒ·4077353 (βŒ‚|🏘️)❱"GitButler Workspace Commit" + "#); meta.data_mut().branches.clear(); add_workspace(&mut meta); diff --git a/crates/but-testing/src/args.rs b/crates/but-testing/src/args.rs index 2b43078cd1..5f3f73307a 100644 --- a/crates/but-testing/src/args.rs +++ b/crates/but-testing/src/args.rs @@ -150,6 +150,21 @@ pub enum Subcommands { /// The name of the ref to get workspace information for. ref_name: Option, }, + /// Returns a segmented graph starting from `HEAD`. + Graph { + /// The amount of commits to traverse in non-workspace regions. + /// Specifying no limit with `--limit` removes all limits. + #[clap(long, short = 'l', default_value = "300")] + limit: Option>, + /// Refill the limit when running over these hashes, provided as short or long hash. + #[clap(long, short = 'e')] + limit_extension: Vec, + /// Avoid opening the resulting dot-file and instead write it to standard output. + #[clap(long)] + no_open: bool, + /// The name of the ref to start the graph traversal at. + ref_name: Option, + }, /// Return all stack branches related to the given `id`. StackBranches { /// The ID of the stack to list branches from. diff --git a/crates/but-testing/src/command/mod.rs b/crates/but-testing/src/command/mod.rs index 08d1453cad..817d01d78e 100644 --- a/crates/but-testing/src/command/mod.rs +++ b/crates/but-testing/src/command/mod.rs @@ -5,6 +5,9 @@ use but_settings::AppSettings; use but_workspace::{DiffSpec, HunkHeader}; use gitbutler_project::{Project, ProjectId}; use gix::bstr::{BString, ByteSlice}; +use gix::odb::store::RefreshMode; +use std::io::{Write, stdout}; +use std::mem::ManuallyDrop; use std::path::Path; use tokio::sync::mpsc::unbounded_channel; @@ -494,6 +497,72 @@ pub fn ref_info(args: &super::Args, ref_name: Option<&str>, expensive: bool) -> }?) } +pub fn graph( + args: &super::Args, + ref_name: Option<&str>, + no_open: bool, + limit: Option, + limit_extension: Vec, +) -> anyhow::Result<()> { + let (mut repo, project) = repo_and_maybe_project(args, RepositoryOpenMode::General)?; + repo.objects.refresh = RefreshMode::Never; + let opts = but_graph::init::Options { + collect_tags: true, + commits_limit_hint: limit, + commits_limit_recharge_location: limit_extension + .into_iter() + .map(|short_hash| { + repo.objects + .lookup_prefix( + gix::hash::Prefix::from_hex(&short_hash).expect("valid hex prefix"), + None, + ) + .unwrap() + .expect("object for prefix exists") + .expect("the prefix is unambiguous") + }) + .collect(), + }; + + let meta_with_drop; + let meta_without_drop; + let meta = match project { + None => { + meta_without_drop = ManuallyDrop::new(VirtualBranchesTomlMetadata::from_path( + "should-never-be-written-back.toml", + )?); + &meta_without_drop + } + Some(project) => { + meta_with_drop = ref_metadata_toml(&project)?; + &meta_with_drop + } + }; + let graph = match ref_name { + None => but_graph::Graph::from_head(&repo, meta, opts), + Some(ref_name) => { + let mut reference = repo.find_reference(ref_name)?; + let id = reference.peel_to_id_in_place()?; + but_graph::Graph::from_commit_traversal(id, reference.name().to_owned(), meta, opts) + } + }?; + + eprintln!( + "Graph with {num_segments}, {num_edges} edges and {num_commits} commits", + num_segments = graph.num_segments(), + num_edges = graph.num_edges(), + num_commits = graph.num_commits() + ); + if no_open { + stdout().write_all(graph.dot_graph().as_bytes())?; + } else if cfg!(unix) { + graph.open_as_svg(); + } else { + bail!("Can't show SVG on non-unix") + } + Ok(()) +} + fn indices_or_headers_to_hunk_headers( repo: &gix::Repository, indices_or_headers: Option>, diff --git a/crates/but-testing/src/main.rs b/crates/but-testing/src/main.rs index 93167c10ae..bd500e2bf0 100644 --- a/crates/but-testing/src/main.rs +++ b/crates/but-testing/src/main.rs @@ -109,6 +109,18 @@ async fn main() -> Result<()> { ref_name, expensive, } => command::ref_info(&args, ref_name.as_deref(), *expensive), + args::Subcommands::Graph { + ref_name, + no_open, + limit, + limit_extension, + } => command::graph( + &args, + ref_name.as_deref(), + *no_open, + limit.flatten(), + limit_extension.clone(), + ), args::Subcommands::HunkAssignments => { command::assignment::hunk_assignments(&args.current_dir, args.json) } From 4961311d1258fdf2d0d394778aa76d912b72332b Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Thu, 19 Jun 2025 15:07:04 +0200 Subject: [PATCH 5/8] Make the limit more robust in the face of remote-tracking runways --- crates/but-graph/src/api.rs | 70 +++++--- crates/but-graph/src/init/mod.rs | 81 +++++++-- crates/but-graph/src/init/utils.rs | 165 ++++++++++++------ crates/but-graph/src/lib.rs | 2 + crates/but-graph/tests/graph/init/mod.rs | 39 +++-- crates/but-graph/tests/graph/init/utils.rs | 1 + .../tests/graph/init/with_workspace.rs | 10 +- crates/but-graph/tests/graph/utils.rs | 3 + crates/but-testing/src/args.rs | 8 +- crates/but-testing/src/command/mod.rs | 16 +- crates/but-testing/src/main.rs | 2 + 11 files changed, 287 insertions(+), 110 deletions(-) diff --git a/crates/but-graph/src/api.rs b/crates/but-graph/src/api.rs index 8e3e0b9586..43592cff27 100644 --- a/crates/but-graph/src/api.rs +++ b/crates/but-graph/src/api.rs @@ -92,6 +92,10 @@ impl Graph { /// Query impl Graph { + /// Return `true` if this graph is possibly partial as the hard limit was hit. + pub fn hard_limit_hit(&self) -> bool { + self.hard_limit_hit + } /// Return the entry-point of the graph as configured during traversal. /// It's useful for when one wants to know which commit was used to discover the entire graph. /// @@ -186,6 +190,15 @@ impl Graph { .sum::() } + /// Return the number segments whose commits are all exclusively in a remote. + pub fn num_remote_segments(&self) -> usize { + self.inner + .raw_nodes() + .iter() + .map(|n| usize::from(n.weight.commits.iter().all(|c| c.flags.is_empty()))) + .sum::() + } + /// Return an iterator over all indices of segments in the graph. pub fn segments(&self) -> impl Iterator { self.inner.node_indices() @@ -211,11 +224,16 @@ impl Graph { is_entrypoint: bool, show_message: bool, is_early_end: bool, + hard_limit: bool, ) -> String { format!( "{ep}{end}{kind}{conflict}{hex}{flags}{msg}{refs}", ep = if is_entrypoint { "πŸ‘‰" } else { "" }, - end = if is_early_end { "βœ‚οΈ" } else { "" }, + end = if is_early_end { + if hard_limit { "❌" } else { "βœ‚οΈ" } + } else { + "" + }, kind = if commit.flags.contains(CommitFlags::NotInRemote) { "Β·" } else { @@ -379,6 +397,7 @@ impl Graph { !show_segment_entrypoint && Some((sidx, Some(cidx))) == entrypoint, false, self.is_early_end_of_traversal(sidx, cidx), + self.hard_limit_hit, ) }) .collect::>() @@ -389,30 +408,31 @@ impl Graph { id = sidx.index(), ) }; - let dot = petgraph::dot::Dot::with_attr_getters( - &self.inner, - &[], - &|g, e| { - let src = &g[e.source()]; - let dst = &g[e.target()]; - // Don't mark connections from the last commit to the first one, - // but those that are 'splitting' a segment. These shouldn't exist. - let Err(err) = check_edge(g, e) else { - return ", label = \"\"".into(); - }; - let e = e.weight(); - let src = src - .commit_id_by_index(e.src) - .map(|c| c.to_hex_with_len(HEX).to_string()) - .unwrap_or_else(|| "src".into()); - let dst = dst - .commit_id_by_index(e.dst) - .map(|c| c.to_hex_with_len(HEX).to_string()) - .unwrap_or_else(|| "dst".into()); - format!(", label = \"⚠️{src} β†’ {dst} ({err})\", fontname = Courier") - }, - &node_attrs, - ); + + let edge_attrs = &|g: &PetGraph, e: EdgeReference<'_, Edge>| { + let src = &g[e.source()]; + let dst = &g[e.target()]; + // Graphs may be half-baked, let's not worry about it then. + if self.hard_limit_hit { + return ", label = \"\"".into(); + } + // Don't mark connections from the last commit to the first one, + // but those that are 'splitting' a segment. These shouldn't exist. + let Err(err) = check_edge(g, e) else { + return ", label = \"\"".into(); + }; + let e = e.weight(); + let src = src + .commit_id_by_index(e.src) + .map(|c| c.to_hex_with_len(HEX).to_string()) + .unwrap_or_else(|| "src".into()); + let dst = dst + .commit_id_by_index(e.dst) + .map(|c| c.to_hex_with_len(HEX).to_string()) + .unwrap_or_else(|| "dst".into()); + format!(", label = \"⚠️{src} β†’ {dst} ({err})\", fontname = Courier") + }; + let dot = petgraph::dot::Dot::with_attr_getters(&self.inner, &[], &edge_attrs, &node_attrs); format!("{dot:?}") } } diff --git a/crates/but-graph/src/init/mod.rs b/crates/but-graph/src/init/mod.rs index 95efa15e3f..ec1826e4b7 100644 --- a/crates/but-graph/src/init/mod.rs +++ b/crates/but-graph/src/init/mod.rs @@ -55,17 +55,34 @@ pub struct Options { /// commits to traverse back to `commit_limit_hint`. /// Imagine it like a gas station that can be chosen to direct where the commit-budge should be spent. pub commits_limit_recharge_location: Vec, + /// As opposed to the limit-hint, if not `None` we will stop after pretty much this many commits have been seen. + /// + /// This is a last line of defense against runaway traversals and for not it's recommended to set it to a high + /// but manageable value. Note that depending on the commit-graph, we may need more commits to find the local branch + /// for a remote branch, leaving remote branches unconnected. + /// + /// Due to multiple paths being taken, more commits may be queued (which is what's counted here) than actually + /// end up in the graph, so usually one will see many less. + pub hard_limit: Option, } /// Builder impl Options { - /// Set the maximum amount of commits that each lane in a tip may traverse. - pub fn with_limit(mut self, limit: usize) -> Self { + /// Set the maximum amount of commits that each lane in a tip may traverse, but that's less important + /// than building consistent, connected graphs. + pub fn with_limit_hint(mut self, limit: usize) -> Self { self.commits_limit_hint = Some(limit); self } - /// Keep track of commits at which the traversal limit should be reset to the [`limit`](Self::with_limit()). + /// Set a hard limit for the amount of commits to traverse. Even though it may be off by a couple, it's not dependent + /// on any additional logic. + pub fn with_hard_limit(mut self, limit: usize) -> Self { + self.hard_limit = Some(limit); + self + } + + /// Keep track of commits at which the traversal limit should be reset to the [`limit`](Self::with_limit_hint()). pub fn with_limit_extension_at( mut self, commits: impl IntoIterator, @@ -157,9 +174,10 @@ impl Graph { collect_tags, commits_limit_hint: limit, commits_limit_recharge_location: mut max_commits_recharge_location, + hard_limit, }: Options, ) -> anyhow::Result { - let limit = Limit(limit); + let limit = Limit::from(limit); // TODO: also traverse (outside)-branches that ought to be in the workspace. That way we have the desired ones // automatically and just have to find a way to prune the undesired ones. let repo = tip.repo; @@ -206,7 +224,7 @@ impl Graph { v }; - let mut next = VecDeque::::new(); + let mut next = Queue::new_with_limit(hard_limit); if !workspaces .iter() .any(|(wsrn, _)| Some(wsrn) == ref_name.as_ref()) @@ -216,12 +234,14 @@ impl Graph { meta, Some((&refs_by_id, tip.detach())), )?); - next.push_back(( + if next.push_back_exhausted(( tip.detach(), tip_flags, Instruction::CollectCommit { into: current }, limit, - )); + )) { + return Ok(graph.with_hard_limit()); + } } for (ws_ref, workspace_info) in workspaces { let Some(ws_tip) = try_refname_to_id(repo, ws_ref.as_ref())? else { @@ -261,7 +281,7 @@ impl Graph { let ws_segment = graph.insert_root(ws_segment); // As workspaces typically have integration branches which can help us to stop the traversal, // pick these up first. - next.push_front(( + if next.push_front_exhausted(( ws_tip, CommitFlags::InWorkspace | // We only allow workspaces that are not remote, and that are not target refs. @@ -270,14 +290,16 @@ impl Graph { CommitFlags::NotInRemote | add_flags, Instruction::CollectCommit { into: ws_segment }, limit, - )); + )) { + return Ok(graph.with_hard_limit()); + } if let Some((target_ref, target_ref_id)) = target { let target_segment = graph.insert_root(branch_segment_from_name_and_meta( Some(target_ref), meta, None, )?); - next.push_front(( + if next.push_front_exhausted(( target_ref_id, CommitFlags::Integrated, Instruction::CollectCommit { @@ -285,7 +307,9 @@ impl Graph { }, /* unlimited traversal for integrated commits */ Limit::unspecified(), - )); + )) { + return Ok(graph.with_hard_limit()); + } } } @@ -295,7 +319,7 @@ impl Graph { let max_limit = limit; while let Some((id, mut propagated_flags, instruction, mut limit)) = next.pop_front() { if max_commits_recharge_location.binary_search(&id).is_ok() { - limit = max_limit; + limit.inner = max_limit.inner; } let info = find(commit_graph.as_ref(), repo, id, &mut buf)?; let src_flags = graph[instruction.segment_idx()] @@ -315,6 +339,7 @@ impl Graph { &mut seen, &mut next, id, + limit, propagated_flags, src_sidx, )?; @@ -343,6 +368,7 @@ impl Graph { &mut seen, &mut next, id, + limit, propagated_flags, parent_above, )?; @@ -366,7 +392,7 @@ impl Graph { let segment = &mut graph[segment_idx_for_id]; let commit_idx_for_possible_fork = segment.commits.len(); - queue_parents( + let hard_limit_hit = queue_parents( &mut next, &info.parent_ids, propagated_flags, @@ -374,6 +400,9 @@ impl Graph { commit_idx_for_possible_fork, limit, ); + if hard_limit_hit { + return Ok(graph.with_hard_limit()); + } let refs_at_commit_before_removal = refs_by_id.remove(&id).unwrap_or_default(); segment.commits.push( @@ -393,7 +422,7 @@ impl Graph { )?, ); - try_queue_remote_tracking_branches( + let hard_limit_hit = try_queue_remote_tracking_branches( repo, &refs_at_commit_before_removal, &mut next, @@ -402,8 +431,12 @@ impl Graph { &configured_remote_tracking_branches, &target_refs, meta, + id, limit, )?; + if hard_limit_hit { + return Ok(graph.with_hard_limit()); + } prune_integrated_tips(&mut graph.inner, &mut next, &desired_refs, max_limit); } @@ -416,10 +449,28 @@ impl Graph { &configured_remote_tracking_branches, ) } + + fn with_hard_limit(mut self) -> Self { + self.hard_limit_hit = true; + self + } +} + +/// A queue to keep track of tips, which additionally counts how much was queued over time. +struct Queue { + inner: VecDeque, + /// The current number of queued items. + count: usize, + /// The maximum number of queuing operations, each representing one commit. + max: Option, } #[derive(Debug, Copy, Clone)] -struct Limit(Option); +struct Limit { + inner: Option, + /// The commit we want to see to be able to assume normal limits. Until then there is no limit. + goal: Option, +} #[derive(Debug, Copy, Clone)] enum Instruction { diff --git a/crates/but-graph/src/init/utils.rs b/crates/but-graph/src/init/utils.rs index ba0121cee9..2ac125f5e3 100644 --- a/crates/but-graph/src/init/utils.rs +++ b/crates/but-graph/src/init/utils.rs @@ -1,5 +1,5 @@ use crate::init::walk::TopoWalk; -use crate::init::{EdgeOwned, Instruction, Limit, PetGraph, QueueItem, remotes}; +use crate::init::{EdgeOwned, Instruction, Limit, PetGraph, Queue, QueueItem, remotes}; use crate::{ Commit, CommitFlags, CommitIndex, Edge, Graph, Segment, SegmentIndex, SegmentMetadata, is_workspace_ref_name, @@ -13,7 +13,7 @@ use gix::reference::Category; use gix::refs::FullName; use gix::traverse::commit::Either; use petgraph::Direction; -use std::collections::{BTreeSet, VecDeque}; +use std::collections::BTreeSet; use std::ops::Deref; type RefsById = gix::hashtable::HashMap>; @@ -22,7 +22,7 @@ type RefsById = gix::hashtable::HashMap> /// from `sidx` to the new segment, and return that. pub fn split_commit_into_segment( graph: &mut Graph, - next: &mut VecDeque, + next: &mut Queue, seen: &mut gix::revwalk::graph::IdMap, sidx: SegmentIndex, commit: CommitIndex, @@ -146,11 +146,7 @@ fn collect_edges_from_commit( .collect() } -pub fn replace_queued_segments( - queue: &mut VecDeque, - find: SegmentIndex, - replace: SegmentIndex, -) { +pub fn replace_queued_segments(queue: &mut Queue, find: SegmentIndex, replace: SegmentIndex) { for instruction_to_replace in queue.iter_mut().map(|(_, _, instruction, _)| instruction) { let cmp = instruction_to_replace.segment_idx(); if cmp == find { @@ -159,7 +155,7 @@ pub fn replace_queued_segments( } } -pub fn swap_queued_segments(queue: &mut VecDeque, a: SegmentIndex, b: SegmentIndex) { +pub fn swap_queued_segments(queue: &mut Queue, a: SegmentIndex, b: SegmentIndex) { for instruction_to_replace in queue.iter_mut().map(|(_, _, instruction, _)| instruction) { let cmp = instruction_to_replace.segment_idx(); if cmp == a { @@ -240,16 +236,17 @@ pub fn try_split_non_empty_segment_at_branch( /// Queue the `parent_ids` of the current commit, whose additional information like `current_kind` and `current_index` /// are used. /// `limit` is used to determine if the tip is NOT supposed to be dropped, with `0` meaning it's depleted. +#[must_use] pub fn queue_parents( - next: &mut VecDeque, + next: &mut Queue, parent_ids: &[gix::ObjectId], flags: CommitFlags, current_sidx: SegmentIndex, current_cidx: CommitIndex, mut limit: Limit, -) { +) -> bool { if limit.is_exhausted_or_decrement(flags, next) { - return; + return false; } if parent_ids.len() > 1 { let instruction = Instruction::ConnectNewSegment { @@ -258,18 +255,22 @@ pub fn queue_parents( }; let limit_per_parent = limit.per_parent(parent_ids.len()); for pid in parent_ids { - next.push_back((*pid, flags, instruction, limit_per_parent)) + if next.push_back_exhausted((*pid, flags, instruction, limit_per_parent)) { + return true; + } } - } else if !parent_ids.is_empty() { - next.push_back(( + } else if !parent_ids.is_empty() + && next.push_back_exhausted(( parent_ids[0], flags, Instruction::CollectCommit { into: current_sidx }, limit, - )); - } else { - return; - }; + )) + { + return true; + } + + false } pub fn branch_segment_from_name_and_meta( @@ -540,6 +541,8 @@ pub fn try_refname_to_id( pub fn propagate_flags_downward( graph: &mut PetGraph, + next: &mut Queue, + limit: Limit, flags_to_add: CommitFlags, dst_sidx: SegmentIndex, dst_commit: Option, @@ -548,6 +551,11 @@ pub fn propagate_flags_downward( while let Some((segment, commit_range)) = topo.next(graph) { for commit in &mut graph[segment].commits[commit_range] { commit.flags |= flags_to_add; + // Note that this just works if the remote is still fast-forwardable. + // If the goal isn't met, it's OK as well, but there is a chance for runaways. + if Some(commit.id) == limit.goal { + next.inner.iter_mut().for_each(|t| t.3.goal = None); + } } } } @@ -562,23 +570,20 @@ pub fn propagate_flags_downward( pub fn try_queue_remote_tracking_branches( repo: &gix::Repository, refs: &[gix::refs::FullName], - next: &mut VecDeque, + next: &mut Queue, graph: &mut Graph, target_symbolic_remote_names: &[String], configured_remote_tracking_branches: &BTreeSet, target_refs: &[gix::refs::FullName], meta: &impl RefMetadata, - mut limit: Limit, -) -> anyhow::Result<()> { + id: gix::ObjectId, + limit: Limit, +) -> anyhow::Result { // As a commit can be reachable by many remote tracking branches while we need // specificity, there is no need to propagate anything. // We also *do not* propagate "NotInCommit" so remote-exclusive commits can be identified // even across segment boundaries let flags = CommitFlags::empty(); - if limit.is_exhausted_or_decrement(flags, next) { - return Ok(()); - } - for rn in refs { let Some(remote_tracking_branch) = remotes::lookup_remote_tracking_branch_or_deduce_it( repo, @@ -602,16 +607,18 @@ pub fn try_queue_remote_tracking_branches( meta, None, )?); - next.push_back(( + if next.push_back_exhausted(( remote_tip, flags, Instruction::CollectCommit { into: remote_segment, }, - limit, - )); + limit.with_goal(id), + )) { + return Ok(true); + }; } - Ok(()) + Ok(false) } /// Remove if there are only tips with integrated commits… @@ -624,7 +631,7 @@ pub fn try_queue_remote_tracking_branches( /// be the initially configured limit. pub fn prune_integrated_tips( graph: &mut PetGraph, - next: &mut VecDeque, + next: &mut Queue, workspace_refs: &BTreeSet, max_limit: Limit, ) { @@ -657,8 +664,9 @@ pub fn prune_integrated_tips( pub fn possibly_split_occupied_segment( graph: &mut Graph, seen: &mut gix::revwalk::graph::IdMap, - next: &mut VecDeque, + next: &mut Queue, id: gix::ObjectId, + limit: Limit, propagated_flags: CommitFlags, src_sidx: SegmentIndex, ) -> anyhow::Result<()> { @@ -715,6 +723,8 @@ pub fn possibly_split_occupied_segment( let bottom_flags = graph[bottom_sidx].commits[bottom_cidx].flags; propagate_flags_downward( &mut graph.inner, + next, + limit, propagated_flags | top_flags | bottom_flags, bottom_sidx, Some(bottom_cidx), @@ -729,48 +739,103 @@ impl Limit { /// The problem with this is that we never get back the split limit when segments re-unite, /// so effectively we loose gas here. fn per_parent(&self, num_parents: usize) -> Self { - Limit( - self.0 + Limit { + inner: self + .inner .map(|l| if l == 0 { 0 } else { (l / num_parents).max(1) }), - ) + goal: self.goal, + } } /// Return `true` if this limit is depleted, or decrement it by one otherwise. /// /// `flags` are used to selectively decrement this limit. /// Thanks to flag-propagation there can be no runaways. - fn is_exhausted_or_decrement( - &mut self, - flags: CommitFlags, - next: &VecDeque, - ) -> bool { - // Allow remote tracking branch tips to traverse until they meet a portion that is local, - // without burning their own fuel so that it arrives in the local branch they meet eventually. - if flags.is_empty() { + fn is_exhausted_or_decrement(&mut self, flags: CommitFlags, next: &Queue) -> bool { + if self.goal.is_some() { return false; } // Do not let *any* tip consume gas as long as there is still remotes tracking branches in the game // that need to meet their local branches. Otherwise, everything is considered remote as the local tips // that could tell otherwise never reach their remote counterparts. - let traverses_any_unmatched_remote = next.iter().any(|(_, flags, _, _)| flags.is_empty()); - if traverses_any_unmatched_remote { + if !flags.is_empty() && next.iter().any(|(_, flags, _, _)| flags.is_empty()) { return false; } - if self.0.is_some_and(|l| l == 0) { + if self.inner.is_some_and(|l| l == 0) { return true; } - self.0 = self.0.map(|l| l - 1); + self.inner = self.inner.map(|l| l - 1); false } fn is_unset(&self) -> bool { - self.0.is_none() + self.inner.is_none() } } impl Limit { /// Allow unlimited traversal. - pub(crate) fn unspecified() -> Self { - Limit(None) + pub fn unspecified() -> Self { + Limit::from(None) + } + + pub fn with_goal(mut self, goal: gix::ObjectId) -> Self { + self.goal = Some(goal); + self + } +} + +impl From> for Limit { + fn from(value: Option) -> Self { + Limit { + inner: value, + goal: None, + } + } +} + +/// Lifecycle +impl Queue { + pub fn new_with_limit(limit: Option) -> Self { + Queue { + inner: Default::default(), + count: 0, + max: limit, + } + } +} + +/// Counted queuing +impl Queue { + #[must_use] + pub fn push_back_exhausted(&mut self, item: QueueItem) -> bool { + self.inner.push_back(item); + self.is_exhausted_after_increment() + } + #[must_use] + pub fn push_front_exhausted(&mut self, item: QueueItem) -> bool { + self.inner.push_front(item); + self.is_exhausted_after_increment() + } + + fn is_exhausted_after_increment(&mut self) -> bool { + self.count += 1; + self.max.is_some_and(|l| self.count >= l) + } +} + +/// Various other - good to know what we need though. +impl Queue { + pub fn pop_front(&mut self) -> Option { + self.inner.pop_front() + } + pub fn iter_mut(&mut self) -> impl Iterator { + self.inner.iter_mut() + } + pub fn iter(&self) -> impl Iterator { + self.inner.iter() + } + pub fn retain_mut(&mut self, f: impl FnMut(&mut QueueItem) -> bool) { + self.inner.retain_mut(f); } } diff --git a/crates/but-graph/src/lib.rs b/crates/but-graph/src/lib.rs index 342b763a78..6c84d8e88e 100644 --- a/crates/but-graph/src/lib.rs +++ b/crates/but-graph/src/lib.rs @@ -18,6 +18,8 @@ pub struct Graph { /// The [`CommitIndex`] is empty if the entry point is an empty segment, one that is supposed to receive /// commits later. entrypoint: Option<(SegmentIndex, Option)>, + /// It's `true` only if we have stopped the traversal due to a hard limit. + hard_limit_hit: bool, } /// A resolved entry point into the graph for easy access to the segment, commit, diff --git a/crates/but-graph/tests/graph/init/mod.rs b/crates/but-graph/tests/graph/init/mod.rs index 5d206e4604..1141e00a5c 100644 --- a/crates/but-graph/tests/graph/init/mod.rs +++ b/crates/but-graph/tests/graph/init/mod.rs @@ -31,6 +31,7 @@ fn unborn() -> anyhow::Result<()> { None, ), ), + hard_limit_hit: false, } "#); Ok(()) @@ -103,6 +104,7 @@ fn detached() -> anyhow::Result<()> { ), ), ), + hard_limit_hit: false, } "#); Ok(()) @@ -224,7 +226,8 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { "); // A remote will always be able to find their non-remotes so they don't seem cut-off. - let graph = Graph::from_head(&repo, &*meta, standard_options().with_limit(1))?.validated()?; + let graph = + Graph::from_head(&repo, &*meta, standard_options().with_limit_hint(1))?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ πŸ‘‰β–Ί:0:B β”‚ └── Β·312f819 (βŒ‚)❱"B" @@ -238,6 +241,20 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { └── 🟣e29c23d❱"A" └── β†’:4: (main) "#); + // The hard limit is always respected though. + let graph = + Graph::from_head(&repo, &*meta, standard_options().with_hard_limit(7))?.validated()?; + insta::assert_snapshot!(graph_tree(&graph), @r#" + β”œβ”€β”€ πŸ‘‰β–Ί:0:B + β”‚ └── Β·312f819 (βŒ‚)❱"B" + β”‚ └── β–Ί:2:A + β”‚ └── Β·e255adc (βŒ‚)❱"A" + β”‚ └── β–Ί:4:main + β”‚ └── Β·fafd9d0 (βŒ‚)❱"init" + β”œβ”€β”€ β–Ί:1:origin/B + β”‚ └── ❌🟣682be32❱"B" + └── β–Ί:3:origin/A + "#); // Everything we encounter is checked for remotes. let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; @@ -323,14 +340,16 @@ fn with_limits() -> anyhow::Result<()> { // There is no empty starting points, we always traverse the first commit as we really want // to get to remote processing there. - let graph = Graph::from_head(&repo, &*meta, standard_options().with_limit(0))?.validated()?; + let graph = + Graph::from_head(&repo, &*meta, standard_options().with_limit_hint(0))?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" └── πŸ‘‰β–Ί:0:C └── βœ‚οΈΒ·2a95729 (βŒ‚)❱"Merge branches \'A\' and \'B\' into C" "#); // A single commit, the merge commit. - let graph = Graph::from_head(&repo, &*meta, standard_options().with_limit(1))?.validated()?; + let graph = + Graph::from_head(&repo, &*meta, standard_options().with_limit_hint(1))?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" └── πŸ‘‰β–Ί:0:C └── Β·2a95729 (βŒ‚)❱"Merge branches \'A\' and \'B\' into C" @@ -343,7 +362,8 @@ fn with_limits() -> anyhow::Result<()> { "#); // The merge commit, then we witness lane-duplication of the limit so we get more than requested. - let graph = Graph::from_head(&repo, &*meta, standard_options().with_limit(2))?.validated()?; + let graph = + Graph::from_head(&repo, &*meta, standard_options().with_limit_hint(2))?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" └── πŸ‘‰β–Ί:0:C └── Β·2a95729 (βŒ‚)❱"Merge branches \'A\' and \'B\' into C" @@ -364,7 +384,7 @@ fn with_limits() -> anyhow::Result<()> { &repo, &*meta, standard_options() - .with_limit(2) + .with_limit_hint(2) .with_limit_extension_at(Some(id_by_rev(&repo, ":/A3").detach())), )? .validated()?; @@ -388,12 +408,9 @@ fn with_limits() -> anyhow::Result<()> { let graph = Graph::from_head( &repo, &*meta, - standard_options().with_limit(2).with_limit_extension_at([ - id(":/A3"), - id(":/A1"), - id(":/B3"), - id(":/C3"), - ]), + standard_options() + .with_limit_hint(2) + .with_limit_extension_at([id(":/A3"), id(":/A1"), id(":/B3"), id(":/C3")]), )? .validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" diff --git a/crates/but-graph/tests/graph/init/utils.rs b/crates/but-graph/tests/graph/init/utils.rs index 92f0f18884..e644077bae 100644 --- a/crates/but-graph/tests/graph/init/utils.rs +++ b/crates/but-graph/tests/graph/init/utils.rs @@ -136,5 +136,6 @@ pub fn standard_options() -> but_graph::init::Options { collect_tags: true, commits_limit_hint: None, commits_limit_recharge_location: vec![], + hard_limit: None, } } diff --git a/crates/but-graph/tests/graph/init/with_workspace.rs b/crates/but-graph/tests/graph/init/with_workspace.rs index 1708529622..5767d58329 100644 --- a/crates/but-graph/tests/graph/init/with_workspace.rs +++ b/crates/but-graph/tests/graph/init/with_workspace.rs @@ -633,6 +633,7 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { └── 🟣e29c23d❱"A" └── β†’:2: (origin/main) "#); + assert_eq!(graph.num_remote_segments(), 2); Ok(()) } @@ -779,7 +780,8 @@ fn integrated_tips_stop_early() -> anyhow::Result<()> { "#); // The limit is effective for integrated workspaces branches though to prevent runaways. - let graph = Graph::from_head(&repo, &*meta, standard_options().with_limit(1))?.validated()?; + let graph = + Graph::from_head(&repo, &*meta, standard_options().with_limit_hint(1))?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace β”‚ └── Β·4077353 (βŒ‚|🏘️)❱"GitButler Workspace Commit" @@ -839,7 +841,8 @@ fn workspace_obeys_limit_when_target_branch_is_missing() -> anyhow::Result<()> { meta.data_mut().default_target.is_none(), "without target, limits affect workspaces too" ); - let graph = Graph::from_head(&repo, &*meta, standard_options().with_limit(0))?.validated()?; + let graph = + Graph::from_head(&repo, &*meta, standard_options().with_limit_hint(0))?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" └── πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace └── βœ‚οΈΒ·4077353 (βŒ‚|🏘️)❱"GitButler Workspace Commit" @@ -852,7 +855,8 @@ fn workspace_obeys_limit_when_target_branch_is_missing() -> anyhow::Result<()> { "But with workspace and target, we see everything" ); // It's notable that there is no way to bypass the early abort when everything is integrated. - let graph = Graph::from_head(&repo, &*meta, standard_options().with_limit(0))?.validated()?; + let graph = + Graph::from_head(&repo, &*meta, standard_options().with_limit_hint(0))?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace β”‚ └── Β·4077353 (βŒ‚|🏘️)❱"GitButler Workspace Commit" diff --git a/crates/but-graph/tests/graph/utils.rs b/crates/but-graph/tests/graph/utils.rs index 0842630001..47bfbe7485 100644 --- a/crates/but-graph/tests/graph/utils.rs +++ b/crates/but-graph/tests/graph/utils.rs @@ -11,6 +11,7 @@ pub fn graph_tree(graph: &but_graph::Graph) -> SegmentTree { has_conflicts: bool, is_entrypoint: bool, is_early_end: bool, + hard_limit_hit: bool, ) -> SegmentTree { Graph::commit_debug_string( commit, @@ -18,6 +19,7 @@ pub fn graph_tree(graph: &but_graph::Graph) -> SegmentTree { is_entrypoint, true, /* show message */ is_early_end, + hard_limit_hit, ) .into() } @@ -94,6 +96,7 @@ pub fn graph_tree(graph: &but_graph::Graph) -> SegmentTree { commit.has_conflicts, segment_is_entrypoint && Some(cidx) == ep.commit_index, graph.is_early_end_of_traversal(sidx, cidx), + graph.hard_limit_hit(), ); if let Some(segment_indices) = connected_segments.get(&Some(cidx)) { for sidx in segment_indices { diff --git a/crates/but-testing/src/args.rs b/crates/but-testing/src/args.rs index 5f3f73307a..a5c88ed73f 100644 --- a/crates/but-testing/src/args.rs +++ b/crates/but-testing/src/args.rs @@ -152,7 +152,13 @@ pub enum Subcommands { }, /// Returns a segmented graph starting from `HEAD`. Graph { - /// The amount of commits to traverse in non-workspace regions. + /// The maximum number of commits to traverse. + /// + /// Use only as safety net to prevent runaways. + #[clap(long)] + hard_limit: Option, + /// The hint of the number of commits to traverse. + /// /// Specifying no limit with `--limit` removes all limits. #[clap(long, short = 'l', default_value = "300")] limit: Option>, diff --git a/crates/but-testing/src/command/mod.rs b/crates/but-testing/src/command/mod.rs index 817d01d78e..f5c5a0fe41 100644 --- a/crates/but-testing/src/command/mod.rs +++ b/crates/but-testing/src/command/mod.rs @@ -503,11 +503,13 @@ pub fn graph( no_open: bool, limit: Option, limit_extension: Vec, + hard_limit: Option, ) -> anyhow::Result<()> { let (mut repo, project) = repo_and_maybe_project(args, RepositoryOpenMode::General)?; repo.objects.refresh = RefreshMode::Never; let opts = but_graph::init::Options { collect_tags: true, + hard_limit, commits_limit_hint: limit, commits_limit_recharge_location: limit_extension .into_iter() @@ -548,17 +550,21 @@ pub fn graph( }?; eprintln!( - "Graph with {num_segments}, {num_edges} edges and {num_commits} commits", + "Graph with {num_segments} segments ({num_remote_segments} of which remote), {num_edges} edges and {num_commits} commits{hard_limit}", num_segments = graph.num_segments(), num_edges = graph.num_edges(), - num_commits = graph.num_commits() + num_commits = graph.num_commits(), + num_remote_segments = graph.num_remote_segments(), + hard_limit = graph + .hard_limit_hit() + .then_some(" (HARD LIMIT REACHED)") + .unwrap_or_default() ); if no_open { stdout().write_all(graph.dot_graph().as_bytes())?; - } else if cfg!(unix) { - graph.open_as_svg(); } else { - bail!("Can't show SVG on non-unix") + #[cfg(unix)] + graph.open_as_svg(); } Ok(()) } diff --git a/crates/but-testing/src/main.rs b/crates/but-testing/src/main.rs index bd500e2bf0..5c35ddc03c 100644 --- a/crates/but-testing/src/main.rs +++ b/crates/but-testing/src/main.rs @@ -114,12 +114,14 @@ async fn main() -> Result<()> { no_open, limit, limit_extension, + hard_limit, } => command::graph( &args, ref_name.as_deref(), *no_open, limit.flatten(), limit_extension.clone(), + *hard_limit, ), args::Subcommands::HunkAssignments => { command::assignment::hunk_assignments(&args.current_dir, args.json) From b480ba65bbf1dd9f58fb1cb55acc93cd5e689722 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Thu, 19 Jun 2025 19:05:25 +0200 Subject: [PATCH 6/8] Improve workspace handling so GitLab results are more usable. --- crates/but-graph/src/api.rs | 130 ++++++++++++++++-- crates/but-graph/src/init/mod.rs | 8 +- crates/but-graph/src/init/post.rs | 32 ++++- crates/but-graph/src/init/utils.rs | 47 +++++-- crates/but-graph/src/init/walk.rs | 5 +- crates/but-graph/src/lib.rs | 44 ++++++ crates/but-graph/tests/graph/init/mod.rs | 36 ++++- .../tests/graph/init/with_workspace.rs | 48 +++++-- crates/but-testing/src/command/mod.rs | 12 +- 9 files changed, 301 insertions(+), 61 deletions(-) diff --git a/crates/but-graph/src/api.rs b/crates/but-graph/src/api.rs index 43592cff27..87dfc28668 100644 --- a/crates/but-graph/src/api.rs +++ b/crates/but-graph/src/api.rs @@ -1,5 +1,8 @@ use crate::init::PetGraph; -use crate::{CommitFlags, CommitIndex, Edge, EntryPoint, Graph, Segment, SegmentIndex}; +use crate::{ + CommitFlags, CommitIndex, Edge, EntryPoint, Graph, Segment, SegmentIndex, SegmentMetadata, + Statistics, +}; use anyhow::{Context, bail}; use bstr::ByteSlice; use gix::refs::Category; @@ -177,7 +180,7 @@ impl Graph { } /// Return the number of edges that are connecting segments. - pub fn num_edges(&self) -> usize { + pub fn num_connections(&self) -> usize { self.inner.edge_count() } @@ -190,19 +193,124 @@ impl Graph { .sum::() } - /// Return the number segments whose commits are all exclusively in a remote. - pub fn num_remote_segments(&self) -> usize { - self.inner - .raw_nodes() - .iter() - .map(|n| usize::from(n.weight.commits.iter().all(|c| c.flags.is_empty()))) - .sum::() - } - /// Return an iterator over all indices of segments in the graph. pub fn segments(&self) -> impl Iterator { self.inner.node_indices() } + + /// Return the number segments whose commits are all exclusively in a remote. + pub fn statistics(&self) -> Statistics { + let mut out = Statistics::default(); + let Statistics { + segments, + segments_integrated, + segments_remote, + segments_with_remote_tracking_branch, + segments_empty, + segments_unnamed, + segments_in_workspace, + segments_in_workspace_and_integrated, + segments_with_workspace_metadata, + segments_with_branch_metadata, + entrypoint_in_workspace, + segments_behind_of_entrypoint, + segments_ahead_of_entrypoint, + connections, + commits, + commit_references, + commits_at_cutoff, + } = &mut out; + + *segments = self.inner.node_count(); + *connections = self.inner.edge_count(); + + if let Ok(ep) = self.lookup_entrypoint() { + *entrypoint_in_workspace = ep + .segment + .commits + .first() + .map(|c| c.flags.contains(CommitFlags::InWorkspace)); + for (storage, direction, start_cidx) in [ + ( + segments_behind_of_entrypoint, + Direction::Outgoing, + ep.segment.commits.first().map(|_| 0), + ), + ( + segments_ahead_of_entrypoint, + Direction::Incoming, + ep.segment.commits.last().map(|_| ep.segment.commits.len()), + ), + ] { + let mut walk = crate::init::walk::TopoWalk::start_from( + ep.segment_index, + start_cidx, + direction, + ) + .skip_tip_segment(); + while walk.next(&self.inner).is_some() { + *storage += 1; + } + } + } + + for node in self.inner.raw_nodes() { + let n = &node.weight; + *commits += n.commits.len(); + + if n.ref_name.is_none() { + *segments_unnamed += 1; + } + if n.remote_tracking_ref_name.is_some() { + *segments_with_remote_tracking_branch += 1; + } + match n.metadata { + None => {} + Some(SegmentMetadata::Workspace(_)) => { + *segments_with_workspace_metadata += 1; + } + Some(SegmentMetadata::Branch(_)) => { + *segments_with_branch_metadata += 1; + } + } + // We assume proper segmentation, so the first commit is all we need + match n.commits.first() { + Some(c) => { + if c.flags.contains(CommitFlags::InWorkspace) { + *segments_in_workspace += 1 + } + if c.flags.contains(CommitFlags::Integrated) { + *segments_integrated += 1 + } + if c.flags + .contains(CommitFlags::InWorkspace | CommitFlags::Integrated) + { + *segments_in_workspace_and_integrated += 1 + } + if c.flags.is_empty() { + *segments_remote += 1; + } + } + None => { + *segments_empty += 1; + } + } + + *commit_references += n.commits.iter().map(|c| c.refs.len()).sum::(); + } + + for sidx in self.inner.node_indices() { + *commits_at_cutoff += usize::from(self[sidx].commits.last().is_some_and(|c| { + !c.parent_ids.is_empty() + && self + .inner + .edges_directed(sidx, Direction::Outgoing) + .next() + .is_none() + })); + } + out + } } /// Debugging diff --git a/crates/but-graph/src/init/mod.rs b/crates/but-graph/src/init/mod.rs index ec1826e4b7..341dba1beb 100644 --- a/crates/but-graph/src/init/mod.rs +++ b/crates/but-graph/src/init/mod.rs @@ -16,7 +16,7 @@ use utils::*; mod remotes; mod post; -mod walk; +pub(crate) mod walk; pub(super) type PetGraph = petgraph::Graph; @@ -273,7 +273,7 @@ impl Graph { let mut ws_segment = branch_segment_from_name_and_meta(Some(ws_ref), meta, None)?; // Drop the limit if we have a target ref let limit = if workspace_info.target_ref.is_some() { - Limit::unspecified() + limit.with_goal(tip.detach()) } else { limit }; @@ -306,7 +306,7 @@ impl Graph { into: target_segment, }, /* unlimited traversal for integrated commits */ - Limit::unspecified(), + limit.with_goal(tip.detach()), )) { return Ok(graph.with_hard_limit()); } @@ -438,7 +438,7 @@ impl Graph { return Ok(graph.with_hard_limit()); } - prune_integrated_tips(&mut graph.inner, &mut next, &desired_refs, max_limit); + prune_integrated_tips(&mut graph, &mut next, &desired_refs, max_limit); } graph.post_processed( diff --git a/crates/but-graph/src/init/post.rs b/crates/but-graph/src/init/post.rs index 7a33b0e407..32e27e802c 100644 --- a/crates/but-graph/src/init/post.rs +++ b/crates/but-graph/src/init/post.rs @@ -172,7 +172,8 @@ impl Graph { ) -> anyhow::Result<()> { // Map (segment-to-be-named, [candidate-remote]), so we don't set a name if there is more // than one remote. - let mut remotes_by_segment_map = BTreeMap::>::new(); + let mut remotes_by_segment_map = + BTreeMap::>::new(); for (remote_sidx, remote_ref_name) in self.inner.node_indices().filter_map(|sidx| { self[sidx] @@ -182,8 +183,8 @@ impl Graph { .map(|rn| (sidx, rn)) }) { let start_idx = self[remote_sidx].commits.first().map(|_| 0); - let mut walk = - TopoWalk::start_from(remote_sidx, start_idx, Direction::Outgoing).skip_tip(); + let mut walk = TopoWalk::start_from(remote_sidx, start_idx, Direction::Outgoing) + .skip_tip_segment(); while let Some((sidx, commit_range)) = walk.next(&self.inner) { let segment = &self[sidx]; @@ -204,7 +205,7 @@ impl Graph { .iter() .all(|c| c.flags.contains(CommitFlags::NotInRemote)) { - // a candidate for naming, and we'd either expect all or none of the comits + // a candidate for naming, and we'd either expect all or none of the commits // to be in or outside a remote. let first_commit = segment.commits.first().expect("we know there is commits"); if let Some(local_tracking_branch) = first_commit.refs.iter().find_map(|rn| { @@ -223,7 +224,7 @@ impl Graph { remotes_by_segment_map .entry(sidx) .or_default() - .push(local_tracking_branch); + .push((local_tracking_branch, remote_ref_name.clone())); } break; } @@ -237,10 +238,29 @@ impl Graph { .filter(|(_, candidates)| candidates.len() == 1) { let s = &mut self[anon_sidx]; - s.ref_name = disambiguated_name.pop(); + let (local, remote) = disambiguated_name.pop().expect("one item as checked above"); + s.ref_name = Some(local); + s.remote_tracking_ref_name = Some(remote); let rn = s.ref_name.as_ref().unwrap(); s.commits.first_mut().unwrap().refs.retain(|crn| crn != rn); } + + // TODO: we should probably try to set this right when we traverse the segment + // to save remote-ref lookup. + for segment in self.inner.node_weights_mut() { + if segment.remote_tracking_ref_name.is_some() { + continue; + }; + let Some(ref_name) = segment.ref_name.as_ref() else { + continue; + }; + segment.remote_tracking_ref_name = remotes::lookup_remote_tracking_branch_or_deduce_it( + repo, + ref_name.as_ref(), + symbolic_remote_names, + configured_remote_tracking_branches, + )?; + } Ok(()) } } diff --git a/crates/but-graph/src/init/utils.rs b/crates/but-graph/src/init/utils.rs index 2ac125f5e3..77221be317 100644 --- a/crates/but-graph/src/init/utils.rs +++ b/crates/but-graph/src/init/utils.rs @@ -539,6 +539,8 @@ pub fn try_refname_to_id( .map(|id| id.detach())) } +/// Propagation is always called if one segment reaches another one, which is when the flag +/// among the shared commit are send downward, towards the base. pub fn propagate_flags_downward( graph: &mut PetGraph, next: &mut Queue, @@ -560,6 +562,32 @@ pub fn propagate_flags_downward( } } +pub fn propagate_limit_upward( + graph: &mut PetGraph, + next: &mut Queue, + limit: Limit, + dst_sidx: SegmentIndex, +) { + let Some(goal) = limit.goal else { + return; + }; + let commits = &graph[dst_sidx].commits; + let mut topo = TopoWalk::start_from( + dst_sidx, + commits.last().map(|_| commits.len()), + petgraph::Direction::Incoming, + ); + while let Some((segment, commit_range)) = topo.next(graph) { + for commit in &mut graph[segment].commits[commit_range] { + // In case the goal was already seen, find it and assure we don't traverse it anymore, + // the graphs are now connected. + if commit.id == goal { + next.inner.iter_mut().for_each(|t| t.3.goal = None); + } + } + } +} + /// Check `refs` for refs with remote tracking branches, and queue them for traversal after creating a segment named /// after the tracking branch. /// This eager queuing makes sure that the post-processing doesn't have to traverse again when it creates segments @@ -613,6 +641,8 @@ pub fn try_queue_remote_tracking_branches( Instruction::CollectCommit { into: remote_segment, }, + // If the remote is behind the goal it will never reach it, but it will stop once it + // touches another commit as well. limit.with_goal(id), )) { return Ok(true); @@ -630,7 +660,7 @@ pub fn try_queue_remote_tracking_branches( /// `max_limit` is applied to integrated workspace refs if they are not yet limited, and should /// be the initially configured limit. pub fn prune_integrated_tips( - graph: &mut PetGraph, + graph: &mut Graph, next: &mut Queue, workspace_refs: &BTreeSet, max_limit: Limit, @@ -642,6 +672,10 @@ pub fn prune_integrated_tips( return; } next.retain_mut(|(_id, _flags, instruction, tip_limit)| { + // Let them reach their goal - this could lead to runaways, if the integration branch is behind the entrypoint. + if tip_limit.goal.is_some() { + return true; + } let sidx = instruction.segment_idx(); let s = &graph[sidx]; let any_segment_ref_is_contained_in_workspace = s @@ -655,9 +689,9 @@ pub fn prune_integrated_tips( *tip_limit = max_limit; } if !any_segment_ref_is_contained_in_workspace && s.commits.is_empty() { - graph.remove_node(sidx); + graph.inner.remove_node(sidx); } - any_segment_ref_is_contained_in_workspace + any_segment_ref_is_contained_in_workspace || tip_limit.goal.is_some() }); } @@ -729,6 +763,7 @@ pub fn possibly_split_occupied_segment( bottom_sidx, Some(bottom_cidx), ); + propagate_limit_upward(&mut graph.inner, next, limit, bottom_sidx); Ok(()) } @@ -774,11 +809,7 @@ impl Limit { } impl Limit { - /// Allow unlimited traversal. - pub fn unspecified() -> Self { - Limit::from(None) - } - + /// Keep queueing without limit until `goal` is seen during [propagate_flags_downward()] and [propagate_limit_upward()]. pub fn with_goal(mut self, goal: gix::ObjectId) -> Self { self.goal = Some(goal); self diff --git a/crates/but-graph/src/init/walk.rs b/crates/but-graph/src/init/walk.rs index 69418d2605..b8a8d3fa6e 100644 --- a/crates/but-graph/src/init/walk.rs +++ b/crates/but-graph/src/init/walk.rs @@ -53,7 +53,7 @@ impl TopoWalk { /// Builder impl TopoWalk { /// Call to not return the tip as part of the iteration. - pub fn skip_tip(mut self) -> Self { + pub fn skip_tip_segment(mut self) -> Self { self.skip_tip = Some(()); self } @@ -107,7 +107,8 @@ impl TopoWalk { { continue; } - self.next.push_back((edge.source(), edge.weight().src)); + self.next + .push_back((edge.source(), edge.weight().src.map(|cidx| cidx + 1))); } } } diff --git a/crates/but-graph/src/lib.rs b/crates/but-graph/src/lib.rs index 6c84d8e88e..67835d8470 100644 --- a/crates/but-graph/src/lib.rs +++ b/crates/but-graph/src/lib.rs @@ -22,6 +22,50 @@ pub struct Graph { hard_limit_hit: bool, } +/// All kinds of numbers generated from a graph, returned by [Graph::statistics()]. +/// +/// Note that the segment counts aren't mutually exclusive, so the sum of these fields can be more +/// than the total of segments. +#[derive(Default, Debug, Copy, Clone)] +pub struct Statistics { + /// The number of segments in the graph. + pub segments: usize, + /// Segments where all commits are integrated. + pub segments_integrated: usize, + /// Segments where all commits are on a remote tracking branch. + pub segments_remote: usize, + /// Segments where the remote tracking branch is set + pub segments_with_remote_tracking_branch: usize, + /// Segments that are empty. + pub segments_empty: usize, + /// Segments that are anonymous. + pub segments_unnamed: usize, + /// Segments that are reachable by the workspace commit. + pub segments_in_workspace: usize, + /// Segments that are reachable by the workspace commit and are integrated. + pub segments_in_workspace_and_integrated: usize, + /// Segments that have metadata for workspaces. + pub segments_with_workspace_metadata: usize, + /// Segments that have metadata for branches. + pub segments_with_branch_metadata: usize, + /// `true` if the start of the traversal is in a workspace. + /// `None` if the information could not be determined, maybe because the entrypoint + /// is invalid (bug) or it's empty (unusual) + pub entrypoint_in_workspace: Option, + /// Segments, excluding the entrypoint, that can be reached downwards through the entrypoint. + pub segments_behind_of_entrypoint: usize, + /// Segments, excluding the entrypoint, that can be reached upwards through the entrypoint. + pub segments_ahead_of_entrypoint: usize, + /// Connections between segments. + pub connections: usize, + /// All commits within segments. + pub commits: usize, + /// All references stored with commits, i.e. not the ref-names absorbed by segments. + pub commit_references: usize, + /// The traversal was stopped at this many commits. + pub commits_at_cutoff: usize, +} + /// A resolved entry point into the graph for easy access to the segment, commit, /// and the respective indices for later traversal. #[derive(Debug, Copy, Clone)] diff --git a/crates/but-graph/tests/graph/init/mod.rs b/crates/but-graph/tests/graph/init/mod.rs index 1141e00a5c..c2e6362d44 100644 --- a/crates/but-graph/tests/graph/init/mod.rs +++ b/crates/but-graph/tests/graph/init/mod.rs @@ -206,7 +206,7 @@ fn four_diamond() -> anyhow::Result<()> { ); assert_eq!(graph.num_commits(), 8, "one commit per node"); assert_eq!( - graph.num_edges(), + graph.num_connections(), 10, "however, we see only a portion of the edges as the tree can only show simple stacks" ); @@ -229,9 +229,9 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { let graph = Graph::from_head(&repo, &*meta, standard_options().with_limit_hint(1))?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" - β”œβ”€β”€ πŸ‘‰β–Ί:0:B + β”œβ”€β”€ πŸ‘‰β–Ί:0:B <> origin/B β”‚ └── Β·312f819 (βŒ‚)❱"B" - β”‚ └── β–Ί:2:A + β”‚ └── β–Ί:2:A <> origin/A β”‚ └── Β·e255adc (βŒ‚)❱"A" β”‚ └── β–Ί:4:main β”‚ └── Β·fafd9d0 (βŒ‚)❱"init" @@ -259,9 +259,9 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { // Everything we encounter is checked for remotes. let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" - β”œβ”€β”€ πŸ‘‰β–Ί:0:B + β”œβ”€β”€ πŸ‘‰β–Ί:0:B <> origin/B β”‚ └── Β·312f819 (βŒ‚)❱"B" - β”‚ └── β–Ί:2:A + β”‚ └── β–Ί:2:A <> origin/A β”‚ └── Β·e255adc (βŒ‚)❱"A" β”‚ └── β–Ί:4:main β”‚ └── Β·fafd9d0 (βŒ‚)❱"init" @@ -276,7 +276,7 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { let (id, name) = id_at(&repo, "A"); let graph = Graph::from_commit_traversal(id, name, &*meta, standard_options())?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" - β”œβ”€β”€ πŸ‘‰β–Ί:0:A + β”œβ”€β”€ πŸ‘‰β–Ί:0:A <> origin/A β”‚ └── Β·e255adc (βŒ‚)❱"A" β”‚ └── β–Ί:2:main β”‚ └── Β·fafd9d0 (βŒ‚)❱"init" @@ -432,6 +432,30 @@ fn with_limits() -> anyhow::Result<()> { β”œβ”€β”€ Β·4f1f248 (βŒ‚)❱"C2" └── βœ‚οΈΒ·487ffce (βŒ‚)❱"C1" "#); + + insta::assert_debug_snapshot!(graph.statistics(), @r" + Statistics { + segments: 5, + segments_integrated: 0, + segments_remote: 0, + segments_with_remote_tracking_branch: 0, + segments_empty: 0, + segments_unnamed: 1, + segments_in_workspace: 0, + segments_in_workspace_and_integrated: 0, + segments_with_workspace_metadata: 0, + segments_with_branch_metadata: 0, + entrypoint_in_workspace: Some( + false, + ), + segments_behind_of_entrypoint: 4, + segments_ahead_of_entrypoint: 0, + connections: 4, + commits: 12, + commit_references: 0, + commits_at_cutoff: 3, + } + "); Ok(()) } diff --git a/crates/but-graph/tests/graph/init/with_workspace.rs b/crates/but-graph/tests/graph/init/with_workspace.rs index 5767d58329..fcffcdb8ff 100644 --- a/crates/but-graph/tests/graph/init/with_workspace.rs +++ b/crates/but-graph/tests/graph/init/with_workspace.rs @@ -389,7 +389,7 @@ fn minimal_merge() -> anyhow::Result<()> { β”‚ β”‚ └── Β·0cc5a6f (βŒ‚)❱"Merge branch \'A\' into merge" β–Ίempty-1-on-merge, β–Ίempty-2-on-merge, β–Ίmerge β”‚ β”‚ β”œβ”€β”€ β–Ί:6:A β”‚ β”‚ β”‚ └── Β·e255adc (βŒ‚)❱"A" - β”‚ β”‚ β”‚ └── β–Ί:7:main + β”‚ β”‚ β”‚ └── β–Ί:7:main <> origin/main β”‚ β”‚ β”‚ └── Β·fafd9d0 (βŒ‚)❱"init" β”‚ β”‚ └── β–Ί:5:B β”‚ β”‚ └── Β·7fdb58d (βŒ‚)❱"B" @@ -447,7 +447,7 @@ fn just_init_with_branches() -> anyhow::Result<()> { add_workspace(&mut meta); let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" - β”œβ”€β”€ πŸ‘‰β–Ί:0:main + β”œβ”€β”€ πŸ‘‰β–Ί:0:main <> origin/main β”‚ └── β–Ί:2:origin/main β”‚ └── Β·fafd9d0 (βŒ‚|🏘️|βœ“)❱"init" β–ΊA, β–ΊB, β–ΊC, β–ΊD, β–ΊE, β–ΊF, β–Ίmain └── β–Ίβ–Ίβ–Ί:1:gitbutler/workspace @@ -475,7 +475,7 @@ fn just_init_with_branches() -> anyhow::Result<()> { // also: order is wrong now due to target branch handling // - needs insertion of multi-segment above 'fixed' references like the target branch. insta::assert_snapshot!(graph_tree(&graph), @r#" - β”œβ”€β”€ πŸ‘‰β–Ί:0:main + β”œβ”€β”€ πŸ‘‰β–Ί:0:main <> origin/main β”‚ └── β–Ί:2:origin/main β”‚ └── β–Ί:3:C β”‚ └── β–Ί:4:B @@ -505,7 +505,7 @@ fn proper_remote_ahead() -> anyhow::Result<()> { insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace β”‚ └── Β·9bcd3af (βŒ‚|🏘️)❱"GitButler Workspace Commit" - β”‚ └── β–Ί:2:main + β”‚ └── β–Ί:2:main <> origin/main β”‚ β”œβ”€β”€ Β·998eae6 (βŒ‚|🏘️|βœ“)❱"shared" β”‚ └── Β·fafd9d0 (βŒ‚|🏘️|βœ“)❱"init" └── β–Ί:1:origin/main @@ -539,7 +539,7 @@ fn deduced_remote_ahead() -> anyhow::Result<()> { insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace β”‚ └── Β·8b39ce4 (βŒ‚|🏘️)❱"GitButler Workspace Commit" - β”‚ └── β–Ί:1:A + β”‚ └── β–Ί:1:A <> origin/A β”‚ β”œβ”€β”€ Β·9d34471 (βŒ‚|🏘️)❱"A2" β”‚ └── Β·5b89c71 (βŒ‚|🏘️)❱"A1" β”‚ └── β–Ί:5:anon: @@ -562,7 +562,7 @@ fn deduced_remote_ahead() -> anyhow::Result<()> { insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ β–Ίβ–Ίβ–Ί:1:gitbutler/workspace β”‚ └── Β·8b39ce4 (βŒ‚|🏘️)❱"GitButler Workspace Commit" - β”‚ └── β–Ί:2:A + β”‚ └── β–Ί:2:A <> origin/A β”‚ β”œβ”€β”€ Β·9d34471 (βŒ‚|🏘️)❱"A2" β”‚ └── Β·5b89c71 (βŒ‚|🏘️)❱"A1" β”‚ └── β–Ί:5:anon: @@ -602,9 +602,9 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace β”‚ └── Β·7786959 (βŒ‚|🏘️)❱"GitButler Workspace Commit" - β”‚ └── β–Ί:2:B + β”‚ └── β–Ί:2:B <> origin/B β”‚ └── Β·312f819 (βŒ‚|🏘️)❱"B" - β”‚ └── β–Ί:4:A + β”‚ └── β–Ί:4:A <> origin/A β”‚ └── Β·e255adc (βŒ‚|🏘️)❱"A" β”‚ └── β–Ί:1:origin/main β”‚ └── Β·fafd9d0 (βŒ‚|🏘️|βœ“)❱"init" β–Ίmain @@ -621,9 +621,9 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ β–Ίβ–Ίβ–Ί:1:gitbutler/workspace β”‚ └── Β·7786959 (βŒ‚|🏘️)❱"GitButler Workspace Commit" - β”‚ └── β–Ί:4:B + β”‚ └── β–Ί:4:B <> origin/B β”‚ └── Β·312f819 (βŒ‚|🏘️)❱"B" - β”‚ └── πŸ‘‰β–Ί:0:A + β”‚ └── πŸ‘‰β–Ί:0:A <> origin/A β”‚ └── Β·e255adc (βŒ‚|🏘️)❱"A" β”‚ └── β–Ί:2:origin/main β”‚ └── Β·fafd9d0 (βŒ‚|🏘️|βœ“)❱"init" β–Ίmain @@ -633,7 +633,29 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { └── 🟣e29c23d❱"A" └── β†’:2: (origin/main) "#); - assert_eq!(graph.num_remote_segments(), 2); + insta::assert_debug_snapshot!(graph.statistics(), @r" + Statistics { + segments: 6, + segments_integrated: 1, + segments_remote: 2, + segments_with_remote_tracking_branch: 2, + segments_empty: 0, + segments_unnamed: 0, + segments_in_workspace: 4, + segments_in_workspace_and_integrated: 1, + segments_with_workspace_metadata: 1, + segments_with_branch_metadata: 0, + entrypoint_in_workspace: Some( + true, + ), + segments_behind_of_entrypoint: 1, + segments_ahead_of_entrypoint: 2, + connections: 5, + commits: 6, + commit_references: 1, + commits_at_cutoff: 0, + } + "); Ok(()) } @@ -666,9 +688,9 @@ fn disambiguate_by_remote() -> anyhow::Result<()> { β”‚ └── Β·e30f90c (βŒ‚|🏘️)❱"GitButler Workspace Commit" β”‚ └── β–Ί:5:anon: β”‚ └── Β·2173153 (βŒ‚|🏘️)❱"C" β–ΊC, β–Ίambiguous-C - β”‚ └── β–Ί:8:B + β”‚ └── β–Ί:8:B <> origin/B β”‚ └── Β·312f819 (βŒ‚|🏘️)❱"B" β–Ίambiguous-B - β”‚ └── β–Ί:7:A + β”‚ └── β–Ί:7:A <> origin/A β”‚ └── Β·e255adc (βŒ‚|🏘️)❱"A" β–Ίambiguous-A β”‚ └── β–Ί:1:origin/main β”‚ └── Β·fafd9d0 (βŒ‚|🏘️|βœ“)❱"init" β–Ίmain diff --git a/crates/but-testing/src/command/mod.rs b/crates/but-testing/src/command/mod.rs index f5c5a0fe41..79f111d992 100644 --- a/crates/but-testing/src/command/mod.rs +++ b/crates/but-testing/src/command/mod.rs @@ -549,17 +549,7 @@ pub fn graph( } }?; - eprintln!( - "Graph with {num_segments} segments ({num_remote_segments} of which remote), {num_edges} edges and {num_commits} commits{hard_limit}", - num_segments = graph.num_segments(), - num_edges = graph.num_edges(), - num_commits = graph.num_commits(), - num_remote_segments = graph.num_remote_segments(), - hard_limit = graph - .hard_limit_hit() - .then_some(" (HARD LIMIT REACHED)") - .unwrap_or_default() - ); + eprintln!("{:#?}", graph.statistics()); if no_open { stdout().write_all(graph.dot_graph().as_bytes())?; } else { From 53f9577d13f53d1b70df2c250b4e45ae4bbbc8f7 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Fri, 20 Jun 2025 08:02:24 +0200 Subject: [PATCH 7/8] Avoid propagating limit goals upward by rather propagating goals downward. This is a per-goal bitflag --- crates/but-graph/src/api.rs | 92 ++++-- crates/but-graph/src/init/mod.rs | 112 ++++--- crates/but-graph/src/init/post.rs | 17 +- crates/but-graph/src/init/utils.rs | 240 ++++++++------- crates/but-graph/src/lib.rs | 18 +- crates/but-graph/src/segment.rs | 94 +++--- crates/but-graph/tests/graph/init/mod.rs | 182 +++++++----- .../tests/graph/init/with_workspace.rs | 280 ++++++++++-------- crates/but-graph/tests/graph/utils.rs | 6 +- crates/but-graph/tests/graph/vis.rs | 24 +- crates/but-testing/src/args.rs | 3 + crates/but-testing/src/command/mod.rs | 9 + crates/but-testing/src/main.rs | 2 + crates/but-workspace/src/ref_info.rs | 77 ++++- crates/but-workspace/src/stacks.rs | 4 +- 15 files changed, 705 insertions(+), 455 deletions(-) diff --git a/crates/but-graph/src/api.rs b/crates/but-graph/src/api.rs index 87dfc28668..77cdb5d183 100644 --- a/crates/but-graph/src/api.rs +++ b/crates/but-graph/src/api.rs @@ -215,6 +215,11 @@ impl Graph { entrypoint_in_workspace, segments_behind_of_entrypoint, segments_ahead_of_entrypoint, + entrypoint, + segment_entrypoint_incoming, + segment_entrypoint_outgoing, + top_segments, + segments_at_bottom, connections, commits, commit_references, @@ -223,6 +228,19 @@ impl Graph { *segments = self.inner.node_count(); *connections = self.inner.edge_count(); + *top_segments = self + .tip_segments() + .map(|s| { + let s = &self[s]; + ( + s.ref_name.as_ref().map(|rn| rn.clone()), + s.id, + s.flags_of_first_commit(), + ) + }) + .collect(); + *segments_at_bottom = self.base_segments().count(); + *entrypoint = self.entrypoint.unwrap_or_default(); if let Ok(ep) = self.lookup_entrypoint() { *entrypoint_in_workspace = ep @@ -230,6 +248,14 @@ impl Graph { .commits .first() .map(|c| c.flags.contains(CommitFlags::InWorkspace)); + *segment_entrypoint_incoming = self + .inner + .edges_directed(ep.segment_index, Direction::Incoming) + .count(); + *segment_entrypoint_outgoing = self + .inner + .edges_directed(ep.segment_index, Direction::Outgoing) + .count(); for (storage, direction, start_cidx) in [ ( segments_behind_of_entrypoint, @@ -287,7 +313,7 @@ impl Graph { { *segments_in_workspace_and_integrated += 1 } - if c.flags.is_empty() { + if c.flags.is_remote() { *segments_remote += 1; } } @@ -320,11 +346,19 @@ impl Graph { // TODO: maybe make this mandatory as part of post-processing. pub fn validated(self) -> anyhow::Result { for edge in self.inner.edge_references() { - check_edge(&self.inner, edge)?; + check_edge(&self.inner, edge, false)?; } Ok(self) } + /// Validate the graph for consistency and return all errors. + pub fn validation_errors(&self) -> Vec { + self.inner + .edge_references() + .filter_map(|edge| check_edge(&self.inner, edge, false).err()) + .collect() + } + /// Produce a string that concisely represents `commit`, adding `extra` information as needed. pub fn commit_debug_string( commit: &crate::Commit, @@ -354,11 +388,13 @@ impl Graph { "".to_string() }, hex = commit.id.to_hex_with_len(7), - msg = if show_message { - format!("❱{:?}", commit.message.trim().as_bstr()) - } else { - "".into() - }, + msg = commit + .details + .as_ref() + .map(|d| d.message.trim().as_bstr()) + .filter(|_| show_message) + .map(|msg| { format!("❱{:?}", msg.trim().as_bstr()) }) + .unwrap_or_default(), refs = if commit.refs.is_empty() { "".to_string() } else { @@ -395,7 +431,7 @@ impl Graph { #[cfg(unix)] pub fn validated_or_open_as_svg(self) -> anyhow::Result { for edge in self.inner.edge_references() { - let res = check_edge(&self.inner, edge); + let res = check_edge(&self.inner, edge, false); if res.is_err() { self.open_as_svg(); } @@ -484,12 +520,20 @@ impl Graph { let entrypoint = self.entrypoint; let node_attrs = |_: &PetGraph, (sidx, s): (SegmentIndex, &Segment)| { let name = format!( - "{}{maybe_centering_newline}", + "{}{remote}{maybe_centering_newline}", s.ref_name .as_ref() .map(Self::ref_debug_string) .unwrap_or_else(|| "".into()), - maybe_centering_newline = if s.commits.is_empty() { "" } else { "\n" } + maybe_centering_newline = if s.commits.is_empty() { "" } else { "\n" }, + remote = if let Some(remote_ref_name) = s.remote_tracking_ref_name.as_ref() { + format!( + " <> {remote_name}", + remote_name = Self::ref_debug_string(remote_ref_name) + ) + } else { + "".into() + } ); // Reduce noise by preferring ref-based entry-points. let show_segment_entrypoint = s.ref_name.is_some() @@ -501,7 +545,10 @@ impl Graph { .map(|(cidx, c)| { Self::commit_debug_string( c, - c.has_conflicts, + c.details + .as_ref() + .map(|d| d.has_conflicts) + .unwrap_or_default(), !show_segment_entrypoint && Some((sidx, Some(cidx))) == entrypoint, false, self.is_early_end_of_traversal(sidx, cidx), @@ -526,7 +573,7 @@ impl Graph { } // Don't mark connections from the last commit to the first one, // but those that are 'splitting' a segment. These shouldn't exist. - let Err(err) = check_edge(g, e) else { + let Err(err) = check_edge(g, e, true) else { return ", label = \"\"".into(); }; let e = e.weight(); @@ -546,30 +593,41 @@ impl Graph { } /// Fail with an error if the `edge` isn't consistent. -fn check_edge(graph: &PetGraph, edge: EdgeReference<'_, Edge>) -> anyhow::Result<()> { +fn check_edge( + graph: &PetGraph, + edge: EdgeReference<'_, Edge>, + weight_only: bool, +) -> anyhow::Result<()> { let e = edge; let src = &graph[e.source()]; let dst = &graph[e.target()]; let w = e.weight(); + let display = if weight_only { + w as &dyn std::fmt::Debug + } else { + &e as &dyn std::fmt::Debug + }; if w.src != src.last_commit_index() { bail!( - "{w:?}: edge must start on last commit {last:?}", + "{display:?}: edge must start on last commit {last:?}", last = src.last_commit_index() ); } let first_index = dst.commits.first().map(|_| 0); if w.dst != first_index { - bail!("{w:?}: edge must end on {first_index:?}"); + bail!("{display:?}: edge must end on {first_index:?}"); } let seg_cidx = src.commit_id_by_index(w.src); if w.src_id != seg_cidx { - bail!("{w:?}: the desired source index didn't match the one in the segment {seg_cidx:?}"); + bail!( + "{display:?}: the desired source index didn't match the one in the segment {seg_cidx:?}" + ); } let seg_cidx = dst.commit_id_by_index(w.dst); if w.dst_id != seg_cidx { bail!( - "{w:?}: the desired destination index didn't match the one in the segment {seg_cidx:?}" + "{display:?}: the desired destination index didn't match the one in the segment {seg_cidx:?}" ); } Ok(()) diff --git a/crates/but-graph/src/init/mod.rs b/crates/but-graph/src/init/mod.rs index 341dba1beb..ac376133b2 100644 --- a/crates/but-graph/src/init/mod.rs +++ b/crates/but-graph/src/init/mod.rs @@ -206,15 +206,18 @@ impl Graph { }), )?; let (workspaces, target_refs, desired_refs) = - obtain_workspace_infos(ref_name.as_ref().map(|rn| rn.as_ref()), meta)?; + obtain_workspace_infos(repo, ref_name.as_ref().map(|rn| rn.as_ref()), meta)?; let mut seen = gix::revwalk::graph::IdMap::::default(); - let tip_flags = CommitFlags::NotInRemote; + let mut goals = Goals::default(); + let tip_limit_with_goal = limit.with_goal(tip.detach(), &mut goals); + // The tip transports itself. + let tip_flags = CommitFlags::NotInRemote | tip_limit_with_goal.goal; let target_symbolic_remote_names = { let remote_names = repo.remote_names(); let mut v: Vec<_> = workspaces .iter() - .filter_map(|(_, data)| { + .filter_map(|(_, _, data)| { let target_ref = data.target_ref.as_ref()?; remotes::extract_remote_name(target_ref.as_ref(), &remote_names) }) @@ -227,7 +230,7 @@ impl Graph { let mut next = Queue::new_with_limit(hard_limit); if !workspaces .iter() - .any(|(wsrn, _)| Some(wsrn) == ref_name.as_ref()) + .any(|(_, wsrn, _)| Some(wsrn) == ref_name.as_ref()) { let current = graph.insert_root(branch_segment_from_name_and_meta( ref_name.clone(), @@ -243,14 +246,7 @@ impl Graph { return Ok(graph.with_hard_limit()); } } - for (ws_ref, workspace_info) in workspaces { - let Some(ws_tip) = try_refname_to_id(repo, ws_ref.as_ref())? else { - tracing::warn!( - "Ignoring stale workspace ref '{ws_ref}', which didn't exist in Git but still had workspace data", - ws_ref = ws_ref.as_bstr() - ); - continue; - }; + for (ws_tip, ws_ref, workspace_info) in workspaces { let target = workspace_info.target_ref.as_ref().and_then(|trn| { try_refname_to_id(repo, trn.as_ref()) .map_err(|err| { @@ -265,18 +261,14 @@ impl Graph { .map(|tid| (trn.clone(), tid)) }); - let add_flags = if Some(&ws_ref) == ref_name.as_ref() { - tip_flags + let (ws_flags, ws_limit) = if Some(&ws_ref) == ref_name.as_ref() { + (tip_flags, limit) } else { - CommitFlags::empty() + (CommitFlags::empty(), tip_limit_with_goal) }; let mut ws_segment = branch_segment_from_name_and_meta(Some(ws_ref), meta, None)?; - // Drop the limit if we have a target ref - let limit = if workspace_info.target_ref.is_some() { - limit.with_goal(tip.detach()) - } else { - limit - }; + // The limits for the target ref and the worktree ref are synced so they can always find each other, + // while being able to stop when the entrypoint is included. ws_segment.metadata = Some(SegmentMetadata::Workspace(workspace_info)); let ws_segment = graph.insert_root(ws_segment); // As workspaces typically have integration branches which can help us to stop the traversal, @@ -287,9 +279,9 @@ impl Graph { // We only allow workspaces that are not remote, and that are not target refs. // Theoretically they can still cross-reference each other, but then we'd simply ignore // their status for now. - CommitFlags::NotInRemote | add_flags, + CommitFlags::NotInRemote | ws_flags, Instruction::CollectCommit { into: ws_segment }, - limit, + ws_limit, )) { return Ok(graph.with_hard_limit()); } @@ -305,8 +297,7 @@ impl Graph { Instruction::CollectCommit { into: target_segment, }, - /* unlimited traversal for integrated commits */ - limit.with_goal(tip.detach()), + tip_limit_with_goal, )) { return Ok(graph.with_hard_limit()); } @@ -319,7 +310,7 @@ impl Graph { let max_limit = limit; while let Some((id, mut propagated_flags, instruction, mut limit)) = next.pop_front() { if max_commits_recharge_location.binary_search(&id).is_ok() { - limit.inner = max_limit.inner; + limit.set_but_keep_goal(max_limit); } let info = find(commit_graph.as_ref(), repo, id, &mut buf)?; let src_flags = graph[instruction.segment_idx()] @@ -390,8 +381,23 @@ impl Graph { }, }; + let refs_at_commit_before_removal = refs_by_id.remove(&id).unwrap_or_default(); + let (remote_items, maybe_goal_for_id) = try_queue_remote_tracking_branches( + repo, + &refs_at_commit_before_removal, + &mut graph, + &target_symbolic_remote_names, + &configured_remote_tracking_branches, + &target_refs, + meta, + id, + limit, + &mut goals, + )?; + let segment = &mut graph[segment_idx_for_id]; let commit_idx_for_possible_fork = segment.commits.len(); + let propagated_flags = propagated_flags | maybe_goal_for_id; let hard_limit_hit = queue_parents( &mut next, &info.parent_ids, @@ -404,10 +410,8 @@ impl Graph { return Ok(graph.with_hard_limit()); } - let refs_at_commit_before_removal = refs_by_id.remove(&id).unwrap_or_default(); segment.commits.push( info.into_commit( - repo, segment .commits // Flags are additive, and meanwhile something may have dumped flags on us @@ -422,20 +426,10 @@ impl Graph { )?, ); - let hard_limit_hit = try_queue_remote_tracking_branches( - repo, - &refs_at_commit_before_removal, - &mut next, - &mut graph, - &target_symbolic_remote_names, - &configured_remote_tracking_branches, - &target_refs, - meta, - id, - limit, - )?; - if hard_limit_hit { - return Ok(graph.with_hard_limit()); + for item in remote_items { + if next.push_back_exhausted(item) { + return Ok(graph.with_hard_limit()); + } } prune_integrated_tips(&mut graph, &mut next, &desired_refs, max_limit); @@ -469,7 +463,39 @@ struct Queue { struct Limit { inner: Option, /// The commit we want to see to be able to assume normal limits. Until then there is no limit. - goal: Option, + /// This is represented by bitflag, one for each goal. + /// The flag is empty if no goal is set. + goal: CommitFlags, +} + +/// A set of commits to keep track of in bitflags. +#[derive(Default)] +struct Goals(Vec); + +impl Goals { + /// Return the bitflag for `goal`, or `None` if we can't track any more goals. + fn flag_for(&mut self, goal: gix::ObjectId) -> Option { + let existing_flags = CommitFlags::all().iter().count(); + let max_goals = size_of::() * 8 - existing_flags; + + let goals = &mut self.0; + let goal_index = match goals.iter().position(|existing| existing == &goal) { + None => { + let idx = goals.len(); + goals.push(goal); + idx + } + Some(idx) => idx, + }; + if goal_index >= max_goals { + tracing::warn!("Goals limit reached, cannot track {goal}"); + None + } else { + Some(CommitFlags::from_bits_retain( + 1 << (existing_flags + goal_index), + )) + } + } } #[derive(Debug, Copy, Clone)] diff --git a/crates/but-graph/src/init/post.rs b/crates/but-graph/src/init/post.rs index 32e27e802c..54688d1304 100644 --- a/crates/but-graph/src/init/post.rs +++ b/crates/but-graph/src/init/post.rs @@ -1,7 +1,6 @@ use crate::init::walk::TopoWalk; use crate::init::{EdgeOwned, PetGraph, branch_segment_from_name_and_meta, remotes}; -use crate::{Commit, CommitFlags, CommitIndex, Edge, Graph, SegmentIndex}; -use bstr::{BStr, ByteSlice}; +use crate::{Commit, CommitFlags, CommitIndex, Edge, Graph, SegmentIndex, is_workspace_ref_name}; use but_core::{RefMetadata, ref_metadata}; use gix::reference::Category; use petgraph::Direction; @@ -127,9 +126,13 @@ impl Graph { } continue; }; - if is_managed_workspace_commit(commit.message.as_bstr()) { + if commit + .refs + .iter() + .any(|rn| is_workspace_ref_name(rn.as_ref())) + { tracing::warn!( - "Workspace commit {} had eligible references pointing to it - ignoring this for now", + "Commit {} had eligible workspace references pointing to it - ignoring this for now", commit.id ); // Now we have to assign this uninteresting commit to the last created segment, if there was one. @@ -302,12 +305,6 @@ fn delete_anon_if_empty_and_reconnect(graph: &mut Graph, sidx: SegmentIndex) { } } -fn is_managed_workspace_commit(message: &BStr) -> bool { - let message = gix::objs::commit::MessageRef::from_bytes(message); - let title = message.title.trim().as_bstr(); - title == "GitButler Workspace Commit" || title == "GitButler Integration Commit" -} - /// Create a new stack from `N` refs that match a ref in `ws_stack` (in the order given there), with `N-1` segments being empty on top /// of the last one `N`. /// `commit_parent` is the segment to use `commit_idx` on to get its data. We also use this information to re-link diff --git a/crates/but-graph/src/init/utils.rs b/crates/but-graph/src/init/utils.rs index 77221be317..cd1162bc65 100644 --- a/crates/but-graph/src/init/utils.rs +++ b/crates/but-graph/src/init/utils.rs @@ -1,5 +1,6 @@ use crate::init::walk::TopoWalk; -use crate::init::{EdgeOwned, Instruction, Limit, PetGraph, Queue, QueueItem, remotes}; +use crate::init::{EdgeOwned, Goals, Instruction, Limit, PetGraph, Queue, QueueItem, remotes}; +use crate::segment::CommitDetails; use crate::{ Commit, CommitFlags, CommitIndex, Edge, Graph, Segment, SegmentIndex, SegmentMetadata, is_workspace_ref_name, @@ -8,9 +9,7 @@ use anyhow::{Context, bail}; use bstr::BString; use but_core::{RefMetadata, ref_metadata}; use gix::hashtable::hash_map::Entry; -use gix::prelude::ObjectIdExt; use gix::reference::Category; -use gix::refs::FullName; use gix::traverse::commit::Either; use petgraph::Direction; use std::collections::BTreeSet; @@ -319,12 +318,9 @@ pub struct TraverseInfo { impl TraverseInfo { pub fn into_commit( self, - repo: &gix::Repository, flags: CommitFlags, refs: Vec, ) -> anyhow::Result { - let commit = but_core::Commit::from_id(self.id.attach(repo))?; - let has_conflicts = commit.is_conflicted(); Ok(match self.commit { Some(commit) => Commit { refs, @@ -334,11 +330,9 @@ impl TraverseInfo { None => Commit { id: self.inner.id, parent_ids: self.inner.parent_ids.into_iter().collect(), - message: commit.message.clone(), - author: commit.author.clone(), flags, refs, - has_conflicts, + details: None, }, }) } @@ -394,12 +388,14 @@ pub fn find( Some(Commit { id, parent_ids: parent_ids.iter().cloned().collect(), - message: message.context("Every valid commit must have a message")?, - author: author.context("Every valid commit must have an author signature")?, refs: Vec::new(), flags: CommitFlags::empty(), - // TODO: we probably should set this - has_conflicts: false, + details: Some(CommitDetails { + message: message.context("Every valid commit must have a message")?, + author: author.context("Every valid commit must have an author signature")?, + // TODO: make it clear that this is optionally computed as well, maybe `Option`? + has_conflicts: false, + }), }) } }; @@ -449,23 +445,24 @@ pub fn collect_ref_mapping_by_prefix<'a>( Ok(all_refs_by_id) } -/// Returns `([(workspace_ref_name, workspace_info)], target_refs, desired_refs)` for all available workspace, +/// Returns `([(workspace_tip, workspace_ref_name, workspace_info)], target_refs, desired_refs)` for all available workspace, /// or exactly one workspace if `maybe_ref_name`. /// already points to a workspace. That way we can discover the workspace containing any starting point, but only if needed. /// /// This means we process all workspaces if we aren't currently and clearly looking at a workspace. /// -/// Also prune all non-standard workspaces early. +/// Also prune all non-standard workspaces early, or those that don't have a tip. #[allow(clippy::type_complexity)] pub fn obtain_workspace_infos( + repo: &gix::Repository, maybe_ref_name: Option<&gix::refs::FullNameRef>, meta: &impl RefMetadata, ) -> anyhow::Result<( - Vec<(gix::refs::FullName, ref_metadata::Workspace)>, + Vec<(gix::ObjectId, gix::refs::FullName, ref_metadata::Workspace)>, Vec, BTreeSet, )> { - let mut workspaces = if let Some((ref_name, ws_data)) = maybe_ref_name + let workspaces = if let Some((ref_name, ws_data)) = maybe_ref_name .and_then(|ref_name| { meta.workspace_opt(ref_name) .transpose() @@ -486,46 +483,52 @@ pub fn obtain_workspace_infos( .collect() }; - let target_refs: Vec<_> = workspaces - .iter() - .filter_map(|(_, data)| data.target_ref.clone()) - .collect(); - let desired_refs = workspaces - .iter() - .flat_map(|(_, data): &(_, ref_metadata::Workspace)| { - data.stacks - .iter() - .flat_map(|stacks| stacks.branches.iter().map(|b| b.ref_name.clone())) - }) - .collect(); - // defensive pruning - workspaces.retain(|(rn, data)| { + let (mut out, mut target_refs, mut desired_refs) = (Vec::new(), Vec::new(), BTreeSet::new()); + for (rn, data) in workspaces { if rn.category() != Some(Category::LocalBranch) { tracing::warn!( "Skipped workspace at ref {} as workspaces can only ever be on normal branches", rn.as_bstr() ); - return false; + continue; } - if target_refs.contains(rn) { + if target_refs.contains(&rn) { tracing::warn!( "Skipped workspace at ref {} as it was also a target ref for another workspace (or for itself)", rn.as_bstr() ); - return false; + continue; } - if let Some(invalid_target_ref) = data.target_ref.as_ref().filter(|trn| trn.category() != Some(Category::RemoteBranch)) { + if let Some(invalid_target_ref) = data + .target_ref + .as_ref() + .filter(|trn| trn.category() != Some(Category::RemoteBranch)) + { tracing::warn!( "Skipped workspace at ref {} as its target reference {target} was not a remote tracking branch", rn.as_bstr(), target = invalid_target_ref.as_bstr(), ); - return false; + continue; } - true - }); + let Some(ws_tip) = try_refname_to_id(repo, rn.as_ref())? else { + tracing::warn!( + "Ignoring stale workspace ref '{ws_ref}', which didn't exist in Git but still had workspace data", + ws_ref = rn.as_bstr() + ); + continue; + }; + + target_refs.extend(data.target_ref.clone()); + desired_refs.extend( + data.stacks + .iter() + .flat_map(|stacks| stacks.branches.iter().map(|b| b.ref_name.clone())), + ); + out.push((ws_tip, rn, data)) + } - Ok((workspaces, target_refs, desired_refs)) + Ok((out, target_refs, desired_refs)) } pub fn try_refname_to_id( @@ -555,41 +558,16 @@ pub fn propagate_flags_downward( commit.flags |= flags_to_add; // Note that this just works if the remote is still fast-forwardable. // If the goal isn't met, it's OK as well, but there is a chance for runaways. - if Some(commit.id) == limit.goal { - next.inner.iter_mut().for_each(|t| t.3.goal = None); - } - } - } -} - -pub fn propagate_limit_upward( - graph: &mut PetGraph, - next: &mut Queue, - limit: Limit, - dst_sidx: SegmentIndex, -) { - let Some(goal) = limit.goal else { - return; - }; - let commits = &graph[dst_sidx].commits; - let mut topo = TopoWalk::start_from( - dst_sidx, - commits.last().map(|_| commits.len()), - petgraph::Direction::Incoming, - ); - while let Some((segment, commit_range)) = topo.next(graph) { - for commit in &mut graph[segment].commits[commit_range] { - // In case the goal was already seen, find it and assure we don't traverse it anymore, - // the graphs are now connected. - if commit.id == goal { - next.inner.iter_mut().for_each(|t| t.3.goal = None); + // TODO(perf): only walk to where the flags differ, with custom walk. + if commit.flags.contains(limit.goal) { + next.inner.iter_mut().for_each(|t| t.3.unset_goal()); } } } } -/// Check `refs` for refs with remote tracking branches, and queue them for traversal after creating a segment named -/// after the tracking branch. +/// Check `refs` for refs with remote tracking branches, and return a queue for them for traversal after creating a segment +/// named after the tracking branch. /// This eager queuing makes sure that the post-processing doesn't have to traverse again when it creates segments /// that were previously ambiguous. /// If a remote tracking branch is in `target_refs`, we assume it was already scheduled and won't schedule it again. @@ -598,20 +576,22 @@ pub fn propagate_limit_upward( pub fn try_queue_remote_tracking_branches( repo: &gix::Repository, refs: &[gix::refs::FullName], - next: &mut Queue, graph: &mut Graph, target_symbolic_remote_names: &[String], - configured_remote_tracking_branches: &BTreeSet, + configured_remote_tracking_branches: &BTreeSet, target_refs: &[gix::refs::FullName], meta: &impl RefMetadata, id: gix::ObjectId, - limit: Limit, -) -> anyhow::Result { + mut limit: Limit, + goals: &mut Goals, +) -> anyhow::Result<(Vec, CommitFlags)> { // As a commit can be reachable by many remote tracking branches while we need // specificity, there is no need to propagate anything. // We also *do not* propagate "NotInCommit" so remote-exclusive commits can be identified // even across segment boundaries let flags = CommitFlags::empty(); + limit.goal = CommitFlags::empty(); + let mut queue = Vec::new(); for rn in refs { let Some(remote_tracking_branch) = remotes::lookup_remote_tracking_branch_or_deduce_it( repo, @@ -635,7 +615,9 @@ pub fn try_queue_remote_tracking_branches( meta, None, )?); - if next.push_back_exhausted(( + + limit = limit.with_goal(id, goals); + queue.push(( remote_tip, flags, Instruction::CollectCommit { @@ -643,12 +625,10 @@ pub fn try_queue_remote_tracking_branches( }, // If the remote is behind the goal it will never reach it, but it will stop once it // touches another commit as well. - limit.with_goal(id), - )) { - return Ok(true); - }; + limit, + )); } - Ok(false) + Ok((queue, limit.goal)) } /// Remove if there are only tips with integrated commits… @@ -671,9 +651,11 @@ pub fn prune_integrated_tips( if !all_integated { return; } - next.retain_mut(|(_id, _flags, instruction, tip_limit)| { - // Let them reach their goal - this could lead to runaways, if the integration branch is behind the entrypoint. - if tip_limit.goal.is_some() { + next.retain_mut(|(_id, flags, instruction, tip_limit)| { + // Let them reach their goal - this could lead to runaways, if the integration branch is behind the entrypoint, + // but once the goal catches up with the bottom section of the graph it propagates the flags so we will eventually + // see it. + if tip_limit.goal_in(*flags) == Some(false) { return true; } let sidx = instruction.segment_idx(); @@ -691,7 +673,7 @@ pub fn prune_integrated_tips( if !any_segment_ref_is_contained_in_workspace && s.commits.is_empty() { graph.inner.remove_node(sidx); } - any_segment_ref_is_contained_in_workspace || tip_limit.goal.is_some() + any_segment_ref_is_contained_in_workspace }); } @@ -755,45 +737,36 @@ pub fn possibly_split_occupied_segment( .map(|cidx| graph[top_sidx].commits[cidx].flags) .unwrap_or_default(); let bottom_flags = graph[bottom_sidx].commits[bottom_cidx].flags; - propagate_flags_downward( - &mut graph.inner, - next, - limit, - propagated_flags | top_flags | bottom_flags, - bottom_sidx, - Some(bottom_cidx), - ); - propagate_limit_upward(&mut graph.inner, next, limit, bottom_sidx); + let new_flags = propagated_flags | top_flags | bottom_flags; + // Only propagate if there is something new. + if new_flags != bottom_flags { + propagate_flags_downward( + &mut graph.inner, + next, + limit, + new_flags, + bottom_sidx, + Some(bottom_cidx), + ); + } Ok(()) } impl Limit { - /// It's important to try to split the limit evenly so we don't create too - /// much extra gas here. We do, however, make sure that we see each segment of a parent - /// with one commit so we know exactly where it stops. - /// The problem with this is that we never get back the split limit when segments re-unite, - /// so effectively we loose gas here. - fn per_parent(&self, num_parents: usize) -> Self { - Limit { - inner: self - .inner - .map(|l| if l == 0 { 0 } else { (l / num_parents).max(1) }), - goal: self.goal, - } - } - /// Return `true` if this limit is depleted, or decrement it by one otherwise. /// /// `flags` are used to selectively decrement this limit. /// Thanks to flag-propagation there can be no runaways. fn is_exhausted_or_decrement(&mut self, flags: CommitFlags, next: &Queue) -> bool { - if self.goal.is_some() { + // Keep going if the goal wasn't seen yet, unlimited gas. + if self.goal_in(flags) == Some(false) { return false; } - // Do not let *any* tip consume gas as long as there is still remotes tracking branches in the game - // that need to meet their local branches. Otherwise, everything is considered remote as the local tips - // that could tell otherwise never reach their remote counterparts. - if !flags.is_empty() && next.iter().any(|(_, flags, _, _)| flags.is_empty()) { + // Do not let *any* tip consume gas as long as there is still anything with a goal in the queue + // that need to meet their local branches. + // TODO(perf): could we remember that we are a tip and look for our specific counterpart by matching the goal? + // That way unrelated tips wouldn't cause us to keep traversing. + if self.goal.is_empty() && next.iter().any(|(_, _, _, limit)| !limit.goal.is_empty()) { return false; } if self.inner.is_some_and(|l| l == 0) { @@ -803,24 +776,61 @@ impl Limit { false } + /// It's important to try to split the limit evenly so we don't create too + /// much extra gas here. We do, however, make sure that we see each segment of a parent + /// with one commit so we know exactly where it stops. + /// The problem with this is that we never get back the split limit when segments re-unite, + /// so effectively we loose gas here. + fn per_parent(&self, num_parents: usize) -> Self { + Limit { + inner: self + .inner + .map(|l| if l == 0 { 0 } else { (l / num_parents).max(1) }), + goal: self.goal, + } + } + fn is_unset(&self) -> bool { self.inner.is_none() } } impl Limit { - /// Keep queueing without limit until `goal` is seen during [propagate_flags_downward()] and [propagate_limit_upward()]. - pub fn with_goal(mut self, goal: gix::ObjectId) -> Self { - self.goal = Some(goal); + /// Keep queueing without limit until `goal` is seen during [propagate_flags_downward()]. + /// `goals` are used to keep track of existing bitflags. + /// No goal will be set if we can't track more goals, effectively causing traversal to stop earlier, + /// leaving potential isles in the graph. + pub fn with_goal(mut self, goal: gix::ObjectId, goals: &mut Goals) -> Self { + self.goal = goals.flag_for(goal).unwrap_or_default(); self } + + /// Return `None` if this limit has no goal set, otherwise return `true` if `flags` contains it. + /// This is useful to determine if a commit that is ahead was seen during traversal. + #[inline] + fn goal_in(&self, flags: CommitFlags) -> Option { + if self.goal.is_empty() { + None + } else { + Some(flags.contains(self.goal)) + } + } + + /// Set our limit from `other`, but do not alter our goal. + pub(crate) fn set_but_keep_goal(&mut self, other: Limit) { + self.inner = other.inner; + } + + fn unset_goal(&mut self) { + self.goal = CommitFlags::empty(); + } } impl From> for Limit { fn from(value: Option) -> Self { Limit { inner: value, - goal: None, + goal: CommitFlags::empty(), } } } diff --git a/crates/but-graph/src/lib.rs b/crates/but-graph/src/lib.rs index 67835d8470..341d2eb2af 100644 --- a/crates/but-graph/src/lib.rs +++ b/crates/but-graph/src/lib.rs @@ -3,7 +3,7 @@ #![deny(missing_docs, rust_2018_idioms)] mod segment; -pub use segment::{Commit, CommitFlags, Segment, SegmentMetadata}; +pub use segment::{Commit, CommitDetails, CommitFlags, Segment, SegmentMetadata}; /// Edges to other segments are the index into the list of local commits of the parent segment. /// That way we can tell where a segment branches off, despite the graph only connecting segments, and not commits. @@ -26,7 +26,7 @@ pub struct Graph { /// /// Note that the segment counts aren't mutually exclusive, so the sum of these fields can be more /// than the total of segments. -#[derive(Default, Debug, Copy, Clone)] +#[derive(Default, Debug, Clone)] pub struct Statistics { /// The number of segments in the graph. pub segments: usize, @@ -56,6 +56,20 @@ pub struct Statistics { pub segments_behind_of_entrypoint: usize, /// Segments, excluding the entrypoint, that can be reached upwards through the entrypoint. pub segments_ahead_of_entrypoint: usize, + /// The entrypoint of the graph traversal. + pub entrypoint: (SegmentIndex, Option), + /// The number of incoming connections into the entrypoint segment. + pub segment_entrypoint_incoming: usize, + /// The number of outgoing connections into the entrypoint segment. + pub segment_entrypoint_outgoing: usize, + /// Segments without incoming connections. + pub top_segments: Vec<( + Option, + SegmentIndex, + Option, + )>, + /// Segments without outgoing connections. + pub segments_at_bottom: usize, /// Connections between segments. pub connections: usize, /// All commits within segments. diff --git a/crates/but-graph/src/segment.rs b/crates/but-graph/src/segment.rs index daba16c183..e7bcfc6e4f 100644 --- a/crates/but-graph/src/segment.rs +++ b/crates/but-graph/src/segment.rs @@ -1,25 +1,33 @@ use crate::{CommitIndex, SegmentIndex}; use bitflags::bitflags; +use bstr::ByteSlice; use gix::bstr::BString; /// A commit with must useful information extracted from the Git commit itself. -/// -/// Note that additional information can be computed and placed in the [`LocalCommit`] and [`RemoteCommit`] #[derive(Clone, Eq, PartialEq)] pub struct Commit { /// The hash of the commit. pub id: gix::ObjectId, /// The IDs of the parent commits, but may be empty if this is the first commit. pub parent_ids: Vec, + /// Additional properties to help classify this commit. + pub flags: CommitFlags, + /// The references pointing to this commit, even after dereferencing tag objects. + /// These can be names of tags and branches. + pub refs: Vec, + /// Additional, and possibly expensive information to obtain on demand for commits of interest only. + pub details: Option, +} + +/// Lazily obtained detailed information. +/// This should only be fetched when it's clear the commit is of interest, +/// which a majority of commits in a traversal might not be. +#[derive(Clone, Eq, PartialEq)] +pub struct CommitDetails { /// The complete message, verbatim. pub message: BString, /// The signature at which the commit was authored. pub author: gix::actor::Signature, - /// The references pointing to this commit, even after dereferencing tag objects. - /// These can be names of tags and branches. - pub refs: Vec, - /// Additional properties to help classify this commit. - pub flags: CommitFlags, /// Whether the commit is in a conflicted state, a GitButler concept. /// GitButler will perform rebasing/reordering etc. without interruptions and flag commits as conflicted if needed. /// Conflicts are resolved via the Edit Mode mechanism. @@ -28,62 +36,30 @@ pub struct Commit { pub has_conflicts: bool, } -impl Commit { - /// Read the object of the `commit_id` and extract relevant values, while setting `flags` as well. - pub fn new_from_id( - commit_id: gix::Id<'_>, - flags: CommitFlags, - has_conflicts: bool, - ) -> anyhow::Result { - let commit = commit_id.object()?.into_commit(); - // Decode efficiently, no need to own this. - let commit = commit.decode()?; - Ok(Commit { - id: commit_id.detach(), - parent_ids: commit.parents().collect(), - message: commit.message.to_owned(), - author: commit.author.to_owned()?, - refs: Vec::new(), - flags, - has_conflicts, - }) - } -} - impl std::fmt::Debug for Commit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "Commit({hash}, {msg:?}{flags})", hash = self.id.to_hex_with_len(7), - msg = self.message, - flags = if self.flags.is_empty() { - "".to_string() - } else { - format!(", {}", self.flags.debug_string()) - } + msg = self + .details + .as_ref() + .map(|d| d.message.as_bstr()) + .unwrap_or_default(), + flags = self.flags.debug_string() ) } } -impl From> for Commit { - fn from(value: but_core::Commit<'_>) -> Self { - Commit { - id: value.id.into(), - parent_ids: value.parents.iter().cloned().collect(), - message: value.inner.message, - author: value.inner.author, - refs: Vec::new(), - flags: CommitFlags::empty(), - has_conflicts: false, - } - } -} - bitflags! { /// Provide more information about a commit, as gathered during traversal. + /// + /// Note that unknown bits beyond this list are used to track individual goals that we want to discover. + /// This is useful for when they are ahead of the tip that looks for them. + /// If they are below, the goal will be propagated downward automatically. #[derive(Default, Debug, Copy, Clone, Eq, PartialEq)] - pub struct CommitFlags: u8 { + pub struct CommitFlags: u32 { /// Identify commits that have never been owned *only* by a remote. /// It may be that a remote is directly pointing at them though. /// Note that this flag is negative as all flags are propagated through the graph, @@ -107,16 +83,28 @@ impl CommitFlags { if self.is_empty() { "".into() } else { - let string = format!("{:?}", self); + let flags = *self & Self::all(); + let extra = (self.bits() & !Self::all().bits()) >> Self::all().iter().count(); + let string = format!("{:?}", flags); let out = &string["CommitFlags(".len()..]; - out[..out.len() - 1] + let mut out = out[..out.len() - 1] .to_string() .replace("NotInRemote", "βŒ‚") .replace("InWorkspace", "🏘️") .replace("Integrated", "βœ“") - .replace(" ", "") + .replace(" ", ""); + if extra != 0 { + out.push_str(&format!("|{:b}", extra)); + } + out } } + + /// Return `true` if this flag denotes a remote commit, i.e. a commit that isn't reachable from anything + /// but a remote tracking branch tip. + pub fn is_remote(&self) -> bool { + self.is_empty() + } } /// A segment of a commit graph, representing a set of commits exclusively. diff --git a/crates/but-graph/tests/graph/init/mod.rs b/crates/but-graph/tests/graph/init/mod.rs index c2e6362d44..b3ba7ed794 100644 --- a/crates/but-graph/tests/graph/init/mod.rs +++ b/crates/but-graph/tests/graph/init/mod.rs @@ -48,9 +48,9 @@ fn detached() -> anyhow::Result<()> { let graph = Graph::from_head(&repo, &*meta, standard_options())?; insta::assert_snapshot!(graph_tree(&graph), @r#" └── πŸ‘‰β–Ί:0:main - └── Β·541396b (βŒ‚)❱"first" β–Ίtags/annotated, β–Ίtags/release/v1 + └── Β·541396b (βŒ‚|1)❱"first" β–Ίtags/annotated, β–Ίtags/release/v1 └── β–Ί:1:other - └── Β·fafd9d0 (βŒ‚)❱"init" + └── Β·fafd9d0 (βŒ‚|1)❱"init" "#); insta::assert_debug_snapshot!(graph, @r#" Graph { @@ -65,7 +65,7 @@ fn detached() -> anyhow::Result<()> { ref_name: "refs/heads/main", remote_tracking_ref_name: "None", commits: [ - Commit(541396b, "first\n", βŒ‚), + Commit(541396b, "first\n"βŒ‚|1), ], metadata: "None", }, @@ -74,7 +74,7 @@ fn detached() -> anyhow::Result<()> { ref_name: "refs/heads/other", remote_tracking_ref_name: "None", commits: [ - Commit(fafd9d0, "init\n", βŒ‚), + Commit(fafd9d0, "init\n"βŒ‚|1), ], metadata: "None", }, @@ -129,19 +129,19 @@ fn multi_root() -> anyhow::Result<()> { let graph = Graph::from_head(&repo, &*meta, standard_options())?; insta::assert_snapshot!(graph_tree(&graph), @r#" └── πŸ‘‰β–Ί:0:main - └── Β·c6c8c05 (βŒ‚)❱"Merge branch \'C\'" + └── Β·c6c8c05 (βŒ‚|1)❱"Merge branch \'C\'" β”œβ”€β”€ β–Ί:2:C - β”‚ └── Β·8631946 (βŒ‚)❱"Merge branch \'D\' into C" + β”‚ └── Β·8631946 (βŒ‚|1)❱"Merge branch \'D\' into C" β”‚ β”œβ”€β”€ β–Ί:6:D - β”‚ β”‚ └── Β·f4955b6 (βŒ‚)❱"D" + β”‚ β”‚ └── Β·f4955b6 (βŒ‚|1)❱"D" β”‚ └── β–Ί:5:anon: - β”‚ └── Β·00fab2a (βŒ‚)❱"C" + β”‚ └── Β·00fab2a (βŒ‚|1)❱"C" └── β–Ί:1:anon: - └── Β·76fc5c4 (βŒ‚)❱"Merge branch \'B\'" + └── Β·76fc5c4 (βŒ‚|1)❱"Merge branch \'B\'" β”œβ”€β”€ β–Ί:4:B - β”‚ └── Β·366d496 (βŒ‚)❱"B" + β”‚ └── Β·366d496 (βŒ‚|1)❱"B" └── β–Ί:3:anon: - └── Β·e5d0542 (βŒ‚)❱"A" + └── Β·e5d0542 (βŒ‚|1)❱"A" "#); assert_eq!( graph.tip_segments().count(), @@ -179,23 +179,23 @@ fn four_diamond() -> anyhow::Result<()> { let graph = Graph::from_head(&repo, &*meta, standard_options())?; insta::assert_snapshot!(graph_tree(&graph), @r#" └── πŸ‘‰β–Ί:0:merged - └── Β·8a6c109 (βŒ‚)❱"Merge branch \'C\' into merged" + └── Β·8a6c109 (βŒ‚|1)❱"Merge branch \'C\' into merged" β”œβ”€β”€ β–Ί:2:C - β”‚ └── Β·7ed512a (βŒ‚)❱"Merge branch \'D\' into C" + β”‚ └── Β·7ed512a (βŒ‚|1)❱"Merge branch \'D\' into C" β”‚ β”œβ”€β”€ β–Ί:6:D - β”‚ β”‚ └── Β·ecb1877 (βŒ‚)❱"D" + β”‚ β”‚ └── Β·ecb1877 (βŒ‚|1)❱"D" β”‚ β”‚ └── β–Ί:7:main - β”‚ β”‚ └── Β·965998b (βŒ‚)❱"base" + β”‚ β”‚ └── Β·965998b (βŒ‚|1)❱"base" β”‚ └── β–Ί:5:anon: - β”‚ └── Β·35ee481 (βŒ‚)❱"C" + β”‚ └── Β·35ee481 (βŒ‚|1)❱"C" β”‚ └── β†’:7: (main) └── β–Ί:1:A - └── Β·62b409a (βŒ‚)❱"Merge branch \'B\' into A" + └── Β·62b409a (βŒ‚|1)❱"Merge branch \'B\' into A" β”œβ”€β”€ β–Ί:4:B - β”‚ └── Β·f16dddf (βŒ‚)❱"B" + β”‚ └── Β·f16dddf (βŒ‚|1)❱"B" β”‚ └── β†’:7: (main) └── β–Ί:3:anon: - └── Β·592abec (βŒ‚)❱"A" + └── Β·592abec (βŒ‚|1)❱"A" └── β†’:7: (main) "#); @@ -230,11 +230,11 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { Graph::from_head(&repo, &*meta, standard_options().with_limit_hint(1))?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ πŸ‘‰β–Ί:0:B <> origin/B - β”‚ └── Β·312f819 (βŒ‚)❱"B" + β”‚ └── Β·312f819 (βŒ‚|1)❱"B" β”‚ └── β–Ί:2:A <> origin/A - β”‚ └── Β·e255adc (βŒ‚)❱"A" + β”‚ └── Β·e255adc (βŒ‚|11)❱"A" β”‚ └── β–Ί:4:main - β”‚ └── Β·fafd9d0 (βŒ‚)❱"init" + β”‚ └── Β·fafd9d0 (βŒ‚|11)❱"init" └── β–Ί:1:origin/B └── 🟣682be32❱"B" └── β–Ί:3:origin/A @@ -246,11 +246,11 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { Graph::from_head(&repo, &*meta, standard_options().with_hard_limit(7))?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ πŸ‘‰β–Ί:0:B - β”‚ └── Β·312f819 (βŒ‚)❱"B" + β”‚ └── Β·312f819 (βŒ‚|1)❱"B" β”‚ └── β–Ί:2:A - β”‚ └── Β·e255adc (βŒ‚)❱"A" + β”‚ └── Β·e255adc (βŒ‚|11)❱"A" β”‚ └── β–Ί:4:main - β”‚ └── Β·fafd9d0 (βŒ‚)❱"init" + β”‚ └── Β·fafd9d0 (βŒ‚|11)❱"init" β”œβ”€β”€ β–Ί:1:origin/B β”‚ └── ❌🟣682be32❱"B" └── β–Ί:3:origin/A @@ -260,11 +260,11 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ πŸ‘‰β–Ί:0:B <> origin/B - β”‚ └── Β·312f819 (βŒ‚)❱"B" + β”‚ └── Β·312f819 (βŒ‚|1)❱"B" β”‚ └── β–Ί:2:A <> origin/A - β”‚ └── Β·e255adc (βŒ‚)❱"A" + β”‚ └── Β·e255adc (βŒ‚|11)❱"A" β”‚ └── β–Ί:4:main - β”‚ └── Β·fafd9d0 (βŒ‚)❱"init" + β”‚ └── Β·fafd9d0 (βŒ‚|11)❱"init" └── β–Ί:1:origin/B └── 🟣682be32❱"B" └── β–Ί:3:origin/A @@ -277,9 +277,9 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { let graph = Graph::from_commit_traversal(id, name, &*meta, standard_options())?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ πŸ‘‰β–Ί:0:A <> origin/A - β”‚ └── Β·e255adc (βŒ‚)❱"A" + β”‚ └── Β·e255adc (βŒ‚|1)❱"A" β”‚ └── β–Ί:2:main - β”‚ └── Β·fafd9d0 (βŒ‚)❱"init" + β”‚ └── Β·fafd9d0 (βŒ‚|1)❱"init" └── β–Ί:1:origin/A └── 🟣e29c23d❱"A" └── β†’:2: (main) @@ -315,26 +315,26 @@ fn with_limits() -> anyhow::Result<()> { let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" └── πŸ‘‰β–Ί:0:C - └── Β·2a95729 (βŒ‚)❱"Merge branches \'A\' and \'B\' into C" + └── Β·2a95729 (βŒ‚|1)❱"Merge branches \'A\' and \'B\' into C" β”œβ”€β”€ β–Ί:3:B - β”‚ β”œβ”€β”€ Β·9908c99 (βŒ‚)❱"B3" - β”‚ β”œβ”€β”€ Β·60d9a56 (βŒ‚)❱"B2" - β”‚ └── Β·9d171ff (βŒ‚)❱"B1" + β”‚ β”œβ”€β”€ Β·9908c99 (βŒ‚|1)❱"B3" + β”‚ β”œβ”€β”€ Β·60d9a56 (βŒ‚|1)❱"B2" + β”‚ └── Β·9d171ff (βŒ‚|1)❱"B1" β”‚ └── β–Ί:4:main - β”‚ β”œβ”€β”€ Β·edc4dee (βŒ‚)❱"5" - β”‚ β”œβ”€β”€ Β·01d0e1e (βŒ‚)❱"4" - β”‚ β”œβ”€β”€ Β·4b3e5a8 (βŒ‚)❱"3" - β”‚ β”œβ”€β”€ Β·34d0715 (βŒ‚)❱"2" - β”‚ └── Β·eb5f731 (βŒ‚)❱"1" + β”‚ β”œβ”€β”€ Β·edc4dee (βŒ‚|1)❱"5" + β”‚ β”œβ”€β”€ Β·01d0e1e (βŒ‚|1)❱"4" + β”‚ β”œβ”€β”€ Β·4b3e5a8 (βŒ‚|1)❱"3" + β”‚ β”œβ”€β”€ Β·34d0715 (βŒ‚|1)❱"2" + β”‚ └── Β·eb5f731 (βŒ‚|1)❱"1" β”œβ”€β”€ β–Ί:2:A - β”‚ β”œβ”€β”€ Β·20a823c (βŒ‚)❱"A3" - β”‚ β”œβ”€β”€ Β·442a12f (βŒ‚)❱"A2" - β”‚ └── Β·686706b (βŒ‚)❱"A1" + β”‚ β”œβ”€β”€ Β·20a823c (βŒ‚|1)❱"A3" + β”‚ β”œβ”€β”€ Β·442a12f (βŒ‚|1)❱"A2" + β”‚ └── Β·686706b (βŒ‚|1)❱"A1" β”‚ └── β†’:4: (main) └── β–Ί:1:anon: - β”œβ”€β”€ Β·6861158 (βŒ‚)❱"C3" - β”œβ”€β”€ Β·4f1f248 (βŒ‚)❱"C2" - └── Β·487ffce (βŒ‚)❱"C1" + β”œβ”€β”€ Β·6861158 (βŒ‚|1)❱"C3" + β”œβ”€β”€ Β·4f1f248 (βŒ‚|1)❱"C2" + └── Β·487ffce (βŒ‚|1)❱"C1" └── β†’:4: (main) "#); @@ -344,7 +344,7 @@ fn with_limits() -> anyhow::Result<()> { Graph::from_head(&repo, &*meta, standard_options().with_limit_hint(0))?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" └── πŸ‘‰β–Ί:0:C - └── βœ‚οΈΒ·2a95729 (βŒ‚)❱"Merge branches \'A\' and \'B\' into C" + └── βœ‚οΈΒ·2a95729 (βŒ‚|1)❱"Merge branches \'A\' and \'B\' into C" "#); // A single commit, the merge commit. @@ -352,13 +352,13 @@ fn with_limits() -> anyhow::Result<()> { Graph::from_head(&repo, &*meta, standard_options().with_limit_hint(1))?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" └── πŸ‘‰β–Ί:0:C - └── Β·2a95729 (βŒ‚)❱"Merge branches \'A\' and \'B\' into C" + └── Β·2a95729 (βŒ‚|1)❱"Merge branches \'A\' and \'B\' into C" β”œβ”€β”€ β–Ί:3:B - β”‚ └── βœ‚οΈΒ·9908c99 (βŒ‚)❱"B3" + β”‚ └── βœ‚οΈΒ·9908c99 (βŒ‚|1)❱"B3" β”œβ”€β”€ β–Ί:2:A - β”‚ └── βœ‚οΈΒ·20a823c (βŒ‚)❱"A3" + β”‚ └── βœ‚οΈΒ·20a823c (βŒ‚|1)❱"A3" └── β–Ί:1:anon: - └── βœ‚οΈΒ·6861158 (βŒ‚)❱"C3" + └── βœ‚οΈΒ·6861158 (βŒ‚|1)❱"C3" "#); // The merge commit, then we witness lane-duplication of the limit so we get more than requested. @@ -366,16 +366,16 @@ fn with_limits() -> anyhow::Result<()> { Graph::from_head(&repo, &*meta, standard_options().with_limit_hint(2))?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" └── πŸ‘‰β–Ί:0:C - └── Β·2a95729 (βŒ‚)❱"Merge branches \'A\' and \'B\' into C" + └── Β·2a95729 (βŒ‚|1)❱"Merge branches \'A\' and \'B\' into C" β”œβ”€β”€ β–Ί:3:B - β”‚ β”œβ”€β”€ Β·9908c99 (βŒ‚)❱"B3" - β”‚ └── βœ‚οΈΒ·60d9a56 (βŒ‚)❱"B2" + β”‚ β”œβ”€β”€ Β·9908c99 (βŒ‚|1)❱"B3" + β”‚ └── βœ‚οΈΒ·60d9a56 (βŒ‚|1)❱"B2" β”œβ”€β”€ β–Ί:2:A - β”‚ β”œβ”€β”€ Β·20a823c (βŒ‚)❱"A3" - β”‚ └── βœ‚οΈΒ·442a12f (βŒ‚)❱"A2" + β”‚ β”œβ”€β”€ Β·20a823c (βŒ‚|1)❱"A3" + β”‚ └── βœ‚οΈΒ·442a12f (βŒ‚|1)❱"A2" └── β–Ί:1:anon: - β”œβ”€β”€ Β·6861158 (βŒ‚)❱"C3" - └── βœ‚οΈΒ·4f1f248 (βŒ‚)❱"C2" + β”œβ”€β”€ Β·6861158 (βŒ‚|1)❱"C3" + └── βœ‚οΈΒ·4f1f248 (βŒ‚|1)❱"C2" "#); // Allow to see more commits just in the middle lane, the limit is reset, @@ -390,17 +390,17 @@ fn with_limits() -> anyhow::Result<()> { .validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" └── πŸ‘‰β–Ί:0:C - └── Β·2a95729 (βŒ‚)❱"Merge branches \'A\' and \'B\' into C" + └── Β·2a95729 (βŒ‚|1)❱"Merge branches \'A\' and \'B\' into C" β”œβ”€β”€ β–Ί:3:B - β”‚ β”œβ”€β”€ Β·9908c99 (βŒ‚)❱"B3" - β”‚ └── βœ‚οΈΒ·60d9a56 (βŒ‚)❱"B2" + β”‚ β”œβ”€β”€ Β·9908c99 (βŒ‚|1)❱"B3" + β”‚ └── βœ‚οΈΒ·60d9a56 (βŒ‚|1)❱"B2" β”œβ”€β”€ β–Ί:2:A - β”‚ β”œβ”€β”€ Β·20a823c (βŒ‚)❱"A3" - β”‚ β”œβ”€β”€ Β·442a12f (βŒ‚)❱"A2" - β”‚ └── βœ‚οΈΒ·686706b (βŒ‚)❱"A1" + β”‚ β”œβ”€β”€ Β·20a823c (βŒ‚|1)❱"A3" + β”‚ β”œβ”€β”€ Β·442a12f (βŒ‚|1)❱"A2" + β”‚ └── βœ‚οΈΒ·686706b (βŒ‚|1)❱"A1" └── β–Ί:1:anon: - β”œβ”€β”€ Β·6861158 (βŒ‚)❱"C3" - └── βœ‚οΈΒ·4f1f248 (βŒ‚)❱"C2" + β”œβ”€β”€ Β·6861158 (βŒ‚|1)❱"C3" + └── βœ‚οΈΒ·4f1f248 (βŒ‚|1)❱"C2" "#); // Multiple extensions are fine as well. @@ -415,25 +415,25 @@ fn with_limits() -> anyhow::Result<()> { .validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" └── πŸ‘‰β–Ί:0:C - └── Β·2a95729 (βŒ‚)❱"Merge branches \'A\' and \'B\' into C" + └── Β·2a95729 (βŒ‚|1)❱"Merge branches \'A\' and \'B\' into C" β”œβ”€β”€ β–Ί:3:B - β”‚ β”œβ”€β”€ Β·9908c99 (βŒ‚)❱"B3" - β”‚ β”œβ”€β”€ Β·60d9a56 (βŒ‚)❱"B2" - β”‚ └── βœ‚οΈΒ·9d171ff (βŒ‚)❱"B1" + β”‚ β”œβ”€β”€ Β·9908c99 (βŒ‚|1)❱"B3" + β”‚ β”œβ”€β”€ Β·60d9a56 (βŒ‚|1)❱"B2" + β”‚ └── βœ‚οΈΒ·9d171ff (βŒ‚|1)❱"B1" β”œβ”€β”€ β–Ί:2:A - β”‚ β”œβ”€β”€ Β·20a823c (βŒ‚)❱"A3" - β”‚ β”œβ”€β”€ Β·442a12f (βŒ‚)❱"A2" - β”‚ └── Β·686706b (βŒ‚)❱"A1" + β”‚ β”œβ”€β”€ Β·20a823c (βŒ‚|1)❱"A3" + β”‚ β”œβ”€β”€ Β·442a12f (βŒ‚|1)❱"A2" + β”‚ └── Β·686706b (βŒ‚|1)❱"A1" β”‚ └── β–Ί:4:main - β”‚ β”œβ”€β”€ Β·edc4dee (βŒ‚)❱"5" - β”‚ └── βœ‚οΈΒ·01d0e1e (βŒ‚)❱"4" + β”‚ β”œβ”€β”€ Β·edc4dee (βŒ‚|1)❱"5" + β”‚ └── βœ‚οΈΒ·01d0e1e (βŒ‚|1)❱"4" └── β–Ί:1:anon: - β”œβ”€β”€ Β·6861158 (βŒ‚)❱"C3" - β”œβ”€β”€ Β·4f1f248 (βŒ‚)❱"C2" - └── βœ‚οΈΒ·487ffce (βŒ‚)❱"C1" + β”œβ”€β”€ Β·6861158 (βŒ‚|1)❱"C3" + β”œβ”€β”€ Β·4f1f248 (βŒ‚|1)❱"C2" + └── βœ‚οΈΒ·487ffce (βŒ‚|1)❱"C1" "#); - insta::assert_debug_snapshot!(graph.statistics(), @r" + insta::assert_debug_snapshot!(graph.statistics(), @r#" Statistics { segments: 5, segments_integrated: 0, @@ -450,12 +450,36 @@ fn with_limits() -> anyhow::Result<()> { ), segments_behind_of_entrypoint: 4, segments_ahead_of_entrypoint: 0, + entrypoint: ( + NodeIndex(0), + Some( + 0, + ), + ), + segment_entrypoint_incoming: 0, + segment_entrypoint_outgoing: 3, + top_segments: [ + ( + Some( + FullName( + "refs/heads/C", + ), + ), + NodeIndex(0), + Some( + CommitFlags( + NotInRemote | 0x8, + ), + ), + ), + ], + segments_at_bottom: 3, connections: 4, commits: 12, commit_references: 0, commits_at_cutoff: 3, } - "); + "#); Ok(()) } diff --git a/crates/but-graph/tests/graph/init/with_workspace.rs b/crates/but-graph/tests/graph/init/with_workspace.rs index fcffcdb8ff..ebde108d28 100644 --- a/crates/but-graph/tests/graph/init/with_workspace.rs +++ b/crates/but-graph/tests/graph/init/with_workspace.rs @@ -24,14 +24,14 @@ fn single_stack_ambigous() -> anyhow::Result<()> { let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" └── πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace - └── Β·20de6ee (βŒ‚|🏘️)❱"GitButler Workspace Commit" + └── Β·20de6ee (βŒ‚|🏘️|1)❱"GitButler Workspace Commit" └── β–Ί:2:B - β”œβ”€β”€ Β·70e9a36 (βŒ‚|🏘️)❱"with-ref" - β”œβ”€β”€ Β·320e105 (βŒ‚|🏘️)❱"segment-B" β–Ίtags/without-ref - β”œβ”€β”€ Β·2a31450 (βŒ‚|🏘️)❱"segment-B~1" β–ΊB-empty, β–Ίambiguous-01 - └── Β·70bde6b (βŒ‚|🏘️)❱"segment-A" β–ΊA, β–ΊA-empty-01, β–ΊA-empty-02, β–ΊA-empty-03 + β”œβ”€β”€ Β·70e9a36 (βŒ‚|🏘️|1)❱"with-ref" + β”œβ”€β”€ Β·320e105 (βŒ‚|🏘️|1)❱"segment-B" β–Ίtags/without-ref + β”œβ”€β”€ Β·2a31450 (βŒ‚|🏘️|1)❱"segment-B~1" β–ΊB-empty, β–Ίambiguous-01 + └── Β·70bde6b (βŒ‚|🏘️|1)❱"segment-A" β–ΊA, β–ΊA-empty-01, β–ΊA-empty-02, β–ΊA-empty-03 └── β–Ί:1:origin/main - └── Β·fafd9d0 (βŒ‚|🏘️|βœ“)❱"init" β–Ίmain, β–Ίnew-A, β–Ίnew-B + └── Β·fafd9d0 (βŒ‚|🏘️|βœ“|1)❱"init" β–Ίmain, β–Ίnew-A, β–Ίnew-B "#); // There is always a segment for the entrypoint, and code working with the graph @@ -45,11 +45,11 @@ fn single_stack_ambigous() -> anyhow::Result<()> { └── β–Ί:3:B └── Β·70e9a36 (βŒ‚|🏘️)❱"with-ref" └── πŸ‘‰β–Ί:0:tags/without-ref - β”œβ”€β”€ Β·320e105 (βŒ‚|🏘️)❱"segment-B" - β”œβ”€β”€ Β·2a31450 (βŒ‚|🏘️)❱"segment-B~1" β–ΊB-empty, β–Ίambiguous-01 - └── Β·70bde6b (βŒ‚|🏘️)❱"segment-A" β–ΊA, β–ΊA-empty-01, β–ΊA-empty-02, β–ΊA-empty-03 + β”œβ”€β”€ Β·320e105 (βŒ‚|🏘️|1)❱"segment-B" + β”œβ”€β”€ Β·2a31450 (βŒ‚|🏘️|1)❱"segment-B~1" β–ΊB-empty, β–Ίambiguous-01 + └── Β·70bde6b (βŒ‚|🏘️|1)❱"segment-A" β–ΊA, β–ΊA-empty-01, β–ΊA-empty-02, β–ΊA-empty-03 └── β–Ί:2:origin/main - └── Β·fafd9d0 (βŒ‚|🏘️|βœ“)❱"init" β–Ίmain, β–Ίnew-A, β–Ίnew-B + └── Β·fafd9d0 (βŒ‚|🏘️|βœ“|1)❱"init" β–Ίmain, β–Ίnew-A, β–Ίnew-B "#); // We don't have to give it a ref-name @@ -61,11 +61,11 @@ fn single_stack_ambigous() -> anyhow::Result<()> { └── β–Ί:3:B └── Β·70e9a36 (βŒ‚|🏘️)❱"with-ref" └── β–Ί:0:anon: - β”œβ”€β”€ πŸ‘‰Β·320e105 (βŒ‚|🏘️)❱"segment-B" β–Ίtags/without-ref - β”œβ”€β”€ Β·2a31450 (βŒ‚|🏘️)❱"segment-B~1" β–ΊB-empty, β–Ίambiguous-01 - └── Β·70bde6b (βŒ‚|🏘️)❱"segment-A" β–ΊA, β–ΊA-empty-01, β–ΊA-empty-02, β–ΊA-empty-03 + β”œβ”€β”€ πŸ‘‰Β·320e105 (βŒ‚|🏘️|1)❱"segment-B" β–Ίtags/without-ref + β”œβ”€β”€ Β·2a31450 (βŒ‚|🏘️|1)❱"segment-B~1" β–ΊB-empty, β–Ίambiguous-01 + └── Β·70bde6b (βŒ‚|🏘️|1)❱"segment-A" β–ΊA, β–ΊA-empty-01, β–ΊA-empty-02, β–ΊA-empty-03 └── β–Ί:2:origin/main - └── Β·fafd9d0 (βŒ‚|🏘️|βœ“)❱"init" β–Ίmain, β–Ίnew-A, β–Ίnew-B + └── Β·fafd9d0 (βŒ‚|🏘️|βœ“|1)❱"init" β–Ίmain, β–Ίnew-A, β–Ίnew-B "#); // Putting the entrypoint onto a commit in an anonymous segment makes no difference. @@ -79,10 +79,10 @@ fn single_stack_ambigous() -> anyhow::Result<()> { β”œβ”€β”€ Β·70e9a36 (βŒ‚|🏘️)❱"with-ref" └── Β·320e105 (βŒ‚|🏘️)❱"segment-B" β–Ίtags/without-ref └── β–Ί:0:anon: - β”œβ”€β”€ πŸ‘‰Β·2a31450 (βŒ‚|🏘️)❱"segment-B~1" β–ΊB-empty, β–Ίambiguous-01 - └── Β·70bde6b (βŒ‚|🏘️)❱"segment-A" β–ΊA, β–ΊA-empty-01, β–ΊA-empty-02, β–ΊA-empty-03 + β”œβ”€β”€ πŸ‘‰Β·2a31450 (βŒ‚|🏘️|1)❱"segment-B~1" β–ΊB-empty, β–Ίambiguous-01 + └── Β·70bde6b (βŒ‚|🏘️|1)❱"segment-A" β–ΊA, β–ΊA-empty-01, β–ΊA-empty-02, β–ΊA-empty-03 └── β–Ί:2:origin/main - └── Β·fafd9d0 (βŒ‚|🏘️|βœ“)❱"init" β–Ίmain, β–Ίnew-A, β–Ίnew-B + └── Β·fafd9d0 (βŒ‚|🏘️|βœ“|1)❱"init" β–Ίmain, β–Ίnew-A, β–Ίnew-B "#); // If we pass an entrypoint ref name, it will be used as segment name (despite ambiguous without it) @@ -95,10 +95,10 @@ fn single_stack_ambigous() -> anyhow::Result<()> { β”œβ”€β”€ Β·70e9a36 (βŒ‚|🏘️)❱"with-ref" └── Β·320e105 (βŒ‚|🏘️)❱"segment-B" β–Ίtags/without-ref └── πŸ‘‰β–Ί:0:B-empty - β”œβ”€β”€ Β·2a31450 (βŒ‚|🏘️)❱"segment-B~1" β–Ίambiguous-01 - └── Β·70bde6b (βŒ‚|🏘️)❱"segment-A" β–ΊA, β–ΊA-empty-01, β–ΊA-empty-02, β–ΊA-empty-03 + β”œβ”€β”€ Β·2a31450 (βŒ‚|🏘️|1)❱"segment-B~1" β–Ίambiguous-01 + └── Β·70bde6b (βŒ‚|🏘️|1)❱"segment-A" β–ΊA, β–ΊA-empty-01, β–ΊA-empty-02, β–ΊA-empty-03 └── β–Ί:2:origin/main - └── Β·fafd9d0 (βŒ‚|🏘️|βœ“)❱"init" β–Ίmain, β–Ίnew-A, β–Ίnew-B + └── Β·fafd9d0 (βŒ‚|🏘️|βœ“|1)❱"init" β–Ίmain, β–Ίnew-A, β–Ίnew-B "#); Ok(()) } @@ -136,18 +136,18 @@ fn single_stack_ws_insertions() -> anyhow::Result<()> { let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" └── πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace - └── Β·20de6ee (βŒ‚|🏘️)❱"GitButler Workspace Commit" + └── Β·20de6ee (βŒ‚|🏘️|1)❱"GitButler Workspace Commit" └── β–Ί:2:B - β”œβ”€β”€ Β·70e9a36 (βŒ‚|🏘️)❱"with-ref" - └── Β·320e105 (βŒ‚|🏘️)❱"segment-B" β–Ίtags/without-ref + β”œβ”€β”€ Β·70e9a36 (βŒ‚|🏘️|1)❱"with-ref" + └── Β·320e105 (βŒ‚|🏘️|1)❱"segment-B" β–Ίtags/without-ref └── β–Ί:3:B-empty - └── Β·2a31450 (βŒ‚|🏘️)❱"segment-B~1" β–Ίambiguous-01 + └── Β·2a31450 (βŒ‚|🏘️|1)❱"segment-B~1" β–Ίambiguous-01 └── β–Ί:4:A-empty-03 └── β–Ί:5:A-empty-01 └── β–Ί:6:A - └── Β·70bde6b (βŒ‚|🏘️)❱"segment-A" β–ΊA-empty-02 + └── Β·70bde6b (βŒ‚|🏘️|1)❱"segment-A" β–ΊA-empty-02 └── β–Ί:1:origin/main - └── Β·fafd9d0 (βŒ‚|🏘️|βœ“)❱"init" β–Ίmain, β–Ίnew-A, β–Ίnew-B + └── Β·fafd9d0 (βŒ‚|🏘️|βœ“|1)❱"init" β–Ίmain, β–Ίnew-A, β–Ίnew-B "#); // TODO: do more complex new-stack segmentation @@ -227,15 +227,15 @@ fn single_stack() -> anyhow::Result<()> { let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" └── πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace - └── Β·2c12d75 (βŒ‚|🏘️)❱"GitButler Workspace Commit" + └── Β·2c12d75 (βŒ‚|🏘️|1)❱"GitButler Workspace Commit" └── β–Ί:2:B - └── Β·320e105 (βŒ‚|🏘️)❱"segment-B" + └── Β·320e105 (βŒ‚|🏘️|1)❱"segment-B" └── β–Ί:3:B-sub - └── Β·2a31450 (βŒ‚|🏘️)❱"segment-B~1" + └── Β·2a31450 (βŒ‚|🏘️|1)❱"segment-B~1" └── β–Ί:4:A - └── Β·70bde6b (βŒ‚|🏘️)❱"segment-A" + └── Β·70bde6b (βŒ‚|🏘️|1)❱"segment-A" └── β–Ί:1:origin/main - └── Β·fafd9d0 (βŒ‚|🏘️|βœ“)❱"init" β–Ίmain, β–Ίnew-A + └── Β·fafd9d0 (βŒ‚|🏘️|βœ“|1)❱"init" β–Ίmain, β–Ίnew-A "#); meta.data_mut().branches.clear(); @@ -263,16 +263,16 @@ fn single_stack() -> anyhow::Result<()> { let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" └── πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace - └── Β·2c12d75 (βŒ‚|🏘️)❱"GitButler Workspace Commit" + └── Β·2c12d75 (βŒ‚|🏘️|1)❱"GitButler Workspace Commit" └── β–Ί:2:B - └── Β·320e105 (βŒ‚|🏘️)❱"segment-B" + └── Β·320e105 (βŒ‚|🏘️|1)❱"segment-B" └── β–Ί:3:B-sub - └── Β·2a31450 (βŒ‚|🏘️)❱"segment-B~1" + └── Β·2a31450 (βŒ‚|🏘️|1)❱"segment-B~1" └── β–Ί:4:A - └── Β·70bde6b (βŒ‚|🏘️)❱"segment-A" + └── Β·70bde6b (βŒ‚|🏘️|1)❱"segment-A" └── β–Ί:1:origin/main └── β–Ί:5:new-A - └── Β·fafd9d0 (βŒ‚|🏘️|βœ“)❱"init" β–Ίmain + └── Β·fafd9d0 (βŒ‚|🏘️|βœ“|1)❱"init" β–Ίmain "#); Ok(()) @@ -300,22 +300,22 @@ fn minimal_merge_no_refs() -> anyhow::Result<()> { let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" └── πŸ‘‰β–Ί:0:gitbutler/workspace - └── Β·47e1cf1 (βŒ‚)❱"GitButler Workspace Commit" + └── Β·47e1cf1 (βŒ‚|1)❱"GitButler Workspace Commit" └── β–Ί:1:anon: - └── Β·f40fb16 (βŒ‚)❱"Merge branch \'C\' into merge-2" + └── Β·f40fb16 (βŒ‚|1)❱"Merge branch \'C\' into merge-2" β”œβ”€β”€ β–Ί:3:anon: - β”‚ └── Β·c6d714c (βŒ‚)❱"C" + β”‚ └── Β·c6d714c (βŒ‚|1)❱"C" β”‚ └── β–Ί:4:anon: - β”‚ └── Β·0cc5a6f (βŒ‚)❱"Merge branch \'A\' into merge" + β”‚ └── Β·0cc5a6f (βŒ‚|1)❱"Merge branch \'A\' into merge" β”‚ β”œβ”€β”€ β–Ί:6:anon: - β”‚ β”‚ └── Β·e255adc (βŒ‚)❱"A" + β”‚ β”‚ └── Β·e255adc (βŒ‚|1)❱"A" β”‚ β”‚ └── β–Ί:7:anon: - β”‚ β”‚ └── Β·fafd9d0 (βŒ‚)❱"init" + β”‚ β”‚ └── Β·fafd9d0 (βŒ‚|1)❱"init" β”‚ └── β–Ί:5:anon: - β”‚ └── Β·7fdb58d (βŒ‚)❱"B" + β”‚ └── Β·7fdb58d (βŒ‚|1)❱"B" β”‚ └── β†’:7: └── β–Ί:2:anon: - └── Β·450c58a (βŒ‚)❱"D" + └── Β·450c58a (βŒ‚|1)❱"D" └── β†’:4: "#); Ok(()) @@ -343,12 +343,12 @@ fn segment_on_each_incoming_connection() -> anyhow::Result<()> { let graph = Graph::from_commit_traversal(id, name, &*meta, standard_options())?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ πŸ‘‰β–Ί:0:entrypoint - β”‚ β”œβ”€β”€ Β·98c5aba (βŒ‚)❱"C" - β”‚ β”œβ”€β”€ Β·807b6ce (βŒ‚)❱"B" - β”‚ └── Β·6d05486 (βŒ‚)❱"A" + β”‚ β”œβ”€β”€ Β·98c5aba (βŒ‚|1)❱"C" + β”‚ β”œβ”€β”€ Β·807b6ce (βŒ‚|1)❱"B" + β”‚ └── Β·6d05486 (βŒ‚|1)❱"A" β”‚ └── β–Ί:3:anon: - β”‚ β”œβ”€β”€ Β·b688f2d (βŒ‚|🏘️)❱"other-1" - β”‚ └── Β·fafd9d0 (βŒ‚|🏘️)❱"init" + β”‚ β”œβ”€β”€ Β·b688f2d (βŒ‚|🏘️|1)❱"other-1" + β”‚ └── Β·fafd9d0 (βŒ‚|🏘️|1)❱"init" └── β–Ίβ–Ίβ–Ί:1:gitbutler/workspace └── Β·b6917c7 (βŒ‚|🏘️)❱"GitButler Workspace Commit" └── β–Ί:2:main @@ -380,22 +380,22 @@ fn minimal_merge() -> anyhow::Result<()> { let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ πŸ‘‰β–Ί:0:gitbutler/workspace - β”‚ └── Β·47e1cf1 (βŒ‚)❱"GitButler Workspace Commit" + β”‚ └── Β·47e1cf1 (βŒ‚|1)❱"GitButler Workspace Commit" β”‚ └── β–Ί:1:merge-2 - β”‚ └── Β·f40fb16 (βŒ‚)❱"Merge branch \'C\' into merge-2" + β”‚ └── Β·f40fb16 (βŒ‚|1)❱"Merge branch \'C\' into merge-2" β”‚ β”œβ”€β”€ β–Ί:3:C - β”‚ β”‚ └── Β·c6d714c (βŒ‚)❱"C" + β”‚ β”‚ └── Β·c6d714c (βŒ‚|1)❱"C" β”‚ β”‚ └── β–Ί:4:anon: - β”‚ β”‚ └── Β·0cc5a6f (βŒ‚)❱"Merge branch \'A\' into merge" β–Ίempty-1-on-merge, β–Ίempty-2-on-merge, β–Ίmerge + β”‚ β”‚ └── Β·0cc5a6f (βŒ‚|1)❱"Merge branch \'A\' into merge" β–Ίempty-1-on-merge, β–Ίempty-2-on-merge, β–Ίmerge β”‚ β”‚ β”œβ”€β”€ β–Ί:6:A - β”‚ β”‚ β”‚ └── Β·e255adc (βŒ‚)❱"A" + β”‚ β”‚ β”‚ └── Β·e255adc (βŒ‚|1)❱"A" β”‚ β”‚ β”‚ └── β–Ί:7:main <> origin/main - β”‚ β”‚ β”‚ └── Β·fafd9d0 (βŒ‚)❱"init" + β”‚ β”‚ β”‚ └── Β·fafd9d0 (βŒ‚|11)❱"init" β”‚ β”‚ └── β–Ί:5:B - β”‚ β”‚ └── Β·7fdb58d (βŒ‚)❱"B" + β”‚ β”‚ └── Β·7fdb58d (βŒ‚|1)❱"B" β”‚ β”‚ └── β†’:7: (main) β”‚ └── β–Ί:2:D - β”‚ └── Β·450c58a (βŒ‚)❱"D" + β”‚ └── Β·450c58a (βŒ‚|1)❱"D" β”‚ └── β†’:4: └── β–Ί:8:origin/main └── β†’:7: (main) @@ -413,24 +413,24 @@ fn minimal_merge() -> anyhow::Result<()> { let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" └── πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace - └── Β·47e1cf1 (βŒ‚|🏘️)❱"GitButler Workspace Commit" + └── Β·47e1cf1 (βŒ‚|🏘️|1)❱"GitButler Workspace Commit" └── β–Ί:2:merge-2 - └── Β·f40fb16 (βŒ‚|🏘️)❱"Merge branch \'C\' into merge-2" + └── Β·f40fb16 (βŒ‚|🏘️|1)❱"Merge branch \'C\' into merge-2" β”œβ”€β”€ β–Ί:4:C - β”‚ └── Β·c6d714c (βŒ‚|🏘️)❱"C" + β”‚ └── Β·c6d714c (βŒ‚|🏘️|1)❱"C" β”‚ └── β–Ί:8:empty-2-on-merge β”‚ └── β–Ί:9:empty-1-on-merge β”‚ └── β–Ί:10:merge - β”‚ └── Β·0cc5a6f (βŒ‚|🏘️)❱"Merge branch \'A\' into merge" + β”‚ └── Β·0cc5a6f (βŒ‚|🏘️|1)❱"Merge branch \'A\' into merge" β”‚ β”œβ”€β”€ β–Ί:6:B - β”‚ β”‚ └── Β·7fdb58d (βŒ‚|🏘️)❱"B" + β”‚ β”‚ └── Β·7fdb58d (βŒ‚|🏘️|1)❱"B" β”‚ β”‚ └── β–Ί:1:origin/main - β”‚ β”‚ └── Β·fafd9d0 (βŒ‚|🏘️|βœ“)❱"init" β–Ίmain + β”‚ β”‚ └── Β·fafd9d0 (βŒ‚|🏘️|βœ“|1)❱"init" β–Ίmain β”‚ └── β–Ί:7:A - β”‚ └── Β·e255adc (βŒ‚|🏘️)❱"A" + β”‚ └── Β·e255adc (βŒ‚|🏘️|1)❱"A" β”‚ └── β†’:1: (origin/main) └── β–Ί:3:D - └── Β·450c58a (βŒ‚|🏘️)❱"D" + └── Β·450c58a (βŒ‚|🏘️|1)❱"D" └── β†’:8: (empty-2-on-merge) "#); Ok(()) @@ -449,7 +449,7 @@ fn just_init_with_branches() -> anyhow::Result<()> { insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ πŸ‘‰β–Ί:0:main <> origin/main β”‚ └── β–Ί:2:origin/main - β”‚ └── Β·fafd9d0 (βŒ‚|🏘️|βœ“)❱"init" β–ΊA, β–ΊB, β–ΊC, β–ΊD, β–ΊE, β–ΊF, β–Ίmain + β”‚ └── Β·fafd9d0 (βŒ‚|🏘️|βœ“|1)❱"init" β–ΊA, β–ΊB, β–ΊC, β–ΊD, β–ΊE, β–ΊF, β–Ίmain └── β–Ίβ–Ίβ–Ί:1:gitbutler/workspace └── β†’:2: (origin/main) "#); @@ -480,7 +480,7 @@ fn just_init_with_branches() -> anyhow::Result<()> { β”‚ └── β–Ί:3:C β”‚ └── β–Ί:4:B β”‚ └── β–Ί:5:A - β”‚ └── Β·fafd9d0 (βŒ‚|🏘️|βœ“)❱"init" β–ΊD, β–ΊE, β–ΊF, β–Ίmain + β”‚ └── Β·fafd9d0 (βŒ‚|🏘️|βœ“|1)❱"init" β–ΊD, β–ΊE, β–ΊF, β–Ίmain └── β–Ίβ–Ίβ–Ί:1:gitbutler/workspace └── β†’:2: (origin/main) "#); @@ -504,10 +504,10 @@ fn proper_remote_ahead() -> anyhow::Result<()> { let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace - β”‚ └── Β·9bcd3af (βŒ‚|🏘️)❱"GitButler Workspace Commit" + β”‚ └── Β·9bcd3af (βŒ‚|🏘️|1)❱"GitButler Workspace Commit" β”‚ └── β–Ί:2:main <> origin/main - β”‚ β”œβ”€β”€ Β·998eae6 (βŒ‚|🏘️|βœ“)❱"shared" - β”‚ └── Β·fafd9d0 (βŒ‚|🏘️|βœ“)❱"init" + β”‚ β”œβ”€β”€ Β·998eae6 (βŒ‚|🏘️|βœ“|1)❱"shared" + β”‚ └── Β·fafd9d0 (βŒ‚|🏘️|βœ“|1)❱"init" └── β–Ί:1:origin/main β”œβ”€β”€ 🟣ca7baa7 (βœ“)❱"only-remote-02" └── 🟣7ea1468 (βœ“)❱"only-remote-01" @@ -538,14 +538,14 @@ fn deduced_remote_ahead() -> anyhow::Result<()> { let graph = Graph::from_head(&repo, &*meta, standard_options())?; insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace - β”‚ └── Β·8b39ce4 (βŒ‚|🏘️)❱"GitButler Workspace Commit" + β”‚ └── Β·8b39ce4 (βŒ‚|🏘️|1)❱"GitButler Workspace Commit" β”‚ └── β–Ί:1:A <> origin/A - β”‚ β”œβ”€β”€ Β·9d34471 (βŒ‚|🏘️)❱"A2" - β”‚ └── Β·5b89c71 (βŒ‚|🏘️)❱"A1" + β”‚ β”œβ”€β”€ Β·9d34471 (βŒ‚|🏘️|11)❱"A2" + β”‚ └── Β·5b89c71 (βŒ‚|🏘️|11)❱"A1" β”‚ └── β–Ί:5:anon: - β”‚ └── Β·998eae6 (βŒ‚|🏘️)❱"shared" + β”‚ └── Β·998eae6 (βŒ‚|🏘️|11)❱"shared" β”‚ └── β–Ί:3:main - β”‚ └── Β·fafd9d0 (βŒ‚|🏘️)❱"init" + β”‚ └── Β·fafd9d0 (βŒ‚|🏘️|11)❱"init" └── β–Ί:2:origin/A β”œβ”€β”€ 🟣3ea1a8f❱"only-remote-02" └── 🟣9c50f71❱"only-remote-01" @@ -563,12 +563,12 @@ fn deduced_remote_ahead() -> anyhow::Result<()> { β”œβ”€β”€ β–Ίβ–Ίβ–Ί:1:gitbutler/workspace β”‚ └── Β·8b39ce4 (βŒ‚|🏘️)❱"GitButler Workspace Commit" β”‚ └── β–Ί:2:A <> origin/A - β”‚ β”œβ”€β”€ Β·9d34471 (βŒ‚|🏘️)❱"A2" - β”‚ └── Β·5b89c71 (βŒ‚|🏘️)❱"A1" + β”‚ β”œβ”€β”€ Β·9d34471 (βŒ‚|🏘️|10)❱"A2" + β”‚ └── Β·5b89c71 (βŒ‚|🏘️|10)❱"A1" β”‚ └── β–Ί:5:anon: - β”‚ └── Β·998eae6 (βŒ‚|🏘️)❱"shared" + β”‚ └── Β·998eae6 (βŒ‚|🏘️|10)❱"shared" β”‚ └── πŸ‘‰β–Ί:0:main - β”‚ └── Β·fafd9d0 (βŒ‚|🏘️)❱"init" + β”‚ └── Β·fafd9d0 (βŒ‚|🏘️|11)❱"init" └── β–Ί:3:origin/A β”œβ”€β”€ 🟣3ea1a8f❱"only-remote-02" └── 🟣9c50f71❱"only-remote-01" @@ -601,13 +601,13 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace - β”‚ └── Β·7786959 (βŒ‚|🏘️)❱"GitButler Workspace Commit" + β”‚ └── Β·7786959 (βŒ‚|🏘️|1)❱"GitButler Workspace Commit" β”‚ └── β–Ί:2:B <> origin/B - β”‚ └── Β·312f819 (βŒ‚|🏘️)❱"B" + β”‚ └── Β·312f819 (βŒ‚|🏘️|11)❱"B" β”‚ └── β–Ί:4:A <> origin/A - β”‚ └── Β·e255adc (βŒ‚|🏘️)❱"A" + β”‚ └── Β·e255adc (βŒ‚|🏘️|111)❱"A" β”‚ └── β–Ί:1:origin/main - β”‚ └── Β·fafd9d0 (βŒ‚|🏘️|βœ“)❱"init" β–Ίmain + β”‚ └── Β·fafd9d0 (βŒ‚|🏘️|βœ“|111)❱"init" β–Ίmain └── β–Ί:3:origin/B └── 🟣682be32❱"B" └── β–Ί:5:origin/A @@ -622,18 +622,18 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { β”œβ”€β”€ β–Ίβ–Ίβ–Ί:1:gitbutler/workspace β”‚ └── Β·7786959 (βŒ‚|🏘️)❱"GitButler Workspace Commit" β”‚ └── β–Ί:4:B <> origin/B - β”‚ └── Β·312f819 (βŒ‚|🏘️)❱"B" + β”‚ └── Β·312f819 (βŒ‚|🏘️|10)❱"B" β”‚ └── πŸ‘‰β–Ί:0:A <> origin/A - β”‚ └── Β·e255adc (βŒ‚|🏘️)❱"A" + β”‚ └── Β·e255adc (βŒ‚|🏘️|11)❱"A" β”‚ └── β–Ί:2:origin/main - β”‚ └── Β·fafd9d0 (βŒ‚|🏘️|βœ“)❱"init" β–Ίmain + β”‚ └── Β·fafd9d0 (βŒ‚|🏘️|βœ“|11)❱"init" β–Ίmain └── β–Ί:5:origin/B └── 🟣682be32❱"B" └── β–Ί:3:origin/A └── 🟣e29c23d❱"A" └── β†’:2: (origin/main) "#); - insta::assert_debug_snapshot!(graph.statistics(), @r" + insta::assert_debug_snapshot!(graph.statistics(), @r#" Statistics { segments: 6, segments_integrated: 1, @@ -650,12 +650,45 @@ fn stacked_rebased_remotes() -> anyhow::Result<()> { ), segments_behind_of_entrypoint: 1, segments_ahead_of_entrypoint: 2, + entrypoint: ( + NodeIndex(0), + Some( + 0, + ), + ), + segment_entrypoint_incoming: 1, + segment_entrypoint_outgoing: 1, + top_segments: [ + ( + Some( + FullName( + "refs/heads/gitbutler/workspace", + ), + ), + NodeIndex(1), + Some( + CommitFlags( + NotInRemote | InWorkspace, + ), + ), + ), + ( + Some( + FullName( + "refs/remotes/origin/B", + ), + ), + NodeIndex(5), + None, + ), + ], + segments_at_bottom: 1, connections: 5, commits: 6, commit_references: 1, commits_at_cutoff: 0, } - "); + "#); Ok(()) } @@ -685,15 +718,15 @@ fn disambiguate_by_remote() -> anyhow::Result<()> { let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace - β”‚ └── Β·e30f90c (βŒ‚|🏘️)❱"GitButler Workspace Commit" + β”‚ └── Β·e30f90c (βŒ‚|🏘️|1)❱"GitButler Workspace Commit" β”‚ └── β–Ί:5:anon: - β”‚ └── Β·2173153 (βŒ‚|🏘️)❱"C" β–ΊC, β–Ίambiguous-C + β”‚ └── Β·2173153 (βŒ‚|🏘️|11)❱"C" β–ΊC, β–Ίambiguous-C β”‚ └── β–Ί:8:B <> origin/B - β”‚ └── Β·312f819 (βŒ‚|🏘️)❱"B" β–Ίambiguous-B + β”‚ └── Β·312f819 (βŒ‚|🏘️|111)❱"B" β–Ίambiguous-B β”‚ └── β–Ί:7:A <> origin/A - β”‚ └── Β·e255adc (βŒ‚|🏘️)❱"A" β–Ίambiguous-A + β”‚ └── Β·e255adc (βŒ‚|🏘️|1111)❱"A" β–Ίambiguous-A β”‚ └── β–Ί:1:origin/main - β”‚ └── Β·fafd9d0 (βŒ‚|🏘️|βœ“)❱"init" β–Ίmain + β”‚ └── Β·fafd9d0 (βŒ‚|🏘️|βœ“|1111)❱"init" β–Ίmain β”œβ”€β”€ β–Ί:2:origin/C β”‚ └── β†’:5: β”œβ”€β”€ β–Ί:3:origin/ambiguous-C @@ -748,12 +781,12 @@ fn integrated_tips_stop_early() -> anyhow::Result<()> { let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace - β”‚ └── Β·4077353 (βŒ‚|🏘️)❱"GitButler Workspace Commit" + β”‚ └── Β·4077353 (βŒ‚|🏘️|1)❱"GitButler Workspace Commit" β”‚ └── β–Ί:2:B - β”‚ β”œβ”€β”€ Β·6b1a13b (βŒ‚|🏘️)❱"B2" - β”‚ └── Β·03ad472 (βŒ‚|🏘️)❱"B1" + β”‚ β”œβ”€β”€ Β·6b1a13b (βŒ‚|🏘️|1)❱"B2" + β”‚ └── Β·03ad472 (βŒ‚|🏘️|1)❱"B1" β”‚ └── β–Ί:5:A - β”‚ └── βœ‚οΈΒ·79bbb29 (βŒ‚|🏘️|βœ“)❱"8" + β”‚ └── βœ‚οΈΒ·79bbb29 (βŒ‚|🏘️|βœ“|1)❱"8" └── β–Ί:1:origin/main β”œβ”€β”€ 🟣d0df794 (βœ“)❱"remote-2" └── 🟣09c6e08 (βœ“)❱"remote-1" @@ -779,17 +812,17 @@ fn integrated_tips_stop_early() -> anyhow::Result<()> { let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace - β”‚ └── Β·4077353 (βŒ‚|🏘️)❱"GitButler Workspace Commit" + β”‚ └── Β·4077353 (βŒ‚|🏘️|1)❱"GitButler Workspace Commit" β”‚ └── β–Ί:2:B - β”‚ β”œβ”€β”€ Β·6b1a13b (βŒ‚|🏘️)❱"B2" - β”‚ └── Β·03ad472 (βŒ‚|🏘️)❱"B1" + β”‚ β”œβ”€β”€ Β·6b1a13b (βŒ‚|🏘️|1)❱"B2" + β”‚ └── Β·03ad472 (βŒ‚|🏘️|1)❱"B1" β”‚ └── β–Ί:5:A - β”‚ β”œβ”€β”€ Β·79bbb29 (βŒ‚|🏘️|βœ“)❱"8" - β”‚ β”œβ”€β”€ Β·fc98174 (βŒ‚|🏘️|βœ“)❱"7" - β”‚ β”œβ”€β”€ Β·a381df5 (βŒ‚|🏘️|βœ“)❱"6" - β”‚ └── Β·777b552 (βŒ‚|🏘️|βœ“)❱"5" + β”‚ β”œβ”€β”€ Β·79bbb29 (βŒ‚|🏘️|βœ“|1)❱"8" + β”‚ β”œβ”€β”€ Β·fc98174 (βŒ‚|🏘️|βœ“|1)❱"7" + β”‚ β”œβ”€β”€ Β·a381df5 (βŒ‚|🏘️|βœ“|1)❱"6" + β”‚ └── Β·777b552 (βŒ‚|🏘️|βœ“|1)❱"5" β”‚ └── β–Ί:6:anon: - β”‚ └── βœ‚οΈΒ·ce4a760 (βŒ‚|🏘️|βœ“)❱"Merge branch \'A-feat\' into A" + β”‚ └── βœ‚οΈΒ·ce4a760 (βŒ‚|🏘️|βœ“|1)❱"Merge branch \'A-feat\' into A" └── β–Ί:1:origin/main β”œβ”€β”€ 🟣d0df794 (βœ“)❱"remote-2" └── 🟣09c6e08 (βœ“)❱"remote-1" @@ -801,19 +834,20 @@ fn integrated_tips_stop_early() -> anyhow::Result<()> { └── βœ‚οΈΒ·34d0715 (βŒ‚|βœ“)❱"2" "#); - // The limit is effective for integrated workspaces branches though to prevent runaways. + // The limit is effective for integrated workspaces branches, but the traversal proceeds until + // the integration branch finds its goal. let graph = Graph::from_head(&repo, &*meta, standard_options().with_limit_hint(1))?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace - β”‚ └── Β·4077353 (βŒ‚|🏘️)❱"GitButler Workspace Commit" + β”‚ └── Β·4077353 (βŒ‚|🏘️|1)❱"GitButler Workspace Commit" β”‚ └── β–Ί:2:B - β”‚ β”œβ”€β”€ Β·6b1a13b (βŒ‚|🏘️)❱"B2" - β”‚ └── Β·03ad472 (βŒ‚|🏘️)❱"B1" + β”‚ β”œβ”€β”€ Β·6b1a13b (βŒ‚|🏘️|1)❱"B2" + β”‚ └── Β·03ad472 (βŒ‚|🏘️|1)❱"B1" β”‚ └── β–Ί:5:A - β”‚ β”œβ”€β”€ Β·79bbb29 (βŒ‚|🏘️|βœ“)❱"8" - β”‚ β”œβ”€β”€ Β·fc98174 (βŒ‚|🏘️|βœ“)❱"7" - β”‚ └── βœ‚οΈΒ·a381df5 (βŒ‚|🏘️|βœ“)❱"6" + β”‚ β”œβ”€β”€ Β·79bbb29 (βŒ‚|🏘️|βœ“|1)❱"8" + β”‚ β”œβ”€β”€ Β·fc98174 (βŒ‚|🏘️|βœ“|1)❱"7" + β”‚ └── βœ‚οΈΒ·a381df5 (βŒ‚|🏘️|βœ“|1)❱"6" └── β–Ί:1:origin/main β”œβ”€β”€ 🟣d0df794 (βœ“)❱"remote-2" └── 🟣09c6e08 (βœ“)❱"remote-1" @@ -839,10 +873,10 @@ fn integrated_tips_stop_early() -> anyhow::Result<()> { β”‚ β”œβ”€β”€ Β·6b1a13b (βŒ‚|🏘️)❱"B2" β”‚ └── Β·03ad472 (βŒ‚|🏘️)❱"B1" β”‚ └── πŸ‘‰β–Ί:0:A - β”‚ β”œβ”€β”€ Β·79bbb29 (βŒ‚|🏘️|βœ“)❱"8" - β”‚ β”œβ”€β”€ Β·fc98174 (βŒ‚|🏘️|βœ“)❱"7" - β”‚ β”œβ”€β”€ Β·a381df5 (βŒ‚|🏘️|βœ“)❱"6" - β”‚ └── βœ‚οΈΒ·777b552 (βŒ‚|🏘️|βœ“)❱"5" + β”‚ β”œβ”€β”€ Β·79bbb29 (βŒ‚|🏘️|βœ“|1)❱"8" + β”‚ β”œβ”€β”€ Β·fc98174 (βŒ‚|🏘️|βœ“|1)❱"7" + β”‚ β”œβ”€β”€ Β·a381df5 (βŒ‚|🏘️|βœ“|1)❱"6" + β”‚ └── βœ‚οΈΒ·777b552 (βŒ‚|🏘️|βœ“|1)❱"5" └── β–Ί:2:origin/main β”œβ”€β”€ 🟣d0df794 (βœ“)❱"remote-2" └── 🟣09c6e08 (βœ“)❱"remote-1" @@ -867,7 +901,7 @@ fn workspace_obeys_limit_when_target_branch_is_missing() -> anyhow::Result<()> { Graph::from_head(&repo, &*meta, standard_options().with_limit_hint(0))?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" └── πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace - └── βœ‚οΈΒ·4077353 (βŒ‚|🏘️)❱"GitButler Workspace Commit" + └── βœ‚οΈΒ·4077353 (βŒ‚|🏘️|1)❱"GitButler Workspace Commit" "#); meta.data_mut().branches.clear(); @@ -881,12 +915,12 @@ fn workspace_obeys_limit_when_target_branch_is_missing() -> anyhow::Result<()> { Graph::from_head(&repo, &*meta, standard_options().with_limit_hint(0))?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace - β”‚ └── Β·4077353 (βŒ‚|🏘️)❱"GitButler Workspace Commit" + β”‚ └── Β·4077353 (βŒ‚|🏘️|1)❱"GitButler Workspace Commit" β”‚ └── β–Ί:2:B - β”‚ β”œβ”€β”€ Β·6b1a13b (βŒ‚|🏘️)❱"B2" - β”‚ └── Β·03ad472 (βŒ‚|🏘️)❱"B1" + β”‚ β”œβ”€β”€ Β·6b1a13b (βŒ‚|🏘️|1)❱"B2" + β”‚ └── Β·03ad472 (βŒ‚|🏘️|1)❱"B1" β”‚ └── β–Ί:5:A - β”‚ └── βœ‚οΈΒ·79bbb29 (βŒ‚|🏘️|βœ“)❱"8" + β”‚ └── βœ‚οΈΒ·79bbb29 (βŒ‚|🏘️|βœ“|1)❱"8" └── β–Ί:1:origin/main β”œβ”€β”€ 🟣d0df794 (βœ“)❱"remote-2" └── 🟣09c6e08 (βœ“)❱"remote-1" @@ -917,8 +951,8 @@ fn on_top_of_target_with_history() -> anyhow::Result<()> { insta::assert_snapshot!(graph_tree(&graph), @r#" └── πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace └── β–Ί:1:origin/main - β”œβ”€β”€ Β·2cde30a (βŒ‚|🏘️|βœ“)❱"5" β–ΊA, β–ΊB, β–ΊC, β–ΊD, β–ΊE, β–ΊF - └── βœ‚οΈΒ·1c938f4 (βŒ‚|🏘️|βœ“)❱"4" + β”œβ”€β”€ Β·2cde30a (βŒ‚|🏘️|βœ“|1)❱"5" β–ΊA, β–ΊB, β–ΊC, β–ΊD, β–ΊE, β–ΊF + └── βœ‚οΈΒ·1c938f4 (βŒ‚|🏘️|βœ“|1)❱"4" "#); // TODO: fix this - it builds a wrong graph. diff --git a/crates/but-graph/tests/graph/utils.rs b/crates/but-graph/tests/graph/utils.rs index 47bfbe7485..e6e7d9e653 100644 --- a/crates/but-graph/tests/graph/utils.rs +++ b/crates/but-graph/tests/graph/utils.rs @@ -93,7 +93,11 @@ pub fn graph_tree(graph: &but_graph::Graph) -> SegmentTree { for (cidx, commit) in segment.commits.iter().enumerate() { let mut commit_tree = tree_for_commit( commit, - commit.has_conflicts, + commit + .details + .as_ref() + .map(|d| d.has_conflicts) + .unwrap_or_default(), segment_is_entrypoint && Some(cidx) == ep.commit_index, graph.is_early_end_of_traversal(sidx, cidx), graph.hard_limit_hit(), diff --git a/crates/but-graph/tests/graph/vis.rs b/crates/but-graph/tests/graph/vis.rs index 9cb7a4978b..384c019ee3 100644 --- a/crates/but-graph/tests/graph/vis.rs +++ b/crates/but-graph/tests/graph/vis.rs @@ -2,7 +2,7 @@ use crate::graph_tree; use but_core::ref_metadata; -use but_graph::{Commit, CommitFlags, Graph, Segment, SegmentMetadata}; +use but_graph::{Commit, CommitDetails, CommitFlags, Graph, Segment, SegmentMetadata}; /// Simulate a graph data structure after the first pass, i.e., right after the walk. /// There is no pruning of 'empty' branches, just a perfect representation of the graph as is, @@ -59,10 +59,14 @@ fn post_graph_traversal() -> anyhow::Result<()> { remote_tracking_ref_name: Some("refs/remotes/origin/A".try_into()?), commits: vec![ Commit { - has_conflicts: true, + details: Some(CommitDetails { + has_conflicts: true, + author: author(), + message: "2 in A".into(), + }), ..commit( id("a"), - "2 in A", + "overridden above", Some(init_commit_id), CommitFlags::InWorkspace, ) @@ -126,7 +130,7 @@ fn unborn_head() { } mod utils { - use but_graph::{Commit, CommitFlags}; + use but_graph::{Commit, CommitDetails, CommitFlags}; use gix::ObjectId; use std::str::FromStr; @@ -139,11 +143,13 @@ mod utils { Commit { id, parent_ids: parent_ids.into_iter().collect(), - message: message.into(), - author: author(), refs: Vec::new(), flags, - has_conflicts: false, + details: Some(CommitDetails { + message: message.into(), + author: author(), + has_conflicts: false, + }), } } @@ -161,7 +167,7 @@ mod utils { .unwrap() } - fn author() -> gix::actor::Signature { + pub fn author() -> gix::actor::Signature { gix::actor::Signature { name: "Name".into(), email: "name@example.com".into(), @@ -169,4 +175,4 @@ mod utils { } } } -use utils::{commit, id}; +use utils::{author, commit, id}; diff --git a/crates/but-testing/src/args.rs b/crates/but-testing/src/args.rs index a5c88ed73f..a5645d41ed 100644 --- a/crates/but-testing/src/args.rs +++ b/crates/but-testing/src/args.rs @@ -152,6 +152,9 @@ pub enum Subcommands { }, /// Returns a segmented graph starting from `HEAD`. Graph { + /// Debug-print the whole graph. + #[clap(long, short = 'd')] + debug: bool, /// The maximum number of commits to traverse. /// /// Use only as safety net to prevent runaways. diff --git a/crates/but-testing/src/command/mod.rs b/crates/but-testing/src/command/mod.rs index 79f111d992..4ecb40018c 100644 --- a/crates/but-testing/src/command/mod.rs +++ b/crates/but-testing/src/command/mod.rs @@ -504,6 +504,7 @@ pub fn graph( limit: Option, limit_extension: Vec, hard_limit: Option, + debug: bool, ) -> anyhow::Result<()> { let (mut repo, project) = repo_and_maybe_project(args, RepositoryOpenMode::General)?; repo.objects.refresh = RefreshMode::Never; @@ -549,6 +550,10 @@ pub fn graph( } }?; + let errors = graph.validation_errors(); + if !errors.is_empty() { + eprintln!("VALIDATION FAILED: {errors:?}"); + } eprintln!("{:#?}", graph.statistics()); if no_open { stdout().write_all(graph.dot_graph().as_bytes())?; @@ -556,6 +561,10 @@ pub fn graph( #[cfg(unix)] graph.open_as_svg(); } + + if debug { + eprintln!("{graph:#?}"); + } Ok(()) } diff --git a/crates/but-testing/src/main.rs b/crates/but-testing/src/main.rs index 5c35ddc03c..07c21439c5 100644 --- a/crates/but-testing/src/main.rs +++ b/crates/but-testing/src/main.rs @@ -115,6 +115,7 @@ async fn main() -> Result<()> { limit, limit_extension, hard_limit, + debug, } => command::graph( &args, ref_name.as_deref(), @@ -122,6 +123,7 @@ async fn main() -> Result<()> { limit.flatten(), limit_extension.clone(), *hard_limit, + *debug, ), args::Subcommands::HunkAssignments => { command::assignment::hunk_assignments(&args.current_dir, args.json) diff --git a/crates/but-workspace/src/ref_info.rs b/crates/but-workspace/src/ref_info.rs index ee20c649e8..9a4944d5ad 100644 --- a/crates/but-workspace/src/ref_info.rs +++ b/crates/but-workspace/src/ref_info.rs @@ -23,9 +23,84 @@ pub struct Options { /// Types driven by the user interface, not general purpose. pub mod ui { - use but_graph::{Commit, CommitFlags, SegmentMetadata}; + use bstr::BString; + use but_graph::{CommitFlags, SegmentMetadata}; use std::ops::{Deref, DerefMut}; + /// A commit with must useful information extracted from the Git commit itself. + /// + /// Note that additional information can be computed and placed in the [`LocalCommit`] and [`RemoteCommit`] + #[derive(Clone, Eq, PartialEq)] + pub struct Commit { + /// The hash of the commit. + pub id: gix::ObjectId, + /// The IDs of the parent commits, but may be empty if this is the first commit. + pub parent_ids: Vec, + /// The complete message, verbatim. + pub message: BString, + /// The signature at which the commit was authored. + pub author: gix::actor::Signature, + /// The references pointing to this commit, even after dereferencing tag objects. + /// These can be names of tags and branches. + pub refs: Vec, + /// Additional properties to help classify this commit. + pub flags: CommitFlags, + /// Whether the commit is in a conflicted state, a GitButler concept. + /// GitButler will perform rebasing/reordering etc. without interruptions and flag commits as conflicted if needed. + /// Conflicts are resolved via the Edit Mode mechanism. + /// + /// Note that even though GitButler won't push branches with conflicts, the user can still push such branches at will. + pub has_conflicts: bool, + } + + impl Commit { + /// Read the object of the `commit_id` and extract relevant values, while setting `flags` as well. + pub fn new_from_id( + commit_id: gix::Id<'_>, + flags: CommitFlags, + has_conflicts: bool, + ) -> anyhow::Result { + let commit = commit_id.object()?.into_commit(); + // Decode efficiently, no need to own this. + let commit = commit.decode()?; + Ok(Commit { + id: commit_id.detach(), + parent_ids: commit.parents().collect(), + message: commit.message.to_owned(), + author: commit.author.to_owned()?, + refs: Vec::new(), + flags, + has_conflicts, + }) + } + } + + impl std::fmt::Debug for Commit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Commit({hash}, {msg:?}{flags})", + hash = self.id.to_hex_with_len(7), + msg = self.message, + flags = self.flags.debug_string() + ) + } + } + + impl From> for Commit { + fn from(value: but_core::Commit<'_>) -> Self { + Commit { + id: value.id.into(), + parent_ids: value.parents.iter().cloned().collect(), + message: value.inner.message, + author: value.inner.author, + refs: Vec::new(), + flags: CommitFlags::empty(), + has_conflicts: false, + } + } + } + /// A commit that is reachable through the *local tracking branch*, with additional, computed information. #[derive(Clone, Eq, PartialEq)] pub struct LocalCommit { diff --git a/crates/but-workspace/src/stacks.rs b/crates/but-workspace/src/stacks.rs index 5586f3ad87..cd0a210e59 100644 --- a/crates/but-workspace/src/stacks.rs +++ b/crates/but-workspace/src/stacks.rs @@ -1,5 +1,5 @@ use crate::integrated::IsCommitIntegrated; -use crate::ref_info::ui::Segment; +use crate::ref_info::ui::{Commit, Segment}; use crate::ref_info::ui::{LocalCommit, LocalCommitRelation, RemoteCommit}; use crate::ui::{CommitState, PushStatus, StackDetails}; use crate::{ @@ -8,7 +8,7 @@ use crate::{ use anyhow::Context; use bstr::BString; use but_core::RefMetadata; -use but_graph::{Commit, SegmentMetadata, VirtualBranchesTomlMetadata}; +use but_graph::{SegmentMetadata, VirtualBranchesTomlMetadata}; use gitbutler_command_context::CommandContext; use gitbutler_commit::commit_ext::CommitExt; use gitbutler_oxidize::{ObjectIdExt, OidExt, git2_signature_to_gix_signature}; From 219c4d75eaedac7ae4cfe764ae6098dd96b95201 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sat, 21 Jun 2025 20:34:19 +0200 Subject: [PATCH 8/8] the final stretch to make GitLab work It doesn't currently manage to traverse enough commits to connect a far-away integration branch with the entrypoint, simply master. --- crates/but-graph/Cargo.toml | 2 +- crates/but-graph/src/api.rs | 6 +- crates/but-graph/src/init/mod.rs | 69 ++- crates/but-graph/src/init/post.rs | 3 +- crates/but-graph/src/init/utils.rs | 456 ++++++++++-------- crates/but-graph/tests/fixtures/scenarios.sh | 105 ++++ .../tests/graph/init/with_workspace.rs | 403 +++++++++++++++- 7 files changed, 778 insertions(+), 266 deletions(-) diff --git a/crates/but-graph/Cargo.toml b/crates/but-graph/Cargo.toml index 43c60efe9c..a794290f6b 100644 --- a/crates/but-graph/Cargo.toml +++ b/crates/but-graph/Cargo.toml @@ -8,7 +8,7 @@ publish = false [lib] doctest = false -test = false +test = true [dependencies] but-core.workspace = true diff --git a/crates/but-graph/src/api.rs b/crates/but-graph/src/api.rs index 77cdb5d183..25dd10256e 100644 --- a/crates/but-graph/src/api.rs +++ b/crates/but-graph/src/api.rs @@ -232,11 +232,7 @@ impl Graph { .tip_segments() .map(|s| { let s = &self[s]; - ( - s.ref_name.as_ref().map(|rn| rn.clone()), - s.id, - s.flags_of_first_commit(), - ) + (s.ref_name.clone(), s.id, s.flags_of_first_commit()) }) .collect(); *segments_at_bottom = self.base_segments().count(); diff --git a/crates/but-graph/src/init/mod.rs b/crates/but-graph/src/init/mod.rs index ac376133b2..4740816705 100644 --- a/crates/but-graph/src/init/mod.rs +++ b/crates/but-graph/src/init/mod.rs @@ -39,17 +39,8 @@ pub struct Options { /// is nothing interesting left to traverse. /// /// Also note: This is a hint and not an exact measure, and it's always possible to receive a more commits - /// for various reasons, for instance the need to let remote branches find their local brnach independently + /// for various reasons, for instance the need to let remote branches find their local branch independently /// of the limit. - /// - /// ### Tip Configuration - /// - /// * HEAD - uses the limit - /// * workspaces with target branch - no limit, but auto-stop if workspace is exhausted as everything is integrated. - /// - The target branch: no limit - /// - Integrated workspace branches: use the limit - /// * workspace without target branch - uses the limit - /// * remotes tracking branches - use the limit, but only once they have reached a local branch. pub commits_limit_hint: Option, /// A list of the last commits of partial segments previously returned that reset the amount of available /// commits to traverse back to `commit_limit_hint`. @@ -107,7 +98,7 @@ impl Graph { gix::head::Kind::Unborn(ref_name) => { let mut graph = Graph::default(); graph.insert_root(branch_segment_from_name_and_meta( - Some(ref_name), + Some((ref_name, None)), meta, None, )?); @@ -165,6 +156,8 @@ impl Graph { /// * The traversal is cut short when there is only tips which are integrated, even though named segments that are /// supposed to be in the workspace will be fully traversed (implying they will stop at the first anon segment /// as will happen at merge commits). + /// * The traversal is always as long as it needs to be to fully reconcile possibly disjoint branches, despite + /// this sometimes costing some time when the remote is far ahead in a huge repository. #[instrument(skip(meta, ref_name), err(Debug))] pub fn from_commit_traversal( tip: gix::Id<'_>, @@ -177,10 +170,10 @@ impl Graph { hard_limit, }: Options, ) -> anyhow::Result { - let limit = Limit::from(limit); + let repo = tip.repo; + let max_limit = Limit::new(limit); // TODO: also traverse (outside)-branches that ought to be in the workspace. That way we have the desired ones // automatically and just have to find a way to prune the undesired ones. - let repo = tip.repo; let ref_name = ref_name.into(); if ref_name .as_ref() @@ -205,13 +198,15 @@ impl Graph { None }), )?; - let (workspaces, target_refs, desired_refs) = + let (workspaces, target_refs) = obtain_workspace_infos(repo, ref_name.as_ref().map(|rn| rn.as_ref()), meta)?; let mut seen = gix::revwalk::graph::IdMap::::default(); let mut goals = Goals::default(); - let tip_limit_with_goal = limit.with_goal(tip.detach(), &mut goals); // The tip transports itself. - let tip_flags = CommitFlags::NotInRemote | tip_limit_with_goal.goal; + let tip_flags = CommitFlags::NotInRemote + | goals + .flag_for(tip.detach()) + .expect("we more than one bitflags for this"); let target_symbolic_remote_names = { let remote_names = repo.remote_names(); @@ -233,7 +228,7 @@ impl Graph { .any(|(_, wsrn, _)| Some(wsrn) == ref_name.as_ref()) { let current = graph.insert_root(branch_segment_from_name_and_meta( - ref_name.clone(), + ref_name.clone().map(|rn| (rn, None)), meta, Some((&refs_by_id, tip.detach())), )?); @@ -241,7 +236,7 @@ impl Graph { tip.detach(), tip_flags, Instruction::CollectCommit { into: current }, - limit, + max_limit, )) { return Ok(graph.with_hard_limit()); } @@ -261,12 +256,16 @@ impl Graph { .map(|tid| (trn.clone(), tid)) }); - let (ws_flags, ws_limit) = if Some(&ws_ref) == ref_name.as_ref() { - (tip_flags, limit) + let (ws_extra_flags, ws_limit) = if Some(&ws_ref) == ref_name.as_ref() { + (tip_flags, max_limit) } else { - (CommitFlags::empty(), tip_limit_with_goal) + ( + CommitFlags::empty(), + max_limit.with_indirect_goal(tip.detach(), &mut goals), + ) }; - let mut ws_segment = branch_segment_from_name_and_meta(Some(ws_ref), meta, None)?; + let mut ws_segment = + branch_segment_from_name_and_meta(Some((ws_ref, None)), meta, None)?; // The limits for the target ref and the worktree ref are synced so they can always find each other, // while being able to stop when the entrypoint is included. ws_segment.metadata = Some(SegmentMetadata::Workspace(workspace_info)); @@ -279,7 +278,7 @@ impl Graph { // We only allow workspaces that are not remote, and that are not target refs. // Theoretically they can still cross-reference each other, but then we'd simply ignore // their status for now. - CommitFlags::NotInRemote | ws_flags, + CommitFlags::NotInRemote | ws_extra_flags, Instruction::CollectCommit { into: ws_segment }, ws_limit, )) { @@ -287,7 +286,7 @@ impl Graph { } if let Some((target_ref, target_ref_id)) = target { let target_segment = graph.insert_root(branch_segment_from_name_and_meta( - Some(target_ref), + Some((target_ref, None)), meta, None, )?); @@ -297,7 +296,11 @@ impl Graph { Instruction::CollectCommit { into: target_segment, }, - tip_limit_with_goal, + // Once the goal was found, be done immediately, + // we are not interested in these. + max_limit + .with_indirect_goal(tip.detach(), &mut goals) + .without_allowance(), )) { return Ok(graph.with_hard_limit()); } @@ -305,9 +308,6 @@ impl Graph { } max_commits_recharge_location.sort(); - // Set max-limit so that we compensate for the way this is counted. - // let max_limit = limit.incremented(); - let max_limit = limit; while let Some((id, mut propagated_flags, instruction, mut limit)) = next.pop_front() { if max_commits_recharge_location.binary_search(&id).is_ok() { limit.set_but_keep_goal(max_limit); @@ -330,7 +330,6 @@ impl Graph { &mut seen, &mut next, id, - limit, propagated_flags, src_sidx, )?; @@ -359,7 +358,6 @@ impl Graph { &mut seen, &mut next, id, - limit, propagated_flags, parent_above, )?; @@ -432,7 +430,7 @@ impl Graph { } } - prune_integrated_tips(&mut graph, &mut next, &desired_refs, max_limit); + prune_integrated_tips(&mut graph, &mut next); } graph.post_processed( @@ -459,15 +457,6 @@ struct Queue { max: Option, } -#[derive(Debug, Copy, Clone)] -struct Limit { - inner: Option, - /// The commit we want to see to be able to assume normal limits. Until then there is no limit. - /// This is represented by bitflag, one for each goal. - /// The flag is empty if no goal is set. - goal: CommitFlags, -} - /// A set of commits to keep track of in bitflags. #[derive(Default)] struct Goals(Vec); diff --git a/crates/but-graph/src/init/post.rs b/crates/but-graph/src/init/post.rs index 54688d1304..9e5678c73c 100644 --- a/crates/but-graph/src/init/post.rs +++ b/crates/but-graph/src/init/post.rs @@ -358,7 +358,8 @@ fn create_connected_multi_segment( }) { let ref_name = &refs[ref_idx]; - let new_segment = branch_segment_from_name_and_meta(Some(ref_name.clone()), meta, None)?; + let new_segment = + branch_segment_from_name_and_meta(Some((ref_name.clone(), None)), meta, None)?; let above_commit_idx = { let s = &graph[above_idx]; let cidx = s.commit_index_of(commit.id); diff --git a/crates/but-graph/src/init/utils.rs b/crates/but-graph/src/init/utils.rs index cd1162bc65..822209a967 100644 --- a/crates/but-graph/src/init/utils.rs +++ b/crates/but-graph/src/init/utils.rs @@ -1,5 +1,5 @@ use crate::init::walk::TopoWalk; -use crate::init::{EdgeOwned, Goals, Instruction, Limit, PetGraph, Queue, QueueItem, remotes}; +use crate::init::{EdgeOwned, Goals, Instruction, PetGraph, Queue, QueueItem, remotes}; use crate::segment::CommitDetails; use crate::{ Commit, CommitFlags, CommitIndex, Edge, Graph, Segment, SegmentIndex, SegmentMetadata, @@ -201,11 +201,8 @@ pub fn try_split_non_empty_segment_at_branch( if src_segment.commits.is_empty() { return Ok(None); } - let maybe_segment_name_from_unabigous_refs = local_branches_by_id(refs_by_id, info.id) - .and_then(|mut branches| { - let first_ref = branches.next()?; - branches.next().is_none().then(|| first_ref.to_owned()) - }); + let maybe_segment_name_from_unabigous_refs = + disambiguate_refs_by_branch_metadata((refs_by_id, info.id), meta); let Some(maybe_segment_name) = maybe_segment_name_from_unabigous_refs .map(Some) .or_else(|| { @@ -272,41 +269,101 @@ pub fn queue_parents( false } +/// As convenience, if `ref_name` is `Some` and the metadata is not set, it will look it up for you. +/// If `ref_name` is `None`, and `refs_by_id_lookup` is `Some`, it will try to look up unambiguous +/// references on that object. pub fn branch_segment_from_name_and_meta( - mut ref_name: Option, + ref_name: Option<(gix::refs::FullName, Option)>, meta: &impl RefMetadata, refs_by_id_lookup: Option<(&RefsById, gix::ObjectId)>, ) -> anyhow::Result { - if let Some((refs_by_id, id)) = refs_by_id_lookup.filter(|_| ref_name.is_none()) { - if let Some(unambiguous_local_branch) = local_branches_by_id(refs_by_id, id) - .and_then(|mut branches| branches.next().filter(|_| branches.next().is_none())) - { - ref_name = Some(unambiguous_local_branch.clone()); - } - } + let (ref_name, metadata) = + unambiguous_local_branch_and_segment_data(ref_name, meta, refs_by_id_lookup)?; Ok(Segment { - metadata: ref_name - .as_ref() - .and_then(|rn| { - meta.branch_opt(rn.as_ref()) - .map(|res| res.map(|md| SegmentMetadata::Branch(md.clone()))) - .transpose() + metadata, + ref_name, + ..Default::default() + }) +} + +fn unambiguous_local_branch_and_segment_data( + ref_name: Option<(gix::refs::FullName, Option)>, + meta: &impl RefMetadata, + refs_by_id_lookup: Option<(&RefsById, gix::ObjectId)>, +) -> anyhow::Result<(Option, Option)> { + Ok(match ref_name { + None => { + let Some(lookup) = refs_by_id_lookup else { + return Ok(Default::default()); + }; + disambiguate_refs_by_branch_metadata(lookup, meta) + .map(|(rn, md)| (Some(rn), md)) + .unwrap_or_default() + } + Some((ref_name, maybe_metadata)) => { + let metadata = maybe_metadata + .map(Ok) + .or_else(|| extract_local_branch_metadata(ref_name.as_ref(), meta).transpose()) + .transpose()?; + (Some(ref_name), metadata) + } + }) +} + +fn disambiguate_refs_by_branch_metadata( + refs_by_id_lookup: (&RefsById, gix::ObjectId), + meta: &impl RefMetadata, +) -> Option<(gix::refs::FullName, Option)> { + let (refs_by_id, id) = refs_by_id_lookup; + local_branches_by_id(refs_by_id, id).and_then(|branches| { + let branches = branches + .map(|rn| { + ( + rn, + extract_local_branch_metadata(rn.as_ref(), meta) + .ok() + .flatten(), + ) }) - // Also check for workspace data so we always correctly classify segments. - // This could happen if we run over another workspace commit which is reachable - // through the current tip. + .collect::>(); + let mut branches_with_metadata = branches + .iter() + .filter_map(|(rn, md)| md.is_some().then_some((*rn, md.as_ref()))); + // Take an unambiguous branch *with* metadata, or fallback to one without metadata. + branches_with_metadata + .next() + .filter(|_| branches_with_metadata.next().is_none()) .or_else(|| { - let rn = ref_name.as_ref()?; - meta.workspace_opt(rn.as_ref()) - .map(|res| res.map(|md| SegmentMetadata::Workspace(md.clone()))) - .transpose() + let mut iter = branches.iter(); + iter.next() + .filter(|_| iter.next().is_none()) + .map(|(rn, md)| (*rn, md.as_ref())) }) - .transpose()?, - ref_name, - ..Default::default() + .map(|(rn, md)| (rn.clone(), md.cloned())) }) } +fn extract_local_branch_metadata( + ref_name: &gix::refs::FullNameRef, + meta: &impl RefMetadata, +) -> anyhow::Result> { + if ref_name.category() != Some(Category::LocalBranch) { + return Ok(None); + } + meta.branch_opt(ref_name) + .map(|res| res.map(|md| SegmentMetadata::Branch(md.clone()))) + .transpose() + // Also check for workspace data so we always correctly classify segments. + // This could happen if we run over another workspace commit which is reachable + // through the current tip. + .or_else(|| { + meta.workspace_opt(ref_name) + .map(|res| res.map(|md| SegmentMetadata::Workspace(md.clone()))) + .transpose() + }) + .transpose() +} + // Like the plumbing type, but will keep information that was already accessible for us. #[derive(Debug)] pub struct TraverseInfo { @@ -460,7 +517,6 @@ pub fn obtain_workspace_infos( ) -> anyhow::Result<( Vec<(gix::ObjectId, gix::refs::FullName, ref_metadata::Workspace)>, Vec, - BTreeSet, )> { let workspaces = if let Some((ref_name, ws_data)) = maybe_ref_name .and_then(|ref_name| { @@ -483,7 +539,7 @@ pub fn obtain_workspace_infos( .collect() }; - let (mut out, mut target_refs, mut desired_refs) = (Vec::new(), Vec::new(), BTreeSet::new()); + let (mut out, mut target_refs) = (Vec::new(), Vec::new()); for (rn, data) in workspaces { if rn.category() != Some(Category::LocalBranch) { tracing::warn!( @@ -520,15 +576,10 @@ pub fn obtain_workspace_infos( }; target_refs.extend(data.target_ref.clone()); - desired_refs.extend( - data.stacks - .iter() - .flat_map(|stacks| stacks.branches.iter().map(|b| b.ref_name.clone())), - ); out.push((ws_tip, rn, data)) } - Ok((out, target_refs, desired_refs)) + Ok((out, target_refs)) } pub fn try_refname_to_id( @@ -546,8 +597,6 @@ pub fn try_refname_to_id( /// among the shared commit are send downward, towards the base. pub fn propagate_flags_downward( graph: &mut PetGraph, - next: &mut Queue, - limit: Limit, flags_to_add: CommitFlags, dst_sidx: SegmentIndex, dst_commit: Option, @@ -556,12 +605,6 @@ pub fn propagate_flags_downward( while let Some((segment, commit_range)) = topo.next(graph) { for commit in &mut graph[segment].commits[commit_range] { commit.flags |= flags_to_add; - // Note that this just works if the remote is still fast-forwardable. - // If the goal isn't met, it's OK as well, but there is a chance for runaways. - // TODO(perf): only walk to where the flags differ, with custom walk. - if commit.flags.contains(limit.goal) { - next.inner.iter_mut().for_each(|t| t.3.unset_goal()); - } } } } @@ -582,15 +625,10 @@ pub fn try_queue_remote_tracking_branches( target_refs: &[gix::refs::FullName], meta: &impl RefMetadata, id: gix::ObjectId, - mut limit: Limit, + limit: Limit, goals: &mut Goals, ) -> anyhow::Result<(Vec, CommitFlags)> { - // As a commit can be reachable by many remote tracking branches while we need - // specificity, there is no need to propagate anything. - // We also *do not* propagate "NotInCommit" so remote-exclusive commits can be identified - // even across segment boundaries - let flags = CommitFlags::empty(); - limit.goal = CommitFlags::empty(); + let mut goal_flags = CommitFlags::empty(); let mut queue = Vec::new(); for rn in refs { let Some(remote_tracking_branch) = remotes::lookup_remote_tracking_branch_or_deduce_it( @@ -611,70 +649,25 @@ pub fn try_queue_remote_tracking_branches( continue; }; let remote_segment = graph.insert_root(branch_segment_from_name_and_meta( - Some(remote_tracking_branch), + Some((remote_tracking_branch, None)), meta, None, )?); - limit = limit.with_goal(id, goals); + let remote_limit = limit.with_indirect_goal(id, goals); + // These flags are to be attached to `id` so it can propagate itself later. + // The remote limit is for searching `id`. + goal_flags |= remote_limit.goal_flags(); queue.push(( remote_tip, - flags, + CommitFlags::empty(), Instruction::CollectCommit { into: remote_segment, }, - // If the remote is behind the goal it will never reach it, but it will stop once it - // touches another commit as well. - limit, + remote_limit, )); } - Ok((queue, limit.goal)) -} - -/// Remove if there are only tips with integrated commits… -/// -/// - keep tips that are adding segments that are or contain a workspace ref -/// - prune the rest -/// - delete empty segments of pruned tips. -/// -/// `max_limit` is applied to integrated workspace refs if they are not yet limited, and should -/// be the initially configured limit. -pub fn prune_integrated_tips( - graph: &mut Graph, - next: &mut Queue, - workspace_refs: &BTreeSet, - max_limit: Limit, -) { - let all_integated = next - .iter() - .all(|tip| tip.1.contains(CommitFlags::Integrated)); - if !all_integated { - return; - } - next.retain_mut(|(_id, flags, instruction, tip_limit)| { - // Let them reach their goal - this could lead to runaways, if the integration branch is behind the entrypoint, - // but once the goal catches up with the bottom section of the graph it propagates the flags so we will eventually - // see it. - if tip_limit.goal_in(*flags) == Some(false) { - return true; - } - let sidx = instruction.segment_idx(); - let s = &graph[sidx]; - let any_segment_ref_is_contained_in_workspace = s - .ref_name - .as_ref() - .into_iter() - .chain(s.commits.iter().flat_map(|c| c.refs.iter())) - .any(|segment_rn| workspace_refs.contains(segment_rn)); - // For integrated workspace tips, use a limit to prevent runaway-worst-cases. - if any_segment_ref_is_contained_in_workspace && tip_limit.is_unset() { - *tip_limit = max_limit; - } - if !any_segment_ref_is_contained_in_workspace && s.commits.is_empty() { - graph.inner.remove_node(sidx); - } - any_segment_ref_is_contained_in_workspace - }); + Ok((queue, goal_flags)) } pub fn possibly_split_occupied_segment( @@ -682,7 +675,6 @@ pub fn possibly_split_occupied_segment( seen: &mut gix::revwalk::graph::IdMap, next: &mut Queue, id: gix::ObjectId, - limit: Limit, propagated_flags: CommitFlags, src_sidx: SegmentIndex, ) -> anyhow::Result<()> { @@ -694,7 +686,7 @@ pub fn possibly_split_occupied_segment( // If a normal branch walks into a workspace branch, put the workspace branch on top. if graph[dst_sidx].workspace_metadata().is_some() && graph[src_sidx].ref_name.as_ref() - .is_some_and(|rn| rn.category().is_some_and(|c| matches!(c, Category::LocalBranch))) { + .and_then(|rn| rn.category()).is_some_and(|c| matches!(c, Category::LocalBranch)) { // `dst` is basically swapping with `src`, so must swap commits and connections. swap_commits_and_connections(&mut graph.inner, dst_sidx, src_sidx); swap_queued_segments(next, dst_sidx, src_sidx); @@ -738,101 +730,43 @@ pub fn possibly_split_occupied_segment( .unwrap_or_default(); let bottom_flags = graph[bottom_sidx].commits[bottom_cidx].flags; let new_flags = propagated_flags | top_flags | bottom_flags; - // Only propagate if there is something new. + + // Only propagate if there is something new as propagation is slow if new_flags != bottom_flags { - propagate_flags_downward( - &mut graph.inner, - next, - limit, - new_flags, - bottom_sidx, - Some(bottom_cidx), - ); + propagate_flags_downward(&mut graph.inner, new_flags, bottom_sidx, Some(bottom_cidx)); } Ok(()) } -impl Limit { - /// Return `true` if this limit is depleted, or decrement it by one otherwise. - /// - /// `flags` are used to selectively decrement this limit. - /// Thanks to flag-propagation there can be no runaways. - fn is_exhausted_or_decrement(&mut self, flags: CommitFlags, next: &Queue) -> bool { - // Keep going if the goal wasn't seen yet, unlimited gas. - if self.goal_in(flags) == Some(false) { - return false; - } - // Do not let *any* tip consume gas as long as there is still anything with a goal in the queue - // that need to meet their local branches. - // TODO(perf): could we remember that we are a tip and look for our specific counterpart by matching the goal? - // That way unrelated tips wouldn't cause us to keep traversing. - if self.goal.is_empty() && next.iter().any(|(_, _, _, limit)| !limit.goal.is_empty()) { - return false; - } - if self.inner.is_some_and(|l| l == 0) { - return true; - } - self.inner = self.inner.map(|l| l - 1); - false - } - - /// It's important to try to split the limit evenly so we don't create too - /// much extra gas here. We do, however, make sure that we see each segment of a parent - /// with one commit so we know exactly where it stops. - /// The problem with this is that we never get back the split limit when segments re-unite, - /// so effectively we loose gas here. - fn per_parent(&self, num_parents: usize) -> Self { - Limit { - inner: self - .inner - .map(|l| if l == 0 { 0 } else { (l / num_parents).max(1) }), - goal: self.goal, - } - } - - fn is_unset(&self) -> bool { - self.inner.is_none() - } -} - -impl Limit { - /// Keep queueing without limit until `goal` is seen during [propagate_flags_downward()]. - /// `goals` are used to keep track of existing bitflags. - /// No goal will be set if we can't track more goals, effectively causing traversal to stop earlier, - /// leaving potential isles in the graph. - pub fn with_goal(mut self, goal: gix::ObjectId, goals: &mut Goals) -> Self { - self.goal = goals.flag_for(goal).unwrap_or_default(); - self - } - - /// Return `None` if this limit has no goal set, otherwise return `true` if `flags` contains it. - /// This is useful to determine if a commit that is ahead was seen during traversal. - #[inline] - fn goal_in(&self, flags: CommitFlags) -> Option { - if self.goal.is_empty() { - None - } else { - Some(flags.contains(self.goal)) - } - } - - /// Set our limit from `other`, but do not alter our goal. - pub(crate) fn set_but_keep_goal(&mut self, other: Limit) { - self.inner = other.inner; +/// Remove if there are only tips with integrated commits and delete empty segments of pruned tips, +/// as these are uninteresting. +/// However, do so only if our entrypoint isn't integrated itself and is not in a workspace. The reason for this is that we +/// always also traverse workspaces and their targets, even if the traversal starts outside a workspace. +pub fn prune_integrated_tips(graph: &mut Graph, next: &mut Queue) { + let all_integated_and_done = next.iter().all(|(_id, flags, _instruction, tip_limit)| { + flags.contains(CommitFlags::Integrated) && tip_limit.goal_reached() + }); + if !all_integated_and_done { + return; } - - fn unset_goal(&mut self) { - self.goal = CommitFlags::empty(); + if graph + .lookup_entrypoint() + .ok() + .and_then(|ep| ep.segment.flags_of_first_commit()) + .is_some_and(|flags| flags.contains(CommitFlags::Integrated)) + { + return; } -} -impl From> for Limit { - fn from(value: Option) -> Self { - Limit { - inner: value, - goal: CommitFlags::empty(), - } - } + next.inner + .retain_mut(|(_id, _flags, instruction, _tip_limit)| { + let sidx = instruction.segment_idx(); + let s = &graph[sidx]; + if s.commits.is_empty() { + graph.inner.remove_node(sidx); + } + false + }); } /// Lifecycle @@ -876,7 +810,135 @@ impl Queue { pub fn iter(&self) -> impl Iterator { self.inner.iter() } - pub fn retain_mut(&mut self, f: impl FnMut(&mut QueueItem) -> bool) { - self.inner.retain_mut(f); +} + +mod limit { + use crate::CommitFlags; + use crate::init::{Goals, Queue}; + + #[derive(Debug, Copy, Clone)] + pub struct Limit { + inner: Option, + /// The commit we want to see to be able to assume normal limits. Until then there is no limit. + /// Each tracked commit is represented by bitflag, one for each goal, allowing commits to know + /// if they can be reached by the tracked commit. + /// The flag is empty if no goal is set. + goal: CommitFlags, + } + + /// Lifecycle and builders + impl Limit { + pub fn new(value: Option) -> Self { + Limit { + inner: value, + goal: CommitFlags::empty(), + } + } + + /// Keep queueing without limit until `goal` is seen in a commit that has **it ahead of itself**. + /// Then stop searching for that goal. + /// `goals` are used to keep track of existing bitflags. + /// `origin` is used to know where the search for `goal` came from. + /// + /// ### Note + /// + /// No goal will be set if we can't track more goals, effectively causing traversal to stop earlier, + /// leaving potential isles in the graph. + /// This can happen if we have to track a lot of remotes, but since these are queued later, they are also + /// secondary and may just work for the typical remote. + pub fn with_indirect_goal(mut self, goal: gix::ObjectId, goals: &mut Goals) -> Self { + self.goal = goals.flag_for(goal).unwrap_or_default(); + self + } + + /// It's important to try to split the limit evenly so we don't create too + /// much extra gas here. We do, however, make sure that we see each segment of a parent + /// with one commit so we know exactly where it stops. + /// The problem with this is that we never get back the split limit when segments re-unite, + /// so effectively we loose gas here. + pub fn per_parent(&self, num_parents: usize) -> Self { + Limit { + inner: self + .inner + .map(|l| if l == 0 { 0 } else { (l / num_parents).max(1) }), + goal: self.goal, + } + } + + /// Assure this limit won't perform any traversal after reaching its goals. + pub fn without_allowance(mut self) -> Self { + self.set_but_keep_goal(Limit::new(Some(0))); + self + } + } + + /// Limit-check + impl Limit { + /// Return `true` if this limit is depleted, or decrement it by one otherwise. + /// + /// `flags` are used to selectively decrement this limit. + /// Thanks to flag-propagation there can be no runaways. + pub fn is_exhausted_or_decrement(&mut self, flags: CommitFlags, next: &Queue) -> bool { + // Keep going if the goal wasn't seen yet, unlimited gas. + match self.goal_reachable(flags) { + Some(false) => return false, + Some(true) => self.set_goal_reached(), + None => {} + } + // Do not let *any* non-goal tip consume gas as long as there is still anything with a goal in the queue + // that need to meet their local branches. + // This is effectively only affecting the entrypoint tips, which isn't setup with a goal. + // TODO(perf): could we remember that we are a tip and look for our specific counterpart by matching the goal? + // That way unrelated tips wouldn't cause us to keep traversing. + if self.goal_unset() && next.iter().any(|(_, _, _, limit)| !limit.goal_reached()) { + return false; + } + if self.inner.is_some_and(|l| l == 0) { + return true; + } + self.inner = self.inner.map(|l| l - 1); + false + } + } + + /// Other access and mutation + impl Limit { + /// Out-of-band way to use commit-flags differently - they never set the earlier flags, so we + /// can use them. + pub fn set_goal_reached(&mut self) { + self.goal.insert(CommitFlags::Integrated); + } + + pub fn goal_reached(&self) -> bool { + self.goal_unset() || self.goal.contains(CommitFlags::Integrated) + } + + fn goal_unset(&self) -> bool { + self.goal.is_empty() + } + /// Return `None` if this limit has no goal set, otherwise return `true` if `flags` contains it, + /// meaning it was reached through the commit the flags belong to. + /// This is useful to determine if a commit that is ahead was seen during traversal. + #[inline] + pub fn goal_reachable(&self, flags: CommitFlags) -> Option { + if self.goal_reached() { + None + } else { + Some(flags.contains(self.goal_flags())) + } + } + + /// Return the goal flags, which may be empty. + pub fn goal_flags(&self) -> CommitFlags { + // Should only be one, at a time + let all_goals = self.goal.bits() & !CommitFlags::all().bits(); + CommitFlags::from_bits_retain(all_goals) + } + + /// Set our limit from `other`, but do not alter our goal. + pub fn set_but_keep_goal(&mut self, other: Limit) { + self.inner = other.inner; + } } } +pub use limit::Limit; diff --git a/crates/but-graph/tests/fixtures/scenarios.sh b/crates/but-graph/tests/fixtures/scenarios.sh index 5e0794fda6..98fcb92a8d 100644 --- a/crates/but-graph/tests/fixtures/scenarios.sh +++ b/crates/but-graph/tests/fixtures/scenarios.sh @@ -407,5 +407,110 @@ EOF setup_remote_tracking soon-origin-main main "move" git checkout gitbutler/workspace ) + + # partition 1: main - start of traversal + # partition 2: workspace - connected to 1 via short route that isn't including the tip of partition 1 + # partition 3: target - connected to 2 via short route and to 1 via longest rout (2 would find 1 first) + git init gitlab-case + (cd gitlab-case + # there is along tail of history under main which we should be able to traverse as well the entrypoint permits. + commit M1 + commit M2 + commit M3 + commit M4 + commit M5 + commit M6 + commit M7 + commit M8 + commit M9 + commit M10 + # short link to the workspace, connects to 'main' + git checkout -b main-to-workspace + commit Ws1 + + git checkout main + commit M2 + + # the long link to the workspace, through 'main' + git checkout -b long-main-to-workspace main + commit Wl1 + commit Wl2 + commit Wl3 + commit Wl4 + + # workspace finds 'main' through short leg. + git checkout -b workspace main-to-workspace + git merge -m "W1-merge" --no-ff long-main-to-workspace + # NOTE: could have multiple lanes, to be done later for realism. + git checkout -b workspace-to-target + commit Ts1 + commit Ts2 + commit Ts3 + git checkout -b long-workspace-to-target workspace + commit Tl1 + commit Tl2 + commit Tl3 + commit Tl4 + commit Tl5 + commit Tl6 + commit Tl7 + git checkout -b soon-remote-main workspace-to-target + git merge -m "target" --no-ff long-workspace-to-target + git checkout workspace + # This creates a workspace commit outside of the workspace, it can't be reached by the target. + create_workspace_commit_once workspace + + setup_remote_tracking soon-remote-main main "move" + ) + + # like above, but triggers a different case where 'main' can't be reached easily. + git init gitlab-case2 + (cd gitlab-case2 + commit M1 + # short link to the workspace, connects to 'main' + git checkout -b main-to-workspace + commit Ws1 + git checkout -b longer-workspace-to-target + commit Tll1 + commit Tll2 + commit Tll3 + commit Tll4 + commit Tll5 + commit Tll6 + + git checkout main + commit M2 + + # the long link to the workspace, through 'main' + git checkout -b long-main-to-workspace main + commit Wl1 + commit Wl2 + commit Wl3 + commit Wl4 + + # workspace finds 'main' through short leg. + git checkout -b workspace main-to-workspace + git merge -m "W1-merge" --no-ff long-main-to-workspace + # NOTE: could have multiple lanes, to be done later for realism. + git checkout -b long-workspace-to-target workspace + commit Tl1 + git merge -m "Tl-merge" --no-ff longer-workspace-to-target + commit Tl2 + commit Tl3 + commit Tl4 + commit Tl5 + commit Tl6 + commit Tl7 + commit Tl8 + commit Tl9 + commit Tl10 + # target is connected through a long leg that takes longer than everything else + git checkout -b soon-remote-main long-workspace-to-target + git checkout workspace + # This creates a workspace commit outside of the workspace, it can't be reached by the target. + create_workspace_commit_once workspace + + setup_remote_tracking soon-remote-main main "move" + ) ) diff --git a/crates/but-graph/tests/graph/init/with_workspace.rs b/crates/but-graph/tests/graph/init/with_workspace.rs index ebde108d28..f8c0c2edf3 100644 --- a/crates/but-graph/tests/graph/init/with_workspace.rs +++ b/crates/but-graph/tests/graph/init/with_workspace.rs @@ -743,6 +743,37 @@ fn disambiguate_by_remote() -> anyhow::Result<()> { 0, "a fully realized graph" ); + + // If 'C' is in the workspace, it's naturally disambiguated. + add_stack_with_segments( + &mut meta, + StackId::from_number_for_testing(0), + "C", + StackState::InWorkspace, + &[], + ); + let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; + insta::assert_snapshot!(graph_tree(&graph), @r#" + β”œβ”€β”€ πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace + β”‚ └── Β·e30f90c (βŒ‚|🏘️|1)❱"GitButler Workspace Commit" + β”‚ └── β–Ί:2:C <> origin/C + β”‚ └── Β·2173153 (βŒ‚|🏘️|11)❱"C" β–Ίambiguous-C + β”‚ └── β–Ί:8:B <> origin/B + β”‚ └── Β·312f819 (βŒ‚|🏘️|111)❱"B" β–Ίambiguous-B + β”‚ └── β–Ί:7:A <> origin/A + β”‚ └── Β·e255adc (βŒ‚|🏘️|1111)❱"A" β–Ίambiguous-A + β”‚ └── β–Ί:1:origin/main + β”‚ └── Β·fafd9d0 (βŒ‚|🏘️|βœ“|1111)❱"init" β–Ίmain + β”œβ”€β”€ β–Ί:3:origin/C + β”‚ └── β†’:2: (C) + β”œβ”€β”€ β–Ί:4:origin/ambiguous-C + β”‚ └── β†’:2: (C) + β”œβ”€β”€ β–Ί:5:origin/B + β”‚ └── 🟣ac24e74❱"remote-of-B" + β”‚ └── β†’:8: (B) + └── β–Ί:6:origin/A + └── β†’:7: (A) + "#); Ok(()) } @@ -786,7 +817,8 @@ fn integrated_tips_stop_early() -> anyhow::Result<()> { β”‚ β”œβ”€β”€ Β·6b1a13b (βŒ‚|🏘️|1)❱"B2" β”‚ └── Β·03ad472 (βŒ‚|🏘️|1)❱"B1" β”‚ └── β–Ί:5:A - β”‚ └── βœ‚οΈΒ·79bbb29 (βŒ‚|🏘️|βœ“|1)❱"8" + β”‚ β”œβ”€β”€ Β·79bbb29 (βŒ‚|🏘️|βœ“|1)❱"8" + β”‚ └── βœ‚οΈΒ·fc98174 (βŒ‚|🏘️|βœ“|1)❱"7" └── β–Ί:1:origin/main β”œβ”€β”€ 🟣d0df794 (βœ“)❱"remote-2" └── 🟣09c6e08 (βœ“)❱"remote-1" @@ -795,7 +827,8 @@ fn integrated_tips_stop_early() -> anyhow::Result<()> { β”œβ”€β”€ β†’:5: (A) └── β–Ί:4:main β”œβ”€β”€ Β·4b3e5a8 (βŒ‚|βœ“)❱"3" - └── βœ‚οΈΒ·34d0715 (βŒ‚|βœ“)❱"2" + β”œβ”€β”€ Β·34d0715 (βŒ‚|βœ“)❱"2" + └── Β·eb5f731 (βŒ‚|βœ“)❱"1" "#); add_stack_with_segments( @@ -805,10 +838,8 @@ fn integrated_tips_stop_early() -> anyhow::Result<()> { StackState::InWorkspace, &["A"], ); - // Now that `A` is part of the workspace, it's not cut off anymore. - // Instead, we get to keep `A` in full, and it aborts only one later as the - // segment definitely isn't in the workspace. - // As we start at a workspace, even a limit of 0 has no effect - we get to see the whole workspace. + // ~~Now that `A` is part of the workspace, it's not cut off anymore.~~ + // This special handling was removed for now, relying on limits and extensions. let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; insta::assert_snapshot!(graph_tree(&graph), @r#" β”œβ”€β”€ πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace @@ -818,11 +849,7 @@ fn integrated_tips_stop_early() -> anyhow::Result<()> { β”‚ └── Β·03ad472 (βŒ‚|🏘️|1)❱"B1" β”‚ └── β–Ί:5:A β”‚ β”œβ”€β”€ Β·79bbb29 (βŒ‚|🏘️|βœ“|1)❱"8" - β”‚ β”œβ”€β”€ Β·fc98174 (βŒ‚|🏘️|βœ“|1)❱"7" - β”‚ β”œβ”€β”€ Β·a381df5 (βŒ‚|🏘️|βœ“|1)❱"6" - β”‚ └── Β·777b552 (βŒ‚|🏘️|βœ“|1)❱"5" - β”‚ └── β–Ί:6:anon: - β”‚ └── βœ‚οΈΒ·ce4a760 (βŒ‚|🏘️|βœ“|1)❱"Merge branch \'A-feat\' into A" + β”‚ └── βœ‚οΈΒ·fc98174 (βŒ‚|🏘️|βœ“|1)❱"7" └── β–Ί:1:origin/main β”œβ”€β”€ 🟣d0df794 (βœ“)❱"remote-2" └── 🟣09c6e08 (βœ“)❱"remote-1" @@ -831,7 +858,8 @@ fn integrated_tips_stop_early() -> anyhow::Result<()> { β”œβ”€β”€ β†’:5: (A) └── β–Ί:4:main β”œβ”€β”€ Β·4b3e5a8 (βŒ‚|βœ“)❱"3" - └── βœ‚οΈΒ·34d0715 (βŒ‚|βœ“)❱"2" + β”œβ”€β”€ Β·34d0715 (βŒ‚|βœ“)❱"2" + └── Β·eb5f731 (βŒ‚|βœ“)❱"1" "#); // The limit is effective for integrated workspaces branches, but the traversal proceeds until @@ -846,8 +874,7 @@ fn integrated_tips_stop_early() -> anyhow::Result<()> { β”‚ └── Β·03ad472 (βŒ‚|🏘️|1)❱"B1" β”‚ └── β–Ί:5:A β”‚ β”œβ”€β”€ Β·79bbb29 (βŒ‚|🏘️|βœ“|1)❱"8" - β”‚ β”œβ”€β”€ Β·fc98174 (βŒ‚|🏘️|βœ“|1)❱"7" - β”‚ └── βœ‚οΈΒ·a381df5 (βŒ‚|🏘️|βœ“|1)❱"6" + β”‚ └── βœ‚οΈΒ·fc98174 (βŒ‚|🏘️|βœ“|1)❱"7" └── β–Ί:1:origin/main β”œβ”€β”€ 🟣d0df794 (βœ“)❱"remote-2" └── 🟣09c6e08 (βœ“)❱"remote-1" @@ -856,13 +883,14 @@ fn integrated_tips_stop_early() -> anyhow::Result<()> { β”œβ”€β”€ β†’:5: (A) └── β–Ί:4:main β”œβ”€β”€ Β·4b3e5a8 (βŒ‚|βœ“)❱"3" - └── βœ‚οΈΒ·34d0715 (βŒ‚|βœ“)❱"2" + β”œβ”€β”€ Β·34d0715 (βŒ‚|βœ“)❱"2" + └── Β·eb5f731 (βŒ‚|βœ“)❱"1" "#); meta.data_mut().branches.clear(); add_workspace(&mut meta); - // When looking from an integrated branch, we get a bit further until we know we can stop as - // the target branch first has to catch up with us. + // When looking from an integrated branch within the workspace, but without limit, + // the limit is respected. let (id, ref_name) = id_at(&repo, "A"); let graph = Graph::from_commit_traversal(id, ref_name, &*meta, standard_options())?.validated()?; @@ -876,15 +904,26 @@ fn integrated_tips_stop_early() -> anyhow::Result<()> { β”‚ β”œβ”€β”€ Β·79bbb29 (βŒ‚|🏘️|βœ“|1)❱"8" β”‚ β”œβ”€β”€ Β·fc98174 (βŒ‚|🏘️|βœ“|1)❱"7" β”‚ β”œβ”€β”€ Β·a381df5 (βŒ‚|🏘️|βœ“|1)❱"6" - β”‚ └── βœ‚οΈΒ·777b552 (βŒ‚|🏘️|βœ“|1)❱"5" + β”‚ └── Β·777b552 (βŒ‚|🏘️|βœ“|1)❱"5" + β”‚ └── β–Ί:6:anon: + β”‚ └── Β·ce4a760 (βŒ‚|🏘️|βœ“|1)❱"Merge branch \'A-feat\' into A" + β”‚ β”œβ”€β”€ β–Ί:8:A-feat + β”‚ β”‚ β”œβ”€β”€ Β·fea59b5 (βŒ‚|🏘️|βœ“|1)❱"A-feat-2" + β”‚ β”‚ └── Β·4deea74 (βŒ‚|🏘️|βœ“|1)❱"A-feat-1" + β”‚ β”‚ └── β–Ί:7:anon: + β”‚ β”‚ └── Β·01d0e1e (βŒ‚|🏘️|βœ“|1)❱"4" + β”‚ β”‚ └── β–Ί:5:main + β”‚ β”‚ β”œβ”€β”€ Β·4b3e5a8 (βŒ‚|🏘️|βœ“|1)❱"3" + β”‚ β”‚ β”œβ”€β”€ Β·34d0715 (βŒ‚|🏘️|βœ“|1)❱"2" + β”‚ β”‚ └── Β·eb5f731 (βŒ‚|🏘️|βœ“|1)❱"1" + β”‚ └── β†’:7: └── β–Ί:2:origin/main β”œβ”€β”€ 🟣d0df794 (βœ“)❱"remote-2" └── 🟣09c6e08 (βœ“)❱"remote-1" └── β–Ί:4:anon: └── 🟣7b9f260 (βœ“)❱"Merge branch \'A\' into soon-origin-main" β”œβ”€β”€ β†’:0: (A) - └── β–Ί:5:main - └── βœ‚οΈΒ·4b3e5a8 (βŒ‚|βœ“)❱"3" + └── β†’:5: (main) "#); Ok(()) } @@ -920,7 +959,8 @@ fn workspace_obeys_limit_when_target_branch_is_missing() -> anyhow::Result<()> { β”‚ β”œβ”€β”€ Β·6b1a13b (βŒ‚|🏘️|1)❱"B2" β”‚ └── Β·03ad472 (βŒ‚|🏘️|1)❱"B1" β”‚ └── β–Ί:5:A - β”‚ └── βœ‚οΈΒ·79bbb29 (βŒ‚|🏘️|βœ“|1)❱"8" + β”‚ β”œβ”€β”€ Β·79bbb29 (βŒ‚|🏘️|βœ“|1)❱"8" + β”‚ └── βœ‚οΈΒ·fc98174 (βŒ‚|🏘️|βœ“|1)❱"7" └── β–Ί:1:origin/main β”œβ”€β”€ 🟣d0df794 (βœ“)❱"remote-2" └── 🟣09c6e08 (βœ“)❱"remote-1" @@ -929,7 +969,8 @@ fn workspace_obeys_limit_when_target_branch_is_missing() -> anyhow::Result<()> { β”œβ”€β”€ β†’:5: (A) └── β–Ί:4:main β”œβ”€β”€ Β·4b3e5a8 (βŒ‚|βœ“)❱"3" - └── βœ‚οΈΒ·34d0715 (βŒ‚|βœ“)❱"2" + β”œβ”€β”€ Β·34d0715 (βŒ‚|βœ“)❱"2" + └── Β·eb5f731 (βŒ‚|βœ“)❱"1" "#); Ok(()) } @@ -974,3 +1015,321 @@ fn on_top_of_target_with_history() -> anyhow::Result<()> { // insta::assert_snapshot!(graph_tree(&graph), @r#""#); Ok(()) } + +#[test] +fn partitions_with_long_and_short_connections_to_each_other() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/gitlab-case")?; + insta::assert_snapshot!(visualize_commit_graph_all(&repo)?, @r" + * 41ed0e4 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + | * 232ed06 (origin/main) target + | |\ + | | * 9e2a79e (long-workspace-to-target) Tl7 + | | * fdeaa43 Tl6 + | | * 30565ee Tl5 + | | * 0c1c23a Tl4 + | | * 56d152c Tl3 + | | * e6e1360 Tl2 + | | * 1a22a39 Tl1 + | |/ + |/| + | * abcfd9a (workspace-to-target) Ts3 + | * bc86eba Ts2 + | * c7ae303 Ts1 + |/ + * 9730cbf (workspace) W1-merge + |\ + | * 77f31a0 (long-main-to-workspace) Wl4 + | * eb17e31 Wl3 + | * fe2046b Wl2 + | * 5532ef5 Wl1 + | * 2438292 (main) M2 + * | dc7ab57 (main-to-workspace) Ws1 + |/ + * c056b75 M10 + * f49c977 M9 + * 7b7ebb2 M8 + * dca4960 M7 + * 11c29b8 M6 + * c32dd03 M5 + * b625665 M4 + * a821094 M3 + * bce0c5e M2 + * 3183e43 M1 + "); + + add_workspace(&mut meta); + let (id, ref_name) = id_at(&repo, "main"); + // Validate that we will perform long searches to connect connectable segments, without interfering + // with other searches that may take even longer. + // Also, without limit, we should be able to see all of 'main' without cut-off + let graph = Graph::from_commit_traversal(id, ref_name.clone(), &*meta, standard_options())? + .validated()?; + insta::assert_snapshot!(graph_tree(&graph), @r#" + β”œβ”€β”€ β–Ίβ–Ίβ–Ί:1:gitbutler/workspace + β”‚ └── Β·41ed0e4 (βŒ‚|🏘️)❱"GitButler Workspace Commit" + β”‚ └── β–Ί:5:workspace + β”‚ └── Β·9730cbf (βŒ‚|🏘️|βœ“)❱"W1-merge" + β”‚ β”œβ”€β”€ β–Ί:7:long-main-to-workspace + β”‚ β”‚ β”œβ”€β”€ Β·77f31a0 (βŒ‚|🏘️|βœ“)❱"Wl4" + β”‚ β”‚ β”œβ”€β”€ Β·eb17e31 (βŒ‚|🏘️|βœ“)❱"Wl3" + β”‚ β”‚ β”œβ”€β”€ Β·fe2046b (βŒ‚|🏘️|βœ“)❱"Wl2" + β”‚ β”‚ └── Β·5532ef5 (βŒ‚|🏘️|βœ“)❱"Wl1" + β”‚ β”‚ └── πŸ‘‰β–Ί:0:main + β”‚ β”‚ └── Β·2438292 (βŒ‚|🏘️|βœ“|1)❱"M2" + β”‚ β”‚ └── β–Ί:8:anon: + β”‚ β”‚ β”œβ”€β”€ Β·c056b75 (βŒ‚|🏘️|βœ“|1)❱"M10" + β”‚ β”‚ β”œβ”€β”€ Β·f49c977 (βŒ‚|🏘️|βœ“|1)❱"M9" + β”‚ β”‚ β”œβ”€β”€ Β·7b7ebb2 (βŒ‚|🏘️|βœ“|1)❱"M8" + β”‚ β”‚ β”œβ”€β”€ Β·dca4960 (βŒ‚|🏘️|βœ“|1)❱"M7" + β”‚ β”‚ β”œβ”€β”€ Β·11c29b8 (βŒ‚|🏘️|βœ“|1)❱"M6" + β”‚ β”‚ β”œβ”€β”€ Β·c32dd03 (βŒ‚|🏘️|βœ“|1)❱"M5" + β”‚ β”‚ β”œβ”€β”€ Β·b625665 (βŒ‚|🏘️|βœ“|1)❱"M4" + β”‚ β”‚ β”œβ”€β”€ Β·a821094 (βŒ‚|🏘️|βœ“|1)❱"M3" + β”‚ β”‚ β”œβ”€β”€ Β·bce0c5e (βŒ‚|🏘️|βœ“|1)❱"M2" + β”‚ β”‚ └── Β·3183e43 (βŒ‚|🏘️|βœ“|1)❱"M1" + β”‚ └── β–Ί:6:main-to-workspace + β”‚ └── Β·dc7ab57 (βŒ‚|🏘️|βœ“)❱"Ws1" + β”‚ └── β†’:8: + └── β–Ί:2:origin/main + └── 🟣232ed06 (βœ“)❱"target" + β”œβ”€β”€ β–Ί:4:long-workspace-to-target + β”‚ β”œβ”€β”€ 🟣9e2a79e (βœ“)❱"Tl7" + β”‚ β”œβ”€β”€ 🟣fdeaa43 (βœ“)❱"Tl6" + β”‚ β”œβ”€β”€ 🟣30565ee (βœ“)❱"Tl5" + β”‚ β”œβ”€β”€ 🟣0c1c23a (βœ“)❱"Tl4" + β”‚ β”œβ”€β”€ 🟣56d152c (βœ“)❱"Tl3" + β”‚ β”œβ”€β”€ 🟣e6e1360 (βœ“)❱"Tl2" + β”‚ └── 🟣1a22a39 (βœ“)❱"Tl1" + β”‚ └── β†’:5: (workspace) + └── β–Ί:3:workspace-to-target + β”œβ”€β”€ 🟣abcfd9a (βœ“)❱"Ts3" + β”œβ”€β”€ 🟣bc86eba (βœ“)❱"Ts2" + └── 🟣c7ae303 (βœ“)❱"Ts1" + └── β†’:5: (workspace) + "#); + + // When setting a limit when traversing 'main', it is respected. + // We still want it to be found and connected though, and it's notable that the limit kicks in + // once everything reconciled. + let graph = + Graph::from_commit_traversal(id, ref_name, &*meta, standard_options().with_limit_hint(1))? + .validated()?; + insta::assert_snapshot!(graph_tree(&graph), @r#" + β”œβ”€β”€ β–Ίβ–Ίβ–Ί:1:gitbutler/workspace + β”‚ └── Β·41ed0e4 (βŒ‚|🏘️)❱"GitButler Workspace Commit" + β”‚ └── β–Ί:5:workspace + β”‚ └── Β·9730cbf (βŒ‚|🏘️|βœ“)❱"W1-merge" + β”‚ β”œβ”€β”€ β–Ί:7:long-main-to-workspace + β”‚ β”‚ β”œβ”€β”€ Β·77f31a0 (βŒ‚|🏘️|βœ“)❱"Wl4" + β”‚ β”‚ β”œβ”€β”€ Β·eb17e31 (βŒ‚|🏘️|βœ“)❱"Wl3" + β”‚ β”‚ β”œβ”€β”€ Β·fe2046b (βŒ‚|🏘️|βœ“)❱"Wl2" + β”‚ β”‚ └── Β·5532ef5 (βŒ‚|🏘️|βœ“)❱"Wl1" + β”‚ β”‚ └── πŸ‘‰β–Ί:0:main + β”‚ β”‚ └── Β·2438292 (βŒ‚|🏘️|βœ“|1)❱"M2" + β”‚ β”‚ └── β–Ί:8:anon: + β”‚ β”‚ β”œβ”€β”€ Β·c056b75 (βŒ‚|🏘️|βœ“|1)❱"M10" + β”‚ β”‚ β”œβ”€β”€ Β·f49c977 (βŒ‚|🏘️|βœ“|1)❱"M9" + β”‚ β”‚ β”œβ”€β”€ Β·7b7ebb2 (βŒ‚|🏘️|βœ“|1)❱"M8" + β”‚ β”‚ β”œβ”€β”€ Β·dca4960 (βŒ‚|🏘️|βœ“|1)❱"M7" + β”‚ β”‚ β”œβ”€β”€ Β·11c29b8 (βŒ‚|🏘️|βœ“|1)❱"M6" + β”‚ β”‚ β”œβ”€β”€ Β·c32dd03 (βŒ‚|🏘️|βœ“|1)❱"M5" + β”‚ β”‚ β”œβ”€β”€ Β·b625665 (βŒ‚|🏘️|βœ“|1)❱"M4" + β”‚ β”‚ β”œβ”€β”€ Β·a821094 (βŒ‚|🏘️|βœ“|1)❱"M3" + β”‚ β”‚ └── βœ‚οΈΒ·bce0c5e (βŒ‚|🏘️|βœ“|1)❱"M2" + β”‚ └── β–Ί:6:main-to-workspace + β”‚ └── Β·dc7ab57 (βŒ‚|🏘️|βœ“)❱"Ws1" + β”‚ └── β†’:8: + └── β–Ί:2:origin/main + └── 🟣232ed06 (βœ“)❱"target" + β”œβ”€β”€ β–Ί:4:long-workspace-to-target + β”‚ β”œβ”€β”€ 🟣9e2a79e (βœ“)❱"Tl7" + β”‚ β”œβ”€β”€ 🟣fdeaa43 (βœ“)❱"Tl6" + β”‚ β”œβ”€β”€ 🟣30565ee (βœ“)❱"Tl5" + β”‚ β”œβ”€β”€ 🟣0c1c23a (βœ“)❱"Tl4" + β”‚ β”œβ”€β”€ 🟣56d152c (βœ“)❱"Tl3" + β”‚ β”œβ”€β”€ 🟣e6e1360 (βœ“)❱"Tl2" + β”‚ └── 🟣1a22a39 (βœ“)❱"Tl1" + β”‚ └── β†’:5: (workspace) + └── β–Ί:3:workspace-to-target + β”œβ”€β”€ 🟣abcfd9a (βœ“)❱"Ts3" + β”œβ”€β”€ 🟣bc86eba (βœ“)❱"Ts2" + └── 🟣c7ae303 (βœ“)❱"Ts1" + └── β†’:5: (workspace) + "#); + + // From the workspace, even without limit, we don't traverse all of 'main' as it's uninteresting. + // However, we wait for the target to be fully reconciled to get the proper workspace configuration. + let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; + insta::assert_snapshot!(graph_tree(&graph), @r#" + β”œβ”€β”€ πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace + β”‚ └── Β·41ed0e4 (βŒ‚|🏘️|1)❱"GitButler Workspace Commit" + β”‚ └── β–Ί:4:workspace + β”‚ └── Β·9730cbf (βŒ‚|🏘️|βœ“|1)❱"W1-merge" + β”‚ β”œβ”€β”€ β–Ί:6:long-main-to-workspace + β”‚ β”‚ β”œβ”€β”€ Β·77f31a0 (βŒ‚|🏘️|βœ“|1)❱"Wl4" + β”‚ β”‚ β”œβ”€β”€ Β·eb17e31 (βŒ‚|🏘️|βœ“|1)❱"Wl3" + β”‚ β”‚ β”œβ”€β”€ Β·fe2046b (βŒ‚|🏘️|βœ“|1)❱"Wl2" + β”‚ β”‚ └── Β·5532ef5 (βŒ‚|🏘️|βœ“|1)❱"Wl1" + β”‚ β”‚ └── β–Ί:7:main + β”‚ β”‚ └── Β·2438292 (βŒ‚|🏘️|βœ“|1)❱"M2" + β”‚ β”‚ └── β–Ί:8:anon: + β”‚ β”‚ β”œβ”€β”€ Β·c056b75 (βŒ‚|🏘️|βœ“|1)❱"M10" + β”‚ β”‚ β”œβ”€β”€ Β·f49c977 (βŒ‚|🏘️|βœ“|1)❱"M9" + β”‚ β”‚ β”œβ”€β”€ Β·7b7ebb2 (βŒ‚|🏘️|βœ“|1)❱"M8" + β”‚ β”‚ β”œβ”€β”€ Β·dca4960 (βŒ‚|🏘️|βœ“|1)❱"M7" + β”‚ β”‚ β”œβ”€β”€ Β·11c29b8 (βŒ‚|🏘️|βœ“|1)❱"M6" + β”‚ β”‚ └── βœ‚οΈΒ·c32dd03 (βŒ‚|🏘️|βœ“|1)❱"M5" + β”‚ └── β–Ί:5:main-to-workspace + β”‚ └── Β·dc7ab57 (βŒ‚|🏘️|βœ“|1)❱"Ws1" + β”‚ └── β†’:8: + └── β–Ί:1:origin/main + └── 🟣232ed06 (βœ“)❱"target" + β”œβ”€β”€ β–Ί:3:long-workspace-to-target + β”‚ β”œβ”€β”€ 🟣9e2a79e (βœ“)❱"Tl7" + β”‚ β”œβ”€β”€ 🟣fdeaa43 (βœ“)❱"Tl6" + β”‚ β”œβ”€β”€ 🟣30565ee (βœ“)❱"Tl5" + β”‚ β”œβ”€β”€ 🟣0c1c23a (βœ“)❱"Tl4" + β”‚ β”œβ”€β”€ 🟣56d152c (βœ“)❱"Tl3" + β”‚ β”œβ”€β”€ 🟣e6e1360 (βœ“)❱"Tl2" + β”‚ └── 🟣1a22a39 (βœ“)❱"Tl1" + β”‚ └── β†’:4: (workspace) + └── β–Ί:2:workspace-to-target + β”œβ”€β”€ 🟣abcfd9a (βœ“)❱"Ts3" + β”œβ”€β”€ 🟣bc86eba (βœ“)❱"Ts2" + └── 🟣c7ae303 (βœ“)❱"Ts1" + └── β†’:4: (workspace) + "#); + Ok(()) +} + +#[test] +fn partitions_with_long_and_short_connections_to_each_other_part_2() -> anyhow::Result<()> { + let (repo, mut meta) = read_only_in_memory_scenario("ws/gitlab-case2")?; + insta::assert_snapshot!(visualize_commit_graph_all(&repo)?, @r" + * f514495 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + | * 024f837 (origin/main, long-workspace-to-target) Tl10 + | * 64a8284 Tl9 + | * b72938c Tl8 + | * 9ccbf6f Tl7 + | * 5fa4905 Tl6 + | * 43074d3 Tl5 + | * 800d4a9 Tl4 + | * 742c068 Tl3 + | * fe06afd Tl2 + | * 3027746 Tl-merge + | |\ + | | * edf041f (longer-workspace-to-target) Tll6 + | | * d9f03f6 Tll5 + | | * 8d1d264 Tll4 + | | * fa7ceae Tll3 + | | * 95bdbf1 Tll2 + | | * 5bac978 Tll1 + | * | f0d2a35 Tl1 + |/ / + * | c9120f1 (workspace) W1-merge + |\ \ + | |/ + |/| + | * b39c7ec (long-main-to-workspace) Wl4 + | * 2983a97 Wl3 + | * 144ea85 Wl2 + | * 5aecfd2 Wl1 + | * bce0c5e (main) M2 + * | 1126587 (main-to-workspace) Ws1 + |/ + * 3183e43 M1 + "); + + add_workspace(&mut meta); + let (id, ref_name) = id_at(&repo, "main"); + // Here the target shouldn't be cut off from finding its workspace + let graph = + Graph::from_commit_traversal(id, ref_name, &*meta, standard_options())?.validated()?; + insta::assert_snapshot!(graph_tree(&graph), @r#" + β”œβ”€β”€ β–Ίβ–Ίβ–Ί:1:gitbutler/workspace + β”‚ └── Β·f514495 (βŒ‚|🏘️)❱"GitButler Workspace Commit" + β”‚ └── β–Ί:3:workspace + β”‚ └── Β·c9120f1 (βŒ‚|🏘️|βœ“)❱"W1-merge" + β”‚ β”œβ”€β”€ β–Ί:5:long-main-to-workspace + β”‚ β”‚ β”œβ”€β”€ Β·b39c7ec (βŒ‚|🏘️|βœ“)❱"Wl4" + β”‚ β”‚ β”œβ”€β”€ Β·2983a97 (βŒ‚|🏘️|βœ“)❱"Wl3" + β”‚ β”‚ β”œβ”€β”€ Β·144ea85 (βŒ‚|🏘️|βœ“)❱"Wl2" + β”‚ β”‚ └── Β·5aecfd2 (βŒ‚|🏘️|βœ“)❱"Wl1" + β”‚ β”‚ └── πŸ‘‰β–Ί:0:main + β”‚ β”‚ └── Β·bce0c5e (βŒ‚|🏘️|βœ“|1)❱"M2" + β”‚ β”‚ └── β–Ί:6:anon: + β”‚ β”‚ └── Β·3183e43 (βŒ‚|🏘️|βœ“|1)❱"M1" + β”‚ └── β–Ί:4:main-to-workspace + β”‚ └── Β·1126587 (βŒ‚|🏘️|βœ“)❱"Ws1" + β”‚ └── β†’:6: + └── β–Ί:2:origin/main + β”œβ”€β”€ 🟣024f837 (βœ“)❱"Tl10" β–Ίlong-workspace-to-target + β”œβ”€β”€ 🟣64a8284 (βœ“)❱"Tl9" + β”œβ”€β”€ 🟣b72938c (βœ“)❱"Tl8" + β”œβ”€β”€ 🟣9ccbf6f (βœ“)❱"Tl7" + β”œβ”€β”€ 🟣5fa4905 (βœ“)❱"Tl6" + β”œβ”€β”€ 🟣43074d3 (βœ“)❱"Tl5" + β”œβ”€β”€ 🟣800d4a9 (βœ“)❱"Tl4" + β”œβ”€β”€ 🟣742c068 (βœ“)❱"Tl3" + └── 🟣fe06afd (βœ“)❱"Tl2" + └── β–Ί:7:anon: + └── 🟣3027746 (βœ“)❱"Tl-merge" + β”œβ”€β”€ β–Ί:9:longer-workspace-to-target + β”‚ β”œβ”€β”€ 🟣edf041f (βœ“)❱"Tll6" + β”‚ β”œβ”€β”€ 🟣d9f03f6 (βœ“)❱"Tll5" + β”‚ β”œβ”€β”€ 🟣8d1d264 (βœ“)❱"Tll4" + β”‚ β”œβ”€β”€ 🟣fa7ceae (βœ“)❱"Tll3" + β”‚ β”œβ”€β”€ 🟣95bdbf1 (βœ“)❱"Tll2" + β”‚ └── 🟣5bac978 (βœ“)❱"Tll1" + β”‚ └── β†’:4: (main-to-workspace) + └── β–Ί:8:anon: + └── 🟣f0d2a35 (βœ“)❱"Tl1" + └── β†’:3: (workspace) + "#); + + // Now the target looks for the entrypoint, which is the workspace, something it can do more easily. + // We wait for targets to fully reconcile as well. + let graph = Graph::from_head(&repo, &*meta, standard_options())?.validated()?; + insta::assert_snapshot!(graph_tree(&graph), @r#" + β”œβ”€β”€ πŸ‘‰β–Ίβ–Ίβ–Ί:0:gitbutler/workspace + β”‚ └── Β·f514495 (βŒ‚|🏘️|1)❱"GitButler Workspace Commit" + β”‚ └── β–Ί:2:workspace + β”‚ └── Β·c9120f1 (βŒ‚|🏘️|βœ“|1)❱"W1-merge" + β”‚ β”œβ”€β”€ β–Ί:4:long-main-to-workspace + β”‚ β”‚ β”œβ”€β”€ Β·b39c7ec (βŒ‚|🏘️|βœ“|1)❱"Wl4" + β”‚ β”‚ β”œβ”€β”€ Β·2983a97 (βŒ‚|🏘️|βœ“|1)❱"Wl3" + β”‚ β”‚ β”œβ”€β”€ Β·144ea85 (βŒ‚|🏘️|βœ“|1)❱"Wl2" + β”‚ β”‚ └── Β·5aecfd2 (βŒ‚|🏘️|βœ“|1)❱"Wl1" + β”‚ β”‚ └── β–Ί:5:main + β”‚ β”‚ └── Β·bce0c5e (βŒ‚|🏘️|βœ“|1)❱"M2" + β”‚ β”‚ └── β–Ί:6:anon: + β”‚ β”‚ └── Β·3183e43 (βŒ‚|🏘️|βœ“|1)❱"M1" + β”‚ └── β–Ί:3:main-to-workspace + β”‚ └── Β·1126587 (βŒ‚|🏘️|βœ“|1)❱"Ws1" + β”‚ └── β†’:6: + └── β–Ί:1:origin/main + β”œβ”€β”€ 🟣024f837 (βœ“)❱"Tl10" β–Ίlong-workspace-to-target + β”œβ”€β”€ 🟣64a8284 (βœ“)❱"Tl9" + β”œβ”€β”€ 🟣b72938c (βœ“)❱"Tl8" + β”œβ”€β”€ 🟣9ccbf6f (βœ“)❱"Tl7" + β”œβ”€β”€ 🟣5fa4905 (βœ“)❱"Tl6" + β”œβ”€β”€ 🟣43074d3 (βœ“)❱"Tl5" + β”œβ”€β”€ 🟣800d4a9 (βœ“)❱"Tl4" + β”œβ”€β”€ 🟣742c068 (βœ“)❱"Tl3" + └── 🟣fe06afd (βœ“)❱"Tl2" + └── β–Ί:7:anon: + └── 🟣3027746 (βœ“)❱"Tl-merge" + β”œβ”€β”€ β–Ί:9:longer-workspace-to-target + β”‚ β”œβ”€β”€ 🟣edf041f (βœ“)❱"Tll6" + β”‚ β”œβ”€β”€ 🟣d9f03f6 (βœ“)❱"Tll5" + β”‚ β”œβ”€β”€ 🟣8d1d264 (βœ“)❱"Tll4" + β”‚ β”œβ”€β”€ 🟣fa7ceae (βœ“)❱"Tll3" + β”‚ β”œβ”€β”€ 🟣95bdbf1 (βœ“)❱"Tll2" + β”‚ └── 🟣5bac978 (βœ“)❱"Tll1" + β”‚ └── β†’:3: (main-to-workspace) + └── β–Ί:8:anon: + └── 🟣f0d2a35 (βœ“)❱"Tl1" + └── β†’:2: (workspace) + "#); + Ok(()) +}